发表时间:2022-03-25来源:网络
前几天很多朋友要求贴一篇有关C#游戏开发的实例。本篇文章是创建一个简单的卷轴射击游戏开发实例,内容比较完整,分享给大家,不过篇幅有些长,可以慢慢看哈!本文除CSDN博客外,任何媒体和出版物禁止转载和使用。
下面将介绍如何开发一个简单的游戏,首先设计一个基本的计划,然后展示其实现过程。实现将以实效的迭代方式完成。高层次的第一遍开发将使游戏结构可以工作,然后优化这个结构,直到它接近对游戏的最初描述。
通过一个简单的游戏就可以演示到目前为止介绍的所有技术。我们将开发一个2D卷轴射击游戏。这类游戏开发起来很简单,而且可以通过添加更多特性进行扩展。开发这个游戏的一种实效方法是尽快创建一个可以工作的游戏,但是提前计划好最初的各个阶段仍然十分重要。图1显示了游戏流程的一个高层概览。

图1 高层游戏流程
这个游戏的思想是创建一个简单但是完整的游戏。因此,游戏将包括启动屏幕、游戏主体和游戏结束屏幕。游戏主体中将包含玩家可以移动的飞船。玩家应该能够通过按下按键来从飞船前部发射子弹。有一个关卡就可以了,但是多添加几个关卡的效果会更好。当玩家从开始状态进入游戏主体后,关卡便开始。在一定的时间后,关卡结束。如果在关卡结束后玩家仍然存活,则认为玩家胜利,否则玩家失败。游戏中需要一些敌人,它们可以朝向玩家移动,并且能够发射子弹。敌人具有生命值,需要被击中几次后才会死亡。敌人死亡时应该显示爆炸效果。
通过阅读这个简短的游戏描述,很容易开始构建一个类和交互的列表。一种不错的方法是,为主类绘制几个框,然后绘制一些箭头来表示主要的交互。我们需要3个主游戏状态,其中游戏主体状态最复杂。通过游戏描述,可以看到需要的一些重要的类包括Player、Level、Enemy和Bullet。关卡需要包含和更新玩家、敌人和子弹。子弹可以与敌人和玩家发生碰撞。
在游戏的主体中,玩家可以操纵飞船并消灭侵犯自己的敌人。玩家的飞船并不会在太空中实际移动,相反,移动效果是模拟出来的。玩家可以把飞船移动到屏幕上的任意位置,但是“摄像机”总是停留在屏幕正中。为了产生在太空中加速穿行的效果,背景将朝向与玩家前进的方向相反的方向进行卷动。这可以显著地简化玩家跟踪代码或摄像机代码。
这个游戏很小,所以借助这个不太正式的描述就可以开始编码了。所有的游戏代码都放在游戏项目中,而我们生成的可用于多个项目的代码可放在引擎库中。一个更加认真的游戏计划可能需要几个小测试程序,游戏状态非常适合为这种编码思想构建一个基本结构。
高层视图把游戏分为了3个状态。第一遍编码将创建这3个状态,并使它们可以工作。
新建一个Windows Forms Application项目。我把这个项目命名为Shooter,但是你可以随意选择其他的名称。这里概述一下设置项目的过程。建立解决方案的方式与前面章节建立EngineTest项目的方式相似。Shooter项目使用了下面的引用:Tao.DevIL、Tao.OpenGL、Tao.Platform.Windows和System.Drawing。它还需要引擎Engine项目。为此,应该把Engine项目添加到解决方案中(右击Solution文件夹,在弹出的快捷菜单中选择Add|Existing Project命令,然后找到并添加Engine项目)。解决方案中有了Engine项目后,就可以添加对它的引用了。为了添加对Engine项目的引用,右击Shooter项目引用文件夹,在弹出的快捷菜单中选择Add Reference命令,然后打开Projects选项卡,选择Engine项目。
Shooter项目将使用OpenGL,所以在Form编辑器中,将SimpleOpenGLControl拖放到窗体上,并将其Dock属性设为Fill。右击Form1.cs,在弹出的快捷菜单中选择View Code命令。需要为这个文件添加游戏循环和初始化代码,如下所示。
using System; using System.Collections.Generic; using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; using System.Text; using System.Windows.Forms; using Engine; using Engine.Input; using Tao.OpenGl; using Tao.DevIl; namespace Shooter { public partial class Form1 : Form { bool _fullscreen = false; FastLoop _fastLoop; StateSystem _system = new StateSystem(); Input _input = new Input(); TextureManager _textureManager = new TextureManager(); SoundManager _soundManager = new SoundManager(); public Form1() { InitializeComponent(); simpleOpenGlControl1.InitializeContexts(); _input.Mouse = new Mouse(this, simpleOpenGlControl1); _input.Keyboard = new Keyboard(simpleOpenGlControl1); InitializeDisplay(); InitializeSounds(); InitializeTextures(); InitializeFonts(); InitializeGameState(); _fastLoop = new FastLoop(GameLoop); } private void InitializeFonts() { // Fonts are loaded here. } private void InitializeSounds() { // Sounds are loaded here. } private void InitializeGameState() { // Game states are loaded here } private void InitializeTextures() { // Init DevIl Il.ilInit(); Ilu.iluInit(); Ilut.ilutInit(); Ilut.ilutRenderer(Ilut.ILUT_OPENGL); // Textures are loaded here. } private void UpdateInput(double elapsedTime) { _input.Update(elapsedTime); } private void GameLoop(double elapsedTime) { UpdateInput(elapsedTime); _system.Update(elapsedTime); _system.Render(); simpleOpenGlControl1.Refresh(); } private void InitializeDisplay() { if (_fullscreen) { FormBorderStyle = FormBorderStyle.None; WindowState = FormWindowState.Maximized; } else { ClientSize = new Size(1280, 720); } Setup2DGraphics(ClientSize.Width, ClientSize.Height); } protected override void OnClientSizeChanged(EventArgs e) { base.OnClientSizeChanged(e); Gl.glViewport(0, 0, this.ClientSize.Width, this.ClientSize. Height); Setup2DGraphics(ClientSize.Width, ClientSize.Height); } private void Setup2DGraphics(double width, double height) { double halfWidth = width / 2; double halfHeight = height / 2; Gl.glMatrixMode(Gl.GL_PROJECTION); Gl.glLoadIdentity(); Gl.glOrtho(-halfWidth, halfWidth, -halfHeight, halfHeight, -100, 100); Gl.glMatrixMode(Gl.GL_MODELVIEW); Gl.glLoadIdentity(); } } }
在这个Form.cs的代码中,创建了一个Keyboard对象,并把它指派给了Input对象。为使这段代码可以工作,必须在Input类中添加一个Keyboard成员,如下所示。
需要把下面列出的DLL文件添加到bin\Debug和bin\Release文件夹中:alut.dll、DevIL.dll、ILU.dll、OpenAL32.dll和SDL.dll。现在,这个项目就可以用于开发游戏了。
这是我们创建的第一个游戏,当游戏运行时,如果窗体的标题栏显示的不是Form1,而是其他内容,那就更好了。在Visual Studio中修改这些文本很容易。在Solution Explorer中双击文件Form1.cs,打开窗体设计器。单击窗体,并进入Properties窗口(如果没有看到Properties窗口,则从菜单栏中选择View|Properties Window命令)。Properties窗口中列出了窗体的全部属性。找到Text属性,将其值改为Shooter,如图2所示。

图2 修改窗体标题
第一个要创建的状态是开始菜单。在第一遍编码中,菜单只提供了两个选项:Start Game和Exit。这些选项是一种按钮,所以这个状态需要两个按钮和一些标题文本。图3显示了这个屏幕的模拟图。

图3 屏幕的模拟图
标题将使用本书前面定义的Font类和Text类创建。本书配套光盘中有一个字体叫做title font,这是48像素的字体,具有适合在视频游戏中使用的外形。将.fnt和.tga文件添加到项目中,并设置它们的属性,以便在生成项目时它们会被复制到bin目录中。
需要把字体文件加载到Form.cs文件中。如果要处理的字体很多,可能有必要创建一个FontManager类,但是因为我们只会使用一种或两种字体,所以可以把它们存储为成员变量。加载字体文件的代码如下。
private void InitializeTextures() { // Init DevIl Il.ilInit(); Ilu.iluInit(); Ilut.ilutInit(); Ilut.ilutRenderer(Ilut.ILUT_OPENGL); // Textures are loaded here. _textureManager.LoadTexture("title_font", "title_font.tga"); } Engine.Font _titleFont; private void InitializeFonts() { _titleFont = new Engine.Font(_textureManager.Get("title_font"), FontParser.Parse("title_font.fnt")); }
字体纹理在InitializeTextures函数中加载,当在InitializeFonts方法中创建字体对象时将使用这个纹理。
可以把标题字体传递到StartMenuState构造函数中。将下面的新的StartMenuState添加到Shooter项目中。
class StartMenuState : IGameObject { Renderer _renderer = new Renderer(); Text _title; public StartMenuState(Engine.Font titleFont) { _title new Text("Shooter", titleFont); _title.SetColor(new Color(0, 0, 0, 1)); // Center on the x and place somewhere near the top _title.SetPosition(-_title.Width/2,300); } public void Update(double elapsedTime) { } public void Render() { Gl.glClearColor(1, 1, 1, 0); Gl.glClear(Gl.GL_COLOR_BUFFER_BIT); _renderer.DrawText(_title); _renderer.Render(); } }
StartMenuState使用传入构造函数的字体创建标题文本。文本的颜色为黑色,水平居中显示。渲染循环将屏幕清除为白色,然后绘制文本。为了运行这个状态,需要把它添加到状态系统中,并设置为默认状态。
运行程序后,看到的结果如图4所示。

图4 渲染标题
这个阶段只是第一遍处理。在以后可以细化标题,使其更加美观。就现在而言,功能是最重要的。为了完成标题屏幕,需要添加开始和退出选项。这两个选项都是按钮,意味着需要创建一个按钮类。
按钮将在垂直列表中表示。任何时候,都会有一个按钮是选中的按钮。如果用户按下了键盘上的Enter键,或者游戏手柄上的A按键,那么当前选择的按钮将被按下。
按钮需要知道自己何时被选中,也就是获得焦点。它们还需要知道自己被按下时应该采取什么操作,这是一个非常适合使用委托的场合。在构造函数中可以向按钮传递一个委托,当按下按钮时就调用这个委托。按钮还需要设置自己位置的方法。使用代码表示对按钮的这些需求后,得到的类如下所示。Button类是可以重用的,所以可以把它添加到Engine项目中,以便将来的项目也可以使用该类。
public class Button { EventHandler _onPressEvent; Text _label; Vector _position = new Vector(); public Vector Position { get { return _position; } set { _position = value; UpdatePosition(); } } public Button(EventHandler onPressEvent, Text label) { _onPressEvent = onPressEvent; _label label; _label.SetColor(new Color(0, 0, 0, 1)); UpdatePosition(); } public void UpdatePosition() { // Center label text on position. _label.SetPosition(_position.X - (_label.Width / 2), _position.Y + (_label.Height / 2)); } public void OnGainFocus() { _label.SetColor(new Color(1, 0, 0, 1)); } public void OnLoseFocus() { _label.SetColor(new Color(0, 0, 0, 1)); } public void OnPress() { _onPressEvent(this, EventArgs.Empty); } public void Render(Renderer renderer) { renderer.DrawText(_label); } }
Button类不直接处理用户输入,相反,它依赖于使用它的代码向它传递相关的输入事件。OnGainFocus和OnLoseFocus方法根据焦点的状态修改按钮的外观,这样用户就可以知道当前选中了哪个按钮。当按钮的位置改变时,标签文本的位置也会更新并居中。EventHandler中包含当按下按钮时调用的函数,它描述了一个接受对象和事件参数枚举作为参数的委托。
玩家输入由Menu类检测,它通知相关按钮已被选中或按下。Menu类包含一个按钮列表,任何时候只有一个按钮可以拥有焦点。用户可以使用手柄或者键盘导航菜单。OnGainFocus和OnLoseFocus会修改按钮的标签文本,这样用户就可以知道当前哪个按钮拥有焦点。
获得焦点时,字体的颜色变为红色,否则字体的颜色为黑色。还有其他进行区分的方法,例如可以放大文本、修改背景图片或者使用补间显示或者删除其他某个值,但是现在只是第一遍编码,所以不需要进行这些改进。
菜单将把按钮垂直排列在一列中,所以将它命名为VerticalMenu很合适。VerticalMenu也是一个可重用的类,也可以添加到Engine项目中。菜单还需要添加按钮的方法和一个Render方法。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine.Input; // Input needs to be added for gamepad input. using System.Windows.Forms; // Used for keyboard input namespace Engine { public class VerticalMenu { Vector _position = new Vector(); Input.Input _input; List _buttons = new List(); public double Spacing { get; set; } public VerticalMenu(double x, double y, Input.Input input) { _input = input; _position = new Vector(x, y, 0); Spacing = 50; } public void AddButton(Button button) { double _currentY = _position.Y; if (_buttons.Count != 0) { _currentY = _buttons.Last().Position.Y; _currentY -= Spacing; } else { // It's the first button added it should have // focus button.OnGainFocus(); } button.Position = new Vector(_position.X, _currentY, 0); _buttons.Add(button); } public void Render(Renderer renderer) { _buttons.ForEach(x => x.Render(renderer)); } } }
按钮的位置被自动处理。每次添加按钮时,会把新按钮添加到Y轴上其他按钮的下方。Spacing成员决定了按钮之间的距离,默认为50像素。菜单本身也有一个位置,允许把按钮作为一个整体四处移动。这个位置只在构造函数中进行设置。在构造VerticalMenu后,其位置不能改变,因为这需要一个额外的方法在新位置重新排列所有的按钮。这种功能很不错,但是却不是必须具有的。Render方法使用C#新增的lambda操作符来渲染所有按钮。
菜单类还不处理用户输入,但是在添加处理输入的代码之前,首先将菜单与StartMenuState关联起来,以便测试菜单和按钮是否可以正确工作。按钮上的标签将使用与标题文本不同的字体。从本书的配套光盘上找到general_font.fnt和general_font.tga,把它们添加到项目中。然后需要在Form.cs文件中设置这个新字体。
// In form.cs private void InitializeTextures() { // Init DevIl Il.ilInit(); Ilu.iluInit(); Ilut.ilutInit(); Ilut.ilutRenderer(Ilut.ILUT_OPENGL); // Textures are loaded here. _textureManager.LoadTexture("title_font", "title_font.tga"); _textureManager.LoadTexture("general_font", "general_font.tga"); } Engine.Font _generalFont; Engine.Font _titleFont; private void InitializeFonts() { // Fonts are loaded here. _titleFont = new Engine.Font(_textureManager.Get("title_font"), FontParser.Parse("title_font.fnt")); _generalFont = new Engine.Font(_textureManager.Get("general_font"), FontParser.Parse("general_font.fnt")); }
这个新的通用字体可以传递给构造函数中的StartMenuState,以构造垂直菜单。此时也会传递Input类,所以需要把using Engine.Input语句添加到StartMenuState.cs文件顶部。
Engine.Font _generalFont; Input _input; VerticalMenu _menu; public StartMenuState(Engine.Font titleFont, Engine.Font generalFont, Input = input) { _input = input; _generalFont = generalFont; InitializeMenu();
实际的菜单创建工作是在InitializeMenu函数中完成的,这样可以避免StartMenuState构造函数变得拥挤。StartMenuState创建了一个在X轴居中、在Y轴上方150像素位置的垂直菜单。这会在标题文本下方以比较整洁的方式放置菜单。
private void InitializeMenu() { _menu = new VerticalMenu(0, 150, _input); Button startGame = new Button( delegate(object o, EventArgs e) { // Do start game functionality. }, new Text("Start", _generalFont)); Button exitGame = new Button( delegate(object o, EventArgs e) { // Quit System.Windows.Forms.Application.Exit(); }, new Text("Exit", _generalFont)); _menu.AddButton(startGame); _menu.AddButton(exitGame); }
这里创建了两个按钮:一个用于退出游戏,一个用于开始游戏。显然,添加其他按钮也并不困难(例如,使用这个系统添加载入游戏、致谢、设置或访问网站等按钮也相当简单)。Exit按钮的委托被完全实现,当调用它时,将退出程序。Start菜单按钮的功能现在还是空的,当创建了游戏主体状态后,将实现相关功能。
现在已经成功地创建了垂直菜单,但是只有添加到渲染循环以后才可以看到它。
public void Render() { Gl.glClearColor(1, 1, 1, 0); Gl.glClear(Gl.GL_COLOR_BUFFER_BIT); _renderer.DrawText(_title); _menu.Render(_renderer); _renderer.Render(); }
运行程序,菜单将在标题下显示出来。
现在只剩下输入处理代码还没有实现。菜单将通过游戏手柄的左控制杆或键盘进行导航。需要一些额外的逻辑来判断控制杆何时被摇上或者摇下。如下所示为VerticalMenu类的HandleInput类,其中显示了这种逻辑。可能还需要修改Input类,使Controller成员变为公有,这样就可以从Engine项目以外访问它了。
bool _inDown = false; bool _inUp = false; int _currentFocus = 0; public void HandleInput() { bool controlPadDown = false; bool controlPadUp = false; float invertY = _input.Controller.LeftControlStick.Y * -1; if (invertY < -0.2) { // The control stick is pulled down if (_inDown == false) { controlPadDown = true; _inDown = true; } } else { _inDown = false; } if (invertY > 0.2) { if (_inUp == false) { controlPadUp = true; _inUp = true; } } else { _inUp = false; } if (_input.Keyboard.IsKeyPressed(Keys.Down) || controlPadDown) { OnDown(); } else if(_input.Keyboard.IsKeyPressed(Keys.Up) || controlPadUp) { OnUp(); } }
需要在StartMenuState.Update方法中调用HandleInput函数。如果没有添加这个调用,将无法检测到任何输入。HandleInput检测到与垂直菜单有关的特定输入,然后调用其他函数来处理输入。现在只有两个函数OnUp和OnDown,它们将修改当前拥有焦点的菜单项。
private void OnUp() { int oldFocus = _currentFocus; _currentFocus++; if (_currentFocus == _buttons.Count) { _currentFocus = 0; } ChangeFocus(oldFocus, _currentFocus); } private void OnDown() { int oldFocus = _currentFocus; _currentFocus–; if (_currentFocus == -1) { _currentFocus = (_buttons.Count - 1); } ChangeFocus(oldFocus, _currentFocus); } private void ChangeFocus(int from, int to) { if (from != to) { _buttons[from].OnLoseFocus(); _buttons[to].OnGainFocus(); } }
通过按键盘上的上下方向键,焦点会在垂直菜单的按钮之间上下移动。焦点还会发生环绕。如果对菜单最顶部的按钮按上方向键,焦点将转移到菜单底部的按钮。ChangeFocus方法减少了重复的代码,它告诉一个按钮已丢失焦点,并告诉另外一个按钮已获得焦点。
现在可以选择按钮,但是还没有代码来处理按钮被按下的情况。修改VerticalMenu类,以检测到手柄上的A按键或键盘上的Enter键何时被按下。检测到这些键被按下 ,将调用当前选中的按钮的委托。
// Inside the HandleInput function else if(_input.Keyboard.IsKeyPressed(Keys.Up) || controlPadUp) { OnUp(); } else if (_input.Keyboard.IsKeyPressed(Keys.Enter) || _input.Controller.ButtonA.Pressed) { OnButtonPress(); } } private void OnButtonPress() { _buttons[_currentFocus].OnPress(); }
运行代码,并使用键盘或手柄来导航菜单。按下Exit按钮将退出游戏,但是按下Start按钮现在不会发生任何操作。Start按钮需要将状态修改为游戏主体状态,这意味着StartMenuState需要能够访问状态系统。
还需要修改StartMenuState构造函数,它将保存对状态系统的引用。
Start按钮可以使用这些代码在自己被按下时修改状态。Start按钮在InitializeMenu方法中建立,需要对其做如下修改。
inner_game状态还不存在,但是马上我们就会开发该状态。对于第一遍编码,现在的Start菜单已经完整了。运行程序得到的结果如图5所示。

图5 第一遍编码中的Start游戏菜单
在后面进行编码时可以根据需要修改这个菜单,添加更多的动画、演示等。
对于第一遍编码,游戏主体将尽可能简单。它会等待几秒,然后变为游戏结束状态。游戏主体状态。还需要向游戏结束状态传递一些信息,以报告玩家是获胜还是失败。
需要使用一个PersistentGameData类来存储与玩家有关的信息,包括获胜还是失败的信息。最终,游戏主体将允许玩家玩一个射击游戏,但是在第一遍编码中不提供这种功能。
游戏主体的关卡将持续固定的时间,如果到了指定的时间后玩家依然存活,那么玩家获胜。关卡需要的时间通过LevelDescription类描述。就现在而言,这个类只包含关卡的持续时间。
class LevelDescription { // Time a level lasts in seconds. public double Time { get; set; } }
PersistentGameData类中将包含对当前关卡的描述,以及玩家在该关卡中是否获胜的信息。
JustWon成员在构造函数中被设为false,因为玩家不能在还未创建游戏数据的时候就获胜。在Form.cs文件中创建PersistentGAmeData类。添加一个将从构造函数中调用的新函数InitializeGameData,它应该在调用InitializeTextures之后、创建游戏字体之前调用。
建立这个类之后,设计InnerGameState就会变得很容易。
代码很标准。创建敌人列表后,向其中添加了一个敌人。然后使用lambda语法更新并渲染列表。现在运行程序,应该看到两个飞船:玩家飞船和面对玩家飞船的敌人。在当前的代码中,玩家可以飞过敌人飞船,而没有任何反应。如果玩家撞到了敌人的飞船,应该遭受损伤,或者游戏状态应该变为游戏结束状态。但是在发生这种行为前,需要检测碰撞。
这里的碰撞检测就是本书前面探讨的矩形-矩形碰撞。在对碰撞进行编码之前,绘制出敌人飞船的包围框会很有用。通过使用OpenGL的立即模式和GL_LINE_LOOP,这并不困难。将下面的代码添加到Enemy类中。
public RectangleF GetBoundingBox() { float width = (float)(_spaceship.Texture.Width * _scale); float height = (float)(_spaceship.Texture.Height * _scale); return new RectangleF( (float)_spaceship.GetPosition().X - width / 2, (float)_spaceship.GetPosition().Y - height / 2, width, height); } // Render a bounding box public void Render_Debug() { Gl.glDisable(Gl.GL_TEXTURE_2D); RectangleF bounds = GetBoundingBox(); Gl.glBegin(Gl.GL_LINE_LOOP); { Gl.glColor3f(1, 0, 0); Gl.glVertex2f(bounds.Left, bounds.Top); Gl.glVertex2f(bounds.Right, bounds.Top); Gl.glVertex2f(bounds.Right, bounds.Bottom); Gl.glVertex2f(bounds.Left, bounds.Bottom); } Gl.glEnd(); Gl.glEnable(Gl.GL_TEXTURE_2D); }
这里使用了C#的RectangleF类,因此,需要在Enemy.cs文件的顶部添加using System.Drawing库。函数GetBoundingBox使用精灵来计算出其周围的包围框。包围框的宽度和高度根据精灵进行缩放,所以即使精灵被缩放了,包围框也是正确的。RectangleF构造函数接受左上角的x和y位置,以及矩形的宽度和高度作为参数。精灵的位置就是其中心的位置,所以为了获得左上角的坐标,必须从其位置减去一半的宽度和高度。
Render_Debug方法在精灵旁边绘制了一个红色的方框。应该从Enemy.Render方法中调用这个Render_Debug方法。在不需要它时,任何时候都可以删除这个调试函数。
public void Render(Renderer renderer) { renderer.DrawSprite(_spaceship); Render_Debug(); }
运行代码后,敌人的周围将显示一个红色的方框,如图7所示。可视的调试例程是理解代码功能的一种极佳的方式。

图7 敌人包围框
GetBoundingBox函数可以用来确定敌人是否与其他某个对象发生碰撞。现在,玩家飞船没有GetBoundingBox函数,而DRY(Don't Repeat Yourself)原则意味着不应该简单复制这段代码。相反,需要创建一个新的父类来把这种功能集中到一起,然后Enemy和PlayerCharacter都可以从该父类继承。
在使Enemy和PlayerCharacter类一般化之前,需要修改这个Sprite类。为了简化包围框绘制函数,精灵中应该包含一些方法来报告当前的缩放比例。
public class Sprite { double _scaleX = 1; double _scaleY = 1; public double ScaleX { get { return _scaleX; } } public double ScaleY { get { return _scaleY; } }
修改Sprite类是对Engine库的修改,对这种修改不应掉以轻心。这里的修改是一个很有益的修改,将来任何使用Engine库的项目都会受益。更新Sprite方法后,可以在Shooter项目中创建出Entity类。
Entity类包含一个精灵,以及渲染该精灵的包围框的一些代码。
定义了Entity以后,Enemy类可以得到显著简化。
现在Enemy也是一个Entity,不再需要自己对精灵的引用。对PlayerCharacter类可以应用相同的重构。
public class PlayerCharacter : Entity { double _speed = 512; // pixels per second public void Move(Vector amount) { amount *= _speed; _sprite.SetPosition(_sprite.GetPosition() + amount); } public PlayerCharacter(TextureManager textureManager) { _sprite.Texture = textureManager.Get("player_ship"); _sprite.SetScale(0.5, 0.5); // spaceship is quite big, scale it down. } public void Render(Renderer renderer) { Render_Debug(); renderer.DrawSprite(_sprite); } }
再次运行代码,现在敌人和玩家的周围都有了合适的包围框。
现在的规则是,如果PlayerCharacter击中一个敌人,游戏结束。以后可以通过给敌人增加生命值来细化游戏。为了尽快得到可以工作的游戏,现在选择一击致命。
首先需要做出的修改发生在InnerGameState中,它需要能够确定玩家角色何时死亡,此时当前关卡无法完成。
public void Update(double elapsedTime) { _level.Update(elapsedTime); _gameTime -= elapsedTime; if (_gameTime x.Render(renderer)); _playerCharacter.Render(renderer); _bulletManager.Render(renderer); }
最后才渲染BulletManager类,这样将在其他对象之上渲染子弹。现在,BulletManager被完全整合了,但是如果不能让玩家发射子弹,就没有办法测试它。为了给玩家提供这种能力,PlayerCharacter需要能够访问子弹管理器。在Level构造函数中,将BulletManager传递给PlayerCharacter构造函数。
PlayerCharacter构造函数还存储了在发射子弹时将使用的bulletTexture。为了发射子弹,需要创建一个bullet对象,并使其在玩家的附近开始显示,然后把它传递给BulletManager。PlayerCharacter类中新增加的Fire方法将负责完成这些处理。
通过在构造函数中建立的bulletTexture可创建子弹。它被设置为绿色,但是你可以选择其他任意一种颜色。子弹的位置被设置为从玩家的飞船偏移一定位置,这样子弹看起来是从飞船的前端发射出去的。如果没有偏移,子弹刚好出现在飞船精灵的正中,这看上起有点奇怪。这里没有修改子弹的方向,因为在X轴上前向是默认值。默认的速度设置也是可以接受的。最后,将子弹分配给BulletManager,并使用Shoot方法正式把它发射出去。
玩家现在可以发射子弹,但是没有检测输入和调用Fire方法的代码。玩家的所有输入将在Level类的Update方法中被处理。将输入代码放到Update方法中看上起有点混乱,所以我把这些代码提取为一个新函数UpdateInput,这样代码看上去就更加整洁了。
取出Update循环的所有输入代码,并把它放到新的UpdateInput方法的结尾。然后从Update方法中调用这个UpdateInput方法。另外还添加了一些新代码来处理玩家发射子弹的动作。如果按下了键盘上的空格键或者手柄上的A按键,那么玩家就发射了一个子弹。运行程序,尝试飞船新增的开火功能。
从图8中可以看到新添加的子弹。每次玩家按下发射按键时,都会创建子弹。处于可玩性考虑,最好降低发射速度,使飞船在连续发射子弹之间有一个恢复时间。修改PlayerCharacter类如下。
图8 发射子弹
为了对恢复时间进行倒计时,需要在PlayerCharacter类中添加一个Update方法。Update方法将对恢复时间进行倒计时,但是绝不会低于0。这是使用Math.Max函数完成的。设置了恢复时间后,如果飞船仍然处在从上一次发射恢复的状态,Fire命令将被立即返回。如果恢复时间为0,飞船可以发射子弹,恢复时间将被重设,以便重新开始倒计时。
还需要对Level类做一点小修改:它需要调用PlayerCharacter的Update方法。
再次运行程序,现在不能像以前那样快速发射子弹了。这是你自己的游戏,所以只要你觉得合适,可以随意调整恢复速度。
现在添加了子弹,但是它们直接穿过敌人飞船,却不会造成任何伤害。接下来我们就来解决这个问题。敌人应该知道自己何时被击中,并相应地做出恰当的反应。我们将首先处理碰撞问题,然后创建一个爆炸动画。
碰撞代码在Level类的UpdateCollisions函数中处理。需要扩展这个函数,使其也可以处理子弹和敌人之间的碰撞。
在for循环的末尾新添加了一行,让BulletManager检查子弹与当前敌人之间是否发生碰撞。UpdateEnemyCollisions是BulletManager中的新函数,所以需要实现它。
子弹和敌人之间的碰撞通过检查包围框是否相交进行判断。如果子弹击中了敌人,则销毁子弹并通知敌人发生碰撞。
子弹击中敌人后,有多种不同的方式进行反应。可以立即销毁敌人并播放爆炸动画,或者可以使敌人遭受一定的伤害,还需要再击中敌人几次后才销毁它。为敌人分配生命值这种功能在将来可能会需要,不如现在就将这种功能添加进来。为敌人添加一个Health成员变量,然后可以实现子弹的OnCollision函数。
Enemy类已经有一个OnCollision方法,但是该方法用于敌人与PlayerCharacter的碰撞。我们将创建一个新的重载后的OnCollision方法,它只关心与子弹的碰撞。当子弹击中敌人后,敌人会受到一些伤害,其生命值会降低。如果敌人的生命值低于0,则会被销毁。如果玩家击中了敌人,并使其受到了伤害,需要在视觉上有一些显示,以表明敌人被击中了。表示这种反馈的一种好方法是在零点几秒的时间内使敌人飞船闪烁黄色。
static readonly double HitFlashTime = 0.25; double _hitFlashCountDown = 0; internal void OnCollision(Bullet bullet) { // If the ship is already dead then ignore any more bullets. if (Health == 0) { return; } Health = Math.Max(0, Health - 25); _hitFlashCountDown = HitFlashTime; // half _sprite.SetColor(new Engine.Color(1, 1, 0, 1)); if (Health == 0) { OnDestroyed(); } } private void OnDestroyed() { // Kill the enemy here. }
OnDestroyed函数现在还只是一个占位函数,稍后我们才会关心如何销毁敌人。在OnCollision函数中,第一个if语句会检查飞船的生命值是否为0。在这里将忽略任何额外的伤害,因为玩家已经击毁了敌人,游戏不需要再考虑其他命中的子弹。接下来将Health的值减少25,这是一个随意选取的数字,代表子弹击中一次时造成的伤害。Math.Max用于确保生命值不会低于0。当被击中时,飞船将闪烁黄色。设置的倒计时用于代表闪烁的时间。飞船精灵被设为黄色,其RGBA值为(1,1,0,1)。最后,检查生命值,如果生命值等于0,则调用占位函数OnDestroyed,爆炸将在这个函数中触发。
为了使飞船开始闪烁,需要修改Update循环。在该方法中需要倒计时闪烁时间,并把颜色从黄色改为白色。
Update循环修改敌人飞船的闪烁颜色。如果闪烁时间降低为0,那么闪烁已经结束,不需要再进行更新。如果_hitFlashCountDown不等于0,则将它减去从上一帧后经过的时间。这里再次使用Math.Max来确保倒计时不会低于0。然后倒计时被减小到0~1之间的一个值,以此表示闪烁的进度:0代表闪烁刚开始,1代表闪烁已结束。将1减去得到的这个值,从而使数字的意义刚好相反:1代表闪烁刚开始,0代表闪烁已经结束。然后使用这个缩放后的数值在0~1之间改变颜色的蓝色通道。这会使飞船的闪烁颜色从黄色变为白色。
运行程序,然后多次击中敌人飞船,它会多次闪烁黄色,然后被销毁,从而停止了响应。但是,敌人的飞船不能只是停止响应,它们应该爆炸。
产生一个出色的爆炸效果的最简单的方法是使用动画精灵。图9显示了一次爆炸的关键帧纹理贴图。纹理使用过程式爆炸生成器创建,该生成器可以从Positech游戏(http://www.positech.co.uk/content/explosion/explosiongenerator.html)上免费下载。
图9总共有16个帧,横向4个、纵向4个。通过读取这个纹理并修改(U,V)坐标,使其随时间从第一个帧移动到最后一个帧,可以创建动画精灵。动画精灵只不过是另外一类精灵,所以为了创建它们,需扩展已有的Sprite类。动画精灵可以被多种不同的游戏使用,所以应该在Engine项目中而不是在游戏项目中创建它们。

图9 动画显示的爆炸纹理贴图
public class AnimatedSprite : Sprite { int _framesX; int _framesY; int _currentFrame = 0; double _currentFrameTime = 0.03; public double Speed { get; set; } // seconds per frame public bool Looping { get; set; } public bool Finished { get; set; } public AnimatedSprite() { Looping = false; Finished = false; Speed = 0.03; // 30 fps-ish _currentFrameTime = Speed; } public System.Drawing.Point GetIndexFromFrame(int frame) { System.Drawing.Point point = new System.Drawing.Point(); point.Y = frame / _framesX; point.X = frame - (point.Y * _framesY); return point; } private void UpdateUVs() { System.Drawing.Point index = GetIndexFromFrame(_currentFrame); float frameWidth = 1.0f / (float)_framesX; float frameHeight = 1.0f / (float)_framesY; SetUVs(new Point(index.X * frameWidth, index.Y * frameHeight), new Point((index.X t 1) * frameWidth, (index.Y t 1) * frameHeight)); } public void SetAnimation(int framesX, int framesY) { _framesX = framesX; _framesY = framesY; UpdateUVs(); } private int GetFrameCount() { return _framesX * _framesY; } public void AdvanceFrame() { int numberOfFrames = GetFrameCount(); _currentFrame = (_currentFrame + 1) % numberOfFrames; } public int GetCurrentFrame() { return _currentFrame; } public void Update(double elapsedTime) { if (_currentFrame == GetFrameCount() - 1 && Looping == false) { Finished = true; return; } _currentFrameTime -= elapsedTime; if (_currentFrameTime < 0) { AdvanceFrame(); _currentFrameTime = Speed; UpdateUVs(); } } }
这个AnimatedSprite类的工作方式与Sprite类完全相同,只不过可以告诉AnimatedSprite类纹理在X和Y方向上有多少帧。调用Update循环时,帧随时间改变。
这个类有好几个成员,但是它们主要用于描述动画和跟踪其进度。X和Y方向上的帧数由_framesX和_framesY成员变量描述。对于图9中的示例,这两个变量都将被设为4。_currentFrame变量是精灵的(U,V)当前被设置的帧。_currentFrameTime是动画前进到下一帧之前,当前帧将占用的时间。Speed度量每一帧占用的时间,单位为s。Looping决定动画是否应该循环。Finished是一个标志,当动画结束后被设置为true。
AnimatedSprite的构造函数设置一些默认值。新创建的精灵不循环,Finished标志被设为false,帧速被设为每秒大约30帧,_currentFrameTime被设为0.03s,这会使动画以每秒30帧的速度运行。
GetIndexFromFrame方法接受如图10所示的索引为参数,返回索引位置的(X,Y)坐标。例如,索引0将返回(0,0),索引15将返回(3,3)。通过把索引除以行的长度,索引被分解为(X,Y)坐标,除法得到了行数,从而也得到了索引的Y坐标。X坐标就是将Y行移除后索引剩下的部分。进行平移时,这个函数非常有用,可以计算出特定帧的(U,V)坐标。

图10 索引
UpdateUVs使用当前帧索引来修改(U,V),所以精灵可以正确地代表帧。它首先使用GetIndexFromFrame获得当前帧的(X,Y)坐标,然后计算各个帧的宽度和高度。纹理坐标在0~1之间,单个帧的宽度和高度通过用1除以X轴和Y轴的帧数计算出来。计算出单个帧的尺寸后,可以通过把帧的宽度和高度乘以当前帧的(X,Y)坐标计算出来(U,V)的位置,这将得到帧在纹理贴图上左上角的点。SetUVs方法需要左上角的点和右下角的点作为参数。右下角的位置通过把左上角的点加上一个帧的宽度和高度计算出来。
SetAnimation用于设置纹理贴图在X方向和Y方向上的帧数。它调用UpdateUVs,以更新精灵来显示正确的帧。GetFrameCount获得动画中的总帧数。AdvanceFrame方法将动画移动到下一帧,如果到达最后一帧,则索引将变为0。这种环绕是使用取模运算符%完成的。取模运算符用于计算整数除法的余数。理解取模运算符的用途的最佳方式是向你提供一个你可能已经很熟悉的示例:时间。钟表上有12个数字,它以与12取模的方式工作:13点与12取模是1点。在这里,模数等于动画中的总帧数。
Update方法负责更新当前帧,并使爆炸看上去以动画方式显示。如果Looping被设为false,并且当前帧是最后一帧,那么Update方法会立即返回,且Finished标志被设为true。如果动画还未结束或正在循环,则更新帧倒计时_currentFrameTime,如果该值小于0,则需要修改帧。帧的更新是通过调用AdvanceFrame、重置_currentFrameTime并最终更新(U,V)完成的。
把AnimatedSprite类添加到Engine项目中后,可以测试爆炸。从本书配套光盘的Assets文件夹中找到explode.tga文件,把它添加到项目中,并像以前一样设置属性。然后可以在form.cs中加载它。
_textureManager.LoadTexture("explosion", "explode.tga");
一种快速测试动画的方法是把它作为动画精灵直接加载到Level中。
运行程序并进入关卡,现在将播放一次爆炸动画。这证明了代码工作正确(见图11)。

图11 游戏中的爆炸效果
在第10.3.5节中,我们创建了爆炸效果,但是只有毁灭敌人时才应该引发这种爆炸效果。为此,需要创建两个新系统:一个处理爆炸和一般的游戏效果,另一个处理迫近的敌人。
处理爆炸的方式应该与处理子弹的方式类似,即创建一个专门的管理器来处理爆炸的创建和销毁。将来你自己创建项目时,可能想添加更多的效果,例如烟雾、火星或者增加玩家能力的东西。EffectsManager类应该在Shooter项目中创建。
using System; using System.Collections.Generic; using System.Linq; using System.Text; using Engine; namespace Shooter { public class EffectsManager { List _effects = new List(); TextureManager _textureManager; public EffectsManager(TextureManager textureManager) { _textureManager = textureManager; } public void AddExplosion(Vector position) { AnimatedSprite explosion = new AnimatedSprite(); explosion.Texture = _textureManager.Get("explosion"); explosion.SetAnimation(4, 4); explosion.SetPosition(position); _effects.Add(explosion); } public void Update(double elapsedTime) { _effects.ForEach(x => x.Update(elapsedTime)); RemoveDeadExplosions(); } public void Render(Renderer renderer) { _effects.ForEach(x => renderer.DrawSprite(x)); } private void RemoveDeadExplosions() { for (int i = _effects.Count - 1; i >= 0; i–) { if (_effects[i].Finished) { _effects.RemoveAt(i); } } } } }
EffectsManager引发爆炸,播放爆炸动画直到其结束,然后删除爆炸效果。注意它与BulletManager类很相似。这些独立的管理器可以合并为一个通用的管理器,但是通过使它们保持独立,游戏对象之间的交互会更加具体而高效。爆炸不关心与敌人或玩家的碰撞检测,但是子弹则相反。在独立的管理器中,分离出每个对象的特定需求很容易:爆炸只需要运行动画,而子弹需要检查与所有敌人的相交。当游戏中只有少量的对象时,独立的管理器的效果很好,但是如果游戏中将存在很多个不同的实体,那么更通用的实体管理器是更好的选择。
EffectsManager需要在Level类中实例化,并与渲染和更新循环关联在一起。
EffectsManager _effectsManager; public Level(Input input, TextureManager textureManager, PersistantGameData gameData) { _input = input; _gameData = gameData; _textureManager = textureManager; _effectsManager = new EffectsManager(_textureManager); // code omitted public void Update(double elapsedTime) { _effectsManager.Update(elapsedTime); // code omitted public void Render(Renderer renderer) { // Background, sprites and bullet code omitted _effectsManager.Render(renderer); renderer.Render(); }
ExplosionManager现在已被关联,可以用于同时启动多个爆炸。为了使敌人在死亡时可以启动爆炸,需要使它们能够访问管理器,所以将管理器作为参数传递给Enemy的构造函数。
EffectsManager _effectsManager; public Enemy(TextureManager textureManager, EffectsManager effectsManager) { _effectsManager = effectsManager;
现在敌人就可以在死亡时引发爆炸了。
private void OnDestroyed() { // Kill the enemy here. _effectsManager.AddExplosion(_sprite.GetPosition()); }
在Level.cs文件中,需要把EffectsManager传入Enemy的构造函数。完成这个操作以后,在游戏中多次击中敌人将会消灭敌人并引发爆炸。
接下来,为敌人创建自己的管理器,这是为了开发出完整可工作的游戏需要创建的最后一个管理器。
public class EnemyManager { List _enemies = new List(); TextureManager _textureManager; EffectsManager _effectsManager; int _leftBound; public List EnemyList { get { return _enemies; } } public EnemyManager(TextureManager textureManager, EffectsManager effectsManager, int leftBound) { _textureManager = textureManager; _effectsManager = effectsManager; _leftBound = leftBound; // Add a test enemy. Enemy enemy = new Enemy(_textureManager, _effectsManager); _enemies.Add(enemy); } public void Update(double elapsedTime) { _enemies.ForEach(x => x.Update(elapsedTime)); CheckForOutOfBounds(); RemoveDeadEnemies(); } private void CheckForOutOfBounds() { foreach (Enemy enemy in _enemies) { if (enemy.GetBoundingBox().Right < _leftBound) { enemy.Health = 0; // kill the enemy off } } } public void Render(Renderer renderer) { _enemies.ForEach(x => x.Render(renderer)); } private void RemoveDeadEnemies() { for (int i = _enemies.Count - 1; i >= 0; i–) { if (_enemies[i].IsDead) { _enemies.RemoveAt(i); } } } }
还需要在Enemy类中添加另外一个函数来检查敌人是否被消灭。
class Enemy : Entity { public bool IsDead { get { return Health == 0; } }
如果敌人的生命值等于0,则Enemy类的IsDead方法返回true,否则返回false。EnemyManager与BulletManager一样有越界检查,但是它稍有不同。卷轴射击游戏中的敌人一般从屏幕的最右边开始出现,然后越过玩家从屏幕左边离开。越界检查比较敌人包围框最右边的点与屏幕最左边的点,这样就可以删除没有被玩家消灭、从屏幕左边逃脱的敌人。
现在需要修改Level类来引入这个新的管理器,并删除旧列表。
皓盘云建最新版下载v9.0 安卓版
53.38MB |商务办公
ris云客移动销售系统最新版下载v1.1.25 安卓手机版
42.71M |商务办公
粤语翻译帮app下载v1.1.1 安卓版
60.01MB |生活服务
人生笔记app官方版下载v1.19.4 安卓版
125.88MB |系统工具
萝卜笔记app下载v1.1.6 安卓版
46.29MB |生活服务
贯联商户端app下载v6.1.8 安卓版
12.54MB |商务办公
jotmo笔记app下载v2.30.0 安卓版
50.06MB |系统工具
鑫钜出行共享汽车app下载v1.5.2
44.7M |生活服务