跳到主要内容

联机同步随机怪物的常见方法

在做联机游戏时,不知道大家会不会碰到,每个客户端的的技能和怪物的随机数不一样。我虽然玩的游戏不多。但是我的对游戏的体验是很深刻的。挺好奇是怎么实现每个客户端生成相同的随机数是至关重要的,因为任何不同步的行为(如随机数生成不一致)都会导致游戏状态的分歧。在网上看了答题的资料,大概整理一下几种方法。

伪随机数生成器(PRNG)同步

最常见的方法是使用伪随机数生成器(Pseudo-Random Number Generator, PRNG),它是基于一个初始种子(seed)来生成的。只要所有客户端使用相同的种子,并且以同样的顺序调用随机数生成器,所有客户端将得到相同的随机数

步骤:

  • 在游戏开始时,服务器或第一个客户端生成一个随机种子

  • 将该种子广播给所有客户端,并确保所有客户端使用相同的 PRNG 算法(例如,线性同余生成器等)。

  • 所有客户端在相同的时刻、以相同的顺序调用随机数生成器,以确保生成的随机数完全一致。

代码验证 🥳

// 客户端
class PRNG {
constructor(seed) {
this.seed = seed;
this.a = 1664525; // 乘数
this.c = 1013904223; // 增量
this.m = 2 ** 32; // 模数
this.state = seed;
}

// 生成一个新的随机数
next() {
this.state = (this.a * this.state + this.c) % this.m; // 使用了线性同余生成器(LCG)算法来生成。
return this.state / this.m; // 返回 [0, 1) 之间的随机数
}
}

//服务器或第一个客户端生成了一个种子
const seed = Math.floor(Math.random() * 10000);
console.log("使用的种子:", seed);

// 每个客户端都会初始化一个 PRNG,使用相同的种子
const client1PRNG = new PRNG(seed);
const client2PRNG = new PRNG(seed);

// 验证
console.log("客户端1 生成的随机数:", client1PRNG.next());
console.log("客户端2 生成的随机数:", client2PRNG.next());

console.log("客户端1 生成的随机数:", client1PRNG.next());
console.log("客户端2 生成的随机数:", client2PRNG.next());

console.log("客户端1 生成的随机数:", client1PRNG.next());
console.log("客户端2 生成的随机数:", client2PRNG.next());
[16:01:42.341]  使用的种子: 6260
[16:01:42.344] 客户端1 生成的随机数: 0.6621461666654795
[16:01:42.345] 客户端2 生成的随机数: 0.6621461666654795
[16:01:42.345] 客户端1 生成的随机数: 0.08413683017715812
[16:01:42.346] 客户端2 生成的随机数: 0.08413683017715812
[16:01:42.347] 客户端1 生成的随机数: 0.09331860695965588
[16:01:42.348] 客户端2 生成的随机数: 0.09331860695965588

经过验证,我们一个生成了三次随机数,无论多少客户端要使用相同的种子并按相同的顺序调用 PRNG,都会得到完全一致的随机数,确保帧同步时随机事件一致。

亮点

在生成随机数时用了一种常见的 PRNG 算法:线性同余生成器(Linear Congruential Generator, LCG),它的公式为:

Xn+1=(aXn+c)modmX_{n+1} = (a \cdot X_n + c) \mod m

其中:

  • a, c, m 是常数,通常是预定义的。

  • X0X_0 是种子,后续的随机数 Xn+1X_{n+1} 基于前一个值计算。

  • 优点:

  • 确保不同客户端在相同情况下生成相同的随机数。

  • 高效,简单易实现。

危险

每次调用随机数生成函数的顺序必须严格一致,如果一个客户端多调用了一次,可能会导致结果不同步。

帧号绑定随机数

可以将每一帧的帧号与随机数生成结合起来。也就是说,基于当前帧号以及固定的种子来生成一个随机数,这样在每一帧所有客户端生成的随机数都将完全一致。

步骤:

  • 所有客户端在相同的帧号下基于帧号和一个固定的种子计算随机数。例如,使用 (seed + frameNumber) 作为 PRNG 的输入。

  • 因为帧号在每个客户端都是同步的,所以生成的随机数也会同步。

代码验证 🥳

class FrameBasedPRNG {
constructor(seed) {
this.seed = seed; // 固定的种子
}

// 生成基于当前帧号的随机数
getRandomNumber(frameNumber) {
const combined = this.seed + frameNumber; // 使用种子和帧号结合生成随机数
return this.simpleHash(combined); // 调用哈希函数来生成伪随机数
}

// 简单的哈希函数,将输入转换为 [0, 1) 之间的随机数
simpleHash(value) {
let hash = value;
hash = (hash * 9301 + 49297) % 233280; // 线性同余生成器
return hash / 233280;
}
}

// 假设服务器生成了一个固定的种子
const seed = 12345;
console.log("使用的种子:", seed);

// 每个客户端都使用相同的种子
const client1PRNG = new FrameBasedPRNG(seed);
const client2PRNG = new FrameBasedPRNG(seed);

// 假设我们有帧号
const frameNumber = 1;
console.log("帧号:", frameNumber);

// 生成帧号为1的随机数
console.log("客户端1 生成的随机数:", client1PRNG.getRandomNumber(frameNumber));
console.log("客户端2 生成的随机数:", client2PRNG.getRandomNumber(frameNumber));

// 生成帧号为2的随机数
console.log("帧号:", 2);
console.log("客户端1 生成的随机数:", client1PRNG.getRandomNumber(2));
console.log("客户端2 生成的随机数:", client2PRNG.getRandomNumber(2));
[16:20:14.607]  使用的种子: 12345
[16:20:14.607] 帧号: 1
[16:20:14.608] 客户端1 生成的随机数: 0.4530306927297668
[16:20:14.608] 客户端2 生成的随机数: 0.4530306927297668
[16:20:14.609] 帧号: 2
[16:20:14.609] 客户端1 生成的随机数: 0.49290123456790125
[16:20:14.609] 客户端2 生成的随机数: 0.49290123456790125

优点:

  • 确保每一帧的随机数是一致的,帧的不同步不会影响游戏。

缺点:

  • 随机数的顺序性可能不如直接基于种子的 PRNG 模式。

服务器同步随机数

该方法是通过服务器控制随机数的生成。客户端不直接生成随机数,而是依赖服务器发送的随机数。

步骤:

  • 在需要生成随机数的时刻,客户端向服务器请求随机数。

  • 服务器负责生成并广播给所有客户端。

  • 客户端接收到随机数后再应用。

优点:

  • 完全避免了客户端之间可能的不同步问题,因为所有的随机数都是从同一个来源获得的。

缺点:

  • 增加了网络延迟,实时较高的游戏不太合适。

  • 网络开销很高。

参考

伪随机数生成器