【OpenHarmony/HarmonyOS】游戏成长系统实战:晶石收集、商城升级、数值结算与持久化

一个完整游戏不仅有“当局战斗”,还需要把当局奖励转化为长期目标。本文讲解迷宫坦克项目如何用晶石、升级商城和 Preferences 建立最小可用的局外成长循环。💎

一、先画出成长闭环

项目中的循环可以概括为:

flowchart LR
    A[进入关卡] --> B[收集晶石]
    B --> C[胜利或失败结算]
    C --> D[写入 UpgradeManager]
    D --> E[商城购买升级]
    E --> F[下一局应用倍率]
    F --> A

当局中的 coinsCollected 是临时数据,只有在结算节点才转入持久货币。这样重开、失败惩罚和模式规则都可以明确控制。

二、晶石实体的数据和动画

晶石只保存位置、半径、价值和收集状态:

export class Crystal {
  position: Vector2;
  radius: number;
  value: number;
  isCollected: boolean = false;
  public floatOffset: number = 0;

  update(deltaTime: number) {
    this.floatTime += deltaTime;
    this.floatOffset = Math.sin(this.floatTime * 5) * 3;
  }
}

正弦函数让晶石上下浮动。绘制时用四条线构成菱形,并配合描边和发光。大量晶石由引擎批量加入同一 Canvas Path,而不是每个对象独立 save/restore

三、刷新密度随模式变化

基础数量与波次相关,再按难度和模式放大:

let count = 5 + Math.floor(this.currentWave * 2);

if (this.difficulty === 'nightmare') {
  count = Math.floor(count * 2.0);
} else if (this.difficulty === 'normal') {
  count = Math.floor(count * 1.5);
}

if (this.gameMode === 'puzzle') {
  count = Math.floor(count * 6.0);
} else if (this.gameMode === 'time_attack') {
  count = Math.floor(count * 10.0);
}

解谜和限时模式更强调路线收集,因此密度更高;极限难度风险更高,也给更多奖励机会。奖励与风险共同变化,比只增加敌人更有驱动力。

四、收集检测与当局统计

玩家和晶石使用圆形距离判断:

const dx = player.position.x - crystal.position.x;
const dy = player.position.y - crystal.position.y;
const distance = Math.sqrt(dx * dx + dy * dy);

if (distance < player.radius + crystal.radius) {
  crystal.collect();
  this.gameStats.coinsCollected += crystal.value;
  AudioManager.getInstance().playSound('coin');
  this.swapRemoveCrystal(index);
}

更高频的实现可比较平方距离,省去 sqrt。收集后立即从活动数组交换删除,避免后续继续更新和绘制。

音效名称必须与预加载表一致。当前工程播放 coin,而音频管理器加载列表中没有对应资源,这是联调时应修正的契约问题:要么增加 coin.wav,要么映射到已有 powerup 音效。

五、不同模式的结算规则

PvE 或限时模式中玩家失败,也会把已收集晶石加入长期账户:

UpgradeManager.getInstance()
  .addCoins(this.gameStats.coinsCollected);

解谜模式进入错误出口时只保留约四分之一:

this.gameStats.coinsCollected = Math.round(
  this.gameStats.coinsCollected * 0.25
);

正确出口则保留全部,并按晶石数量增加得分。结算规则集中在模式结束位置,避免收集一枚就直接写长期货币,导致退出关卡无法实施惩罚。

六、UpgradeManager 的持久状态

export interface UpgradeState {
  coins: number;
  speedLevel: number;
  fireRateLevel: number;
  shieldLevel: number;
}

状态作为一个 JSON 整体保存:

await this.pref.put(
  'upgrade_state',
  JSON.stringify(this.state)
);
await this.pref.flush();

整体保存简单且保持字段一致,但多个异步修改仍应串行化,避免读改写竞争。正式游戏还应加入 schemaVersion 和非法数值校验,防止负金币或等级超过上限。

七、购买升级要由数据层校验

商城页面可以显示价格,但最终扣费不能只相信 UI 传入值。Manager 重新计算实际价格:

public async purchaseUpgrade(
  type: 'speed' | 'fireRate' | 'shield',
  ignoredCost: number
): Promise<boolean> {
  if (!this.canAffordUpgrade(type)) return false;

  const level = this.getUpgradeLevel(type);
  const realCost = this.getUpgradeCost(level);
  this.state.coins -= realCost;

  if (type === 'speed') this.state.speedLevel++;
  else if (type === 'fireRate') this.state.fireRateLevel++;
  else this.state.shieldLevel++;

  await this.saveState();
  return true;
}

即使这是本地游戏,也应该让业务规则只有一个可信来源。以后迁移到云端时,同样原则会变成服务端权威校验。

八、价格契约必须统一 ⚠️

项目页面定义:速度基础价 50、射速基础价 50、护盾基础价 100;但 Manager 的 getUpgradeCost() 统一按 50 * (level + 1) 计算。由于 Manager 忽略页面传入价格,护盾实际扣费可能与 UI 展示不一致。

正确做法是把配置集中在数据层:

const UPGRADE_CONFIG = {
  speed: { baseCost: 50, maxLevel: 5, step: 0.05 },
  fireRate: { baseCost: 50, maxLevel: 5, step: 0.05 },
  shield: { baseCost: 100, maxLevel: 5, step: 1 }
};

页面只调用 getUpgradeQuote(type) 获取等级、价格和效果,购买时只传 type。这样展示与扣费不可能分叉。

九、把升级应用到下一局

玩家坦克创建后异步读取升级状态:

UpgradeManager.getInstance().getUpgradeState().then(state => {
  if (!this.playerTank) return;

  this.playerTank.upgradeSpeedMultiplier =
    1.0 + state.speedLevel * 0.05;
  this.playerTank.upgradeFireRateMultiplier =
    1.0 + state.fireRateLevel * 0.05;

  if (state.shieldLevel >= 1) {
    this.playerTank.hasShield = true;
  }
});

每级 5% 的速度和射速成长比较平滑。异步回调中再次检查 playerTank,防止玩家已经退出或引擎重置。

当前工程定义了 upgradeFireRateMultiplier,但实际开火限制主要由场上子弹数控制,尚未形成明确的冷却时间使用点。因此文章中应描述为“已读取倍率、仍需接入开火冷却”,而不能声称射速升级已完整生效。

十、商城页面的响应式更新

页面进入时读取状态,购买成功后刷新:

refreshData() {
  UpgradeManager.getInstance().getUpgradeState().then(state => {
    this.coins = state.coins;
    this.speedLevel = state.speedLevel;
    this.fireRateLevel = state.fireRateLevel;
    this.shieldLevel = state.shieldLevel;
  });
}

按钮根据余额决定颜色和是否可用,余额不足时显示 Toast。还应增加满级状态:隐藏价格或显示“已满级”,而不是继续给出不可购买按钮。

十一、数值设计不能只看公式

成长系统应建立可计算的经济表:

项目 需要观察的指标
单局产出 平均、P50、P90 晶石数
失败保留 玩家是否愿意冒险继续探索
升级成本 首次升级所需局数
效果强度 每级对胜率和时长的影响
满级周期 核心玩家多久完成成长
模式差异 是否存在刷取效率压倒其他模式

例如限时模式的晶石密度是普通模式的多倍,如果没有独立奖励系数,很可能成为唯一高效刷取模式。技术实现正确不代表经济平衡合理。

十二、防重复结算

结束条件可能连续多帧成立,或者异步计时器重复回调。项目使用 isGameOverSequenceStarted 防止多次加币:

if (!this.isGameOverSequenceStarted) {
  this.isGameOverSequenceStarted = true;
  UpgradeManager.getInstance().addCoins(coinsCollected);
  // 进入结算
}

进一步可为每一局生成 sessionId,已结算 ID 写入短期记录。即使页面重复回调,也能做到幂等。

十三、测试清单 ✅

  • 收集晶石后 HUD 是否立即更新;
  • 退出、失败、正确出口、错误出口分别结算多少;
  • 同一局是否可能重复加币;
  • 页面展示价格是否等于真实扣费;
  • 余额刚好等于价格时能否购买;
  • 满级后是否禁止升级;
  • 应用重启后金币和等级是否恢复;
  • JSON 缺字段或等级越界时如何修复;
  • 速度升级是否在不同屏幕缩放下保持比例;
  • 射速倍率是否真正接入冷却逻辑。

十四、总结 ✨

一个最小但完整的成长系统需要:

  • 当局资源与长期货币分离;
  • 模式结束时统一结算;
  • Manager 作为价格和扣费的权威来源;
  • 状态持久化并支持版本迁移;
  • 下一局读取并应用升级;
  • 防重复结算和并发写入;
  • UI 价格、真实扣费和效果配置来自同一份数据;
  • 用实际产出和完成周期验证数值平衡。

成长循环让一次次独立关卡连接成长期体验,但越是看似简单的“加金币、点升级”,越需要严谨的一致性和结算边界。💎


推荐标签: HarmonyOS OpenHarmony ArkTS 游戏数值 商城系统 Preferences

img

Logo

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

更多推荐