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

射击游戏 .NET

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (73投票s)

2013年11月1日

CPOL

25分钟阅读

viewsIcon

78424

这是一个关于如何使用声明式动画在.NET中创建射击游戏的小型教程。

引言前

如果您只想下载代码并查看发生了什么,那么重要的事情是:

  • 有一个用于 Silverlight 5 的解决方案和一个用于 WPF 的解决方案,但大多数单元都由两者简单地使用;
  • 游戏使用方向键移动,空格键射击,以及 EscPEnter 暂停;
  • 游戏有三个级别。在级别之间,您可以为您的飞船获取升级,所以如果您认为初始飞船太弱,那是有意为之。

您可以通过打开此链接玩 Silverlight 游戏:http://paulozemek.azurewebsites.net/ShootEmUpTestPage.2013_10_10.html

或者您可以点击下一张图片下载 WPF 版本

引言

我知道许多程序员都喜欢编写游戏。我自己开始编程是因为我想创建游戏,即使我已经编写了一些游戏,我大部分时间都在处理系统并解决与数据库连接和更好的缓存机制相关的问题。

我认为编写游戏的一个问题是材料的缺乏。就我个人而言,我通常寻找图形和声音,因为这些是我无法做到的事情。然而,我认为普遍存在游戏材料的缺失。很难找到游戏教程,当我找到一些东西时,我只看到使用 XNAUnity 编写游戏的材料。关于如何使用 WPFSilverlight 或其他类似技术编写游戏的材料几乎没有。

考虑到我通常使用这些技术编写游戏,我认为提出一个使用非游戏特定技术编写游戏的解决方案将是一个好主意。

为什么不使用 XNAUnity

我不用 XNAUnity 编写游戏的想法是想表明您可以使用普通的UI技术来编写游戏,这在许多情况下简化了工作,因为您无需关心如何将事物渲染到屏幕或重新创建布局控件。此外,通过这样做,您将能够在不安装此类库/引擎的情况下创建游戏,并且您甚至可以为 Windows 8 编写游戏,因为使用 C# + XAML(这是我将在本文中使用的)是 SilverlightWPFWindows 8 应用程序的特点。

声明式动画

大多数关于游戏的文章都说我们必须处理一个“游戏循环”,在每个帧或时间间隔移动物体(如玩家飞船和敌人)。即使在非 XNAUnity 特定文章中也这样说,而且这确实是大多数游戏的编写方式,但这并非必须如此。

通过观察 WPFSilverlight 都使用的 Storyboard,可以发现动画可以是声明性的,而游戏主要由动画构成。那么,我们为什么要关心游戏循环呢?

最令人信服的原因可能是互动性。声明式动画不具有互动性。它们更新值,唯一输入是时间。好吧,至少 WPFSilverlightStoryboard 是这样的,但这并不意味着您不能拥有可以对外在因素做出反应的声明式动画。

出于这个原因以及其他一些原因,我最近编写了一个专门用于动画的库,我在这里介绍了它。在这个库中,声明式动画仍然是用 C# 而不是 XAML 编写的,它使用与常见声明式代码非常相似的 Fluent API,但它允许使用委托来构建部分动画或验证某些条件,这是我们在用户按下某些键时移动角色所需的一切。

有了这个库,就可以编写以下代码来控制玩家角色的移动

AnimationBuilder.
BeginParallel().
  BeginLoop().
    BeginPrematureEndCondition(() => HorizontalMove >= 0).
      RangeBySpeed(() => Canvas.GetLeft(this), 0, 120, (value) => Canvas.SetLeft(this, value)). 
    EndPrematureEndCondition().
  EndLoop().
  BeginLoop().
    BeginPrematureEndCondition(() => HorizontalMove <= 0).
      RangeBySpeed(() => Canvas.GetLeft(this), canvas.Width-Width, 120, (value) => Canvas.SetLeft(this, value)). 
    EndPrematureEndCondition().
  EndLoop().
  BeginLoop().
    BeginPrematureEndCondition(() => VerticalMove >= 0).
      RangeBySpeed(() => Canvas.GetTop(this), 0, 120, setTop). 
    EndPrematureEndCondition().
  EndLoop().
  BeginLoop().
    BeginPrematureEndCondition(() => VerticalMove <= 0).
      RangeBySpeed(() => Canvas.GetTop(this), canvas.Height-Height, 120, setTop). 
    EndPrematureEndCondition().
  EndLoop().
EndParallel();

最棒的是,这个库不会改变您编写的应用程序类型。您可以继续创建 WPF 应用程序、Silverlight 应用程序、Windows Forms 应用程序、控制台应用程序或任何您想要的应用程序,甚至是 XNA 应用程序。这个库真正需要的只是一个“事件”,该事件触发得足够快以播放动画(事实上,如果您想录制一个具有“一致时间”的视频文件,您甚至可以在调用动画时伪造这个事件)。

所以,如果您担心应该使用外部库来编写所呈现的游戏,请不要担心。它确实是一个库,但可以添加到任何应用程序中,并且,由于您将收到源代码,您可以只将您使用的部分添加到您的应用程序和游戏中。

编写代码前

本文将解释编写游戏的重要步骤。然而,仅仅复制这些代码块不足以构成一个游戏。事实上,要开始,您需要创建一个新项目(SilverlightWPF)并添加对动画库的引用(无论是 dll 还是其源代码)。此外,一些图像、声音和其他文件并未作为文章本身的一部分呈现,但您可以通过下载最终应用程序来验证此处呈现的所有步骤确实都在使用中。

1. 星星

我已经说过,我通常寻找图形,因为我不是设计师,而游戏需要好的设计。有时,仅仅用一个更好看的角色替换一个写得不好的角色,游戏就会显得更真实、更有趣。

最初我不确定是要写一个太空射击游戏还是一个平台游戏,但由于我没有找到好看的背景,我决定选择射击游戏,因为我能够创建它的背景。事实上,我开始编写背景是因为我想检查它是否可以接受。

为了拥有背景,我只需要星星,所以我立即创建了一个 Star 用户控件。它唯一的内容是一个 Ellipse,其真实大小由代码定义。在它的代码中,Intensity 决定了它的颜色(也许全白色也可以,但我想要一些小的差异)以及它下降的速度。为了制造飞船始终向前移动的错觉,实际发生的是只有星星向底部移动。

最初的 Star 动画是一个简单的 Range,从其当前位置到屏幕外的底部,通过在随机位置创建一些星星,我得到了看起来像雪的东西,因为星星会移出屏幕,并且没有新的星星出现。

我考虑了许多可能的解决方案来让新的星星出现,但我选择伪造它。每次一个 Star 移出屏幕时,它都会被重新定位到顶部(也在屏幕外),并计算一个新的水平位置和 Intensity。所以,我可以简单地重新启动动画(也就是说,把它放在一个 Loop 中),看起来好像新的星星一直在出现。而且,由于也计算了新的位置、大小甚至顶部的一个小小的屏幕外值,所以你不会看到一个从底部消失的星星立即在顶部重新出现。

最终代码如下

public IEnumerable<IAnimation> CreateAnimation()
{
  var canvas = MainPage._instance.canvas;
  int width = (int)canvas.Width;

  canvas.Children.Add(this);

  while(true)
  {
    yield return 
      AnimationBuilder.
      RangeBySpeed
      (
        (int)Canvas.GetTop(this),
        canvas.Height,
        (_intensity + 1),
        (value) => Canvas.SetTop(this, value)
      );

    int x = MainPage._random.Next(width);
    int y = -MainPage._random.Next(30);
    int intensity = MainPage._random.Next(MaxIntensity + 1);

    Canvas.SetLeft(this, x);
    Canvas.SetTop(this, y);
    Intensity = intensity;
  }

如果您愿意,可以在以下位置查看其工作版本:http://paulozemek.azurewebsites.net/ShootEmUp/StarsSilverlight.html

2. 玩家角色

为了移动角色,我使用了被 PrematureEndCondition 包含的 RangeBySpeed 动画。也就是说,我有一个 RangeBySpeed 动画可以向右移动角色,但如果右箭头没有按下,它就会提前结束。

我使用 RangeBySpeed 而不是普通的 Range,因为屏幕中央的角色比屏幕极左的角色更快到达右侧边界。如果我使用普通的 Range,那么角色总是需要相同的时间到达右侧角落,这会表现为它以不同的速度移动。

这里的诀窍是,将每一种可能的移动(左、右、上、下)范围放入它们各自的条件中,这些条件又在各自的循环中,这样如果您按住一个键,然后停止,然后再次按下该键,它们就可以重新执行。最后,所有这些动画都包含在一个 ParallelGroup 动画中(好吧,至少水平和垂直移动允许并行运行)。

唯一缺少的是如何告诉 PrematureEndCondition 是否应该结束。至少在 Silverlight 中,我们不能简单地检查一个键是否被按下。为了解决这个问题,我实现了 KeyDownKeyUp 事件来设置 HorizontalMoveverticalMove,并且条件只检查这些变量。请注意,如果我们将游戏编写到其他框架(甚至是 WPF),我们可能能够检查一个键是否按下而无需实现 KeyDownKeyUp 事件。

我已经展示了动画角色的代码,所以在这里我将介绍 KeyUpKeyDown 事件处理程序

void _KeyDown(object sender, KeyEventArgs e)
{
  switch(e.Key)
  {
    case Key.Left:
      HorizontalMove = -1;
      break;

    case Key.Right:
      HorizontalMove = 1;
      break;

    case Key.Up:
      VerticalMove = -1;
      break;

    case Key.Down:
      VerticalMove = 1;
      break;
  }
}
void _KeyUp(object sender, KeyEventArgs e)
{
  switch(e.Key)
  {
    case Key.Left:
      if (HorizontalMove == -1)
        HorizontalMove = 0;

      break;

    case Key.Right:
      if (HorizontalMove == 1)
        HorizontalMove = 0;

      break;

    case Key.Up:
      if (VerticalMove == -1)
        VerticalMove = 0;

      break;

    case Key.Down:
      if (VerticalMove == 1)
        VerticalMove = 0;

      break;
  }
}

您可以在 http://paulozemek.azurewebsites.net/ShootEmUp/StarsAndPlayerSilverlight.html 查看其工作版本。

注意:您可能需要点击 Silverlight 窗口才能使用方向箭头控制飞船。

3. 敌人

如果没什么可射击的,我怎么能说一款游戏是“射击游戏”呢?

我再次从创建一个名为 Enemy 的类开始。也许对于一个真正多功能的游戏,我应该把它创建为一个抽象类,然后在它自己的类中实现每个不同的敌人,但对于这个游戏,一个类就足够了。

起初,Enemy 类有一个固定的飞船图像,其动画是一个简单的 Range,从屏幕外的顶部移动到屏幕外的底部。然后,还有另一个动画,它会不时地创建新的敌人。

代码如下

return
  AnimationBuilder.
  BeginSequence().
    Add(() => canvasChildren.Add(this)).
    BeginPrematureEndCondition(() => Health <= 0).
      RangeBySpeed(-Height, MainPage._instance.canvas.Height, (30 + MainPage._random.Next(90)) * speed, (value) => Canvas.SetTop(this, value)). 
    EndPrematureEndCondition().
    ExplodeIfDead(this, 1).
    Add(() => canvasChildren.Remove(this)).
  EndSequence();

4. 矩形碰撞 + 分段时间

此时我们有一个移动的玩家角色,屏幕上出现了新的敌人……但是飞船从未发生碰撞。

为了进行基本的碰撞检测,我们处理矩形。我们考虑角色的 Canvas.LeftCanvas.Top 以及它们的 WidthHeight 来构建矩形,然后我们使用 Rect 类型的 Intersect 方法检查是否存在交集。

从这张图片可以看出,红色矩形和绿色矩形之间存在交集,我用黄色矩形表示。

但是,我们什么时候检查碰撞呢?

游戏使用声明式动画,它们没有合适的时机进行碰撞检测。您可以轻松地放置一个命令式动画并在每次调用时检查碰撞,但这会产生不精确的碰撞检测,因为 Update 可能以不同的时间间隔调用。

解决方案是使用分段时间(无论是通过 BeginSegmentedTime/EndSegmentedTime 还是直接实例化 SegmentedType)。这样的分段时间保证它将以其 SegmentDuration 确定的最大间隔更新其内部动画。请注意,动画将保持平滑,因为如果时间间隔短,它们将在更小的间隔内更新,但 SegmentCompleted 将只在分段完成时调用(也就是说,如果分段为 15 毫秒,10 毫秒的 Update 不会生成分段结束,但新的 5 毫秒 Update 将完成这 15 毫秒并生成完成事件)。

例如,如果定义的时间间隔为15毫秒,并且有两次更新,两次都是10毫秒,那么实际会发生的是

  1. 第一次更新将用这10毫秒更新内部动画;
  2. 第二次更新将分两步进行。第一步只更新5毫秒,以完成15毫秒并触发事件,然后它将触发 SegmentCompleted 事件。最后,它将进行额外的5毫秒更新,以将所有对象放置在正确的位置。

这里最重要的事情是将所有参与碰撞检测的动画作为此分段时间的内部动画,因为外部动画不会被分段,因此减速可能会使外部动画直接前进 500 毫秒,而不是在每次碰撞检测之前将其分段为 15 毫秒。

所以,进行基本碰撞检测的代码可能如下所示

internal static void _CheckAllCollisions()
{
  var objects = _instance.canvas.Children;

  PlayerCharacter._instance.IsReceivingDamage = false;
  foreach(var enemy in objects.OfType<Enemy>())
  {
    if (PlayerCharacter._instance.Health > 0)
    {
      if (enemy.CollidesWith((WriteableBitmap)enemy.image.Source, PlayerCharacter._instance, (WriteableBitmap)PlayerCharacter._instance.image.Source))
      {
        PlayerCharacter._instance.Health --;
        enemy.Health --;
        PlayerCharacter._instance.IsReceivingDamage = true;
      }
    }
  }
}

5. 爆炸

爆炸可能看起来很简单,因为我们已经检测到碰撞并且已经有很多动画。然而,这里有一些非常不同的东西。

到目前为止,所有动画都只是由运动组成,因为我们没有不同的帧。考虑到我不是通过代码模拟爆炸,而是使用已经绘制好的动画,我必须能够加载和播放它。

我在网上搜索了一些爆炸雪碧图。所以,此时,我必须能够使用雪碧图。

雪碧图通常由许多大小相同的图像组成(嗯,至少如果雪碧图中只有一个动画,这是最常见的情况)。我找到的一张雪碧图是这样的

我们需要做的是一次只显示一个“精灵”。为此,我创建了 SpriteSheet 用户控件。它的主要目的是简单地加载雪碧图图像,但一次只呈现一帧。

为了做到这一点,我们必须将 RectangleGeometry 应用到 UserControlClip 属性。控件的内容是一个带有图像的画布,因此,每次我需要显示不同帧时,我都会更改图像的 Canvas.Left 和/或 Canvas.Top(为了显示第二帧,我们必须将 Canvas.Left 设置为负帧宽度,这样第一帧在左侧不可见,第二帧变得可见)。

嗯,我做了更多的事情。SpriteSheet 控件能够以不同大小呈现帧。为此,我将整个图像大小乘以我想要显示的帧大小,然后将大小除以实际帧大小。在拉伸整个图像后,在进行 Left/Top 计算时,我还必须使用修改后的大小,因此真正“重新定位帧”的方法最终如下所示

private void _Invalidate()
{
  var source = _bitmapSource;
  if (source == null)
    return;

  if (_bitmapSource.PixelWidth <= 0)
    return;

  if (double.IsNaN(Width) || double.IsNaN(Height))
    return;

  int itemsPerRow = _bitmapSource.PixelWidth / _frameWidth;
  int column = _frameIndex % itemsPerRow;
  int row = _frameIndex / itemsPerRow;

  Canvas.SetLeft(_image, Width * -column);
  Canvas.SetTop(_image, Height * -row);

  _image.Width = _bitmapSource.PixelWidth * Width / _frameWidth;
  _image.Height = _bitmapSource.PixelHeight * Height / _frameHeight;

  _clip.Rect = new Rect(0, 0, Width, Height);
}

最后,考虑到我创建了 FrameIndex 属性,要动画化爆炸,只需在可接受的时间内(大多数情况下我使用 1 秒)从第一个帧索引到最后一个帧索引执行一个 Range 即可。

当然,我必须为爆炸创建这样的雪碧图组件,将其放置在爆炸飞船的相同位置,将其添加到所有角色已经添加的画布中,播放它,然后将其从画布中移除。所以,爆炸动画是这样的

var explosion = _SpriteSheetImages.CreateExplosion((int)enemy.Width);

// And inside the animation:
Add
(
  () => 
  {
    Canvas.SetLeft(explosion, Canvas.GetLeft(enemy));
    Canvas.SetTop(explosion, Canvas.GetTop(enemy));
    Canvas.SetZIndex(explosion, Canvas.GetZIndex(enemy) + 1);
    canvasChildren.Add(explosion);
  }
).
BeginParallel().
  Range(0, explosion.FrameCount, time, (value) => explosion.FrameIndex = value).
  Range(1.0, 0.0, time/2, (value) => enemy.Opacity = value).
EndParallel().
Add(() => canvasChildren.Remove(explosion)).

您可能会注意到,我也对敌人的 Opacity 应用了一个范围。对我来说,这看起来更自然,因为飞船在爆炸时开始消失,因为我的爆炸不包括飞船图像本身被摧毁。如果每艘飞船都有一个爆炸,并且已经包含了飞船部件,我可能会在播放爆炸时立即将飞船从屏幕上移除。

5.1. 爆炸声。

没有爆炸声,爆炸就不完整。最初,我每次想播放声音时都会创建一个新的媒体元素,但效果并不理想。

我真的不知道原因,但有时声音没有播放。我不认为这是因为使用了太多的声音通道,因为有时第一次播放的声音也会发生这种情况。

所以,我找到的解决方案是通过将 MediaElement 添加到 MainWindow 来一直创建它们。这样,每当我想要播放声音时,我就直接播放正确的媒体元素。

然而,在某些情况下,我想让同一个声音播放 2 或 3 次,然后才让它停止。对于音量高然后降低的声音,我通过简单地停止仍在播放的声音,然后重新开始播放来解决这个问题。但在一些连续发生的爆炸情况下,它似乎只是“延迟”了单个爆炸的声音,这发生在最后一艘飞船被摧毁时。

我不能说我用了有史以来最好的解决方案,但我通过为爆炸创建3个媒体元素并在播放时交替使用它们来解决了这个问题。所以,第一次、第二次和第三次爆炸可以同时播放,如果发生第四次爆炸,它会停止第一次爆炸,而第一次爆炸可能已经结束,所以,至少对我来说,爆炸听起来很棒。

因此,在播放爆炸声时,我使用 _GetExplosion 方法获取正确的媒体元素

private static int _explosionIndex;

// this is initialized in the constructor with 3 explosions.
private static MediaElement[] _explosions; 

internal static MediaElement _GetExplosion()
{
  var result = _explosions[_explosionIndex];
  _explosionIndex++;

  if (_explosionIndex >= _explosions.Length)
    _explosionIndex = 0;

  return result;
}

6. 射击

当我第一次想到射击时,我以为会有各种不同的射击,比如爆炸性射击、激光射击等等。但我从一个单一的射击开始,一个 Laser,正如你可能想象的那样,它成了一个类。

但是当我最终决定向游戏中添加新资源时,我决定只在普通和强力射击之间进行切换,而没有真正创建新的射击。我这样做只是为了避免创建一个庞大的游戏,因为我的目的不是创建最好的游戏,而是创建一个关于如何创建基本游戏的教程。

所以,我们有一个 Laser 类,这个类负责执行射击动画。我需要修改玩家动画以包含射击本身,这是通过将以下代码添加到 PlayerCharacter 的并行动画中来完成的

BeginLoop().
  BeginRunCondition(() => IsShootPressed && _hotOffset > 0).
    BeginSequence().
      Add(_Shoot).
      Wait(() => _traits._waitBetweenShoots / _shootSpeed).
    EndSequence().
  EndRunCondition().
EndLoop().

射击本身又是一个简单的 RangeBySpeed,但由于我播放了声音并让激光在触及敌人后变得更弱,最终代码如下

AnimationBuilder.
BeginSequence().
  PlaySound(() => MainPage._instance.mediaElementLaser, 0.4).
  BeginPrematureEndCondition(() => _health <= 0).
    RangeBySpeed(Canvas.GetTop(this), -Height, 300, (value) => Canvas.SetTop(this, value)).
  EndPrematureEndCondition().
  Add(() => MainPage._instance.canvas.Children.Remove(this)).
EndSequence();

而且,由于射击也会发生碰撞,我在 SegmentCompleted 事件中(实际上,在所有敌人的 foreach 循环中)添加了检查射击碰撞的代码

foreach(var shoot in objects.OfType<Laser>())
{
  Rect shotRect = 
    new Rect
    (
      Canvas.GetLeft(shoot) - Canvas.GetLeft(enemy),
      Canvas.GetTop(shoot) - Canvas.GetTop(enemy),
      shoot.Width,
      shoot.Height
    );

  int oldHealth = enemy.Health;
  if (oldHealth > 0 && shotRect.CollidesWith((WriteableBitmap)enemy.image.Source))
  {
    if (enemy._hitBy.Add(shoot))
    {
      byte value = (byte)shoot.Health;
      var hit = new Hit();
      hit.Intensity = value;
      hit.SetCenterX(shoot.GetCenterX());
      hit.SetCenterY(shoot.GetCenterY());
      AnimationManager.Add(hit.CreateAnimation());
    }

    int shootDamage = PlayerCharacter._instance._traits._shootDamage;

    if (shoot._isGreen)
      shootDamage *= 2;

    enemy.Health -= shootDamage;
    shoot.Health -= 10;
    PlayerCharacter._instance.Score += oldHealth - enemy.Health;

    if (enemy.Health <= 0)
    {
      enemy._killedByShoot = true;
      shoot._killCount++;

      double enemyValue = enemy.MaxHealth / 40.0;
      enemyValue *= enemyValue;
      enemyValue *= 100;

      int killScore = (int)enemyValue / enemy._hitBy.Count;
      if (killScore < 1)
        killScore = 1;

      killScore *= shoot._killCount;

      PlayerCharacter._instance.Score += killScore;

      var positionedScore = new PositionedScore();
      var animation = positionedScore.CreateAnimation(enemy.GetCenterX(), enemy.GetCenterY(), killScore);
      AnimationManager.Add(animation);
    }
  }
}

正如你所看到的,我仍然忽略了单一职责原则。每次出现新的有效碰撞时,我都必须修改 SegmentCompleted 处理程序。如果我真的想开发一个不断增长的游戏,我就必须重新审视这种实现,但我仍然认为对于只有 3 个关卡的游戏来说,保持这种方式是可以的。

7. 逐像素碰撞检测

您可能已经注意到,有些事情并不需要特定的顺序。我本可以在支持射击之前添加逐像素碰撞检测,但我选择先实现射击功能,然后再编写更好的碰撞检测算法。如果我与其他人合作,我们可以同时处理不同的事情,所以不要将“七”这个数字视为必需的顺序。

考虑到我最初是将该项目作为一个 Silverlight 项目启动的,并且考虑到我已经知道 WriteableBitmap 具有 Pixels 属性,我立即想到我应该将我的位图转换为 WriteableBitmap 来检查它们的像素。也许我应该使用一种更好的方法,因为它对于 WPF 来说不是最好的,而且我确实创建了一个 WPF 项目,并在其中大部分 Silverlight 代码都未触动地重用了。然而,这就是我所做的,我正在展示它。对于将参与碰撞检测的图像(即飞船图像),我将它们作为 WriteableBitmap 加载。

SilverlightWriteableBitmap.Pixels 属性将它们作为 int 数组返回。颜色采用 ARGB 格式,为了检测碰撞,我只考虑不透明度大于 127 的像素(即 A(lpha) 元素应大于 127)。

这可能是此应用程序中最难理解的代码,所以我将尝试将其拆分为几个部分

  1. 我仍然保留了执行矩形交集的代码。事实上,如果没有交集,就没有碰撞的可能,如果存在交集,我们必须检查像素值;
  2. 要获取给定 X, Y 坐标处的像素,考虑到 Pixels 属性是一个一维数组,我们应该这样计算
        int pixelIndex = (y * bitmap.Width) + x;
      
  3. 看这张交集图。你可以看到在红色和绿色“方块”(不要评判我绘画的精确度)之间有一个黄色的交集。在分析碰撞时,我们将分析来自两边的所有交集像素,所以我们需要计算我们应该确切地检查每个图像的哪个位置。交集的“0, 0”位置与绿色方块的 0, 0 重合,但它不是红色方块的 0, 0。

嗯,考虑到这些因素,并尝试优化读取,我最终得到了这段代码

public static bool CollidesWith(this FrameworkElement element1, WriteableBitmap bitmap1, FrameworkElement element2, WriteableBitmap bitmap2)
{
  int left1 = (int)Canvas.GetLeft(element1);
  int top1 = (int)Canvas.GetTop(element1);
  int width1 = (int)element1.Width;
  int height1 = (int)element1.Height;

  int left2 = (int)Canvas.GetLeft(element2);
  int top2 = (int)Canvas.GetTop(element2);
  int width2 = (int)element2.Width;
  int height2 = (int)element2.Height;

  Rect rect1 = new Rect(left1, top1, width1, height1);
  Rect rect2 = new Rect(left2, top2, width2, height2);

  Rect intersect = rect1;
  intersect.Intersect(rect2);
      
  if (intersect.IsEmpty)
    return false;

  int intersectLeft = (int)intersect.Left;
  int intersectTop = (int)intersect.Top;
  int intersectRight = (int)intersect.Right;
  int intersectBottom = (int)intersect.Bottom;

  var pixels1 = bitmap1.Pixels;
  var pixels2 = bitmap2.Pixels;

  for(int y=intersectTop; y<intersectBottom; y++)
  {
    int index1 = (y-top1)*width1 + (intersectLeft-left1);
    int index2 = (y-top2)*width2 + (intersectLeft-left2);
    for(int x=intersectLeft; x<intersectRight; x++)
    {
      if ((pixels1[index1] & 0xFF000000) > 0x7F000000 && (pixels2[index2] & 0xFF000000) > 0x7F000000)
        return true;

      index1++;
      index2++;
    }
  }

  return false;
}

& 0xFF000000 用于仅提取 ARGB 像素的 Alpha 元素。> 0x7F000000 用于比较该值是否大于 127。事实上,我可以只将 Alpha 元素提取为 0 到 255 之间的值,但这段代码避免了额外的操作。

我只在从一行移动到另一行时计算 index1index2 变量。我本可以将 index1index2 的计算放在内部的 for 循环中,但由于我知道我只需要读取“下一个”像素,所以我更喜欢每行只计算一次,然后在每次迭代时递增 index1index2

你可以看到,如果我发现碰撞,我立即返回。在我的例子中,我只想知道是否发生了碰撞。如果你需要知道碰撞的像素数量,你需要增加一个变量(比如 result)并继续检查像素。

8. 老板。

在最初的创建过程中,我只是编写了一个 Enemy 类。为了创建 boss,我考虑对其进行重构,但我决定仍然不需要(这通常是邪恶的根源,但我有时会偷懒)。我只是为 boss 创建了一个新的动画方法,并在构造时给了它不同的图像和生命值。

Boss 不能像普通飞船一样简单地消失,所以我使用了一个命令式动画,它不断 yield return 具有随机值的 RangeBySpeed 动画来让 Boss 在屏幕上移动。而且,在关卡动画本身中,我将 Boss 动画放入一个序列中,该序列首先等待 60 秒(后来我改成了 3 分钟),然后才让 Boss 出现。

所以,boss 动画是这样的

public IAnimation CreateBoss1Animation()
{
  Canvas.SetTop(this, -Height);
  this.SetCenterX(MainPage._instance.canvas.Width / 2);

  return
    AnimationBuilder.
    BeginSequence().
      Add(() => MainPage._instance.canvas.Children.Add(this)).
      BeginPrematureEndCondition(() => Health <= 0).
        Add(new ImperativeAnimation(_Boss1Animation())).
      EndPrematureEndCondition().
      ExplodeIfDead(this, 3).
      Add(() => MainPage._instance.canvas.Children.Remove(this)).
      Add(() => _bossDefeated = true).
    EndSequence();
}

将 boss 放入关卡中的动画是这样的

BeginSequence().
  Wait(3 * 60).
  Add(CreateBossAnimation(boss)).
EndSequence().

9. 关卡和游戏动画

好吧,我最初玩游戏时只是把东西“扔”到屏幕上。游戏直接开始。没有开始画面,也没有不同的关卡。我甚至把“游戏结束”画面作为 PlayerCharacter 动画的一部分,所以当时我忽略了“单一职责原则”。

事实上,您可能会注意到,即使在最新版本中,也有一些您可能不喜欢​​的静态变量。好吧,我只能说我只更正和抽象了我需要的东西。我开始编写游戏时确实很懒惰。理论并不难:“每个类都应该有一个单一的职责。”“您应该有良好的抽象。”但是,当我开始时,我只专注于一次非常有限的结果(比如创建星空背景或让角色移动)。直到后来我才决定创建关卡、带有不同选项的开始画面等等。

然后,我做的最后一件事是真正添加重新开始游戏的选项,因为以前游戏结束后,我需要重新加载页面(或关闭并重新打开 WPF 应用程序)才能再次玩游戏。

嗯,我想我越来越懒了,因为我只会说“整个游戏”由这个动画来玩

AnimationBuilder.
BeginLoop().
  BeginSequence().
    Add(_ShowStartMenu()).
    BeginPrematureEndCondition(() => PlayerCharacter._instance.Health <= 0).
      Add(_PlayLevels()).
    EndPrematureEndCondition().
    BeginRunCondition(() => PlayerCharacter._instance.Health <= 0).
      Add(_GameOver()).
    EndRunCondition().
    BeginRunCondition(() => PlayerCharacter._instance.Health > 0).
      Add(_YouConqueredTheUniverse()).
    EndRunCondition().
  EndSequence().
EndLoop();

所以,我真的认为,如果你想了解我对游戏的改进,现在是时候查看代码并玩它了(或者如果你到目前为止还没有玩过的话,就去玩它!)。

WPF

我想我是在把第一个 boss 放到游戏中时才开始制作 WPF 应用程序的。我创建了一个新项目,然后只把项目文件复制到 Silverlight 应用程序的同一个目录下。考虑到我不能在 XAML 中使用 #if 指令,并且 WPF 使用 Window 而不是 UserControl 作为初始“页面”,所以我创建了一个新的窗口。但是,如果你仔细看,我让它使用了与 Silverlight 相同的类。

我确实做了最小限度的工作,让游戏在 WPF 中运行。例如,WPFWriteableBitmap 没有 Pixels 属性,但它允许将任何 BitmapSource 的像素复制到 int 数组中,所以我本可以避免在 WPF 中使用 WriteableBitmap,但我只做了最小限度的工作,使“Pixels”属性可访问。因此,如果您发现一些 #if WPF,那就是我对 WPF 进行了特定更改的地方。

嗯,我想如果你查看代码,你会发现那些 #if 并不多,但足以证明 WPF 不仅仅比 Silverlight 更完整,它们是不同的。

关注点

也许使用声明式动画来编写游戏是最有趣的部分。但是,对我来说,最让我印象深刻的是,当我在很长一段时间里都认为 WPF 更完整时,Silverlight 中的一些类却比 WPF 更完整、更友好。

在最终版本中,当你杀死 Boss 时,声音会播放得更慢,以模拟一次巨大的爆炸,然而这段代码只在 Silverlight 中有效,因为 WPFMediaElement 没有 PlaybackRate 属性,而 SpeedRatio 根本没有给我预期的结果。

最后评论

我知道我没有制作出最吸引人的游戏,而且在这个时代,人们通常期待的是 3D 游戏。但我不是设计师,我没有找到很多好的且兼容的 2D 图像,而且,至少对我来说,找到 3D 图像更难。

但别担心,大多数概念(例如通过时间进行的动画,这可以通过声明式动画实现,以及进行碰撞检测的时机)在 3D 游戏中可能也是相同的。当然,您将需要一些不同的算法来进行碰撞检测,但基本思想是:您将更新“属性”,检查状态(例如碰撞和按键),并且您可能会创建新的动画并将其添加到一些 ParallelGroup 中。而且,在像 XNA 这样的框架中,您还需要实现一个动画管理器,它在更新动画对象后处理 Draw

我真的希望您喜欢这篇文章,并且至少能通关一次游戏(或者我应该说“希望您征服宇宙!”?)

图片和声音

本游戏中使用的所有图片和声音均来自互联网。声音我使用了网站 http://www.freesfx.co.uk/

图片来自不同的网站。文章中展示的爆炸雪碧图来自 https://www.touchdevelop.com/lvxdmali,但还有另一个爆炸(一个大的)来自 http://april-young.com/home/wp-content/uploads/2012/04/Explosion1spritesheet.png

我真的不记得在哪里找到这些角色了,因为我在真正选择我认为最好的图片之前下载了很多图片。

事实上,本文的许可不适用于图片,我也不知道这些图片是否有任何许可。但我希望我没有做任何坏事,因为我没有从游戏中赚钱。

一个隐藏功能:5598

我的儿子有时“敲击键盘”写下了5598(他当时9个月大)。我应该告诉您,我真的考虑过在游戏中使用这段代码放置一些隐藏功能,但由于他确实将其写入了文章中,好吧,我决定用它来结束文章会很有趣。如果您正在看到这条消息,那么恭喜您“破解”了文章的源代码。

Facebook 应用程序

文章本身已经写完了。现在我想请教一些问题,因为今天我才真正决定研究如何为 Facebook 编写程序。事实上,我是偶然开始的,因为我决定删除我个人资料中的所有应用程序,然后在详细信息中进入了说明如何构建 Facebook 应用程序的帮助。我很容易就把这个游戏变成了 Facebook 游戏(好吧,还不是社交游戏,但它可以在 Facebook 中运行)。

我真正需要知道的是,与我无关的用户是否可以在 https://apps.facebook.com/pfzshootemup/[^] 上看到游戏,我想请问是否有人能告诉我如何从 Silverlight 应用程序中显示用户资料信息(和朋友列表信息),以及是否可以从外部应用程序(即应用程序的 WPF 版本)中显示此类信息。

任何帮助都欢迎,您可以通过电子邮件 paulozemek@outlook.com 联系我

© . All rights reserved.