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

游戏编程 - 二

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (48投票s)

2008年5月9日

CPOL

10分钟阅读

viewsIcon

102959

downloadIcon

1582

介绍用于创建简单游戏的方法。

引言

这些教程的最初目标是,向您展示如何从头开始创建一个简单的游戏,而不借助 XNA 或 DirectX 等高级 API,这些 API 会自动化您一半的工作。我们只会使用 Windows Form 和 GDI+ 进行一些基本的绘图,并为了方便起见,使用 Form 的一些事件。

在本教程中,我们将涵盖双缓冲以消除闪烁,设置简单的 FPS 计数器,获取玩家输入,以及在屏幕上绘制精灵。

« 上一篇 下一篇 »

第二步:绘制场景

减少闪烁

如果我们使用单独的线程来运行逻辑并将内容绘制到屏幕上,我们可能会注意到屏幕在绘制新内容时会闪烁。如果应用程序中有许多控件,您甚至可能看到应用程序闪烁。解决此问题的方法是使用双缓冲

我们已经有一个缓冲区,即屏幕。此缓冲区由显卡用来更新您的显示。如果我们直接绘制到此缓冲区,那么我们可以几乎立即看到任何更改,这会导致闪烁。对于目前我们所做的少量绘图,您可能不会注意到任何闪烁,但随着绘图量的增加,效果会变得更加明显。

通过双缓冲,我们有两个缓冲区,一个后备缓冲区和一个屏幕。我们在后备缓冲区上进行所有绘图,然后一次性将后备缓冲区绘制到屏幕上。这解决了闪烁问题。双缓冲的一个变体是页面翻转。在页面翻转中,显卡会执行额外的工作。它会在其 VRAM 中有两个缓冲区。其中一个将在屏幕上显示,而我们将在另一个上绘图。然后它将交换缓冲区。因此,当我们在缓冲区 2 上绘图时,卡会显示缓冲区 1。然后它会交换并显示缓冲区 2,同时我们在缓冲区 1 上绘图。

两者之间的主要区别在于,页面翻转将始终等待显卡的 VSync(因此它会等待垂直刷新,然后才绘制新缓冲区)。这会将帧率限制在显示器的刷新率。虽然这并非坏事,但等待 VSync 仍可能减慢您的游戏速度。然而,等待垂直刷新的优点是您不会遇到画面撕裂。画面撕裂是指我们在刷新期间复制新缓冲区,因此屏幕的上半部分使用旧场景,而下半部分则显示新场景。

因此,在实现双缓冲后,我们的代码将如下所示:

Image buffer;

GetInput();
PerformLogic();
DrawGraphics();

...

DrawGraphics()
{
    Graphics g = Graphics.FromImage(buffer);
    g.Draw...
    g.Dispose();
    //We will then draw 'buffer' to the screen
}

仍然关于计时 - FPS 计数器

那么,那个简单的 FPS 计数器怎么样?为此,我们只需要计算每秒运行我们绘图代码的次数。因此,我们需要一个变量来计算我们绘制的帧数,以及另一个计时器,该计时器每秒运行一次。如果我们回到上一篇文章结尾处的代码并添加这些内容,我们将得到这个:

Timer MainTimer;
Timer FpsTimer;
MainTimer.Interaval = 1000/60;
FpsTimer.Interval = 1000;
bool runGame = true;
volatile uint speedCounter = 0;
uint fpsCounter = 0;
uint fps = 0;

Main()
{
  while(runGame)
  {
    if(speedCounter >0)
    {
      GetInput();
      PerformLogic();
      speedCounter--;
      if(speedCounter == 0)
        DrawGraphics();
    }
  }
}

DrawGraphics()
{
  ...
  fpsCounter++;
}

FpsTimer()
{
  fps = fpsCounter;
  fpsCounter = 0;
}

Timer()
{
  speedCounter++;
}

看,这真的很简单。

糟糕

如果您现在添加 FPS 计数器,您可能会发现实际 FPS 低于您尝试设置的值。这是因为 .NET 提供的标准计时器精度大约为 1/18 秒。因此,我们将不得不使用旧式计时器,它更精确。此计时器包含在本文章的演示中,因此无需担心。

按下那些按钮 - 用户输入

我们需要从玩家那里获取输入。为此,我们将使用 Windows Form 的 KeyDownKeyUp 事件。这看起来很简单,但请记住,我们应该只在主循环中并在正确的时间运行逻辑。您不能在按下按钮时执行逻辑。一个非常明显的例子是,如果您的游戏以 2 FPS 运行,而玩家以每帧 4 像素的速度移动,那么玩家每秒应该移动 8 像素,对吧?但是,如果我们一按下按钮就移动玩家,那么玩家就能以他们能按按钮的速度移动。此外,仅使用事件,我们不知道按钮是否仍然被按下。

因此,我们需要一种方法来存储已按下的按钮,以便我们可以在运行逻辑时在循环中检查它们。您可以使用 bool 数组来跟踪按钮,但使用标志更容易。我说的标志是,我们有一个 intlong,这些变量中的每一位都代表一个键。因此,在 int 中,我们可以跟踪 32 个键,在 long 中,我们可以跟踪 64 个键。如果我们使用 int,第一个位可能代表“左”,第二个代表“右”,第三个代表“上”,依此类推。此 int 将显示左键和上键被按下

00000000000000000000000000000101

因此,从我们的 int 的二进制表示中,我们可以看到有两个按钮被按下。 int 的实际值将是 5。我们将使用枚举来跟踪我们的游戏键,并使用按位运算符来添加和删除键。枚举和实现将如下所示:

[Flags]
public enum GameKeys : int
{
  Null  = 0,
  Up    = 0x01,
  Down  = 0x02,
  Left  = 0x04,
  Right = 0x08
}
...
GameKeys pressedKeys = GameKeys.Null;
...
KeyDown(KeyEventArgs e)
{
  switch(e.KeyCode)
  {
    case Keys.Left:
      pressedKeys |= GameKeys.Left;
      break;
      
    ...
  }
}

KeyUp(KeyEventArgs e)
{
  switch(e.KeyCode)
  {
    case Keys.Left:
      pressedKeys &= ~GameKeys.Left;
      break;
      
    ...
  }
}

然后,我们可以通过以下方式检查是否按下了某个键:

if((pressedKeys&GameKeys.Left) == GameKeys.Left)

此语句将检查左箭头键是否被按下。要添加一个键,我们使用 OR 运算符,这意味着我们将两个值组合在一起 - 这与加法不完全相同。

   0010
OR 1001
=  1011;
//We basically just gather all of the 1's together

当我们在检查一个键是否被按下时,我们使用 AND 运算符,这意味着结果只包含 **两个** 位都为 1 的地方。

0100 (GameKeys.Key)
AND 1111 (pressedKeys) 
=   0100

因此,如果我们的 AND 操作的结果与我们正在检查的键相同,那么按钮就被按下了。

在事件本身中执行逻辑至少有一个例外 - 关闭游戏。在演示中,您会看到我将 Escape 设置为将 m_playing 更改为 false,以便我们的线程关闭,然后我调用 Application.Exit() 来关闭窗体。确保在关闭窗体之前退出我们的线程非常重要,否则我们很可能会遇到错误,这看起来不太好。

我们都在这里做什么 - 动画精灵

现在,我们要开始绘制和动画精灵了。

什么是精灵?

在 2D 游戏中,精灵基本上就是一个代表游戏中对象的图像。因此,要绘制一个没有动画的精灵,您只需将图像或图像的一部分绘制到屏幕上。

如果我们想在屏幕上绘制一个太空侵略者外星人,我们会从硬盘加载图像,然后使用 Graphics.DrawImage(...); 将其绘制到我们的缓冲区。因此,如果我们使用此图像(我们确实使用了):

SI256.png

那么,我们需要找到图像中外星人的 X 和 Y 坐标,以及它的宽度和高度。我可以告诉您,第一个外星人位于 (48, 89),大小为 16*16。要将其绘制到屏幕上,我们将使用 DrawImage 方法的此重载:DrawImage(Image, destination rectangle, srcX, srcY, srcWidth, srcHeight, GraphicsUnit)

Bitmap spaceInvaders = new Bitmap("path to image");
...
DrawGraphics()
{
  Graphics g = Graphics.FromImage(buffer);
  g.DrawImage(spaceInvaders, new Rectangle(50,50,16,16), 
               48, 89, 16, 16, GraphicsUnit.Pixel);
}

这将在屏幕上的 (50,50) 位置绘制那个小外星人。当然,如果您想拉伸或缩小精灵,只需更改矩形的宽度和高度。

这很好,但没什么用

要对其进行动画处理,我们只需绘制图像,然后在下一帧,我们绘制序列中的下一张图像。因此,我们需要跟踪我们处于动画的哪个帧,动画有多少帧(以便知道何时返回开头),以及单个精灵的宽度和高度。要动画化我们的外星人,我们将在 (48, 89) 位置绘制图像,然后在下一帧,在 (48, 105) 位置绘制图像,然后重复。

int numberOfFrames = 2;
int currentFrame = 0;
int height = 16;
Logic()
{
  currentFrame++;
  if(currentFrame == numberOfFrames)
    currentFrame = 0;  
}

DrawGraphics()
{
  Graphics g = Graphics.FromImage(buffer);

  int srcY = 89 + (currentFrame*height);
  g.DrawImage(spaceInvaders, new Rectangle(50,50,16,16), 
               48, srcY, 16, 16, GraphicsUnit.Pixel);
}

Logic() 中的代码将递增 currentFrame,并在其达到动画帧数时将其重置为 0。currentFrame*height 将为我们提供与第一帧 Y 坐标的差值。在第一帧,currentFrame 将为 0;0*16 为 0,因此 srcY 保持在 89。在第二帧,currentFrame 为 1;1*16 为 16,因此我们将其添加到 89 以得到图像中的新 Y 坐标。

我们仍然缺少一些东西

您可能在游戏中注意到,当您按下不同的方向时,角色通常会面向这些方向。实现这一点与动画精灵非常相似。如果您有这张图片:

sprite4.png

那么,要改变角色面对的方向,而不是改变精灵的 X 坐标来动画化它,您将改变 Y 坐标,以便在图像中遍历不同的行。

更新我们的源代码

好了,现在我们已经讲完了动画,最好更新源代码以使用更精确的计时器,并使用单独的线程来为我们的逻辑使用正确的循环。在演示中,您现在会看到一个 GameLoop 方法,它在一个单独的线程中启动,并包含您在上一篇文章中看到的循环。您可能还会注意到我们使用 Thread.Sleep(1);,这是为了让游戏不占用 100% 的处理器,等待我们的计时器通知我们运行逻辑。我们也不再使用 OnPaint 重写和 Invalidate 方法来绘制我们的场景,我们将自己完成所有事情。

演示

本文的演示包括用户输入,您可以使用它来在屏幕上移动我们的小外星人,一个用于动画和渲染外星人的精灵类,以及一个新的计时器,以便我们可以正确设置 fps。它还有 FPS 计数器,但它将 FPS 平均计算了 2 秒,并使用 float 而不是 int 来表示小数。

更多的家庭作业,哎呀

加载并动画化该男人,使用精灵类或否则(每帧 32*32),然后尝试编辑精灵类,以便您可以更改它正在循环的行或列,以便您可以更改男人面对的方向。提示:该类具有成员变量 srcXsrcY,您需要编写一个新方法来更新这些值。

即将推出

这是又一篇文章的结尾,敬请期待下一篇文章,我们将介绍:

  • 碰撞检测
  • 瓷砖、背景和视差滚动
  • 设置一些基本玩家/敌人类

您想要什么?

随意发布您希望在这些文章中看到的内容。我在这里尽力提供帮助,所以如果我知道如何做您要求的事情,我会尝试包含它。这些文章正在深入 2D 游戏的世界,并进入 3D 领域 - 尽管为此我们可能需要切换到 C++ 和 OpenGL,我一直不太喜欢 DirectX,我也从未使用过 XNA 进行 3D。请记住,我不是要给您代码来制作游戏,而是向您展示如何构建可用于制作游戏的 कोड - 如果您能跟上,语言就不那么重要了。

历史

  • 2008 年 5 月 9 日 - 文章提交。
© . All rights reserved.