在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;
}
在更新过程中,还做了两件事:
// 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);
}
}
}