Wednesday, January 23, 2013

Meter and pixel units in a box2d game - LibGDX

I often see posts on the LibGDX forum from users confused about converting game units to pixel units, while coding box2d+scene2d games. I'm writing this short article to present my approach on this matter, which actually doesn't involve any unit conversion. It's all done by different cameras with different viewports.

As you know, box2d wants its bodies sized in meters. "But hey my character is 200 pixels big, I'll make it 200 meters and use a 1:1 conversion" you can't. The box2d manual says that the physics simulation starts to go freaky with dynamic bodies bigger than a truck. So stick with real-size dimensions.

The key is to have 2 different stages with different viewports. In the following example I'll use a stage to represent the box2d world, which will be the "game stage" and will have a viewport of about 3x6 meters; then I'll use another stage on top of the other one, aka "GUI stage", which will be used to render the GUI and will have a viewport of 1024x600 pixels. This way you get a meter-sized game with pixel-exact GUI.

Game Screen - this class is our main game screen. It contains the GUI stage.

 public class GameScreen implements Screen {  
   
     // this is actually my tablet resolution in landscape mode. I'm using it for making the GUI pixel-exact.  
     public static float SCREEN_WIDTH = 1024;  
     public static float SCREEN_HEIGHT = 600;  
   
     private GameWorld world; // contains the game world's bodies and actors.  
     private GameRenderer renderer; // our custom game renderer.  
     private Stage stage; // stage that holds the GUI. Pixel-exact size.  
     private OrthographicCamera guiCam; // camera for the GUI. It's the stage default camera.  
       
     @Override  
     public final void show() {  
           
         this.stage = new Stage(); // create the GUI stage
         this.stage.setViewport(SCREEN_WIDTH, SCREEN_HEIGHT, false); // set the GUI stage viewport to the pixel size
   
         world = new GameWorld();  
         renderer = new GameRenderer(world);  
           
         // add GUI actors to stage, labels, meters, buttons etc.  
         Label labelStatus = new Label("TOUCH TO START", Assets.skin);  
         labelStatus.setPosition(GenericScreen.SCREEN_WIDTH/2-500, GenericScreen.SCREEN_HEIGHT/2);  
         labelStatus.setWidth(1000);  
         labelStatus.setAlignment(Align.center);  
         labelStatus.setFontScale(2);  
         stage.addActor(labelStatus);  
         // add other GUI elements here  
     }  
       
     @Override  
     public void render(float delta) {  
           
         guiCam = (OrthographicCamera) stage.getCamera();  
         guiCam.position.set(SCREEN_WIDTH/2, SCREEN_HEIGHT/2, 0);  
   
         Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);  
         Gdx.gl.glEnable(GL10.GL_TEXTURE_2D);  
         guiCam.update();  
   
         world.update(delta); // update the box2d world          
         stage.act(delta); // update GUI  
           
         renderer.render(); // draw the box2d world  
         stage.draw(); // draw the GUI  
     }  
 }  

Game World - our container for the box2d world and other game elements. It also contains the game stage.

 public class GameWorld {  
       
     // here we set up the actual viewport size of the game in meters.  
     public static float UNIT_WIDTH = GameScreen.SCREEN_WIDTH/160; // 6.4 meters width  
     public static float UNIT_HEIGHT = GameScreen.SCREEN_HEIGHT/160; // 3.75 meters height  
       
     public static final Vector2 GRAVITY = new Vector2(0, -9.8f);  
       
     public final Stage stage; // stage containing game actors (not GUI, but actual game elements)  
     public World box2dWorld; // box2d world  
     public Bob bob; // our playing character  
       
     public GameWorld() {      
           
         box2dWorld = new World(GRAVITY, true);          
         stage = new Stage(); // create the game stage  
         stage.setViewport(UNIT_WIDTH, UNIT_HEIGHT, false); // set the game stage viewport to the meters size   
           
         createWorld();  
     }  
       
     private void createWorld() {  
           
         // create box2d bodies and the respective actors here.  
         bob = new Bob(this);  
         stage.addActor(bob);  
         // add more game elements here  
     }  
       
     public void update(float delta) {  
       
         // perform game logic here  
         box2dWorld.step(delta, 3, 3); // update box2d world  
         stage.act(delta); // update game stage  
     }  
 }  

Bob - our character. It extends Image which is a subclass of Actor, and contains a reference to the box2d body (in this case a circle). Its texture is resized to the game meter units, but it will be rendered using the game stage camera, so even if the texture is scaled to 0.8x0.8, it will be quite big since the viewport is 6x3 meters.

 public class Bob extends Image {  
   
     public static final float RADIUS = 0.4f; // bob is a ball with 0.8m diameter      
     public final Body body; // bob's box2d body  
       
     public Bob(GameWorld world) {  
           
         // bob is an Image, so we load the graphics from the assetmanager  
         Texture tex = Assets.manager.get("characters.png", Texture.class);  
         this.setDrawable(new TextureRegionDrawable(new TextureRegion(tex, 0, 256, 128, 128)));  
           
         // generate bob's box2d body  
         CircleShape circle = new CircleShape();  
         circle.setRadius(RADIUS);  
           
         BodyDef bodyDef = new BodyDef();  
         bodyDef.type = BodyType.DynamicBody;  
         bodyDef.position.x = 2f;  
         bodyDef.position.y = 2f;  
         bodyDef.linearDamping = 0.1f;  
         bodyDef.angularDamping = 0.5f;  
           
         this.body = world.box2dWorld.createBody(bodyDef);  
         this.body.setUserData(ElementType.BOB);  
           
         Fixture fix = body.createFixture(circle, 50);  
         fix.setDensity(1);  
         fix.setFriction(1f);  
         fix.setRestitution(0.8f);  
         fix.setFilterData(filter);  
           
         circle.dispose();  
           
         // generate bob's actor  
         this.setPosition(body.getPosition().x-RADIUS, body.getPosition().y-RADIUS); // set the actor position at the box2d body position  
         this.setSize(RADIUS*2, RADIUS*2); // scale actor to body's size  
         this.setScaling(Scaling.stretch); // stretch the texture  
         this.setAlign(Align.center);  
     }  
       
     @Override  
     public void act(float delta) {  
         // here we override Actor's act() method to make the actor follow the box2d body  
         super.act(delta);  
         setOrigin(RADIUS, RADIUS);  
         setRotation(MathUtils.radiansToDegrees * body.getAngle());  
         setPosition(body.getPosition().x-RADIUS, body.getPosition().y-RADIUS);  
     }  
 }  

Game Renderer - a custom renderer used to draw the game world. It just positions the camera each frame and draws the game stage. Very simple.

 public class GameRenderer  
 {      
     GameWorld world;  
     OrthographicCamera camera;  
     Box2DDebugRenderer renderer;  
   
     public GameRenderer(GameWorld world)  
     {  
         this.world = world;  
         this.renderer = new Box2DDebugRenderer();  
       
         // we obtain a reference to the game stage camera. The camera is scaled to box2d meter units  
         this.camera = (OrthographicCamera) world.stage.getCamera();  
       
         // center the camera on bob (optional)  
         camera.position.x = world.bob.body.getPosition().x;  
         camera.position.y = world.bob.body.getPosition().y;  
     }  
   
     public void render()  
     {  
         // have the camera follow bob  
         camera.position.x = world.bob.body.getPosition().x;  
         camera.position.y = world.bob.body.getPosition().y;  
           
         Gdx.gl.glClear(GL10.GL_COLOR_BUFFER_BIT);  
   
         // box2d debug renderering (optional)  
         camera.update();   
         renderer.render(world.box2dWorld, camera.combined);  
          
         // game stage rendering  
         world.stage.draw();  
     }  
 }  

Tuesday, January 22, 2013

Animated actor with scene2d - LibGDX

I am currently tampering with LibGDX scene2d API for the next game we're producing.

For Candy Falls! I used a personal framework for managing moving/transitioning/interactive game objects (named PimentosoLib) which is basically a watered-down version of scene2d. For the next game I'm trying scene2d, which is more complex, complete and already available inside the LibGDX framework.

The game will be kinda heavy animation-wise, and scene2d does not provide an Actor that manages an animated sprite. I quickly created one extending the Widget class, it's very similar to the Image actor but manages an Animation instance instead of a Drawable instance.

Here is the code, if someone's interested.

NOTE: the following code will work VERY wacky if you are using LibGDX TexturePacker to pack the animation assets, using "stripWhitespace" or "rotation" parameters set to true. This is because direct TextureRegion drawing to a SpriteBatch does not rotate/pad the regions. To solve this, you can change the "region" global field from TextureRegion to a Sprite, and cast it inside the draw() method. I might fix this myself in the future, but I need to test the memory usage.

 import com.badlogic.gdx.graphics.Color;  
 import com.badlogic.gdx.graphics.g2d.Animation;  
 import com.badlogic.gdx.graphics.g2d.SpriteBatch;  
 import com.badlogic.gdx.graphics.g2d.TextureRegion;  
 import com.badlogic.gdx.math.Vector2;  
 import com.badlogic.gdx.scenes.scene2d.ui.Image;  
 import com.badlogic.gdx.scenes.scene2d.ui.Widget;  
 import com.badlogic.gdx.scenes.scene2d.utils.Align;  
 import com.badlogic.gdx.scenes.scene2d.utils.Drawable;  
 import com.badlogic.gdx.utils.Scaling;  
   
 /**  
  * Animated scene2d actor. Similar to {@link Image} but uses {@link Animation} instead of a {@link Drawable}.  
  * @author Pimentoso   
  */  
 public class AnimatedImage extends Widget {  
   
     private Scaling scaling;  
     private int align = Align.center;  
     private float imageX, imageY, imageWidth, imageHeight;  
       
     private Animation animation;  
     private TextureRegion region;  
     public int state;  
     public float stateTime;  
       
     public AnimatedImage() {  
         this((Animation) null);  
     }  
       
     public AnimatedImage(Animation animation) {  
         this(animation, Scaling.stretch, Align.center);  
     }  
       
     public AnimatedImage(Animation animation, Scaling scaling, int align) {  
         setAnimation(animation);  
         this.scaling = scaling;  
         this.align = align;  
         setWidth(getPrefWidth());  
         setHeight(getPrefHeight());  
     }  
       
     @Override  
     public void draw(SpriteBatch batch, float parentAlpha) {  
         validate();  
   
         Color color = getColor();  
         batch.setColor(color.r, color.g, color.b, color.a * parentAlpha);  
   
         float x = getX();  
         float y = getY();  
         float scaleX = getScaleX();  
         float scaleY = getScaleY();  
   
         if (animation != null) {  
             region = animation.getKeyFrame(stateTime);  
             float rotation = getRotation();  
             if (scaleX == 1 && scaleY == 1 && rotation == 0)  
                 batch.draw(region, x + imageX, y + imageY, imageWidth, imageHeight);  
             else {  
                 batch.draw(region, x + imageX, y + imageY, getOriginX() - imageX, getOriginY() - imageY, imageWidth, imageHeight,  
                     scaleX, scaleY, rotation);  
             }  
         }  
     }  
       
     @Override  
     public void layout() {  
         float regionWidth, regionHeight;  
         if (animation != null) {  
             regionWidth = animation.getKeyFrame(0).getRegionWidth();  
             regionHeight = animation.getKeyFrame(0).getRegionHeight();  
         } else  
             return;  
   
         float width = getWidth();  
         float height = getHeight();  
   
         Vector2 size = scaling.apply(regionWidth, regionHeight, width, height);  
         imageWidth = size.x;  
         imageHeight = size.y;  
   
         if ((align & Align.left) != 0)  
             imageX = 0;  
         else if ((align & Align.right) != 0)  
             imageX = width-imageWidth;  
         else  
             imageX = (width/2)-(imageWidth/2);  
   
         if ((align & Align.top) != 0)  
             imageY = height-imageHeight;  
         else if ((align & Align.bottom) != 0)  
             imageY = 0;  
         else  
             imageY = (height/2)-(imageHeight/2);  
     }  
   
     @Override  
     public void act(float delta) {  
         super.act(delta);  
         stateTime += delta;  
     }  
       
     public void setState(int state) {  
         this.state = state;  
         stateTime = 0.0f;  
     }  
       
     public void setPlayMode (int playMode) {  
         animation.setPlayMode(playMode);  
     }  
   
     public Animation getAnimation() {  
         return animation;  
     }  
       
     public void setAnimation(Animation animation) {  
         if (animation != null) {  
             if (this.animation == animation) return;  
             invalidateHierarchy();  
         } else {  
             if (getPrefWidth() != 0 || getPrefHeight() != 0) invalidateHierarchy();  
         }  
         this.animation = animation;  
     }  
   
     public void setScaling (Scaling scaling) {  
         if (scaling == null) throw new IllegalArgumentException("scaling cannot be null.");  
         this.scaling = scaling;  
     }  
   
     public void setAlign (int align) {  
         this.align = align;  
     }  
   
     public float getMinWidth () {  
         return 0;  
     }  
   
     public float getMinHeight () {  
         return 0;  
     }  
   
     public float getPrefWidth () {  
         if (animation != null) return animation.getKeyFrame(0).getRegionWidth();  
         return 0;  
     }  
   
     public float getPrefHeight () {  
         if (animation != null) return animation.getKeyFrame(0).getRegionHeight();  
         return 0;  
     }  
   
     public float getImageX () {  
         return imageX;  
     }  
   
     public float getImageY () {  
         return imageY;  
     }  
   
     public float getImageWidth () {  
         return imageWidth;  
     }  
   
     public float getImageHeight () {  
         return imageHeight;  
     }  
 }