Article

Unity WebGL 小游戏实战 03:经济与升级循环(局内成长 + 局外解锁)

路线阶段:Unity WebGL 小游戏实战第 3 章。
本章目标:把“打完一局就结束”升级为“有成长目标的循环玩法”。

学习目标

完成本章后,你应该能做到:

  1. 设计局内临时成长与局外永久成长的双循环。
  2. 搭建货币流入/流出模型并控制经济稳定性。
  3. 实现升级选项池与权重刷新机制。
  4. 将经济与存档、结算、UI联动形成闭环。

经济循环结构

建议采用双层模型:

  1. 局内资源(Run Currency):本局掉落,本局内用于临时升级,局结束清空。
  2. 局外资源(Meta Currency):通关结算发放,用于永久解锁与养成。

这样既有短期刺激,也有长期目标。

核心数据模型

public enum CurrencyType
{
    RunGold = 0,
    MetaCrystal = 1
}

public sealed class EconomyState
{
    public int RunGold;
    public int MetaCrystal;
}
[Serializable]
public sealed class UpgradeConfig
{
    public int UpgradeId;
    public string Name;
    public string Desc;

    public bool IsRunUpgrade;
    public int BaseCost;
    public float CostScale;
    public int MaxLevel;

    public string EffectKey;
    public float EffectValue;
    public int Weight;
}
public sealed class UpgradeRuntime
{
    public readonly UpgradeConfig Config;
    public int CurrentLevel;

    public UpgradeRuntime(UpgradeConfig config)
    {
        Config = config;
        CurrentLevel = 0;
    }

    public int NextCost()
    {
        var c = Config.BaseCost * Mathf.Pow(Config.CostScale, CurrentLevel);
        return Mathf.CeilToInt(c);
    }

    public bool IsMaxed()
    {
        return CurrentLevel >= Config.MaxLevel;
    }
}

经济服务

public sealed class EconomyService
{
    private readonly EconomyState _state;
    private readonly EventBus _eventBus;

    public EconomyService(EconomyState state, EventBus eventBus)
    {
        _state = state;
        _eventBus = eventBus;
    }

    public int Get(CurrencyType type)
    {
        if (type == CurrencyType.RunGold) return _state.RunGold;
        return _state.MetaCrystal;
    }

    public void Add(CurrencyType type, int value, string reason)
    {
        if (value <= 0) return;

        if (type == CurrencyType.RunGold)
        {
            _state.RunGold += value;
        }
        else
        {
            _state.MetaCrystal += value;
        }

        _eventBus.Publish("CurrencyChanged", new CurrencyPayload(type, Get(type), reason));
    }

    public bool Spend(CurrencyType type, int value, string reason)
    {
        if (value <= 0) return true;

        var current = Get(type);
        if (current < value)
        {
            return false;
        }

        if (type == CurrencyType.RunGold)
        {
            _state.RunGold -= value;
        }
        else
        {
            _state.MetaCrystal -= value;
        }

        _eventBus.Publish("CurrencyChanged", new CurrencyPayload(type, Get(type), reason));
        return true;
    }

    public void ResetRunCurrency()
    {
        _state.RunGold = 0;
        _eventBus.Publish("CurrencyChanged", new CurrencyPayload(CurrencyType.RunGold, 0, "run_reset"));
    }
}

public struct CurrencyPayload
{
    public CurrencyType Type;
    public int Value;
    public string Reason;

    public CurrencyPayload(CurrencyType type, int value, string reason)
    {
        Type = type;
        Value = value;
        Reason = reason;
    }
}

掉落与结算

敌人掉落

public sealed class LootRewardSystem
{
    private readonly EconomyService _economy;

    public LootRewardSystem(EconomyService economy)
    {
        _economy = economy;
    }

    public void OnEnemyDead(EnemyRuntime enemy)
    {
        var reward = enemy.IsElite ? 25 : 8;
        _economy.Add(CurrencyType.RunGold, reward, "enemy_kill");
    }
}

关卡结算转换

public sealed class StageRewardCalculator
{
    public int CalcMetaCrystal(StageResult result)
    {
        var baseReward = result.Win ? 20 : 6;
        var killBonus = result.KillCount / 5;
        var timeBonus = result.Win ? Mathf.Max(0, 10 - result.MinuteUsed) : 0;

        return baseReward + killBonus + timeBonus;
    }
}

局内升级选择

升级池选择器

public sealed class UpgradeDraftService
{
    private readonly List<UpgradeRuntime> _runUpgrades;
    private readonly System.Random _rng;

    public UpgradeDraftService(List<UpgradeRuntime> runUpgrades, int seed)
    {
        _runUpgrades = runUpgrades;
        _rng = new System.Random(seed);
    }

    public List<UpgradeRuntime> DrawOptions(int count)
    {
        var candidates = ListPool<UpgradeRuntime>.Get();
        for (var i = 0; i < _runUpgrades.Count; i++)
        {
            if (!_runUpgrades[i].IsMaxed())
            {
                candidates.Add(_runUpgrades[i]);
            }
        }

        var result = new List<UpgradeRuntime>(count);
        while (result.Count < count && candidates.Count > 0)
        {
            var idx = WeightedPick(candidates);
            result.Add(candidates[idx]);
            candidates.RemoveAt(idx);
        }

        ListPool<UpgradeRuntime>.Release(candidates);
        return result;
    }

    private int WeightedPick(List<UpgradeRuntime> list)
    {
        var sum = 0;
        for (var i = 0; i < list.Count; i++) sum += Mathf.Max(1, list[i].Config.Weight);

        var v = _rng.Next(0, sum);
        var acc = 0;
        for (var i = 0; i < list.Count; i++)
        {
            acc += Mathf.Max(1, list[i].Config.Weight);
            if (v < acc) return i;
        }

        return list.Count - 1;
    }
}

升级购买

public sealed class UpgradePurchaseService
{
    private readonly EconomyService _economy;
    private readonly EventBus _eventBus;

    public UpgradePurchaseService(EconomyService economy, EventBus eventBus)
    {
        _economy = economy;
        _eventBus = eventBus;
    }

    public bool TryBuyRunUpgrade(UpgradeRuntime u)
    {
        if (u == null || u.IsMaxed()) return false;

        var cost = u.NextCost();
        if (!_economy.Spend(CurrencyType.RunGold, cost, "run_upgrade_buy"))
        {
            return false;
        }

        u.CurrentLevel++;
        _eventBus.Publish("RunUpgradeBought", u.Config.UpgradeId);
        return true;
    }
}

升级效果应用

建议把效果映射统一放在一个地方:

public sealed class UpgradeEffectApplier
{
    public void Apply(Actor player, UpgradeRuntime u)
    {
        var v = u.Config.EffectValue;

        if (u.Config.EffectKey == "atk_pct") player.AttackBonus += v;
        else if (u.Config.EffectKey == "crit_pct") player.CritRate += v;
        else if (u.Config.EffectKey == "move_pct") player.MoveSpeedMultiplier += v;
        else if (u.Config.EffectKey == "cdr_pct") player.CooldownReduce += v;
    }
}

局外永久升级

使用 MetaCrystal 购买:

  1. 永久攻击成长
  2. 初始技能槽位
  3. 初始金币加成

购买成功后写入存档,下一局生效。

防通胀策略

  1. 升级价格按指数增长(CostScale)。
  2. 同一局内高等级收益递减。
  3. 每局 Meta 奖励设上限,防止速刷爆表。
  4. 定期观察“每局平均收入/支出比”。

UI 流程

  1. HUD 显示 RunGold
  2. 升级弹窗显示 3 选 1 与价格。
  3. 结算页显示 MetaCrystal 收益明细。
  4. 主菜单养成页展示永久升级树。

与前面系统联动

  1. 波次系统:每清一波触发一次升级抽选。
  2. 存档系统:保存 Meta 货币和永久升级等级。
  3. 回放系统:记录每次升级选择,支撑平衡分析。
  4. 流程状态机:升级弹窗期间切到可控暂停态。

WebGL 注意点

  1. 升级弹窗资源应预热,避免打开卡顿。
  2. 价格与收益计算尽量纯 CPU 轻量。
  3. 货币变更事件节流,避免 UI 高频刷新。

验收清单

  1. 击杀敌人可稳定获得局内金币。
  2. 可购买局内升级且效果即时生效。
  3. 结算可转换局外货币并正确入档。
  4. 重开后局内货币清零,局外成长保留。

常见坑

坑 1:局内与局外资源混用

会导致数值体系失控。两类资源必须隔离。

坑 2:升级效果直接修改基础配置资产

会污染全局。应改运行时实例数据。

坑 3:结算奖励直接写死常量

后续调平衡困难。应集中到计算器与配置。

本月作业

实现“每波 3 选 1 升级”:

  1. 每清一波自动弹出升级选择。
  2. 选中后立刻生效并记录到回放。
  3. 关卡结束展示本局升级路径摘要。

下一章进入 Unity WebGL 小游戏实战 04:关卡难度曲线与导演系统(节奏控制与随机性约束)。