《炸弹人》系列是带有一整套有趣机制的简单游戏。通过在一些项目中使用ECS框架,我们将能够清楚如何使用这一模式去执行这些机制。
我会提供一款包含许多玩家对抗玩家核心机制的职业游戏,并着眼于ECS带给我们怎样的价值(以及哪里存在完善空间)。游戏基于许多其它方式使用了ECS,但是出于本篇文章的目的,我将只讨论核心的游戏机制。
为了能够清楚第进行解释,本文所提及的代码样本都将是伪代码。而如果你想要执行完整的C#,你可以着眼于样本本身。
同样的,我会使用粗体大写去提及组件。所以Bomb(粗体大写)便是指官方组件,而如果我是用bomb(细体小写),那便只是在说理念或代表该理念的实体。
让我们开始落实行动 在设计任何系统之前,我们必须理解你想要解决的问题范围。这便意味着列出各种游戏对象之间的所有互动。从那里,我们可以想出最合理的抽象进行使用。当然了,如果你的游戏仍出于开发阶段,或者你正尝试着去复制游戏,那么知道所有互动是不可能的,因为一开始你将会错失一些内容。ECS的一大优势便是能够根据需求做出改变,同时不会对其它代码造成太大影响。
关于《炸弹人》的PvP游戏玩法的总结如下。玩家种植炸弹去摧毁其他玩家并击碎砖块。被击碎的砖块将留下各种道具去增强你的弹药的破坏力,或影响玩家的速度。最后幸存下来的玩家将看到彻底的毁灭。
让我们立刻开始分析《炸弹人》的基本炸弹机制,并思考使用ECS模式去执行怎样的设计。
爆炸explosion
当一个炸弹爆炸后,威力将波及不同方向。并会发生以下情况:
“硬模块”将阻碍爆炸路径
“软模块”将阻碍爆炸路径(通常),并会被摧毁;它们将随机呈现一种道具
未被触发的炸弹将爆炸
任何被炸到的玩家都将死去
bomb
这里存在两种理念:一个特殊对象将对这种爆炸做出何种反应(死亡,触发,碎裂),一个特殊对象将如何影响爆炸路径(阻止它或不阻止它)。
我们可以创造一个ExplosionImpact组件去描写这一内容。对象只能通过一些方法去影响爆炸路径,但是它们会基于多少种方式做出回应?在组件中描述对象对爆炸做出反应的无数方式是没有意义的,所以我们会在每个对象上留下定制信息处理程序。ExplosionImpact应该如下:
enum ExplosionBarrier
{
None = 0,
Soft = 1,
Hard = 2,
}
class ExplosionImpact : Component
{
ExplosionBarrier Barrier;
}
这真的很简单。接下来让我们着眼于爆炸的内在属性。这主要取决于炸弹的类型。但是我们必须区分炸弹和爆炸,因为炸弹拥有像定时器和拥有者这样的属性。
爆炸可以:
延伸到任何或所有的8个方向
有时候会延伸至软砖块(传递炸弹,产生蓝色的爆炸)
不同的延伸范围(或无限范围)
有时候会延伸至硬砖块
power bomb
存在两种明显的方法去塑造爆炸。对于爆炸你可以设置一个单一实体,或者面向每个爆炸组件设置一个实体(《炸弹人》是基于网格机制,所以后者是可行的)。考虑到爆炸将基于撞击物而发生不同的延伸,我们将很难以一个单一实体进行呈现。似乎基于每次爆炸平方行的实体将更合理。注:这也许会让我们很难在屏幕上渲染一次爆炸的统一图像,但是如果你的爆炸是单一实体的话你也会遇到这一问题。单一实体将需要一种复杂的方式去描述整体爆炸的形状。
dangerous bomb
所以让我们试试Explosion组件:
class Explosion : Component
{
bool IsPassThrough; // Does it pass through soft blocks?
bool IsHardPassThrough; // Does it pass through hard blocks?
int Range; // How much further will it propagate?
PropagationDirection PropagationDirection;
float Countdown; // How long does it last?
float PropagationCountdown; // How long does it take to propagate to the next square?
}
enum PropagationDirection
{
None = 0×00000000,
Left = 0×00000001,
UpperLeft = 0×00000002,
Up = 0×00000004,
UpperRight = 0×00000008,
Right = 0×00000010,
BottomRight = 0×00000020,
Bottom = 0×00000040,
BottomLeft = 0×00000080,
NESW = Left | Up | Right | Bottom,
All = 0x000000ff,
}
当然了,因为我们正在使用ECS,所以其它组件将处理位置和爆炸图像等内容。
我已经在Explosion组件中添加了2个领域去支撑之前提到的内容。Countdown代表爆炸的持续事件。这并不是瞬间的—-它将持续较短时间,即在玩家走向死亡期间。我同样也添加了PropagationCountdown。在《炸弹人》中,爆炸将立即延伸。没有特殊原因,我决定做得与众不同。
所以这些是如何整合在一起?在ECS,系统将提供逻辑去控制组件。所以我们将拥有ExplosionSystem能够运行Explosion组件。你可以着眼于整体代码的样本项目,但让我们简单地列出它所包含的一些逻辑。首先,它将负责延伸爆炸。所以对于每个爆炸组件:
*更新Countdown和PropagationCountdown
*如果Countdown到达0,删除实体
*获得爆炸下的任何实体,并发送信息告诉它们自己正处于爆炸中
*如果PropagationCountdown到达0,在预想方向中创造全新的爆炸实体
ExplosionSystem同样也包含延伸逻辑。它需要寻找该形式下带有ExplosionImpact组件的任何实体。然后它便会比较ExplosionImpact的ExplosionBarrier与当前Explosion的属性((IsHardPassThrough等等)。当然,任何全新的Explosion将少一个范围。
道具 接下来我们将追踪玩家收集道具以及投下炸弹的路径。我使用了《炸弹人》中12个经典道具。与之前一样,让我们着眼于情节—-道具能够做什么,并想出设计方法。
Bomb–Up:按照玩家同时放置的炸弹数量而增加
Fire–Up:增加炸弹的爆炸半径
Speed-Up:提高玩家速度
(上述商店也拥有“Down”版本)
Full–Fire:炸弹拥有无限范围(除了与Dangerous–Bomb结合在一起时)
Dangerous Bomb:爆炸在平方形范围内扩展着,并通过硬砖块
Pass-Through Bomb:爆炸穿过软砖块
Remote Bomb:只有你触发炸弹时它们才会爆炸
Land Mine Bomb:你的第一个炸弹只会在有人走过时爆炸
Power Bomb:你的第一个炸弹拥有无限范围(就像Full–Fire,但仅限于第一个炸弹)
various powerups
你将看到在大多数道具影响你放置的炸弹类型的同时,它们也会影响着其它元素,如玩家速度。所以道具是与炸弹不一样的理念。此外,道具也不是专属的,它们是相结合的。所以如果你拥有几个带有Dangerous Bomb的Fire–Up,你便拥有一个具有较大爆炸范围的炸弹。
pass-through bombs
所以从本质上来看,玩家拥有一组属性能够预示着它们将放置怎样的炸弹。道具将会改变这些属性。让我们着眼于PlayerInfo组件看起来会是怎样。记住,这并不包含位置,当前速度或纹理等信息。该信息存在于玩家实体中的其它组件。另一方面,PlayerInfo组件包含针对于游戏中玩家实体的信息。
class PlayerInfo : Component
{
int PlayerNumber; // Some way to identify the player – this could also be a player name string
float MaxSpeed;
int PermittedSimultaneousBombs;
bool FirstBombInfinite;
bool FirstBombLandMine;
bool CanRemoteTrigger;
bool AreBombsPassThrough;
bool AreBombsHardPassThrough;
int BombRange;
PropagationDirection BombPropagationDirection;
}
当玩家丢下炸弹,我们便可以通过PlayerInfo组件去明确我们该掉落怎样类型的炸弹。这么做的逻辑有点复杂。这里存在许多条件句:举个例子来说,Land Mine炸弹看起来与朝着所有方向爆炸的Dangerous Bomb并不相同。所以当你拥有Land Mine同时也是Dangerous Bomb时,游戏使用了何种纹理?同样的,Power Bomb道具给予我们无限的BombRange,但是当炸弹超所有方向延伸时我们并不想要无限的范围。
这里存在一些非常复杂的逻辑。复杂性是源自《炸弹人》的属性,而不是代码所具有的任何问题。它是作为代码一个单独组件而存在着。你可以无需影响其它代码去改变逻辑。
我们也需要考虑当前的玩家激活了多少炸弹。我们需要设置上限,同时也需要为他们放置的第一个炸弹设置一些独特的属性。比起储存玩家当前的炸弹数,我们可以通过列举世界上所有的Bomb组件去估算到底有多少炸弹。这将避免在PlayerInfo组件中隐藏一个UndetonatedBombs价值。这也会减少不同步所引起的漏洞风险,并基于bomb–dropping逻辑所需要的信息去避免搞乱PlayerInfo组件。
考虑到这点,让我们着眼于谜题的最后组件:炸弹。
class Bomb : Component
{
float FuseCountdown; // Set to float.Max if the player can remotely trigger bombs.
int OwnerId; // Identifies the player entity that owns the bomb. Lets us count
// how many undetonated bombs a player has on-screen
int Range;
bool IsPassThrough;
bool IsHardPassThrough;
PropagationDirection PropagationDirection;
}
然后我们将拥有一个BombSystem能够更新所有Bomb的FuseCountdown。当一个Bomb的countdown到达0,它便会删除所拥有的实体并创造一个全新的爆炸实体。
在我的ECS执行中,系统也能够处理信息。BombSyestem处理了2种信息:一种是ExplosionSystem发送给爆炸下的实体(这将触发炸弹所以我们能够拥有连锁反应),另一种是玩家的输入处理器所发送的,用于远距离触发炸弹(遥控炸弹)。
你将注意到的是Explosion,Bomb和Player组件拥有一些共享元素:范围,延伸范围,IsPassThrough,IsHardPassThrough。这是否暗示着它们应该是相同的组件?并不是。运行着所有这3种类型组件的逻辑是不同的,所以我们可以将其分割。我们可以创造包含类似数据的BombState组件。所以一个爆炸实体便包含了一个Explosion组件和一个BombState组件。而这只会无缘无故地添加额外基础设置—-并不存在只能基于BombState组件运转的系统。
我所选择的解决方法便是设置BombState结构(并不是完整的组件),而Explosion,Bomb和PlayerInfo拥有该结构。就像Bomb如下:
struct BombState
{
bool IsPassThrough;
bool IsHardPassThrough;
int Range;
PropagationDirection PropagationDirection;
}
class Bomb : Component
{
float FuseCountdown; // Set to float.Max if the player can remotely trigger bombs.
int OwnerId; // Identifies the player entity that owns the bomb. Lets us count
// how many undetonated bombs a player has on-screen
BombState State;
}
关于玩家和炸弹还有另外一点需要注意的。当创造了炸弹时,它将拥有玩家当时放置的能力(游戏邦注:范围等等),而不是玩家的能力。我相信真正的《炸弹人》逻辑也是不同的:如果你获得一个Fire–Up道具,它将影响已经放置的炸弹。无论如何,这是我所做出的一个重要决定。
soft block
最后让我们来说说道具本身。他们看起来怎样?我拥有一些非常简单的PowerUp组件:
class PowerUp : Component
{
PowerUpType Type;
int SoundId; // The sound to play when this is picked up
}
PowerUpType是不同类型道具的一种枚举类型。操纵PowerUp组件并控制它们的PowerUpSystem拥有一个较大的切换语句,即将操纵实体的PlayerInfo组件。
我考虑将不同的信息处理程序附加在每个道具预制件中(包含了特殊道具的定制逻辑)。这是最可扩展且最灵活的系统。我们甚至不需要一个PowerUp组件或PowerUpSystem。我们只需要简单地定义“玩家与我发生碰撞”信息便可,而定制道具信息处理器将获取该信息。这对于我来说似乎是过度设计的,所以我将选择一种更加简单且快速的执行方法。
以下是切换语句的一些片段,我们将在此基于道具分配玩家的能力:
case PowerUpType.BombDown:
player.PermittedSimultaneousBombs = Math.Max(1, player.PermittedSimultaneousBombs – 1);
break;
case PowerUpType.DangerousBomb:
player.BombState.PropagationDirection = PropagationDirection.All;
player.BombState.IsHardPassThrough = true;
break;
预制件 我的ECS允许你去构造实体模版或预制件。这将赋予模版一个名字(如“BombUpPowerUp”),并联合一群组件及其价值。我们可以让EnitityManager对“BombUpPowerUp”进行实例化,这将创造一个带有所有合适组件的实体。
我认为罗列出我在《炸弹人》的复制品中使用的一些预制件会很有帮助。我不会详细描述每个预制件的细节;我只会列出每种实体所使用的组件,并且它们还带有一些有用的评论。为了获得更多谢姐你可以着眼于源代码。这些只是预制件的例子——如在真正的游戏中存在多种砖块类型(软砖块,硬砖块),并且它们的组件还带有不同的价值。
"Player"
Placement
Aspect
Physics
InputMap // controls what inputs control the player (Spacebar, game pad button, etc...)
InputHandlers // and how the player reacts to those inputs (DropBomb, MoveUp)
ExplosionImpact
MessageHandler
PlayerInfo"Brick"
Placement
Aspect
Physics
ExplosionImpact
MessageHandler // to which we attach behavior to spawn powerups when a brick is destroyed"Bomb"
Placement
Aspect
Physics
ExplosionImpact
MessageHandler
ScriptContainer // we attach a script that makes the bomb "wobble"
Bomb"Explosion"
Placement
Aspect
Explosion
FrameAnimation // this one lets us animation the explosion image"PowerUp"
Placement
Aspect
ExplosionImpact
ScriptContainer
PowerUp有趣的点
ECS同样也会提供给你一些灵活性去创造全新实体类型。它让你能够轻松地说“嘿,如果我将这一元素与其它元素结合在一起会怎样?”这能够激发头脑风暴去想出全新的机制。如果你能够控制一个炸弹的话会怎样?(添加InputMap到炸弹实体)。如果爆炸将导致其他玩家放慢速度的话会怎样?(添加PowerUp到一个爆炸实体)。如果爆炸是稳定的会怎样?(添加Physics到一个爆炸实体)。如果玩家可以调转爆炸的方向并朝着某些人发射会怎样?(添加一些逻辑,但仍然是琐碎的)。
你将会发现这能让你轻松地实验并添加新代码,而无需阻碍其它内容。组件间的独立性是明确且最低标准。
当然,我在这个小项目中也面对了一些问题。
我决定使用Farseer Physics程序库去处理玩家和其它对象间的碰撞检测。游戏是基于网格机制,但是玩家可以在更细粒度的关卡上移动着。所以这是帮助我们轻松做到这点的简单方法。然而许多游戏玩法都是基于网格(注:例如炸弹智能在完整的场所掉落)。所以我也拥有自己简单的网格碰撞检测(让你能够咨询“在这个网格中存在怎样的实体?”)。有时候这两种方法也会出现矛盾。尽管这一问题并不是针对于ECS。实际上,ECS会告诉我Farseer Physics的使用是完全局限于Collision System。所以它能够轻松地置换出物理程序库并且未影响到其它代码。Physics组件本身并不依赖于Farseer。
我所面对的另一个问题便是存在一种倾向去创造符合ECS思考方式的特定问题。一个例子便是整体的游戏需要一种状态:剩余时间,棋盘的大小以及其它全局状态。我最终停止创造GameState组件和一个伴随着的GameStateSystem。GameStateSystem负责呈现剩余时间,并决定谁最终赢得游戏。这似乎不适合整合到ECS框架中–它只对GameState对象有意义。不过这也具有一些优势,即能让我们更轻松地执行一种保存游戏机制。我的组件必须支持序列化。所以我便确保了所有实体为流线形,然后对其进行反序列化,并最终回到之前所在的位置。
对于我常面对的情况我的决定是:“我是否该为此创造一个新的组件类型,或只是在定制行为上附加一个脚本?”有时候这是一种较为抽象的决定,即关于一块逻辑是否值得拥有自己的组件或系统。一个组件或系统看起来较为重要,所以你必须拥有将定制行为附加到实体上的能力。尽管这将导致我们很难去了解整个系统。
现在我拥有3种方法能够将定制行为附加到一个实体上:输入处理器,信息处理器和脚本。脚本是执行于每个更新循环。输入和信息处理器会在输入行动和发送信息时被召唤出来。对于该项目我尝试过一种全新的输入处理方法。它发挥了很大的作用(在与信息处理结合在一起也具有意义)。我使用了键盘去控制玩家。当是时候执行控制器支持时,它总共只花费了5分钟。
powerup entity
脚本通常需要储存状态。举个例子来说,能让道具动起来或炸弹实体摇晃的脚本需要知道最小和最大规模,以及我们处在行动进程的哪个点上。我可以创造带有状态的成熟脚本,并确保每个实体都需要它。但是这也将引起序列化问题(每个脚本将需要清楚如何对自身进行序列化)。所以在我当前的执行中,脚本是简单的回调函数。它们需要的状态被储存在Scripts组件的通有性质包中(Scripts组件只是储存一列能够映射特殊回调函数的id)。这会让C#脚本代码显得较为笨拙,而变量的获取和设置便是属性包中的方法调用。在某种情况下,我计划支持一个带有糖衣语法的简单定制脚本语言去隐藏一些难看的内容。但我现在还没做到那里。
总结 纵使理论很棒,但是我希望这篇文章能够通过呈现有关ECS机制的执行而带来有效的帮助。
附加的样本项目是在XNA 4.0中执行的。除了上文所描述的机制外,它还呈现了其它有趣的内容:
*我如何处理像爆炸这样组件的动画
*我在上文简要描述的输入映射系统
*我如何在特定网格位置上处理对象检索
*像炸弹是如何摇晃或地雷是上升/下降
我没有时间在样本中执行AI玩家,但的确存在三种人类可以控制的角色。其中两种是使用键盘进行控制:(箭头键,空格和输入)以及(方向键,Q和E)。第三种使用的是控制器(如果你有的话)。我们也能轻松地执行鼠标控制玩家的方法。
Bomberman
样本突出的是12个具有完整功能的道具(有些更强大的道具会更频繁地出现),随机软砖块和在时间快耗尽时出现的“死亡砖块”。当然了,我们也忽视了许多优化,如图像还很丑陋,并不存在死亡动画或玩家行走动画。但这是因为本篇文章的关注点在于游戏机制。