sábado, 27 de octubre de 2012

Create a Simple Asteroids Game Using Component-Based Entities

In the previous tutorial, we created a bare-bones component-based Entity system. Now we’ll use this system to create a simple Asteroids game.


Final Result Preview

Here’s the simple Asteroids game we’ll be creating in this tutorial. It’s written using Flash and AS3, but the general concepts apply to most languages.

The full source code is available on GitHub.


Class Overview

There are six classes:

  • AsteroidsGame, which extends the base game class and adds the logic specific to our space shoot-’em-up.
  • Ship, which is the thing you control.
  • Asteroid, which is the thing that you shoot at.
  • Bullet, which is the thing that you fire.
  • Gun, which creates those bullets.
  • EnemyShip, which is a wandering alien who’s just there to add a bit of variety to the game.
  • Let’s go through these entity types one by one.


    The Ship Class

    We’ll start with the player’s ship:

  package asteroids  {          import com.iainlobb.gamepad.Gamepad;          import com.iainlobb.gamepad.KeyCode;          import engine.Body;          import engine.Entity;          import engine.Game;          import engine.Health;          import engine.Physics;          import engine.View;          import flash.display.GraphicsPathWinding;          import flash.display.Sprite;          /**           * ...           * @author Iain Lobb - iainlobb@gmail.com           */          public class Ship extends Entity          {                  protected var gamepad:Gamepad;                  public function Ship()                  {                          body = new Body(this);                          body.x = 400;                          body.y = 300;                          physics = new Physics(this);                          physics.drag = 0.9;                          view = new View(this);                          view.sprite = new Sprite();                          view.sprite.graphics.lineStyle(1.5, 0xFFFFFF);                          view.sprite.graphics.drawPath(Vector.<int>([1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]),                                                        Vector.<Number>([ -7.3, 10.3, -5.5, 10.3, -7, 0.6, -0.5, -2.8, 6.2, 0.3, 4.5, 10.3, 6.3, 10.3, 11.1, -1.4, -0.2, -9.6, -11.9, -1.3, -7.3, 10.3]),                                                        GraphicsPathWinding.NON_ZERO);                          health = new Health(this);                          health.hits = 5;                          health.died.add(onDied);                          weapon = new Gun(this);                          gamepad = new Gamepad(Game.stage, false);                          gamepad.fire1.mapKey(KeyCode.SPACEBAR);                  }                  override public function update():void                  {                          super.update();                          body.angle += gamepad.x * 0.1;                          physics.thrust(-gamepad.y);                          if (gamepad.fire1.isPressed) weapon.fire();                  }                  protected function onDied(entity:Entity):void                  {                          destroy();                  }          }  }  

There’s quite a bit of implementation detail here, but the main thing to notice is that in the constructor we instantiate and configure Body, Physics, Health, View and Weapon components. (The Weapon component is in fact an instance of Gun rather than the weapon base class.)

I’m using the Flash graphics drawing APIs to create my ship (lines 29-32), but we could just as easily use a bitmap image. I’m also creating an instance of my Gamepad class — this is an open source library I wrote a couple of years ago to make keyboard input easier in Flash.

I have also overridden the update function from the base class to add some custom behaviour: after triggering all the default behaviour with super.update() we rotate and thrust the ship based on the keyboard input, and fire the weapon if the fire key is pressed.

By listening to the died Signal of the health component, we trigger the onDied function if the player runs out of hit points. When this happens we just tell the ship to destroy itself.


The Gun Class

Next let’s fire up that Gun class:

  package asteroids  {          import engine.Entity;          import engine.Weapon;          /**           * ...           * @author Iain Lobb - iainlobb@gmail.com           */          public class Gun extends Weapon          {                  public function Gun(entity:Entity)                  {                          super(entity);                  }                  override public function fire():void                  {                          var bullet:Bullet = new Bullet();                          bullet.targets = entity.targets;                          bullet.body.x = entity.body.x;                          bullet.body.y = entity.body.y;                          bullet.body.angle = entity.body.angle;                          bullet.physics.thrust(10);                          entity.entityCreated.dispatch(bullet);                          super.fire();                  }          }  }  

This is a nice short one! We just override the fire() function to create a new Bullet whenever the player fires. After matching the position and rotation of the bullet to the ship, and thrusting it off in the right direction, we dispatch entityCreated so that it can be added to the game.

A great thing about this Gun class is that it’s used by both the player and enemy ships.


The Bullet Class

A Gun creates an instance of this Bullet class:

  package asteroids  {          import engine.Body;          import engine.Entity;          import engine.Physics;          import engine.View;          import flash.display.Sprite;          /**           * ...           * @author Iain Lobb - iainlobb@gmail.com           */          public class Bullet extends Entity          {                  public var age:int;                  public function Bullet()                  {                          body = new Body(this);                          body.radius = 5;                          physics = new Physics(this);                          view = new View(this);                          view.sprite = new Sprite();                          view.sprite.graphics.beginFill(0xFFFFFF);                          view.sprite.graphics.drawCircle(0, 0, body.radius);                  }                  override public function update():void                  {                          super.update();                          for each (var target:Entity in targets)                          {                                  if (body.testCollision(target))                                  {                                          target.health.hit(1);                                          destroy();                                          return;                                  }                          }                          age++;                          if (age > 20) view.alpha -= 0.2;                          if (age > 25) destroy();                  }          }  }  

The constructor instantiates and configures the body, physics and view. In the update function, you can now see the list called targets come in handy, as we loop through all the things we want to hit and see if any of them are intersecting the bullet.

This collision system wouldn’t scale to thousands of bullets, but is fine for most casual games.

If the bullet gets more than 20 frames old we start to fade it out, and if it’s older than 25 frames we destroy it. As with the Gun, the Bullet is used by both the player and enemy — the instances just have a different targets list.

Speaking of which…


The EnemyShip Class

Now let’s look at that enemy ship:

  package asteroids  {          import engine.Body;          import engine.Entity;          import engine.Health;          import engine.Physics;          import engine.View;          import flash.display.GraphicsPathWinding;          import flash.display.Sprite;          /**           * ...           * @author Iain Lobb - iainlobb@gmail.com           */          public class EnemyShip extends Entity          {                  protected var turnDirection:Number = 1;                  public function EnemyShip()                  {                          body = new Body(this);                          body.x = 750;                          body.y = 550;                          physics = new Physics(this);                          physics.drag = 0.9;                          view = new View(this);                          view.sprite = new Sprite();                          view.sprite.graphics.lineStyle(1.5, 0xFFFFFF);                          view.sprite.graphics.drawPath(Vector.<int>([1, 2, 2, 2, 2]),                                                        Vector.<Number>([ 0, 10, 10, -10, 0, 0, -10, -10, 0, 10]),                                                        GraphicsPathWinding.NON_ZERO);                          health = new Health(this);                          health.hits = 5;                          health.died.add(onDied);                          weapon = new Gun(this);                  }                  override public function update():void                  {                          super.update();                          if (Math.random() < 0.1) turnDirection = -turnDirection;                          body.angle += turnDirection * 0.1;                          physics.thrust(Math.random());                          if (Math.random() < 0.05) weapon.fire();                  }                  protected function onDied(entity:Entity):void                  {                          destroy();                  }          }  }  

As you can see, it’s fairly similar to the player ship class. The only real difference is that in the update() function, rather than having player control via the keyboard, we have some “artificial stupidity” to make the ship wander and fire randomly.


The Asteroid Class

The other entity type the player can shoot at is the asteroid itself:

  package asteroids  {          import engine.Body;          import engine.Entity;          import engine.Health;          import engine.Physics;          import engine.View;          import flash.display.Sprite;          /**           * ...           * @author Iain Lobb - iainlobb@gmail.com           */          public class Asteroid extends Entity          {                  public function Asteroid()                  {                          body = new Body(this);                          body.radius = 20;                          body.x = Math.random() * 800;                          body.y = Math.random() * 600;                          physics = new Physics(this);                          physics.velocityX = (Math.random() * 10) - 5;                          physics.velocityY = (Math.random() * 10) - 5;                          view = new View(this);                          view.sprite = new Sprite();                          view.sprite.graphics.lineStyle(1.5, 0xFFFFFF);                          view.sprite.graphics.drawCircle(0, 0, body.radius);                          health = new Health(this);                          health.hits = 3;                          health.hurt.add(onHurt);                  }                  override public function update():void                  {                          super.update();                          for each (var target:Entity in targets)                          {                                  if (body.testCollision(target))                                  {                                          target.health.hit(1);                                          destroy();                                          return;                                  }                          }                  }                  protected function onHurt(entity:Entity):void                  {                          body.radius *= 0.75;                          view.scale *= 0.75;                          if (body.radius < 10)                          {                                  destroy();                                  return;                          }                          var asteroid:Asteroid = new Asteroid();                          asteroid.targets = targets;                          group.push(asteroid);                          asteroid.group = group;                          asteroid.body.x = body.x;                          asteroid.body.y = body.y;                          asteroid.body.radius = body.radius;                          asteroid.view.scale = view.scale;                          entityCreated.dispatch(asteroid);                  }          }  }  

Hopefully you’re getting used to how these entity classes look by now.

In the constructor we initialise our components and randomise the position and velocity.

In the update() function we check for collisions with our targets list — which in this example will just have a single item — the player’s ship. If we find a collision we do damage to the target and then destroy the asteroid. On the other hand, if the asteroid is itself damaged (i.e. it’s hit by a player bullet), we shrink it and create a second asteroid, creating the illusion that it has been blasted into two pieces. We know when to do this by listening to the Health component’s “hurt” Signal.


The AsteroidsGame Class

Finally, let’s look at the AsteroidsGame class that controls the whole show:

  package asteroids  {          import engine.Entity;          import engine.Game;          import flash.events.MouseEvent;          import flash.filters.GlowFilter;          import flash.text.TextField;          /**           * ...           * @author Iain Lobb - iainlobb@gmail.com           */          public class AsteroidsGame extends Game          {                  public var players:Vector.<Entity> = new Vector.<Entity>();                  public var enemies:Vector.<Entity> = new Vector.<Entity>();                  public var messageField:TextField;                  public function AsteroidsGame()                  {                  }                  override protected function startGame():void                  {                          var asteroid:Asteroid;                          for (var i:int = 0; i < 10; i++)                          {                                  asteroid = new Asteroid();                                  asteroid.targets = players;                                  asteroid.group = enemies;                                  enemies.push(asteroid);                                  addEntity(asteroid);                          }                          var ship:Ship = new Ship();                          ship.targets = enemies;                          ship.destroyed.add(onPlayerDestroyed);                          players.push(ship);                          addEntity(ship);                          var enemyShip:EnemyShip = new EnemyShip();                          enemyShip.targets = players;                          enemyShip.group = enemies;                          enemies.push(enemyShip);                          addEntity(enemyShip);                          filters = [new GlowFilter(0xFFFFFF, 0.8, 6, 6, 1)];                          update();                          render();                          isPaused = true;                          if (messageField)                          {                                  addChild(messageField);                          }                          else                          {                                  createMessage();                          }                          stage.addEventListener(MouseEvent.MOUSE_DOWN, start);                  }                  protected function createMessage():void                  {                          messageField = new TextField();                          messageField.selectable = false;                          messageField.textColor = 0xFFFFFF;                          messageField.width = 600;                          messageField.scaleX = 2;                          messageField.scaleY = 3;                          messageField.text = "CLICK TO START";                          messageField.x = 400 - messageField.textWidth;                          messageField.y = 240;                          addChild(messageField);                  }                  protected function start(event:MouseEvent):void                  {                          stage.removeEventListener(MouseEvent.MOUSE_DOWN, start);                          isPaused = false;                          removeChild(messageField);                          stage.focus = stage;                  }                  protected function onPlayerDestroyed(entity:Entity):void                  {                          gameOver();                  }                  protected function gameOver():void                  {                          addChild(messageField);                          isPaused = true;                          stage.addEventListener(MouseEvent.MOUSE_DOWN, restart);                  }                  protected function restart(event:MouseEvent):void                  {                          stopGame();                          startGame();                          stage.removeEventListener(MouseEvent.MOUSE_DOWN, restart);                          isPaused = false;                          removeChild(messageField);                          stage.focus = stage;                  }                  override protected function stopGame():void                  {                          super.stopGame();                          players.length = 0;                          enemies.length = 0;                  }                  override protected function update():void                  {                          super.update();                          for each (var entity:Entity in entities)                          {                                  if (entity.body.x > 850) entity.body.x -= 900;                                  if (entity.body.x < -50) entity.body.x += 900;                                  if (entity.body.y > 650) entity.body.y -= 700;                                  if (entity.body.y < -50) entity.body.y += 700;                          }                          if (enemies.length == 0) gameOver();                  }          }  }  

This class is pretty long (well, more than 100 lines!) because it does a lot of things.

In startGame() it creates and configures 10 asteroids, the ship and the enemy ship, and also creates the “CLICK TO START” message.

The start() function unpauses the game and removes the message, while the gameOver function pauses the game again and restores the message. The restart() function listens for a mouse click on the Game Over screen — when this happens it stops the game and starts it again.

The update() function loops through all the enemies and warps any that have drifted off screen, as well as checking for the win condition, which is that there are no enemies left in the enemies list.


Taking It Further

This is a pretty bare bones engine and a simple game, so now let’s think about ways we could expand it.

  • We could add a priority value for each entity, and sort the list before each update, so that we can make sure that some types of Entity always update after other types.
  • We could use object pooling so that we’re reusing dead objects (e.g. bullets) instead just creating hundreds of new ones.
  • We could add a camera system so we can scroll and zoom the scene. We could extend the Body and Physics components to add support for Box2D or another physics engine.
  • We could create an inventory component, so that entities can carry items.

As well as extending the individual components, we might at times need to extend the IEntity interface to create special types of Entity with specialised components.

For example, if we’re making a platform game, and we have a new component that handles all the very specific things that a platform game character needs — are they on the ground, are they touching a wall, how long have they been in the air, can they double-jump, etc. — other entities might also need to access this information. But it is not part of the core Entity API, which is kept intentionally very general. So we need to define a new interface, which provides access to all the standard entity components, but adds access to the PlatformController component.

For this, we would do something like:

  package platformgame  {          import engine.IEntity;          /**           * ...           * @author Iain Lobb - iainlobb@gmail.com           */          public interface IPlatformEntity extends IEntity          {                  function set platformController(value:PlatformController):void;                  function get platformController():PlatformController;          }  }  

Any entity that needs “platforming” functionality then implements this interface, enabling other entities to interact with the PlatformController component.


Conclusions

By even daring to write about game architecture, I fear I’m stirring a hornets’ nest of opinion — but that’s (mostly) always a good thing, and I hope at the least I’ve made you think about how you organise your code.

Ultimately, I don’t believe you should get too hung up on how you structure things; whatever works for you to get your game done is the best strategy. I know there are far more advanced systems that the one I outline here, which solve a range of issues beyond the ones I’ve discussed, but they can tend to start looking very unfamiliar if you’re used to a traditional inheritance based architecture.

I like the approach I’ve suggested here because it allows code to be organised by purpose, into small focussed classes, whilst providing a statically typed, extensible interface and without relying on dynamic language features or String lookups. If you want to alter the behaviour of a particular component, you can extend that component and override the methods you want to change. Classes tend to stay very short, so I never find myself scrolling through thousands of lines to find the code I’m looking for.

Best of all, I’m able to have a single engine that is flexible enough to use across all the games I make, saving me a huge amount of time.



No hay comentarios:

Publicar un comentario