SpaceShoot 服务器






4.97/5 (32投票s)
为 JavaScript / HTML5 浏览器游戏 SpaceShoot 提供稳定强大的服务器,并带有一些小功能。
- 下载客户端 - 727 KB(不含背景音乐)
- 下载服务器 - 836 KB
引言
这是 SpaceShoot 系列的第三篇文章。第一篇文章(点击这里)讨论了游戏的原理,但实现非常简单且不健壮。第二篇文章(点击这里)展示了使用 JavaScript 和即将推出的 HTML5 标准提供的 API 集成有趣的单人游戏的一种可能方式。在本文中,我们将讨论使用 Jason Staten 构建的 Fleck 在 C# 中实现多人服务器的最终版本。
我们将了解服务器结构背后的基本概念、比赛的面向对象设计以及通过 WebSocket 协议向服务器发送命令的能力。我们还将简要了解修改后的 JavaScript 和整个项目的经验教训。本系列仍有可能推出第四篇文章,主题将是 SpaceShoot 的移动实现。那篇文章将主要关注 UI 的更改和可能的性能优化。然而,目前很难预测那篇文章的发布日期,因为正确的想法以及对移动实现的需求都相当有限。
游戏可以在 html5.florian-rappl.de/SpaceShootMulti/ 找到(并玩)。
视频和背景
我们制作了一个关于我们一场战斗的短视频。尽管它超过 12 分钟,但只需简短的浏览就足以掌握游戏本身的基本概念。视频托管在 YouTube 上。
所谓的 HTML5 标准正日益受到关注。考虑到官方标准在未来几年内还不会进入最终状态,这令人难以置信。这也相当令人惊讶,因为 HTML5 中经常提到的一些词或技术并未包含在官方 W3C 标准中。撇开这两个反对任何 HTML5 文章的有效观点不谈,我们将讨论在 C# 中创建游戏服务器的选项,该服务器将构成在带有 JSON 兼容脚本引擎的网络浏览器中查看的游戏的基础。因此,我们将走在当前技术的前沿。
也可以包含其他系统。网络浏览器只是一个显而易见的选择,因为它提供了平台独立性和丰富的 API。它也是一个包含大量调试工具的系统。这些工具不如 Visual Studio 提供的工具那么出色,但是,它们足以了解问题发生在哪里并了解如何解决它们。在分离的客户端/服务器应用程序中,一个问题始终是整个应用程序的调试。由于客户端和服务器是分离的,通常需要进行两个调试过程。解决这个混乱的一种方法是至少对服务器应用程序使用测试驱动设计。有一些 JavaScript 框架有助于在 JavaScript 中构建测试驱动设计。这些框架(在我看来)编写得很好,但由于 JavaScript 方面或当前的 JavaScript IDE 限制性太大。本文将不关注测试驱动设计。
我们将重点关注 C# 语言和 .NET 框架使用的技术。我们将研究一些最有趣的方法和类,并解释此游戏的客户端-服务器交互的基本结构。我们还将了解基本服务器结构,包括用于通过命令行或其他方式(网页、GUI 等)执行方法的复杂命令模式。最后,我们将分析 SpaceShoot 网站 JavaScript 文件中的更改。
浏览器兼容性
该游戏已在主要浏览器供应商的最新版本中进行测试。除了 Microsoft 的 IE 9,所有主要浏览器都支持 WebSocket 技术。然而,Mozilla 的 Firefox 和 Opera 网络浏览器需要进一步的步骤才能激活此技术。
有关在最新版 Opera 中激活 WebSockets 的信息可在 techdows.com/2010/12/enable-websockets-in-opera-11.html 找到。对于(某些版本的)Mozilla Firefox,可在 techdows.com/2010/12/turn-on-websockets-in-firefox-4.html 找到类似的文档。
技术
该项目已经包含了相当多的不同技术。现在是时候提一下在设计服务器时使用的技术了。
- Fleck 用于在 C# 和 .NET 框架下驱动 WebSocket。
- Microsoft 的 JsonValue 为我们提供了简单的 JSON API。
- 大量的反射(命令、属性、碰撞等)
- 一些 .NET 4.0 功能,例如
Complex
类。 - 少量测试驱动设计,以正确实现一些方法而无需头疼,并为将来的更改提供一些测试。
在开发过程中,Fleck 出现了一些问题。因此,我查阅了 GitHub 上可用的源代码。确认没有错误后,我尝试使用 Nuget 进行更新,意识到我正在使用旧版本。因此,这次更新至关重要,因为它减轻了我大部分的烦恼。
接口
为了区分游戏中可用对象的不同能力,使用了大量的接口。有 五个核心 接口:
IExplodable
要求继承对象实现爆炸(精灵)编号。IOwner
将继承对象标记为可拥有,并包含一个存储所有者的属性。IVelocity
标记对象为可移动,并为对象提供包含 x 和 y 方向速度的属性。IParticleUpgrade
用于创建可识别的粒子升级并实现“执行”升级的方法。IShipUpgrade
用于原子飞船升级,以使其可识别并实现“加载”和“卸载”升级的方法。
六个核心接口中有三个涉及升级。让我们看看这些升级列表的基础结构:
我们看到每个升级列表都直接继承自抽象升级列表类 Upgrades<T>
。这个类为新创建的具体升级类提供了一个专门的列表和一些有用的方法,例如虚拟的 Logic()
方法以及 Add()
和 Clear()
。通过继承 Upgrades<T>
必须设置的泛型类型 T
有一个约束,即必须是 IUpgrade
类型。这个接口是 IShipUpgrade
和 IParticleUpgrade
的父级,也将是未来其他升级接口的父级。
除了区分和扩展接口之外,还有用于描述可能碰撞伙伴的接口。JavaScript 代码中的一个问题是扩展每个新对象的 logic()
方法以涵盖可能的碰撞。使用 C# 和通过反射和全局(通过继承)管理此功能的能力,我们的生产力大大提高。总而言之,每个对象只需继承正确的接口即可将自身标记为可与某些其他类型的对象碰撞。
public class Ship : GameObject, IAsteroidCollision, /* ... */
{
// Code of the Ship class
}
public class Asteroid : GameObject, IShipCollision, /* ... */
{
// Code of the Asteroid class
}
因此,我们不仅将飞船标记为可与小行星碰撞,我们**必须**也将小行星标记为可与飞船碰撞。这是必要的,因为我们不对 *n* 个对象执行 *n2 - n* 次碰撞检测,而只执行 *(n2 - n) / 2* 次碰撞检测,即每次碰撞只检查一次。通过继承相应的接口(如 IAsteroidCollision
),该类必须实现一个方法:OnCollision()
。所有碰撞接口的方法名都相同,但参数不同,且与类碰撞的对象类型相关。通过实现 IAsteroidCollision
,该方法的签名将是 void OnCollision(Asteroid asteroid)
。
用于检查可能碰撞的方法在 Match
类中编码。在这里,我们有 Next()
方法来执行所有逻辑步骤。它包含以下代码片段:
// Some code
Parallel.For(0, N, i =>
{
for (int j = i + 1; j < N; j++)
if (Objects[i].CheckCollision(Objects[j]))
PerformCollision(Objects[i], Objects[j]);
});
//More code
我们使用了 .NET Framework 4 引入的并行 for 循环。它的用法非常直接。总而言之,如果我们没有任何依赖项,我们可以很容易地将任何 for 循环转换为并行 for。否则会变得稍微复杂一些。然后通过使用 lambda 表达式来执行魔术。CheckCollision()
方法在抽象的 GameObject
类中可用。一些特殊对象(如炸弹)会覆盖此方法,只是为了包装它,包括一个更专业的碰撞检查条件。
一旦碰撞看起来可能,就会调用 PerformCollision()
方法。可能性是仅使用两个相应对象的坐标和大小来计算的。现在我们需要知道是否根据对象类型也可能发生碰撞。
public bool PerformCollision(GameObject source, GameObject target)
{
var t_source = source.GetType();
var t_target = target.GetType();
var coll_source = t_source.GetMethod("OnCollision", new Type[]{ t_target });
if (coll_source == null)
return false;
coll_source.Invoke(source, new object[] { target });
return true;
}
因此,我们在这里使用反射来检查源对象是否包含一个名为 OnCollision()
的方法,该方法接受特定的目标作为参数。如果存在,我们则使用目标调用此方法。
反射在升级生成方面也非常有用。我们在 UpgradePack
类中使用一个静态方法 Generate()
。该方法不知道它可以生成任何升级。然而,它知道任何升级都必须实现 IUpgrade
接口。知道了这一点,我们可以生成一个随机数,代表要使用的特定类。然后创建该类的一个实例。
public static UpgradePack Generate(Match game)
{
var ran = game.Random;
var up = new UpgradePack();
up.X = ran.Next(0, game.Width);
up.Y = ran.Next(0, game.Height);
var types = Assembly.GetExecutingAssembly().GetTypes().Where(
m => !m.IsInterface && m.GetInterfaces().Contains(typeof(IUpgrade))).ToList();
var idx = ran.Next(0, types.Count);
var o = types[idx].GetConstructor(System.Type.EmptyTypes).Invoke(null) as IUpgrade;
up.Upgrade = o;
up.Type = o.ToString();
return up;
}
在这里使用反射的优势在于减少了代码维护量。如果没有反射,我们需要在 UpgradePack
的实例(或单例实例)中注册任何可接受的类。因此,我们需要在多个地方编写代码,而不仅仅是添加或删除具有适当继承的类。
类
为了构建一个良好的面向对象方法,编写了不同的类。每个游戏对象都继承自抽象基类 GameObject
,该类已经包含了相当多的有用实现。每个类都必须实现自己的 Logic()
方法。类图如下所示:
我们可以识别出游戏中一些(已知)对象。Particle
和 Bomb
是两种武器类型。飞船由 Ship
类表示。小行星有自己的类。每个升级包都必须继承自抽象类 Pack
。这被反射用于动态注册和生成此类包。包只能与飞船碰撞。因此,图中所示的接口起着至关重要的作用。
同样值得注意的是,不仅真实对象直接继承自抽象类 GameObject
,还有另一种类型的对象:GameInfoObject
。直接继承自此层的类仅用于信息。这里有 InfoText
(用于显示文本)和 Explosion
(用于显示爆炸)。与其他游戏对象有一个很大的不同:虽然普通游戏对象在服务器上执行逻辑步骤,但游戏信息对象在客户端上执行其逻辑。
这意味着游戏信息对象只在服务器上创建。它们在发送给客户端后就会被移除。客户端将对这些信息性(但非交互性)对象执行逻辑步骤。这个技巧帮助我们减少服务器上的工作负载,同时仍然能够生成包含文本、爆炸和其他对象的游戏。现在我们将深入了解上面显示的一个游戏对象:
我们来看看用于粒子的类。我们发现所有相应的常量现在(与 JavaScript 版本相比)都在正确的位置。我们有许多属性和重要方法。最重要的方法当然是 Logic()
方法,它在每个逻辑步骤中都会被调用。实际上有两种方法可以创建这个类的新实例。一种可能性是通过构造函数进行标准创建。另一种(通常更有用)的可能性是通过静态 Create()
方法提供。后者接受一些参数并执行许多重要任务。ArtificalIntelligence()
方法仅在粒子处于**回旋镖**模式时使用。这是借助特殊升级实现的。它会遵循一条更集中和专业的路径,而不是线性路径。为了确定这条路径,需要 ArtificalIntelligence()
方法。
除了(因接口实现或逻辑所需)属性之外,我们还有一个非常重要的 ToJson()
方法。这个方法必须在任何继承自 GameObject
的类中实现。虽然经典的 ToString()
方法可以在任何对象中实现(返回实例的正确字符串表示),但这个方法必须实现(返回正确的 JSON 表示)。我们将在发送整个游戏状态到每个客户端之前,在任何包含的对象中调用此方法。
Commands
每个命令都必须实现上面显示的接口。这保证了调用以及其他所需的方法。通过实现所示方法,可以确定命令是否可以执行。还可以撤消以前执行的命令。值得注意的是,命令完全独立于控制台。它们不会向控制台写入任何输出,即它们可以与各种可能的视图一起使用,例如网页、表单或前面提到的控制台。
所有命令都集中在单例类 Commands
中。要与当前实例对话,我们只需调用 Commands.Instance
。Commands
类的私有构造函数如下所示:
private Commands()
{
// Create a stack of previously commands for undoing them
undoList = new Stack<ICommand>();
// Also save the last typed commands that can be reverted
undoCallings = new List<string>();
// Here we use reflection to dynamically register the commands
var icmd = typeof(ICommand); // Every command has to implement this interface
// Get all commmands in this interface
var cmds = Assembly.GetExecutingAssembly().GetTypes().Where(m => icmd.IsAssignableFrom(m) && !m.IsInterface).ToList();
// Now we will create an array with instances of all commands
commands = new ICommand[cmds.Count];
for(int i = 0; i < cmds.Count; i++) // Here the empty (standard) constructor is very important
commands[i] = cmds[i].GetConstructor(System.Type.EmptyTypes).Invoke(null) as ICommand;
}
为了调用任何命令(从命令行或任何其他基于文本的界面),我们可以使用 Commands
类的 Invoke()
方法。让我们看看这个方法的具体实现:
public bool Invoke(string command)
{
string _origcmd = command;
//Normalize command
var cs = command.Trim().Split(new char[] {' '}, StringSplitOptions.RemoveEmptyEntries);
command = cs.Length > 0 ? cs[0] : string.Empty;
var args = new string[0];
if (cs.Length > 1)
args = cs.Skip(1).ToArray();
foreach (var cmd in List)
{
if (CheckForCall(cmd, command))
{
if (cmd.Invoke(command, args))
{
if (cmd.CanUndo)
{
undoList.Push(cmd);
undoCallings.Add(_origcmd);
}
last = cmd;
return true;
}
return false;
}
}
return false;
}
那么这里到底发生了什么?首先,任何文本命令都会被规范化,以便删除额外的(非必需的)空格。之后,列表中的第一个元素或空字符串被视为实际命令。其余的则保存为可选参数。最后,我们遍历命令列表并寻找一个匹配的调用。如果找到,我们则调用相应的命令。然后评估命令是否可撤销,并保存为最后调用的命令。我们返回 true 以向调用方法表明命令已成功执行。否则,我们返回 false 以表示命令本身或参数失败。
如何从网络使用 Commands
类?通过通过 WebSockets 协议连接到正在运行的服务器,我们不仅可以与服务器玩游戏,还可以向其发送命令。命令将被重定向到 Commands.Instance
对象并调用 Invoke()
方法。为了实现这一点,使用了以下代码片段(位于 MatchCollection
类中):
public void UpdatePlayer(IWebSocketConnection socket, string message)
{
var j = JsonObject.Parse(message);
if (j.ContainsKey("cmd"))
{
switch (ParseString(j["cmd"]))
{
/* Not interesting for commands */
//Everything in order to control the server
case "console":
//Ensures that the server can only be controlled from the localhost (127.0.0.1)
if (socket.ConnectionInfo.ClientIpAddress.Equals(IPAddress.Loopback.ToString()))
{
//Was it a valid command ?
if (Commands.Instance.Invoke(ParseString(j["msg"])))
socket.Send(ReportConsole(Commands.Instance.Last.FlushOutput()));
else //Send error if not
socket.Send(ReportConsole("Command not found or wrong arguments."));
}
else //Obviously not enough rights (connection from outside 127.0.0.1)
socket.Send(ReportConsole("Not enough privileges for sending commands."));
break;
}
}
}
所以,唯一需要敏感的是谁应该能够向服务器发送命令。目前,只有本地连接的*玩家*(我们也可以称他们为客户端)可以发送这些命令。这似乎是一个很大的限制。然而,这给了我们编写一个网页的可能性,该网页(在本地)执行通信并向我们显示结果。为了确保安全,这样的网站应该有一个适当的登录和安全的身份验证过程。
实现人工智能
引入一些不错的机器人通常不是一个坏主意。在这种情况下,我们将遵循一种有趣的方法,它为进一步升级奠定了坚实的基础。此服务器中的机器人将通过 Keyboard
类工作。每个 Ship
在其组合中都有一个此类的实例。机器人和人类之间的区别由 Ship
类的 IsBot
属性给出。在机器人的情况下,当前 Ship
的 Logic()
方法将调用相应 Keyboard
实例的 Automatic()
方法。此方法如下所示:
public void Automatic()
{
//Resets previous keyboard commands
Reset();
//Instantly respawn if necessary
if (Ship.Respawn)
{
Shoot = true;
return;
}
switch (Ship.BotLevel)
{
case 1:
//Monte Carlo is for Bot Level 1 and 2 only
MonteCarlo();
break;
case 2:
MonteCarlo();
//ForceBots depend on some force by other objects
ForceBot();
break;
case 3:
case 4:
//The goodbots is a potential based method -- level 3 is 3/2 and level 4 is squared potential
GoodBots();
DeployBomb();
break;
case 5:
//Ultra bots are not as strong as their name suggests - they just take information and do something with it
UltraBot();
DeployBomb();
break;
}
}
我们首先注意到的是,任何先前设置的命令(或键盘按键)都将被取消设置,并且机器人将始终直接复活(不会等待更好的时机或喝咖啡)。我们还看到,使用了一些方法来区分不同类型的机器人。这有点有趣,需要进一步解释。
在一个普通的游戏中,有很多不同的玩家参与。我们有一些非常差的,一些非常好的,以及许多普通玩家。在这些普通玩家中,大多数还算可以,而少数玩家相当不错。其他人可能表现平平。所以总的来说,我们有一个如下图所示的分布。
为了为每个机器人设置一个固定级别,必须在创建时确定级别。由于 .NET 随机生成器只给我们均匀分布的伪随机数,我们必须使用外部库或编写我们自己的方法。在这种情况下,我们可以做后者,因为它不是一个大问题,并且我们可以控制任何开销。实际上有更多可能的解决方案可以从均匀伪随机数生成器创建一个正态伪随机数生成器。
最常用的解决方案之一是坐标变换,其中将创建两个均匀随机数以创建一个(实际上是两个,我们应该将第二个存储起来以供进一步使用,但到目前为止已被省略)正态随机数。为了使其工作,我们使用 *exp(x2)exp(y2)=exp(x2+y2)* 并用 *r2* 替换 *x2+y2*。完整的代码如下所示:
double GaussianRandom()
{
var u = 0.0;
var v = 0.0;
var r = 0.0;
do
{
u = 2.0 * Game.Random.NextDouble() - 1.0;
v = 2.0 * Game.Random.NextDouble() - 1.0;
r = u * u + v * v;
}
while (r == 0 || r > 1);
var c = Math.Sqrt(-2 * Math.Log(r) / r);
return u * c;
}
市面上还有更快的实现。然而,这是最容易实现的之一,并且因为它不经常使用(每个机器人只使用一次),所以我们可以轻松承受开销。
概念
下面展示了拥有一个**安全**服务器以防止作弊,同时使代码可供任何人阅读的基本概念。
解决安全(作弊)问题是有代价的:我们必须在服务器上完成所有繁重的工作。否则,我们永远无法知道每个人是否都看到相同的游戏。另一点是,这种结构为我们提供了一些关于同步和延迟的健壮性。目前我们正在做以下事情:
- 如果玩家加入游戏(或主持游戏),他将获得游戏的全部信息,即每个对象的所有信息。然后客户端根据这些信息构建游戏。
- 如果玩家按下或释放某个键,更新后的键盘信息将发送到服务器。
- 每 40 毫秒在服务器上执行一个逻辑步骤。此逻辑步骤使用每个用户的当前键盘信息。
- 逻辑步骤完成后,更改后的游戏状态将发送给每个客户端。客户端必须删除将在下一轮中删除的对象,并添加新对象。新对象可以是新玩家(飞船)、小行星、(升级)包等。
- 如果玩家离开游戏,他的飞船将被标记为移除。它将在下一个逻辑步骤后被移除。
这意味着我们不关心玩家是否向我们发送了当前的键盘信息。我们也不关心玩家是否收到了当前状态。一般来说,每个玩家都对自己的连接负责。如果连接不够好(快),他必须承受其后果——而不是其他玩家。这也意味着大部分工作都在服务器上完成。只有少数任务可以在客户端上完成。其中一项任务是信息消息的淡出。淡出不受服务器控制。在这里,服务器只负责创建信息文本。文本发送到客户端后,会立即从服务器内存中移除。然后客户端不仅负责绘制它——还负责它的逻辑步骤。这些逻辑步骤与从服务器接收到的消息同步。
原因很简单,消息的衰减应该与游戏或游戏的绘制绑定。绘制以及游戏本身都与游戏的逻辑绑定,而逻辑发生在服务器上。因此,我们可以说我们只是用服务器的逻辑发送循环替换了客户端的逻辑循环。如果我们将客户端(小型)逻辑例程绑定到网络接收方法,我们实际上就将这些例程绑定到了原始逻辑。简而言之:它看起来更一致。
一些 JavaScript 修改
为了完全支持修改后的服务器,客户端的 JavaScript 代码需要不同的结构。
除了诸如连接菜单(主持、加入等)的明显方法之外,我们还需要包含不同的方法来启动游戏。单人游戏专注于每 40 毫秒调用一次的游戏循环。现在这个循环位于我们的服务器上。我们只以大约 40 毫秒的间隔接收服务器发送的消息。在加入(或主持)多人游戏后,我们会收到服务器发送的一个大包,其中包含游戏的当前状态。此消息用于初始化游戏:
game.startMulti = function(data) {
// Reset game
game.reset();
// Set the appropriate game mode
game.multiplayer = true;
// Set the document's title
document.title = 'Multiplayer - ' + DOC_TITLE;
// Initialize the corresponding arrays
for(var i = 0, n = data.ships.length; i < n; i++)
ships.push(new ship(data.ships[i]));
myship = ships[ships.length - 1];
for(var i = 0, n = data.asteroids.length; i < n; i++)
asteroids.push(new asteroid(data.asteroids[i]));
for(var i = 0, n = data.particles.length; i < n; i++)
particles.push(new particle(data.particles[i]));
for(var i = 0, n = data.packs.length; i < n; i++)
packs.push(new pack(data.packs[i]));
for(var i = 0, n = data.bombs.length; i < n; i++)
bombs.push(new bomb(data.bombs[i]));
game.running = true;
loop = true;
};
变量 loop
被设置为 true
。这只是为了防止任何错误。在此变量被设置为执行 GameLoop
的间隔的指针之前。在游戏停止的情况下,loop
在间隔清除后立即设置为 null
(等效于 false
)。我们看到此方法中只进行了初始化。没有调用任何绘图方法。
除了初始化多人游戏的方法之外,我们还需要一个在接收到数据包时调用的方法。它看起来像这样:
network.receive = function(e) {
var obj = JSON.parse(e.data);
switch(obj.cmd) {
case 'next':
game.continueMulti(obj);
break;
case 'chat':
chat.append(obj);
break;
case 'info':
infoTexts.push(new infoText(obj.info));
break;
case 'list':
network.setList(obj.list);
break;
case 'current':
game.startMulti(obj);
break;
case 'error':
network.error(obj.error);
break;
case 'console':
console.log(obj.msg);
break;
}
};
所以我们基本上只是决定服务器发送的是哪种消息,然后执行相应的方法。我们已经在 current 的情况下看到了相应的方法。现在我们对最常调用的方法感兴趣,即当我们收到类型为 next 的数据包时:
game.continueMulti = function(data) {
// If this has been entered accidentally then leave!
if(!loop || !game.multiplayer)
return;
//OK measure time for ping gauge
network.gauge.value = network.measureTime();
//Use some (local) variables
var length, i;
//Perform some client logic with the local explosions
length = explosions.length;
for(i = length; i--; )
if(!explosions[i].logic())
explosions.splice(i, 1);
//Append the new explosions (from the server)
for(i = data.explosions.length; i--;)
explosions.push(new explosion(data.explosions[i]));
/* Do same for info texts */
//Updating the local array with values from the server
length = ships.length;
for(i = length; i--;) {
if(!data.ships[i] || data.ships[i].remove)
ships.splice(i, 1);
else
ships[i].update(data.ships[i]);
}
//Append the (rest) of the server's array to the client
for(var j = length, n = data.ships.length; j < n; j++)
ships.push(new ship(data.ships[j]));
/* Same for rest [ Asteroids, Particles, Packs, ... ] */
// Set gauges
gauge.speed.value = myship.boost;
// Perform the (client) logic
chat.logic();
statistic();
// Draw the game
draw();
network.gauge.draw();
};
这里的大部分工作是遍历所有数组,要么更新、删除或添加条目。分析传入数据后,我们还需要在客户端进行一些逻辑操作并执行一些绘制。最后一件事非常重要——毕竟,在多人游戏中,这是唯一执行绘制的地方。现在唯一的问题是如何实现交互性。答案在于 manageKeyboard()
方法的一个小修改,如下所示:
var manageKeyboard = function(key, status) {
//Do not use this method if the chat is currently in use
if(chat.shown)
return false;
/* Code as before, returns in case of unimportant key */
//Here is the important part: send to server in case of multiplayer
if(game.multiplayer)
keyboard.send();
return true;
};
keyboard.send()
方法是所需的网络方法之一。它只是 network.send()
方法的一个包装器,其中包含 keyboard
对象和正确的标识符 (cmd
)。这段代码应该足以完全理解正在发生的事情:
keyboard.send = function() {
// Uses the network.send with the included object
network.send({
keyboard : keyboard,
cmd : 'keys',
});
};
network.send = function(obj) {
// Sending the given object as a (JSON-) string
network.socket.send(JSON.stringify(obj));
};
Server 类
将我们的程序与 Fleck(它通过使用 TCP/IP 为我们提供 WebSocket 技术的一个很好的访问层)实际绑定在一起的类叫做 SpaceShootServer
。在这里,我们创建 MatchCollection
类的实例,为我们提供一个大厅和许多可能性。我们还保存了启动时间。服务器然后由 Start()
、Stop()
和 Restart()
方法控制。代码的布局如下:
public class SpaceShootServer
{
#region Members
MatchCollection matches;
WebSocketServer server;
DateTime startTime;
#endregion
#region Properties
///
/// Gets the DateTime of the server's startup
///
public DateTime StartTime
{
get { return startTime; }
}
///
/// Gets the corresponding MatchCollection with all matches and players
///
public MatchCollection Matches
{
get { return matches; }
}
///
/// Gets the current status if the server is running or not
///
public bool Running
{
get { return server != null; }
}
#endregion
#region ctor
public SpaceShootServer()
{
Construct();
}
#endregion
#region Methods
private void Construct()
{
matches = new MatchCollection();
server = new WebSocketServer("ws://:8081");
}
///
/// Starts the server's execution
///
public void Start()
{
startTime = DateTime.Now;
server.Start(socket =>
{
socket.OnOpen = () =>
{
matches.AddPlayer(socket);
};
socket.OnClose = () =>
{
matches.RemovePlayer(socket);
};
socket.OnMessage = message =>
{
matches.UpdatePlayer(socket, message);
};
});
}
///
/// Stops the server's execution and closes the server
///
public void Stop()
{
server.Dispose();
server = null;
}
///
/// Restarts the server :-)
///
public void Restart()
{
matches = null;
server.Dispose();
GC.Collect();
Construct();
Start();
}
#endregion
}
所有这些公共方法都由上述命令控制。可以重新启动服务器或完全停止执行。Start()
方法在程序开始时被调用。SpaceShootServer
类中最重要的部分当然是事件绑定。在这里,我们使用一些 lambda 表达式来适当地设置属性。
使用 Fleck 库作为 WebSocket 访问的负责层时,另一个重要问题是数据发送的有效参数仅限于字符串。确实,规范只允许字符串(无论如何都会序列化为字节进行传输)。然而,我们只对特定类型的字符串感兴趣:由 JSON 对象构建的字符串。因此,我们实现了以下扩展方法:
public static void Send(this IWebSocketConnection socket, JsonObject msg)
{
socket.Send(msg.ToString());
}
使用这个扩展方法,我们可以像使用字符串一样,使用 JsonObject
调用 Send()
方法。优点在于可读性。现在没有不必要的 ToString()
调用,代码更加清晰。
使用代码
该服务器应该很容易调整以适应其他游戏和项目。通过已经实现的基本命令和现有的 MatchCollection
,服务器可以作为各种不同游戏的坚实基础。本节讨论了将此代码作为 WebSocket 游戏的基础时,应该更改、可以更改和不应该更改的代码部分。
下图显示了服务器命令行界面的屏幕截图。高级命令模式(包括已内置的命令)应该是任何基于此项目构建的项目的组成部分。
基本上,Structures
、Interfaces
和 Game/Upgrades
文件夹仅与 SpaceShoot 相关。因此,它们可以为其他项目删除。Game
文件夹包含一些可能作为起点的好文件。MatchHelpers
、MatchCollection
和 Match
确实包含一些与 SpaceShoot 相关的代码,但是,相关部分可以很容易地从更通用的其余部分中剪切出来。GameObject
和 GameInfoObject
也或多或少是通用的。一些方法或属性可能与 SpaceShoot 密切相关,但它们可以轻松修改或删除。Game
文件夹中的其他类完全与 SpaceShoot 相关,可以删除。
Server
文件夹中的文件与 Program
类中带有 Main()
入口函数的代码一样通用。这里的更改不应应用。
“Commands”文件夹中的一些命令将需要根据 MathCollection
类中的更改进行修改。如果该类完全删除,则会发生更多的更改。可能存在涉及更多接口和依赖注入的方法,但是,很明显有些事情可能会过度设计。在这种情况下,服务器某种程度上是专门为游戏设计的,也就是说,如果我们实际上希望将命令专门用于这种特定类型的服务器 (SpaceShoot),那么构建通用接口等将是一种浪费。因此,很明显,对于未来的项目也适用相同的情况(为专门的服务器提供专门的命令)。因此,命令故意依赖于 MatchCollection
(以及 Match
和 Ship
等其他内容)。
测试项目不是测试驱动设计的一部分,而只是包含一些用于测试的固定点。所包含的方法列表既不完整也不非常巧妙。该列表因需求而增减。因此,测试可能应该是从该项目中删除或忽略的第一件事,因为这些测试确实专注于 SpaceShoot 相关的问题。
在 Match
类中包含了一种快速找到玩家良好出生位置的方法。我们看到以下代码:
public Point StartingPosition()
{
//As default point -- the middle of the plane
var pt = new Point { X = Width / 2.0, Y = Height / 2.0 };
var dist = 0.0;
var monitor = new Object();
//If there are other objects included -- determine position
if (Objects.Count > 0)
{
//Check 10 positions in parallel
Parallel.For(0, 10, i =>
{
//Generate a position (x and y within a certain rectangle)
var x = (Random.NextDouble() * 0.6 + 0.2) * Width;
var y = (Random.NextDouble() * 0.6 + 0.2) * Height;
//Find minimum position; first position will always be smaller than this
var min = double.MaxValue;
//Loop over all objects
for (int j = 0; j < Objects.Count; j++)
{
//Perform measurement only if object is "alive"
if (Objects[j].Remove)
continue;
//Calculate euclidean distance
var dx = x - Objects[j].X;
var dy = y - Objects[j].Y;
//OK is this object closer than the closest one before?
min = Math.Min(Math.Sqrt(dx * dx + dy * dy) - 10.0 - Objects[j].Size / 2.0, min);
}
//Now with a locking (global update)
lock (monitor)
{
//Of the minimum now is bigger than the minimum before - GREAT!
if (min > dist)
{
dist = min;
//Lets take this is current starting point
pt.X = x;
pt.Y = y;
}
}
});
}
return pt;
}
那么这里发生了什么?我们正在生成 10 个可能的起始位置,并检查其中哪个使与其他所有对象的最小距离最大化。很少会得到最优解,但是,通常会得到一个好的解决方案。而且我们只用了少量计算就得到了这个(取决于对象的数量)。总的来说,我们必须生成 20 个随机数,并最多执行 *10 * N* 次测量(其中 *N* 是游戏中的对象数量)。将其与通过检查该矩形中的每个像素来获得最佳解决方案进行比较。这可能涉及 *W * H * N* 次测量(其中 *W* 和 *H* 是矩形的宽度和高度)。实际上,我们对这种方式的性能感到非常惊讶。我们没有可测量的性能影响,但乍一看就获得了接近完美的解决方案。
关注点
已经发现了不少作弊行为。最早的一种是有人可以选择黑色作为主要和次要颜色,几乎将自己隐藏起来。这并不是颜色选择的主要问题。最大的问题是粒子也具有玩家的颜色,导致粒子也被隐藏。这太强大了,所以必须在服务器端阻止。任何颜色选择都会在那里进行评估,并调整为足够亮的颜色以供任何对手使用。
通过使用一些真正有用的 .NET Framework 4,以及 C# 4 属性和方法,我们可以提高生产力。一个很好的例子是来自 System.Numerics
命名空间的新 Complex
结构。为了使用它,我们必须包含相应的程序集引用。它为我们提供了一种非常简单的方法来计算二维中两个或多个点之间的角度。我们所需要做的就是用复数表示这些点,其中 X 是实部,Y 是虚部——取决于我们选择的坐标系。每个复数点都有一个特定的绝对值(开发团队将其命名为 Magnitude
)和一个特定的角度(命名为 Phase
)。这是因为可以将任何复数 *z = x + iy* 表示为 *z = r * exp(i * f)*,其中 *r = (x2 + y2)1/2*。
并行 for 循环和任务库的其他功能以及 LINQ 和 lambda 表达式的使用也对我们有很大帮助。通过包含并行任务库,我们使服务器具有可扩展性。通过使用 LINQ,我们生成了更具可读性的代码——写得更少,做得更多。对于 lambda 表达式也可以这样说,它对于 Fleck 来说已经必不可少。
游戏可以在 html5.florian-rappl.de/SpaceShootMulti/ 找到(并玩)。
避免深色
为了选择合适的主色和副色,我们集成了免费的 JSColor 脚本,其中包含一个颜色选择器控件。缺点是玩家也可以选择非常深的颜色,这使得他们难以区分。主要问题不是飞船本身,而是这种飞船发射的粒子(射击)无法看到或非常难以检测到。
安全防止滥用深色的唯一方法是在服务器上引入这样的例程。每种颜色都必须在 Colors
类中设置。这个类包含主色和副色的属性。通过设置其中一个属性,将调用一个例程,并传入值。调用的方法如下:
public string CheckDarkness(string value, string[] colors)
{
var rgb = value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
if (rgb.Length != 3)
return colors[0];
var r = 0;
var g = 0;
var b = 0;
if (!int.TryParse(rgb[0], out r) || !int.TryParse(rgb[1], out g) || !int.TryParse(rgb[2], out b))
return colors[0];
var h = new HSV(r, g, b);
h.Value = Math.Max(h.Value, 0.5);
return h.ToString();
}
我们只获取(逗号分隔的 RGB)颜色字符串。所以第一项任务是查看传入的字符串是否真的是颜色。如果不是,我们返回一个预定义数组中的颜色,该数组是 primaryColors
或 secondaryColors
。如果一切正常,那么我们创建一个新的 HSV
类实例。这是一个嵌套类。它的代码如下:
class HSV
{
public double Hue { get; set; }
public double Saturation { get; set; }
public double Value { get; set; }
public HSV(Color rgb)
{
int max = Math.Max(rgb.R, Math.Max(rgb.G, rgb.B));
int min = Math.Min(rgb.R, Math.Min(rgb.G, rgb.B));
Hue = rgb.GetHue();
Saturation = (max == 0) ? 0 : 1.0 - (1.0 * min / max);
Value = max / 255.0;
}
public HSV(int r, int g, int b) : this(Color.FromArgb(r, g, b))
{ }
public Color ToRgb()
{
var hi = Convert.ToInt32(Math.Floor(Hue / 60)) % 6;
var f = Hue / 60 - Math.Floor(Hue / 60);
var value = Value * 255;
int v = Convert.ToInt32(value);
int p = Convert.ToInt32(value * (1 - Saturation));
int q = Convert.ToInt32(value * (1 - f * Saturation));
int t = Convert.ToInt32(value * (1 - (1 - f) * Saturation));
if (hi == 0)
return Color.FromArgb(255, v, t, p);
else if (hi == 1)
return Color.FromArgb(255, q, v, p);
else if (hi == 2)
return Color.FromArgb(255, p, v, t);
else if (hi == 3)
return Color.FromArgb(255, p, q, v);
else if (hi == 4)
return Color.FromArgb(255, t, p, v);
return Color.FromArgb(255, v, p, q);
}
public override string ToString()
{
var c = ToRgb();
return string.Format("{0},{1},{2}", c.R, c.G, c.B);
}
}
这里没什么令人兴奋的。它基本上是一个 HSV(色相、饱和度、值)类,它有两种可能的构建方式。要么通过 System.Drawing.Color
结构,要么通过三个值(红色、绿色、蓝色)。我们需要这个类,因为它允许我们获取 Value
的值。这代表亮度。我们希望该值是 0.5 或更高。因此,我们之前有以下调用:h.Value = Math.Max(h.Value, 0.5)
。
接下来,我们只使用一些巧妙的代码将 HSV 转换回 RGB。通常我们不会调用此方法,因为我们也重写了 ToString()
方法。此方法现在只会给我们一个包含红色、绿色和蓝色的逗号分隔字符串——一个可以被任何 CSS 解析器读取的 RGB 字符串。有了这段代码,任何人都无法滥用深色以在比赛中获得不公平的优势。
整个项目教会我们什么?
这是我在“完成”服务器程序后问自己的一个非常有趣的问题。最重要的一点是:**在编写任何 HTML、CSS 或 JavaScript 代码之前,先从服务器开始。**
这听起来很奇怪,但我认为如果有一个真正良好和稳定的服务器已经在运行,我的 JavaScript 设计会好得多。像这样编写代码,我不得不首先将服务器调整到 JavaScript 的需求(对我来说,从 OOP 到原型/过程式编程比反过来容易得多),这有点混乱,并告诉我,如果我更多地利用原型模式,我的 JavaScript 代码会写得更好。
程序的扩展性也会好得多,因为我已经知道与服务器的连接。因此,我会设计 JavaScript 来拥抱服务器,而不是反过来。我认为我意识到这些问题某种程度上是由于为服务器使用与客户端不同的语言。通过使用 *node.js* 可以避免许多所述问题。然而,我认为使用 C# 作为服务器不仅能带来一些编码乐趣,还能为问题提供一种不同的视角。此外,您将获得最好的调试工具,这将极大地帮助您构建一个健壮且可扩展的服务器。
历史
- v1.0.0 | 首次发布 | 2012年3月19日。
- v1.0.1 | 次要更新,包含一些修复 | 2012年3月21日。
- v1.1.0 | 更新(包括 AI) | 2012年3月23日。
- v1.2.0 | 更新(包括颜色检查) | 2012年3月24日。
- v1.3.0 | 更新(包括服务器类,Fleck 扩展) | 2012年3月27日。