Saturday, February 2, 2013

Spaceship Warrior Pt 4 (Level 1)

As of this post, we have a game with a ship that can be controlled, and guns that can fire.  We've even made the bullets disappear after a few seconds, so they don't keep using system resources.  But no enemies.  And somehow, a game with nothing more than the player's ship isn't all that fun.

This post will cover how to add enemy ships, but first I think it's high time we improved our Sprite class.  If you'll remember, the one we made was a lot simpler than the demo's class, mostly because I wanted to get SOMETHING on the screen somehow, so I reverted to my previous skills.  But today we're diving into TexturePacker.

Well, as of more recent versions of libgdx, I should say TexturePacker2, which is apparently faster in Java 1.7.  The fist thing to look at is the ImagePacker.java class that comes with Spaceship Warrior.  It's not very long, but most of the settings included in it aren't all that important.  You can see the full list of settings here, and notice that some of them are just set to the default, and others have been replaced (for instance padding has been replaced with paddingX and paddingY).  This is the modified version that I made in com.gamexyz.utils:
package com.gamexyz.utils;

import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.tools.imagepacker.TexturePacker2;
import com.badlogic.gdx.tools.imagepacker.TexturePacker2.Settings;

public class ImagePacker {

 public static void run() {
  Settings settings = new Settings();  
  settings.filterMin = Texture.TextureFilter.Linear;
  settings.filterMag = Texture.TextureFilter.Linear;
  settings.pot=false;
        TexturePacker2.process(settings, "textures-original", "resources/textures", "pack");
 }

}

Setting pot=false is fine for us because we opted to use OgenGL2.0, so our images don't have to be loaded as powers of two.  "textures-original" is the folder in GameXYZ-desktop which holds all the images, and this file will make a new folder in that project called resources, with a subfolder called textures, and files pack.atlas and pack.png.

To run this, in our Launcher.java file, just include (somewhere in main(), before the new LwjglApplication() line.
ImagePacker.run();

And make sure to ctrl+shift+o to update your imports list.  Now running the program will include some console lines about making your files, but over in the Eclipse explorer, they're nowhere to be seen!  When "Eclipse" doesn't create files for your project, even if it's your program IN Eclipse, those files won't be automatically added to the project.  To make them show, right click on the GameXYZ-desktop project and click "Refresh".  They should show up now.  Open up pack.png to see what it looks like.

NOTE:  If you ever release your game, you will not want to include this line of code, but rather just include the already created Texture atlas.  For large games, it will add unnecessary launch time to recreate the atlas every time.

Of course, we still aren't loading using them to get our images, we're still referencing the old folder (which is still there, thankfully).  To access images from within a TextureAtlas, we just need these lines of code
TextureAtlas atlas = new TextureAtlas(Gdx.files.internal("textures/pack.atlas"),Gdx.files.internal("textures"));
AtlasRegion region = atlas.findRegion("imagename")

"imagename" should not include the ".png", but to access fighter.png we would just say findRegion("fighter").  Unfortunately, findRegion is slow, so we don't want to do it EVERY single time we want to load that image (which is quite often - for instance, every time we shoot a bullet we will need to point to that image).  The demo's workaround is to create a HashMap which maps String->AtlasRegion.  The HashMap is quicker to look up in, and so right after we load the TextureAtlas the first time, we'll make a HashMap to associate the imagename with with correct image.  Even then, the HashMap isn't as quick as we'd like, and Artemis includes a much faster container called a Bag.

So when we first load the program, we will read the Textures from the atlas and store them in a HashMap associating the name to the image.  From there, whenever we create a new entity we will ask the HashMap which image it should get.  Then we will store that image in a Bag, referenced by the entity's ID.  That way, every time we draw entities to the screen we only have to look the images up in the quickest container.

All of this will go into our SpriteRenderSystem, and to get started we can add the following code
public class SpriteRenderSystem extends EntitySystem {
(...)
 private TextureAtlas atlas;
 private HashMap<String, AtlasRegion> regions;
 private Bag<Atlasregion> regionsByEntity;
 
 @Override
 protected void initialize() {
  batch = new SpriteBatch();
  
  atlas = new TextureAtlas(Gdx.files.internal("textures/pack.atlas"),Gdx.files.internal("textures"));
  regions = new HashMap<String, AtlasRegion>();
  for (AtlasRegion r : atlas.getRegions()) {
   regions.put(r.name, r);
  }
  regionsByEntity = new Bag<atlasregion>();
 }
(...)
 @Override
 protected void inserted(Entity e) {
  Sprite sprite = sm.get(e);
  regionsByEntity.set(e.getId(), regions.get(sprite.name));
 }
 
 @Override
 protected void removed(Entity e) {
  regionsByEntity.set(e.getId(), null);
 }
(...)
}
Notice that we have included in the inserted() and removed() methods code which will keep our Bag up to date, but we need to update our Sprite component to have a String called name, which stores something like "fighter" or "bullet".  For now, I'll let you add it however you want, but later I'll post my full Sprite class so we can be on the same page.

Now that we have a Bag which maps entities to the correct AtlasRegion, we can update our process(Entity) method to draw from this.
 protected void process(Entity e) {
  if (pm.has(e)) {
   Position position = pm.getSafe(e);
   Sprite sprite = sm.get(e);
   
   AtlasRegion spriteRegion = regionsByEntity.get(e.getId());
   batch.setColor(sprite.r, sprite.g, sprite.b, sprite.a);

   float posX = position.x - (spriteRegion.getRegionWidth() / 2 * sprite.scaleX);
   float posY = position.y - (spriteRegion.getRegionHeight() / 2 * sprite.scaleX);
   batch.draw(spriteRegion, posX, posY, 0, 0, spriteRegion.getRegionWidth(), spriteRegion.getRegionHeight(), sprite.scaleX, sprite.scaleY, sprite.rotation);

  }
 }
Here lines 9-10 allow us to draw the image CENTERED at the position coordinates, whereas before the sprite was always drawn from the bottom left corner.

Running this should basically give you the same result as before, except now our bullets are off center because we changed where the ship is drawn relative to it's position.  Back in PlayerInputSystem we can change where we create the bullets, I like them in the x direction at pos.x-27 and pos.x+27, and in the y direction to both be pos.y+7.

There is one more thing we need to do to bring our SpriteRenderSystem and Sprite classes up to snuff.  In the Spaceship Warrior demo, the particle effects are drawn on top of everything, the little ships are always above you, you are always above the bigger ships, and everything is above the starfield.  This was accomplished by introducing Layers, and drawing the bottom layer first and so on.  You can see this really easily by looking at a 2D platformer, like this Indie game Hyperion, which has a starfield in the back, a layer of trees in front of that, another layer of trees, and then the game world.  They scroll these layers at different speeds to get an effect of depth, but it would be ruined if the stars got drawn last, because they would become the front layer!

To manage layers, the Sprite component gets an enumeration of the different possible layers, and each sprite gets assigned to one.  Then the SpriteRenderSystem maintains a SORTED list of entities, sorted based on which layer they are in.  Thus the back layers get drawn first, and so on.  The demo stored this sorted list as a List<Entity> called sortedEntities.  Remember the processEntities() method which passed us our bag of entities?  Well, now we'll ignore that passed Bag because the Bag class doesn't maintain any order, and thus can't be sorted.

This is what my final Sprite and SpriteRenderSystem look like to take all this into account:
package com.gamexyz.components;

import com.artemis.Component;

public class Sprite extends Component {
 
 public enum Layer {
  DEFAULT,
  BACKGROUND,
  ACTORS_1,
  ACTORS_2,
  ACTORS_3,
  PARTICLES;
  
  public int getLayerId() {
   return ordinal();
  }
 }
 
 public Sprite(String name, Layer layer) {
  this.name = name;
  this.layer = layer;
 }
 
 public Sprite(String name) {
  this(name, Layer.DEFAULT);
 }
 
 public Sprite() {
  this("default",Layer.DEFAULT);
 }
 
 public String name;
 public float r = 1;
 public float g = 1;
 public float b = 1;
 public float a = 1;
 public float scaleX = 1;
 public float scaleY = 1;
 public float rotation;
 public Layer layer = Layer.DEFAULT;

}
package com.gamexyz.systems;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;

import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.EntitySystem;
import com.artemis.annotations.Mapper;
import com.artemis.utils.Bag;
import com.artemis.utils.ImmutableBag;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.graphics.g2d.TextureAtlas.AtlasRegion;
import com.gamexyz.components.Position;
import com.gamexyz.components.Sprite;

public class SpriteRenderSystem extends EntitySystem {
 @Mapper ComponentMapper<Position> pm;
 @Mapper ComponentMapper<Sprite> sm;
 
 private OrthographicCamera camera;
 private SpriteBatch batch;
 
 private TextureAtlas atlas;
 private HashMap<String, AtlasRegion> regions;
 private Bag<AtlasRegion> regionsByEntity;
 
 private List<Entity> sortedEntities;
 
 @SuppressWarnings("unchecked")
 public SpriteRenderSystem(OrthographicCamera camera) {
  super(Aspect.getAspectForAll(Position.class, Sprite.class));
  this.camera = camera;
 }
 
 @Override
 protected void initialize() {
  batch = new SpriteBatch();
  
  atlas = new TextureAtlas(Gdx.files.internal("textures/pack.atlas"),Gdx.files.internal("textures"));
  regions = new HashMap<String, AtlasRegion>();
  for (AtlasRegion r : atlas.getRegions()) {
   regions.put(r.name, r);
  }
  regionsByEntity = new Bag<AtlasRegion>();
  
  sortedEntities = new ArrayList<Entity>();
 }

 @Override
 protected boolean checkProcessing() {
  return true;
 }

 @Override
 protected void processEntities(ImmutableBag<Entity> entities) {
  for (Entity e : sortedEntities) {
   process(e);
  }
 }
 
 @Override
 protected void begin() {
  batch.setProjectionMatrix(camera.combined);
  batch.begin();
 }
 
 protected void process(Entity e) {
  if (pm.has(e)) {
   Position position = pm.getSafe(e);
   Sprite sprite = sm.get(e);
   
   AtlasRegion spriteRegion = regionsByEntity.get(e.getId());
   batch.setColor(sprite.r, sprite.g, sprite.b, sprite.a);

   float posX = position.x - (spriteRegion.getRegionWidth() / 2 * sprite.scaleX);
   float posY = position.y - (spriteRegion.getRegionHeight() / 2 * sprite.scaleX);
   batch.draw(spriteRegion, posX, posY, 0, 0, spriteRegion.getRegionWidth(), spriteRegion.getRegionHeight(), sprite.scaleX, sprite.scaleY, sprite.rotation);

  }
 }
 
 @Override
 protected void end() {
  batch.end();
 }
 
 @Override
 protected void inserted(Entity e) {
  Sprite sprite = sm.get(e);
  regionsByEntity.set(e.getId(), regions.get(sprite.name));
  
  sortedEntities.add(e);
  
  Collections.sort(sortedEntities, new Comparator<Entity>() {
   @Override
   public int compare(Entity e1, Entity e2) {
    Sprite s1 = sm.get(e1);
    Sprite s2 = sm.get(e2);
    return s1.layer.compareTo(s2.layer);
   }
  });
  
 }
 
 @Override
 protected void removed(Entity e) {
  regionsByEntity.set(e.getId(), null);
  sortedEntities.remove(e);
 }
}
The Sprite class doesn't have too many changes.  It has the enum, and a new constructor which takes Sprite(String name, Layer layer).  If those options aren't passed, I refer to a "default" in both cases.  For a default sprite, I googled to find a small exclamation mark png
and stuck it in the textures-original folder.

In SpriteRenderSystem, we added a List called sortedEntities and initialized it in the initialize() method.  In the inserted() and removed() methods we added the entity to sortedEntities, and in inserted() also added a sorter which compared them based on layer.  Entities in the same layer will be stacked in the order of their creation, from first on bottom to last on top.

In processEntities(), as promised we ignore the ImmutableBag we are given, and stick with our sortedEntities List to make sure we draw in the correct order.  I admit this was a lot of work to achieve no discernible change in our program, but I'm going to trust that it is stronger for it now, more expandable.  Because of course that's the ultimate goal, borrow the code and methods learned here and apply them to an original game.

Before we're done for the day, let's add some enemies, it turns out to not be too tough.  First, I edited my EntityFactory to look a bit more like the Demo's (though not completely) and added a createEnemyShip() method:
public class EntityFactory {

 public static Entity createPlayer(World world, float x, float y) {
  Entity e = world.createEntity();
  
  Position position = new Position();
  position.x = x;
  position.y = y;
  e.addComponent(position);
  
  Sprite sprite = new Sprite("fighter",Sprite.Layer.ACTORS_3);
  sprite.r = 93/255f;
  sprite.g = 255/255f;
  sprite.b = 129/255f;
  e.addComponent(sprite);
  
  Velocity velocity = new Velocity(0,0);
  e.addComponent(velocity);
  e.addComponent(new Player());
  
  return e;
 }
 
 public static Entity createBullet(World world, float x, float y) {
  Entity e = world.createEntity();
  
  Position position = new Position();
  position.x = x;
  position.y = y;
  e.addComponent(position);
  
  Sprite sprite = new Sprite();
  sprite.name = "bullet";
  sprite.layer = Sprite.Layer.PARTICLES;
  e.addComponent(sprite);
  
  Velocity velocity = new Velocity(0, 800);
  e.addComponent(velocity);
  
  Expires expires = new Expires(2f);
  e.addComponent(expires);
  
  return e;
 }
 
 public static Entity createEnemyShip(World world, String name, Sprite.Layer layer, float x, float y, float vx, float vy) {
  Entity e = world.createEntity();
  
  Position position = new Position();
  position.x = x;
  position.y = y;
  e.addComponent(position);
  
  Sprite sprite = new Sprite();
  sprite.name = name;
  sprite.r = 255/255f;
  sprite.g = 0/255f;
  sprite.b = 142/255f;
  sprite.layer = layer;
  e.addComponent(sprite);
  
  Velocity velocity = new Velocity(vx, vy);
  e.addComponent(velocity);
  
  return e;
 }
}

The lines setting Sprite.r, etc, provide a color filter.  If you look, the textures have white borders, where in the demo game the ships are colored.  That magic happens by setting these red, green, blue, and alpha filters.  The enemy ship method has a lot of arguments used to decide which kind of ship to create.  To actually create them, we'll make a new System called EntitySpawningTimerSystem, taken almost straight from the demo:
package com.gamexyz.systems;

import com.artemis.systems.VoidEntitySystem;
import com.artemis.utils.Timer;
import com.badlogic.gdx.math.MathUtils;
import com.gamexyz.EntityFactory;
import com.gamexyz.components.Sprite;

public class EntitySpawningTimerSystem extends VoidEntitySystem {

 private Timer timer1;
 private Timer timer2;
 private Timer timer3;

 public EntitySpawningTimerSystem() {
  timer1 = new Timer(2, true) {
   @Override
   public void execute() {
    EntityFactory.createEnemyShip(world, "enemy1", Sprite.Layer.ACTORS_3, MathUtils.random(0, 1280), 900 + 50, 0, -40).addToWorld();
   }
  };

  timer2 = new Timer(6, true) {
   @Override
   public void execute() {
    EntityFactory.createEnemyShip(world, "enemy2", Sprite.Layer.ACTORS_2, MathUtils.random(0, 1280), 900 + 100, 0, -30).addToWorld();
   }
  };

  timer3 = new Timer(12, true) {
   @Override
   public void execute() {
    EntityFactory.createEnemyShip(world, "enemy3", Sprite.Layer.ACTORS_1, MathUtils.random(0, 1280), 900 + 200, 0, -20).addToWorld();
   }
  };
 }

 @Override
 protected void processSystem() {
  timer1.update(world.delta);
  timer2.update(world.delta);
  timer3.update(world.delta);
 }

}
There are a couple of differences I noticed.  First, because we have separated ourselves into 2 projects, files in this project cannot see the variables we set in Launcher.java for width and height, so they had to be hardcoded.  In general, that's a pretty awful idea.  Perhaps they should be stored as static fields in GameXYZ.java?  Or perhaps a constants file.  Also, the demo's world seemed to range from [-width/2, +width/2] and likewise for height, so that (0,0) is right in the middle of the screen.  I suspect that the difference lies in the camera, but I couldn't just trivially see what was different.  As it stands, I actually like having (0,0) be the bottom left corner, and (width,height) be the top right, it just meant we had to tweak the code somewhat.

Other than that, I changed the createEnemyShip arguments to match those in my EntityFactory (since we haven't implemented all the components yet, certain things didn't need to be there - someday they probably will).  One neat thing is that the class extends VoidEntitySystem, which is a system that runs without processing any actual entities (i.e. there is no getAspectForAll(...)).  Other than that I think the Timer class is pretty awesome, and hopefully I'll be able to play with it more another time.

In summary, we revamped our SpriteRenderSystem to use a TextureAtlas instead of loading individual .PNGs, and implemented a set of Layers to control which entities are drawn on top of which other ones.  We also added a new system which spawns lots of entities.  Overall, I feel like at this point most of the quirks are out of the way.  Coming into the project, the one that scared me was their rendering system.  Of course we haven't touched the collision detection yet, I'm eager to see how that works out.  We also haven't touched on the Groups - the demo had them but I don't really think it ever used them.  It may be that we can totally ignore that facet, we'll see.  I'm also excited to get around to the ScaleAnimationSystem, and add particles and stars.  Fun times ahead!

You have gained 50 XP.  Progress to Level 2: 200/400

1 comment: