Article

Unity WebGL 小游戏实战 05:关卡内容管线(数据驱动关卡配置与热更新准备)

路线阶段:Unity WebGL 小游戏实战第 5 章。
本章目标:把关卡内容从“写死在场景和脚本里”升级为“可配置、可校验、可演进”的管线。

学习目标

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

  1. 把关卡参数、刷怪脚本、奖励规则拆成配置资产。
  2. 构建配置校验器,提前发现脏数据与逻辑冲突。
  3. 设计版本字段和兼容策略,支撑长期内容更新。
  4. 让策划可在不改代码情况下产出新关卡。

背景问题

当关卡数量上升后,常见痛点:

  1. 新关卡需要程序改代码并重打包。
  2. 配置分散在多个 Prefab,难追踪。
  3. 小改动容易破坏旧关卡。
  4. 线上问题无法快速定位到具体配置版本。

关卡配置拆分

建议拆成 4 类配置:

  1. StageMeta:关卡基础信息(ID、名称、目标、时长)。
  2. SpawnTimeline:刷怪波次与导演参数。
  3. RewardProfile:结算奖励与掉落规则。
  4. PresentationProfile:BGM、背景、UI文案、演出钩子。

数据结构示例

[Serializable]
public sealed class StageMetaConfig
{
    public int StageId;
    public int Version;

    public string Name;
    public float TimeLimitSeconds;
    public int TargetWave;

    public string SpawnTimelineKey;
    public string RewardProfileKey;
    public string PresentationProfileKey;
}
[Serializable]
public sealed class SpawnTimelineConfig
{
    public string Key;
    public int Version;

    public List<SpawnSegment> Segments;
}

[Serializable]
public sealed class SpawnSegment
{
    public float StartTime;
    public float Duration;

    public string EnemyPool;
    public float SpawnInterval;
    public float EliteChance;
    public int MaxAlive;
}
[Serializable]
public sealed class RewardProfileConfig
{
    public string Key;
    public int Version;

    public int BaseGold;
    public int BaseCrystal;
    public float TimeBonusScale;
    public float NoDeathBonusScale;
}

配置仓库

public sealed class StageConfigRepository
{
    private readonly Dictionary<int, StageMetaConfig> _stageMeta = new Dictionary<int, StageMetaConfig>(128);
    private readonly Dictionary<string, SpawnTimelineConfig> _spawn = new Dictionary<string, SpawnTimelineConfig>(128);
    private readonly Dictionary<string, RewardProfileConfig> _reward = new Dictionary<string, RewardProfileConfig>(64);

    public void AddStageMeta(StageMetaConfig cfg)
    {
        _stageMeta[cfg.StageId] = cfg;
    }

    public void AddSpawn(SpawnTimelineConfig cfg)
    {
        _spawn[cfg.Key] = cfg;
    }

    public void AddReward(RewardProfileConfig cfg)
    {
        _reward[cfg.Key] = cfg;
    }

    public StageMetaConfig GetStageMeta(int stageId)
    {
        StageMetaConfig cfg;
        return _stageMeta.TryGetValue(stageId, out cfg) ? cfg : null;
    }

    public SpawnTimelineConfig GetSpawn(string key)
    {
        SpawnTimelineConfig cfg;
        return _spawn.TryGetValue(key, out cfg) ? cfg : null;
    }

    public RewardProfileConfig GetReward(string key)
    {
        RewardProfileConfig cfg;
        return _reward.TryGetValue(key, out cfg) ? cfg : null;
    }
}

关卡装配器

public sealed class StageAssembler
{
    private readonly StageConfigRepository _repo;

    public StageAssembler(StageConfigRepository repo)
    {
        _repo = repo;
    }

    public StageAssemblyResult Build(int stageId)
    {
        var meta = _repo.GetStageMeta(stageId);
        if (meta == null)
        {
            throw new InvalidOperationException("Stage meta missing id=" + stageId);
        }

        var spawn = _repo.GetSpawn(meta.SpawnTimelineKey);
        if (spawn == null)
        {
            throw new InvalidOperationException("Spawn timeline missing key=" + meta.SpawnTimelineKey);
        }

        var reward = _repo.GetReward(meta.RewardProfileKey);
        if (reward == null)
        {
            throw new InvalidOperationException("Reward profile missing key=" + meta.RewardProfileKey);
        }

        return new StageAssemblyResult(meta, spawn, reward);
    }
}

public sealed class StageAssemblyResult
{
    public readonly StageMetaConfig Meta;
    public readonly SpawnTimelineConfig Spawn;
    public readonly RewardProfileConfig Reward;

    public StageAssemblyResult(StageMetaConfig meta, SpawnTimelineConfig spawn, RewardProfileConfig reward)
    {
        Meta = meta;
        Spawn = spawn;
        Reward = reward;
    }
}

配置校验器

校验目标

  1. ID/Key 是否重复。
  2. 引用链是否完整(Meta -> Spawn/Reward)。
  3. 数值是否越界(负时间、间隔为0、概率 >1)。
  4. 时间段是否重叠冲突。

校验实现

public sealed class StageConfigValidator
{
    public List<string> Validate(StageConfigRepository repo, IEnumerable<int> stageIds)
    {
        var errors = new List<string>(64);

        foreach (var id in stageIds)
        {
            var meta = repo.GetStageMeta(id);
            if (meta == null)
            {
                errors.Add("StageMeta missing id=" + id);
                continue;
            }

            if (meta.TimeLimitSeconds <= 0f)
            {
                errors.Add("StageMeta invalid timeLimit id=" + id);
            }

            var spawn = repo.GetSpawn(meta.SpawnTimelineKey);
            if (spawn == null)
            {
                errors.Add("SpawnTimeline missing key=" + meta.SpawnTimelineKey + " stage=" + id);
            }
            else
            {
                ValidateSpawn(spawn, errors, id);
            }

            var reward = repo.GetReward(meta.RewardProfileKey);
            if (reward == null)
            {
                errors.Add("RewardProfile missing key=" + meta.RewardProfileKey + " stage=" + id);
            }
        }

        return errors;
    }

    private static void ValidateSpawn(SpawnTimelineConfig spawn, List<string> errors, int stageId)
    {
        for (var i = 0; i < spawn.Segments.Count; i++)
        {
            var s = spawn.Segments[i];

            if (s.Duration <= 0f)
                errors.Add("SpawnSegment duration invalid stage=" + stageId + " index=" + i);

            if (s.SpawnInterval <= 0f)
                errors.Add("SpawnSegment interval invalid stage=" + stageId + " index=" + i);

            if (s.EliteChance < 0f || s.EliteChance > 1f)
                errors.Add("SpawnSegment elite chance invalid stage=" + stageId + " index=" + i);

            if (s.MaxAlive <= 0)
                errors.Add("SpawnSegment maxAlive invalid stage=" + stageId + " index=" + i);
        }
    }
}

版本与兼容策略

每份配置都携带 Version,加载时:

  1. 低版本配置先迁移。
  2. 迁移失败时降级到默认关卡模板。
  3. 记录配置版本到运行日志与回放头。
FoundationLog.Info("StageConfig",
    "stage=" + meta.StageId +
    " metaVer=" + meta.Version +
    " spawnVer=" + spawn.Version +
    " rewardVer=" + reward.Version);

热更新准备(不改核心逻辑)

即便暂不接远程拉取,也应预留:

  1. IStageConfigProvider 接口。
  2. 本地 provider 与远程 provider 可替换。
  3. 配置加载失败可回退上一版缓存。
public interface IStageConfigProvider
{
    IEnumerator LoadAllAsync(Action<StageConfigRepository> onDone, Action<string> onError);
}

与前面系统联动

  1. 导演系统:读取 SpawnTimeline 动态调节。
  2. 经济系统:读取 RewardProfile 结算奖励。
  3. 流程状态机:Loading 阶段先校验配置再进战斗。
  4. 存档系统:记录当前关卡配置版本用于追踪。

WebGL 注意点

  1. 配置文件体积要小,建议按章节拆分。
  2. 解析阶段避免大量字符串分配。
  3. 加载失败必须给出可恢复 UI(重试/回退)。

验收清单

  1. 新增一个关卡只改配置,不改代码。
  2. 配置错误可在加载阶段被明确拦截。
  3. 关卡运行日志可定位配置版本与 key。
  4. 回放可还原对应配置版本下的行为。

常见坑

坑 1:把配置对象当运行时对象直接修改

会污染后续关卡。运行时应复制或映射到独立状态。

坑 2:校验器只做非空检查

数值边界和时间重叠才是线上高频问题。

坑 3:热更新路径与本地路径两套代码

维护成本翻倍。应统一 provider 接口。

本月作业

落地“配置驱动新关卡”流程:

  1. 新增 Stage 02 配置(不同刷怪与奖励)。
  2. 通过校验器检查并修复问题。
  3. 打包后验证 Stage 01/02 都可正常运行。

下一章进入 Unity WebGL 小游戏实战 06:广告与商业化接入(激励视频、插屏与收益归因)。