Saturday, March 16, 2013

User Input and Screens (Level 2)

Up to this point, I've tried to implement what I considered "basic tricky" systems like animation and pathfinding.  I still have some work to do on the animation system (see some of the comments on that post), but I feel the fundamental idea is sound enough to continue until I really need to address it.

Clearly, what we have now does NOT constitute a game, but it's starting to take shape.  For this update, I wanted to add more sophisticated controls, so that the player could select one of the little dudes and tell him where to move, and select other dudes and have them decide where to move.  The idea seemed simple, but really got me thinking heavily about how to keep the code modular and managable.

In this update, we will see that done, but I had to redesign the game structure down to its core to make it happen in a way that didn't seem overly forced, and I'm really happy with how it has turned out.

First, in case you haven't read it, Andrew Steigert has a wonderful set of libgdx tutorials where he walks through creating a simple game (not unlike Spaceship Warrior) called Tyrion.  One of the main focuses of his tutorials is using Screens to manage your code.  The idea is that most games have numerous screens, each of which behave quite differently.  For instance, we may end up with a:
  • Logo splash screen
  • Main menu screen
  • Load screen
  • Overworld screen
  • Battle map screen
  • Game menu screen
    • Inventory
    • Character stats
    • Party stats
  • And who knows what the heck else?
Each of these screens out to run very different code: for instance the touchDrag() we have implemented wouldn't really make sense on a logo splash screen, or main menu screen.  You really don't want your users dragging those screens around.  Similarly, the idea of clicking on a cell and selecting an entity doesn't make sense in most contexts.  On the main menu, we really DON'T want to render the GameMap.

One of the cool things about screens is that each one can contain its own code.  For us, this could be really helpful in deciding which rendering systems to process, and setting custom controllers for each screen.  As of now, our Launcher.java file extends Game, and our GameXYZ.java implements Screen.  libgdx gave us these classes so that a Game can run, and delegate to different screens as needed, but we're not using it that way.

The first major change I made was to make Launcher.java just a regular class, and no longer extend Game.  Instead, I made GameXYZ.java extend Game.  The idea here is that GameXYZ.java will now be able to delegate to different screens.

To clarify the difference between Game and Screen, consider the methods that are part of each:
Game
  • create()
  • setScreen()
  • getScreen()
  • render(), resize(), show(), hide(), pause(), etc...
Screen
  • render(), resize(), show(), hide(), pause(), etc...
When Game "render()"s, it checks to see if it currently has a screen, and if so, calls screen.render().  In essence, Game is really just a manager for Screens.  Each screen ought to have a reference to the Game controlling it so that they can call game.setScreen(some_other_screen) - that is, so you can change screens.

I created an Abstract class called AbstractScreen.java which holds some things that I expect to be common to all the screens I use, such as an OrthographicCamera, a reference to the Artemis World (so the screens can interact with Entities and process systems), and a reference to GameXYZ.  As of now, I just implemented a single Screen called OverworldScreen which extends AbstractScreen.  OverworldScreen is more or less a rough copy of the old GameXYZ, because I want it to represent the main Screen I have as of yet.  There are a few differences we'll get to.

Here is the updated and new code for all this:
package com.blogspot.javagamexyz.gamexyz;

import com.badlogic.gdx.backends.lwjgl.LwjglApplication;
import com.badlogic.gdx.backends.lwjgl.LwjglApplicationConfiguration;
import com.blogspot.javagamexyz.gamexyz.utils.ImagePacker;

public class Launcher {
 
 private static final int WIDTH = 1300;
 private static final int HEIGHT = 720;
 
 public static void main(String[] args) {
  ImagePacker.run();
  
  LwjglApplicationConfiguration cfg = new LwjglApplicationConfiguration();
  cfg.width=WIDTH;
  cfg.height=HEIGHT;
  cfg.useGL20=true;
  cfg.title = "GameXYZ";
  cfg.vSyncEnabled = false;
  cfg.resizable=false;
  new LwjglApplication(new GameXYZ(WIDTH,HEIGHT), cfg);
 }
}

Two major things to discuss here.  First, Launcher no longer extends Game - that's because I'm not using Laucher to control my screens, I'm using GameXYZ to do that.  Consequenty, when I declare a new LwjglApplication I don't pass it "this", I pass it a reference to GameXYZ.java (more like the SimpleApp did).

Second, as a major improvement, I am storing the width and height in the Launcher.java file now.  To let my GameXYZ see this, I have to pass it as an argument, but this is no problem!  This is helpful because if we make an HTML5 or Android launcher, we will want to set their width and height separately from one another.

package com.blogspot.javagamexyz.gamexyz;

import com.artemis.World;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.blogspot.javagamexyz.gamexyz.screens.OverworldScreen;
import com.blogspot.javagamexyz.gamexyz.systems.ColorAnimationSystem;
import com.blogspot.javagamexyz.gamexyz.systems.ExpiringSystem;
import com.blogspot.javagamexyz.gamexyz.systems.ScaleAnimationSystem;
import com.blogspot.javagamexyz.gamexyz.systems.SpriteAnimationSystem;

public class GameXYZ extends Game {

 public int WINDOW_WIDTH;
 public int WINDOW_HEIGHT;
 
 public World world;
 private SpriteBatch batch;

 public GameXYZ(int width, int height) {
  WINDOW_WIDTH = width;
  WINDOW_HEIGHT = height;
 }
 
 public void create() {
  
     world = new World();
     batch = new SpriteBatch();
     
     world.setSystem(new SpriteAnimationSystem());
     world.setSystem(new ScaleAnimationSystem());
     world.setSystem(new ExpiringSystem());
     world.setSystem(new ColorAnimationSystem());
     world.initialize(); 
     
     setScreen(new OverworldScreen(this, batch, world));
 }
}

Here we can see we cut out a lot of code.  All we have is a constructor, with which we set the width and height, and a method called create(), which is called automatically upon creation.  I'm no expert, and I don't really understand the difference between that method and the constructor.  But I do know that if you try to jam it all into the constructor, it fails.  So I keep it separted and it works like a charm!

Notice I've set the basic processing systems, but none of the rendering systems.  I'm not sure if I want to stick with it this way, but right now each screen will be responsible for its own rendering systems.  One reason for this is that everything used to statically reference GameXYZ.gameMap, but that no longer exists.  Primarily because different screens may want different maps.

Note however that GameXYZ has its own SpriteBatch, even though its not doing any of the rendering.  All the best practices seem to indicate that it's best to have only one instance of SpriteBatch in your whole game because it's a resource hog.  All rendering systems that need it will have it passed to them.

Line 36 calls the setScreen method, which for now just goes to OverworldScreen.  Notice I pass OverworldScreen a reference to this instance of GameXYZ, a reference to the SpriteBatch, and a reference to the World.

All screens I paln on implementing will extend AbstractScreen.java

package com.blogspot.javagamexyz.gamexyz.screens;

import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.GL10;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;

public abstract class AbstractScreen implements Screen {
 
 protected final GameXYZ game;
 protected final World world;
 protected final OrthographicCamera camera;
 
 public AbstractScreen(GameXYZ game, World world) {
  this.game = game;
  this.world = world;
  camera = new OrthographicCamera();
 }
 
 @Override
 public void render(float delta) {
  Gdx.gl.glClearColor(0,0,0,1);
     Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
     
     camera.update();
     
     world.setDelta(delta);
     world.process();
 }
 
 @Override
 public void show() {
 }
 
 @Override
 public void hide() {
 }
 
 @Override
 public void pause() {
 }
 
 @Override
 public void resume() {
 }
 
 @Override
 public void resize(int width, int height) {
     game.WINDOW_WIDTH = width;
     game.WINDOW_HEIGHT = height;
     
     camera.setToOrtho(false, width,height);
 }
 
 @Override
 public void dispose() {
 }
}

Notice it has fields to hold the Game and World passed into it, but it doesn't hold the SpriteBatch.  Each screen will also have its own OrthographicCamera (you don't erally want them all sharing the same camera, or zooming out in one screen could influence the way another screen renders).

The render() method calls some of the basic methods that GameXYZ.java used to.  These are things that I could see being generally useful, though I may change my mind about that later and lose the world.process().  Resize changes WINDOW_WIDTH and WINDOW_HEIGHT back in the Game, so all screens should see the updated value.

package com.blogspot.javagamexyz.gamexyz.screens;

import com.artemis.World;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;
import com.blogspot.javagamexyz.gamexyz.input.OverworldControlSystem;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;
import com.blogspot.javagamexyz.gamexyz.systems.HudRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.MapRenderSystem;
import com.blogspot.javagamexyz.gamexyz.systems.PathRenderingSystem;
import com.blogspot.javagamexyz.gamexyz.systems.SpriteRenderSystem;

public class OverworldScreen extends AbstractScreen {
 
 public static GameMap gameMap;
 private OrthographicCamera hudCam;
 
 
 public SpriteRenderSystem spriteRenderSystem;
 public HudRenderSystem hudRenderSystem;
 public MapRenderSystem mapRenderSystem;
 public PathRenderingSystem pathRenderSystem;
 
 private OverworldControlSystem overworldControlSystem;
 
 public OverworldScreen(GameXYZ game, SpriteBatch batch, World world) {
  super(game,world);

     gameMap  = new GameMap();
     hudCam = new OrthographicCamera();
     
     spriteRenderSystem = world.setSystem(new SpriteRenderSystem(camera,batch), true);
     mapRenderSystem = world.setSystem(new MapRenderSystem(camera,batch,gameMap),true);
     hudRenderSystem = world.setSystem(new HudRenderSystem(hudCam, batch),true);
     pathRenderSystem = world.setSystem(new PathRenderingSystem(camera,batch),true);
     
     overworldControlSystem = world.setSystem(new OverworldControlSystem(camera,world,gameMap,game));
     Gdx.input.setInputProcessor(overworldControlSystem);
     
     world.initialize();
     
     int x, y;
     for (int i=0; i<100; i++) {
      do {
       x = MathUtils.random(MapTools.width()-1);
       y = MathUtils.random(MapTools.height()-1);
      } while (gameMap.cellOccupied(x, y));
      EntityFactory.createNPC(world,x,y,gameMap).addToWorld();
     }
 }
 
 @Override
 public void render(float delta) {
  super.render(delta);
  
  mapRenderSystem.process();
  pathRenderSystem.process();
  spriteRenderSystem.process();
  hudRenderSystem.process();
 }

 @Override
 public void show() {
  // TODO Auto-generated method stub
  
 }
 
 @Override
 public void resize(int width, int height) {
  super.resize(width, height);
  hudCam.setToOrtho(false, width, height);
 }

 @Override
 public void hide() {
  // TODO Auto-generated method stub
  
 }

 @Override
 public void pause() {
  // TODO Auto-generated method stub
  
 }

 @Override
 public void resume() {
  // TODO Auto-generated method stub
  
 }

 @Override
 public void dispose() {
  // TODO Auto-generated method stub
  game.world.deleteSystem(hudRenderSystem);
  game.world.deleteSystem(mapRenderSystem);
  game.world.deleteSystem(pathRenderSystem);
  game.world.deleteSystem(spriteRenderSystem); 
 }
}

This has a GameMap which is initialized in the constructor.  That means that as long as we have THIS screen running around, we'll have that same GameMap.  It also has a "hudCam" in addition to the camera defined in AbstractScreen.  Because all RenderingSystems now have to share the same SpriteBatch, you get problems if in one rendering system you call batch.setProjectionMatrix(camera.combined), but you don't want to do that for the next rendering system in line.  Once it's set for the batch once, it holds for the rest.  This runs in to that old problem of zooming out from out hud, and scrolling it away off the screen.  This would be silly, so we need a camera which WON'T be changed so the hud can always render from the perspective of that camera.

All of the RenderingSystems live here, and are initialized in the constructor.  Notice they are all given the camera and SpriteBatch we want them to use.  Furthermore, mapRenderSystem is given a reference to the gameMap (remember, it can no longer statically get GameXYZ.gameMap).

We'll skip lines 42-43 for now, but below that we just add a bunch of characters to the world.  createNPC() is a lot like createWarrior() from before.  Remember, I want to be able to SELECT the character I'm controlling at that moment, so I don't want to automatically assign ONE character to be the player.  The render() method isn't too shocking - first we call render() from AbstractScreen, then draw each of our systems in turn.  resize() not only calls super.resize(), but also deals with the hudCam.

Now for lines 42-43.  Each Screen can be controlled in its own unique way, so I created a class called OverworldControlSystem.  It gets a camera, because it needs to be able to zoom, etc..., it gets a copy of the World because it needs to be able to influence entities, the GameMap because it also had to be able to read what was going on in that.  It also has a reference to GameXYZ so that this control system has the power to change screens.

package com.blogspot.javagamexyz.gamexyz.input;

import com.artemis.Aspect;
import com.artemis.ComponentMapper;
import com.artemis.Entity;
import com.artemis.World;
import com.artemis.annotations.Mapper;
import com.artemis.systems.EntityProcessingSystem;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.math.Vector2;
import com.blogspot.javagamexyz.gamexyz.EntityFactory;
import com.blogspot.javagamexyz.gamexyz.GameXYZ;
import com.blogspot.javagamexyz.gamexyz.components.MapPosition;
import com.blogspot.javagamexyz.gamexyz.components.Movement;
import com.blogspot.javagamexyz.gamexyz.components.PlayerSelected;
import com.blogspot.javagamexyz.gamexyz.custom.Pair;
import com.blogspot.javagamexyz.gamexyz.maps.GameMap;
import com.blogspot.javagamexyz.gamexyz.maps.MapTools;

public class OverworldControlSystem extends EntityProcessingSystem implements InputProcessor {
 @Mapper ComponentMapper<MapPosition> pm;
 
 private OrthographicCamera camera;
 private World world;
 private GameMap gameMap;
 
 // We need a copy of the screen implementing this controller (which has a copy of
 // the Game delegating to it) so we can change screens based on users making selections
 private GameXYZ game;
 
 private int selectedEntity;
 private Pair pathTarget;
 private State state, lastState;
 

 @SuppressWarnings("unchecked")
 public OverworldControlSystem(OrthographicCamera camera, World world, GameMap gameMap, GameXYZ game) {
  super(Aspect.getAspectForAll(PlayerSelected.class, MapPosition.class));
  
  this.camera = camera;
  this.world = world;
  this.gameMap = gameMap;
  this.game = game;
  
  state = State.DEFAULT;
  lastState = State.DEFAULT;
  selectedEntity = -1;
 }
 
 @Override
 protected void process(Entity e) {
  
  // We should only get here if the player has selected an entity and asked for a path
  if (state == State.FIND_PATH) {
   state = State.ENTITY_SELECTED;
   lastState = State.FIND_PATH;
   
   // Get the entity's position
   MapPosition pos = pm.getSafe(e);
   
   // Add a Movement component to the entity
   Movement movement = new Movement(pos.x,pos.y,pathTarget.x,pathTarget.y, gameMap);
   e.addComponent(movement);
   e.changedInWorld();
  }
  
 }

 @Override
 public boolean keyDown(int keycode) {
  // TODO Auto-generated method stub
  return false;
 }

 @Override
 public boolean keyUp(int keycode) {
  // TODO Auto-generated method stub
  return false;
 }

 @Override
 public boolean keyTyped(char character) {
  // TODO Auto-generated method stub
  return false;
 }

 @Override
 public boolean touchDown(int screenX, int screenY, int pointer, int button) {
  // TODO Auto-generated method stub
  return false;
 }

 @Override
 public boolean touchUp(int screenX, int screenY, int pointer, int button) {
  
  // Are they releasing from dragging?
  if (state == State.DRAGGING) {
   state = lastState;
   lastState = State.DRAGGING;
   return true;
  }
  
  // Otherwise, get the coordinates they clicked on
  Pair coords = MapTools.window2world(Gdx.input.getX(), Gdx.input.getY(), camera);
   
  // Check the entityID of the cell they click on
  int entityId = gameMap.getEntityAt(coords.x, coords.y);
  
  // If it's an actual entity (not empty) then "select" it (unless it's already selected)  
  if ((entityId > -1) && (entityId != selectedEntity)){
   
   // If there was previously another entity selected, "deselect" it
   if (selectedEntity > -1) {
    Entity old = world.getEntity(selectedEntity);
    old.removeComponent(PlayerSelected.class);
    old.removeComponent(Movement.class);
    old.changedInWorld();
   }
   
   // Now select the current entity
   selectedEntity = entityId;
   Entity e = world.getEntity(selectedEntity);
   e.addComponent(new PlayerSelected());
   e.changedInWorld();
   System.out.println(e.getId());
   
   EntityFactory.createClick(world, coords.x, coords.y, 0.1f, 4f).addToWorld();
   
   lastState = state;
   state = State.ENTITY_SELECTED;
   
   return true;
  }
  
  // Are they clicking to find a new path?
  else if (state == State.ENTITY_SELECTED) {
   lastState = state;
   state = State.FIND_PATH;
   pathTarget = coords;
   return true;
  }
  
  return false;
 }

 @Override
 public boolean touchDragged(int screenX, int screenY, int pointer) {
  // If it hadn't been dragging, set the current state to dragging 
  if (state != State.DRAGGING) {
   lastState = state;
   state = State.DRAGGING;
  }
  Vector2 delta = new Vector2(-camera.zoom*Gdx.input.getDeltaX(), camera.zoom*Gdx.input.getDeltaY());
  camera.translate(delta);
  
  return true;
 }

 @Override
 public boolean mouseMoved(int screenX, int screenY) {
  return false;
 }

 @Override
 public boolean scrolled(int amount) {
  if ((camera.zoom > 0.2f || amount == 1) && (camera.zoom < 8 || amount == -1)) camera.zoom += amount*0.1;
  return true;
 }
 
 private enum State {
  DEFAULT,
  ENTITY_SELECTED,
  DRAGGING,
  FIND_PATH,
 };
}


This should somewhat resemble our old "PlayerInputSystem", but there are some new, neat changes.  First, I don't store information about what the player has done as booleans (boolean moving, boolean dragging, etc...) instead I defined an enum called State (lines 172-177).  The idea is that the control system will run (roughly) as a Finite State Machine, where the state dictates what it can do.

For now I've thought of a tentative list of states we may care about, starting in default (just show stuff, leave it open for most anything).  Here's how it works:
If you are in DEFAULT you can
  • Select an NPC by clicking on one (ENTITY_SELECTED)
  • Drag the screen by click dragging (DRAGGING)
If you are in DRAGGING you can
  • Return to the previous state you had been in by letting up on the button (lastState)
If you are in ENTITY_SELECTED you can
  • Find a path from that entity to any cell by clicking on it (FIND_PATH)
  • Select another entity by clicking on it (ENTITY_SELECTED)
  • Drag the screen by click draggin (DRAGGING)
If you are in FIND_PATH
  • process() will automatically find the path (if possible) and return you to just (ENTITY_SELECTED)
   
I imagine expanding this to include more things such as action menus (Move, Attack, Ability, etc...)

The OverworldControlSystem constantly keeps track of which (if any) entity is selected, which cell you have clicked on (for pathfinding), the current state, and the previous state.

On line 109 it asks the GameMap for the entity ID of what is in a particular cell.  If it's nothing, it gets -1.  If there's something there, it gets the ID.  Whatever that ID is, it adds a component called PlayerSelected to that entity (I just renamed the old "Player" component to be more appropriate).  If there had previously been a selected Entity, it removes PlayerSelected status from it first (and also any path it may have been looking at).

I updated GameMap to hold a 2D integer array to hold the ID of entities, retrievable by cell coordinates using getEntityAt(x,y).  I fear that this will be a pain in the but to maintain, but for now (since nothing is really moving) it's simple enough and works.  To keep it up to date, I pass the GameMap into EntityFactory.createNPC() so that it can store the ID into the correct cell of the array upon creation.  When things start moving, I'll have to be careful to force that to update the array.

package com.blogspot.javagamexyz.gamexyz.maps;

import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;
import com.blogspot.javagamexyz.gamexyz.pathfinding.AStarPathFinder;

public class GameMap {
 public int[][] map;
 public int[][] entityLocations;
 public int width, height;
 public Pixmap pixmap;
 public Texture texture;
 public AStarPathFinder pathFinder;
 
 public GameMap() {
  HexMapGenerator hmg = new HexMapGenerator();
  map = hmg.getDiamondSquare();
  width = map.length;
  height = map[0].length;
  
  entityLocations = new int[width][height];
  
  pixmap = new Pixmap(width,height,Pixmap.Format.RGBA8888);
  
  for (int i=0; i<width;i++) {
   for (int j=0;j<height;j++) {
    pixmap.setColor(getColor(map[i][j]));
    pixmap.drawPixel(i, j);
    
    entityLocations[i][j] = -1;
    
   }
  }
  
  texture = new Texture(pixmap);
  pixmap.dispose();
  
  pathFinder = new AStarPathFinder(map, 100);
  
 }
 
 private Color getColor(int color) { //  r    g    b
  if (color == 0)      return myColor(34  ,53  ,230);
  else if (color == 1) return myColor(105 ,179 ,239);
  else if (color == 2) return myColor(216 ,209 ,129);
  else if (color == 3) return myColor(183 ,245 ,99);
  else if (color == 4) return myColor(109 ,194 ,46);
  else if (color == 5) return myColor(87  ,155 ,36);
  else if (color == 6) return myColor(156 ,114 ,35);
  else if (color == 7) return myColor(135 ,48  ,5);
  else return new Color(1,1,1,1);
 }
 
 private static Color myColor(int r, int g, int b) {
  return new Color(r/255f, g/255f, b/255f,1);
 }
 
 public int getEntityAt(int x, int y) {
  return entityLocations[x][y];
 }
 
 public boolean cellOccupied(int x, int y) {
  return (entityLocations[x][y] > -1);
 }
 
}


Other than that the changes were fairly minor.  On line 129 of OverworldControlSystem I add a cute click effect for when players select a new character to control.  One thing frustrating me about project organization is that OverworldControlSystem does extend EntityProcessingSystem, so it is an Artemis system.  But I thought it best to put it in a separate package, com.blogspot.javagamexyz.gamexyz.input.

That's a heck of an update!  I went ahead and posted the full code to the repository, including images.  Check it out using SVN from https://code.google.com/p/javagamexyz/source/browse/#svn%2Ftags%2F2013-03-16, or just browse the code.

You have gained 150 XP.  Progress to Level 3: 600/600
DING!  You have advanced to Level 3, congratulations!
As a Level 3 PC, you have mastered
  • Using animations in a libgdx/Artemis framework
  • Creating, handling, and drawing 2D tile based maps, even with Hex cells.  You can deal with helper functions like getNeighbors() and distance()
You have also gained some proficiency at
  • Basic pathfinding using the A* algorithm
  • Managing Screens using Game to split your code up into manageable chunks
Your game is now on the path to becoming something that can actually be played!



No comments:

Post a Comment