随机数

带种子的确定性随机数,支持命名流与快照/恢复,确保与存档一致的游戏进程。

可复现的随机性:同一个种子,同样的掷点——在任何平台上、跨越存档与读档都成立。

接口IRandomService
关闭开关NullRandomService
程序集CommonGameSystem.Core
启动启动时自动注册——无需任何设置

它做什么

随机数服务用相互独立、可复现的随机源取代 Unity 单一的全局 Random 状态。确定性的含义是:给它一个固定种子,你的掉落表、敌人遭遇和程序化生成在每次重放中都精确复现——并且序列在所有平台上完全一致。这对 bug 报告("种子 12345,第三个房间")非常理想,对存档/读档的正确性更是必不可少。

每个游戏系统都可以从自己的命名流中取数——由 "loot""vfx" 这类字符串标识的独立随机序列。流与流之间彼此隔离,因此在一个系统里多加一次随机调用绝不会挪动另一个系统的序列。一次纯装饰性的屏幕震动掷点永远不可能改变掉落内容。

完整状态可以捕获为快照并在之后恢复:在一局中途读档,下一次掷点恰好接在你离开的位置——无法靠反复读档刷出更好的掉落。

快速上手

在标注了 [DefaultExecutionOrder(100)] 的 MonoBehaviour 中解析并缓存服务,确保它在框架启动之后运行:

var rng = ServiceLocator.Resolve<IRandomService>();
// or the short alias: var rng = SL.Resolve<IRandomService>();

若要获得完全确定性的一局,用一个由显式种子构造的服务替换默认服务:

ServiceLocator.Replace<IRandomService>(new RandomService(new RandomOptions(seed: 12345)));

服务本身就是默认(主)流——它的所有取数方法都可以立即调用。

API 参考

核心取数方法

每次调用都会推进流状态。

  • ulong NextULong() — 原始 64 位值。
  • uint NextUInt() — 原始 32 位值。
  • int NextInt(int maxExclusive)[0, maxExclusive) 内的无偏整数。
  • int NextInt(int minInclusive, int maxExclusive) — 给定范围内的无偏整数。
  • float NextFloat()[0, 1) 内的均匀值;永远不会恰好返回 1.0。
  • float NextFloat(float min, float max) — 给定范围内的均匀值。
  • double NextDouble()[0, 1) 内的均匀值。
  • bool NextBool() — 公平的掷硬币。
  • bool Chance(float probability) — 以给定概率返回 true。概率恰为 0 或 1 时立即返回,不消耗取数。

便捷方法

  • int NextIndex(int count)[0, count) 内的均匀索引;count 为零或负数时返回 -1。
  • int NextWeightedIndex(IReadOnlyList<float> weights) — 按权重比例选取一个索引;对退化输入(空列表、全零权重)返回 -1。
  • T Pick<T>(IReadOnlyList<T> items) — 从列表中均匀选取一个元素。
  • void Shuffle<T>(IList<T> items) — 就地打乱列表;每种排列出现的概率相同。

命名流(隔离)

  • IRandomStream GetStream(string name) — 获取或创建一个独立的流。同一名字在整个会话中返回同一个流实例。IRandomStream 提供与上文相同的取数方法。
  • ulong Seed { get; } — 当前的主种子。
  • void Reseed(ulong seed) — 重置为新种子。这会丢弃所有命名流。

保存与恢复

  • RandomSnapshot Capture() — 序列化完整状态:主种子、默认流以及每一个命名流。
  • void Restore(RandomSnapshot snapshot) — 从快照恢复,例如在一局中途读档时。

RandomSnapshot 是一个普通的可序列化结构体,可以直接放进你的存档数据类(见下方的存档集成示例)。它的 IsValid 属性用于判断读入的快照是否携带可用的载荷。

示例

日常取数

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)]
public class LootDropper : MonoBehaviour
{
    private IRandomService _rng;

    void Awake()
    {
        _rng = ServiceLocator.Resolve<IRandomService>();
    }

    public void RollDrop()
    {
        // Deterministic damage roll: the same seed gives the same sequence on all platforms.
        int dmg = _rng.NextInt(8, 13);
        Debug.Log($"Damage: {dmg}");

        // A named stream keeps cosmetic effects isolated from gameplay rolls.
        var vfx = _rng.GetStream("vfx");
        float shake = vfx.NextFloat(0f, 0.5f);
        Debug.Log($"Screen shake: {shake}");

        // Weighted loot table.
        float[] weights = { 30f, 50f, 20f }; // common, rare, epic
        int rarity = _rng.NextWeightedIndex(weights);
        Debug.Log($"Rarity: {rarity}");
    }
}

与保存服务配合的快照 / 恢复

服务本身不会把快照写到任何地方——由你通过保存 / 加载服务将它与其余存档数据一起存取。把 RandomSnapshot 作为存档类的一个字段嵌入:

using CommonGameSystem.Core;
using UnityEngine;

[System.Serializable]
public class RunSaveData
{
    public int stage;
    public RandomSnapshot rng; // the full random state rides inside your save data
}

[DefaultExecutionOrder(100)]
public class RunSaveManager : MonoBehaviour
{
    private IRandomService _rng;
    private ISaveService _save;

    void Awake()
    {
        _rng = ServiceLocator.Resolve<IRandomService>();
        _save = ServiceLocator.Resolve<ISaveService>();
    }

    public void SaveRun(int currentStage)
    {
        var data = new RunSaveData
        {
            stage = currentStage,
            rng = _rng.Capture() // freeze the dice exactly where they are
        };
        _save.Save("run-autosave", data);
    }

    public void LoadRun()
    {
        var result = _save.Load<RunSaveData>("run-autosave");
        if (result.Status != SaveStatus.Ok)
            return; // no save yet, or the file is unreadable — start fresh

        if (result.Value.rng.IsValid)
            _rng.Restore(result.Value.rng); // the next roll continues the saved sequence

        // ... restore the rest of your run state from result.Value ...
    }
}

Restore 之后,紧接着的下一次 NextInt(无论在主流还是任何命名流上)返回的值,与游戏从未退出时将要返回的值完全相同。重新读档无法重掷结果。

关闭它

ServiceLocator.Replace<IRandomService>(new NullRandomService());

这会静默随机数服务。每次取数都返回固定的安全默认值(0、0f、false、-1 或 default(T)),而不是随机值。适合在测试或确定性沙盒模式中抑制随机性。编程错误仍会抛出异常:null 的流名称或在 Dispose 之后调用会引发异常,与真实服务完全一致。

常见陷阱

  • 仅限主线程。 所有取数与流访问都必须发生在主线程上。如果工作线程需要随机值,先在主线程上取好(或捕获快照),再把结果交给工作线程。
  • 不要跨越 Reseed() 持有命名流。 Reseed() 会把所有命名流从服务中丢弃。在重设种子之前缓存的流自身仍能继续工作,但服务已不再追踪它——它也不会再出现在快照里。重设种子后请再次调用 GetStream(),拿到由新种子派生的流。
  • Chance(0f)Chance(1f) 会跳过取数。 这两种情形立即返回,不推进流状态。如果你的代码依赖精确的取数次数来保证确定性,要把这一点算进去。
  • 保存快照是你的责任。 Capture() 返回一个普通值;服务不会把它写到任何地方。请像上文所示,将它与其余存档数据一起经由保存 / 加载服务存取,并在读档时调用 Restore
  • 坏输入返回安全值而不是抛异常。 空列表、零宽度范围、全零权重或负的数量返回 0、-1 或 default(T),并可选地记录一条警告(由 RandomOptions.WarnOnDegenerateInput 控制)。服务绝不会因退化数据而崩溃。

相关页面