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

编写多人游戏(在 WPF 中)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (130投票s)

2011年8月2日

CPOL

25分钟阅读

viewsIcon

243046

downloadIcon

17184

本文将解释游戏开发的一些概念,以及如何将其应用于多人游戏开发并进行调整。

引言

本文将解释游戏开发的一些概念,以及如何将其应用于多人游戏开发并进行调整。

背景

当我开始编程时,我使用的是 AMOS BASIC。它面向游戏,这也是我喜欢编程的原因。专业上,我从未有机会创建游戏,但一位学习游戏开发的朋友问我是否有东西可以展示。我什么都没有,但我决定创建一个带有多人支持的基本赛车游戏,以使其更具吸引力。

一些原则

即使可以制作事件驱动的游戏,这也不常见。虽然普通程序等待事件发生然后响应,但游戏会不断检查状态(按键)并进行动画,即使玩家什么也不做。

嗯,这对于单人游戏来说效果很好,但多人游戏还有很多其他问题。一个客户端可能连接很快,而另一个可能很慢。游戏本身不能等待慢速客户端,即使有人可能丢失帧也应该继续进行。

为了解决慢速连接问题,有一些技术。其中之一是允许这样的玩家看到他们的游戏正在运行,并计算一切都按预期进行,并在某个时候纠正不按预期进行的事情。即使这通常会产生一个永不停歇的游戏的错觉,但在其他时候,它会产生问题,例如,射击某人后发现没有造成伤害,或者看起来某人朝一个方向跑,有时却出现在不同的地方。

我决定采用另一种方法。它更简单,在互联网上表现不佳,但在本地工作得很好。服务器只处理它所知道的客户端的“状态”。客户端负责立即发送它们的状态,并绘制服务器告诉它们绘制的内容。它们不尝试猜测。如果我想向前移动,即使我能,我的客户端也只会在服务器告诉我移动已完成时显示此移动。

在慢速连接上,如果你按下向前移动,可能需要一些时间才能显示发生的事情,如果你停止向前移动,角色仍然会在服务器上向前移动,直到它收到此状态更改。这就是为什么它对于延迟巨大的用户来说不太好(但我认为对于巨大延迟没有正确的技术)。

技术

XNA 有 Update/Draw 原则,但它是本地的。即使我可以一遍又一遍地绘制相同的帧,我也不确定 XNA 是否这样做,或者它是否在服务器不响应时尝试发现应该发生什么。但我没有使用 XNA 的真正原因是它在我的家用电脑上无法工作。所以我正在使用 WPF,但我只使用 OnRender 方法来绘制我所有的组件,所以不要期望在本文中找到 WPF 模板或控件。

为了使通信快速,我决定只从服务器向客户端以及从客户端向服务器发送更改的值。如果没有值更改,我不需要发送 TCP/IP 数据包。但我如何做到这一点呢?

解决方案类似于 WPF 的依赖属性,但它是自制的。我决定每个属性都应该有一个 ID(整数值),并且它们的值将存储在一个字典中。在每次更改时,该字典中的值都会更改,并且还会将一个副本放入更改字典中。在每个帧结束时,更改都会存储在每个参与者的修改字典中,如果参与者此时不忙,则发送更改。ID 的第一个版本应该手动设置,并且属性应该始终使用相同的模式实现,但我最终实现了自动生成(运行时)的属性。

在最终版本中,属性应保持为 abstract,例如

public abstract int FrameIndex { get; set; }

并且,在运行时,我做的框架将实现它。它看起来像

public int FrameIndex
{
  get
  {
    return RemoteGameProperty.GetPropertyValueById<int>(1, this);
  }
  set
  {
    RemoteGameProperty.SetPropertyValueById(1, this, value);
  }
}

我这样做不是为了性能原因,我是为了让代码看起来更整洁,减少出错的可能性。粗体和斜体的 1 是 PropertyId,它不应该重复。自动生成还填充了另一个现有属性的结构,然后将其发送到客户端,以确保两者使用相同的属性索引,并添加更多验证,防止客户端更改服务器属性。

最基本的游戏结构

首先,客户端连接后,服务器会立即将其 ID 发送给它(用于识别哪些对象归其所有,即使它们只在服务器端创建),发送现有属性及其对应的 ID,并发送游戏的实际状态(所有已创建的组件及其属性值)。

然后一个线程不断等待服务器更改并在可用时发送它们,另一个线程不断等待客户端更改,应用(并验证它们)并等待更多更改。

游戏本身在另一个线程中,并且会不时地(在这个游戏中,每秒 40 次)运行“更新”。

更新

游戏的客户端/服务器方法有一些优点。只要服务器能够以可接受的速度处理下一帧,游戏就不会有延迟(至少从服务器的角度来看不会),并且无需编写复杂的代码来将角色应该移动的方向乘以经过的时间。时间可以是固定的。如果服务器遇到真正的减速,让游戏运行得慢一点是可以接受的。如果客户端很慢,那么它将收到更少的通知,因此它会自然地丢失帧。

所以,与其这样做

x += 40*timeDiff.TotalSeconds;

如果你的游戏是每秒 40 帧,你可以简单地这样做

x++;

但是,更新的概念又出现了问题。如果你想让一个角色向右,然后向下,然后向左,然后向上,你不能简单地写

public override void Update()
{
  while(true)
  {
    for (int x=0; x<40; x++)
    {
      Position = new Point(x, 0);
    }

    for(int y=0; y<40; y++)
    {
      Position = new Point(40, y);
    }

    for (int x=40; x>0; x--)
    {
      Position = new Point(x, 40);
    }

    for(int y=40; y>0; y--)
    {
      Position = new Point(0, y);
    }
  }
}

你应该做类似这样的事情

int state;
int x;
int y;
public override void Update()
{
  switch(state)
  {
    case 0:
     x++;
     if (x == 40)
       state++;

     break;

    case 1:
     y++;
     if (y == 40)
       state++;

     break;

    case 2:
     x--;
     if (x == 0)
       state++;

     break;

    case 3:
     y--;
     if (y == 0)
       state = 0;

     break;
  }

  Position = new Point(x, y);
}

即使两者都做同样的事情,我真的相信第一个更容易理解和编程,特别是如果我们做内循环。但是没有语言资源可以做到这一点......嗯,至少不是直接的。

IEnumerator 和 IEnumerable - 一种新的动画制作方式

在 C# 4.0 中,编译器能够为我们创建状态机。老实说,我希望这种资源更通用,因为我不想生成值,我只希望状态保持和更新。所以,考虑到这些限制,我决定我的所有动画都是 IEnumerator<bool>,但我没有考虑这种枚举的结果。

这样,动画就可以非常类似于我在第一次演示中预期的那样完成,只进行了微小的修改

public IEnumerator<bool> Animation()
{
  while(true)
  {
    for (int x=0; x<40; x++)
    {
      Position = new Point(x, 0);
      yield return true;
      // false can be used, as the result is ignored. The yield return
      // must be seen as "wait for the next frame".
    }

    for(int y=0; y<40; y++)
    {
      Position = new Point(40, y);
      yield return true;
    }

    for (int x=39; x>0; x--)
    {
      Position = new Point(x, 40);
      yield return true;
    }

    for(int y=39; y>0; y--)
    {
      Position = new Point(0, y);
      yield return true;
    }
  }
}

这更容易,不是吗?

我真的希望它是 public Animation Animation() 而不是 public IEnumerator<bool>,但目前不可能。如果 C# 将来允许更好的东西,比如返回 AnimationIIterator 而不是 IEnumerator<bool>,我将更改框架。

不喜欢编译器生成的状态机,但又想要顺序动画?

另一件我希望看到的事情是能够标记一个“返回点”,并在某个时候调用一个方法来保存当前的堆栈并返回到那里。我最近在 CodeProject 上发表了一篇关于这方面的文章,点此查看

但一如既往,我们需要寻找替代方案。我的解决方案是使用线程和同步来解决问题。当然,它使用了大量资源(一个线程和两个自动重置事件),但它有效,而且由于线程保持在等待状态,它们不消耗 CPU 时间。

还有其他缺点,比如锁。实际上,由调用 Update 的线程拥有的锁并不由运行方法的线程拥有,即使第一个线程正在等待它。因此,任何持有的锁都可能导致死锁。但我认为我已经解决了我的框架中的所有死锁。

作为线程,它们可以并行运行,我利用了这一点。那么,哪一个更好呢?编译器生成的状态机还是 ThreadedAnimations/ThreadedEnumerators?嗯,这取决于情况。ThreadedAnimations 更具可移植性,使用最自然的代码并且可以扩展。但是,当动画很短时,同步代码比动画本身花费的时间更多。无论哪种方式,它在我的旧 32 位计算机上运行良好且快速,所以我不认为这是一个真正的问题。

有了这个 ThreadedAnimation,动画代码可以这样写

public void Animation(Action finishFrame)
{
    while(true)
    {
        for (int x=0; x<40; x++)
        {
            Position = new Point(x, 0);
            finishFrame();
        }
        for(int y=0; y<40; y++)
        {
            Position = new Point(40, y);
            finishFrame();
        }
        for (int x=39; x>0; x--)
        {
            Position = new Point(x, 40);
            finishFrame();
        }
        for(int y=39; y>0; y--)
        {
            Position = new Point(0, y);
            finishFrame();
        }
    }
}

再次强调,我这样做不是出于性能原因。我这样做是因为它更容易移植,并且最终得到一个可伸缩性更好的东西,所以如果动画逻辑真的复杂,它可能会有一些优势。

游戏本身

到目前为止,我一直在谈论我构建的技术和框架。我不会详细解释框架的内部工作原理,因为我的真正目的是介绍如何创建游戏。这个框架可以用于 2D 或 3D 游戏,因为它唯一的目的是在两者之间自动通信属性值和请求。

我决定游戏必须以服务器为中心。当然,我本可以制作一些动画或将开始屏幕仅设置为客户端,但我真的想要一个以服务器为中心的游戏。

这个游戏的结构是

  • RemoteGameSample.Common:这是包含公共对象的 DLL。客户端和服务器都知道属性的游戏组件,或者从一个序列化到另一个的对象必须在这里。
  • RemotaGameSample.Server:这是游戏逻辑实际运行的地方。
  • RemoteGameSample:这是玩家启动和用来玩游戏的游戏。它只负责向服务器发送请求,并对公共对象进行子类化以在屏幕上显示它们。

游戏的首次测试

游戏的首次测试并不是真正的游戏,它只是一个简单的连接、进入游戏地图和移动。没有碰撞,每个人都从相同的位置开始,也没有开始/结束比赛。我只是在检查移动。

第一个 PlayerCar 动画在服务器端运行,看起来像这样

public IEnumerator<bool> ControlledByPlayerAnimation()
{
  while(true)
  {
    int angle = Angle;

    var movement = Movement;

    double x = movement.X;
    double y = movement.Y;
    if (x < 0)
    {
      angle -= 4;

      if(angle < 0)
        angle += 360;

      Angle = angle;
    }
    else
    if (x > 0)
    {
      angle += 4;
      if (angle >= 360)
        angle -= 360;

      Angle = angle;
    }

    if (y < 0)
    {
      if (_speed >= 0)
      {
        int diff = (int)Math.Sqrt(1000-_speed);

        _speed += diff;
        if (_speed > 1000)
          _speed = 1000;
      }
      else
        _speed  = _speed * 90 / 100;
    }
    else
    if (y > 0)
    {
      if (_speed <= 0)
      {
        int diff = (int)Math.Sqrt(200-_speed);

        _speed -= diff;
        if (_speed < -200)
          _speed = -200;
      }
      else
        _speed  = _speed * 90 / 100;
    }
    else
    {
      if (_speed != 0)
      {
        int diff = (_speed/100);
        if (diff == 0)
        {
          if (_speed < 0)
            diff = -1;
          else
            diff = 1;
        }

        _speed -= diff;
      }
    }

    if (_speed != 0)
    {
      var oldPosition = Position;
      x = oldPosition.X + Math.Sin(angle * 2 * Math.PI / 360) * _speed / 100;
      y = oldPosition.Y - Math.Cos(angle * 2 * Math.PI / 360) * _speed / 100;

      Position = new Point(x, y);
    }

    yield return true;
  }
}

服务器读取 Movement 属性(从客户端更改),并决定旋转(x 不等于 0),或决定加速/减速,甚至向后移动(y 不等于零)。

angle 属性必须保持在 0 到 359 之间。所以,如果它小于 0,则会添加一个 360 的值;如果它大于或等于 360,则其值会减少 360。对于速度计算,我希望汽车开始加速更快,这就是代码复杂的原因。

但无论如何,服务器可以改变汽车的角度或位置,而无需任何验证。客户端只负责设置 Movement 属性,并且不允许设置角度或位置。

这样做的原因在 CommonPlayerCar 的声明中

using System.Windows;
using Pfz.RemoteGaming;

namespace RemoteGameSample.Common
{
  public abstract class CommonPlayerCar:
    RemoteGameComponent
  {
    [ClientGameProperty]
    public abstract Point Movement { get; set; }

    [VolatileGameProperty]
    public abstract Point Position { get; set; }

    [VolatileGameProperty]
    public abstract int Angle { get; set; }

    public abstract int CarIndex { get; set; }
  }
}

可从客户端更改的属性必须用 [ClientGameProperty] 属性标记。所有其他属性都被视为服务器属性。用 [VolatileGameProperty] 标记的服务器属性会在每一帧发送给客户端,即使它们没有改变,但尽可能使用 UDP,使其更快。

PositionAngle 是动画的一部分,而 CarIndex 决定了用于表示汽车的图像。

你所看到的并非服务器所看到的

让汽车移动后,我希望让汽车碰撞(与地图)。但要做到这一点,我不想使用人工智能来让游戏识别地图并理解在哪里应该发生碰撞,我只是创建了一个“碰撞地图”。

对于服务器来说,它处理的地图只是碰撞地图。是客户端向我们呈现了一个真实的地图(可以进一步分为背景和前景……这就是为什么汽车可以从树下通过,例如)。

添加碰撞检查和减速检查的代码是这样的

if (_speed != 0)
{
  var oldPosition = Position;
  x = oldPosition.X + Math.Sin(angle * 2 * Math.PI / 360) * _speed / 100;
  y = oldPosition.Y - Math.Cos(angle * 2 * Math.PI / 360) * _speed / 100;

  int ix = (int)x;
  int iy = (int)y;
  bool canMove = ix >= 0 && iy >= 0 && ix < _lockedBitmap.Width &&
            iy < _lockedBitmap.Height;

  if (canMove)
  {
    Argb color = _lockedBitmap[ix, iy];

    canMove = color != new Argb(255, 255, 255);
    if (canMove)
    {
      if (color.Red == color.Green && color.Green == color.Blue)
      {
        if (color.Red > 10)
          _speed = _speed * 90 / 100; // reduce speed by 10%
      }
    }
  }

  if (canMove)
    Position = new Point(x, y);
  else
  {
    if (_speed > 500)
      _ShowMessage("Big Hit!", -2, 0, new Argb(255, 0, 0));

    _speed = 0;
  }
}

这段新代码认为地图边界之外总是不可逾越的(canMove 变量的声明)。然后,如果它尚未不可逾越,它会检查服务器端的颜色。如果颜色是白色 [Argb(255, 255, 255)],它将是不可逾越的;如果颜色是灰色但不是黑色,它将降低速度。

考虑到它可以移动,设置新的 Position。如果不能移动但 oldSpeed 大于 500,则显示如下消息

这条消息是动画的,代码看起来像这样

private void _ShowMessage(string message, int xMovement, int yMovement, Argb color)
{
  Room._animations.Add(_ShowMessageAnimation(message, xMovement, yMovement, color));
}

private IEnumerator<bool> _ShowMessageAnimation(string message,
        int xMovement, int yMovement, Argb color)
{
  using(var messageComponent = Owner.CreateComponent<CommonMessage>())
  {
    messageComponent.Text = message;
    messageComponent.Color = color;

    for(int i=11; i<=40; i++)
    {
      messageComponent.Position = Position;
      messageComponent.Size = i;
      yield return true;
    }

    for(int i=1; i<=25; i++)
    {
      var position = Position;
      messageComponent.Position =
        new Point(position.X + (xMovement*i), position.Y + (yMovement*i));
      int alpha = 255-i*10;
      messageComponent.Color =
        new Argb((byte)alpha, color.Red, color.Green, color.Blue);
      yield return true;
    }
  }
}

事实上,ShowMessage 方法向房间添加了一个动画。消息从大小 11 变为 40,然后持续 25 帧,其不透明度降低,同时消息向某个方向移动。同样的 ShowMessage 也用于发送稍后将呈现的其他消息。

还有什么?

到目前为止,我们已经使汽车移动、减速和碰撞。它没有与其他汽车碰撞,没有开始比赛/结束比赛屏幕,我也没有介绍客户端是如何工作的。

所以,在继续讨论游戏本身(服务器端)之前,我将简要介绍一下客户端。

连接到服务器

实际代码使用配置文件来决定要连接的主机。代码在 MainWindow.xaml.cs 中,看起来像这样

_game = new RemoteGameClient();

string host = ConfigurationManager.AppSettings["Host"];
_game.Changed += game_Changed;
_game.Start(host, 578);

正如你所看到的,端口是固定的。这很容易更改,但我的测试中不需要这样的配置,只有主机在变化。调用 Start 后,游戏将开始接收服务器信息,创建组件,更改其属性,并调用 Changed 事件。

Changed 事件告诉哪些组件已被添加或移除,但无法知道客户端哪些属性已更改。当调用 Changed 事件时,确实需要重绘所有内容,但这就是游戏通常的工作方式。

现在 Changed 有一个字典,其中包含所有已更改的组件,以及在该字典中已更改的属性。此外,为了支持 WPF 绑定结构,我创建了实现 INotifyPropertyChangedObservableRemoteGameComponent。请参阅本文末尾。

我原本想直接在 MainWindow 中渲染所有内容,但 WindowOnRender 从未被调用。所以我创建了一个 GameControl 组件,它负责绘制所有 IDrawable 组件。

如您所料,组件(如汽车、消息甚至背景)必须实现 IDrawable。它们还必须继承自公共组件,以便框架知道如何创建它们。

Message 类(用于显示动画消息,如“重击!”、“完成第 1 圈”等)看起来像这样

using System.Windows;
using System.Windows.Media;
using RemoteGameSample.Common;

namespace RemoteGameSample
{
  public abstract class Message:
    CommonMessage,
    IDrawable
  {
    public void Draw()
    {
      var dc = GameControl._drawingContext;

      var argbColor = Color;
      var color = System.Windows.Media.Color.FromArgb(argbColor.Alpha,
                  argbColor.Red, argbColor.Green, argbColor.Blue);
      var brush = new SolidColorBrush(color);

      var text = FormattedTextCreator.Create(Text, Size, brush);

      var point = Position;
      var newPoint = new Point(point.X+PlayerCar._leftModifier-text.Width/2,
                         point.Y+PlayerCar._topModifier-text.Height/2);
      dc.DrawText(text, newPoint);
    }
  }
}

Message 类本身并不知道它是动画的。但是,每次服务器更改时,消息都会使用新的属性值(例如新的 Color 和新的 Position)重新绘制,这就是它如何获得动画效果。服务器是更改这些值的实体。

如果你想查看其他类,请随意。即使是最复杂的类 PlayerCar,也很容易理解。它只注册按键事件,改变 Movement 值,然后绘制。下载源代码并查看。

现在,我将回到服务器,因为所有事情都发生在那里。

回到服务器

房间

游戏的第一版没有房间的概念,但它是一个非常重要的概念。

首先,房间允许玩家加入不同的地图。此外,地图一次最多容纳四名玩家,因此创建新房间允许更多玩家在同一张地图上但分别进行游戏。而且,对于这个游戏来说,制作第一个屏幕至关重要。

即使是这个简单的视觉效果(这是使用 WPF 组件 LabelListBox 绘制的),这个第一个屏幕也在服务器端的一个独立房间中运行。参与者必须始终绑定到一个房间,并且将始终收到来自该房间的所有公共更改以及针对他的私人更改。因此,为了避免在未选择游戏时接收游戏的公共更改,一旦参与者连接,就会创建一个新房间。

要理解这一点,我们来看看服务器本身的初始化

using System;
using System.Net;
using Pfz.RemoteGaming;
using RemoteGameSample.Common;

namespace RemoteGameSample.Server
{
  class Program
  {
    static void Main(string[] args)
    {
      using(var listener =
        new RemoteGameListener(IPAddress.Any, 578, typeof(CommonMessage).Assembly))
      {
        listener.ClientConnected += new
          EventHandler<RemoteGameConnectedEventArgs>(listener_ClientConnected);
        listener.Start();
        Console.ReadLine();
      }
    }

    static void listener_ClientConnected(object sender,
                RemoteGameConnectedEventArgs e)
    {
      var participant = new ServerParticipant();
      e.Participant = participant;
      var room = new StartRoom();
      room.Start(participant);
    }
  }
}

这是服务器的主单元。它开始在端口 578 监听,并指示通用程序集是找到 CommonMessage 的位置。

这是必要的,以便仅限服务器端的组件可以使用在公共程序集中找到的基本类型发送给客户端。然后,设置 ClientConnected 事件并启动监听器。

ClientConnected 处理程序创建一个新的 Participant(可以看作是游戏连接,以区别于直接的 TCP/IP 连接),创建一个 StartRoom,并告诉该房间启动。

这样的房间在启动时,将 ParticipantRoom 设置为自身,创建“StartScreen”并将 MapTitlesMapNames 设置到该启动屏幕,然后真正开始“动画”,即使该房间目前没有任何动画

public void Start(RemoteGameParticipant participant)
{
  _participant = participant;
  participant.Room = this;
  _startScreen = _participant.CreateComponent<CommonStartScreen>();
  _startScreen.MapTitles = Map.Titles;
  _startScreen.MapNames = Map.Names;

  base.Start(1000/40, _Animate());
}

所以,这是玩家选择地图的初始屏幕。一旦选择了地图,ParticipantRoom 就会设置为相应的 RaceRoom,无论是已存在的房间还是新创建的房间。

比赛开始与结束

最初,比赛在参与者连接后直接开始。实际上,PlayerCar 动画可以修改为等待游戏开始,但我决定 PlayerCar 动画不应该知道比赛本身的状况。

事实上,RaceRoom 需要等待四名玩家或者请求开始

然后我决定应该有三秒钟的倒计时

最后,游戏应该开始。即使 PlayerCar 类立即被添加到房间,其动画也只在比赛开始时才会被调用。

一个 Room 只有一个动画,但这样的动画可以管理内部动画。而这正是发生的情况,如下面的代码所示

private IEnumerator<bool> _Animate()
{
  // Wait for 4 players.
  using(RegisterAction<StartRequest>(() => _started = true))
    using(var startScreen = CreateComponent<CommonWaitingScreen>())
      while(_carIndex < 4 && !_started)
        yield return true;

  _started = true;
  // Tells that the game is started,
  // but to really start there is a 3 seconds countdown.

  using(var timeComponent = CreateComponent<CommonTimeToStart>())
  {
    for(int secondsToStart=3; secondsToStart>0; secondsToStart--)
    {
      timeComponent.Second = secondsToStart;

      for(int frameIndex=0; frameIndex<40; frameIndex++)
      {
        timeComponent.Size = (40 - frameIndex)*2;
        yield return true;
      }
    }
  }

  using(RegisterAction<PauseRequest>(_Pause))
  {
    // here is were the race really happens.
    var animationWithCollisions = _AnimateWithCollisions(true);
    while(animationWithCollisions.MoveNext())
    {
      yield return true;

      while(_pause != null)
        yield return true;
    }
  }

  var animationWithCollisions2 = _AnimateWithCollisions(false);
  using(var resultScreen = CreateComponent>CommonResultScreen<())
  {
    InitializeResultScreen(resultScreen);

    while(animationWithCollisions2.MoveNext())
      yield return true;
  }
}

这就是整个“比赛循环”。它首先等待,然后动画倒计时(timeComponent),然后进行游戏,当游戏结束时,它会再次进行相同的游戏。

不同之处在于,第一次游戏可以暂停,而第二次则有“比赛结束”屏幕,并且游戏无法暂停。

比赛如何结束

稍微回顾一下,我还没有解释比赛是如何结束的。为了实现这一点,我在服务器地图中创建了检查点。如果你仔细看,它有一些绿色区域。

绿色值是十的倍数(10、20、30,而不是 11、12 或 13)的绿色区域被视为检查点区域。如果玩家在实际检查点,什么都不会发生;如果他在下一个检查点,那么它就会成为实际检查点;但如果他在另一个检查点,则会出现消息“你错过了一个检查点”。完成所有检查点并返回到第一个检查点后,会出现消息“完成第 1 圈”,如果这是最后一圈,则会为玩家设置排名,如果他是最后一个,比赛就结束了。

所有这些检查都在 PlayerCar_Move 方法中。该方法的第一版已在 PlayerCarControlledByPlayerAnimation 中直接呈现,但后来我决定为其创建一个单独的方法。我不会再次呈现它,因为我认为文章已经足够长了,但如果你愿意,可以下载并查看源代码。

汽车碰撞

我决定汽车碰撞应该在汽车动画之后才发生。这样做的原因是动画执行的顺序。

为了同时运行多个动画,我必须创建一个 AnimationsAnimation。它作为一个单一动画(这是启动游戏所必需的)工作,但能够每帧动画许多动画。动画按照它们添加的顺序运行,我希望避免第一个玩家移动并检查碰撞,然后第二个玩家移动并检查碰撞。它们必须都移动,然后它们必须都应用碰撞。

所以,真正发生的是:有一个动画,它首先动画 AnimationsAnimation,然后在调用 yield return 之前,存储所有汽车信息并使用原始值应用碰撞。因此,即使汽车 A 先收到碰撞(从碰撞中移开),汽车 B 的碰撞检查仍将针对原始值进行。

代码如下

private IEnumerator<bool> _AnimateWithCollisions()
{
  while(_animations.Update())
  {
    var cars = GetComponents().OfType<PlayerCar>().ToArray();

    int count = cars.Length;
    var speeds = new int[count];
    var positions = new Point[count];
    for(int i=0; i<count; i++)
    {
      var car = cars[i];
      speeds[i] = car._speed;
      positions[i] = car.Position;
    }

    _CheckAndApplyCollisions(cars, speeds, positions);
    yield return true;
  }
}
private void _CheckAndApplyCollisions(PlayerCar[] cars,
             int[] speeds, Point[] positions)
{
  int count = cars.Length;
  for(int i=0; i<count; i++)
  {
    var car1 = cars[i];
    var speed1 = speeds[i];
    var position1 = positions[i];
    for(int j=i+1; j<count; j++)
    {
      var car2 = cars[j];
      var speed2 = speeds[j];
      var position2 = positions[j];

      PlayerCar._CheckAndApplyCollision(
        car1, speed1, position1, car2, speed2, position2);
      PlayerCar._CheckAndApplyCollision(
        car2, speed2, position2, car1, speed1, position1);
    }
  }
}

属性与请求

我不知道你们中有多少人可能已经注意到,但 RaceRoom 动画使用 RegisterAction 方法来处理 StartGamePause。这些“动作”将处理客户端请求。

但是,请求和属性值有什么区别?为什么移动是一个属性而不是一个请求?

嗯,属性更适合表达“状态”。像 MoveForward 这样的请求可以处理当前帧,但下一帧它是否应该再次移动?

考虑到客户端可能会丢失帧,等待下一个请求是不合适的。此外,出于“安全”原因,最好在每一帧检查状态,而不是允许每个请求执行一次移动。毕竟,如果同一帧中有 20 个请求会发生什么?

但是,请求有什么用呢?

嗯……想象一下你快速按下并释放暂停键。按下暂停时,IsPausePressed 被设置为 true,但释放键时,IsPausePressed 被设置为 false。如果连接只是有点慢,在服务器意识到它曾经是 true 之前,该值就变回 false 了。

因此,当动作不是状态,并且即使某些状态变化发生得非常快也必须执行时,使用请求更合适。当值是状态更改器时,应该使用属性来表示实际状态。

此外,处理请求的动作必须注册,因此有时请求可能只是被忽略。嗯,属性也可以被忽略,但您永远不需要注册属性,因为它们始终可用。

更多

游戏的实现还有更多内容。如果文件发生更改,它会将地图从服务器传输到客户端;服务器将运行中的地图保存在内存中,并尝试缓存不再使用的已加载地图;加载前景时,黑色会转换为透明等等,但我认为这里不需要介绍这些逻辑,因为它们会改变文章的焦点。

如果您对我如何做到这一点有任何疑问,请提问或查看代码。我真的认为许多这些事情都可以通过调试代码轻松理解。

更多 - 第二部分

在修订代码以避免死锁、添加基于线程的动画等方面,我还尝试了 .NET 4 中提供的 SpinLocks。它们在我的测试中表现糟糕,但我决定尝试我自己的乐观锁。

我真的以为它们的性能会很差,但我很惊讶它们甚至优于传统的 lock 关键字,而且我设法使它们像读写锁一样工作。这些乐观读写锁基于 Interlocked 关键字,并且总是开始考虑它们将获得锁。只有当它们没有获得锁时,如果它们是某种类型的读者,它们才会减少计数并继续尝试。但是它们如何继续尝试呢?

它们调用 Thread.Yield() 来等待,让另一个线程/进程运行,然后再次检查。如果没有其他线程可运行,它们会立即检查。这使得 CPU 在等待时达到 100%,但这是一个虚假的值,因为当它们运行时,它们很快就会要求另一个线程运行。而且,与我避免使用的 ReaderWriterLockSlim 相比,它们的速度快了近 10 倍。现在我真的在考虑更频繁地使用读写锁了。

一篇文章?我在这篇文章这里解释了那个类以及其他一些线程同步原语。

TCP 或 UDP

游戏通常使用 UDP,但我的第一个实现使用 TCP 进行通信。那么,哪一个更好呢?

嗯,在发送地图文件时,使用 TCP 肯定更好,因为数据包按顺序接收且永不丢失。另一方面,UDP 更适合总是变化的 states。所以,如果我丢失了状态 10,也没问题,因为状态 11 将包含我们需要的所有更改。

考虑到这种丢失以及 UDP 永远不会等待旧数据包重新发送的事实,它通常“更快”。但我的实现只发送更改的属性,所以我不能丢失数据包。但是,如果我以错误的顺序接收它们,我可以处理最后一个数据包,当旧数据包到达时,我可以处理它并重新处理当前的数据包。

为了实现这一点,我创建了 GuaranteedUdpConnection,它能够重新发送丢失的数据包,但允许您无序接收它们。因此,属性更改(但不是请求)现在使用 UDP 发送,在我的测试中,现在在互联网上玩起来更顺畅了。

未来改进

我之前说过我想让汽车互相射击并投掷汽油。嗯,这已经完成了。汽油看起来像一个黑洞,但这个功能已经存在,看起来像这样

当然,游戏还需要更多内容。它需要更多信息,例如圈数、还有多少油可以投掷、你遭受了多少次重击等等。此外,它需要更好的配置,因为有些人会更喜欢纯粹的比赛,没有射击或其他类似的东西。

我计划实现所有这些,但我不知道何时发布这些更改。目前,我更专注于完成框架,因为我计划编写其他游戏,我希望其他人也想使用我的框架。

此外,当前版本使用更精确(且更耗 CPU)的计时器。由于它在等待时不断调用 Thread.Yield,一个 CPU 会达到 100%,但线程并没有真正阻塞。如果将来我能找到一个既精确又不看起来消耗所有 CPU 的计时器,我将替换当前的计时器。但我对此不抱期望,因为 .NET 已经到了第 4 版,但对于这种限制没有内置解决方案。

许可说明

源代码是 CPOL,但我从网上获得的地图许可证类型不详。如果有人想绘制并发送地图给我,以便包含在此处(甚至替换当前的地图),我将很乐意将它们包含进来并在文章中提及你的名字。

试玩游戏

要试玩游戏,您必须首先运行服务器和至少一个客户端。要通过网络试玩,服务器的 IP 地址必须对所有客户端可见,并且客户端的 RemoteGame.exe.config 文件必须指向服务器 IP(实际上,它连接到本地 [127.0.0.1] 服务器)。

更新 - ObservableRemoteGameComponent

我不太习惯(使用?)WPF DataTemplates。我真的相信游戏的许多方面可以使用 WPF DataTemplates 来完成,而不是使用 DrawingContext 绘制,但最初游戏组件没有实现 INotifyPropertyChanged 接口,而且它们仍然不使用 DependencyProperties

为了在屏幕上显示更多信息,我非常想使用 DataTemplates。不是我需要它们,而是因为我想将游戏技术集成到 WPF 中,这样更多习惯于 DataTemplates 的人会认为使用 WPF 模板创建游戏是自然而然的事情。所以,现在 RemoteGameComponent 有一个 OnChanged 虚方法,它只在客户端为服务器更改的属性调用,而 ObservableRemoteGameComponent 实现了 INotifyPropertyChanged 并为每个更改的属性调用 PropertyChanged 事件。

有了它,现在可以直接将 ObservableRemoteGameComponent 添加到窗口并使用 WPF 数据模板来显示它们。所以,要显示一些信息,像这样

我使用了这个 DataTemplate

<DataTemplate DataType="{x:Type App:PlayerCar}">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition Width="Auto"/>
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <Label Content="Angle: " FontSize="20" Foreground="White" FontWeight="Bold"/>
        <Label Content="{Binding Angle}" Grid.Column="1" 
		FontSize="20" Foreground="White"/>
        <Label Content="Lap Number: " Grid.Row="1" FontSize="20" 
		Foreground="White" FontWeight="Bold"/>
        <Label Content="{Binding LapNumber}" Grid.Row="1" Grid.Column="1" 
		FontSize="20" Foreground="White"/>
        <Label Content="Oil Thrown: " Grid.Row="2" FontSize="20" 
		Foreground="White" FontWeight="Bold"/>
        <Label Content="{Binding OilThrown}" Grid.Row="2" Grid.Column="1" 
		FontSize="20" Foreground="White"/>
    </Grid>
</DataTemplate>

好的……那不是模板。我认为 Grid 有三点令人恼火。首先,我必须以完整的对象形式设置列。然后,我必须设置行,因为它不能简单地假定新行是自动的。最后,我必须在每个不在第 0 列或第 0 行的控件中设置 Grid.ColumnGrid.Row

这并非文章的主要内容,但我有一个 WrapGrid,它简单地认为下一个控件总是位于下一列,或者如果没有更多列,则位于下一行。所以,我的模板最终是这样的

<DataTemplate DataType="{x:Type App:PlayerCar}">
    <PfzWpf:WrapGrid ColumnWidths="Auto, 60" 
	HorizontalAlignment="Right" VerticalAlignment="Top">
        <Label Content="Angle: " FontSize="20" Foreground="White" FontWeight="Bold"/>
        <Label Content="{Binding Angle}" FontSize="20" Foreground="White"/>
        <Label Content="Lap Number: " FontSize="20" Foreground="White" FontWeight="Bold"/>
        <Label Content="{Binding LapNumber}" FontSize="20" Foreground="White"/>
        <Label Content="Oil Thrown: " FontSize="20" Foreground="White" FontWeight="Bold"/>
        <Label Content="{Binding OilThrown}" FontSize="20" Foreground="White" />
    </PfzWpf:WrapGrid>
</DataTemplate>
© . All rights reserved.