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

SpaceShoot - 一款HTML5多人游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (55投票s)

2012年1月15日

CPOL

24分钟阅读

viewsIcon

129643

downloadIcon

8639

使用C#作为服务器,以及JavaScript(使用WebSockets和Canvas)作为客户端应用程序来构建一个简单的多人游戏。

SpaceShoot - A simple Asteroid clone

引言

最近,人们对使用 HTML5 制作出色的跨平台游戏的兴趣有所增加。WebSocket、Canvas 等技术正变得越来越稳定,也越来越安全,可供 Web 开发人员使用。规则仍然是产品应该使用在产品发布时可用的技术来构建——而不是在产品构建时就可用的技术。因此,我预计未来几年,结合 CSS3 和 JavaScript 使用 HTML5 进行高级游戏开发的需求将进一步增加。

背景

我一直想编写一款外观精美的多人游戏,但我从未有时间在所有方面实现我的目标。要么我有非常可靠和快速的多人代码,要么我有好的游戏创意,要么我有正确的图形/图形方法。随着 HTML5 的出现,一个充满新可能性的世界展现在我们面前。首先,编写游戏的可能性有很多。各种类型的游戏都有可能。一些浏览器甚至实现了(当然不属于即将到来的 HTML5 标准的)WebGL 技术。一些浏览器,如 Google 的 Chrome,甚至提供了所谓的原生客户端。所有这些技术都使得在浏览器中实现(加速)3D 成为可能。

目前,大多数游戏都是某种 2D 社交游戏。它们遵循一个简单的原则,包含漂亮的 2D 图形以及与 Facebook / Google+ / Twitter 或其他社交网络的界面。你最终大多会玩一个单人游戏,它以某种方式(通常通过 AJAX)连接到大型网站的数据库,为你提供其他人表现如何的更新或不同类型的反馈和信息。然而,有一种技术将改变这一切。

随着 HTML5 标准的出现,越来越多的流行技术将标准化。其中一项技术将是 WebSocket 技术。关于 node.js 已经有很多讨论。该软件是为了让每个人都能用 JavaScript 编写自己的 (WebSocket-) 服务器代码而编写的。代码将被编译为机器码(通过 Google 的开源 V8 引擎),并将以事件驱动的形式处理所有请求。如果你想用一种类似于客户端代码的语言来编写服务器代码,这当然是一个优势,尽管必须指出 node 是一种不同的技术(我想你只要用一次就会发现这一点!)。

因为我喜欢 C#,所以我想用 C# 编写服务器代码。服务器代码的基础是一个名为 Fleck 的开源代码。它调用必要的 .NET-Framework 类和方法,并支持大多数当前的协议规范。在客户端,我想使用 <canvas> 元素。我不想在这里展示一个完整的游戏:我在这里展示的游戏是可玩的(多人模式),并且肯定会带来一段时间的乐趣。我构建它的目的是使其可扩展。应该可以包含你想要的功能。其中的所有内容都旨在让这也成为你的游戏。

JavaScript 客户端

在我们直接转到 JavaScript 之前,我们必须先看一下网站的 HTML。它非常简短和简单

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SpaceShoot SINGLEPLAYER</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="controls">
</div>
<canvas id="canvas" width=800 height=600>
Your browser does not support the HTML5 canvas element.
Please consider upgrading to a modern browser like 
    <a href="http://www.opera.com">Opera</a>.
</canvas>
<script src="base.js"></script>
<script src="objects.js"></script>
<script src="canvas.js"></script>
<script src="logic.js"></script>
<script src="sockets.js"></script>
<script src="events.js"></script>
</body>
</html>

它还可以写得更短一些。然而,我更喜欢更严格的 XHTML 风格。在解释 JavaScript 源代码之前,我想让你了解一下用于页面样式的 CSS。它不多,但是我们这里有一些可能性。

html, body
{
    margin: 0;
    padding: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
}

canvas
{
    width: 100%;
    height: 100%;
    background: url('bg.jpg');
    background-size: 100% 100%;
}

如你所见,第一个选择器是标准类型。为了使整个页面可用,我们告诉 <html><body> 元素既不设置外边距也不设置内边距。同时,我们给这些元素所有可用的空间。为了避免在某些浏览器上可能出现的问题,我们将溢出设置为隐藏,因为我们不想看到任何滚动条之类的东西。现在谈谈 <canvas> 元素。我尝试了两种可能性

  • 浏览器窗口中心的画布。如果浏览器窗口小于 800 x 600 像素,这里就会出现问题。此外,在大屏幕上,我们可能会很难发现我们的飞船或其他有趣的物体。这距离经典游戏体验也更远。
  • 这是我选择的:我将画布的大小设置为整个浏览器窗口。这既有一些优点,也有一个缺点。首先,它符合经典的游戏体验。其次,它会适应大屏幕和小屏幕。但是,它也会比另一种方法更占用性能。

我还设置了 backgroundbackground-size 规则,以便通过 CSS 绘制游戏背景。我不太确定这是否比在每次重绘循环中将背景图像绘制到画布上更能节省性能,但我假设是这样。最后,很高兴知道可以将画布上的直接绘制与 CSS 规则结合起来。

CSS 和 HTML 说够了——让我们谈谈 JavaScript!我们这里有 6 个子文件。我应该注意,我将代码分成几个文件是为了保持可读性——如果你想在线部署它,那么你应该考虑将文件(按此顺序)组合起来并进行最小化。这些文件具有以下用途

  • base.js:在这里我声明了整个代码中使用的变量、常量和全局辅助函数。为了保持简单,我将所有内容都保持在全局范围内,而不是在一个单独的命名空间中。如果考虑将代码用作库或与许多库结合使用,这样的命名空间(或局部作用域)将很有帮助。
  • objects.js:在这里,我创建了将频繁使用的特殊类型的对象(“类”)。这些特殊对象的例子有 particleasteroidship。该文件只包含这些对象的构造函数及其属性。
  • canvas.js:在这里,我创建了绘制所有对象的函数。我还将 draw() 方法设置为特殊对象的 prototype。然后,大的绘制方法将调用每个对象的 draw() 方法。
  • logic.js:该文件与 *canvas.js* 基本相同,只是我实现了并调用了 logic() 方法,而不是 draw() 方法。调用这些方法是为了移动对象、执行命令(方向、射击等)并评估是否发生碰撞。此外,对象会返回它们是否仍然存活。如果它们不存活,它们将被移动或从包含它们的数组中删除。
  • sockets.js:此文件包含设置 WebSocket 连接的方法。它还将检测是否存在连接(否则不会调用 onconnect 事件)或是否可以使用 WebSocket 对象。如果无法建立连接,则此文件将不启用多人模式,而是启动单人游戏。
  • events.js:这里一切都已连接。此文件设置了基本事件,并包含将执行逻辑和绘图方法的循环。需要注意的是,通常将逻辑循环设置为一个固定的时间间隔(从而保证逻辑将以您控制的 *n* 次每秒执行),而 draw 方法将尽可能频繁地执行,从而导致每秒帧数达到特定计算机的当前最大值。

我不会逐行讲解代码,我只会写一些重要的实现细节。由于我们的画布需要获得焦点才能获取键盘输入,因此我们需要为其提供焦点。可以通过以下方式完成

c.canvas.addEventListener('keydown', keyboard.press, false);
c.canvas.addEventListener('keyup', keyboard.release, false);
c.canvas.addEventListener('mouseover', handlefocus, false);
c.canvas.setAttribute('tabindex', '0');
c.canvas.focus();

看起来只是为了获取焦点就写了这么多代码?但等等!还有更多……实际上,前两行只是用于必要的键盘事件所需的 callback。这两个 callback 都是 keyboard 对象的方法,它们将调用 manageKeyboard 函数。区别在于一个以 true 状态(按键按下)调用 manageKeyboard 方法,而另一个以 false 状态(按键释放)调用。

接下来的几行代码执行以下操作:设置将鼠标悬停在画布上(即浏览器视口)将使画布元素获得焦点。因此,用户可以将焦点转移到地址栏,但如果他将鼠标光标移回游戏区域,焦点将返回到游戏。为了清楚地说明画布是网页上第一个(在这种情况下也是唯一一个)应该获得焦点的元素,我们给它 tabindex 为 0(最低)。然后我们也调用 focus() 方法。

为了绘制高质量的游戏,你需要高质量的图形。这些图形需要在运行时可用,以便可以在屏幕上绘制。在 HTML 中,这是一项容易完成的任务,因为我们有很棒的 <img> 元素。在 JavaScript 中,我们可以生成这些对象而无需将其附加到节点。这意味着浏览器将加载图像,但不会渲染,即显示或绘制图像。对于这个示例,我确实要求一些图像在运行时准备好——所以我必须确保这些资源可用。以下代码说明了如何确保这些资源已加载

//Create basic objects
explosionImg = document.createElement('img');
asteroidImg = document.createElement('img');
implosionImg = document.createElement('img');
shiposionImg = document.createElement('img');
//Set the required src attribute to those objects
explosionImg.src = 'explosion.png';
asteroidImg.src = 'asteroid.png';
implosionImg.src = 'implosion.png';
shiposionImg.src = 'shiposion.png';
//Set the number of resources that do have to be loaded
var count = 4;

//Set all the onload callbacks to the same method
shiposionImg.onload = implosionImg.onload = explosionImg.onload = 
    asteroidImg.onload = function() {
    --count;//Decrease the number of resources that still have to be loaded

    if(count === 0)             //If everything was loaded correctly
        loop = setInterval(gameLoop, 40);     //Start the game loop
};

如果将要添加更多资源,创建一个管理这些资源的另一个对象会更容易。一个(简单而强大)的方法是只使用一个数组,如下所示

//This will be initialized straight from the beginning
var temp = ['explosion.png', 'asteroid.png', /* more entries */];
//...
//This will be executed once necessary
for(var i in temp) {
    var count = 0;
    var t = document.createElement('img');
    t.src = temp[i];
    t.onload = function() {
        count++;
        if(count === temp.length) { /* Do Something */ }
    };
    temp[i] = t;
}

对于这个简单的例子,我认为我的代码可能更清晰。那么我到底预取了什么呢?一张图片用于小行星,另外三张实际上是精灵图。精灵图可以用于动画,因为它们包含许多(相似的)图像。在这种情况下,每个精灵图的尺寸为 256 x 256 像素,其中包含 16 张图片。因此,每张图片为 64 x 64 像素。为了绘制精灵图,我们需要一种具有特殊绘制方法的特殊对象。我创建了以下对象

var explosion = function(x, y, size, img) {
    this.x = x;                                //Set object's center x
    this.y = y;                                //Set object's center y
    this.size = size;                        //Set object's size
    this.frame = 15;                            //Initialize the framecount to 16
    this.img = img || explosionImg;            //Use the given image or a default one
};

这个类的名称是 explosion。它的构造函数接受坐标(中心坐标——游戏中的所有坐标都是中心坐标)以及一个独立的尺寸和对我们要绘制的(精灵图)图像的引用。最后一个参数是可选的。如果 img 参数未设置,它将具有 undefined 值(导致 false)并将 explosionImg 设置为要使用的图像。此外,还有一个名为 frame 的属性,初始化值为 15。这会将给定精灵图的初始帧数设置为 16。我们将使用此值来设置要显示的帧的当前索引和对象的生命周期。对象的逻辑非常简单,如下所示

explosion.prototype.logic = function() {
    return this.frame--;                //First give back current object state 
                            //(0 = dead, else alive) then decrement
};

所以在这里我们只是减少了帧数。return 语句将告诉逻辑循环该对象是否仍然被认为是活着的。在这里我们看到当前值为 0(递减到 -1)将导致 false。这意味着将显示 16 帧,第 17th 帧将不会显示,而是处置该对象。绘制方法具有以下实现

explosion.prototype.draw = function() {
    var pos = (15 - this.frame) * 64;      //Set the position in a one-dimensional array
    var s2 = this.size / 2;                    //This variable saves us one execution step
    c.save();                                //Save the current canvas configuration 
                                //(coordinates, colors, ...)
    c.translate(this.x, this.y);                //Make coordinate transformation
    c.drawImage(this.img, pos / 256, pos % 256, 64, 64, //Draw the image from the source 
                                    //to the destination
                -s2, -s2, this.size, this.size);
    c.restore();                               //Restore the previous canvas configuration
};

我们设置一个(一维)位置并用它来确定二维精灵图中的位置。这里我们也可以看到为什么我决定只使用中心坐标:我正在对对象的中心进行坐标变换。这对于这些对象并没有太大帮助,但对于宇宙飞船和小行星来说将非常有用。对于这些对象,需要旋转——围绕对象的中心进行的旋转。坐标变换对于执行此类任务非常有帮助。如果从未见过画布 2D 绘图上下文的 drawImage 方法的语法:它接受 9 个参数,说明要绘制哪个图像,以及源图像的 x、y、宽度和高度坐标。其他参数是目标 x、y、宽度和高度坐标。这些坐标在应用平移后被选择。在这里,我们将要绘制的图像的大小从 64 x 64 像素更改为不同的 *size* x *size* 语句。

单人游戏

现在我们更详细地了解一下 SpaceShoot 游戏。我从一个可用的单人游戏开始,只是为了在开始编写更复杂的东西之前有一个可用的代码。例如,我使用了以下变量

var c = document.getElementById('canvas').getContext('2d'), //The Canvas Context to draw on
    particles = [],        //All particles (ammo) that should be drawn
    asteroids = [],        //All asteroids that should be drawn
    explosions = [],       //All explosions that should be drawn
    ships = [],            //All living ships (self + opponents)
    deadVessels = [],      //All dead ships (self + opponents)
    /* ... */,
    multiPlayer = false;   //GameMode (SinglePlayer w/o Network, Multiplayer)

此外,我还使用了一些常量——以便于维护游戏。为了在 IE9 中工作,我不得不将最终版本中的 const 更改为 var。但是,为了更具语义性,我在这里仍将它与 const 关键字一起呈现

const     MAX_AMMO = 50,                //Maximum amount of ammo that can be carried
        INIT_COOLDOWN = 10,             //Cooldown between two shots
        MAX_LIFE = 100,                 //Maximum amount of life (of the ship)
        MAX_PARTICLE_TIME = 200,        //Maximum time that a particle (ammo) is alive
        ASTEROID_LIFE = 10,            //Initial life of an asteroid
        MAX_SPEED = 6,                 //Maximum speed of the sheep
        DAMAGE_FACTOR = 15,             //Maximum damage per shot
        ACCELERATE = 0.1;               //Acceleration per round

总而言之,我们已经准备好通过实现一些逻辑和绘制方法以及一个将所有内容融合在一起的大循环来使我们的游戏运行。

var gameLoop = function() {
    if(gameRunning) {
        logic();        //Perform our actions!
        items();        //Generate new objects like Asteroids
        draw();         //Draw all current objects!
        ++ticks;        //Increase game time
    }
};

为了更好地了解大循环中实际发生的情况,我们可以看一下在开始时调用的 logic() 方法。这里,我们有以下代码

var logic = function() {
    var i;

    for(i = explosions.length; i--; )
        if(!explosions[i].logic())
            explosions.splice(i, 1);

    for(i = ships.length; i--; )
        ships[i].logic();

    for(i = particles.length; i--; )
        if(!particles[i].logic())
            particles.splice(i, 1);

    for(i = asteroids.length; i--; )
        if(!asteroids[i].logic())
            asteroids.splice(i, 1);

    for(i = ships.length; i--; )
        if(ships[i].life <= 0) {
            explosions.push(new explosion(ships[i].x, ships[i].y, 24, shiposionImg));
            deadVessels.push(ships[i]);
            ships.splice(i, 1);
        }
};

除了飞船,每个对象都执行其逻辑,然后返回该对象是否应该被处置。飞船是这里的例外,因为这种方法有一些好处。在这里,我让飞船有机会在小行星和其他重要对象获得机会之前行动(因为它们也可能伤害飞船)。所以我将飞船的逻辑与飞船是否已死亡的测试分开,以给玩家一点优势。至于 for 循环,我使用了比更方便的 for(i = 0; i < x.length; i++) {...} 更快的版本。我不想在循环中浪费任何性能!

值得注意的是,一旦玩家自己的飞船死亡(ID 为 1 的那个),玩家将无法再控制游戏。这时应该显示一个总结画面或类似的东西。在我构建它之前,我必须编写一些漂亮的界面。然而,在当前代码中,已经记录了一些统计数据,例如被击中的小行星总数以及被摧毁的小行星总数。所有这些飞船特定的统计数据都是飞船基础对象的一部分。

C# 服务器端

The server application is a simple console program

为了实现服务器端,我使用了 C# 4.0 并结合了一个名为 Fleck 的开源解决方案。Fleck 可以使用 NuGet 包管理器集成到任何项目中——只需搜索“Fleck”即可。使用此库的优点是,从安全 WebSocket 到 WebSocket 协议的不同版本的所有功能都已实现。因此,我们不必专注于编写基本服务器代码和测试等繁琐的任务——我们可以直接进入编写专门用于我们游戏的服务器代码的部分。

需要注意的是,在尝试使用多人模式编程此类游戏时,会出现许多问题。延迟怎么办?发送和接收哪种消息?逻辑是在客户端还是只在服务器端完成?对于这个示例项目,我的方法有点简单。然而,我的目标是介绍这种方法,并告诉你它的优点和缺点以及如何消除它们。

每个客户端都会在键盘输入被评估之前,将其发送给服务器。然后服务器将输入分发给其他客户端。这样每个人都会看到相同的动作。这有一个优点,即游戏与单人游戏几乎保持不变,只是消息必须在逻辑被评估之前发送,并且可以接收导致额外逻辑被执行的消息。然而,这有几个严重的缺点——即

  • 如果一个客户端滞后,他将太迟收到逻辑,无法看到对手的动作。
  • 更糟糕的是,如果此客户端的消息未在时间 n 内分发,则来自一个客户端的 n 条消息将不会发送到其他客户端。因此,其他客户端可能只会看到一条消息——最后一条发送的消息。而不是采取一些回合并进行一些射击,只会看到最后一次动作,导致游戏不同步。

为了防止同步问题,每个飞船都会将其当前的生命状态和当前位置发送到服务器。这些数据随后会分发给其他客户端。这又有一些缺点

  • 一个人可以通过改变自己飞船的生命值和弹药属性来作弊。
  • 被射出的粒子不包括在内,即,这里键盘操作真的至关重要。

大多数这些缺点都可以通过以下方法解决

  • 游戏的重要逻辑仅在多人模式下在服务器上执行。
  • 游戏不太重要的逻辑(例如移动小行星)也在客户端完成。
  • 客户端只负责绘图和发送键盘命令。
  • 服务器收集一轮键盘命令——除非所有人都发送了键盘数据,否则它不会发送键盘命令。
  • 客户端循环仍然发送到 40 毫秒,但只发送键盘信息(在多人模式下)。
  • 如果所有键盘命令都已收到,则完成其余操作。因此,每个人都肯定会保持同步。

这种方法有几个优点。首先,如果有新玩家加入,服务器可以将所有必要的对象(包括坐标)提供给客户端。这是可能的,因为服务器本身执行逻辑——知道所有位置。此外,服务器在收到所有键盘命令后只执行一个逻辑步骤。因此,它不会依赖于总时间(这有时很好,但通常很糟糕),而是依赖于同步时间。

这种方法更高级,并带来某种程度的更好结果(而且 node.js 此时可能是更好的选择,因为我们可以重用我们为单人游戏逻辑编写的对象)。因此,为了保持一个简单的解决方案,我坚持使用有缺陷的版本。但是我承诺很快就会提供一个完全可用的惊人的多人游戏体验。

以下是基本的服务器结构

class Program
{
    // In order to keep things simple (sync and such) 
    // I'll start the game when GAMEPLAYERS players are in
    const int GAMEPLAYERS = 2;

    static void Main(string[] args)
    {
        var id = 1000;    // I give every ship an ID - 
                          // the own ship on every client has ID = 1
        var ticks = 0;    // I will also record game time
        var allSockets = new Hashtable();    // Stores the connections with ship-IDs
        var server = new WebSocketServer("ws://:8081");    //Creates the server
        var timer = new Timer(40);    // This is quite close the interval in JavaScript

        server.Start(socket =>
        {
            socket.OnOpen = () =>
            {
                //Code what happens when a client connects to the server
            };

            socket.OnClose = () =>
            {
                //Code what happens when a client disconnects from the server
            };

            socket.OnMessage = message =>
            {
                //Code when a client sends a status update to the server
            };
        });

        //Server side logic
        timer.Elapsed += new ElapsedEventHandler((sender, e) => //This callback is 
                    //used to generate objects like Asteroids
        {
            //Here the server executes some logic and sends the result to all 
            //connected clients
        });

        // Here we close everything
        var input = Console.ReadLine();
        timer.Close();
        timer.Dispose();
        server.Dispose();
    }
}

这段代码看起来并不太复杂。实际上,我发现它非常简单而且功能强大。毕竟,你这里拥有的一切都是一个很棒的多人游戏的基础。由于我忘记提及这种方法的另一个缺点,现在我必须提一下。如你所见,我为每场游戏设置了固定的玩家人数。为了让每个客户端在这种非常简单的方法下保持同步,我坚持不立即启动多人游戏(当有人连接时)。相反,服务器会等待直到达到固定玩家人数,然后通知所有人游戏已开始。

为了不让每个人都在游戏区域的中间开始,我生成了一对随机数,对应于自己飞船的 x 和 y 坐标。在一个更高级的服务器中,玩家可以在任何时候加入(因此对应于一个也计算一些逻辑的服务器),有必要为加入的玩家提供大量数据,其中包含游戏世界中所有对象所需的所有信息。

数据以字符串形式传输(这是 WebSocket 技术的问题之一)。为了传输有意义的数据,我选择了 JSON 格式。这种格式的开销比 XML 小,并且在 C# 中有一些实现得非常好的解析器。为了保持简单,我使用了微软自己的 HttpFormater 扩展——它带有包含在 System.Json 命名空间中的对象。

以下代码片段展示了客户端向服务器发送键盘数据时发生的情况

socket.OnMessage = message =>
{
    var json = new JsonObject();
    json["logic"] = JsonObject.Parse(message);
    json["logic"]["ship"]["id"] = (int)allSockets[socket];
    var back = json.ToString();

    foreach (IWebSocketConnection s in allSockets.Keys)
        if(s != socket)
            s.Send(back);
};

这里我使用了 JSON 库。我创建了一个新的 JsonObject,并将传入 JSON 对象的内容(存储在可以通过 message 变量访问的 string 中)放置在一个名为 logic 的属性中。由于客户端不知道它自己的飞船 ID(自己的飞船 ID 为 1),因此我们必须在将其传输到其他客户端之前更改飞船的 ID。这是通过 json["logic"]["ship"]["id"] = (int)allSockets[socket]; 完成的。在这里,我们访问 logic.ship.id,并通过存储在 Hashtable 中的 ID 更改 ID。之后,我们从 JSON 对象创建一个 string 并将其传输到除发送消息的客户端之外的所有客户端。

我应该指出,收集键盘输入并一次性发送也具有扩展优势。在这里,我们通常每轮(由 n 个客户端)接收 n 条消息,并且必须将每条消息发送给 n-1 个客户端。这将导致每轮发送 n2-n 条消息。由于我们接收 n 条消息,消息总量将是 n2。如果我们要将其扩展到 100 或 1000 个客户端,这简直太多了(尽管游戏区域现在太小,无法支持超过 20 名玩家)。如果考虑收集所有键盘消息并重新发送它们,那么我们只有 n 条传出消息。这将导致总共 2n 条消息,或原始负载的 2/n。在只有两个客户端的情况下,我们看到这对我们的程序没有任何好处——所以这是坚持简单示例的另一个原因。

多人模式

Four players battling it out

我已经写了很多关于多人事件的基本处理等等。现在我想更深入地了解细节。让我们首先考虑 Websocket 的设置。我编写了以下方法

var socketSetup = function() {
    if(typeof(WebSocket) !== 'undefined') {        //If the type of WebSocket is 
                        //known then they are active
        socket = new WebSocket('ws://:8081');//So let us create an 
                        //instance of them!

        socket.onopen = function() {        //What happens if we can now really connect?!
            multiPlayer = true;            //We are obviously in multiplayer mode
            document.title = 'SpaceShoot MULTIPLAYER';    //And I do want to see this - 
                                    //so I'll make a title change
        };

        socket.onclose = function() {
            //Right now nothing will happen on close - maybe switch to single player 
            //again or something like this...
        };

        socket.onmessage = function(e) {     // A message from the server!
            var obj = JSON.parse(e.data);     //Parse it - so that we can handle the data

            if(obj.asteroid) {        //An asteroid has been added - add it to the game
                var a = obj.asteroid;
                asteroids.push(new asteroid(a.size, a.life, a.angle, 
                a.rotation, a.x, a.y, a.vx, a.vy));
            } else if(obj.added) {    //Some ship has connected - add it to the game
                for(var i = obj.added.length; i--; )
                    ships.push(new ship(obj.added[i], { }, 1));
            } else if(obj.removed) {     //Some ship has disconnected - 
                    //remove it from the game
                for(var i = ships.length; i--; )
                    if(ships[i].id === obj.removed) {
                        ships.slice(i, 1);
                        break;
                    }
            } else if(obj.logic) {         //Another logic iteration from the server - 
                    //integrate it
                for(var i = ships.length; i--; )
                    if(ships[i].id === obj.logic.ship.id) {
                        //Update the ship's properties
                        break;
                    }
            }
            else if(obj.status) {         //Status update from the server
                ships[0].x = obj.status.x;      //Sets starting x of own vessel
                ships[0].y = obj.status.y;      //Sets starting y of own vessel
                gameRunning = true;         //There is just one status update right now - 
                                //that starts the game
            }
        };
    } else
        gameRunning = true; //Single player games will always be started instantly
};

这看起来代码量很大,但实际上幕后并没有那么多。首先,检查 WebSocket 对象的类型是否已知。如果类型未知,那么只有一种可能性:浏览器不支持 WebSocket 对象!这可能是旧浏览器的情况,也可能是决定等待规范完全标准化后再支持的浏览器。在这样的系统上,我们将直接进入单人模式。

否则,我们创建套接字(即连接)并告诉浏览器在连接运行时该怎么做。在这种情况下,onopen 事件非常重要。值得注意的是,WebSocket 连接将保持打开状态,直到它们被浏览器关闭。这发生在您更改网站、关闭浏览器或显式告诉 socket 对象关闭时。在另外两个事件中,onclose 事件不太有趣。如果连接关闭,无论来源如何(也可以从服务器关闭),都会调用此事件。onmessage 事件更重要。在这里,我们将尽一切努力区分服务器可能发送给我们的不同类型的消息。通常,我们会确定一种特殊的属性,例如“type”,其中包含一个特殊的有意义的 string ,有助于区分消息类型。选择是通过一个大型的 switch-case 块完成的。

在这里我做了些不同的事情(又一次)。由于我只有有限的数据量,我通过使用未定义属性总是具有 null 值来选择,该值转换为 false。这区分了飞船的添加、竞争对手的退出、小行星的添加以及其他飞船的逻辑。此外,之前提到的状态也包含在这里。

Using the Code

要使用该代码,您不需要做很多事情。但是,我需要区分单人模式和多人模式。单人模式应该在您的浏览器中运行(如果它不太旧的话!)。我已在以下浏览器中测试了该游戏(单人模式)

  • Microsoft Internet Explorer 9+
  • Opera 11.5+
  • Google Chrome 16+
  • Apple Safari 5+
  • Mozilla Firefox 7+

既然单人模式玩起来很简单——让我们看看多人模式。首先,你需要知道,为了在你的 webserver 上运行,你可能需要设置某些防火墙设置。即使你设置了它们,你也应该考虑到客户端可能也位于防火墙后面(来自本地路由器、你的电脑……)。因此,为了保证连接,你需要仔细选择你的端口和测试方法。否则你可以做以下事情

  1. 打开提供的 Visual Studio 2010 解决方案。
  2. new WebSocketServer("ws://:8081"); 这一行更改为您想要选择的端口(而不是 8081)。
  3. 打开文件 sockets.js
  4. 通过替换您要使用的端口(而不是 8081)和计算机的 IP 地址或 DNS 名称(例如,将 *localhost* 替换为 *192.168.0.1* 或 *example.com*),修改 new WebSocket('ws://:8081'); 这一行。
  5. 将 HTML/CSS 和 JavaScript 文件托管在本地服务器或互联网上。
  6. 如果您在本地网络中执行服务器程序,那么通过互联网提供 HTML 代码意义不大——但是,如果您在本地执行页面,它仍然应该有效。

最后两点并非微不足道。为什么您仍然需要将页面托管在服务器(例如 localhost)上,而不是将网站托管在文件系统上?问题在于安全性。大多数浏览器都有一个安全设置,阻止网站在本地文件系统上托管时执行某些操作。Google Chrome 不支持来自本地托管网站的 WebSocket 请求。对于大多数 XHR 请求和其他功能(如 WebWorkers),情况也是如此。一些浏览器提供选项来调整这些设置。由于我不知道您的浏览器,我只假设您有一个能够使用 WebSockets 的浏览器,并为您提供一组肯定有效的说明!

关于性能:如果游戏在你的电脑上运行不流畅,你应该考虑调整浏览器窗口大小或采用不同的 CSS 设置。在提供的 CSS 文件中,我包含了另一个设置。你只需将第一行替换为第二行

<canvas id="canvas" class="stretched" width=800 height=600><!--Is in use right now -->
<canvas id="canvas" class="centered" width=800 height=600>
    <!--Will give you some performance boost-->

完整的单人演示可以在 http://html5.florian-rappl.de/SpaceShoot/ 在线观看。

关注点

我认为这段代码包含了一些非常重要或值得了解的东西。我也认为这个项目仍有创新和改进的空间。正如我多次声明的:安全是一个问题。现在,任何具有出色 JavaScript 技能的人都可以破解这段代码并无限作弊。这无疑是更高级的主题之一——如何保护这些应用程序。

我真的很想用 WebSockets 做些事情。这无疑是写这篇文章的动力之一。另一个是编写一个仍然简单却又非凡的游戏,仅仅使用 Canvas 技术。我真的很好奇 Canvas 在各种设备上的性能(我的 PC、MacBook Air、WindowsPhone 7 和 iPod Touch (3G))。Canvas 仍处于早期阶段——我对大型机器上的性能印象深刻,但对小型机器上的性能感到失望。

致谢

感谢 Vangos Pterneas 关于使用 WebSockets 和 Canvas 的 Kinect & HTML5 的精彩文章。那篇文章包含了关于 Fleck 项目的信息,我将它用于这个项目。文章可以在这里找到。

另外,我还要感谢 Jason Staten 创建了 Fleck。该项目托管在 GitHub 上,可以在这里找到。我更喜欢使用 NuGet 将附加功能包含到我的项目中。

更多文章

由于游戏除了两个状态栏之外不包含最终统计数据、音频和仪表,因此还有很大的改进空间。目前我计划撰写更多关于编写代码以提供信息丰富的仪表以及以优雅且不引人注目的方式实现音频的文章。我将不断更新本文,包含游戏的重大改进和相关文章的链接。

历史

  • v1.0.0 | 首次发布 | 2012年1月14日
© . All rights reserved.