随机数
带种子的确定性随机数,支持命名流与快照/恢复,确保与存档一致的游戏进程。
可复现的随机性:同一个种子,同样的掷点——在任何平台上、跨越存档与读档都成立。
| 接口 | 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控制)。服务绝不会因退化数据而崩溃。