第 43 章 本地存档、配置表与热更新:游戏的”记忆”与”可调旋钮”

你大概还记得第 35 章那张”主程能力全景图”。当时我们把你的能力照了回镜子,九大模块里,模块四”状态持续与数据”被标成短板——联机/后端持久化你因为 Go 老本行很强,但”本地存档、配置表、热更新”三项都是零命中。我原话说的是”客户端数据层是短板”。

今天这一章,就是来填这三项里的核心两块(热更新思想也一并讲),对着那张图把缺口补上一截。但我不想一上来就甩 API。还是走我们老办法:先想清楚”现实里这东西对应什么、没有它会出什么岔子、工程师拿出了什么方案、最后落进 Unity 成了哪些旋钮”。你既然写 Go,我会一路用你后端那套认知来桥接——存档像写文件/落库、配置表像配置中心、热更像你服务不停机发版。这样你不是记新东西,是把旧本事挪了个战场。

先抛个问题给你:你玩过一个单机手游,第一天打了两小时,关了手机,第二天打开——进度还在。这个”还在”,到底存在哪?是你这台手机里,还是某个服务器上?还是两者都有?想清楚这个,本章的主线就出来了。


43.1 状态持续的三层:谁是”记忆”的 owner

我们先把”游戏里哪些东西需要被记住、记住在谁那”这件事拆开。这是理解本地存档和配置表的前提,也是连回第 19 章”联机数据在后端”的那条线。

一个游戏运行起来,世界里跑着各种各样的”状态”,但按”该存在哪、谁说了算”,其实可以清楚分成三层:

第一层:联机数据,存在后端(你已会)。 第 19 章讲的登录、排行榜、对局结果,这些是”跨设备、跨玩家、要权威”的数据。你 Go 服务落数据库,玩家换手机、换电脑,数据还在。这层你熟,不用多说。它的关键词是”服务端权威”——第 19.1 节那个”多份状态要一致、防作弊”的麻烦,就出在这层。

第二层:本地存档,存在玩家设备。 比如单机进度、你 last 选的画质、音量是开是关、上次玩到第几关。这些数据”只跟这台设备/这个玩家有关”,不上服务器也行。你关掉游戏,它得留在你硬盘或手机存储里,下次打开还能读回来。这就像你随身带一个记事本,记”我玩到哪了”,放自己兜里,不往公司档案柜(服务器)里塞。

第三层:配置表,存在”策划手里”,运行时被游戏读进来。 这一层很特殊——它不是”玩家的记忆”,而是”游戏的规则数值”。敌人血量多长、建一座塔花多少金币、某关掉宝率百分之几、技能冷却几秒。这些数字不该写死在代码里,而应该放在一张表(Excel/JSON)里,策划改个数字,游戏行为就变,不用程序员改代码重编译。这像你后端系统里的”配置中心”——开关、阈值、灰度比例都放配置服务,改配置即时生效,不用重新发布二进制。

反问:你有没有想过,为什么”玩家金币”和”建塔花费”要分属不同层?金币是某个玩家的私有进度(本地存档 or 后端);建塔花费是游戏规则(配置表)。如果我把”建塔花费 100”也写死在 C# 代码里,策划想改成 120 怎么办?这一问,正好引到 43.3。

连回第 35 章:模块四的”状态持续”缺口,说的就是第二层(本地存档)和第三层(配置表)你还没碰。本章 43.2 填本地存档,43.3 填配置表,43.4 把”热更新”这个让前两样能”不改安装包就变”的能力讲透。三层合起来,才是完整的”数据工程化”。

现在你脑子里该有张图:云端(后端,第 19 章)→ 设备(本地存档,本节)→ 策划桌(配置表,本节)。三层各管各的,别混。第 20.4.3 节我提醒过你”会变的要算的逻辑归实时层,要记住的要排名的归存储层”,今天是把”设备侧”和”规则侧”也补全。


43.2 本地存档方案:三种”记事本”,各有各的脾气

进入正题。本地存档,说白了就是”把游戏运行时的一些变量,写到玩家设备的存储介质上,下次启动再读回来”。你写 Go 时干过一模一样的事——把结构体 json.Marshal 写进文件,或者写进 Redis、落进 MySQL。Unity 这边给的”记事本”有三种主流,脾气完全不同,主程得会挑。

43.2.1 PlayerPrefs:最简陋的”便利贴”

Unity 自带一个叫 PlayerPrefs 的东西。它本质是个键值对(Key-Value,简称 KV)存储——你给一个字符串 key,存一个 int / float / string 值,引擎帮你落到设备的某个固定位置(Windows 在注册表、手机在系统偏好区、Mac 在 plist)。用法极简:

// 存
PlayerPrefs.SetInt("SoundOn", 1);
PlayerPrefs.SetFloat("MasterVolume", 0.8f);
PlayerPrefs.SetString("PlayerName", "老哥");
PlayerPrefs.Save();          // 注意:要显式 Save,否则可能缓在内存里
 
// 读
int soundOn = PlayerPrefs.GetInt("SoundOn", 0);   // 第二个参数是"没这 key 时的默认值"
float vol = PlayerPrefs.GetFloat("MasterVolume", 1.0f);

它解决什么现实问题? 你只想存几个零碎的小设置——音量开没开、画质档位、新手引导看没看过。这些量小、结构简单、读写频繁但无所谓性能。就像你后端服务里那种”feature flag 开关""用户上次选的语言”,放 Redis 一个 KV 就够,不值得建表。

坑在哪? 第一,PlayerPrefs 是 KV,存不了”嵌套结构”——你想存”玩家有 20 个塔、每个塔等级不同、放在地图 (3,5) 和 (7,2)“这种层级数据,KV 根本表达不了,硬塞得自己拼字符串,丑且易错。第二,它明文存在设备固定位置,谁都能改——玩家拿个编辑器把 SoundOn 改成别的还无所谓,但要是你把”金币”也存这,那玩家分分钟改出 99999。第三,不同平台路径不一致、且不能存大结构,塞多了会拖慢启动。

适用场景一句话:只存”零散小设置”,绝不存”玩家进度/核心数值”。第 19.3 节提过 token 别存明文,同理——PlayerPrefs 是便利贴,不是保险柜。

43.2.2 JSON 文件:结构化、可读的”正式笔记”

当你的存档是个有结构的对象(玩家等级、金币、已解锁关卡、背包列表),KV 就不够了。这时候最直觉的方案:把存档对象序列化成 JSON,写进一个文件。Unity 自带 JsonUtility(第 19.4 节讲 HTTP 时提过,它要求类标 [Serializable]、字段名和 JSON 键对应),配合 C# 的文件读写(你 Go 里 os.WriteFile 的兄弟),就成。

先定义”要存什么”的数据类:

[System.Serializable]   // 必须标这个,JsonUtility 才认得它要序列化
public class SaveData {
    public int level;            // 玩到第几关
    public int gold;             // 金币
    public float playTime;       // 累计游戏时长
    public string[] unlockedMaps; // 已解锁的地图 id 列表
}

再写”保存”和”读取”:

using System.IO;
using UnityEngine;
 
public class SaveManager : MonoBehaviour {
 
    // 存档文件路径:Application.persistentDataPath 是 Unity 给的"本机可写目录"
    // 类比你 Go 里的 os.UserCacheDir() / 一个稳定的落盘路径,跨平台都安全
    string SavePath() => Path.Combine(Application.persistentDataPath, "save.json");
 
    public void SaveGame(int level, int gold, float playTime, string[] maps) {
        SaveData data = new SaveData {
            level = level, gold = gold, playTime = playTime, unlockedMaps = maps
        };
        string json = JsonUtility.ToJson(data, true);  // 第二个参数 true = 美化缩进,方便人读
        File.WriteAllText(SavePath(), json);            // 写文件,和你 Go 的 os.WriteFile 一个意思
    }
 
    public SaveData LoadGame() {
        string path = SavePath();
        if (!File.Exists(path)) return null;            // 第一次玩,没存档,返回空让调用方处理
        string json = File.ReadAllText(path);
        return JsonUtility.FromJson<SaveData>(json);    // 反序列化回对象
    }
}

它解决什么? 结构化、层次清晰、人类可读可改(调试时你直接打开 JSON 看看存对没)。你 Go 里 json.Marshal/Unmarshal 写结构体落文件的整套手感,在这几乎平移——连”字段要对得上”的坑都一样(第 19.4 节那段 struct{Username string} 的对照,正是此处)。

坑在哪? 一个字:防篡改。JSON 明文躺在玩家手机里,玩家想改金币,拿个文本编辑器把 "gold":100 改成 "gold":999999 就完事,存档瞬间”变富”。所以本地 JSON 存档只适合”改了也无伤大雅”的单机进度(比如一个纯单机休闲游戏的金币,反正不影响别人);一旦涉及”排行、付费、会影响他人”的数值,本地存档绝不能当真——得服务端权威(见 43.5)。另一个坑:JsonUtility 不支持 Dictionary 直接序列化、不支持某些复杂类型,复杂场景得上 Newtonsoft.Json(第 19.4 节提过,NuGet 引入)。

43.2.3 二进制序列化:省空间、难改的”加密日记”

JSON 可读,但也”肥”(一堆花括号、字段名重复写)且”透明”。有些游戏想要:存档小一点(手机存储/流量省)、玩家别那么容易手改。于是有第三种选择——二进制序列化:把对象直接按内存布局或自定义格式写成字节流,而非文本。Unity 里可以用 BinaryFormatter(注意:新版 .NET 已标记它不安全,生产建议自己用 MemoryStream + BitConverter 或引入 MessagePack/Protobuf 这类二进制协议)。你 Go 里用 proto.Marshal 就是同源思路——Protobuf 就是二进制、小、跨语言。

它解决什么? 体积小(同样一份存档,二进制可能只有 JSON 的 1/3 到 1/5);玩家用记事本打不开,改起来门槛高(虽然真想改还是能反编译,后面说防作弊会泼冷水)。类比你后端:高频日志、快照用 Protobuf 而不是 JSON,省带宽省存储,一个道理。

坑在哪? 不可读、调试难(你没法直接” cat 一下”看存了啥);版本兼容要自己管——你第 35 章模块四提过”存档版本兼容”,二进制格式一升级,老存档读不出来就崩,得带版本号做迁移(类似你数据库做 schema migration)。另外提醒一句:BinaryFormatter 因反序列化漏洞已被 .NET 官方不推荐,真要用二进制请用 Protobuf/MessagePack,别用老办法。

43.2.4 三种方案怎么选:一张对照表

方案像你后端的什么存啥优点致命坑
PlayerPrefsRedis 里一个 KV零散小设置一行就存,零成本不能存结构、明文可改
JSON 文件结构体落文件 / JSON 落库结构化单机进度可读、好调试、手感熟明文易改、体积大
二进制Protobuf / MsgPack大体量、想难改的存档小、改起来难不可读、版本兼容要自己管

核心心法:选哪种不看”哪个高级”,看”这份数据改了会不会出事、结构复不复杂、在不在意体积”。主程的日常就是这种”匹配场景选型”,不是追新。

反问:你 Go 服务里,用户余额是从不放在客户端、永远服务端算的。那游戏里”玩家金币”该放本地存档(JSON)还是放你后端(第 19 章那套)?什么情况下放本地也”没关系”?这问直接通到 43.5 的防作弊。


43.3 配置表:策划的”数值大脑”,别让规则睡在代码里

讲完”玩家的记忆”,讲”游戏的规则”。这一节我特别想跟你共鸣——因为你写后端,配置中心那套你门儿清。

现象:一个塔防游戏,敌人有血量、移速;塔有价格、伤害、射程、攻速;掉落有概率。这些数字,新手最爱干的事是——直接写死在代码里:

// 千万别这样(反面教材)
float enemyHp = 100f;
int towerCost = 100;
float dropRate = 0.15f;

现实问题:策划(或你自己调数值)想改”塔从 100 块降到 80 块试试手感”。如果写死在代码,改一个数就得重新编译、重新出包。手游出一次包走应用商店审核要好几天。策划调数值像挤牙膏,程序员被叫去改一行编译一发,双方都崩溃。这和你后端把”开关阈值”写死在 const 里、改一次重新部署整个服务,一模一样的痛。

工程师的方案:把”会变、归策划管的数值”从代码里抽出来,放进一张”表”——Excel(策划最爱)、或导出成 JSON/CSV。游戏启动时把表读进内存,运行时按 id 查。“建塔花费”不再是代码常量,而是 configTable.GetTower(102).cost。这就是配置表。

落进 Unity 成了什么:一张结构化数据文件(JSON/CSV/Excel 导出的二进制),加一个”加载器”把它读成 C# 里能查的字典。给个”读配置表初始化塔数据”的小例子:

[System.Serializable]
public class TowerConfig {       // 表里一行 = 一个塔的配置
    public int id;
    public string name;
    public int cost;             // 建塔花费
    public float damage;         // 伤害
    public float range;          // 射程
    public float fireRate;       // 攻速(每秒几下)
}
 
[System.Serializable]
public class TowerConfigList {   // JsonUtility 不支持直接读"数组根",套一层
    public TowerConfig[] towers;
}
 
public class ConfigManager {
    // 运行时从 Resources 或 Addressables 加载配置 JSON(Addressables 见 43.4)
    public Dictionary<int, TowerConfig> towerDict = new Dictionary<int, TowerConfig>();
 
    public void LoadTowerConfigs(TextAsset jsonFile) {
        TowerConfigList list = JsonUtility.FromJson<TowerConfigList>(jsonFile.text);
        foreach (var t in list.towers) towerDict[t.id] = t;   // 建成"id -> 配置"的查表
    }
 
    public TowerConfig GetTower(int id) => towerDict.TryGetValue(id, out var c) ? c : null;
}
 
// 游戏里建塔时:
// TowerConfig cfg = configManager.GetTower(102);
// if (playerGold >= cfg.cost) { playerGold -= cfg.cost; SpawnTower(cfg); }

类比你后端:这不就是配置中心吗?你 Nacos / etcd / 自己写的 config service,把”阈值、开关、灰度比例”从代码抽出来,改配置即时生效不用发版。游戏配置表是同一思想的”单机/客户端版”——策划改 Excel 导出的 JSON,游戏读表,数值就变,程序员不用动 C#。你这套认知直接平移,零成本。

坑与讲究:第一,配置表也要”版本”——表里加了个字段,老客户端读不懂怎么办?早期就给配置带版本号,或做向后兼容的默认值。第二,大项目配置表几百张,加载时机要规划(启动时全载还是按需载),别一开机卡半天。第三,数值校验——策划填了个负数血量,游戏里怪一枪把自己打死,这种”脏数据”最好在加载时校验告警。你后端做配置下发时也做 schema 校验,同理。第四,真正大型项目配置表用 Excel 给策划编辑、用工具导成二进制(省体积、加载快),Unity 直接读二进制——这又是 43.2.3 那套二进制思路的用武之地。

反问:你说”配置表和代码分离”是为了策划能调。那如果策划把”建塔花费”调成 0,玩家免费建塔刷分,这算谁的锅?这又绕回”服务端权威”——配置表在单机无所谓,联网时关键数值谁拍板?接 43.5。


43.4 热更新:不让玩家”为了修个 bug 重下 2 个 G”

前面两节解决了”记忆”和”规则”。但有个现实麻烦横在面前:安装包发出去了,发现 bug,或者想加个新活动/新英雄,难道每次都让玩家去应用商店重新下整个包? 手机上一个游戏安装包动辄 1-2 个 G,每次更新都重下,玩家早跑了。这就是”热更新”要解决的问题。

现象:原生 C#(Unity 默认脚本)编译进安装包,是”静态”的——你改一行逻辑,必须重新编译、重新出包、走商店审核、玩家重新下载安装,整条链路又慢又重。你写 Go 服务时,改一行重新 go build 部署就完,但那是你自己的服务器;游戏的逻辑跑在”千千万万玩家手机里”,你动不了他们的安装包。

现实问题:一个线上 bug(比如某塔伤害算错),如果只能靠”发新版整包”,从修好到玩家都更新完,可能一两周,期间玩家骂声一片、排行榜被刷爆。更别提”加个春节活动”这种运营需求,等活动结束包早该下了。

工程师的方案——两条热更路线,分清楚:

路线 A:资源热更新(Resource Hot Update)。 游戏里大量内容是”资源”——贴图、模型、声音、场景、UI 预制体,这些不是代码,是”素材文件”。Unity 有 AssetBundle(老方案)和官方新推的 Addressables(第 39 章资源管理的延伸,你若读了那章会更熟)。它们把资源打成包,放在远程服务器;游戏启动时/运行时去服务器拉”最新资源包”,覆盖本地的。这样”换批贴图、加个新皮肤、改个 UI 布局”不用动安装包,拉个资源包就完。资源热更改的是”看的东西”,不改”算的逻辑”。

路线 B:逻辑热更新(Logic Hot Update)。 这才是”改代码还不发整包”。原生 C# 不行,行业办法是:把”会变的游戏逻辑”用一门能运行时解释/编译的脚本语言写,比如 Lua(通过 xLua / toLua 等方案嵌入 Unity)。Lua 脚本本身是个文本文件,可以像资源一样从服务器拉下来、运行时执行。于是”修复伤害计算公式""加个新英雄技能逻辑”写成 Lua 脚本热更下去,C# 主框架不动。类比你后端:你服务支持”脚本化规则引擎”(比如用 Lua/JS 写活动规则、热加载),改规则不用重发服务——同源思想。

两者区别与代价(主程必须心里有数):

  • 资源热更成本低、风险小、官方支持好(Addressables 是正规军),几乎所有项目都该做。它解决”美术/内容更新”。
  • 逻辑热更成本高——要引入 Lua 虚拟机、要设计”C# 和 Lua 怎么互相调用”的桥、调试难度上来、包体也大一点。它解决”逻辑 bug 修复 / 运营活动”。不是每个项目都需要,但上线项目几乎迟早要。
  • 还有种” Hybrid”思路:核心框架 C# 写死(随包发),上层玩法/数值/配置用 Lua + Addressables 热更。这样框架稳、玩法活。

我这里不深抠具体框架(你真要做再去翻 xLua/Addressables 文档),但要你掌握”思想”:热更新的本质是”把’会变的部分’从’不能变的部分(安装包)‘里拆出来,放到能远程下发的地方”。你后端做”配置中心 + 脚本化规则”,骨子里就是这个思想——能变的别写死,写死的别常变。

连回第 39 章:如果你读过第 39 章”资源管理/Addressables”,资源热更那块你已经有底;没读也不碍事,记住”Addressables = 把资源放远程、按需拉、可覆盖更新”即可。热更新是资源管理的” killer 应用场景”。

反问:逻辑热更(Lua)听起来万能,但”核心战斗结算”如果用 Lua 热更、又被玩家本地改了脚本,会发生什么?这又撞回第 19 章”服务器权威”——哪些逻辑必须留在服务端、绝不能信任客户端热更的脚本?


43.5 防作弊一句话:本地的一切都可改,关键数值必须服务端权威

前面三节埋了三次”防作弊”的伏笔,这里收口,一句话点透,并连回你已经学过的第 19 章。

事实:玩家的设备是你控制不了的。本地存档(无论 PlayerPrefs / JSON / 二进制)躺在玩家手机里,理论上玩家都能改——JSON 直接记事本改,二进制反编译改,PlayerPrefs 有现成修改器。Lua 热更脚本在客户端,玩家也能替换成”我自己写的无敌版”。所以:

结论:所有”影响公平、影响他人、涉及付费/排行”的数值与逻辑,绝不能信客户端本地数据,必须服务端权威——这正是第 19.1 节你已建立的认知(“多份状态要一致、防作弊”)和第 19.8 节坑点 3(“客户端算血量被改内存作弊”)。

具体落到本章:

  • 玩家金币、钻石、排行榜分数 → 存你后端(第 19 章那套 HTTP + 数据库),本地存档只做”离线缓存显示用、以服务端为准”。
  • 建塔花费、掉落率这些”规则” → 单机游戏可以本地配置表说了算;联网游戏,关键规则(比如这把掉不掉橙装)的”裁决”放服务端,客户端配置表只是”显示和预估用”。
  • 伤害结算、击杀判定 → 联网必须服务端算(第 20.6 节”基地扣血”的反面教材你看过)。

本地存档和配置表的”正确位置”是:单机进度的便利贴 + 单机规则的数值源 + 联网时的”客户端缓存/显示”。真正的”账本”在服务端。这和你后端”客户端不可信、所有关键操作服务端重算一遍”是同一个铁律。你第 19.5 节那句”永远别信客户端发来的’我杀了怪给我加 100 分’“——今天补上半句:“也别信客户端自己存的’我有 99999 分’”。

大实话:热更新方便,但也给作弊者开了”改客户端脚本”的口子。所以主程的权衡是——能热更的放客户端(体验好、迭代快),必须公正的留服务端(安全)。这条线画在哪,体现一个主程的安全素养。


43.6 收尾:三层数据,你终于连成一张图

回到开头那张图。今天你补上了第 35 章模块四的两块短板:本地存档(43.2)、配置表(43.3),并理解了热更新(43.4)怎么让它们”不发整包就能变”,最后用防作弊(43.5)给三层划了安全边界。现在”状态持续”三层完整了:

云端(联机数据 / 服务端权威)   ← 第 19 章你已会,Go 后端
设备(本地存档 / 玩家私有进度) ← 本章 43.2
策划桌(配置表 / 游戏规则数值) ← 本章 43.3
    ↑ 热更新让"设备侧内容 + 部分逻辑"可远程变 ← 本章 43.4
    ↑ 安全铁律:公平相关必服务端权威 ← 第 19 章 + 43.5

你作为 Go 程序员,这套”数据分层 + 配置中心思想 + 服务端权威”几乎是你肌肉记忆,所以本章对你不是学新概念,是把后端认知平移到客户端。下一步第 44 章,我们把”界面”这块(模块八的 UI 框架)也补上,并聊主程的工程化日常。

【思考题】

  1. 玩家金币(影响排行和付费)和”上次选的音量”(纯本地设置),分别该用本章哪种方案存?为什么不能都扔进 PlayerPrefs 或都扔进后端?
  2. 配置表为什么要和代码分离、交给策划用 Excel 调?如果你把”建塔花费 100”写死在 C# 常量里,会带来哪两个具体麻烦(从”迭代效率”和”出包/审核”两端说)?
  3. 资源热更(Addressables)和逻辑热更(Lua)分别改的是游戏的什么?一个”修伤害计算公式的 bug”该走哪条路,为什么?
  4. 玩家本地 JSON 存档里写”gold:999999”,但他联网后排行榜分数却没变——这说明什么架构原则在起作用?如果这是个纯单机游戏(无后端),这种”改存档变富”还需要防吗,为什么?

【参考答案】

  1. 玩家金币涉及公平与付费、且联网时要跨设备一致 → 必须存后端(第 19 章 HTTP + 数据库),本地只做离线缓存显示、以服务端为准。音量设置是纯本地、不影响任何人 → 用 PlayerPrefs 或本地 JSON 都行,不必劳烦后端。不能都扔 PlayerPrefs:金币明文可改、且换设备丢失,破坏公平与一致性;不能都扔后端:音量这种零碎设置每次都打网络请求纯属浪费、且单机玩家没登录也存不了。分层存储是”匹配数据性质选型”,不是越重越好。
  2. 两个麻烦:其一”迭代效率”——策划想试手感改个数,若写死在代码,得叫程序员改 C#、重新编译,策划完全被卡住;分离后策划改 Excel 导出 JSON 游戏读表即刻生效。其二”出包/审核”——哪怕程序员改一行重编译,手游也要重新走应用商店审核(几天),一次小数值调整逼全量发版,运营节奏全毁。配置表分离正是后端”配置中心 vs 代码常量”那套痛点的客户端版解法。
  3. 资源热更改”看的东西”(贴图/模型/UI 布局/场景),逻辑热更改”算的东西”(伤害公式/技能逻辑/活动规则)。“修伤害计算公式 bug”属于逻辑 → 走逻辑热更(Lua 等),因为它改的是游戏算法不是素材;若只走资源热更,Lua/C# 里的计算代码没变,bug 还在。反过来”换个英雄皮肤”走资源热更即可,用不着动逻辑。
  4. 起作用的是”服务端权威”(第 19 章 + 43.5):排行榜分数由服务端算和存,客户端本地改的 JSON 只是自欺,上传时被服务端真实数据覆盖/拒绝,所以排行不变。若是纯单机游戏(无后端、无排行、不影响他人),“改存档变富”通常无需防——单机里玩家改自己进度是”用自己的方式玩”,很多单机甚至鼓励修改器;只有涉及”公平、他人、付费”时才必须服务端权威。这正印证本章反复说的”安全边界画在公平相关处”。