65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Obelisk.js 和 Spike Engine 实现持久化客户端-服务器版生命游戏

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2014年6月18日

CPOL

4分钟阅读

viewsIcon

16918

downloadIcon

290

一款“MMO”版的生命游戏,具有在服务器上运行的持久化模拟。

引言

生命游戏 是由约翰·何顿·康威 (John Horton Conway) 于 1970 年发明的一种细胞自动机。它是一款零玩家的进化游戏。如果我们把这个游戏放在服务器上,让它持续运行,这样,即使两人身处不同大陆,也能观察同一个游戏板。这就是本文的构想:在服务器上创建一个持久化、近乎“MMO”的模拟,并允许客户端远程观察和渲染此模拟。 

 

[在线演示]

背景

让我们先简要总结一下本文实现的功能及其主要亮点。

  1. 生命游戏的模拟在服务器上持续运行。然后,网格会被广播给所有“观察”该游戏的客户端。
  2. 该模拟引入了随机变异棋盘随机化,以便能够持续运行而不会过于枯燥。
  3. 渲染部分使用 JavaScript 的Obelisk.js 库构建。
  4. 它内部使用websockets,但由 Spike Engine 进行了抽象。
  5. 应用程序服务器是自托管的可执行文件,而客户端只是一个纯 HTML 文件

 

由于模拟在服务器上运行,客户端进行渲染,我们需要区分每个节点的角色和功能。在我们的例子中:

  1. 服务器负责模拟执行的全部过程,从一代到下一代。
  2. 服务器还负责管理游戏世界的观察者列表,并定期将游戏世界的状态发送给观察者。
  3. 客户端(或观察者)负责加入/离开服务器,并渲染它们收到的游戏世界

下图说明了该过程。

服务器端实现

让我们开始检查定义客户端-服务器通信过程的部分。我们有 3 个操作:

  1. JoinGameOfLife:由观察者调用以加入游戏。这会告知服务器开始向该特定客户端发送更新。
  2. LeaveGameOfLife:由观察者调用以离开游戏。这会告知服务器停止发送更新。
  3. 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;

}

在更新期间,我们还做了两件额外的事情:

  1. 如果字段未更改(在生命游戏中这种情况很常见),我们会再次随机化棋盘并重新初始化它。这允许我们永远运行模拟,并在没有活动单元格时自动重新启动模拟,例如。
  2. 每个世代有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 - 初始版本
© . All rights reserved.