在 Windows Forms 中使用精灵






4.94/5 (40投票s)
这是一篇关于在 Windows Forms 中制作 2D 精灵的文章。
引言
Windows Forms 已经存在了相当长的一段时间,但在 WinForms 中制作图形游戏仍然不是很容易。有许多复杂的库和其他用于制作游戏的系统,而使用那些功能丰富的系统是他们推荐大多数人制作游戏的方式。这是一个用于 Windows Forms 内部的简单精灵引擎,旨在保持简单。虽然你可以用这个库制作一个完整的游戏,但它本身并不是一个非常完善的系统。它主要是为了让人们开始图形编程。我可能会收到的第一个评论是,“为什么不使用真正的游戏库?”这个库的重点是用于简单的游戏,作为图形编程的入门。当有人使用过这个库之后,他们可能会产生使用 DirectX、OpenGL、Unity、UnrealEngine 等的愿望。
这个 SpriteController
类允许你将一个 PictureBox
变成一个游戏场地。你给它一个背景进行绘制,然后把你的精灵放在上面。所有动画和移动精灵的工作都由它处理。它还包含一个简单的系统来确定按键是否被按下(因为这是困扰新手程序员的一大难题)。你可以用它作为如何制作自己的精灵控制器的例子,或者直接使用这一个。
C# WinForms 并非为轻松处理复杂图形而构建,但激发一个新手程序员的兴趣并不需要太多。而使用像这样的一个类是入门的一个相当简单的方法。首先,我会为那些只想简单使用这个库的人解释如何使用代码。然后,我会解释一下这个库的工作原理。
背景
我最近有机会开始教一个侄子编程。他的心态和我很久以前开始思考编程时的心态一样。“我怎样才能制作一个游戏?”我喜欢 C# 和 Visual Studio。对于不太了解编程但又想做出能用的东西的人来说,它非常容易。它有出色的内置文档,并且可以根据你的需要变得复杂或简单。但它不容易处理图形,除非你非常简化(一个框里一张图片),或者转向 OpenGL、DirectX、Unity 或那些相当复杂的系统。诚然,有很多文档和教程让那些系统变得“简单”。但是,使用这个类更简单。这是我试图将图形游戏创作交到新手 C# 程序员手中的一次尝试。
2D 编程制作图像要容易得多。使用合适的语言,为 3D 编程可能不会复杂太多,但编程的很大一部分在于构建图形。为 2D 世界构建动画仍然有点麻烦,但在没有太多渲染知识的情况下是可行的。
Using the Code
构建库
下载库的源代码后,你需要打开项目并“构建”它。在 Visual Studio 的底部,它应该会告诉你已经构建成功。这个库不能单独执行;你需要创建一个项目(或下载并运行演示)才能看到它的实际效果。生成的 DLL 文件应该在项目的 SpriteLibrary/SpriteLibrary/Bin/Release 目录中。
添加引用
在你的项目中(如果你还没有,就创建一个新项目),在“解决方案资源管理器”中右键单击你的项目名称,然后选择“添加”和“引用”。向下滚动到“浏览”并找到 SpriteLibrary DLL。如果你已经构建了它(见上文),它应该在你的 projects/SpriteLibrary/SpriteLibrary/Bin/Release 目录中。如果你开发了一个游戏并使用内置的 ClickOnce 安装程序,这个 DLL 将会自动安装,连同你的软件包一起,因为你已经将它添加为引用。所以你应该只需要做一次这个操作。
安装文档
在项目的 Doc\Help 目录中有一个 .msha 文件,其中包含了 SpriteLibrary 的文档。如果你安装了这个文件(说明如下),并且你设置了“在帮助查看器中启动帮助”(而不是在线帮助),那么,当你对任何与 SpriteLibrary 相关的内容按 F1 时,就会弹出这些项目的上下文相关帮助。如果你更喜欢在线文档,你可以手动访问 http://tyounglightsys.com/SpriteLibrary/doc 来查找相同的文档(你可以浏览这个文档;Visual Studio 没有设置将另一个网站合并到他们的在线 Visual Studio 仓库中)。
要安装 msha 文件并配置 Visual Studio 以访问在线帮助,请打开 Visual Studio 并转到“帮助” -> “查看帮助”。Microsoft 帮助查看器应该会弹出,通常会选中“管理内容”选项卡。如果未选中,请打开“管理内容”选项卡。
然后,选择“磁盘”并浏览到你的 SpriteLibrary 项目,在 Doc\Help 目录下找到 Documentation.msha 文件。找到后,点击新文档文件旁边的“添加”按钮。
添加后,你仍然需要按窗口右下角的“更新”按钮。在撰写本文时,SpriteLibrary 尚未进行数字签名,安装过程中会弹出相关通知。
使用 Sprite 库
你需要在你的主窗体顶部,以及任何引用 Sprite
库组件(如 Sprites
、SpriteController
等)的文件中添加一行“using SpriteLibrary;
”。
初始化 Sprite 库
在你想要用于游戏的窗体中,你需要创建一个 SpriteController
。你首先需要将它定义为一个变量,比如:
SpriteController MySpriteController;
你可能注意到我们还没有实例化它。我们在创建 SpriteController
时需要将一个 PictureBox
与之关联。所以,在窗体加载并且 InitializeComponents();
运行之后,我们才可以实例化 SpriteController
。在这个例子中,我们有一个名为 MainDrawingArea
的 PictureBox
。你可以在每个游戏中有多个精灵控制器,但每个 PictureBox
最好只有一个。所以你需要存储这个变量,并将它传递到你程序需要的适当位置。
MainDrawingArea.BackgroundImageLayout = ImageLayout.Stretch;
MySpriteController = new SpriteController(MainDrawingArea);
我们将背景布局设置为 stretch
(拉伸)。这对于能够调整窗口大小和许多其他事情都很重要。
添加你的第一个精灵
首先,你必须有一个可以添加的精灵。精灵基本上是一张图像上的一系列帧。每一帧都是一张图片,并且大小相同(例如,100x100)。一张图片中可能有很多帧。最终的图像可能是 400 x 100,或者 200 x 200。精灵会一帧一帧地显示。所以你可能会有一个你的主角站立的图像。下一帧是他的腿稍微伸出。再下一帧是他的腿完全伸出。当我们看到它动起来时,腿就像你的冒险家在走路一样伸出。这个精灵控制器假设图像已经设置了透明度,这样你就可以看到精灵背后的背景,这基本上意味着你使用的是带有透明度的 png 文件。
有几种方法可以创建你的精灵并加载动画。所有的例子都是从项目资源中的精灵表(sprite-sheet)中读取精灵。这些资源文件会随着 ClickOnce 部署一起包含在包中。所以如果你希望把你的项目给别人,这是一个存放它们的好地方。加载一个精灵看起来像这样:
Sprite JellyMonster = new Sprite(new Point(0, 100), MySpriteController,
Properties.Resources.monsters, 100, 100, 200, 4);
JellyMonster.SetName(“jelly”);
在这个例子中,我们通过从图像的第二行中提取动画来创建一个名为 JellyMonster
的精灵。(0,0 是第一行,0,100 是第二行。)我们给它传递精灵控制器,然后是图像文件。我们指定我们提取的图像尺寸是 100 x 100。我们使用每帧 200 毫秒的动画速度,并从图像中提取 4 帧。当我们绘制精灵时,我们可以在 PictureBox
上放大或缩小精灵。它不需要保持在 100x100。那只是精灵表图像中单个帧的大小。
在上面的代码中我们做的最后一步是给精灵命名。我们命名我们的主精灵,然后当我们想要一大堆时就克隆它们。我们通常不会让命名的精灵在没有克隆的情况下显示在屏幕上(尽管你可以这么做)。我们这样做的主要原因是为了可以随时销毁精灵,并随时再次创建任意数量的精灵。当你销毁一个克隆体时,很容易从主精灵那里再造更多。但销毁主精灵意味着你需要从头开始。克隆精灵非常高效,但从头生成新的精灵需要更多的工作。
让你的精灵做点什么
现在我们有了一个精灵,我们可以把它放在某个地方。一旦它被放置在背景上,它会自动开始显示第一个动画(动画 0)。你可以为每个精灵设置多个动画(向左走、向右走、向左摔倒、向右摔倒、死亡等)。你可以告诉一个精灵改变动画,或者只播放一次动画(让精灵停留在动画的最后一帧)。这里是一些在 ShootingDemo
中配置精灵的代码。
JellyMonster.AutomaticallyMoves = true;
JellyMonster.CannotMoveOutsideBox = true;
JellyMonster.SpriteHitsPictureBox += SpriteBounces;
JellyMonster.SetSpriteDirectionDegrees(180);
JellyMonster.PutBaseImageLocation(new Point(startx, starty));
JellyMonster.MovementSpeed = 30;
我们告诉精灵它会自动移动。我们告诉它不能超出 picturebox
的边界。然后我们告诉它从哪里开始,以及向哪个方向移动。 `+=` 这一行是一个事件。SpriteBounces
是一个函数,当精灵撞到 picturebox
边缘时会执行。(见下面的“精灵事件”)。
精灵事件
你可以给精灵添加事件。例如,你可以添加一些代码,当一个精灵撞到另一个精灵时运行。在 ShootingDemo
中,我们给怪物精灵添加了那个事件。每秒很多次,精灵会检查它们是否撞到了另一个精灵,如果撞到了,它们就会执行那个事件中的代码。还有一些事件用于精灵撞到 picturebox
边缘,或者它们已经离开了 picturebox
。甚至有一个事件在精灵移动到新位置之前触发。你可以用那个事件来调整或取消移动位置。
你需要创建一个事件,并将该事件添加到精灵上。事件会随着精灵一起被克隆,所以你可以将事件添加到父精灵上,这些事件将对所有克隆的精灵起作用。
Payload(载荷)
每个精灵都有一个属于空类“SpritePayload
”的 payload
。这意味着你几乎可以存储任何东西在那里。这是为了让你想给精灵添加额外属性时使用的。如果你想追踪不同精灵的健康状况、一些精灵AI的属性或其他东西,你可以创建一个类并将数据存储在 Sprite
.payload
中。你需要创建一个继承自 SpritePayload
并包含你想存储的值的类。
public class MonsterPayload : SpriteLibrary.SpritePayload
{
public int Health = 1;
}
然后你创建一个新的 payload,设置数据,并将其存储在 Sprite.payload
中。
Shooting Demo 详解
ShootingDemo
是一个可下载的示例,它是一个非常简单的“太空入侵者”风格的游戏。你是一艘在屏幕底部的飞船,精灵在顶部来回弹跳。你射击它们。当怪物被子弹击中时,它们会爆炸。
当游戏实例化时,“InitializeComponents
”函数会触发,并创建 picturebox
。之后,我们设置 picturebox
的背景图片,并设置 backgroundlayout = stretch
。这对于精灵的良好运作很重要。我们按这个顺序做是因为 PictureBox
需要存在并配置好,然后我们才能创建使用该 PictureBox
的 SpriteController
。
在 PictureBox
被定义和配置之后,SpriteController
被创建,并被赋予它应该在其上绘制精灵的 PictureBox
。
设置过程中的最后一个主要项目是向 SpriteController
的“Tick
”添加一个事件。Tick
每秒发生多次,我们添加一个响应按键的函数。
现在精灵控制器已经创建好了,我们想加载各种精灵。这个演示展示了许多不同类型的精灵。精灵演示中有一些是在一张图片上有多个精灵,有一些是一张图片包含一个精灵,还有一些是一张图片包含一个精灵的多个动画。一些精灵上添加了事件(下面会详细说明)。
精灵加载后,我们向游戏中添加了许多精灵,然后游戏就开始了。
当游戏开始时,这个特定的游戏是基于一个 Timer
(计时器)工作的。精灵被设置为向左或向右移动,它们会一直移动直到撞到边缘。那时,一个事件会触发,告诉精灵朝相反方向移动。
与此同时,“CheckForKeypress
”函数每秒会触发多次,以查看用户在做什么。如果用户正在使用左或右键,我们就告诉精灵向左或向右移动。如果按下了空格键,我们就生成一个“子弹”并让它直线向上移动。
如果一个怪物被子弹击中,怪物就会被摧毁。一个“爆炸”精灵会被生成,它的大小和被击中的怪物一样。爆炸被告知只播放一次动画,当动画完成时,它会运行最后一个“事件”。爆炸完成事件会摧毁爆炸精灵,从而将它从游戏中移除。每当一个爆炸完成时,我们就会计算怪物精灵的数量。如果它们都消失了,游戏就结束了。
这是一个非常简单的“游戏”。它只是一个演示,而不是一个你会反复玩的游戏。但它展示了 SpriteController
的大部分功能。
它是如何实现的(代码如何工作)
时间
SpriteController
在一个基于时间的系统中工作。它有一个 Timer
(这是 C# 类的名称),每秒触发多次。由于 C# 处理时间的方式,我们无法精确保证该计时器触发的频率。所以所有东西都记录着上次做某事的时间。例如,一个精灵会记录它上次移动的时间。它知道它每毫秒移动多少像素,所以当 tick 发生时,它发现自上次移动以来已经过去了 15.3 毫秒,它就可以计算出应该移动多远。它对动画也做同样的事情;它计算自上次改变帧以来的时间。如果它需要在 10 毫秒前改变一帧,但已经过去了 15 毫秒,它会改变帧,但假装上次改变动画是在 5 毫秒前。这样,即使计时有微小的抖动,下一次动画仍然设定在正确的时间发生。通过这种方式跟踪时间,即使某些事件发生的时间比其他事件长,所有东西看起来都移动得很平滑。
注意:如果事情开始经常性地花费太长时间,SpriteController
还没有东西来调整时间。大多数游戏会识别到更新缓慢,并相应地调整有效的时间流逝。这并不难做,但我还没有做。
绘图
让 SpriteController
能够工作的原因在于它处理绘制精灵的方式。SpriteController
有一个没有任何精灵的原始背景图像的副本。这用于抓取片段来擦除我们的精灵。为此,它只是抓取精灵所在整个矩形区域的原始背景,并将其绘制在背景上(通常上面已经有精灵了)。我们还有另一个图像,就是显示在 PictureBox
上的那个。这个图像上画满了精灵。当我们重新绘制精灵时,我们在 PictureBox
的图像上绘制它们(就是我们“擦除”过的那个)。我们可能会遇到一些问题,特别是当两个精灵重叠时。在这种情况下,我们告诉每个可能被部分擦除的精灵重新绘制自己。这相当低效,但对于一个简单的精灵控制器来说效果很好。
SpriteController
的设计相对简单。每个精灵都有一个或多个“帧”。每一帧都是一个图像,并有一个“持续时间”。每一次 tick,每个精灵都会检查自己是否需要刷新。如果需要重绘,它会擦除自己(复制其背后的背景图像部分,并将那一小块绘制回主图像上)。然后它移动或改变动画,然后在新的位置或用新的图像再次绘制自己。对于这两者,它都会使 picturebox
失效,但只使覆盖那个特定精灵的 picturebox
部分失效。系统会计算出何时真正重绘图像,但通常工作得相当流畅。
所以简单的描述是,我们有一个较大的图像,显示在 PictureBox
内部。这个较大的图像通过在上面擦除或绘制精灵图像来重绘。而 PictureBox
只对已更改的部分进行“失效”处理。所以程序每次只需要重绘屏幕的一小部分。通过只重绘那一小部分,可以相当快地完成,这使得它看起来很流畅。
移动
告诉精灵如何移动的最平滑的方法是给它一个速度和方向。方向可以指定为度数角度、弧度角度或向量。大多数人会使用度数,因为这似乎是我们这些写简单游戏的人的思维方式。如果你在做三维游戏,向量会非常好用。但这个精灵控制器不能轻易处理 3D。
旋转
你可以告诉一个精灵旋转,精灵会改变它被绘制的角度。所以你可以有一个汽车精灵,让它旋转看起来像是在很多不同方向上行驶。我们做旋转的方式有一个小问题;我们旋转图像,然后将旋转后的图像绘制回原始形状的矩形内。这在非直角时会导致一点点收缩。但它看起来还不错。
镜像
你可以获取一个动画并告诉它垂直或水平镜像。这允许你使用一个行走动画(向左走)并翻转它,使它看起来像在向右走。例如:
Sprite oneSprite = OurSpriteController.DuplicateSprite("walker");
oneSprite.MirrorHorizontally = true;
当你希望它翻转回来时,记得关闭镜像。
MoveTo
为了让精灵控制更理智一些,有一个 Sprite
.MoveTo
函数。你可以给它一个单点,或者一个点列表。精灵会从当前位置移动到指定的点,然后继续移动到后续的每个点。你可以用 CancelMoveTo()
取消 MoveTo
。
当精灵到达每个路点时(除了最后一个点之外的所有沿途点都是路点),SpriteArrivedAtWaypoint
事件会触发。并且,当到达最终点时,SpriteArrivedAtEndPoint
事件会触发。
使用 SpriteLibrary 制作游戏
线程
我们已经提到过 SpriteLibrary
是基于一个每秒发生多次的计时器“Tick
”来运作的。我们还需要提到的是,所有这些都发生在一个“Thread
”(线程)内。我们让每个事件接连发生,但它们通常不会互相中断。它看起来有点像这样:
其中黑线是每隔一段时间就会触发的主要代码块。蓝色和绿色是 Windows 在需要时插入的东西。例如,蓝色是重绘屏幕,绿色是“垃圾回收”(清理未使用的内存)。
关于这一点,重要的是要意识到,你可以做一些事情来阻止线程继续前进。你可以使用 "Thread.Sleep(100)
" 命令,但这会暂停“thread
”中的所有内容。结果是,当线程休眠时,精灵不会移动或播放动画。它们都是同一线程的一部分。
SpriteLibrary
假设你会有触发的事件,并且这些事件在完成后会将控制权返回给 SpriteController
。
主例程
你需要有一个中心函数,通常与 SpriteController
的“Tick
”事件绑定。这个函数要做的第一件事是确定你的代码的状态。你是否在等待什么?你是否应该检查某件事是否已完成?最后,检查你是否应该获取输入并根据当前输入改变你的状态。
ShootingDemo
只有一个检查。它确保我们还没有赢。如果我们还没赢,就检查我们是否应该移动到任何地方或做任何事情。
那么,让我给你一个更复杂的例子。假设我们有一个名为 myDoTick
的函数,它被添加到了 SpriteController
的 DoTick
事件中。我们有三种状态:移动(精灵正在使用 MoveTo
函数移动到屏幕上的一个特定点)、等待按键,和“游戏已结束”。
enum GameState { moving, waiting, end_game }
void myDoTick()
{
select (myGameState)
{
case moving:
if(!mySprite.MovingToPoint)
{
myGameState = waiting;
}
break;
case waiting:
check_for_keypress();
break;
case end_game:
TimeSpan duration = DateTime.UtcNow - TimeWhenWeStartedEnd;
if(duration.TotalSeconds >= 5)
{ //We have delayed 5 seconds after the end of game was reached. Now close
Close();
}
}
}
当我们决定我们赢了游戏时,我们想要设置一个 DateTime
变量:
void EndGame()
{
Sprite newSprite = mySpriteController.DuplicateSprite("GameOver");
newSprite.PutPictureBoxLocation(endGamePoint);
myGameState = GameState.end_game;
TimeWhenWeStartedEnd = DateTime.UtcNow;
}
下一次它进入 myDoTick
函数时,它会带我们到 "case end_game
",这里会检查是否已经过了五秒钟。如果是,那么我们就关闭窗口。
CheckForKeypress
函数会检查是否有任何按键被按下,如果有,就告诉玩家的精灵它应该做什么。
精灵事件
精灵会经历许多事件。它们与 picture-box 的边缘碰撞,它们撞上其他精灵,它们移动到新的位置。如果你能利用这些事件来触发游戏中的不同事物,生活会变得简单得多。例如,在 ShootingDemo
中,每个怪物都会检查自己被什么击中。它们经常被其他怪物击中,但它们也可能被“子弹”击中。当它们被击中时,它们会爆炸。这就是面向对象编程的乐趣所在。你告诉对象,当它被子弹击中时,它会爆炸。然后,当游戏进行时,这似乎就自然发生了。如果你能熟悉一个精灵可能拥有的事件,你很快就会成为使用 SpriteLibrary
编程的专家。
弱点(当你遇到它们时)
这个 SpriteLibrary
对于简单的游戏来说效果很好,但如果你做的东西太复杂,它就会变慢。如果有太多的精灵在四处乱跑,或者你需要比它内置的更多功能,你最终会需要一个更复杂的游戏系统。但对于你的前几个游戏来说,这个应该能很好地工作,至少是这样。
但两个主要的弱点是 SpriteLibrary
的范围有限,主要是为了保持简单,并且由于它处理时间的方式而存在限制。
范围有限
它没有滚动背景,碰撞检测有限,而且它只在精灵数量有限的情况下工作得很好。在你编程时,你会遇到这些限制。你会想找到某种方法来做某件事,却发现它不存在。
时间问题
我之前谈到了一些限制。在这里,我想讨论一下如何注意到你正在遇到基于时间的问题。
如果你有太多的精灵,或者你在一个函数中做了太多的事情,你会开始注意到奇怪的行为。你会注意到精灵的移动相当不连贯。它们会消失然后重新出现在一个不同的位置,等等。你还会看到碰撞检测的问题。精灵会穿过其他精灵而没有触发事件。所以,你会射击某个东西,而子弹会无害地穿过它。
在这种情况下,思考一下你在做什么,以及如何让它运行得更快或更简洁。你需要给 SpriteController
足够的时间,让它能够处理它需要处理的所有事情。
资源
- 一个使用 SpriteLibrary 开发的角色扮演游戏演示
- SpriteLibrary 的在线文档
历史
- 2022年1月6日:版本 1.0.7
- 更改了文档 URL
- 2016年11月23日:版本 1.0.6
- 大幅增加了文档
- 增加了对透明鼠标悬停功能的支持
- 添加了 SpriteDatabase 和链接的 SpriteControllers
- 添加了我的文档 PDF 的第一个版本
- 添加了到开发源代码的链接
- 2016年11月23日:版本 1.0.5
- 一些错误修复,一些新增功能,以及大量更好的文档
- 还添加了一个文档资源链接
- 7-19-2016
- 添加了第二个演示(潜艇游戏)以及一个指向另一个页面的链接,该页面展示了如何使用 SpriteLibrary 制作 RPG 游戏
- 2016年6月30日:版本 1.0.4
- 大量的错误修复(在发布说明中列出)
- 为管理动画添加了一些更多功能,一些声音文件修复,以及其他一些东西
- 4-10-2016
- 一些错误修复(我添加了一个发布说明文件),以及关于使用
SpriteLibrary
设计程序的额外帮助
- 一些错误修复(我添加了一个发布说明文件),以及关于使用
- 3-25-2016
- 根据 CodeProject 建议进行更新
- 3-17-2016
- 首次发布