这章解决一个非常实际的问题:你迟早要改数值、改参数、加字段、删字段。
如果你没有配置体系,最终会变成:
- 全靠硬编码:改一次要重新打包。
- 全靠散落的 ScriptableObject:字段一变就一堆数据坏掉。
- 全靠 JSON:默认值、迁移、回滚全靠手工,越改越乱。
我们要的是一套“能活很久”的方案:
- 默认配置:放在工程里,版本可控。
- 本地覆盖:放在
persistentDataPath,可热更新(至少不必重打包)。 - 版本迁移:字段变化时能自动升级。
- 回滚:升级失败时能恢复到旧版本。
本章交付物
以“技能系统配置”为例,交付:
SkillConfig.asset(默认配置,随包发布)skill-config.json(本地覆盖,运行时加载)ConfigLoader(加载、迁移、回滚)
约束(Unity 老版本)
- 使用
JsonUtility(不依赖 Newtonsoft)。 - 配置结构保持“可序列化、可向前兼容”。
- 不做“任意字典”作为核心结构(JsonUtility 不支持 Dictionary)。
配置结构设计:先定好版本字段
最关键的是加一个 schemaVersion。
using System;
using UnityEngine;
[Serializable]
public class SkillEntry
{
public string id;
public int cooldownMs;
public int damage;
}
[Serializable]
public class SkillConfigData
{
public int schemaVersion;
public SkillEntry[] skills;
}
SkillConfigData 是“运行时数据结构”,它要稳定、容易迁移。
默认配置:ScriptableObject 只负责承载 TextAsset
Unity 的 ScriptableObject 很适合做“默认值容器”,但不建议把它当作长期可迁移的数据结构(字段变化时资源容易乱)。
更稳的方法:SO 里只放一份 JSON 文本作为默认配置。
using UnityEngine;
public sealed class SkillConfigAsset : ScriptableObject
{
public TextAsset defaultJson;
}
你在 Editor 里创建一个 SkillConfigAsset,把默认 JSON 拖进去即可。
默认 JSON 示例:
{
"schemaVersion": 1,
"skills": [
{"id": "fireball", "cooldownMs": 1200, "damage": 25},
{"id": "dash", "cooldownMs": 800, "damage": 0}
]
}
本地覆盖:persistentDataPath
本地覆盖文件路径固定:
using System.IO;
using UnityEngine;
public static class ConfigPaths
{
public static string SkillOverridePath
{
get { return Path.Combine(Application.persistentDataPath, "skill-config.json"); }
}
}
加载器:默认 → 覆盖 → 校验 → 迁移 → 回滚
加载器的逻辑必须“可预测”。你要明确每一步发生了什么。
using System;
using System.IO;
using UnityEngine;
public static class SkillConfigLoader
{
public static SkillConfigData Load(SkillConfigAsset asset)
{
Assert.NotNull(asset, "config", "SkillConfigAsset is null");
Assert.NotNull(asset.defaultJson, "config", "defaultJson is null");
var defaultText = asset.defaultJson.text;
var defaultData = JsonUtility.FromJson<SkillConfigData>(defaultText);
Validate(defaultData, "default");
var path = ConfigPaths.SkillOverridePath;
if (!File.Exists(path))
{
return defaultData;
}
var overrideText = File.ReadAllText(path);
var overrideData = JsonUtility.FromJson<SkillConfigData>(overrideText);
if (overrideData == null)
{
Log.Warn("config", "override parse failed, fallback to default");
return defaultData;
}
try
{
var migrated = MigrateIfNeeded(overrideData);
Validate(migrated, "override");
return migrated;
}
catch (Exception ex)
{
Log.Error("config", "override invalid, rollback. ex=" + ex.Message);
BackupBadOverride(path, overrideText);
return defaultData;
}
}
private static void Validate(SkillConfigData data, string source)
{
Assert.NotNull(data, "config", source + " data is null");
Assert.IsTrue(data.schemaVersion >= 1, "config", source + " schemaVersion invalid");
Assert.NotNull(data.skills, "config", source + " skills is null");
for (int i = 0; i < data.skills.Length; i++)
{
var s = data.skills[i];
Assert.NotNull(s, "config", source + " skill entry null at " + i);
Assert.IsTrue(!string.IsNullOrEmpty(s.id), "config", source + " skill id empty");
}
}
private static SkillConfigData MigrateIfNeeded(SkillConfigData data)
{
// 这里演示从 v1 -> v2 的迁移框架
if (data.schemaVersion == 1)
{
// 举例:以后你可能新增字段,比如 "cost"。
// v1 没有 cost,这里可以给默认值。
data.schemaVersion = 2;
}
return data;
}
private static void BackupBadOverride(string path, string text)
{
try
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
var backup = path + ".bad." + DateTime.UtcNow.ToString("yyyyMMddHHmmss");
File.WriteAllText(backup, text);
File.Delete(path);
}
catch (Exception ex)
{
Log.Warn("config", "backup failed: " + ex.Message);
}
}
}
为什么不用 Dictionary
JsonUtility 不支持字典,这会逼你把数据做成数组,然后做一个运行时索引。
建议在加载后构建索引:
using System.Collections.Generic;
public sealed class SkillConfigRuntime
{
private readonly Dictionary<string, SkillEntry> _map = new Dictionary<string, SkillEntry>();
public SkillConfigRuntime(SkillConfigData data)
{
for (int i = 0; i < data.skills.Length; i++)
{
var s = data.skills[i];
_map[s.id] = s;
}
}
public SkillEntry Get(string id)
{
SkillEntry value;
return _map.TryGetValue(id, out value) ? value : null;
}
}
这能保证 JSON 保持简单,而运行时访问保持高效。
本章验收
- 删除本地覆盖文件时,游戏可用默认配置启动。
- 把本地覆盖 JSON 改坏(比如少字段/写错 JSON),会自动回滚。
schemaVersion变化时,迁移逻辑会生效并保持可验证。
下一章预告
下一章做“事件与解耦”:
- 不用 C# event 也能跑
- 控制订阅/退订
- 避免 GC 分配
最终目标是让 UI、玩法、数据层能解耦演进。