C#游戏编程之创建一个简单的卷轴射击游戏


前几天很多朋友要求贴一篇有关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成员,如下所示。

public class Input
{
public Mouse Mouse { get; set; }
public Keyboard Keyboard { get; set; }
public XboxController Controller { get; set; }


需要把下面列出的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  修改窗体标题

1.  开始菜单的状态

第一个要创建的状态是开始菜单。在第一遍编码中,菜单只提供了两个选项: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使用传入构造函数的字体创建标题文本。文本的颜色为黑色,水平居中显示。渲染循环将屏幕清除为白色,然后绘制文本。为了运行这个状态,需要把它添加到状态系统中,并设置为默认状态。 

private void InitializeGameState()
{
// Game states are loaded here
_system.AddState("start_menu", new StartMenuState(_titleFont));
_system.ChangeState("start_menu");
}

 

运行程序后,看到的结果如图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<Button> _buttons = new List<Button>();
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需要能够访问状态系统。

private void InitializeGameState()
{
_system.AddState("start_menu", new StartMenuState(_titleFont,
_generalFont, _input, _system));


还需要修改StartMenuState构造函数,它将保存对状态系统的引用。

StateSystem _system;
public StartMenuState(Engine.Font titleFont, Engine.Font generalFont,
Input input, StateSystem system)
{
_system   system;


Start按钮可以使用这些代码在自己被按下时修改状态。Start按钮在InitializeMenu方法中建立,需要对其做如下修改。

Button startGame = new Button(
delegate(object o, EventArgs e)
{
_system.ChangeState("inner_game");
},
new Text("Start", _generalFont));


inner_game状态还不存在,但是马上我们就会开发该状态。对于第一遍编码,现在的Start菜单已经完整了。运行程序得到的结果如图5所示。

图5  第一遍编码中的Start游戏菜单

在后面进行编码时可以根据需要修改这个菜单,添加更多的动画、演示等。

2.  游戏主体状态

对于第一遍编码,游戏主体将尽可能简单。它会等待几秒,然后变为游戏结束状态。游戏主体状态。还需要向游戏结束状态传递一些信息,以报告玩家是获胜还是失败。

需要使用一个PersistentGameData类来存储与玩家有关的信息,包括获胜还是失败的信息。最终,游戏主体将允许玩家玩一个射击游戏,但是在第一遍编码中不提供这种功能。

游戏主体的关卡将持续固定的时间,如果到了指定的时间后玩家依然存活,那么玩家获胜。关卡需要的时间通过LevelDescription类描述。就现在而言,这个类只包含关卡的持续时间。

class LevelDescription
{
// Time a level lasts in seconds.
public double Time { get; set; }
}


PersistentGameData类中将包含对当前关卡的描述,以及玩家在该关卡中是否获胜的信息。

class PersistantGameData
{
public bool JustWon { get; set; }
public LevelDescription CurrentLevel { get; set; }
public PersistantGameData()
{
JustWon = false;
}
}


JustWon成员在构造函数中被设为false,因为玩家不能在还未创建游戏数据的时候就获胜。在Form.cs文件中创建PersistentGAmeData类。添加一个将从构造函数中调用的新函数InitializeGameData,它应该在调用InitializeTextures之后、创建游戏字体之前调用。
 

PersistantGameData _persistantGameData = new PersistantGameData();
private void InitializeGameData()
{
LevelDescription level = new LevelDescription();
level.Time = 1; // level only lasts for a second
_persistantGameData.CurrentLevel = level;
}


建立这个类之后,设计InnerGameState就会变得很容易。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Engine;
using Engine.Input;
using Tao.OpenGl;

namespace Shooter
{
class InnerGameState : IGameObject
{
Renderer _renderer = new Renderer();
Input _input;
StateSystem _system;
PersistantGameData _gameData;
Font _generalFont;

double _gameTime;

public InnerGameState(StateSystem system, Input input, PersistantGameData
gameData, Font generalFont)
{
_input = input;
_system = system;
_gameData = gameData;
_generalFont = generalFont;
OnGameStart();
}

public void OnGameStart()
{
_gameTime = _gameData.CurrentLevel.Time;
}

#region IGameObject Members

public void Update(double elapsedTime)
{
_gameTime -= elapsedTime;

if (_gameTime <= 0)
{
OnGameStart();
_gameData.JustWon = true;
_system.ChangeState("game_over");
}
}

public void Render()
{
Gl.glClearColor(1, 0, 1, 0);
Gl.glClear(Gl.GL_COLOR_BUFFER_BIT);
_renderer.Render();
}
#endregion
}
}



构造函数接受StateSystem和PersistentGameData作为参数。通过使用这些类,InnerGameState可以确定游戏何时结束,并修改游戏状态。构造函数还接受输入和通用字体,因为在第二遍编码中添加功能时它们会很有用。构造函数调用OnGameStart,该方法设置的变量gameTime确定了游戏关卡的持续时间。现在还没有关卡内容,所以时间被设为1s。

Update函数对关卡时间进行倒计时。时间结束后,状态将被修改为game_over。gameTime通过调用OnGameState重设,因为此时玩家仍然存活,gameData对象的JustWon标志被设为true。游戏主体状态的Render函数将屏幕清除为粉红色,所以状态变化的时间变得很明显。

应该使用InnerGameState类向Form.cs文件的状态系统中添加另外一个状态。

_system.AddState("inner_game", new InnerGameState(_system, _input,
_persistantGameData, _generalFont));


游戏主体的第一遍编码到此结束。

3.  游戏结束状态

游戏结束状态是一个简单的状态,告诉玩家游戏已经结束,他获胜或者失败了。这个状态通过使用PersistentGameData类确定玩家是否获胜。它会在短暂的时间内显示这些信息,然后使玩家回到开始菜单。玩家可以通过提前按下按钮来强制GameOverState结束并返回到开始菜单。

%using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Engine;
using Engine.Input;
using Tao.OpenGl;

namespace Shooter
{
class GameOverState : IGameObject
{
const double _timeOut = 4;
double _countDown = _timeOut;

StateSystem _system;
Input _input;
Font _generalFont;
Font _titleFont;
PersistantGameData _gameData;
Renderer _renderer = new Renderer();

Text _titleWin;
Text _blurbWin;

Text _titleLose;
Text _blurbLose;

public GameOverState(PersistantGameData data, StateSystem system,
Input input, Font generalFont, Font titleFont)
{
_gameData = data;
_system = system;
_input = input;
_generalFont = generalFont;
_titleFont = titleFont;

_titleWin = new Text("Complete!", _titleFont);
_blurbWin = new Text("Congratulations, you won!", _generalFont);
_titleLose = new Text("Game Over!", _titleFont);
_blurbLose = new Text("Please try again...", _generalFont);

FormatText(_titleWin, 300);
FormatText(_blurbWin, 200);

FormatText(_titleLose, 300);
FormatText(_blurbLose, 200);
}

private void FormatText(Text _text, int yPosition)
{
_text.SetPosition(-_text.Width / 2, yPosition);
_text.SetColor(new Color(0, 0, 0, 1));
}

#region IGameObject Members

public void Update(double elapsedTime)
{
_countDown -= elapsedTime;
if ( _countDown <= 0 ||
_input.Controller.ButtonA.Pressed ||

_input.Keyboard.IsKeyPressed(System.Windows.Forms.Keys.
Enter))
{
Finish();
}
}

private void Finish()
{
_gameData.JustWon = false;
_system.ChangeState("start_menu");
_countDown = _timeOut;
}

public void Render()
{
Gl.glClearColor(1, 1, 1, 0);
Gl.glClear(Gl.GL_COLOR_BUFFER_BIT);
if (_gameData.JustWon)
{
_renderer.DrawText(_titleWin);
_renderer.DrawText(_blurbWin);
}
else
{
_renderer.DrawText(_titleLose);
_renderer.DrawText(_blurbLose);
}
_renderer.Render();
}
#endregion
}
}

需要像form.cs类中的其他类一样加载这个类。 

private void InitializeGameState()
{
// Game states are loaded here
_system.AddState("start_menu", new StartMenuState(_titleFont,
_generalFont, _input, _system));
_system.AddState("inner_game", new InnerGameState(_system, _input,
_persistantGameData, _generalFont));
_system.AddState("game_over", new GameOverState(_persistantGameData,
_system, _input, _generalFont, _titleFont));
_system.ChangeState("start_menu");
}


GameOverState为玩家获胜和失败的情况分别创建了一个标题和一条消息。然后它使用JustWon成员来决定显示哪条消息。

GameOverState还有一个计数器,最终这个状态将会结束,并使用户回到开始菜单。

创建了这3个状态后,游戏的第一遍编码也就完成了。虽然这个游戏没什么意思,但是它已经是一个完整的游戏。第10.3节将为游戏主体添加更多细节,并细化整体结构,使其看上去比这里更好一些。

三、 开发游戏主体

现在的游戏主体不允许交互,并且在几秒之后就会结束。为了使游戏主体状态更像是一个游戏,需要引入一个PlayerCharacter,并允许玩家四处移动这个角色。这是第一个目标。创建游戏时,逐个实现一系列较小的、定义良好的目标是很重要的,这样代码编写起来就更加简单。在这个游戏中,PlayerCharacter是某种类型的飞船。

实现第一个目标后,需要让玩家感觉他正在关卡中前进。这是通过卷动背景纹理实现的。下一个小目标是允许玩家发射子弹。子弹需要向某个目标射击,所以还需要添加敌人。每个目标都是一个小步,可合理地引出下一步,以这种方式可以快速构建出一个游戏。

1.  移动玩家角色

玩家由使用精灵和纹理创建的飞船表示。飞船由方向键或游戏手柄上的左控制杆控制。
控制PlayerCharacter的代码不会直接放在InnerGameState类中。InnerGameState类应该是一个轻量级的、易于理解的类。关卡代码的主要部分将存储在Level类中。每次玩家玩一个关卡时,都会创建一个新的关卡对象来替换原来的关卡对象。每次创建新的关卡对象确保了不会发生以前玩关卡时遗留的数据引起奇怪的错误的情况。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Engine;
using Engine.Input;
using System.Windows.Forms;
using System.Drawing;

namespace Shooter
{
class Level
{
Input _input;
PersistantGameData _gameData;
PlayerCharacter _playerCharacter;
TextureManager _textureManager;

public Level(Input input, TextureManager textureManager,
PersistantGameData gameData)
{
_input = input;
_gameData = gameData;
_textureManager = textureManager;
 _playerCharacter = new PlayerCharacter(_textureManager);
}

public void Update(double elapsedTime)
{
// Get controls and apply to player character
}

public void Render(Renderer renderer)
{
_playerCharacter.Render(renderer);
}
}
}


这段代码描述了一个关卡,它在构造函数中接受Input和PersistentGameData作为参数。Input对象用于移动PlayerCharacter。PersistentGameData可以用于记录得分或者其他应该在多个关卡中记录的数据。纹理管理器用于创建玩家、敌人和背景精灵。

PlayerCharacter类将包含代表玩家飞船的精灵。本书配套光盘的Assets目录中包含一个spaceship.tga精灵。需要把这个精灵添加到项目中,并修改其属性,以便它会被复制到生成目录中。在form.cs的InitializeTextures方法中加载这个纹理。

private void InitializeTextures()
{
// Init DevIl
Il.ilInit();
Ilu.iluInit();
Ilut.ilutInit();
Ilut.ilutRenderer(Ilut.ILUT_OPENGL);

_textureManager.LoadTexture("player_ship", "spaceship.tga");


现在已经把玩家精灵加载到了TextureManager中,可以编写PlayerCharacter类了。

 

public class PlayerCharacter
{
Sprite _spaceship   new Sprite();

public PlayerCharacter(TextureManager textureManager)
{
_spaceship.Texture = textureManager.Get("player_ship");
_spaceship.SetScale(0.5, 0.5); // spaceship is quite big, scale
it down.
}
public void Render(Renderer renderer)
{
renderer.DrawSprite(_spaceship);
}
}


现在的PlayerCharacter类只渲染出飞船。为了实际看到飞船,需要在InnerGameState中创建一个Level对象,并将其与Update方法和Render方法关联起来。Level类有自己的Render和Update方法,所以添加到游戏主体状态中十分方便。Level类使用TextureManager类,这意味着必须修改InnerGameState的构造函数,使其接受一个TextureManager对象。在form.cs文件中,需要把textureManager对象传递给InnerGameState构造函数。

 

class InnerGameState : IGameObject
{
Level _level;
TextureManager _textureManager;
// Code omitted

public InnerGameState( StateSystem system, Input input, TextureManager
textureManager,
PersistantGameData gameData, Font generalFont)
{
_ textureManager = textureManager;
// Code omitted

public void OnGameStart()
{
_level = new Level(_input, _textureManager, _gameData);
_gameTime = _gameData.CurrentLevel.Time;
}

// Code omitted
public void Update(double elapsedTime)
{
_level.Update(elapsedTime);

// Code omitted
public void Render()
{
Gl.glClearColor(1, 0, 1, 0);
Gl.glClear(Gl.GL_COLOR_BUFFER_BIT);
_level.Render(_renderer);


运行代码并启动游戏。飞船将会闪现,然后游戏突然结束。为了充分测试InnerGameState,必须增加关卡的长度。关卡长度在form.cs文件的InitializeGameData函数中设置。找到相关代码,增加关卡长度,30s即可。
飞船的移动是非常简单的,没有加速或物理建模。控制杆和方向键直接映射到飞船的移动上。PlayerCharacter类需要一个新的Move方法。

double _speed = 512; // pixels per second
public void Move(Vector amount)
{
amount *= _speed;
_spaceship.SetPosition(_spaceship.GetPosition() + amount);
}


Move方法接受一个指定飞船的移动方向和移动量的向量作为参数。该参数将与speed值相乘,以增加移动量。然后这个新向量与飞船的当前位置相加,得出飞船在太空中的新位置,再将飞船精灵移动到这个位置。街机游戏中的所有基本移动都是这样完成的。通过对更加符合物理原理的系统进行建模,例如加速和摩擦,移动可以产生真实的效果,但我们还是坚持使用基本的移动代码。
飞船根据Input类中的值四处移动,这是在Level的Update循环中处理的。
 

public void Update(double elapsedTime)
{
// Get controls and apply to player character
double _x = _input.Controller.LeftControlStick.X;
double _y = _input.Controller.LeftControlStick.Y * - 1;
Vector controlInput = new Vector(_x, _y, 0);

if (Math.Abs(controlInput.Length()) < 0.0001)
{
// If the input is very small, then the player may not be using
// a controller; he might be using the keyboard.
if (_input.Keyboard.IsKeyHeld(Keys.Left))
{
controlInput.X = -1;
}

if (_input.Keyboard.IsKeyHeld(Keys.Right))
{
controlInput.X = 1;
}

if (_input.Keyboard.IsKeyHeld(Keys.Up))
{
controlInput.Y = 1;
}

if (_input.Keyboard.IsKeyHeld(Keys.Down))
{
controlInput.Y = -1;
}
}

_playerCharacter.Move(controlInput * elapsedTime);
}


游戏手柄的控制代码十分简单。创建一个描述控制杆的摇动的向量(Y轴的值通过乘以-1取反,所以向上推控制杆时,飞船将向上飞,而不是向下飞)。这个向量与经过的时间相乘,这样不管帧率如何,移动都是固定的。被缩放后的这个向量将用于移动飞船。代码中还提供了对键盘的支持。先检查控制杆的值,如果控制杆没有移动,则检查键盘的按键。代码假定如果玩家没有移动控制杆,可能就在使用键盘玩游戏。键盘没有控制杆那么细致,它只能将上、下、左、右表示为0或1的绝对值。键盘输入用于创建一个向量,以便可以像处理控制杆输入一样处理键盘输入。使用IsKeyHeld而不是IsKeyPressed,因为假定当用户按住左方向键时,他希望持续向左移动,而不是移动一次就停止。

运行程序,现在可以在屏幕上四处移动飞船。移动的目标完成了。

2.  使用卷动背景模拟移动

添加背景很简单,本书配套光盘的Assets目录中有两个基本的星空纹理,分别是background.tga和background_p.tga。将这两个文件添加到解决方案中,并修改它们的属性,以便像前面的其他纹理那样把它们复制到生成目录中。然后在form.cs的InitializeTextures函数中把它们加载到纹理管理器中。

_textureManager.LoadTexture("background", "background.tga");
_textureManager.LoadTexture("background_layer_1",
"background_p.tga");


选择的这两个背景可以层叠,产生比只使用一个纹理更加有趣的效果。

背景将通过使用UV卷动(UV scrolling)运动起来。这可以通过创建一个新类ScrollingBackground实现。可以重用这个卷动背景类,使开始和游戏结束菜单更加有趣,但是目前最要紧的是游戏主体。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Engine;

namespace Shooter
{
class ScrollingBackground
{
Sprite _background = new Sprite();

public float Speed { get; set; }
public Vector Direction { get; set; }
Point _topLeft = new Point(0, 0);
Point _bottomRight = new Point(1, 1);

public void SetScale(double x, double y)
{
_background.SetScale(x, y);
}

public ScrollingBackground(Texture background)
{
_background.Texture = background;
Speed = 0.15f;
Direction = new Vector(1, 0, 0);
}

public void Update(float elapsedTime)
{
_background.SetUVs(_topLeft, _bottomRight);
_topLeft.X += (float)(0.15f * Direction.X * elapsedTime);
_bottomRight.X += (float)(0.15f * Direction.X * elapsedTime);
_topLeft.Y += (float)(0.15f * Direction.Y * elapsedTime);
_bottomRight.Y += (float)(0.15f * Direction.Y * elapsedTime);
}

public void Render(Renderer renderer)
{
renderer.DrawSprite(_background);
}
}
}


关于卷动背景类,值得注意的是它有一个方向向量Direction。该向量可以修改卷动的方向。在普通的射击游戏中,背景通常从右向左卷动。修改卷动方向可以使背景在任意期望的方向上卷动。在太空探险游戏中,背景朝着与玩家的移动相反的方向移动,此时这种技术很有用。

卷动类还有一个Speed成员,严格来说它并不是必需的,因为卷动的速度可以编码为向量的大小,但是把速度分离出来以后,修改起来更加方便。Direction和Speed用来在Update方法中移动顶点的(U, V)数据。

现在可以把这个背景添加到Level类中。

ScrollingBackground _background;
ScrollingBackground _backgroundLayer;

public Level(Input input, TextureManager textureManager, PersistantGameData
gameData)
{
_input = input;
_gameData = gameData;
_textureManager = textureManager;

_background = new ScrollingBackground(textureManager.Get
("background"));
_background.SetScale(2, 2);
_background.Speed = 0.15f;

_backgroundLayer = new ScrollingBackground(textureManager.Get
("background_layer_1"));
_backgroundLayer.Speed = 0.1f;
_backgroundLayer.SetScale(2.0, 2.0);

这两个背景对象在构造函数中创建,它们分别被放大一倍。之所以放大背景,是因为纹理大约是屏幕区域大小的一半,放大纹理可以保证纹理足以覆盖整个游戏区域,不会在边缘位置留下空白。

两个背景以不同的速度卷动,产生了所谓的视差效果。人脑通过一些被称作深度线索(depth cue)的不同线索来理解3D世界。例如,每只眼睛看到的角度是不同的,视野的差异可以用来确定第三个维度,这被称为双眼线索(binocular cue)。

视差就是一种线索。简单来说,离观察者越远的对象看上去移动得越慢。假设你自己在驱车远行,在目光尽处有一片连绵的大山。大山看上去不怎么移动,但是公路两旁的树木却飞一样的被抛在身后。这是一种深度线索,大脑知道山在很远的地方。

模拟视差很容易。快速卷动的星空中的星星看上去离飞船很近,而移动较慢的背景中的星星看上去离飞船较远。背景类只需要以不同的速度移动,这样就使背景产生了深度感。

由于需要渲染和更新背景对象,还需要继续修改代码。

public void Update(double elapsedTime)
{
_background.Update((float)elapsedTime);
_backgroundLayer.Update((float)elapsedTime);

// A little later in the code

public void Render(Renderer renderer)
{
_background.Render(renderer);
_backgroundLayer.Render(renderer);

 

运行代码,查看视差效果,如图6所示。对于本书提供的星空,不太容易注意到这种效果,所以你可以自由修改图片,或者再添加几个层。
 
图6  卷动背景


现在飞船看上去在太空中越变越大或越变越小,看上去有点像真正的游戏了。下一个任务是添加敌人。

3.  添加一些简单的敌人

敌人将使用精灵来表示,所以它们将使用Sprite类。敌人的精灵应该与玩家精灵不同,所以添加本书配套光盘的Assets目录中的新精灵纹理spaceship2.tga。修改其属性,以便当生成游戏时它被复制到\bin目录中。

如下代码将纹理加载到纹理管理器中。

_textureManager.LoadTexture("enemy_ship", "spaceship2.tga");

添加了这个纹理后,就可以构造一个类来简单地表示敌人。


 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Engine;

namespace Shooter
{
class Enemy
{
Sprite _spaceship = new Sprite();
double _scale = 0.3;
public Enemy(TextureManager textureManager)
{
_spaceship.Texture = textureManager.Get("enemy_ship");
 _spaceship.SetScale(_scale, _scale);
_spaceship.SetRotation(Math.PI); // make it face the player
_spaceship.SetPosition(200, 0); // put it somewhere easy to see
}

public void Update(double elapsedTime)
{
}

public void Render(Renderer renderer)
{
renderer.DrawSprite(_spaceship);
}

}
}


敌人将由Level类渲染和控制。很可能同时出现多个敌人,所以最好创建一个敌人列表。

 

class Level
{
List<Enemy> _enemyList = new List<Enemy>();

// A little later in the code

public Level(Input input, TextureManager textureManager, PersistantGameData
gameData)
{
_input = input;
_gameData = gameData;
_textureManager = textureManager;
_enemyList.Add(new Enemy(_textureManager));

// A little later in the code

public void Update(double elapsedTime)
{
_background.Update((float)elapsedTime);
_backgroundLayer.Update((float)elapsedTime);
_enemyList.ForEach(x => x.Update(elapsedTime));

// A little later in the code
public void Render(Renderer renderer)
{
_background.Render(renderer);
_backgroundLayer.Render(renderer);
_enemyList.ForEach(x => x.Render(renderer));


代码很标准。创建敌人列表后,向其中添加了一个敌人。然后使用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类。


 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Engine;
using Tao.OpenGl;
using System.Drawing;
namespace Shooter
{
public class Entity
{
protected Sprite _sprite = new Sprite();

public RectangleF GetBoundingBox()
{
float width = (float)(_sprite.Texture.Width * _sprite.ScaleX);
float height = (float)(_sprite.Texture.Height * _sprite.ScaleY);
return new RectangleF((float)_sprite.GetPosition().X - width / 2,
(float)_sprite.GetPosition().Y - height / 2,
width, height);
}

// Render a bounding box
protected 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);
}

}
}


Entity类包含一个精灵,以及渲染该精灵的包围框的一些代码。

定义了Entity以后,Enemy类可以得到显著简化。


 

public class Enemy : Entity
{
double _scale = 0.3;
public Enemy(TextureManager textureManager)
{
_sprite.Texture = textureManager.Get("enemy_ship");
_sprite.SetScale(_scale , _scale);
_sprite.SetRotation(Math.PI); // make it face the player
_sprite.SetPosition(200, 0); // put it somewhere easy to see
}

public void Update(double elapsedTime)
{
}

public void Render(Renderer renderer)
{
renderer.DrawSprite(_sprite);
Render_Debug();
}

public void SetPosition(Vector position)
{
_ sprite.SetPosition(position);
}
}


现在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 <= 0)
{
OnGameStart();
_gameData.JustWon = true;
 _system.ChangeState("game_over");
}

if (_level.HasPlayerDied())
{
OnGameStart();
_gameData.JustWon = false;
_system.ChangeState("game_over");
}
}


 

这里在Level类中新添加了一个额外的函数HasPlayerDied,用于报告玩家角色是否死亡。这里在gameTime后检查玩家角色是否死亡,意味着如果时间到了,但是玩家在最后一刻死亡,他仍然无法赢得关卡。

在Level类中,需要实现HasPlayerDied方法。它只是PlayerCharacter的当前状态的一个简单包装器。


 

public bool HasPlayerDied()
{
return _playerCharacter.IsDead;
}
死亡标志包含在PlayerCharacter类中。
bool _dead = false;
public bool IsDead
{
get
{
return _dead;
}
}


当玩家与敌人发生碰撞时,可以设置死亡标志,此时游戏将会结束,玩家输掉了关卡。关卡还需要一些代码来处理敌人飞船与玩家飞船发生的碰撞。这种碰撞处理是在Level类中完成的,该类可以访问PlayerCharacter和敌人列表。

private void UpdateCollisions
()
{
foreach (Enemy enemy in _enemyList)
{
if (enemy.GetBoundingBox().IntersectsWith(_playerCharacter.
GetBoundingBox()))
{
enemy.OnCollision(_playerCharacter);
_playerCharacter.OnCollision(enemy);
}
}
}

public void Update(double elapsedTime)
{
UpdateCollisions();


 

Level的Update方法在每一帧中都会调用碰撞处理代码。碰撞通过迭代敌人列表,并检查它们的包围框是否与玩家的包围框发生交叉而确定。交叉则是通过C#的RectangleF IntersectWith方法判断的。如果玩家的包围框和敌人的包围框发生交叉,则对玩家和敌人调用OnCollision。Player.OnCollision方法将被传入敌人对象,它与玩家发生碰撞。Enemy.OnCollision将被传入玩家对象。代码中没有测试敌人与其他敌人的碰撞,因为游戏中假定发生这种情况是没有问题的。

在Enemy和PlayerCharacter类中都需要实现OnCollision类。下面是需要添加到Enemy类的一个框架方法。


 

internal void OnCollision(PlayerCharacter player)
{
// Handle collision with player.
}


不同于Enemy,PlayerCharacter类实际上有一些功能,它的实现如下所示。

internal void OnCollision(Enemy enemy)
{
_dead = true;
}


 

当玩家与敌人发生碰撞时,他的死亡标志将被设为true,导致游戏结束。现在游戏已经部分可玩了,可能发生的结果包括玩家赢或者玩家输。从现在开始,将进一步细化游戏,使其拥有更好的可玩性。

4.  添加简单的武器

游戏中的武器主要是各种各样的子弹。一个不错的目标是当玩家按下手柄上的A键或者键盘上的空格键时发射子弹。敌人也可以发射子弹,创建子弹系统时记住这一点很重要。

为了试验子弹,还需要添加另外一个纹理。从本书配套光盘的Assets文件夹中找到bullet. tga,把它添加到项目中,并像前面那样设置它的属性。然后需要把这个纹理加载到纹理管理器中。

_textureManager.LoadTexture("bullet", "bullet.tga");


加载纹理后,下一步自然应该创建Bullet类。该类将有一个包围框和一个精灵,所以也可以从Entity继承。应该在Shooter项目中创建该类。

public class Bullet : Entity
{
public bool Dead { get; set; }
public Vector Direction { get; set; }
public double Speed { get; set; }

public double X
{
get { return _sprite.GetPosition().X; }
}

public double Y
{
get { return _sprite.GetPosition().Y; }
}

public void SetPosition(Vector position)
{
_sprite.SetPosition(position);
}

public void SetColor(Color color)
{
_sprite.SetColor(color);
}

public Bullet(Texture bulletTexture)
{
_sprite.Texture = bulletTexture;

// Some default values
Dead = false;
Direction = new Vector(1, 0, 0);
Speed = 512;// pixels per second
}
public void Render(Renderer renderer)
{
if (Dead)
{
return;
}
renderer.DrawSprite(_sprite);
}

public void Update(double elapsedTime)
{
if (Dead)
{
return;
}
Vector position = _sprite.GetPosition();
position += Direction * Speed * elapsedTime;
_sprite.SetPosition(position);
}
}


 

Bullet类有3个成员:子弹的飞行方向、子弹的速度,以及报告子弹是否消亡的标志。类中提供了子弹精灵的位置setter和getter。另外,还有颜色的setter,因为让子弹具有颜色是很合理的。玩家的子弹只能伤害敌人,敌人的子弹只能伤害玩家。为了使玩家能够区分各种子弹,给它们添加了不同的颜色。

看到位置的setter和getter以及颜色的setter后,你可能会想,使Sprite类成为公有类是不是会更好一些。然后如果我们想要修改位置或颜色,就可以直接修改子弹精灵。每种情况都有不同,但一般原则是,使尽可能多的数据保持私有,并为需要修改的数据提供接口。而且,bullet.SetColor( )比bullet.Sprite.SetColor( )也更容易理解。

构造函数接受一个子弹纹理作为参数,并为颜色、方向和速度设置默认值。速度以每秒像素数度量。最后两个方法是Render和Update。Update循环使用子弹的方向和速度更新子弹的位置。位置增量与自上一帧后经过的时间相乘,这样在任意一台计算机上,移动都是一致的。Render很简单,它只是绘制子弹精灵。如果子弹的Dead标志被设为true,Render和Update循环都不会采取任何操作。

游戏中将有大量的子弹四处飞行,所以还需要一定的逻辑来处理这种情况。离开屏幕的子弹需要被关闭。将这些逻辑放到一个BulletManager类中是很不错的。编写一个BulletManager类的方法有两种:简单直观的方法和高效使用内存的方法。这里介绍的BulletManager采用了简单直观的方法:当从屏幕上销毁敌人时,从BulletManager中删除对它的引用,并在代码中销毁对象,释放它占用的任何内存。每次玩家射击时,创建一个新子弹。这是最基本的,但是在游戏循环中创建和删除大量的对象不是一个好主意,它们会降低代码运行的速度。创建和删除对象一般都会降低操作速度。

在管理子弹时,更加高效地使用内存的方法是创建子弹的一个列表(例如包含1000个子弹)。大多数子弹都是无效的,每次用户射击时,都会搜索列表并激活一个子弹。不需要创建新对象。如果全部1000个子弹都是活动的,那么禁止用户发射子弹,或者使用启发式方法使当前的某个子弹失效(例如生存时间最长的子弹),并让玩家使用该子弹。以这种方式回收子弹是编写BulletManager的更好的方式。理解简单的管理器的应用后,你就可以自行把它转换为高效使用内存的子弹管理器。


 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Engine;

namespace Shooter
{
public class Bullet : Entity
{
public bool Dead { get; set; }
public Vector Direction { get; set; }
public double Speed { get; set; }

public double X
{
get { return _sprite.GetPosition().X; }
}

public double Y
{
get { return _sprite.GetPosition().Y; }
}

public void SetPosition(Vector position)
{
_sprite.SetPosition(position);
}

public void SetColor(Color color)
{
_sprite.SetColor(color);
}

public Bullet(Texture bulletTexture)
{
_sprite.Texture = bulletTexture;

// Some default values
Dead = false;
Direction = new Vector(1, 0, 0);
Speed = 512;// pixels per second
}
public void Render(Renderer renderer)
{
if (Dead)
{
return;
}
renderer.DrawSprite(_sprite);
}

public void Update(double elapsedTime)
{
if (Dead)
{
return;
}
Vector position = _sprite.GetPosition();
position += Direction * Speed * elapsedTime;
_sprite.SetPosition(position);
}
}

}


BulletManager只有两个成员变量:它管理的子弹的一个列表,和代表屏幕边界的一个矩形。记得在文件的顶部包含using System.Drawing语句,这样才能使用RectangleF类。屏幕边界用于确定子弹是否离开了屏幕并可以被销毁。

构造函数接受一个描述游戏区域的矩形的playArea,并把它赋给_bounds成员。Shoot方法用于向BulletManager添加一个子弹。添加了子弹后,BulletManager类会跟踪它,直到该子弹离开了游戏区域或者击中了飞船。Update方法会更新被跟踪的所有子弹,然后检查是否有子弹飞出了边界。最后,BulletManager会删除Dead标志被设为true的任何子弹。

CheckOutOfBounds函数在子弹和游戏区域之间使用矩形相交测试,以确定子弹是否飞出了游戏区域。RemoveDeadBullets使用了一个有意思的小技巧,它反向迭代子弹列表,并删除所有失效的子弹。这里不能使用Foreach,也不能使用前向迭代。如果进行前向迭代并删除子弹,列表将减少一个元素,当循环到达列表结尾时,将产生一个越界错误。反转循环迭代的方向可以解决这个问题。列表的长度不重要,它总是从0开始。

Render方法很标准,它渲染出全部子弹。

最好把BulletManager放到Level类中。如果在Level.cs文件的顶部没有包含using System.Drawing语句,那么在使用RectangleF类之前需要添加该语句。


 

class Level
{
BulletManager _bulletManager   new BulletManager(new RectangleF(-1300
/ 2, -750 / 2, 1300, 750));


BulletManager被赋予了一个游戏区域,该区域比实际的窗口大小略大一些。这就提供了一个缓冲区,使得销毁子弹时它们已经完全离开了屏幕。然后需要把BulletManager添加到Level类的Update和Render方法中。

public void Update(double elapsedTime)
{
UpdateCollisions();
_bulletManager.Update(elapsedTime);

// A little later in the code

public void Render(Renderer renderer)
{
_background.Render(renderer);
_backgroundLayer.Render(renderer);

_enemyList.ForEach(x => x.Render(renderer));
_playerCharacter.Render(renderer);
_bulletManager.Render(renderer);
}


最后才渲染BulletManager类,这样将在其他对象之上渲染子弹。现在,BulletManager被完全整合了,但是如果不能让玩家发射子弹,就没有办法测试它。为了给玩家提供这种能力,PlayerCharacter需要能够访问子弹管理器。在Level构造函数中,将BulletManager传递给PlayerCharacter构造函数。

_playerCharacter = new PlayerCharacter(_textureManager,
_bulletManager);
然后需要修改PlayerCharacter类的代码,以接受和存储对BulletManager的引用。
BulletManager _bulletManager;
Texture _bulletTexture;
public PlayerCharacter(TextureManager textureManager, BulletManager
bulletManager)
{
_bulletManager = bulletManager;
_bulletTexture = textureManager.Get("bullet");


 

PlayerCharacter构造函数还存储了在发射子弹时将使用的bulletTexture。为了发射子弹,需要创建一个bullet对象,并使其在玩家的附近开始显示,然后把它传递给BulletManager。PlayerCharacter类中新增加的Fire方法将负责完成这些处理。


 

Vector _gunOffset = new Vector(55, 0, 0);
public void Fire()
{
Bullet bullet = new Bullet(_bulletTexture);
bullet.SetColor(new Color(0, 1, 0, 1));
bullet.SetPosition(_sprite.GetPosition() + _gunOffset);
_bulletManager.Shoot(bullet);
}


通过在构造函数中建立的bulletTexture可创建子弹。它被设置为绿色,但是你可以选择其他任意一种颜色。子弹的位置被设置为从玩家的飞船偏移一定位置,这样子弹看起来是从飞船的前端发射出去的。如果没有偏移,子弹刚好出现在飞船精灵的正中,这看上起有点奇怪。这里没有修改子弹的方向,因为在X轴上前向是默认值。默认的速度设置也是可以接受的。最后,将子弹分配给BulletManager,并使用Shoot方法正式把它发射出去。
玩家现在可以发射子弹,但是没有检测输入和调用Fire方法的代码。玩家的所有输入将在Level类的Update方法中被处理。将输入代码放到Update方法中看上起有点混乱,所以我把这些代码提取为一个新函数UpdateInput,这样代码看上去就更加整洁了。


 

public void Update(double elapsedTime)
{
UpdateCollisions();
_bulletManager.Update(elapsedTime);

_background.Update((float)elapsedTime);
_backgroundLayer.Update((float)elapsedTime);
_enemyList.ForEach(x => x.Update(elapsedTime));

// Input code has been moved into this method
UpdateInput(elapsedTime);

}

private void UpdateInput(double elapsedTime)
{
if (_input.Keyboard.IsKeyPressed(Keys.Space) || _input.Controller.
ButtonA.Pressed)
{
_playerCharacter.Fire();
}
// Pre-existing input code omitted.


取出Update循环的所有输入代码,并把它放到新的UpdateInput方法的结尾。然后从Update方法中调用这个UpdateInput方法。另外还添加了一些新代码来处理玩家发射子弹的动作。如果按下了键盘上的空格键或者手柄上的A按键,那么玩家就发射了一个子弹。运行程序,尝试飞船新增的开火功能。
从图8中可以看到新添加的子弹。每次玩家按下发射按键时,都会创建子弹。处于可玩性考虑,最好降低发射速度,使飞船在连续发射子弹之间有一个恢复时间。修改PlayerCharacter类如下。

Vector _gunOffset = new Vector(55, 0, 0);
static readonly double FireRecovery   0.25;
double _fireRecoveryTime = FireRecovery;
public void Update(double elapsedTime)
{
_fireRecoveryTime = Math.Max(0, (_fireRecoveryTime - elapsedTime));
}

public void Fire()
{
if (_fireRecoveryTime > 0)
{
return;
}
else
{
_fireRecoveryTime = FireRecovery;
}

Bullet bullet = new Bullet(_bulletTexture);
bullet.SetColor(new Color(0, 1, 0, 1));
bullet.SetPosition(_sprite.GetPosition() + _gunOffset);
_bulletManager.Shoot(bullet);
}


 
图8  发射子弹

为了对恢复时间进行倒计时,需要在PlayerCharacter类中添加一个Update方法。Update方法将对恢复时间进行倒计时,但是绝不会低于0。这是使用Math.Max函数完成的。设置了恢复时间后,如果飞船仍然处在从上一次发射恢复的状态,Fire命令将被立即返回。如果恢复时间为0,飞船可以发射子弹,恢复时间将被重设,以便重新开始倒计时。

还需要对Level类做一点小修改:它需要调用PlayerCharacter的Update方法。


 

public void Update(double elapsedTime)
{
_playerCharacter.Update(elapsedTime);

_background.Update((float)elapsedTime);
_backgroundLayer.Update((float)elapsedTime);

UpdateCollisions();
_enemyList.ForEach(x => x.Update(elapsedTime));
_bulletManager.Update(elapsedTime);

UpdateInput(elapsedTime);
}


再次运行程序,现在不能像以前那样快速发射子弹了。这是你自己的游戏,所以只要你觉得合适,可以随意调整恢复速度。

5.  伤害和爆炸

现在添加了子弹,但是它们直接穿过敌人飞船,却不会造成任何伤害。接下来我们就来解决这个问题。敌人应该知道自己何时被击中,并相应地做出恰当的反应。我们将首先处理碰撞问题,然后创建一个爆炸动画。
碰撞代码在Level类的UpdateCollisions函数中处理。需要扩展这个函数,使其也可以处理子弹和敌人之间的碰撞。


 

private void UpdateCollisions()
{
foreach (Enemy enemy in _enemyList)
{
if (enemy.GetBoundingBox().IntersectsWith(_playerCharacter.
GetBoundingBox()))
{
enemy.OnCollision(_playerCharacter);
_playerCharacter.OnCollision(enemy);
}

_bulletManager.UpdateEnemyCollisions(enemy);
}
}


在for循环的末尾新添加了一行,让BulletManager检查子弹与当前敌人之间是否发生碰撞。UpdateEnemyCollisions是BulletManager中的新函数,所以需要实现它。

internal void UpdateEnemyCollisions(Enemy enemy)
{
foreach (Bullet bullet in _bullets)
{
if(bullet.GetBoundingBox().IntersectsWith(enemy.GetBounding-
Box()))
{
bullet.Dead = true;
enemy.OnCollision(bullet);
}
}
}


 

子弹和敌人之间的碰撞通过检查包围框是否相交进行判断。如果子弹击中了敌人,则销毁子弹并通知敌人发生碰撞。

子弹击中敌人后,有多种不同的方式进行反应。可以立即销毁敌人并播放爆炸动画,或者可以使敌人遭受一定的伤害,还需要再击中敌人几次后才销毁它。为敌人分配生命值这种功能在将来可能会需要,不如现在就将这种功能添加进来。为敌人添加一个Health成员变量,然后可以实现子弹的OnCollision函数。


 

public int Health { get; set; }
public Enemy(TextureManager textureManager)
{
Health = 50; // default health value.
//Remaining constructor code omitted


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循环。在该方法中需要倒计时闪烁时间,并把颜色从黄色改为白色。


 

public void Update(double elapsedTime)
{
if (_hitFlashCountDown != 0)
{
_hitFlashCountDown = Math.Max(0, _hitFlashCountDown - elapsedTime);
double scaledTime = 1 - (_hitFlashCountDown / HitFlashTime);
_sprite.SetColor(new Engine.Color(1, 1, (float)scaledTime, 1));
}
}


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中。

AnimatedSprite _testSprite = new AnimatedSprite();public Level(Input
input, TextureManager textureManager, PersistantGameData gameData)
{
_testSprite.Texture = textureManager.Get("explosion");
_testSprite.SetAnimation(4, 4);

// a little later in the code
public void Update(double elapsedTime)
{
_testSprite.Update(elapsedTime);

// a little later in the code

public void Render(Renderer renderer)
{
// Background and other sprite code omitted.
renderer.DrawSprite(_testSprite);
renderer.Render();
}


 

运行程序并进入关卡,现在将播放一次爆炸动画。这证明了代码工作正确(见图11)。

图11  游戏中的爆炸效果

6.  管理爆炸和敌人

在第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<AnimatedSprite> _effects = new List<AnimatedSprite>();
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<Enemy> _enemies = new List<Enemy>();
TextureManager _textureManager;
EffectsManager _effectsManager;
int _leftBound;

public List<Enemy> 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类来引入这个新的管理器,并删除旧列表。


 

// List<Enemy> _enemyList = new List<Enemy>(); <- Removed
EnemyManager _enemyManager;

public Level(Input input, TextureManager textureManager, PersistantGameData
gameData)
{
_input = input;
_gameData = gameData;
_textureManager = textureManager;

_background = new ScrollingBackground(textureManager.Get
("background"));
_background.SetScale(2, 2);
_background.Speed = 0.15f;

_backgroundLayer = new ScrollingBackground(textureManager.Get
("background_layer_1"));
_backgroundLayer.Speed = 0.1f;
_backgroundLayer.SetScale(2.0, 2.0);

_playerCharacter = new PlayerCharacter(_textureManager,
_bulletManager);

_effectsManager = new EffectsManager(_textureManager);
// _enemyList.Add(new Enemy(_textureManager, _effectsManager));
<- Removed
_enemyManager = new EnemyManager(_textureManager, _effectsManager,
-1300);
}


对碰撞处理也需要做一些修改,现在在检查与敌人的碰撞时,它将使用EnemyManager中的敌人列表。


 

private void UpdateCollisions()
{
foreach (Enemy enemy in _enemyManager.EnemyList)


为了能够看到敌人,需要修改Update和Render循环。

 

public void Update(double elapsedTime)
{
// _enemyList.ForEach(x => x.Update(elapsedTime)); <- Remove this line
_enemyManager.Update(elapsedTime);

// Code omitted

public void Render(Renderer renderer)
{
_background.Render(renderer);
_backgroundLayer.Render(renderer);

//_enemyList.ForEach(x => x.Render(renderer)); <- remove this line
_enemyManager.Render(renderer);


现在运行程序。击中敌人几次后,敌人会爆炸并消失。这看起来更像是一个真正的游戏了。现在最明显的缺陷是,只有一个敌人,而且这个敌人还不会移动。

7.  定义关卡

当前的关卡只持续30s,并且只有一个敌人,所以不是十分有趣。如果有一个定义关卡的系统,那么就可以为这个关卡增添一些激动人心的元素。关卡定义是在特定时间生成的敌人的一个列表。因此,关卡定义需要一种定义敌人的方法,下面的代码可以作为一个不错的起点。应该把EnemyDef类添加到Engine项目中。

 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Engine;
namespace Shooter
{
class EnemyDef
{
public string EnemyType { get; set; }
public Vector StartPosition { get; set; }
public double LaunchTime { get; set; }
public EnemyDef()
{

EnemyType = "cannon_fodder";
StartPosition = new Vector(300, 0, 0);
LaunchTime = 0;
}

public EnemyDef(string enemyType, Vector startPosition, double
launchTime)
{
EnemyType = enemyType;
StartPosition = startPosition;
LaunchTime = launchTime;
}

}
}


这里使用了一个字符串来描述敌人类型。在代码中,可以提供几种不同类型的敌人:体型小而移动迅速的敌人、体型大而移动缓慢的敌人等。默认的敌人类型是Cannon Fodder(炮灰)。敌人的初始位置为屏幕右侧,启动时间是敌人将在关卡中出现的时间。关卡时间从某个较大的数值倒计时为0。如果gameTime小于启动时间,将创建一个enemy对象并将其添加到关卡中。
EnemyManager类将处理生成敌人的操作。这意味着需要修改构造函数,并添加一个将出现的敌人的列表。

List<EnemyDef> _upComingEnemies = new List<EnemyDef>();
public EnemyManager(TextureManager textureManager, EffectsManager
effectsManager, int leftBound)
{
_textureManager = textureManager;
_effectsManager = effectsManager;
_leftBound = leftBound;
_upComingEnemies.Add(new EnemyDef("cannon_fodder", new Vector(300,
300, 0), 25));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", new Vector(300,
-300, 0), 30));
 _upComingEnemies.Add(new EnemyDef("cannon_fodder", new Vector(300, 0,
0), 29));

// Sort enemies so the greater launch time appears first.
_upComingEnemies.Sort(delegate(EnemyDef firstEnemy, EnemyDef
secondEnemy)
{
return firstEnemy.LaunchTime.CompareTo(secondEnemy.LaunchTime);
});
}


 

_upcomingEnemies列表是按照启动时间排序的敌人定义的一个列表。启动时间越长,敌人定义在列表中出现的位置越高。每一帧都检查列表中最顶部的项,看其是否准备好启动。只需要检查最顶部的敌人定义,因为列表已经排好序了。如果列表没有排序,则需要检查列表中的每一项来确定哪个敌人定义的启动时间大于当前的gameTime,因而需要稍后启动。
启动敌人的操作是在EnemyManager的Update循环中完成的,Update将调用新方法UpdateEnemySpawns。


 

private void UpdateEnemySpawns(double gameTime)
{
// If no upcoming enemies then there's nothing to spawn.
if (_upComingEnemies.Count == 0)
{
return;
}

EnemyDef lastElement = _upComingEnemies[_upComingEnemies.Count - 1];
if (gameTime < lastElement.LaunchTime)
{
_upComingEnemies.RemoveAt(_upComingEnemies.Count - 1);
_enemies.Add(CreateEnemyFromDef(lastElement));
}
}

private Enemy CreateEnemyFromDef(EnemyDef definition)
{
Enemy enemy = new Enemy(_textureManager, _effectsManager);
enemy.SetPosition(definition.StartPosition);
if (definition.EnemyType == "cannon_fodder")
{
// The enemy type could be used to alter the health or texture
// but we're using the default texture and health for the cannon
fodder type
}
else
{
System.Diagnostics.Debug.Assert(false, "Unknown enemy type.");
}

return enemy;
}

public void Update(double elapsedTime, double gameTime)
{
UpdateEnemySpawns(gameTime);


EnemyManager中的Update方法和Level类已被修改,接受一个gameTime参数。gameTime是一个用于倒计时的数字,当减小到0时,关卡将结束。这个值用于确定何时创建新敌人。InnerGameState需要把这个gameTime值传递给Level对象的Update方法,Level会把它传递给EnemyManager。

// In Level.cs
public void Update(double elapsedTime, double gameTime)
{
_enemyManager.Update(elapsedTime, gameTime);
// In InnerGameState.cs
public void Update(double elapsedTime)
{
_level.Update(elapsedTime, _gameTime);


 

gameTime从游戏主体状态一直传递给EnemyManager中的UpdateEnemySpawns函数。UpdateEnemySpawns首先检查_upcomingEnemies列表中是否有即将出现的敌人,如果没有,该方法不执行操作。如果有即将出现的敌人,代码会检查列表的顶部,看是否有准备启动的敌人。如果敌人定义已经准备启动,则从_upcomingEnemies列表中移除该定义,并使用该定义创建的新的敌人对象。然后新创建的敌人将被添加到_enemies列表中,并在游戏世界中生成。

CreateEnemyFromDef接受一个EnemyDef对象,并返回一个Enemy对象。现在只有一种类型的敌人,所以这个函数很简单,不过它还有很多添加新敌人类型的空间。

现在运行程序,随着关卡的时间流逝,将会显示3个敌人。

8.  敌人的移动

卷轴射击游戏中的敌人应该从屏幕右边蜂拥而上,并试图从屏幕左边攻击玩家。图12显示了敌人的前进方向。玩家的子弹已经有移动代码,敌人可以重用这些代码。这样得到的代码是可以工作的,但是敌人的移动十分呆板:它们将直直地从右向左移动。敌人的移动本应该更加有趣,对此,最简单的实现方法是为每个敌人预定义一个由多个路径点组成的路径。敌人将经过所有的路径点并从左侧离开。
 
图12  敌人的前进方向

路径可以简单地被描述为从屏幕右侧通向屏幕左侧的一系列点。图13显示了一个由路径点组成的路径,它可以描述敌人在游戏区域经过的路径。


图13  由路径点组成的路径

路径点可以连接到一起,得到如图14所示的路径。这是敌人可以采用的路径,但边角是锯齿形的。如果路径更加平滑就好了。样条函数是创建平滑路径的一种好方法。图15显示了一个Catmull-Rom样条,这种样条一定会穿过所有的控制点。Pixar的Edwin Catmull曾参与创作了《玩具总动员(Toy Story)》,他与Raphael Rom共同创建了这种样条。


图14  路径的线性插值

 
图15  样条路径


样条显然更加平滑,但是需要创建另外一个类。样条是对曲线的数学描述。
Catmull-Rom样条可以获得组成样条的任意两点之间的位置t。在Catmull-Rom样条中,t两边的两个点以及这两个点的邻点将参与计算,如图16所示。
 
图16  Catmull-Rom样条
一旦可以获得任意两个邻点之间的t(0~1)的值,就可以进行推广,使t(0~1)可以映射到整条线上,而不只是一个线段。使用t和4个点计算出位置的方法如下:


 
看上去有点让人生畏:将3个矩阵与一个标量相乘,该标量对所有4个点进行加权,并决定如何把t的值转换为一个位置。准确理解这个公式的工作原理并不重要(当然,鼓励你做一些研究),只要知道应用这个公式时会得到什么结果就行了。
下面给出了Catmull-Rom样条的C#实现。应该把这个类添加到Engine项目中,因为它可以用在多个项目中。这段样条代码可以用在3D中,用于操作摄像机或者沿着一条路径移动3D实体等任务。Spline类的接口基于以Radu Gruian的C++ Overhauser为基础的代码(网址为
http://www.codeproject.com/KB/recipes/Overhauser.aspx,Code Project网站可能要求注册后再查看该文章,注册是免费的)。

public class Spline
{
List<Vector> _points = new List<Vector>();
double _segmentSize = 0;

public void AddPoint(Vector point)
{
_points.Add(point);
_segmentSize = 1 / (double)_points.Count;
}
private int LimitPoints(int point)
{
if(point < 0)
{
return 0;
}
else if (point > _points.Count - 1)
{
return _points.Count - 1;
}
else
{
return point;
}
}

// t ranges from 0 - 1
public Vector GetPositionOnLine(double t)
{
if (_points.Count <= 1)
{
return new Vector(0,0,0);
}

// Get the segment of the line we're dealing with.
int interval = (int)(t / _segmentSize);

// Get the points around the segment
int p0 = LimitPoints(interval - 1);
int p1 = LimitPoints(interval);
int p2 = LimitPoints(interval + 1);
int p3 = LimitPoints(interval + 2);

// Scale t to the current segment
double scaledT = (t - _segmentSize * (double)interval) / _segmentSize;
return CalculateCatmullRom(scaledT, _points[p0], _points[p1],
_points[p2], _points[p3]);
}
private Vector CalculateCatmullRom(double t, Vector p1, Vector p2,
Vector p3, Vector p4)
{
double t2 = t * t;
double t3 = t2 * t;

double b1 = 0.5 * (-t3 + 2 * t2 - t);
double b2 = 0.5 * (3 * t3 - 5 * t2 + 2);
double b3 = 0.5 * (-3 * t3 + 4 * t2 + t);
double b4 = 0.5 * (t3 - t2);

return (p1 * b1 + p2 * b2 + p3 * b3 + p4 * b4);
}
}


Spline类使用起来很简单。可以添加任意数量的点,Spline会把它们连接起来。连线从0到1进行索引,线上的位置0.5将返回线的中点所在的位置。这样在前面的Tween类中使用样条就变得简单了。样条要求各个控制点均匀分布,以得到均匀的t值。

每个敌人都将获得一个新的Path类,以指导其穿过关卡。这个Path类是我们开发的射击游戏所特有的,所以应该在Shooter项目中创建它。

 

public class Path
{
Spline _spline = new Spline();
Tween _tween;

public Path(List<Vector> points, double travelTime)
{
foreach (Vector v in points)
{
_spline.AddPoint(v);
}
_tween = new Tween(0, 1, travelTime);
}

public void UpdatePosition(double elapsedTime, Enemy enemy)
{
_tween.Update(elapsedTime);
Vector position = _spline.GetPositionOnLine(_tween.Value());
enemy.SetPosition(position);
}
}


Path的构造函数接受时间和一个点的列表作为参数,它使用这些点创建样条和补间对象。travelTime决定敌人使用多长的时间穿过样条定义的路径。UpdatePosition方法更新补间,并从样条获得一个新位置,以便重新放置敌人。下面的代码修改Enemy类,使其使用Path类。
 

public Path Path { get; set; }
public void Update(double elapsedTime)
{
if (Path != null)
{
Path.UpdatePosition(elapsedTime, this);
}
if (_hitFlashCountDown != 0)
{
_hitFlashCountDown = Math.Max(0, _hitFlashCountDown - elapsedTime);
double scaledTime = 1 - (_hitFlashCountDown / HitFlashTime);
_sprite.SetColor(new Engine.Color(1, 1, (float)scaledTime, 1));
}
}


现在所有的敌人都具有路径。因为路径将会定义敌人从哪里开始出现,可以从EnemyDef中删除StartPosition变量。敌人可以穿过关卡,但是首先需要给它们提供一个路径。在EnemyManager中,创建一个敌人后,需要给它提供路径。下面的代码为cannon_ fodder敌人类型提供了一个从右向左的路径,当到达中间位置时,它向上运动。敌人走完整个路径的时间为10s。

private Enemy CreateEnemyFromDef(EnemyDef definition)
{
Enemy enemy = new Enemy(_textureManager, _effectsManager);
//enemy.SetPosition(definition.StartPosition); <- this line can be
removed
if (definition.EnemyType == "cannon_fodder")
{
List<Vector> _pathPoints = new List<Vector>();
_pathPoints.Add(new Vector(1400, 0, 0));
_pathPoints.Add(new Vector(0, 250, 0));
_pathPoints.Add(new Vector(-1400, 0, 0));

enemy.Path = new Path(_pathPoints, 10);
}
else
{
System.Diagnostics.Debug.Assert(false, "Unknown enemy type.");
}

return enemy;
}


 

现在,通过编辑EnemyManager的构造函数,可以定义一个更加有趣的关卡。

public EnemyManager(TextureManager textureManager, EffectsManager
effectsManager, int leftBound)
{
_textureManager = textureManager;
_effectsManager = effectsManager;
_leftBound = leftBound;

_textureManager = textureManager;
_effectsManager = effectsManager;
_leftBound = leftBound;
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 30));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 29.5));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 29));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 28.5));

_upComingEnemies.Add(new EnemyDef("cannon_fodder", 25));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 24.5));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 24));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 23.5));

_upComingEnemies.Add(new EnemyDef("cannon_fodder", 20));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 19.5));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 19));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 18.5));

// Sort enemies so the greater launch time appears first.
_upComingEnemies.Sort(delegate(EnemyDef firstEnemy, EnemyDef
secondEnemy)
{
return firstEnemy.LaunchTime.CompareTo(secondEnemy.LaunchTime);

});
}

 

现在,敌人使用路径来描述如何在关卡中移动,所以不再需要每个敌人定义的初始位置。这意味着需要重新编写EnemyDef类。

public class EnemyDef
{
public string EnemyType { get; set; }
public double LaunchTime { get; set; }

public EnemyDef()
{
EnemyType = "cannon_fodder";
LaunchTime = 0;
}

public EnemyDef(string enemyType, double launchTime)
{
EnemyType = enemyType;
LaunchTime = launchTime;
}
}


再次运行代码,可以看到一队敌人在屏幕的上半部分曲折移动,如图17所示。
 
图17  更有趣的关卡
现在可以再添加几种敌人类型,让关卡变得更好玩。

private Enemy CreateEnemyFromDef(EnemyDef definition)
{
Enemy enemy = new Enemy(_textureManager, _effectsManager);

if (definition.EnemyType == "cannon_fodder")
{
List<Vector> _pathPoints = new List<Vector>();
_pathPoints.Add(new Vector(1400, 0, 0));
_pathPoints.Add(new Vector(0, 250, 0));
_pathPoints.Add(new Vector(-1400, 0, 0));
enemy.Path = new Path(_pathPoints, 10);
}
else if (definition.EnemyType == "cannon_fodder_low")
{
List<Vector> _pathPoints = new List<Vector>();
_pathPoints.Add(new Vector(1400, 0, 0));
_pathPoints.Add(new Vector(0, -250, 0));
_pathPoints.Add(new Vector(-1400, 0, 0));

enemy.Path = new Path(_pathPoints, 10);
}
else if (definition.EnemyType == "cannon_fodder_straight")
{
List<Vector> _pathPoints = new List<Vector>();
_pathPoints.Add(new Vector(1400, 0, 0));
_pathPoints.Add(new Vector(-1400, 0, 0));

enemy.Path = new Path(_pathPoints, 14);
}
else if (definition.EnemyType == "up_l")
{
List<Vector> _pathPoints = new List<Vector>();
_pathPoints.Add(new Vector(500, -375, 0));
_pathPoints.Add(new Vector(500, 0, 0));
_pathPoints.Add(new Vector(500, 0, 0));
_pathPoints.Add(new Vector(-1400, 200, 0));

enemy.Path = new Path(_pathPoints, 10);
}
else if (definition.EnemyType == "down_l")
{
List<Vector> _pathPoints = new List<Vector>();
_pathPoints.Add(new Vector(500, 375, 0));
_pathPoints.Add(new Vector(500, 0, 0));
_pathPoints.Add(new Vector(500, 0, 0));
_pathPoints.Add(new Vector(-1400, -200, 0));
enemy.Path   new Path(_pathPoints, 10);
}
else
{
System.Diagnostics.Debug.Assert(false, "Unknown enemy type.");
}

return enemy;
}

 

每种敌人都有一个有趣的路径,可以放到一起来形成一个更加有趣的关卡。下面所示是EnemyManager构造函数中用于设置新的Level的代码。

public EnemyManager(TextureManager textureManager, EffectsManager
effectsManager, int leftBound)
{
_textureManager = textureManager;
_effectsManager = effectsManager;
_leftBound = leftBound;

_upComingEnemies.Add(new EnemyDef("cannon_fodder", 30));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 29.5));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 29));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 28.5));

_upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 30));
_upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 29.5));
_upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 29));
_upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 28.5));

_upComingEnemies.Add(new EnemyDef("cannon_fodder", 25));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 24.5));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 24));
_upComingEnemies.Add(new EnemyDef("cannon_fodder", 23.5));

_upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 20));
_upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 19.5));
_upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 19));
_upComingEnemies.Add(new EnemyDef("cannon_fodder_low", 18.5));

_upComingEnemies.Add(new EnemyDef("cannon_fodder_straight", 16));
_upComingEnemies.Add(new EnemyDef("cannon_fodder_straight", 15.8));
_upComingEnemies.Add(new EnemyDef("cannon_fodder_straight", 15.6));
_upComingEnemies.Add(new EnemyDef("cannon_fodder_straight", 15.4));
_upComingEnemies.Add(new EnemyDef("up_l", 10));
_upComingEnemies.Add(new EnemyDef("down_l", 9));
_upComingEnemies.Add(new EnemyDef("up_l", 8));
_upComingEnemies.Add(new EnemyDef("down_l", 7));
_upComingEnemies.Add(new EnemyDef("up_l", 6));
// Sort enemies so the greater launch time appears first.
_upComingEnemies.Sort(delegate(EnemyDef firstEnemy, EnemyDef
secondEnemy)
{
return firstEnemy.LaunchTime.CompareTo(secondEnemy.LaunchTime);

});

}

 

试试这个关卡,可能会发现关卡的难度有点高。现在可以根据需要修改代码,使游戏简单一些。试试提高玩家发射子弹的速度或者飞船的速度。

9.  敌人攻击

敌人沿着有趣的路径通过关卡,但是它们还是比较被动的。玩家可以向它们狂轰乱炸,而它们不会采取任何行动。敌人应该通过某种方法保护自己,本节就赋予它们这种能力。
已有的BulletManager类只处理玩家的子弹。敌人的子弹将只影响玩家,而玩家的子弹将只影响敌人。因此,创建单独的子弹列表最简单。这意味着需要使BulletManager的一些函数更加通用,以接受一个子弹列表。

public class BulletManager
{
List<Bullet> _bullets = new List<Bullet>();
List<Bullet> _enemyBullets = new List<Bullet>();

// Code omitted

public void Update(double elapsedTime)
{
UpdateBulletList(_bullets, elapsedTime);
UpdateBulletList(_enemyBullets, elapsedTime);
}

public void UpdateBulletList(List<Bullet> bulletList, double
elapsedTime)
{
bulletList.ForEach(x => x.Update(elapsedTime));
CheckOutOfBounds(_bullets);
RemoveDeadBullets(bulletList);
}

private void CheckOutOfBounds(List<Bullet> bulletList)
{
foreach (Bullet bullet in bulletList)
{
if (!bullet.GetBoundingBox().IntersectsWith(_bounds))
{
bullet.Dead = true;
}
}
}

private void RemoveDeadBullets(List<Bullet> bulletList)
{
for (int i = bulletList.Count - 1; i >= 0; i–)
{
if (bulletList[i].Dead)
{
bulletList.RemoveAt(i);
}
}
}

internal void Render(Renderer renderer)
{
_bullets.ForEach(x => x.Render(renderer));
_enemyBullets.ForEach(x => x.Render(renderer));
}


 上面的代码为敌人的子弹引入了另一个列表,现在还需要有一个让敌人发射子弹的函数和一个检查子弹是否击中玩家的函数。

public void EnemyShoot(Bullet bullet)
{
_enemyBullets.Add(bullet);
}

public void UpdatePlayerCollision(PlayerCharacter playerCharacter)
{
foreach (Bullet bullet in _enemyBullets)
{
if(bullet.GetBoundingBox().IntersectsWith(playerCharacter.
GetBoundingBox()))
{
bullet.Dead = true;
playerCharacter.OnCollision(bullet);
}
}
}

 

UpdatePlayerCollision与已有的UpdateEnemyCollision方法非常类似,最终它们应该被合并到一起。但是在游戏开发的这一次迭代中,使两者独立会更加简单一些。PlayerCharacter类需要一个新的接受子弹对象的OnCollision方法。

internal void OnCollision(Bullet bullet)
{
_dead = true;
}


现在PlayerCharacter有两个碰撞方法:一个用于子弹,一个用于敌人。PlayerCharacter在接触到敌人或者子弹后将会死亡,所以这些方法是冗余的。以这种方法编写它们是为了使扩展游戏变得更加简单。知道玩家角色与什么发生碰撞很重要。如果玩家具有生命值,那么与敌人发生碰撞导致的伤害可能比与子弹发生碰撞导致的伤害更大。如果添加了导弹、地雷或者各种增加玩家能力的物品,那么也可以有额外的碰撞检测方法来处理与它们的碰撞。
射击游戏是一种非常严格的游戏。如果玩家与飞船发生碰撞,将立即失败。同样,如果与子弹发生碰撞,玩家也会失败。现在BulletManager需要在Level类的Update循环中添加一个额外的调用,以测试敌人的子弹是否击中了玩家。

private void UpdateCollisions()
{
_bulletManager.UpdatePlayerCollision(_playerCharacter);
为使敌人可以使用新增加的发射子弹的功能,需要使它们能够访问BulletManager类。可以把BulletManager传递给EnemyManager,并借此传递给各个敌人。下面的代码中从Level类的构造函数把它传递给了EnemyManager。
public Level(Input input, TextureManager textureManager, Persistant
GameData gameData)
{
_input = input;
_gameData = gameData;
_textureManager = textureManager;
_effectsManager = new EffectsManager(_textureManager);
_enemyManager = new EnemyManager(_textureManager, _effectsManager,
_bulletManager, -1300);
在下面的代码中,EnemyManager存储了对BulletManager的引用,并在构造敌人的时候使用这个引用。
BulletManager _bulletManager;
public EnemyManager(TextureManager textureManager, EffectsManager
effectsManager, BulletManager bulletManger, int leftBound)
{
_bulletManager = bulletManger;

// Code omitted

private Enemy CreateEnemyFromDef(EnemyDef definition)
{
Enemy enemy   new Enemy(_textureManager, _effectsManager,
_bulletManager);

 

现在敌人有了BulletManager类,可以借助它开始发射子弹了。现在的问题是,敌人应该在什么时候发射子弹?它们不能在每一帧中都发射子弹,那样的话游戏就太难了。敌人也不应该同时发射子弹,那样的话游戏也会太难。这里的技巧是为每个敌人随机设置发射时间。

public double MaxTimeToShoot { get; set; }
public double MinTimeToShoot { get; set; }
Random _random = new Random();
double _shootCountDown;

public void RestartShootCountDown()
{
_shootCountDown = MinTimeToShoot + (_random.NextDouble() *
MaxTimeToShoot);
}

BulletManager _bulletManager;
Texture _bulletTexture;
public Enemy(TextureManager textureManager, EffectsManager
effectsManager, BulletManager bulletManager)
{
_bulletManager = bulletManager;
_bulletTexture = textureManager.Get("bullet");
MaxTimeToShoot = 12;
MinTimeToShoot = 1;
RestartShootCountDown();

// Code omitted
public void Update(double elapsedTime)
{
_shootCountDown = _shootCountDown - elapsedTime;
if (_shootCountDown <= 0)
{
Bullet bullet = new Bullet(_bulletTexture);
bullet.Speed = 350;
bullet.Direction = new Vector(-1, 0, 0);
bullet.SetPosition(_sprite.GetPosition());
bullet.SetColor(new Engine.Color(1, 0, 0, 1));
_bulletManager.EnemyShoot(bullet);
RestartShootCountDown();
}


 创建敌人后,它会通过设置一个计时器来确定下一次发射子弹的时间。计时器使用C#的Random类和最小及最大时间来设置,计时器应该在最小值和最大值之间。所有的飞船将在不同的时间发射子弹。RestartShootCountDown方法设置敌人下一次发射子弹的时间。Math.NextDouble返回0~1之间的一个随机数,它将被放大到MinTimeToShoot和MaxTimeToShoot成员变量之间。

Update循环减小_shootCountDown的值,当这个值等于或者小于0时,敌人就会发射子弹。敌人的子弹被设为比玩家的子弹慢,其方向与玩家的子弹相反,并且颜色被设为红色,以便与玩家的子弹进行区分。当敌人发射子弹后,_shootCountDown计时器将被重置。

敌人朝着屏幕的左侧发射子弹。你可能想让游戏变得更加困难一些,使敌人瞄着玩家发射子弹。为此,敌人必须具有对PlayerCharacter的引用。这样只需要确定玩家相对于敌人飞船的方向。如果决定向敌人增加瞄准能力,下面这个简单的代码段可以提供帮助。

Vector currentPosition = _sprite.GetPosition();
Vector bulletDir = _playerCharacter.GetPosition() - currentPosition;
bulletDir = bulletDir.Normalize(bulletDir);
bullet.Direction = bulletDir;


 对游戏的第二遍细化到此结束。敌人可以向玩家发射子弹,并按照有趣的方式四处移动。在消灭敌人时它们将会爆炸,显示出一个效果令人满意的火球。

四、 继续迭代

  • 在开发过程经历了两次基本的迭代后,我们有了一个很出色、但是很基础的卷轴射击游戏。还有很大的发挥空间,可以把这个项目开发成有自己的特色的游戏。现在这是你的项目,可以根据你自己的需要进行开发。如果感到有一些困惑,可以考虑下面的建议。
  • 一个非常简单的第一步是引入一种新的敌人类型。只需要再添加一个else if语句,还可以修改路径或者生命值。完成这些修改之后,考虑创建一个新的敌人纹理,并把新敌人修改为使用该纹理。这样游戏一下子就有趣得多了。
  • 在卷轴射击游戏中,得分很重要。得分可以使用Text类显示。每次玩家消灭一个敌人时,得分都应该增加。
  • 添加声音也很简单。需要像本书前面那样创建一个声音管理器,并且可以为射击、爆炸和受到损伤等生成各种合适的声音。然后只需要确定爆炸、损伤和射击事件发生的位置,并调用声音管理器来播放正确的声音。主要的工作是在对象间传递声音管理器,以便可以在需要的地方使用它。
  • 代码中存在大量的管理器和一些具有类似代码的函数。如果使这些管理器成为通用管理器,并移除重复的代码,整体的代码可以更加紧凑、更易于扩展。一个不错的起点是看看各个管理器使用了哪些类似的方法,然后考虑扩展Entity类,以便可以创建一个通用的EntityManager。
  • 游戏只有一个关卡,定义在EnemyManager构造函数中。这样做的扩展性不太好。一个良好的入手项目是在一个文本文件中定义关卡。在启动时,程序可以读取文本并加载关卡定义。关卡定义可以十分简单,例如:
cannon_fodder, 30
cannon_fodder, 29.5
cannon_fodder, 29
cannon_fodder, 28.5
  • 每一行都有一个敌人类型和一个启动时间,两者用逗号分隔。这样解析起来和读入到Level定义类中都十分简单。关卡数据可能应该存储在PersistentGameData类中。
  • 加载了一个关卡后,创建一个新的关卡文件很简单,这样就一下子具有了创建多关卡游戏的可能。成功地完成了一个关卡后,不是返回到StartGameState,而是返回到InnerGameState,只不过开始使用下一个关卡。如果游戏有多个关卡,那么存储玩家在关卡中的进度是一个不错的功能。保存游戏数据的一个非常简单的方法是将玩家得分和当前关卡写入到一个文本文件中。
  • 可以不让玩家以线性的方式(1、2、3、4)闯关,而是为玩家展示游戏世界大地图,让玩家选择接下来玩哪一个关卡。游戏世界大地图一般将所有的关卡表示为通过路径连接在一起的节点。《超级马里奥(Super Mario)》游戏系列中的一部分就使用了这种系统。通过使用大地图,很容易引入一些秘密的路径和关卡,只有玩家在前一个关卡中完成得特别出色时,才可以发现它们。
  • 如果玩家被敌人飞船或者子弹击中,PlayerCharacter死亡,游戏结束。更好的做法是让玩家的飞船也具有生命值,使其可以像敌人一样每次受到一定的伤害。生命值可以用屏幕上的一个生命值条表示,每次玩家被击中一次,生命值条会缩短一些。还可以引入生命的概念:玩家一开始有几条生命,每条生命允许玩家再玩一次关卡,当生命数变为0时,玩家将输掉游戏。
  • 游戏关卡越靠后,一般会越难。为了给玩家提供帮助,可以给他提供更好的武器,以及可以修复飞船遭受的损坏的物品。在卷轴射击游戏中,敌人会掉落一些增加玩家能力的物品。可以创建一个新的Item类并添加到场景中(可以通过EffectsManager实现),以便玩家可以拾取。急救包可以恢复玩家的生命值。新武器可以造成更大的伤害,或者一次发射两发而不是一发子弹。
  • 还可以添加通过其他按钮使用的辅助武器。例如,炸弹或者激光发射器可以作为发射次数有限的辅助武器。
  • RPG元素是增加深度感的一种极为流行的方式。敌人可以掉落金钱(或者可以在以后卖出的碎片)。在每一关过后,玩家可以购买新武器,升级现有武器,以至购买新的飞船。甚至还可以通过修改PlayerCharacter的_gunOffset成员,允许玩家在飞船上的不同位置安装武器。
  • 可以进一步利用RPG元素,在游戏中添加解说。这可以在关卡中使用文本框和通过脚本确定的敌人和玩家的移动来实现。也可以把故事元素添加到大地图上,或者在完成每一关后添加。
  • 关卡最后的大boss也是卷轴射击游戏中必不可少的一个元素。可以聚集几种不同类型的敌人来作为boss。boss的各个部分可以被击毁,但是只有当boss的全部部分都被击毁后,PlayerCharacter才会获胜。
  • 卷动太空背景很枯燥,加上一些动画显示的飞船残骸、远方的超新星和行星后,可以变得更加生动。卷动背景可以在任何时候修改,所以只需要做一点工作,很容易就能产生飞船朝着行星表面飞行的效果。
  • 最后一个建议是,可以增加本地多玩家模式,这其实很简单,只需要把另一个游戏控制器或者键盘的输入重定向到第二个PlayerCharacter上。还需要修改一些逻辑,以便当一个玩家角色死亡后,另一个玩家还可以继续玩游戏。

《精通C#游戏编程》试读电子书免费提供,有需要的留下邮箱。 
 

相关推荐