学习 XNA 2D 引擎 IceCream 与 1945 演示项目





5.00/5 (13投票s)
IceCream1945 是一款 XNA 和 IceCream 2D 库的演示,它实现了一个类似 NES 上 1942 的 2D 自上而下卷轴射击游戏。
概述
XNA 是微软提供的优秀游戏开发 SDK。它处理了许多核心游戏引擎功能,让开发者能够直接专注于有趣的开发部分。但由于它为了适应所有人而具有开放性,可以用于 2D 和 3D 游戏,因此当您缩小了游戏范围和类型后,使用起来可能会有点麻烦。如果您正在制作一款 2D 游戏,您将从一个功能强大但需要大量调整的通用库开始。在编写任何代码之前,最好在 XNA 之上使用另一个层来让您更接近您想制作的游戏类型。
IceCream 是一个用 XNA 编写的用于处理 2D 精灵游戏(sprite-based games)的框架。如果您想制作这类游戏,本文和框架就很适合您。如果您想要 3D,您最好阅读其他内容。
在深入本文主题之前,我鼓励您下载源代码和示例演示应用程序并亲自体验一下。它很简短,但演示了本文讨论的许多内容:加载场景、精灵移动、玩家输入(WASD)、敌人、子弹和碰撞、动画、卷轴背景等。控件是:
- 移动:W、A、S 和 D
- 发射子弹:空格键
- 投掷炸弹:左 Shift 键
- 退出:Escape 键
现在您已经看到了引擎的实际运行效果,让我们来谈谈这个框架。
什么是 IceCream 及其历史?
IceCream 库是 Episcode (Loïc Dansart) 和 conkerjo 的劳动成果。自从 2009 年以来,它就没有新的提交记录,直到我发现它并询问是否有适用于 XNA 4.0 的更新。我很幸运,因为 Loïc 已经完成了所有工作,只是还没有提交。几个小时后,最新的 XNA 4.0 兼容代码就被提交并准备就绪。Loïc 通过电子邮件提供的官方代码许可是,您可以随意使用它,只要不是用于公开的衍生编辑器/引擎。
IceCream 的特别之处/为什么我应该使用它?
为什么要为 XNA 编写另一个 2D 引擎,而这个引擎已经拥有海量内置功能和 GUI 编辑器?更具体地说,IceCream 内置支持精灵图集(spritesheets)、静态和动画精灵、层叠、瓦片网格、粒子效果、后期处理效果以及复合实体(想想:2D 人形生物,有手臂和腿,像骨骼一样动画,而不是为每个位置使用一个整体精灵)。它甚至还有一个 GUI 编辑器,用于将所有这些项目放入您的关卡(IceCream 称之为“场景”)。
IceCream 基于组件设计模型。每个 SceneItem
可以拥有 0 个或多个组件,这些组件是您编写的代码块,可以执行或控制任何您想要的功能。最基础的类型是速度组件,它赋予精灵移动能力。一个速度组件可能有一个 Vector2 来描述 X 和 Y 速度,并且在每个 Update()
调用中,精灵都会按照这些量移动。但是 IceCream 没有内置组件,也不会对您想如何编写游戏做出假设。它只是让您能够将可重用的代码通过组件附加到每个场景项上,这些组件会在每个循环中获得一个 Update()
调用。
没有可重写的 Draw()
方法,因为 IceCream 负责所有绘制。这是这个引擎唯一的限制。由于它负责所有绘制,如果您喜欢自己绘制,那么您将没有机会这样做。但这也有好处:所有的绘制代码都为您编写好了。(例外情况是,您的主游戏类,继承自 IceCream.Game
,确实有一个 Draw()
重写,但组件没有。)
但是,如果您觉得它确实缺少您想要的绘图功能,它是开源的,您可以轻松地进入并根据您的意愿进行修改。在过去几个月里使用过代码库后,我可以说,一旦您熟悉了代码的位置,它就很容易理解。绘制部分有点复杂,因为它功能强大,但并非魔法。
我将跳过 GUI 的细节,因为我假设您有一些开发经验和知识。因此,MilkShake UI 在打开包含的项目文件(将 MilkShake 指向 IceCream1945/Game.icproj)后,通过自己探索 10 到 15 分钟应该就能很自然地掌握。
组件属性
正如我提到的,IceCream 是基于组件的,您的代码主要位于这些组件中。每个组件都可以重写以下方法:
CopyValuesTo(object target)
- IceCream 引擎会定期调用此方法以执行深拷贝。任何时候您在组件中添加属性,您都需要将其添加到此方法中发生的复制操作中。由您决定对象的哪些部分的状态与深拷贝相关,哪些应该被跳过。OnRegister()
- 每当父SceneItem
注册到场景时调用,这发生在场景加载到内存时,或者当一个SceneItem
被new
并添加到场景后的下一个Update()
调用时(脚注:也可以告诉场景立即注册一个项,而不是在下一个Update()
时注册)。OnUnRegister()
- 每当SceneItem
被标记为删除并从场景中移除时调用。Update(float elapsedTime)
- 每帧都会调用此方法,让您的组件推进状态,无论这意味着什么。这是真正核心的部分。一个用于检查玩家输入的组件会在这个方法中执行输入检查。同样,我们前面提到的 VelocityComponent 示例将使用此方法来修改其父SceneItem
的 X 和 Y 位置。
有时,这些属性显示在 MilkShake GUI 设置区域(在构建场景时)是有意义的。要做到这一点,我们用 [IceComponentProperty("text")]
来装饰这些属性。这个属性用于告诉 MilkShake UI,该属性在属性列表 UI 中是可编辑的,以及使用什么文本描述。没有此属性的属性不会在 MilkShake 中暴露。简单来说,如果它有一个 IceComponentProperty
装饰器,它就是编辑器中的一个配置值。如果没有,它可能是一个内部管理的状态属性。
[IceComponentProperty("Velocity Vector")]
public Vector2 Velocity { get; set; }
IceCream 组件属性在 UI 中的各种示例namespace IceCream1945.Components
{
[IceComponentAttribute("VelocityComponent")]
public class VelocityComponent : IceComponent
{
[IceComponentProperty("Velocity Vector")]
public Vector2 Velocity { get; set; }
public VelocityComponent() {
Enabled = false;
//we manually Enable the component in other locations
//of code. By default, all components are enabled.
}
public override void OnRegister() { }
public override void CopyValuesTo(object target) {
base.CopyValuesTo(target);
if (target is VelocityComponent) {
VelocityComponent targetCom = target as VelocityComponent;
targetCom.Velocity = this.Velocity;
}
}
public override void Update(float elapsedTime) {
if (Enabled) {
this.Owner.PositionX += Velocity.X * elapsedTime;
this.Owner.PositionY += Velocity.Y * elapsedTime;
}
}
}
}
开始编写代码
在 IceCream1945 中,我们跳过组件,进入初始游戏启动(不是 IceCream 核心库的一部分),我们有 MainGame
类,它继承自 IceCream.Game
。我们可以选择性地重写常见的 XNA 方法,如 Update()
和 Draw()
。我使用这个类进行基本初始化、持有当前场景以及在场景之间导航(例如,从标题屏幕到关卡 1,从关卡 1 到游戏结束等)。在我的示例中,我希望这个类非常轻量级,相对简单。
同样,这个类是自定义代码,不是标准 IceCream 代码库的一部分。
public class MainGame : IceCream.Game
{
public static GameScene CurrentScene;
public static readonly string SplashIntroSceneName = "Splash";
public static readonly string Level1SceneName = "Level1";
public static readonly string EndingSceneName = "Ending";
public MainGame() {
GlobalGameData.ContentDirectoryName = ContentDirectoryName = "IceCream1945Content";
GlobalGameData.ContentManager = Content;
}
protected override void LoadContent() {
base.LoadContent();
this.IsFixedTimeStep = false;
CurrentScene = new MenuScene(SplashIntroSceneName);
CurrentScene.LoadContent();
}
protected override void Update(GameTime gameTime) {
if (GlobalGameData.ShouldQuit) {
if (CurrentScene != null)
CurrentScene.UnloadContent();
this.Exit();
return;
}
if (CurrentScene.MoveToNextScene) {
if (CurrentScene.SceneName == SplashIntroSceneName ||
CurrentScene.SceneName == EndingSceneName) {
CurrentScene.UnloadContent();
CurrentScene = new PlayScene(Level1SceneName);
CurrentScene.LoadContent();
}
else if (CurrentScene.SceneName == Level1SceneName) {
CurrentScene.UnloadContent();
CurrentScene = new MenuScene(EndingSceneName);
CurrentScene.LoadContent();
}
}
base.Update(gameTime);
CurrentScene.Update(gameTime);
}
protected override void Draw(GameTime gameTime) {
base.Draw(gameTime);
CurrentScene.Draw(gameTime);
}
protected override void UnloadContent() {
base.UnloadContent();
}
}
我在这里提到的场景与 IceCream.Scene
不同。我创建了一个抽象的 GameScene
类,它是 PlayScene
和 MenuScene
的基类。这允许我重用 GameScene
中的通用方法,但为“菜单”类场景与“游戏”类场景(以及潜在的其他场景)提供特殊的处理。在 GameScene
中,我期望玩家主动控制一个游戏对象,AI 在思考,相机在移动。但 MenuScene
主要在于呈现信息并等待用户输入。因此,我认为将这两种情况分成两个单独的对象会更清晰,而不是在一个对象中充斥着条件语句。
public override void Update(GameTime gameTime) {
base.Update(gameTime);
float elapsed = (float)gameTime.ElapsedGameTime.TotalSeconds;
IceCream.Debug.OnScreenStats.AddStat(string.Format("FPS: {0}",
DrawCount / gameTime.TotalGameTime.TotalSeconds));
if (ReadInput || WaitBeforeInput.Stopwatch(100)) {
ReadInput = true;
if (InputCore.IsAnyKeyDown()) {
MoveToNextScene = true;
}
}
}
预加载、缓存和全局游戏数据
public static class GlobalGameData
{
public static ContentManager ContentManager = null;
public static bool ShouldQuit = false;
public static string ContentDirectoryName = string.Empty;
public static int ResolutionHeight = 720, ResolutionWidth = 1280;
public static int PlayerHealth;
public static int MaxPlayerHealth = 18;
public static bool SoundOn = true;
public static bool MusicOn = true;
public static float SoundEffectVolume = 0.3f;
public static float MusicVolume = 0.3f;
public static List<sceneitem> InactiveSceneItems = new List<SceneItem>();
public static List<sceneitem> ActiveSceneItems = new List<SceneItem>();
public static SceneItem PlayerAnimatedSprite = null;
public static PostProcessAnimation ScreenDamageEffect = null;
public static PointTracking PlayerOnePointTracking = new PointTracking();
}
GlobalGameData.PlayerAnimatedSprite =
scene.GetSceneItem<AnimatedSprite>("PlayerPlane_1");
HealthBarItem = scene.GetSceneItem<Sprite>("HealthBar");
GlobalGameData.PlayerHealth = GlobalGameData.MaxPlayerHealth;
//[...]
GlobalGameData.ScreenDamageEffect =
scene.CreateCopy<PostProcessAnimation>("PlayerDamageScreenEffect");
GlobalGameData.ScreenDamageEffect.Stop();
scene.RegisterSceneItem(GlobalGameData.ScreenDamageEffect);
//[...]
foreach (SceneItem si in scene.SceneItems)
GlobalGameData.InactiveSceneItems.Add(si);
//sort the inactive list so we only have to look at the very
// first one to know if there is anything to activate.
GlobalGameData.InactiveSceneItems.Sort(delegate(SceneItem a, SceneItem b)
{ return b.PositionY.CompareTo(a.PositionY); });
GlobalGameData
是一个单例类(我的自定义代码,不是 IceCream 基础部分),它持有游戏设置和常用对象的引用。在碰撞检测等过程中,需要遍历所有场景项进行检测。这样的操作非常慢,当您的游戏超过简单的概念验证阶段时,它可能会成为瓶颈。因此,我为此创建了 ActiveSceneItems
和 InactiveSceneItems
列表。在遍历项寻找碰撞时,我只查找活动的项,我将活动项视为屏幕上可见的精灵或已移出屏幕的精灵(但那些应该由边界检测组件自动消除)。这样,我就可以避免在玩家刚开始游戏时检查关卡末尾的场景项,并且我可以控制场景项的启用,让它们随着玩家在关卡中移动而不是让所有精灵立即开始遍历关卡。
有更快的方法,例如将屏幕划分为象限或其他区域,但目前,仅包含屏幕上 SceneItem
的活动列表已经足够快了。
此外,像生命值框和分数这样的元素每帧都会移动到屏幕顶部,而相机则“向上”移动。这需要代码始终访问 PositionY
属性,并且每帧查找这些对象非常浪费。因此,我们一次性找到它们并保持对其的引用。
本质上,GlobalGameData
是“全局”变量的存储位置。在更健壮的游戏中,这些引用可能会被拆分成更简洁的对象,例如静态的 Settings 对象和 SceneItemCache
对象,或相应的管理类对象。但无论如何,我们只需要并希望拥有这个数据的唯一副本,并希望它几乎从我们游戏的任何地方都可以访问。我们不希望在将某个上下文对象传递给所有方法时,使用一个全局可访问的上下文对象就可以实现同样的效果(因为我们永远不需要在单个运行实例中存在两个上下文)。“全局变量不好”和让所有方法签名都变得臃肿以传递对象引用之间存在一条细微的界限。
游戏循环:场景移动和背景滚动
场景与相机的实际移动一起移动。有些卷轴游戏通过移动场景进入静态相机的视野或其他间接方式工作,但我认为最有效且在思维上最直接的方式是只移动相机,让它扫描关卡。其中一些原因是:
- 如果我必须将场景移入相机的视野,我将需要修改每一帧的每个场景项的位置,将它们移入相机的视野。通过移动相机,只有那些通过 AI 实际移动的场景项才需要移动。
- 保持相机静止,但通过脚本在相机视野之外生成对象会大大增加代码和 GUI 的复杂性。
- GUI 编辑器已经为在布局好的场景上进行相机移动进行了设置。使用 IceCream 的目的是利用他人编写的工具。
float ScrollPerUnit = 60.0f;
[...]
float elapsed = (float)gameTime.ElapsedGameTime.TotalSeconds;
float distance = elapsed * ScrollPerUnit;
scene.ActiveCameras[0].PositionY -= distance;
BoundsComponent.ResetScreenBounds();
背景移动仅是部分真实的。它只是一个宽度与屏幕相同、高度 32 像素的单个精灵,复制到屏幕高度加上两个。每次底部条带消失在相机视野之外时,它就会被移到顶部。因此,随着相机沿场景移动,背景图像会不断地从底部向上移动,我们不必浪费内存将条带复制到场景的整个垂直长度。我们只需要足够的内存来覆盖用户的视野。
/* Load Background Water sprites */
Sprite water = scene.CreateCopy<Sprite>("Water1280");
WaterHeight = water.BoundingRectSize.Y;
double totalWaters = Math.Ceiling(GlobalGameData.ResolutionHeight / WaterHeight) + 1;
for (int i = -1; i < totalWaters; ++i) {
Sprite w = scene.CreateCopy<Sprite>("Water1280");
w.Layer = 10;
w.PositionX = 0;
w.PositionY = i * WaterHeight;
scene.RegisterSceneItem(w);
BackgroundWaters.Add(w);
}
/* Background Water Movement (totally fake)
* Since our camera is moving up the Y axis, all we have to do is shift the background upwards
* everytime the camera moves the equivalent of a tile height. */
BackgroundOffset += distance;
if (BackgroundOffset > WaterHeight) {
foreach (Sprite water in BackgroundWaters) {
water.PositionY -= WaterHeight;
}
BackgroundOffset -= WaterHeight;
}
对于精灵和相机的移动,我决定根据更新调用之间经过的时间来移动游戏中的对象。XNA 喜欢以 60 帧/秒的速度运行,无论您是否启用 IsFixedTimeStep
。而且,在这个复杂度较低的游戏中,目前这样做差别不大。但如果由于场景复杂度导致我们的帧率开始下降,这个设计决策将使游戏更具可玩性和一致性。
public override void Update(float elapsedTime) {
if (Enabled) {
this.Owner.PositionX += Velocity.X * elapsedTime;
this.Owner.PositionY += Velocity.Y * elapsedTime;
}
}
编写此方法的另一种方式是,每次调用更新时,将每个精灵移动固定 X 像素,而不管经过了多少时间。20 世纪 80 年代和 90 年代初编写的许多使用此方法的旧游戏在今天的 PC 上已经失控了。当时,开发者没想到他们的游戏会流传至今,所以他们编写的代码是全速运行的,没有任何限制。我认为认识到您的代码比您想象的要更有生命力是很重要的。
为了防止在不可避免的未来出现这种情况,XNA 框架应该足够智能,可以将帧率限制在每秒 60 帧,即使硬件可以以更快的速度运行您的游戏。
玩家移动和射击:PlayerControllableComponent
PlayerControllableComponent 应用于玩家控制的精灵。它通过 IceInput 监听输入并相应地移动精灵。它支持多人游戏,但我尚未实现。其余代码可以轻松修改以支持合作模式,但我决定这超出了我第一个练习的范围。
public override void Update(float elapsedTime) {
// when the owner uses PlayerIndex.One
if (Playerindex == PlayerIndex.One) {
// if W button is pressed
if (InputCore.IsKeyDown(Keys.W)) {
// we go upwards
Owner.PositionY -= Velocity.Y;
}
// if S key is pressed
if (InputCore.IsKeyDown(Keys.S)) {
// we go downwards
Owner.PositionY += Velocity.Y;
}
// if A button is pressed
if (InputCore.IsKeyDown(Keys.A)) {
// we go to the left
Owner.PositionX -= Velocity.X;
}
// if D button is pressed
if (InputCore.IsKeyDown(Keys.D)) {
// we go to the right
Owner.PositionX += Velocity.X;
}
if (BulletTimer.Stopwatch(100) && InputCore.IsKeyDown(Keys.Space)) {
//fire projectile
AnimatedSprite newBullet = Owner.SceneParent.CreateCopy<AnimatedSprite>("FlamingBullet");
newBullet.Visible = true;
newBullet.Position = Owner.Position;
newBullet.PositionX += Owner.BoundingRectSize.X / 2;
VelocityComponent velocityCom = newBullet.GetComponent<VelocityComponent>();
velocityCom.Enabled = true;
Owner.SceneParent.RegisterSceneItem(newBullet);
Sound.Play(Sound.Laser_Shoot_Player);
}
if (BombTimer.Stopwatch(100) && InputCore.IsKeyDown(Keys.LeftShift)) {
//drop bomb
Sprite newBullet = Owner.SceneParent.CreateCopy<Sprite>("Bomb");
newBullet.Visible = true;
newBullet.Position = Owner.Position;
newBullet.PositionX += Owner.BoundingRectSize.X / 2;
Owner.SceneParent.RegisterSceneItem(newBullet);
}
}
//[...] Unused code relating to PlayerTwo
if (InputCore.IsKeyDown(Keys.Escape)) {
GlobalGameData.ShouldQuit = true;
}
}
此组件监听所有玩家输入,包括射击和投掷炸弹按钮,因此它还会处理在必要时生成那些场景项。IceCream 使我们能够非常轻松地处理这个问题。使用 MilkShake,我创建了一个带有以下组件的 FlamingBullet 动画精灵,这些组件已经附加并配置好:
- VelocityComponent:为这个子弹提供 X 和 Y 速度。
- LifeComponent:如果这个子弹存在时间超过两秒,则销毁它。这可以防止子弹穿过其他检查并永远存在。这对于通过抛出异常来测试非常有用,如果达到了 2 秒。目前,没有玩家子弹应该存活那么长时间,所以任何存活的都是错误。编写一些组件作为小的测试以确保其他组件正常工作是可能的,甚至是明智的。
- BoundsComponent:如果这个子弹完全移出相机视野,则销毁它。我们不希望玩家摧毁关卡底部以外的对象。
- BulletComponent:此组件负责检查碰撞并造成伤害。
当玩家发射一个火焰子弹时,我要求 IceCream 创建这个模板的副本,调整 VelocityComponent 的速度,并将场景项注册到 IceCream。IceCream 引擎和我的组件将接管其余的工作,PlayerControllableComponent 可以完全忘记它。
碰撞检测
此演示应用程序中的碰撞检测仅与“子弹”,即射弹相关。玩家发射子弹,敌人也发射子弹。每个子弹都是一个带有 BulletComponent 的动画精灵,该组件有两个标志,用于指示它是否可以伤害玩家以及是否可以伤害敌人(它可以是两者)。
当此组件执行 Update()
时,它会遍历 ActiveSceneItems
列表中的所有场景项,并将其边界矩形与子弹进行比较。如果它们相交,则发生碰撞。我没有在此处实现的更好方法是,在确定矩形交叉后,进行更精细的测试,例如逐像素测试或多边形检测。然而,我再次认为这超出了本次初始练习的范围。IceCream 对多边形碰撞检测有一些初步支持,但尚未完全实现。
如果发生碰撞,会生成爆炸动画并相应地应用伤害。对于敌人,现在它们只是死亡。但是,玩家有一个生命值条,每次击中都会减少。此外,如果玩家被击中,会短暂播放一个后期处理效果,导致屏幕模糊并闪烁非常短暂。这给了玩家比查看子弹是否击中或生命值是否减少更明显的视觉反馈。无论玩家在屏幕的哪个位置,它都能吸引他们的注意力。
public override void Update(float elapsedTime) {
//We get all scene items ( not recommended in bigger games, at least not every frame)
for (int i=0; i < GlobalGameData.ActiveSceneItems.Count; ++i) {
SceneItem si = GlobalGameData.ActiveSceneItems[i];
if (si != Owner && (si.GetType() == typeof(Sprite) ||
si.GetType() == typeof(AnimatedSprite)) && !Owner.MarkForDelete) {
if (CanDamageEnemy) {
if (si.CheckType(2) && Owner.BoundingRect.Intersects(si.BoundingRect)) {
si.MarkForDelete = true;
Owner.MarkForDelete = true;
GlobalGameData.ActiveSceneItems.Remove(si);
GlobalGameData.ActiveSceneItems.Remove(Owner);
--i;
AnimatedSprite explosion =
Owner.SceneParent.CreateCopy<AnimatedSprite>("BigExplosion");
explosion.Visible = true;
explosion.Position = si.Position;
explosion.PositionX += si.BoundingRectSize.X / 2;
explosion.PositionY += si.BoundingRectSize.Y / 2;
explosion.CurrentAnimation.LoopMax = 1;
explosion.CurrentAnimation.HideWhenStopped = true;
Owner.SceneParent.RegisterSceneItem(explosion);
GlobalGameData.PlayerOnePointTracking.AddScore(50);
Sound.Play(Sound.ExplosionHit);
}
}
if (CanDamagePlayer) {
if (Owner.BoundingRect.Intersects(GlobalGameData.PlayerAnimatedSprite.BoundingRect)) {
--GlobalGameData.PlayerHealth;
Owner.MarkForDelete = true;
//if the player is getting bombarded, we want many flashes
GlobalGameData.ScreenDamageEffect.Reset();
GlobalGameData.ScreenDamageEffect.Play();
Sound.Play(Sound.Quick_Hit);
}
}
}
}
}
玩家-子弹碰撞时的后期处理效果
正如我之前提到的,IceCream 开箱即用支持各种后期处理效果。许多经典的卷轴射击游戏在玩家被击中并失去生命值时都有某种屏幕闪烁效果。后期处理效果是实现这种技巧的自然方式。我会在场景首次加载时加载和缓存效果,并在发生碰撞时指示 IceCream 播放一次。我将在 BulletComponent 中调用 Reset()
,然后再调用 Play()
,这样如果玩家反复被击中,效果会立即开始播放,而不是只播放一次。我认为这种即时反馈对玩家来说是游戏感觉“紧凑”的关键。
我通过 Milkshake 中的这个 UI 设置了效果
并在 BulletComponent 中调用它。我会在调用 Play 之前调用 Reset()
,这样如果玩家反复被击中,效果就会立即重新开始,而不是只播放一次。我认为这种即时反馈对玩家来说是游戏感觉“紧凑”的关键。
if (CanDamagePlayer) {
if (Owner.BoundingRect.Intersects(GlobalGameData.PlayerAnimatedSprite.BoundingRect)) {
--GlobalGameData.PlayerHealth;
Owner.MarkForDelete = true;
GlobalGameData.ScreenDamageEffect.Reset();//if the player is getting bombarded, we want many flashes
GlobalGameData.ScreenDamageEffect.Play();
Sound.Play(Sound.Quick_Hit);
}
}
敌人移动和 AI,以及 Tags 功能
IceCream 开源的一个很棒之处在于,如果您觉得它不能满足您的所有需求,您可以自由修改它。我做的一个很棒的改动是添加了一个“tags
”属性,这是一个简单的字符串列表,我用于分组和其他属性。因为在游戏过程中解析这些标签数据会很慢,所以我会在场景加载时一次性解析并缓存它们。
protected Dictionary<string, List<SceneItem>> Cache_TagItems;
public GameScene(string sceneName) {
[...]
Cache_TagItems = new Dictionary<string, List<SceneItem>>();
}
public virtual void LoadContent() {
[...]
CacheTagItems();
}
[...]
public string GetGroupTagFor(SceneItem si) {
foreach (string tag in si.Tags) {
if (tag.StartsWith("group"))
return tag;
}
return string.Empty;
}
protected void CacheTagItems() {
if (scene == null)
throw new Exception("Can't cache tags before loading a scene (scene == null)");
foreach (SceneItem si in scene.SceneItems) {
foreach (string tag in si.Tags) {
if (!string.IsNullOrEmpty(tag)) {
if (!Cache_TagItems.ContainsKey(tag)) {
Cache_TagItems[tag] = new List<SceneItem>();
}
Cache_TagItems[tag].Add(si);
}
}
}
}
public List<sceneitem> GetItemsWithTag(string tag) {
if (Cache_TagItems.ContainsKey(tag))
return Cache_TagItems[tag];
else
return new List<sceneitem>();
}
在 IceCream1945 中,标签的主要用途是用于分组敌人。随着关卡的滚动,每次 Update()
调用都会检查精灵是否已接近相机视野并应该在场景中激活。因为这是一个自上而下的射击游戏,精灵往往成波出现。也就是说,四个或更多的精灵作为一个整体移动。为了实现这一点,每个精灵都通过标签机制分配了一个组。当一个精灵被激活时,会读取组标签,并激活所有具有匹配组的其他场景项。这会激活整个“波次”,并使它们作为一个整体移动,而无需额外的代码或关卡设计复杂性。
int activationBufferUnits = 20;
for (int i=0; i < GlobalGameData.InactiveSceneItems.Count; ++i) {
SceneItem si = GlobalGameData.InactiveSceneItems[i];
if (si.BoundingRect.Bottom + activationBufferUnits > scene.ActiveCameras[0].BoundingRect.Top) {
//grab all other scene items in the same group and turn them on as well
EnableSceneItem(si);
GlobalGameData.InactiveSceneItems.RemoveAt(i);
--i;
string groupTag = GetGroupTagFor(si);
if (groupTag == string.Empty)
continue;
List<sceneitem> groupItems = GetItemsWithGroupTag(groupTag);
foreach (SceneItem gsi in groupItems) {
EnableSceneItem(gsi);
GlobalGameData.InactiveSceneItems.Remove(gsi);
}
}
else //if we didn't find anything to activate, we can just abort the loop. Our inactive list is sorted
break; //so that the "earliest" items are first. Thus, continuing the loop is pointless.
}
得分和成就
最后,对于得分和成就跟踪,我有一个 PointTracking
单例类,如果需要添加多人游戏支持,可以为每个玩家创建(或配置为多人游戏存储)。目前它仅用于得分,但打算在整个应用程序范围内用于其他内容,例如射击次数、击杀次数等,这些都可以作为有趣的统计数据在关卡结束时查看,甚至可以跟踪玩家的整个生命周期并授予成就。
/// <summary>
/// General class for tracking score, number of kills, shots fired,
/// and other metrics for achivements and similar
/// </summary>
public class PointTracking
{
public int PlayerScore { get; private set; }
public void AddScore(int additionalPoints) {
PlayerScore += additionalPoints;
}
}
通过 IceCream1945 这个示例游戏,我们完成了对 2D XNA IceCream 库的介绍。希望您喜欢这篇文章,并会下载示例应用程序和源代码。IceCream 是一个非凡的库,值得更多的关注和贡献。
祝您玩得开心!Tyler Forsythe, http://www.tylerforsythe.com/。