游戏编程 - 二






4.90/5 (48投票s)
介绍用于创建简单游戏的方法。
引言
这些教程的最初目标是,向您展示如何从头开始创建一个简单的游戏,而不借助 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 的 KeyDown
和 KeyUp
事件。这看起来很简单,但请记住,我们应该只在主循环中并在正确的时间运行逻辑。您不能在按下按钮时执行逻辑。一个非常明显的例子是,如果您的游戏以 2 FPS 运行,而玩家以每帧 4 像素的速度移动,那么玩家每秒应该移动 8 像素,对吧?但是,如果我们一按下按钮就移动玩家,那么玩家就能以他们能按按钮的速度移动。此外,仅使用事件,我们不知道按钮是否仍然被按下。
因此,我们需要一种方法来存储已按下的按钮,以便我们可以在运行逻辑时在循环中检查它们。您可以使用 bool
数组来跟踪按钮,但使用标志更容易。我说的标志是,我们有一个 int
或 long
,这些变量中的每一位都代表一个键。因此,在 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(...);
将其绘制到我们的缓冲区。因此,如果我们使用此图像(我们确实使用了):
那么,我们需要找到图像中外星人的 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 坐标。
我们仍然缺少一些东西
您可能在游戏中注意到,当您按下不同的方向时,角色通常会面向这些方向。实现这一点与动画精灵非常相似。如果您有这张图片:
那么,要改变角色面对的方向,而不是改变精灵的 X 坐标来动画化它,您将改变 Y 坐标,以便在图像中遍历不同的行。
更新我们的源代码
好了,现在我们已经讲完了动画,最好更新源代码以使用更精确的计时器,并使用单独的线程来为我们的逻辑使用正确的循环。在演示中,您现在会看到一个 GameLoop
方法,它在一个单独的线程中启动,并包含您在上一篇文章中看到的循环。您可能还会注意到我们使用 Thread.Sleep(1);
,这是为了让游戏不占用 100% 的处理器,等待我们的计时器通知我们运行逻辑。我们也不再使用 OnPaint
重写和 Invalidate
方法来绘制我们的场景,我们将自己完成所有事情。
演示
本文的演示包括用户输入,您可以使用它来在屏幕上移动我们的小外星人,一个用于动画和渲染外星人的精灵类,以及一个新的计时器,以便我们可以正确设置 fps。它还有 FPS 计数器,但它将 FPS 平均计算了 2 秒,并使用 float
而不是 int
来表示小数。
更多的家庭作业,哎呀
加载并动画化该男人,使用精灵类或否则(每帧 32*32),然后尝试编辑精灵类,以便您可以更改它正在循环的行或列,以便您可以更改男人面对的方向。提示:该类具有成员变量 srcX
和 srcY
,您需要编写一个新方法来更新这些值。
即将推出
这是又一篇文章的结尾,敬请期待下一篇文章,我们将介绍:
- 碰撞检测
- 瓷砖、背景和视差滚动
- 设置一些基本玩家/敌人类
您想要什么?
随意发布您希望在这些文章中看到的内容。我在这里尽力提供帮助,所以如果我知道如何做您要求的事情,我会尝试包含它。这些文章正在深入 2D 游戏的世界,并进入 3D 领域 - 尽管为此我们可能需要切换到 C++ 和 OpenGL,我一直不太喜欢 DirectX,我也从未使用过 XNA 进行 3D。请记住,我不是要给您代码来制作游戏,而是向您展示如何构建可用于制作游戏的 कोड - 如果您能跟上,语言就不那么重要了。
历史
- 2008 年 5 月 9 日 - 文章提交。