基于服务器的“生命游戏”模拟

在1970年,约翰·霍顿·康威发明了“生命游戏”,这是一种零玩家的进化游戏。想象一下,如果将这个游戏部署到服务器上,允许两个人即使身处不同大陆也能观察到同一个游戏棋盘,这将是多么令人兴奋的事情。本文的核心思想就是创建一个持久的、类似“大型多人在线”(MMO)的服务器模拟,并允许客户端远程观察和渲染这个模拟。

生命游戏”的模拟在服务器上持续运行,然后将网格广播给每一个“观察”游戏的客户端。为了使模拟不会变得太无聊,引入了随机变异和棋盘随机化。渲染是通过使用JavaScript的Obelisk.js库构建的,它内部使用WebSockets,但由Spike Engine抽象化。应用程序服务器是一个自托管的可执行文件,客户端只是一个简单的HTML文件。

由于模拟在服务器上运行并由客户端渲染,需要划分角色以及每个节点将执行什么操作。在案例中:

  • 服务器负责整个模拟执行,从一代到下一代。
  • 服务器还负责管理游戏世界的观察者列表,并定期向观察者发送游戏世界的状态。
  • 客户端(或观察者)负责加入/离开服务器和渲染他们接收到的游戏世界。

下图说明了这个过程:

// 客户端-服务器通信的定义 JoinGameOfLife: 由观察者调用以加入游戏。这告诉服务器开始向该特定客户端发送更新。 LeaveGameOfLife: 由观察者调用以离开游戏。这告诉服务器停止发送更新。 NewGeneration: 由服务器发起,因此是“推送”操作,简单地将细胞网格发送给客户端。这个网格用0或1(二进制矩阵)填充,代表地图中的活细胞或空细胞。

不会详细介绍“生命游戏”的实际实现,因为它只是众多实现中的一种,而且相当直接。然而,添加了一些有趣的修改来增加模拟的趣味性,以及一些提高性能的技巧。如果看下面的代码片段,函数UpdateCell负责更新字段中的单个细胞。如果至少有一个细胞发生了变化,就将整个一代标记为变化了。

// C# 代码 [MethodImpl(MethodImplOptions.AggressiveInlining)] private void UpdateCell(int i, int j) { var oldState = GetAt(this.FieldOld, i, j); var neighbors = CountNeighbours(this.FieldOld, i, j); // 更新细胞 this.Field[i * FieldSize + j] = (short)(neighbors == 2 ? oldState : (neighbors == 3 ? 1 : 0)); // 如果新的和旧的不一样,则标记为脏 if (this.Field[i * FieldSize + j] != oldState) this.FieldChange = true; }

在更新过程中,还做了两件事:

  • 如果字段没有变化,这在“生命游戏”中经常发生,会再次随机化棋盘并重新初始化它。这允许永远运行模拟,并在没有更多的活细胞时自动重新启动模拟。
  • 每一代有5%的机会产生一些随机变异。一旦发生了一次变异,有95%的概率在同一代中有更多的变异。这允许“打破”生命游戏中的稳定结构,增加趣味性。
// C# 代码 private void Mutate() { if (!this.FieldChange) this.Randomize(); var probability = 0.05; while (Dice.NextDouble() < probability) { var x = Dice.Next(0, this.FieldSize); var y = Dice.Next(0, this.FieldSize); probability = 0.95; this.Field[x * FieldSize + y] = (short)(this.Field[x * FieldSize + y] == (short)1 ? 0 : 1); } }

现在已经实现了模拟,如何将模拟连接到网络后端呢?加入和离开操作相当直接,它们只是将一个IClient实例添加到/从IList<IClient>中移除。还使用Spike.Timer启动了一个游戏循环,它为处理所有的线程。重要的是要注意,如果有多个计时器,它们将共享同一个线程,避免了性能问题,如过度订阅。游戏循环本身的速度可以在这里调整,在下面的代码中每50毫秒调用一次,在生活演示中设置为200毫秒。

// C# 代码 [InvokeAt(InvokeAtType.Initialize)] public static void Initialize() { // 钩住事件 MyGameOfLifeProtocol.JoinGameOfLife += OnJoinGame; MyGameOfLifeProtocol.LeaveGameOfLife += OnLeaveGame; // 启动游戏循环 Timer.PeriodicCall(TimeSpan.FromMilliseconds(50), OnTick); }

游戏循环做了所期望的事情。它更新生命游戏,执行模拟,然后将网格(32x32的二进制矩阵)发送给每个客户端。在协议和Game类中定义了相同的矩阵为IList<Int16>。所以简单地将这个列表传递给发送方法,而不需要做任何转换。

// C# 代码 private static void OnTick() { World.Update(); // 确保在准备发送时不添加新的观察者 lock (Observers) { // 将网格发送给每个观察者 foreach (var observer in Observers) observer.SendNewGenerationInform(World.World); } }

现在让看看客户端的实现。客户端需要连接到服务器并加入游戏,还需要钩住newGenerationInform事件,每当从服务器接收到一个新的网格时就会调用这个事件。一旦接收到一个网格,将其从Array复制到Int8Array并绘制它。

// JavaScript 代码 $(document).ready(function() { var server = new spike.ServerChannel("127.0.0.1:8002"); // 当浏览器连接到服务器时 server.on('connect', function() { // 加入游戏 server.joinGameOfLife(); // 接收更新 server.on('newGenerationInform', function(p) { var field = new Int8Array(gridSize * gridSize); for (var i = 0; i < gridSize * gridSize; ++i) field[i] = p.grid[i]; render(field); }); }); }); // JavaScript 代码 function render(field) { // 清除屏幕 pixelView.clear(); // 绘制棋盘 var boardColor = new obelisk.CubeColor().getByHorizontalColor(obelisk.ColorPattern.GRAY); var p = new obelisk.Point3D(cubeSide / 2, cubeSide / 2, 0); var cube = new obelisk.Cube(boardDimension, boardColor, false); pixelView.renderObject(cube, p); // 绘制细胞 for (var i = 0; i < gridSize; ++i) { for (var j = 0; j < gridSize; ++j) { var z = field[i * gridSize + j]; if (z == 0) continue; var color = new obelisk.CubeColor().getByHorizontalColor((i * 8) << 16 | (j * 8) << 8 | 0x80); var p = new obelisk.Point3D(cubeSide * i, cubeSide * j, 0); var cube = new obelisk.Cube(dimension, color, false); pixelView.renderObject(cube, p); } } }
沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485