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

Quantum Striker

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (36投票s)

2014年7月13日

CPOL

37分钟阅读

viewsIcon

46979

downloadIcon

670

一款跨平台 Windows 桌面 / Windows 应用商店太空射击游戏的架构、设计和实现。

目录

引言

很久以前,我和一位同事参加了 Intel App Innovation Contest (AIC) 2013。尽管游戏类别不像其他类别那样可预测,并且依赖于(经典)编程技能,但我们尽力克服了图形上的不足。最后,我们成功地开发了一款有趣的游戏,至少在比赛中进入了前五名。我们对这个成功感到非常满意。

从那时起,我们就计划在 CodeProject 上发布这款游戏。当然,这花了一段时间,但相关的文章在这里。在接下来的段落中,我将引导您了解这款游戏、其中的逻辑以及设计决策和分析。整个文章将以跨平台应用程序的背景进行讨论。当然,我们现在能够编写*通用*应用程序,即支持我们开发者在 Windows Phone 8 和 Windows 8 应用程序之间共享代码的应用程序,但是,在 Windows 应用商店和 Windows (WPF) 之间实现跨平台仍然不是那么容易。

我们将看到如何使用可移植类库作为共享代码的坚实基础。本文还将演示使用 .NET Framework 使代码可移植的高效(和不那么高效)的方法。最后,我希望能够展示一个可扩展、可移植且灵活的架构。

背景

在我开始为 CodeProject 撰写文章后不久,我创建了一个名为 SpaceShoot 的游戏。我花了几个小时将其作为 HTML5 讲座的演示项目。尽管如此,它却成了一个非常有趣的项目,并且在 CodeProject 读者、我的学生和朋友中很受欢迎。为了参加 AIC 2013,我们决定制作一个重制版——一个值得成为续作的游戏。我们的首选平台不是 HTML5,而是 WPF。主要原因是与触摸层的交互。

回想起来,这并不是最好的决定。我们花了一个多星期的时间来弄清楚如何高效地使用 WPF 进行多点触控。事实证明这是一个存在未解决问题的老话题。其中一些问题源于 WPF 的设计选择,一些源于我们自己的疏忽。最终,我们有了一个可靠的解决方案,但花费的时间比预期的要长。然而,WPF 的好处是,该游戏建立在一个非常坚实的代码基础上,具有高度的灵活性和可扩展性。

我们想创建终极的太空射击模拟器。在我们实现了物理引擎后不久,我们就必须实现辅助功能,因为这款游戏被证明非常难以控制。每一次激光射击、每一次碰撞或加速都有非常现实的影响。要抵消这些影响的累积,对于任何玩家来说都是一项艰巨的挑战。如果没有玩家做出适当的反应,飞船的控制将不可避免地丢失。我们实现了惯性阻尼器、转向辅助等,就像控制汽车一样来控制飞船。这与原始游戏中的行为相同,但是,我们更逼真,这可以在许多其他场合使用。

飞船包含一个损伤模型,然后可以将其打开以禁用标准的飞船系统。这样的系统的一个例子就是前面提到的惯性阻尼器。如果它们被激光束、碰撞或其他影响关闭,惯性效应将不会被抑制。在详细讨论我们设计的后果(优点和问题)之前,我们将首先看看我们应用程序的技术方面。

游戏引擎:Quantum Engine

每个游戏都需要某种游戏引擎。即使是非常简单的游戏也可能有一些核心组件可以被视为游戏引擎。对于实时游戏来说,这比其他类型的游戏更容易识别。

基本上,一个游戏引擎包含两个重要部分:

  • 逻辑管理器,通常以固定的时间间隔触发。
  • 资源管理——即绘制图形或播放声音。这以实时方式进行。

不仅要保持这些组件尽可能解耦是一个好习惯。它还将产生更清晰的游戏设计。我曾见过学生开始混合这些组件,结果陷入了某种编程地狱。如果您的逻辑依赖于特定的图形状态,您基本上就完蛋了。例如:激光是否当前正在发射,不应取决于当前的绘制状态。激光的图像是否已经显示出来并不重要。它要么发射了,要么没有。

另一方面,对象可能已经超出了我们的逻辑范围,但仍在绘制。考虑微小的图形效果,如灰尘、爆炸或其他效果。相应的对象已经从逻辑中移除。它不能再交互了。然而,它的残余仍然可见。

逻辑管理器与视觉效果分开运行也很重要。游戏总是希望尽快重绘屏幕。也许人们希望由于某种节能的愿望而降低帧速率,但这可以通过限制 GPU 进行硬件加速图形处理或 CPU 进行(部分)软件渲染来自然实现。不用说,这些决定不应对逻辑产生任何影响。因此,我们希望游戏在 1 GHz 的 Intel i3 CPU 和 3 GHz 的 Intel i7 CPU 上具有相同的逻辑。操作系统负责在固定的时间间隔内向我们提供回调。

逻辑负责各种任务:

  • 管理对象,称为实体。这包括添加、删除或检查实体是否仍然存活。
  • 更新每个实体的逻辑。
  • 管理控制器,例如键盘和触摸。
  • 检查实体之间的碰撞。
  • 提供与资源管理的桥梁,以强制分离。

最后一点似乎与逻辑与资源管理分离的总体概念相矛盾。然而,我们将看到这对于分离至关重要。

这种分离只有在存在一个通用管道将数据传递给独立的工件单元时才有效。但在我们这里,这并不那么容易实现,因为需要这样一个管道以渲染引擎的速度或逻辑引擎的速度工作。

如果此管道以渲染引擎的速度运行,它将一直运行。然后我们将无法执行逻辑引擎(至少不是每次执行)。另一方面,如果管道以逻辑引擎的速度运行,逻辑将需要等待渲染完成。此外,渲染将不是尽可能快的,可能会导致玩家体验不满意。

因此,整个管道方法不幸地是不可行的。我们有两个管道——一个可以称为逻辑引擎,另一个可以称为图形或渲染引擎。这些引擎从哪里获取数据?嗯,我们可以使其中一个自给自足。当然,我们选择逻辑引擎,因为我们更喜欢逻辑而不是显示。

本质上,这种方法使渲染引擎依赖于逻辑引擎——但仅限于逻辑引擎的数据。我们可以将这种依赖性降到最低,但它确实存在。因此,我们的逻辑引擎也需要一种直接与图形引擎通信的方式。所有这些都已集成到 Quantum Engine 的设计中。

游戏

游戏包含一组可用的模式。原则上,添加另一种模式非常容易。最初,我们提供了以下模式集:

  • 单人战役
  • 多人游戏(如死亡竞赛)模式
  • 团队模式

战役模式包含一个非线性故事情节,其中某些行动和决定会影响即将到来的任务或改变故事情节路径。尽管设计这样的系统需要大量额外的工作(需要更多的关卡,根据某些条件改变任务目标等的逻辑),但它绝对会在乐趣和游戏灵活性方面得到回报。

战役还允许引入带有基于文本的简报的简短任务目标。所有这些场景都可以在引擎中处理,不需要任何 hack 或变通方法。以下屏幕截图显示了这样一个简报的实际应用。

Quantum Striker Campaign

在 Quantum Striker 中,玩家驾驶的飞船拥有有限的生命值、护盾和弹药。飞船可能携带一组可以使用的炸弹。此外,激光射击的弹药是有限的。主(激光)武器可以升级。

  • 有小行星和 AI 控制的敌舰。虽然小行星可以避开,但 AI 控制的飞船必须被击落。否则,它们很可能会对玩家的飞船造成严重损坏。

规则、图形和声音

一些图形直接基于 XAML,但大多数图形存储为位图。游戏包含多种多样的飞船。例如,在以下屏幕截图中,我们看到一艘无人机母舰(右侧,基本上是已知的“博格立方体”)、球形无人机(看起来像小行星,但有生命值条)和标准无人机。

Quantum Striker Ships

玩家的飞船仅以位图图形的形式提供。为了支持多名玩家,这种位图图形有多种颜色(红色、绿色、黄色、蓝色……)。完全使用 XAML 来实现是可能的,但最终,位图的细节水平和简单性是我们坚持位图方法的原因。

尽管如此,对于玩家的 HUD(抬头显示器),我们希望显示一个富有表现力的图形,基本上是飞船的描绘。在此图形中,我们显示当前的弹药、工作的系统和可用的炸弹。此图形应以玩家的颜色显示。

由于此图形基本上只是一条带有可变信息的线,因此选择 XAML 作为我们的实现语言是一个显而易见的决定。因此,HUD 定义如下:

<UserControl x:Class="QuantumStriker.Xaml.Hud"
                xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                Height="120"
                Width="200">
  <Viewbox>
    <Canvas Height="150" Width="250">
      <Path Data="M0,0 L50,0 L50,20 L70,35 L90,15 L160,15 L180,50 L230,50 L230,55 L250,55 L250,65 L230,65 L230,70 L180,70 L160,105 L90,105 L70,85 L50,100 L50,120 L0,120 L0,90 L20,90 L20,75 L0,75 L0,45 L20,45 L20,30 L0,30 Z"
               x:Name="HullFrame"
               Stroke="SteelBlue"
               StrokeThickness="1"/>
      <Path Data="M100,40 L110,30 L140,30 L150,50 L150,70 L140,90 L110,90 L100,80 Z"
               x:Name="CoreFrame"
               Stroke="DarkGray"
               StrokeThickness="1"/>
      <Path Data="M110,50 L140,50 L140,70 L110,70 Z"
            x:Name="CockpitFrame"
            Stroke="DarkGray"
            StrokeThickness="1"/>
      <Image x:Name="Battery"
                Canvas.Left="109"
                Canvas.Top="44"
                Width="32"
                Height="32" />
      <Path Data="M60,55 L60,65 L35,80 L45,65 L45,55 L35,40 Z"
               x:Name="BombFrame"
               Stroke="LightGray"
               StrokeThickness="1" />
      <Path Data="M180,50 L230,50 L230,55 L250,55 L250,65 L230,65 L230,70 L180,70 L190,60 Z"
               x:Name="LaserFrame"
               StrokeThickness="0" />
      <TextBlock Text="0"
                    FontFamily="../../Fonts/#Acknowledge TT BRK"
                    x:Name="BombText"
                    FontSize="28"
                    TextAlignment="Center"
                    Foreground="SteelBlue"
                    Width="30"
                    Canvas.Left="67"
                    Canvas.Top="45" />
      <TextBlock Text="1000"
                    FontFamily="../../Fonts/#Acknowledge TT BRK"
                    x:Name="AmmoText"
                    FontSize="28"
                    TextAlignment="Right"
                    Width="60"
                    Foreground="SteelBlue"
                    Canvas.Left="190"
                    Canvas.Top="80" />
      <TextBlock Text="The main reactor is broken."
                    x:Name="Message"
                    FontWeight="Light"
                    FontSize="16"
                    TextAlignment="Center"
                    Width="250"
                    Foreground="SteelBlue"
                    Canvas.Left="0"
                    Canvas.Top="125" />
    </Canvas>
  </Viewbox>
</UserControl>

基本上,我们使用 Viewbox 来提供可伸缩性。UserControl 然后使用 Canvas 来绘制一条将构成飞船轮廓的路径。各种文本块和图像放置在有趣的位置。我们不使用任何绑定(所有内容都将在后台代码中设置,并且我们的游戏引擎不知道 WPF),并且信息只能在更新步骤中更新。这将由游戏引擎完成,这就是我们绕过 MVVM 绑定的原因。

游戏的布局设计方式是,控件将显示在屏幕上。因此,触摸控件供使用触摸屏的玩家使用——并且它们以半透明的方式呈现。HUD 也以某种透明度显示,以尽可能不显眼。

下一个屏幕截图说明了多人比赛的开始。在这里,我们选择了一个包含 4 名玩家的场景。目前这是最大数量,可以通过实现基于 TCP/IP 或网络连接的多人游戏模式来增加。

Quantum Striker Multiplayer

我们看到标准的触摸控件包括两个射击按钮(左侧,左按钮射击激光,右按钮是弹出炸弹按钮)和一个类似位置的区域。后者可以与手指一起使用。在这种情况下,它基本上代表了一个操纵杆/加速圆。通常,此面板用于放置一种特殊的触摸操纵杆。

这个操纵杆面板以及其他触摸控件再次使用纯 XAML 设计。面板的 XAML 代码在下一个代码片段中给出。

<UserControl x:Class="QuantumStriker.Xaml.JoystickPanel"
                xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                Height="100"
                Width="100">
  <UserControl.Resources>
    <SolidColorBrush x:Key="Inner"
                        Color="#55CCCCCC" />
    <SolidColorBrush x:Key="Outer"
                        Color="#AABBBBBB" />
    <SolidColorBrush x:Key="Line"
                        Color="#AAFF0000" />
  </UserControl.Resources>
  <Grid>
    <Ellipse Margin="0"
             StrokeThickness="4"
             Stroke="{StaticResource Outer}">
      <Ellipse.Clip>
        <GeometryGroup>
          <PathGeometry>
            <PathFigure StartPoint="10,0" IsClosed="True">
              <LineSegment Point="50,50" />
              <LineSegment Point="90, 0" />
            </PathFigure>
          </PathGeometry>
          <PathGeometry>
            <PathFigure StartPoint="0,10" IsClosed="True">
              <LineSegment Point="50,50" />
              <LineSegment Point="0, 90" />
            </PathFigure>
          </PathGeometry>
          <PathGeometry>
            <PathFigure StartPoint="10,100" IsClosed="True">
              <LineSegment Point="50,50" />
              <LineSegment Point="90, 100" />
            </PathFigure>
          </PathGeometry>
          <PathGeometry>
            <PathFigure StartPoint="100,10" IsClosed="True">
              <LineSegment Point="50,50" />
              <LineSegment Point="100, 90" />
            </PathFigure>
          </PathGeometry>
        </GeometryGroup>
      </Ellipse.Clip>
    </Ellipse>

    <Ellipse Margin="20"
                Fill="{StaticResource Inner}" />
    
    <Line X1="7" Y1="7"
             X2="23" Y2="23"
             x:Name="Line1"
             Stroke="{StaticResource Line}" />
    
    <Line X1="93" Y1="93"
             X2="77" Y2="77"
             x:Name="Line2"
             Stroke="{StaticResource Line}" />

    <Line X1="93" Y1="7"
             X2="77" Y2="23"
             x:Name="Line3"
             Stroke="{StaticResource Line}" />

    <Line X1="7" Y1="93"
             X2="23" Y2="77"
             x:Name="Line4"
             Stroke="{StaticResource Line}" />
  </Grid>
</UserControl>

声音有点复杂。基本上,我们在音效(如激光射击)和背景音乐之间进行区分。效果存储为波形文件,而背景声音存储为 MP3 文件。这只是合乎逻辑的,因为效果非常小,播放它们应该尽可能直接。任何解码工作都将是开销,尤其是因为文件大小不会有太大变化。背景声音的情况并非如此。在这里,大小至少会高出 10 倍(取决于所选比特率)。

播放声音/音乐的实现是特定于平台的。因此,我们使用一个抽象层,该层仅定义声音系统的外观。然后,特定于平台的层创建一个该系统的具体实现。

对于 WPF,我们将声音系统的实现基于众所周知的 NAudio 库。这是一个广泛的、几乎完整的用于播放、录制和处理声音的库。我们只对解码(mp3)和播放(所有内容)声音流感兴趣。

对于 Windows 应用商店应用,我们使用 SharpDX。它为 XAudio 提供了一个很好的抽象层,XAudio 是 Windows (8) API 的一部分,可以通过 DirectX 访问。

有关游戏的更多信息可在下面的图片中找到。点击可打开屏幕截图的原始分辨率。

Quantum Striker info

逻辑和物理

引擎已经定义了什么是实体,以及任何实体应包含的基本属性。尽管如此,除了代表与(2D)游戏世界的基本连接,并包含对象当前的(存活、死亡)状态之外,它并没有真正表达任何关于给定对象的特殊内容。

因此,我们需要从实体派生来定义我们正在处理的对象类型。幸运的是,引擎已经提供了有趣的继承起点。例如,OrientableObject 包含使对象可定向的所有内容。

如果我们对给定的类不满意,我们也可以从头开始。最终,重要的是我们是否实现了正确的接口。在我们的例子中,我们开始继承自 OrientableObject 来创建另一个重要的基类,称为 MoveableObject。它通过一种速度来扩展方向。然后,这种速度在每个更新步骤中都会被添加到位置。

此外,我们还包含 AngularVelocity。在这里,我们利用 OrientableObject 基类提供的 Orientation 属性。

abstract class MoveableObject : OrientableObject
{
  // ...

  public override void Update(IGame g)
  {
    base.Update(g);
    Location += Velocity;
    Orientation += AngularVelocity;
    
    if (collisionCoolDown > 0)
      collisionCoolDown--;
  }
}

继续,我们可能会为我们的几个实体特化这个基类。实体的一个特殊组是所有飞船的组。这里有额外的特化。因此,为这个组拥有一个公共基类也是有意义的。

我们将这个基类称为 ControlledShip,并实现几个接口,这些接口也可能用于装饰其他(非飞船)实体。其中一些接口也特别值得游戏引擎关注。例如,IDamageable 告诉游戏引擎该对象可以被实现 IDamaging 接口的其他对象击中。

这基本上是整个碰撞模型的基础,该模型在引擎中自动运行。飞船抽象的实现代码如下:

abstract class ControlledShip : MovableObject, IDamageable, IDamaging, IInterestedInStatistics
{
  // ...

  public override void Update(IGame g)
  {
    base.Update(g);
    _laser.Update(g);
    _alternative.Update(g);
    _engineering.Update(g);
    var effectiveDamperQuality = _damperQuality * _engineering.InertialDamper.Performance;
    ExerciseControl(g, effectiveDamperQuality);
    var vN = Velocity.Norm();
    UseStabilisator(effectiveDamperQuality, vN);
    ImposeSpeedLimit(vN);

    if (HealthPoints <= 0)
    {
      g.PlayEffect(AudioDb.Effects.Explosion);
      g.Register(ExplosionFactory.Create(this, ExplosionFactory.ExplosionType.Explosion2));
      IsGarbage = true;
    }

    if (revengeTimer > 0)
      revengeTimer--;
    else if (revengeTimer == 0)
      HitDirection = null;
  }
}

每个特化,如 DroneShipPlayerShip,都从给定的基类派生。正如我们在 Update 方法的特化中看到的,我们使用更新步骤来保持其他对象(如飞船的工程系统和武器)的最新状态。

一个非常有趣的特化是企业飞船。它比大多数其他飞船都要大得多,难以控制,并且在战役中以 AI 控制的形式使用。它还提供更强大的武器。

因此,飞船还有一个特殊的图形,基本上就是你所期望的。

Quantum Striker Enterprise

紫色的激光射击由特化模型发射。另一个特化是通用玩家飞船。这个类还实现了对游戏(非游戏引擎)重要的几个接口。这里我们有以下代码:

sealed class PlayerShip : ControlledShip, IDroneTarget, IPlayerShip
{
  public override void Update(IGame g)
  {
    base.Update(g);

    if (HealthPoints <= 0)
    {
      PlayerStatistic.IncrementPlayersRecursively(LastCollisionObject, LastCollisionObject);
      Statistic.Deaths++;

      if (Alternative.Ammo > 0)
      {
        g.Register(new BombItem
        {
          Location = this.Location,
          AngularVelocity = this.AngularVelocity
        });
      }
    }
  }
}

IDroneTarget 接口被无人机 AI 用来确定哪些对象应被视为目标。IPlayerShip 基本上只是一个指示符,表明该类是否可用于实例化一个完全由玩家控制的对象。

基于相同的理由,创建了另一个名为 IDroneShip 的接口。因此,我们可以动态创建玩家和无人机飞船工厂。这可以用于,例如,一个有趣的游戏模式,其中每个玩家都可以选择要使用的飞船,和/或(随机)必须抵御相同敌舰的波次。

平台考虑

让我们回顾一下本文的主题。我们想创建一个跨平台游戏。在这两种情况下,相关的平台是 Windows(通过使用 WPF)和 Windows 应用商店。对于这两个平台,我们都选择 C# 作为我们的编程语言。虽然不要求在所有平台上使用相同的语言,但它大大简化了代码共享。否则,我们可能会受到不同代码片段之间 ABI 的限制。但是,ABI 是高度平台特定的,并且可能是提供跨平台功能的最低优先级方式。

通常,我们希望能够与我们的代码片段通信,并将(我们所有的代码)编译到特定平台。这确保了一致性,并最终会产生最简单、最可靠的创建跨平台项目的方式。否则,我们将永远怀疑我们的更改是否会破坏某些东西。

什么可以共享?

当然,代码共享存在限制。最终,两个平台有多么不同并不重要。如果我们处理两个平台,我们总是需要对系统调用进行封装。POSIX 兼容是例外,但由于我们处理的是 .NET 应用程序,我们可以忽略这种例外。此外,我们已经拥有 .NET Framework 这个强大的封装器。

但等等!既然 .NET 已经为我们做了所有事情,为什么还要长篇大论地谈论代码共享呢?好吧,那将是一个梦想。但现实更接近于噩梦。理论上,基本抽象是共享的。例如,我们可以创建一个使用 System.IO 命名空间中的类来读取和写入文件的控制台应用程序。在 Mono 编译器下,在 MacOS 和任何 Linux 发行版上编译和运行此代码都可以正常工作。这我称之为可移植。

然而,一旦我们引入沙箱和专用环境的概念,即使是上面的例子也可能无法正常工作。在某些平台上,无法直接访问文件系统。因此,这样的抽象可能不存在。这就是*可移植类库*的概念。它基本上确定了适合给定平台集合的最低公分母。因此,如果我们选择 4 个平台,而其中一个平台根本无法直接访问文件,那么 PCL 项目也将无法实现此功能。

这个概念是通用的。它基本上决定了什么可以共享。在给定平台集合中通用的任何东西都可以共享。其他所有内容都必须通过下一节中提到的技术来引入。

当然,我们可以尝试进行分类。在我看来,有三个部分决定了可以共享多少代码。

Sharing Categories

UI 可能是最常见的部分。即使一个框架为一组平台提供了相同的抽象,从长远来看,通常明智的做法是避免共享 UI 代码。平台不仅在外观上不同,在使用感受上也不同。因此,任何导致不同平台具有相同外观和感觉的决定,最终都会输给提供平台特定解决方案的其他应用程序。

IO 也可能难以共享。有时没有抽象,甚至没有可访问的 UI 功能。那么我们也可能无法共享任何代码。即使我们可以共享代码,我们也应该在一个通用基础上工作,即只处理通用的 FileStreamStream。根据平台,输入可以更具体。

最后是库和给定的框架。这通常是最容易的部分。例如,如果给定的平台支持 .NET 4.5,我们就可以使用 async / await。如果平台甚至不支持 .NET 4.0,我们就根本无法使用 Task。有变体、排除项、子集等等。最终,我们必须确定我们想要使用什么,以及我们必须使用什么。有时(重新)创建这些类并不难。有时有可用的 NuGet 包。最重要的是,始终有一个大致的计划,说明将需要什么。

代码共享技术

有许多共享技术。Visual Studio 与 C# / .NET 的结合为代码共享提供了绝佳的基础。例如,考虑 C++ 代码,我们已经处于一个非常舒适的区域。

我们将首先更仔细地研究 PCL 的概念。这个概念是实现通用应用等解决方案的驱动力,但也帮助我们为 WPF 和 Windows 应用商店创建共享基础。

接下来,我们将从纯代码共享开始。然后,我们将讨论更多有用的技术,这些技术适用于纯代码共享(即在所有平台上完全支持给定代码段)不可能实现的情况。

可移植类库

可移植类库 (PCL) 是一种特殊类型的项目,旨在帮助开发人员创建可以从各种平台引用的库。该库是我们的共享代码模型。最终,我们将尝试将尽可能多的代码放入可移植类库,因为我们可以从大量平台轻松地定位它。

在本篇文章的情况下,我们只使用了两个平台:WPF(.NET 4.5)和 Windows 应用商店(8.1)。这限制了 .NET 的子集并包括其他方法。一般来说,我们可以说,我们尝试用 PCL 定位的平台越多,可用的 .NET Framework 子集就越小。

Portable Class Library

上面图片中 .NET Framework 的子集由交集表示。*N* 个平台的交集永远不会比 *N - 1* 个平台的交集包含更多元素。这可以一直持续到我们发现它几乎肯定会有一个自然的限制,与仅支持单个平台相比。

以 PCL 作为基础消除了几乎所有即将出现的技术。但是等等!那为什么它们仍然列在这里呢?因为最终我们将专门定位某个平台。没有可移植应用程序项目。因此,特化必须在某个阶段包含在内。当然,这些特化应该尽可能灵活和易于维护。因此,我们需要一些技术来实现高效的代码共享和一些优雅的准备。

一个重要的工具是能够直接链接 Visual Studio 项目中的文件。通常,当我们向项目添加项时,Visual Studio 会复制原始文件或创建一个新文件。这不是我们想要的。如果我们有两个文件的副本,我们需要对这两个文件进行相同的更改。有时我们只想共享文件的一部分跨越多个项目,而某个部分被特化。这将在下一节中介绍。

但是,在我们讨论这项技术之前,我们应该弄清楚如何有效地在各个项目之间共享通用部分。最优雅的方法是创建对现有文件的链接。因此,我们打开一个新对话框,将现有项添加到所需项目中:

Add Existing Item

请注意,仅仅双击一个文件将导致创建文件的物理副本。这不是我们想要的。我们必须明确选择**“添加为链接”**。现在我们只是在项目中添加了对文件的引用,也就是说,在给定位置没有物理文件。链接文件对于在多个项目之间共享代码非常重要。

现在我们知道了如何链接文件来共享代码,让我们看看如何使其非常高效,以至于每个平台只需要最少的更改。

部分类

部分类的想法很简单。为了避免有一个文件包含单个类,可能有数千行,我们可以将该类拆分成保存在不同文件中的部分。最初,它是为了解决混合设计器生成的代码和用户代码的问题而引入的。最后,它能够以一种不显眼的方式隐藏自动生成的代码。

然而,这个概念可以用来减少复制/粘贴,因此减少维护一个部分代码可共享,部分代码不可共享的类的麻烦。我们从以下结构开始:

//MySharedClass.cs
public class MySharedClass
{
  /* Code that can be shared */

  /* Code that cannot be shared and must be (re-)implemented for (each or at least some) platforms */
}

现在当然我们需要添加 partial 关键字。此外,我们应该创建特定于平台的局部实现。

//MySharedClass.cs
public partial class MySharedClass
{
  /* Code that can be shared */
} 

//MySharedClass.WPF.cs
class MySharedClass
{
  /* Specific code for WPF */
}

//MySharedClass.WindowsStore.cs
class MySharedClass
{
  /* Specific code for Windows Store */
}

现在唯一的问题是:这些文件放在哪里?有很多可能的答案。如果我们对如何组织我们的代码有强烈的意见,我们可以过滤这些答案。让我们考虑:

  1. 所有平台同等重要。
  2. 一个平台比其他平台更重要。
  3. 有更重要的平台和更复杂的平台。但这是混合的。

在第一种情况下,我们可能希望创建一个解决方案文件夹来包含所有部分类文件。每个项目现在都获得对此部分类文件的链接,以及其自己的特定代码部分的实现,即一个物理文件,该文件扩展了链接的部分类文件。

在第二种情况下,我们可以首先在一个平台上创建所有内容。然后,其他平台将获得对位于更重要平台项目中的部分类文件的链接。然而,每个项目仍然会有一个物理文件,其中包含扩展部分类文件的特定代码。

最后,在混合场景中,我们可能希望遵循第一种场景中演示的方式,或第二种场景的方式(但针对每个项目)。当然,第一种方式可能更有条理,然而,第二种方式可能更直接。最终,我们不应该混合这两种方法。这意味着我们不应该有一个共享文件夹,但仍然在特定项目内部以物理方式提供一些共享实现。我们要么遵循“源自拥有物理”的模式,要么遵循“共享内部共享”的方法。

扩展方法

扩展方法是真正的宝藏。如果您不相信我,请考虑以下几点:是什么使 LINQ 成为可能?是 lambda 表达式吗?优雅,但好吧,我可以传递现有函数的引用或使用 delegate 语法创建匿名函数。它是用 C# 编写类似 SQL 的代码的可能性吗?我个人不喜欢它,也从不使用它。我看到的唯一优点是使用 let 关键字可以更轻松地声明中间变量,但是我通常手动完成。这就回到了扩展方法。没有它们,我们将编写洋葱式的 LINQ 代码,看起来会像这样:

var dataSource = new [] { 1, 2, 3, 4, 5, 10, 15, 20, 26 };
var querySet = SomeClass.Take(SomeClass.Select(SomeClass.Where(dataSource, m => m % 5 == 0), m => m * m), 3);

在这里,我只是假设所有这些方法都可以在一个名为 SomeClass 的类中找到(在通常的 System.Linq 命名空间中)。我知道您可以猜测该调用做什么,但是,我也确信您需要几秒钟才能完全理解代码。

让我们看看使用扩展方法时情况如何变化:

var dataSource = new [] { 1, 2, 3, 4, 5, 10, 15, 20, 26 };
var querySet = dataSource.Where(m => m % 5 == 0).Select(m => m * m).Take(3);

洋葱现在是一个漂亮的管道。我们从左开始,在右边结束。我们可以像阅读句子一样阅读它。这真是太棒了。我现在不是从左到右开始,而是 SomeClass 类完全缺失。我们不必知道它的名字。这对重构来说非常棒。提供这些方法的类的名称是否重要?不。只有命名空间很重要,并且它已包含在编译过程中。

这种能力对于共享代码来说非常有用。但在我详细介绍之前,我必须警告您。使用扩展方法共享代码存在限制。其中大部分限制来自*方法*这个词。如果我们处理属性、具体类名或其他特殊情况,我们就完蛋了。

让我们以反射为例。在以下代码片段中,我们只想找出给定的 Type 实例是否是另一个 Type 对象的子类。通常我们有:

var result = typeof(FirstClass).IsSubclassOf(typeof(SecondClass));

该代码片段已在 .NET 中实现(例如,版本 4.5)。但是,一旦我们尝试在 Windows 应用商店应用程序上运行此代码片段,我们就会发现它不起作用。反射(开箱即用)只提供有限的可能性。使用 System.Reflection 命名空间中的 GetTypeInfo() 扩展方法可以访问更多内容。

var result = typeof(FirstClass).GetTypeInfo().IsSubclassOf(typeof(SecondClass));

这给我们两种选择:

//First possibility:
public static bool IsSubclassOf(this Type origin, Type compare)
{
  return origin.GetTypeInfo().IsSubclassOf(compare);
}

//Second possibility:
public static Type GetTypeInfo(this Type origin)
{
  return origin;
}

第一种选择允许我们在 Windows 应用商店应用中使用 WPF 代码。第二种描述了反之亦然的情况。在这里,我们可以使用 Windows 应用商店应用中的代码在 WPF 应用程序中使用。哪个更好?这取决于!两者都有优点和缺点。

让我们从一个简单的观察开始:虽然 Windows 应用商店应用程序中的“真实”GetTypeInfo 方法返回一个 TypeInfo 实例,但我们只使用该方法返回标识,它是 Type 类型。当类型被推断或隐式使用时,这通常会起作用。但是,一旦我们需要 TypeInfo 的特定方法或需要创建具有显式类型的变量,我们就无能为力。此外,这已经说明了一个主要问题:方法可能因其名称、签名或其他属性而异。这实际上是 GetTypeInfo() 方法的原因之一。

拥有像 IsSubclassOf 这样的特定方法只有在签名匹配时才有效。实际上,为所有平台实现扩展方法更有意义。这样,人们就不必担心将扩展方法放在正确的位置。方法存在于每个平台,但为每个平台单独实现。

我们将拥有:

//In the Windows Store project
static WindowsStoreExtensions
{
  public static bool DerivesFrom(this Type origin, Type compare)
  {
    return origin.GetTypeInfo().IsSubclassOf(compare);
  }
}

//In the WPF project
static WpfExtensions
{
  public static bool DerivesFrom(this Type origin, Type compare)
  {
    return origin.IsSubclassOf(compare);
  }
}

现在,共享代码可以使用 DerivesFrom 方法,而无需担心。此外,这种策略允许我们绕过实现细节和属性。代替属性,我们将使用方法。

让我们看一个例子,它再次说明了这个解决方案:

//In the Windows Store project
static WindowsStoreExtensions
{
  public static IEnumerable<PropertyInfo> ListProperties(this Type origin, BindingFlags flags)
  {
    return origin.GetTypeInfo().DeclaredProperties;
  }
}

//In the WPF project
static WpfExtensions
{
  public static IEnumerable<PropertyInfo> ListProperties(this Type origin, BindingFlags flags)
  {
    return origin.GetProperties(flags);
  }
}

尽管在 Windows 应用商店应用程序中通过 TypeInfo 对象获取属性是基于访问属性的,但我们可以隐藏具体实现中的这个细节。然而,我们应该注意到,我们扩展了签名以支持额外的 BindingFlags 参数。这个参数在 Windows 应用商店中不被使用,但我们仍然需要提供它。

这不应该成为默认情况,但有时可能会发生。肯定会发生的是,一个平台比另一个平台拥有更多的功能。这没什么好担心的,除非这些限制会导致不同的行为。一旦我们遇到这种情况,我们就需要调整实现。最终,我们的目标应该是从代码行为的角度在不同平台上获得相似/相同的结果。

接口和面向对象编程

如前所述,跨不同平台共享代码的能力可分为两类。一方面,我们有从上到下的库方法,我们在每个库中进行特化。另一方面,我们有一个并排策略,我们在一个库中开始,并链接到另一个库中的原始源文件。

无论我们做什么,都需要一定的代码结构。如果我们有过于强大的平台依赖性,我们就无法将代码放在更通用的库中。此外,无法仅链接文件(至少在未使用前面和后面的技术之前)。

为了获得适当的结构,我们必须回归 OOP 的原则和模式。通过使用 SOLID(特别是 OCP 和 DIP)原则,我们将构建代码,使其具有足够的抽象和通用性,以适应任一模型(自上而下或并排)。

现在我们看到了为什么接口对于跨平台开发至关重要。一方面,我们遵循 DIP 并将所有内容基于抽象,一个不依赖于细节的抽象。另一方面,我们可以将接口实现到任何我们喜欢的类上——即使我们已经必须实现另一个类。

因此,我们的策略是始终将尽可能多的跨平台(独立)代码吸收到底层类中。可能具有某种第三方依赖性的特化将很小并需要抽象。在这种情况下,我认为 WPF 是第三方依赖项。

游戏引擎自然遵循此路径。尽管渲染需要某种 UI 对象,但它不依赖于 WPF。相反,它只需要 IDrawableEntity 类型的对象。这包含了渲染引擎需要知道的一切。在具体实现中,渲染引擎可能需要进一步的信息,但这并不是通用定义的麻烦。

使用 IOC 容器进行依赖注入

要遵循上一节中描述的模式,我们也应该通过属性或构造函数暴露依赖项。这将减少耦合,因为某个类不需要知道在哪里可以找到某个抽象(接口)的具体实现。

因此,强烈推荐使用服务定位器,甚至更好的是依赖注入系统(使用 IOC 容器)。这基本上通过解析依赖项来自动化对象的创建。

定义

有时所有前面提到的技术都会失败。XAML 代码就是一个很好的例子。虽然 XAML 代码本身在 WPF 和 Windows 应用商店应用程序之间(就仅使用两个平台上都可用的控件的 UserControl 而言)基本上是可移植的,但其代码隐藏部分则不然。

让我们看一下一个非常简单的 XAML 控件的 WPF 版本的代码隐藏:

using System;
using System.Windows.Controls;

namespace QuantumStriker.Xaml
{
  public partial class EnemyShip : UserControl
  {
    public EnemyShip()
    {
      InitializeComponent();
    }

    public void SetHealthBarTo(Double percent)
    {
      HealthBar.Width = 24.0 * percent;
    }
  }
}

这看起来还不错。实际上,大多数人不会意识到需要对这个代码片段进行更改才能在 Windows 应用商店环境中运行。让我们看看 Windows 应用商店的等效代码:

using System;
using Windows.UI.Xaml.Controls;

namespace QuantumStriker.Xaml
{
  public partial class EnemyShip : UserControl
  {
    public EnemyShip()
    {
      InitializeComponent();
    }

    public void SetHealthBarTo(Double percent)
    {
      HealthBar.Width = 24.0 * percent;
    }
  }
}

几乎是相同的代码?是的,确实如此。但有一个非常关键的更改,它阻止了例如部分类技术被应用。我们需要不同的命名空间!哇。谢谢微软!您实际上保留了 95% 的控件相同的名称,但更改了命名空间。干得好!即使这在组织级别上可能是有意义的,但对于编写(或想要共享)代码的人来说,这并没有多大意义。为什么?WPF 控件和 Windows 应用商店控件之间根本不可能发生冲突。然而,这种冲突正是命名空间最初存在的原因。这意味着什么?使用不同的命名空间只是令人讨厌。

但是,让我们看看光明的一面。我们现在有机会引入 #define 预处理器指令。它允许我们定义可以使用预处理器语句(如 #if)进行评估的符号。更好的是,我们还可以在项目设置中全局定义这些符号。最好的方法是:有时这些符号已经定义了。例如,DEBUG 符号,它为默认项目目标*Debug* 定义。

using System;
#if NETFX_CORE
using Windows.UI.Xaml.Controls;
#else
using System.Windows.Controls;
#endif

namespace QuantumStriker.Xaml
{
  public partial class EnemyShip : UserControl
  {
    public EnemyShip()
    {
      InitializeComponent();
    }

    public void SetHealthBarTo(Double percent)
    {
      HealthBar.Width = 24.0 * percent;
    }
  }
}

当然,我们可以为每个项目引入一个符号。但总的来说,使用现有的符号要好得多。幸运的是,我们可以使用 NETFX_CORE 符号,它为 Windows 应用商店目标定义。现在我们可以两个项目中使用完全相同的文件。

完全重写

有时一切都会失败,无法定义通用接口,依赖扩展方法,或者只是使用预处理器切换一些代码块。有时最有效的方法实际上是最不有效的方法,即完全重新实现。

起初听起来很疯狂。但是,如果我们考虑与 UI 相关的问​​题,我们最终会得出这样的结论:不同的平台不仅会提供不同的 UI 框架,它们还将遵循不同的 UI 行为和样式。因此,我们可能仍然需要重新处理我们的大部分(UI)代码。很明显,从头开始比试图模仿之前的工作要好。有充分的理由支持这一说法。

最终,这样的重写可能对用户(更好的体验)和程序员(减少如何共享近乎 100% 不兼容的代码的挠头)都有益。然而,有时在重写之前应该三思……

例如,可能一个人认为不兼容的代码可以通过重写每个平台的各种组件来重新使用。这样,代码就可以被重用,并且(甚至更好),所有其他代码也可以被重用。但是,只有当我们用更小的块构建 UI 时,才可能采用这种方法。而且只有当这些小块几乎独立时。一旦它们需要相互交互,例如通过拖放,我们可能会陷入困境。

WPF 特定

由于我们从 WPF 开始,因此在此平台上(从我们的角度来看)对我们的代码几乎没有限制。然而,总的来说,Windows 应用商店在某些场景下可能更优越(尤其是在触摸方面)。尽管如此,除了某些特殊领域,我们总是可以指望 WPF 提供更多可能性。

如果我们从头开始一个项目,那么将所有内容设计为提供最多限制的平台是有意义的。这样,我们最终会遇到更少的问题。如果我们为限制最少的平台设计,我们将不得不调整我们的应用程序,正如本文所示。

这两种方式都是合理的,但我们为什么要走艰难的路呢?同样,在这种情况下,应用程序已经为 WPF 编写,需要我们调整现有代码。最终,我们可以从中吸取有趣的经验,用于设计和编写跨平台应用程序。

WPF 与引擎的特定连接是通过以下配置文件完成的。

namespace QuantumEngine
{
  public class AvalonConfig : Config
  {
    static AvalonView view;

    protected override IEnumerable<Type> LoadTypes()
    {
      yield return typeof(AvalonSoundManager);
      yield return typeof(AvalonPresentation);
      yield return typeof(AvalonDebugBox);
      yield return typeof(AvalonTimer);
      yield return typeof(AvalonKeyboardInput);
      yield return typeof(AvalonMouseInput);
      yield return typeof(AvalonTouchInput);
    }

    internal static AvalonView View
    {
      get { return view; }
    }

    public static void Register(Window window)
    {
      view = new AvalonView(window);                
    }
  }
}

最重要的是创建 AvalonView 实例,它基本上是基于给定的 Window 实例(因为这是高度 WPF 特定的)。

namespace QuantumEngine
{
  sealed class AvalonView
  {
    public event EventHandler<TouchCollectionEventArgs> Touched;

    Window parent;

    public AvalonView(Window window)
    {
      var source = PresentationSource.FromVisual(window) as HwndSource;
      parent = window;
      DisableWPFTabletSupport();

      if (source != null)
      {
        source.AddHook(WndProc);
        RegisterTouchWindow(source.Handle, 0);
      }
    }

    /* Touch specific native API handling */
  }
}

这个特殊的视图实例然后内部用于将 WPF 特定的控制器(例如 AvalonTouchInput)连接到事件分发器。

Windows 应用商店特定

Windows 应用商店部分确实让人生疑。一个大问题是音频。我们在 WPF 中使用 NAudio 轻松解决了这个问题,但由于 NAudio 依赖于本机 API 和直接流访问,因此它当然不适用于 Windows 应用商店平台。SharpDX 提供了一个自然的替代品。这是一个围绕 DirectX API 的良好封装,可以在 Windows 应用商店应用程序中使用。

然而,尽管此解决方案似乎完全适合我们的需求,但仍有一个遗留问题:SharpDX 无法处理 MP3 文件(至少没有 SharpDX.MediaFoundation)。这是一个巨大的缺点,因为背景声音是以 MP3 格式保存的。然而,如果我们真的要走到这一步,我们只会使用一种压缩且两个平台都可以读取的格式。

Windows 应用商店应用的配置文件与 WPF 的相似。这里我们编写了以下代码:

namespace QuantumEngine
{
  public class MetroConfig : Config
  {
    static CoreWindow view;

    public MetroConfig()
    {
      view = CoreWindow.GetForCurrentThread();
    }

    protected override IEnumerable<Type> LoadTypes()
    {
      yield return typeof(MetroSoundManager);
      yield return typeof(MetroPresentation);
      yield return typeof(MetroDebugBox);
      yield return typeof(MetroTimer);
      yield return typeof(MetroKeyboardInput);
      yield return typeof(MetroMouseInput);
      yield return typeof(MetroTouchInput);
    }

    internal static CoreWindow View
    {
      get { return view; }
    }
  }
}

再次,我们返回一系列由我们的 Quantum Engine(可移植类库)特化提供的特殊类型。虽然这不是真正的服务容器,但这是一种非常非常轻量级且不够灵活的服务定位器。Config 类,如 Quantum Engine(可移植类库)中定义的,知道如何创建这些提供的类型的实例。

在这里,我们不需要定义一种特殊的视图。在 Windows 应用商店应用程序中,总有一个名为 CoreWindow 的特殊类。通过使用静态 GetForCurrentThread() 方法,我们可以获取当前线程的 CoreWindow 实例。由于我们知道此代码部分只会从主线程调用,因此我们可以简单地使用此方法。总的来说,我们将指定对 CodeWindow 的依赖。然后需要另一个方法来解析依赖项。

然后,Windows 应用商店平台的控制器实现可能会使用此。以下代码是触摸控制器的实现。

namespace QuantumEngine.Controller
{
  sealed class MetroTouchInput : TouchInput
  {
    public MetroTouchInput(IGame game)
    {
      MetroConfig.View.PointerPressed += PointerHandler;
      MetroConfig.View.PointerReleased += PointerHandler;
      MetroConfig.View.PointerMoved += PointerHandler;
      Game = game;
    }

    void PointerHandler(CoreWindow sender, PointerEventArgs e)
    {
      if (IsCaptured && e.CurrentPoint.PointerDevice.PointerDeviceType != PointerDeviceType.Mouse)
      {
        var pos = e.CurrentPoint;

        if (pos.IsInContact)
          _touchPoints[(Int32)pos.PointerId] = new QPosition(pos.Position.X, pos.Position.Y);
        else
          _touchPoints.Remove((Int32)pos.PointerId);

        e.Handled = true;
      }
    }
  }
}

该控制器基本上使用 CoreWindow 来注册一个侦听器,用于最基本和最基础的触摸事件。由于我们使用 CoreWindow,我们还可以确保在任何情况下都能处理该事件。

使用代码

当初始版本完成后,解决方案中的项目数量可以用一只手数过来。原始解决方案布局如下所示:

After finishing the app

当然,支持两个不同平台的平台考虑使一些项目加倍,并引入了共享项目。最终,解决方案发生了变化,看起来如下:

After introducing another platform for the engine

不用说,给定的图片只展示了一半的故事。这里我们只是为引擎提供了另一个平台。很明显,其他部分也可能加倍。最终,我们有 10 个项目,而我们开始时是 5 个。所以我们为引擎增加了 2 个项目,为应用程序增加了 3 个项目。

Final solution explorer

我们从一个与输入层项目紧密耦合的引擎开始。第一步是解耦这种结构。这分三步完成:

  1. 将引擎设为 PCL(目标是 Windows 应用商店应用程序和 .NET 4.5 框架)。
  2. 引入接口和其他共享技术,以便可以以不同方式实现代码。删除对输入层的依赖。
  3. 创建另外两个项目来提供特化。一个项目目标是 Windows 应用商店应用程序,另一个项目目标是 .NET 4.5。后者必须再次引用输入层。

现在我们已经是平台独立的了,因为我们只需要创建另一个项目,引用特化引擎(在这种情况下是 Windows 应用商店),就可以了。然而,这将是一场噩梦,因为应用程序还包含大量代码,例如敌人、飞船、故事……等等。与其重新创建或复制代码,我们使用诸如链接其他代码、使用定义或扩展方法之类的技术。

大量新创建的项目相当不寻常。在一个理想的世界里,ImagesSounds/ 项目应该是可移植的。这些项目只是嵌入资源。然而,资源管理从 .NET 应用程序更改为 Windows 应用商店应用程序。这真的很不幸,因为我认为旧方法要透明、舒适和可扩展得多。我的字节流现在在哪里?但是,我们可以共享底层的资源(即图像和声音),但不能共享项目。我们需要创建两个单独的项目,一个用于 Windows 应用商店,一个用于 WPF,来提供资源。

兴趣点

提供的源代码包含完整的 Quantum Engine 和大部分资产。我只删除了音轨。原因很简单:我不想上传几十 MB 仅仅是为了提供一些可能根本不有趣的音轨。我还删除了除对战模式以外的所有游戏模式。

排除这些游戏模式的原因是作为 Windows 应用商店应用程序的可能发布流程。目前我们正在考虑重新设计部分内容,并将游戏作为免费下载发布到 Windows 应用商店。如果我们真的发布了该应用程序,我们可能会包含一个网络模式。但这不构成首次提交的标准。

历史

  • v1.0.0 | 初始发布 | 2014 年 7 月 13 日
  • v1.0.1 | 修复下载包 | 2014 年 7 月 14 日
  • v1.0.2 | 修复了一些拼写错误 | 2014 年 7 月 16 日
© . All rights reserved.