Blog·Studio
文章系列日历归档关于搜索
Blog·Studio

一个记录思考、笔记与作品的技术博客。

Connect

© 2026 · Blog Studio

鄂ICP备19019526号

crafted with care

stay curious ✦

  1. 文章
  2. ›客户端养成系统 2026:属性派生、脏标记与掉落经济

Index

  • 一、属性派生:把一张平面表变成一个有向无环图
  • 1.1 从"改一个数看全表"说起
  • 1.2 依赖图、环检测与拓扑序
  • 1.3 定点数:为什么 float 会在养成系统里咬人
  • 二、背包与装备:数据模型、序列化与内存
  • 2.1 模板与实例分离
  • 2.2 堆叠语义与合并边界
  • 2.3 背包容量与溢出:一个必须提前决策的问题
  • 2.4 存档兼容:存档格式是产品的一部分
  • 三、随机与保底:客户端侧的可复现性工程
  • 3.1 为什么不能直接用系统随机
  • 3.2 权重表与抽取算法
  • 3.3 保底计数器:一个状态机而不是一个整数
  • 3.4 随机日志:让"玩家说有问题"变成可复现的问题
  • 四、经济数值:产出、回收与客户端模拟器
  • 4.1 产出与回收的工程表达
  • 4.2 客户端经济模拟器:把数值验证做在工具链里
  • 4.3 货币精度与展示层
  • 4.4 通胀预警:客户端埋点的一个具体切口
  • 五、配置驱动的校验与回归:养成系统的 CI
  • 5.1 配表不是数据,是代码
  • 5.2 黄金样本与蒙特卡洛:两种互补的测试
  • 5.3 配置变更的影响分析
  • 六、几个具体的工程反模式
  • 七、结语:养成系统的工程化收益在哪里
  • 参考文献

客户端养成系统 2026:属性派生、脏标记与掉落经济

养成系统是游戏里最容易被低估、也最容易在版本迭代中腐化的一块功能:等级、装备、词条、资源、保底进度,每一项都要在客户端被正确计算、展示、持久化。本文讲清楚一个养成系统是怎么在客户端被工程化做出来的——属性派生模型的分层求值与依赖图、背包模板与实例分离及存档版本迁移、PCG 可复现随机与保底状态机、客户端离线经济模拟器,以及把配表变更拦在 CI 里的校验与回归。全部工作在客户端完成,不涉及服务端架构,也不涉及 AI 与渲染管线。

2026年10月29日·约 42 分钟阅读·12,509 字·3 次阅读·博主
#游戏开发
客户端养成系统 2026:属性派生、脏标记与掉落经济

Index

  • 一、属性派生:把一张平面表变成一个有向无环图
  • 1.1 从"改一个数看全表"说起
  • 1.2 依赖图、环检测与拓扑序
  • 1.3 定点数:为什么 float 会在养成系统里咬人
  • 二、背包与装备:数据模型、序列化与内存
  • 2.1 模板与实例分离
  • 2.2 堆叠语义与合并边界
  • 2.3 背包容量与溢出:一个必须提前决策的问题
  • 2.4 存档兼容:存档格式是产品的一部分
  • 三、随机与保底:客户端侧的可复现性工程
  • 3.1 为什么不能直接用系统随机
  • 3.2 权重表与抽取算法
  • 3.3 保底计数器:一个状态机而不是一个整数
  • 3.4 随机日志:让"玩家说有问题"变成可复现的问题
  • 四、经济数值:产出、回收与客户端模拟器
  • 4.1 产出与回收的工程表达
  • 4.2 客户端经济模拟器:把数值验证做在工具链里
  • 4.3 货币精度与展示层
  • 4.4 通胀预警:客户端埋点的一个具体切口
  • 五、配置驱动的校验与回归:养成系统的 CI
  • 5.1 配表不是数据,是代码
  • 5.2 黄金样本与蒙特卡洛:两种互补的测试
  • 5.3 配置变更的影响分析
  • 六、几个具体的工程反模式
  • 七、结语:养成系统的工程化收益在哪里
  • 参考文献

客户端养成系统 2026:属性派生、脏标记与掉落经济

养成系统是游戏里最容易被低估、也最容易在版本迭代中腐化的一块功能。它没有渲染管线那么性感,也不像网络同步那样有明确的架构范式,但它承载了玩家几乎全部的长期留存:等级、装备、词条、资源、保底进度,每一项都要在客户端被正确地计算、展示、持久化,并且在策划改了配表之后依然保持自洽。

更麻烦的是,养成系统的问题几乎不会在开发期暴露。它们通常在三个时刻才暴露:配表改动之后的第二天、数值验证跑批的深夜、以及玩家把背包塞满的那一刻。本文要讲清楚的,就是"一个养成系统是怎么在客户端被工程化做出来的"——从属性派生模型、背包数据结构、随机与保底、经济数值模拟,一直到配置校验与回归测试的完整装配链条。

这里有一个必须先划清的边界:本文讨论的一切都发生在客户端。服务端如何裁决掉落、如何存储玩家资产,不在讨论范围内;客户端与服务端交互时,我们只关心"本地怎么算、怎么显示、怎么在收到权威结果后自我纠正"。这个限定不是保守,而是因为客户端养成系统的绝大部分复杂度,恰恰来自于它必须在一台不可信、算力有限、随时可能被杀掉的设备上,给出一个与权威结果一致的"看起来对"的答案。

一、属性派生:把一张平面表变成一个有向无环图

1.1 从"改一个数看全表"说起

最初的养成系统通常长这样:策划在 Excel 里维护一张属性表,每行是一个属性名,每列是一个来源。角色面板上的攻击力等于基础攻击力加上所有来源的加值,暴击率等于基础暴击率加上所有来源的加值。这在早期完全够用,直到某天策划提出"百分比属性加成只作用于装备加成,不作用于等级加成"。

这句话的工程含义是:属性不再是一个可以直接求和的标量,而是一个需要分层求值的结构。系统被迫从"平面表"演进为"分层管线"。

主流的分层模型长这样:

层级名称含义典型来源
L0基础值等级、品质带来的固有值等级模板、角色品质
L1固定加值直接相加的绝对值装备基础属性、天赋、称号
L2百分比加成对 (L0+L1) 整体乘算装备百分比词条、套装效果
L3独立加值独立乘区之前的额外绝对值少量特殊玩法产出
L4独立乘区互相独立连乘的乘区增伤/减伤、破防、异常增伤

对应的求值公式是:

Afinal=((Abase+Aflat)×(1+rpct)+Aind)×∏k=1nmkA_{\text{final}} = \left( (A_{\text{base}} + A_{\text{flat}}) \times (1 + r_{\text{pct}}) + A_{\text{ind}} \right) \times \prod_{k=1}^{n} m_kAfinal​=((Abase​+Aflat​)×(1+rpct​)+Aind​)×k=1∏n​mk​

这个公式看起来只有一行,但它有三条隐藏约束,任何一条被忽略都会导致线上数值对不上。

第一条约束是求值顺序必须被写死。 上式中 A_base 与 A_flat 先相加、r_pct 再作用于整体、A_ind 在百分比之后加入、独立乘区最后连乘——每一步的先后顺序都会改变结果。把 L1 挪到 L2 之后乘,效果完全不同。而很多引擎里的求值是遍历一个字典或者一个哈希表完成的,如果遍历顺序依赖哈希桶分布,那么在不同平台、不同运行时版本甚至不同插入顺序下,求值顺序都会漂移。这是"编辑器里看到的数值和真机上不一致"的经典来源。

工程上的解法有两条,实践中通常同时采用:其一,在代码里把求值写成显式的固定阶段函数(EvaluateAttack() 内部按 L0→L4 顺序调用),而不是靠遍历顺序;其二,在配置加载阶段做一次依赖分析,把"属性 A 依赖属性 B"的关系显式建成图。

1.2 依赖图、环检测与拓扑序

一旦属性可以引用属性(例如"暴击伤害"依赖"暴击率","技能倍率"依赖"攻击力"),求值就变成了图上的问题。这里有三个必须处理的工程细节:

建图与环检测。 配置加载时,对每条属性引用建立有向边,用 Kahn 算法做拓扑排序。如果存在环——比如 A 依赖 B、B 依赖 C、C 又依赖 A——这几乎总是配表错误,而不是有意的设计。正确的做法是在配表校验阶段直接报错并阻断构建,而不是让它带着环上线,然后在运行时死循环或者静默返回零值。拓扑排序同时给出了求值顺序:按拓扑序求值,被依赖的属性先算完。

求值粒度与批处理。 属性的最小计算单位是"一个角色的一个属性"。当一次操作(换装、升级、吃药)可能连锁影响几十个属性时,朴素实现会在每个事件回调里立刻全量重算,导致同一帧内多次重复计算。一个更稳的架构是把它做成脏标记 + 单帧批处理:

// 概念伪代码:脏标记 + 批处理重算
sealed class StatEngine
{
    readonly Dictionary<StatId, long[]> _values;   // 定点数存储
    readonly Dictionary<StatId, StatId[]> _deps;     // A -> 依赖的 B
    readonly Dictionary<StatId, StatId[]> _rdeps;    // B -> 依赖 B 的 A(反向边)
    readonly HashSet<StatId> _dirty = new();
    readonly Queue<StatId> _queue = new();

    public void MarkDirty(StatId changed)
    {
        if (!_dirty.Add(changed)) return;
        foreach (var dependent in _rdeps[changed])
            MarkDirty(dependent);      // 迭代式扩散,避免递归爆栈
    }

    public void Flush()                  // 每帧或每逻辑 tick 调一次
    {
        while (_queue.Count > 0)
        {
            var id = _queue.Dequeue();
            if (_dirty.Remove(id))
                _values[id] = EvaluateByLayer(id);  // 按固定阶段函数求值
        }
    }
}

注意这段伪代码里的两个决定。第一是反向边(_rdeps):脏标记扩散的方向是"我变了,谁依赖我",而不是"我依赖谁",这样才能在 O(变更影响面) 而不是 O(全部属性) 的代价内完成标记。第二是迭代而非递归:养成系统里存在深度超过数百的派生链(例如多层装备词条嵌套),递归实现有爆栈风险。

快照与版本号。 UI 层不应该在每次读取属性时都触发重算。正确做法是 StatEngine 在 Flush() 结束后产出一份不可变快照(数组或 struct of arrays),UI 绑定这份快照,并携带一个 version 计数器。UI 收到"属性已变更"事件时先看 version 是否真的变了,再决定是否重绘。这一层看似简单,但它决定了装备面板在快速连续换装时会不会闪烁。

1.3 定点数:为什么 float 会在养成系统里咬人

养成系统的数值几乎必然会被算术运算反复触碰,而浮点数的不可交换性会在长链式运算中放大。这里有一个必须写进规范的事实:

同一套公式,在 float 与 double 下、或者在 x86 与 ARM 的不同优化级别下,最终结果可能相差 1 个最小单位;而养成系统的属性面板会把 1 点攻击力的差异直接显示给玩家。

因此养成系统的核心数值应当使用定点整数存储,精度统一为万分比(scale = 10000)。整个求值链路上不出现任何浮点中间量,只在最终一步(结算与展示)做一次取整。

// 伪代码:定点数求值,中间全程整数
const long SCALE = 10000;

long EvaluateAttack(in StatSnapshot s)
{
    long baseAndFlat = s.baseAttack + s.flatAttack;      // L0 + L1
    long withPct     = baseAndFlat * (SCALE + s.pctAttack) / SCALE;  // L2
    long withInd     = withPct + s.indAttack;              // L3
    return withInd * s.mulZone0 * s.mulZone1 / SCALE;     // L4
}

这里还要定死取整时机。上面的除法每一步都会产生截断,如果链路上有五层,误差就会累积。规范应该明确二选一:要么中间步骤全部保留高精度(用更长的整数类型承载分子分母,在最终一步统一除),要么明确规定每一步都是"向下取整"且两端实现完全一致。经验上,养成系统更愿意接受"每步向下取整 + 双方共享同一份实现",因为它可被完整复现,也更容易被测试覆盖。关键是:这条规则必须写在规范里并且两端一致,而不是靠某个人的记忆。

这里顺便提一个常见误区:很多团队用"策划在 Excel 里用同样的公式算一遍"来做交叉验证。Excel 用的是 IEEE 754 双精度浮点,而客户端用定点整数,两者在最后一位上必然不一致。交叉验证脚本必须显式模拟客户端的定点语义,而不是复用 Excel 的值。

二、背包与装备:数据模型、序列化与内存

2.1 模板与实例分离

背包系统最先要做的决策是模板(Definition)与实例(Instance)分离。模板是静态配置,描述"这是什么东西";实例是运行时对象,描述"这是玩家的哪一个"。

模板侧的典型字段:id、displayName、iconPath、stackLimit(最大堆叠数,0 表示不可堆叠)、maxDurability、equipSlots、baseStats(键值对列表)、rarity、price、sellPrice、boundType。实例侧的典型字段:instanceUid、defId、count、affix(随机词条列表)、durability、boundFlag、acquiredAt、customName。

分离带来的收益是巨大的:配置内存只占一份(几万个模板可能只有几 MB),实例只存差异字段,玩家的背包里可能有一万个物品,但每个实例可能只有几十字节。此外,UI 层可以自然地做两级缓存:模板层是长期驻留的只读数据,实例层是频繁变动的可变数据。

关于 instanceUid,客户端需要生成一个全局唯一且不会因为重装、重登、跨端而冲突的标识。一个实用的方案是"服务端分配的 id + 客户端本地序号"的两段式:凡是有服务端来源的物品用服务端 id,本地生成的(临时道具、测试物品)用客户端序列号加一个本地命名空间前缀。两段式的好处是客户端可以自己判断"这个东西是不是我本地造的",在处理离线掉落、GM 指令、演示模式时不会误判。

2.2 堆叠语义与合并边界

可堆叠物品在客户端的实现远比"count 字段"复杂,因为什么能合并本身是一条配置规则。经典的合并键是 (defId, boundFlag, affixSignature, durabilityTier):同一模板、同样绑定状态、同样词条签名、同样耐久档位的物品才能合并。

实现上有一个常见事故:把每个槽位的物品都做成一个 class 实例,于是一次打开背包就产生上千次托管堆分配,在中低端机上 GC 压力明显。更好的做法是背包槽位用结构体数组(struct Slot { int defId; int count; long uid; ... }),实例的完整数据(词条、耐久等)放在一个以 uid 为键的池里,槽位只存引用与计数。这样背包本身是连续的、可预测的内存布局。

排序与整理功能会放大这个问题。常见需求是"按战力排序""按品质排序""自动分解低价值物品"。这些操作在实现上通常是一次对槽位数组的原地重排(swap 而非 move,避免无谓的引用计数变动),并且需要一个稳定的比较器——相同战力、相同品质时按 instanceUid 兜底排序,否则每次整理后顺序不同,玩家会认为"整理把东西弄乱了"。同理,自动分解必须有一个明确的、可解释的优先级(比如先分解未绑定,再分解耐久最低的),而不是依赖容器遍历顺序。

2.3 背包容量与溢出:一个必须提前决策的问题

"背包满了"是所有养成系统都会遇到的状态,而处理它的策略必须在系统设计之初就定好,因为后期改策略会牵动掉落逻辑、UI 交互、离线收益结算的一整条链。常见策略有几种:

软上限 + 提示。 达到容量上限时不再掉落新物品并给出提示,不做任何自动处理。体验最安全,但会让玩家在关键时刻卡住。

带外溢出。 超出容量的物品进入一个"临时保管区",玩家需要主动取出。常见于有邮件或仓库系统的游戏。

优先级自动保留。 预先声明一个物品优先级规则(限时道具 > 绑定装备 > 稀有装备 > 材料),满了之后自动保留高价值物品、把低价值物品转化为材料或直接丢弃。这条规则必须同时作用于实时掉落和离线收益结算,否则玩家会发现"在线掉的东西留下了,离线攒的全丢了"。

这里有个跨系统的耦合点值得单独说:溢出处理必须与保底计数解耦。很多实现里掉落流程是"先抽 → 再检查背包 → 满了就丢弃或转化",这样一来,一次被丢弃的抽取仍然消耗了保底进度。玩家会体验到"我抽了 80 次保底,第 80 次掉的东西因为背包满没了,第 81 次又从零开始"。而如果抽取逻辑里保底计数只在"物品真正被授予"时才递增,那么在背包满的情况下,保底进度会一直停滞在 79,玩家又会认为"保底坏了"。两种都可以接受,但必须明确选一种并写进规范,且离线结算与实时掉落使用同一套判定。

2.4 存档兼容:存档格式是产品的一部分

养成系统的存档是玩家投入最多的资产,也是最脆弱的数据。一旦背包里存了词条、存了保底计数、存了累计充值,存档格式就再也不能随便改了。

工程上的常规做法:

版本号 + 迁移链。 存档头部写 schemaVersion,读档时按版本走一条纯函数迁移链 v1 → v2 → v3 → ... → vCurrent。每一步迁移都只做一件事,并且必须有针对旧版本真实存档的黄金样本测试——把 v1 的存档二进制固化成测试夹具,迁移后断言字段值。这条纪律的价值在于:迁移链每加一步就多一个可能出错的地方,而旧存档样本一旦丢失就再也无法重建。

字段兼容规则。 新增字段必须可选并有默认值;禁止复用已废弃的枚举数值(只能追加,不能重排);字符串 ID 一旦下发就不能改,因为存档里存的是字符串本身。

未知物品的降级处理。 客户端收到一个本地配置里不存在的 defId(版本回滚、配置热更失败、跨服物品残留)时,绝不能崩溃也不能直接丢弃。正确做法是渲染成一个"未知物品"占位符,向权威方请求一次该物品的描述,或者退化为返还材料。这个分支必须在代码里有显式处理,因为它是"配置版本不一致"最常见的暴露点。

热重载的边界。 养成系统的配置热更(数值调整)相对安全,但结构热更(新增字段、新增乘区)是高危操作。实践中的做法是给配置打上内容版本哈希,客户端启动时上报本地哈希,与权威版本比对,不一致时走"降级到保守配置"而不是"按新配置硬算"。

三、随机与保底:客户端侧的可复现性工程

3.1 为什么不能直接用系统随机

养成系统里的随机不是"抽卡动画需要一个随机数",而是承担经济价值分配的核心机制。因此它对随机数生成器(PRNG)有三条硬要求:

  • 可复现。 玩家反馈"我今天第三次抽到同一个道具,是不是有暗改",工程师必须能拿同样的种子重放出同样的序列,否则无法排查。
  • 流可分离。 新增一次外观随机不应该改变掉落序列。如果所有系统共用一个全局随机源,任何一次无关的调用都会让整个掉落序列偏移,进而使保底计数与实际产出对不上。
  • 状态可控。 能把随机状态作为普通数据持久化、快照、回滚,而不是只能靠"运行到某一步"来复现。

PCG(Permuted Congruential Generator,Melissa O'Neill 提出的算法族)满足这些要求:单状态 64 位、统计质量高、可以按 stream id 派生互相独立的流,且实现极简(一个 64 位乘数加一个旋转),跨平台确定性极好。相比之下,很多语言标准库自带的 PRNG 要么状态不可导出,要么平台间实现不一致。

// 伪代码:按用途派生独立随机流
ulong SeedFor(ulong worldSeed, ulong actorId, ulong counter)
    => Hash(worldSeed ^ (actorId * 0x9E3779B97F4A7C15UL) ^ (counter * 0xBF58476D1CE4E5B9UL));

// 掉落流、外观流、演出流彼此独立
var lootRng   = new Pcg32(SeedFor(worldSeed, actorId, lootCounter));
var cosmetic = new Pcg32(SeedFor(worldSeed, actorId ^ 0xA5A5A5A5UL, cosmeticCounter));

3.2 权重表与抽取算法

掉落表本质是"权重 → 物品"的映射表。抽取有两种主流实现:

累积分布 + 二分查找。 把权重累积成前缀和数组,抽取时生成 [0, total) 的随机整数再二分定位。实现简单、权重表变更时只需重算前缀和,代价是每次抽取 O(log n)。n 在几十到几百的规模下完全够用。

Alias Method(别名法)。 预计算一张大小为 n 的表,每次抽取 O(1)。适合上万个条目、每秒大量抽取的场景(例如怪物密度极高的 ARPG 或者客户端大规模模拟)。代价是需要重建别名表,且表的调试可读性差——策划配错了权重很难在表里看出来。

对养成系统而言,建议用累积分布:掉落表的条目数量通常不大,可读性和可调试性的收益远大于那几纳秒。而当抽取频率高到成为瓶颈时,正确的做法通常不是换算法,而是降低抽取频率(合并多次结算)或者做客户端侧的批量预抽。

3.3 保底计数器:一个状态机而不是一个整数

保底(pity)机制是养成系统里最容易被实现得"看起来对但其实不对"的部分。一个完整的保底通常包含三个阶段:

        ┌──────────┐  抽数达到软保底阈值   ┌──────────┐  达到硬保底阈值
        │  常规阶段 │ ───────────────────▶ │ 软保底段 │ ──────────────▶ │ 必出段
        │  p = p0   │                      │ p 递增   │                 │ 100% 出货
        └──────────┘                      └──────────┘                 └─────┘
             │                                    │                        │
             └──────────── 抽到目标物 ────────────┴────────────────────────┘
                                   计数清零

这个状态机的工程难点不在状态本身,而在计数的生命周期:

  • 计数必须持久化。 保底进度是玩家资产的一部分,必须跟存档一起走。断线重连、切后台、跨设备登录都不能丢。
  • 计数必须与服务端语义一致。 客户端本地预测展示的"还有 N 次保底",与服务端实际计数语义必须完全一致:同一个物品 ID、同一个保底类型、同一套计数规则。
  • 计数只在"有效抽取"上递增。 什么算有效抽取,需要明确定义。常见的排除项包括:被活动覆盖的池子、背包溢出导致未授予的抽取、系统判定为无效(例如网络失败重试的那一次)。这些定义不统一,就会出现客户端显示 79 次保底、实际已经 82 次的情况。
  • 软保底段的斜率要可配置且可解释。 软保底段通常让概率随次数线性上升。这个"斜率"如果是硬编码在代码里的,策划就永远无法通过配表调它;如果完全交给配表,又容易出现配出概率大于 1 的情况。校验器需要强制 0 ≤ 斜率 ≤ 1 且累计概率不超过 1。

客户端在保底上的角色是展示与预测:它本地维护一份计数用于渲染进度条,并在抽取请求发出时携带自己认为的计数,收到权威结果后用权威计数覆盖本地值。这里最关键的一点是——客户端永远以权威返回的计数为准,本地计数只是乐观 UI。任何反过来的实现(以本地计数为准)都会在弱网重试、跨端登录、多端同时在线的场景下崩掉。

3.4 随机日志:让"玩家说有问题"变成可复现的问题

养成系统里最常见的客服工单是"我明明抽到了,但背包里没有"。处理这类工单需要客户端具备把随机过程记录下来的能力。一个最小可用的随机日志包含:本次抽取使用的 seed、stream id、抽取序号、权重表版本号、抽取前后的保底计数、以及最终结果。

有了这些,一次线上反馈可以在几秒内被本地重放。这条链路的实现成本很低(一次结构化日志写入),但收益极高——它把"无法验证的玩家主观描述"变成了"可复现的技术事实"。

四、经济数值:产出、回收与客户端模拟器

4.1 产出与回收的工程表达

经济系统的经典模型是"水龙头与下水道"(faucets and sinks):产出(faucet)向系统注入货币,回收(sink)把货币从系统抽走。如果注入长期大于抽走,货币会通胀,单价飞涨、设计空间被压缩;如果抽走长期大于注入,货币稀缺、玩家被卡在进度墙上、付费压力被动上升。这个框架在 GDC 2025 的公开分享《Building Sustainable Game Economies: The Three Design Pillars》中被系统化讨论过(据 GDC Vault 页面标题与相关开发者博客),是本领域被引用最多的公开材料之一。

客户端经济系统的工程职责是:把"产出/回收"这两个概念落到具体的功能开关上,并让策划能够看清全局。一个常见的工程化做法是给每个产出点和回收点打上统一的标签:

EconomyNode {
  id: "gacha.pull.silver"
  direction: FAUCET | SINK
  amountExpr: "silver_cost * discount"
  currency: "silver"
  scope: PVE | PVP | SOCIAL | PASSIVE
  cap: optional
  note: "用于描述用途,出现在经济报表里"
}

有了统一的 EconomyNode,才能做三件事:全局产出/回收报表(哪个功能在放水、哪个在漏水)、参数调整的影响预估、以及客户端侧的离线模拟器。

4.2 客户端经济模拟器:把数值验证做在工具链里

这里有一个非常值得投入、却经常被忽略的客户端工具:离线经济模拟器。它运行在编辑器或桌面工具里,不需要连服务器,用一批模拟玩家(agent)按配置的行为模型跑虚拟周期,输出货币存量曲线、通胀率、以及各阶层的消费结构。

模拟器的基本结构并不复杂:

状态:每个模拟玩家持有 {等级, 各货币存量, 每日产出速率, 消费倾向}
每个模拟日:
  1. 产出:按产出配置计算当日各货币增量
  2. 行为:按消费倾向决定当日是否消耗、消耗哪一类(升级/抽卡/外观)
  3. 回收:按回收配置扣减,扣不动则该货币阻塞(sink 被"堵住")
  4. 记录:写下每日的总存量、分层存量、阻塞率
输出:存量-时间曲线、通胀率、货币阻塞率、各消费项贡献度

它能回答的问题非常具体:把某个材料的回收效率提高 20%,两周后中位玩家的存量会变成什么样?新的赛季通行证上线会抽走多少存量、会不会把某个阶段的产出逼成无效内容?某个 30 天回归玩家的货币存量能不能支撑他走完新手引导到第一个 Boss?

这类工具的价值在于它把"我觉得这个数值有点飘"变成了"存量曲线的斜率在这个改动下从 0.03 变成 0.11"。它是纯客户端的工具链工作,不涉及任何服务端架构,但它是养成系统数值健康度的主要保障。

4.3 货币精度与展示层

客户端展示货币时会遇到两个和计算层完全不同的约束:

大数值的可读性。 玩家资产从 1e3 涨到 1e12 是常见现象。展示层需要一套统一的缩写规则(中文语境下通常是"万 / 亿 / 万亿",或者在更高量级切到科学计数法),并且在缩写时保持视觉对齐。规则必须全服一致,且在所有货币上完全相同,否则玩家无法比较两种货币的相对规模。

整数最小单位。 所有货币在存储层必须是最小单位的整数(例如以"分"为单位的铜币),而不是浮点数。展示层做除法得到带小数的值,展示层的舍入方向(向上、向下、就近)也必须全局统一。这是养成系统里少数几个"必须用十进制精确计算"的场景——因为它直接对应真实货币,不能承受浮点误差。

4.4 通胀预警:客户端埋点的一个具体切口

客户端是唯一能实时看到玩家当前状态的观察点之一。虽然严格的服务端数据分析平台不在本文范围内,但客户端侧的埋点规范可以完全在客户端闭环:客户端上报每个玩家的产出速率与回收速率(按 EconomyNode 的 id 聚合),而不是上报原始货币余额。

上报聚合速率而非余额的好处是:余额是绝对值,对单个玩家来说受进度阶段影响巨大,跨玩家聚合会得到噪声很大的结果;而"产出速率/回收速率"是相对量,可以直接按等级段、按付费分层做对比,得到真正可用的信号。同时它避免了上报高价值玩家的精确资产带来的隐私顾虑。

具体到客户端埋点规范,有几条通用要求:事件名与参数 schema 必须版本化;上报失败要本地落盘重试而不是丢;采样策略要在客户端实现而不是让服务端猜;以及最重要的一条——上报字段必须能在客户端被单独复现,否则数据出问题时会无法定位。

五、配置驱动的校验与回归:养成系统的 CI

5.1 配表不是数据,是代码

养成系统的配置表一旦复杂起来(属性表、词条池、掉落表、保底规则、经济节点),它承载的逻辑密度会接近代码。一个只有 200 行表格的掉落表里,可能藏着权重抄错、引用了不存在的物品 ID、区间上下界颠倒、保底斜率超过 1 这样的一堆问题。

因此养成系统的配置必须经过与代码同等强度的自动化校验,而且这些校验要跑在 CI 里,绑定在配置变更的 PR 上。

一个实用的校验器清单:

结构完整性。 必填字段非空;ID 唯一;枚举值在合法集合内;数值字段在配置的合理区间内。

引用完整性。 掉落表引用的物品 ID 必须存在;词条池引用的属性 ID 必须在属性表中定义;保底规则引用的掉落池 ID 必须存在;经济节点引用的货币 ID 必须存在。跨表引用是配表出错的重灾区,因为 Excel 里的下拉框和手输可以共存。

语义合理性。 等级曲线必须单调不减;百分比字段必须落在 [-1, 1];保底斜率累计后概率不得超过 1;属性表中不存在环;伤害减免类的属性总和不得超过 100%(除非设计明确允许)。

跨表一致性。 同一个物品在不同表里的品质、是否可交易、绑定类型必须一致。

5.2 黄金样本与蒙特卡洛:两种互补的测试

养成系统的自动化测试有两条主线,各自解决不同的问题。

黄金样本测试(golden test) 验证的是"确定性行为没有变"。做法是构造一个固定的角色状态(固定等级、固定装备、固定词条、固定等级模板),把最终属性值固化成一组断言值。任何人修改求值逻辑、加乘区、调整取整规则,都必须同步更新这组断言值——而"为什么要改"会在 code review 里被质问。这就是黄金样本的价值:它把数值变更变成一次显式的、有理由的 diff。

蒙特卡洛测试 验证的是"统计行为符合配置"。做法是模拟大量抽取,统计实际产出分布,与配置的目标分布对比。一个典型的断言形如:在配置的 100000 次模拟抽取中,稀有道具的实际出现率应当落在理论值的 95% 置信区间内。

这条断言看起来简单,却能抓出大量真实存在的 bug:权重表的某一档被前面的项覆盖了、累积和的重建逻辑漏掉了最后一项、保底计数在跨档时重复递增、随机流被另一个系统污染导致分布偏移。这些错误在人工测试里几乎不可能被发现,因为它们的表现是"概率有一点点不对",而不是"功能坏掉"。

// 伪代码:掉落分布的统计断言
[Fact]
public void LootTable_MatchesConfiguredRates()
{
    const int N = 100_000;
    var rng = new Pcg32(Seed: 20261029);
    var table = LootTable.Load("gacha.pool.gold");

    var counts = new int[table.Entries.Count];
    for (int i = 0; i < N; i++)
        counts[table.Draw(ref rng)]++;

    for (int i = 0; i < counts.Length; i++)
    {
        double expected = table.Entries[i].Weight / table.TotalWeight;
        double observed = (double)counts[i] / N;
        // 二项分布 95% 区间:p ± 1.96 * sqrt(p(1-p)/N)
        double tol = 1.96 * Math.Sqrt(expected * (1 - expected) / N);
        Assert.InRange(observed, expected - tol, expected + tol);
    }
}

值得注意的是,这类测试需要 PRNG 本身是跨平台确定性的——这又回到了上一节对 PCG 的要求。随机测试的可复现性依赖于生成器的跨平台一致性,否则 CI 在不同机器上会给出不同结论。

5.3 配置变更的影响分析

成熟一点的养成系统会给配置 diff 加上一层影响分析:在"某物品攻击力从 120 改成 135"这类改动旁边,直接给出"受影响玩家比例""面板平均战力变化""与某 Boss 的相对击杀时长变化"。

这类分析不需要服务端参与:客户端可以内置一份随版本发布的参考分布(各等级段玩家的属性分布直方图),用它对改动做近似估算。它是近似值,但足以回答"这个改动是 1% 的微调还是 30% 的大改"这个配置评审中真正要问的问题。

六、几个具体的工程反模式

把前面各节的内容压缩成一份可执行的检查清单:

用 float 存核心养成数值。 养成数值是长链式运算的终点,浮点误差会被放大。存储层必须是定点整数。

求值顺序依赖遍历顺序。 属性求值必须是显式的分层函数,不能靠字典或哈希表的遍历顺序。字典遍历顺序在 .NET 里是插入序、在某些运行时里受哈希分布影响,跨平台不一致。

中间步骤随意取整。 取整时机必须写进规范。养成数值建议全程保留万分比精度,只在最终展示与结算处截断。

同一套公式在客户端和权威端各写一遍。 属性公式应当共享同一份定义(例如由同一份配置生成代码,或由同一份 DSL 编译),否则两份实现必然随版本漂移。这是养成系统里最贵的长期技术债。

背包满时仍然消耗保底计数。 计数语义必须明确,且离线结算与实时掉落使用同一套判定。

UI 直接读取可变的引擎内部状态。 UI 应该读不可变快照加版本号,否则在重算与渲染之间会出现撕裂。

配表校验不在 CI 里。 养成配置的所有校验器都必须绑定到配置变更的 PR 上,尤其是引用完整性检查。

迁移链没有旧版本真实存档夹具。 每加一次迁移就必须新增一个夹具。

"未知物品"没有降级分支。 配置版本不一致是必然事件,不是异常事件,代码里必须有显式的降级处理。

随机不可复现。 随机流必须按用途分离,seed 必须能被日志记录下来。

客户端自己决定保底进度。 本地计数只能用于乐观 UI,必须被权威返回覆盖。

七、结语:养成系统的工程化收益在哪里

回头看,养成系统的工程化投入集中在四个地方:属性求值的分层与依赖图、背包的数据结构与存档兼容、随机与保底的可复现性、以及配置校验与数值回归。这四件事有一个共同点——它们都不是"能跑就行"的功能,而是需要长期维护的资产。

值得强调的是,这套工作全部发生在客户端。养成系统不需要额外的服务器集群、不需要微服务拆分、不需要容器编排。它需要的是:一个正确的属性求值模型、一份能活过版本迭代的存档格式、一套可复现的随机基础设施,以及一条把配表变更拦在 CI 里的校验流水线。

对于一个三人到十人的团队,这套东西的搭建成本大概是几周到几个月。但它的收益曲线是递减的:前两周你可能感觉不到区别,第三个月开始你会发现"改一个数值"不再需要三个人开会对齐,半年之后你会发现线上再也没有"面板数值对不上"这种工单。

养成系统的数值病通常不是一次写错造成的,而是每两周的一次"临时改一下"积累出来的。工程化的意义,就是让这些临时改动不会互相叠加。

参考文献

  1. Unity 官方文档:ScriptableObject 手册(配置资产化与共享数据存储的官方依据)https://docs.unity3d.com/Manual/class-ScriptableObject.html
  2. Unity 官方文档:Addressables 4.1 Manual — Memory management(客户端资源与内存管理的官方章节)https://docs.unity3d.com/Packages/com.unity.addressables@4.1/manual/MemoryManagement.html
  3. Unity 官方文档:Addressables 包索引页(4.1 系列功能目录,含 Build、Load、Remote Content Distribution、Optimization tools)https://docs.unity3d.com/Packages/com.unity.addressables@4.1/manual/index.html
  4. needle-mirror/com.unity.addressables(UPM 官方镜像仓库,用于跟踪 Addressables 版本节奏;截至 2026-10-29 最新 tag 为 4.1.2,镜像仓库 star 数 99)https://github.com/needle-mirror/com.unity.addressables
  5. Melissa E. O'Neill, "PCG: A Family of Simple Fast Space-Efficient Statistically Good Algorithms for Random Number Generation"(可复现随机与独立流派生方法的原始论文)https://www.pcg-random.org/pdf/toms-oneill-pcg-family-v1.02.pdf
  6. GDC Vault, "Building Sustainable Game Economies: The Three Design Pillars"(产出/回收模型与经济可持续性的公开分享)https://gdcvault.com/play/1028982/Building-Sustainable-Game-Economies-The
  7. Machinations 开发者博客:Our GDC Talk on Sustainable Game Economy Design is Now Available on the Vault(上述 GDC 分享的作者方说明与补充材料)https://machinations.io/articles/our-gdc-talk-on-sustainable-game-economy-design-is-now-available-on-the-vault
  8. Scott Fine, GDC 2025 Game Economy Design Talk 的配套设计笔记(经济系统设计过程的公开复盘)https://scottfinegamedesign.com/gdc2025
  9. Unity 官方文档:Memory Profiler package(客户端内存分析与配表资源占用核查工具)https://docs.unity3d.com/Packages/com.unity.memoryprofiler@latest
  10. needle-mirror/com.unity.scriptablebuildpipeline(配置资产构建管线相关的 UPM 官方镜像仓库;截至 2026-10-29 镜像仓库 star 数 18)https://github.com/needle-mirror/com.unity.scriptablebuildpipeline
  11. game-ci/unity-builder(客户端自动化构建流水线,可作为养成配置构建门禁的集成参考;截至 2026-10-29 star 数 1108,MIT 许可,最近推送 2026-10-24)https://github.com/game-ci/unity-builder
  12. Unity-Technologies/UnityCsReference(Unity C# 参考源码,属性与序列化实现的查证来源;截至 2026-10-29 star 数 13053)https://github.com/Unity-Technologies/UnityCsReference
  13. Cysharp/UniTask(Unity 客户端异步与任务调度实现参考;截至 2026-10-29 star 数 11275,MIT 许可)https://github.com/Cysharp/UniTask
  14. ocornut/imgui(即时模式调试 UI,可直接用于养成数值调试面板;截至 2026-10-29 star 数 76871,MIT 许可)https://github.com/ocornut/imgui
  15. simdjson/simdjson(客户端配表 JSON 解析的高性能替代方案;截至 2026-10-29 star 数 24479,Apache-2.0 许可)https://github.com/simdjson/simdjson
  16. google/flatbuffers(配表二进制序列化格式,避免配表解析的内存与 GC 开销;截至 2026-10-29 star 数 26587,Apache-2.0 许可)https://github.com/google/flatbuffers
  17. Unity-Technologies/EntityComponentSystemSamples(属性与养成数据在数据导向架构下的组织方式参考;截至 2026-10-29 star 数 8212)https://github.com/Unity-Technologies/EntityComponentSystemSamples
  18. Odin Inspector 官方站点(Unity 编辑器扩展与配置数据校验工具,试用版可获取)https://odininspector.com/
  19. Unity 官方文档:Optimizing AssetBundles(客户端资源打包与配表资源体积优化)https://docs.unity3d.com/6000.0/Documentation/Manual/assetbundles-optimizing.html

一句话摘要:养成系统的工程化不在渲染与网络,而在属性派生模型、背包数据结构、可复现随机与保底状态机、以及把配表变更拦在 CI 里的校验与回归——这些全部发生在客户端,且决定了养成功能能否活过版本迭代。

←返回文章列表

Related

可能也会喜欢

  • AI 商店评论挖掘工程 2026:从玩家反馈到版本决策11月1日
  • 客户端打击感工程 2026:命中反馈的时序与预算10月31日
  • 游戏内广告变现工程 2026:eCPM 预测与广告负载决策10月31日

Conversation

0 条

留下你的想法

加载评论中…

New comment