使用 Obelisk.js 和 Spike Engine 实现持久化客户端-服务器版生命游戏
一款“MMO”版的生命游戏,具有在服务器上运行的持久化模拟。
引言
生命游戏 是由约翰·何顿·康威 (John Horton Conway) 于 1970 年发明的一种细胞自动机。它是一款零玩家的进化游戏。如果我们把这个游戏放在服务器上,让它持续运行,这样,即使两人身处不同大陆,也能观察同一个游戏板。这就是本文的构想:在服务器上创建一个持久化、近乎“MMO”的模拟,并允许客户端远程观察和渲染此模拟。
[在线演示]
背景
让我们先简要总结一下本文实现的功能及其主要亮点。
- 生命游戏的模拟在服务器上持续运行。然后,网格会被广播给所有“观察”该游戏的客户端。
- 该模拟引入了随机变异和棋盘随机化,以便能够持续运行而不会过于枯燥。
- 渲染部分使用 JavaScript 的Obelisk.js 库构建。
- 它内部使用websockets,但由 Spike Engine 进行了抽象。
- 应用程序服务器是自托管的可执行文件,而客户端只是一个纯 HTML 文件。
由于模拟在服务器上运行,客户端进行渲染,我们需要区分每个节点的角色和功能。在我们的例子中:
- 服务器负责模拟执行的全部过程,从一代到下一代。
- 服务器还负责管理游戏世界的观察者列表,并定期将游戏世界的状态发送给观察者。
- 客户端(或观察者)负责加入/离开服务器,并渲染它们收到的游戏世界。
下图说明了该过程。
服务器端实现
让我们开始检查定义客户端-服务器通信过程的部分。我们有 3 个操作:
JoinGameOfLife
:由观察者调用以加入游戏。这会告知服务器开始向该特定客户端发送更新。LeaveGameOfLife
:由观察者调用以离开游戏。这会告知服务器停止发送更新。NewGeneration
:由服务器发起,因此Direction="Push"
,只需将单元格网格发送给客户端。该网格填充有零和/或一(二进制矩阵),代表地图的活动单元格或空白单元格。
<?xml version="1.0" encoding="UTF-8"?>
<Protocol Name="MyGameOfLifeProtocol" xmlns="http://www.spike-engine.com/2011/spml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Operations>
<!-- Joins the game of life and allows clients to observe the board -->
<Operation Name="JoinGameOfLife" Direction="Pull" SuppressSecurity="true"></Operation>
<!-- Leaves the game of life -->
<Operation Name="LeaveGameOfLife" Direction="Pull" SuppressSecurity="true"></Operation>
<!-- Sends a new generation to the observers -->
<Operation Name="NewGeneration"
Direction="Push"
SuppressSecurity="true">
<Outgoing>
<Member Name="Grid" Type="ListOfInt16" />
</Outgoing>
</Operation>
</Operations>
</Protocol>
我们不会深入探讨生命游戏的实际实现,因为它只是众多实现之一,而且相当直观。但是,我们添加了一些有趣的修改来丰富模拟,以及一些不错的性能技巧来加速。如果您查看下面的代码片段,函数UpdateCell 负责更新字段的单个单元格。如果至少有一个单元格被更改,我们将整个世代标记为已更改。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void UpdateCell(int i, int j)
{
var oldState = GetAt(this.FieldOld, i, j);
var neighbors = CountNeighbours(this.FieldOld, i, j);
// Update the cell
this.Field[i * FieldSize + j] =
(short) (neighbors == 2 ? oldState : (neighbors == 3 ? 1 : 0));
// Mark as dirty if new is not the same as old
if (this.Field[i * FieldSize + j] != oldState)
this.FieldChange = true;
}
在更新期间,我们还做了两件额外的事情:
- 如果字段未更改(在生命游戏中这种情况很常见),我们会再次随机化棋盘并重新初始化它。这允许我们永远运行模拟,并在没有活动单元格时自动重新启动模拟,例如。
- 每个世代有5% 的几率产生一些随机变异。一旦发生一次变异,我们就有 95% 的概率在同一世代中发生更多变异。这允许我们“打破”生命游戏中的稳定结构,使其更加有趣。
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 毫秒。
[InvokeAt(InvokeAtType.Initialize)]
public static void Initialize()
{
// Hook the events
MyGameOfLifeProtocol.JoinGameOfLife += OnJoinGame;
MyGameOfLifeProtocol.LeaveGameOfLife += OnLeaveGame;
// Start the game loop,
Timer.PeriodicCall(TimeSpan.FromMilliseconds(50), OnTick);
}
游戏循环所做的与您期望的几乎一样。它更新生命游戏,执行模拟,然后将网格(32x32 二进制矩阵)发送给每个客户端。我们在协议和 Game 类中都定义了相同的矩阵为 IList<Int16>。因此,我们直接将该列表传递给发送方法,无需进行任何转换。
private static void OnTick()
{
World.Update();
// Make sure we don't add new observers while preparing to send
lock (Observers)
{
// Send the grid to every observer
foreach (var observer in Observers)
observer.SendNewGenerationInform(World.World);
}
}
客户端实现
现在让我们看看客户端。客户端需要连接到服务器并加入游戏。我们还需要挂钩 newGenerationInform 事件,该事件将在每次从服务器接收到新网格时被调用。一旦收到网格,我们就会将其从 Array 复制到 Int8Array 并进行绘制。
// When the document is ready, we connect
$(document).ready(function () {
var server = new spike.ServerChannel("127.0.0.1:8002");
// When the browser is connected to the server
server.on('connect', function () {
// Join the game
server.joinGameOfLife();
// Receive the updates
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);
});
});
});
我们使用了一个名为 Obelisk.js 的渲染引擎来渲染我们的等轴块,并受到 @Safx 工作的影响,他在 codepen.io 上实现了javascript 版的生命游戏。然而,我们的客户端没有任何生命游戏相关的逻辑。我们只有一个渲染函数,它绘制一个Int8Array 网格,我们从服务器接收。由于服务器推送数据,我们甚至不需要渲染循环,并且只需在每次相应接收时重绘我们画布的所有元素。
function render(field) {
// Clear the screen
pixelView.clear();
// Draw the board
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);
// Draw cells
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);
}
}
}
希望您喜欢这篇文章,请查看我们撰写的其他 Spike Engine 文章,并随时贡献!
历史
- 2015 年 6 月 23 日 - 源代码和文章已更新为 Spike v3
- 2014/06/19 - 初始版本