Building a physics world from level data

Physics for levels can be hardcoded in the source code with terrible consequences for maintenance purposes, but I am sure that you have already heard about the data-driven approach that suggests a solution.

Most level editors include mechanisms to attach physics data to the final level file and one of them, Tiled, is familiar to you from Chapter 8, User Interfaces with Scene2D. It supplies a layers system where you can define geometric shapes and some metadata for their properties.

As all the data is parsed by the map, you can create a generic physics populator for your games so the process of applying them to static bodies becomes almost automatic.

Getting ready

The juice of this recipe is concentrated into two Java source files: Box2DMapPopulatorSample.java and MapBodyManager.java. At the end, you will have a bunch of your game objects with their physics shapes properly attached on your screen.

How to do it…

Fortunately, you are a master in dealing with 2D maps so we can focus on the real purpose of this recipe: populating a map with physics elements. This example will make use of the tiled output but it is not hard to adapt it to any other program.

However, it will not be enough with 2D maps, you must be a jack-of-all-trades as we will go through some JSON stuff as well as the typical Libgdx Java code.

Defining materials

First things first, we will start with one of the basic pillars of this recipe: the material file. Already known Box2D physics properties will define its structure. We will code it using JSON standard, but, as always, it applies perfectly for anyone:

[
  { "name" : "ice", "density" : 0.92, "restitution" : 0.1, "friction" : 0.1 },
  { "name" : "sand", "density" : 1.78, "restitution" : 0.0, "friction" : 0.8 },
  { "name" : "grass", "density" : 1.1, "restitution" : 0.0, "friction" : 0.6 }
]

This can be extremely useful to add an extra degree of realism to your game because characters will slide when walking over an icy surface or run slower over dry sand.

Save the file as materials.json and keep reading.

Generating the physics metadata

You will have to edit your old tiled map by following these steps:

  1. Add a new object layer named physics.
  2. Within this layer and using the shape tools, create your physics forms. Do not forget to give them a name. Otherwise, it will be a mess when having a lot of them.
  3. Right-click on the ones that you want to add physical attributes to. Select Object Properties and add a new property called material with your desired material name as value from the ones that you defined in materials.json, as shown in the following screenshot:
    Generating the physics metadata
  4. Repeat step 3 for each of your desired shapes in order to assign physics properties to them.

Populating your world

Keeping the same structure as the other Box2D examples to complete this recipe should not be too complex:

  1. Import the map populator class:
    import com.cookbook.box2d.MapBodyManager
  2. Add a class field that will define the scale ratio for the maps' textures. Otherwise, they will appear really small as we are working in world units:
    private static final float SCREEN_TO_WORLD = 30f;
    private static final float WORLD_TO_SCREEN = 1/SCREEN_TO_WORLD;
    private static final float SCENE_WIDTH = 12.80f ;
    private static final float SCENE_HEIGHT = 7.20f;
  3. Declare a MapBodyManager object as well as the typical map fields:
    MapBodyManager mbm;
    private TiledMap map;
    private TmxMapLoader loader;
    private OrthogonalTiledMapRenderer renderer;
  4. Once those map fields are initialized, taking into account that the renderer must be aware of units translation, do the same with our populator and extract the whole pack of physics shapes and properties:
    renderer = new OrthogonalTiledMapRenderer(map, WORLD_TO_SCREEN);
    
    mbm = new MapBodyManager(world, SCREEN_TO_WORLD, Gdx.files.internal("data/box2D/materials.json"), Logger.INFO);
    mbm.createPhysics(map);

    The MapBodyManager constructor requires the physics world, the number of screen units per Box2D units, the already created materials file, and the verbosity of the embedded logger.

  5. Render the map as usual and … mission accomplished!

How it works…

The magic-like usage of the MapBodyManager constructor is the most beautiful part of this recipe but you know that, sooner or later, the moment to face the truth will come, which makes this section the essence of this recipe.

The populator will make use of the following fields:

private Logger logger;
private World world;
private float units;
private Array<Body> bodies = new Array<Body>();
private ObjectMap<String, FixtureDef> materials = new ObjectMap<String, FixtureDef>();

It is necessary to highlight that units is the conversion ratio from screen units to Box2D ones. To keep track of the potentially generated physics data, there are bodies and materials. The type of the latter is a Libgdx custom implementation of an unordered map, which works fast at retrieving, consulting, and removing data.

Note

Remember that you should use Libgdx data types whenever possible for compatibility and performance reasons.

Within the MapBodyManager constructor, we will initialize world, units, and logger variables. Moreover, it will end by checking the file existence, and, that being the case, its content will be parsed and loaded into the materials object. The next lines of code are destined to show the function in charge of carrying out the last part:

private void loadMaterialsFile(FileHandle materialsFile) {
  logger.info("adding default material");

  FixtureDef fixtureDef = new FixtureDef();
  fixtureDef.density = 1.0f;
  fixtureDef.friction = 1.0f;
  fixtureDef.restitution = 0.0f;
  materials.put("default", fixtureDef);

  logger.info("loading materials file");

  try {
    JsonReader reader = new JsonReader();
    JsonValue root = reader.parse(materialsFile);
    JsonIterator materialIt = root.iterator();

    while (materialIt.hasNext()) {
      JsonValue materialValue = materialIt.next();

      if (!materialValue.has("name")) {
        logger.error("material without name");
        continue;
      }

      String name = materialValue.getString("name");

      fixtureDef = new FixtureDef();
      fixtureDef.density = materialValue.getFloat("density", 1.0f);
      fixtureDef.friction = materialValue.getFloat("friction", 1.0f);
      fixtureDef.restitution = materialValue.getFloat
("restitution", 0.0f);
      logger.info("adding material " + name);
      materials.put(name, fixtureDef);
    }
  } catch (Exception e) {
    logger.error("error loading " + materialsFile.name() + " " + e.getMessage());
  }
}

The function starts by adding a default material for those shapes with no physics properties so that they do not feel like an orphan. Next, it makes use of the already explained navigation techniques over the JSON file to retrieve and store the materials' data.

At this point, we have everything to allow the user to call the createPhysics(Map map) function which, in turn, will call her namesake bigger sister to process the physics layer. However, she is very skeptical about her sister's suggestions, so the first thing she does is to check that the received layer name truly exists within the map:

public void createPhysics(Map map, String layerName) {
  MapLayer layer = map.getLayers().get(layerName);

  if (layer == null) {
    logger.error("layer " + layerName + " does not exist");
    return;
  }
…

In its mission to please her little sister, she keeps diving into the dangerous map structure skipping anything different from shapes:

…
for(MapObject object : layer.getObjects()) {

  if (object instanceof TextureMapObject)
    continue;
…

Once one of those precious creatures appears, she runs quickly to capture it:

…
Shape shape;
BodyDef bodyDef = new BodyDef();
bodyDef.type = BodyDef.BodyType.StaticBody;

if (object instanceof RectangleMapObject) {
  RectangleMapObject rectangle = (RectangleMapObject)object;
  shape = getRectangle(rectangle);
}
else if (object instanceof PolygonMapObject) {
  shape = getPolygon((PolygonMapObject)object);
}
else if (object instanceof PolylineMapObject) {
  shape = getPolyline((PolylineMapObject)object);
}
else if (object instanceof CircleMapObject) {
  shape = getCircle((CircleMapObject)object);
}
else {
  logger.error("non supported shape " + object);
  continue;
}
…

It does not matter what type of shape goes into action because we will provide her with some useful tools. The first one is able to build a rectangle from its bounds, taking into account the unit field member passed to the constructor:

private Shape getRectangle(RectangleMapObject rectangleObject) {
  Rectangle rectangle = rectangleObject.getRectangle();
  PolygonShape polygon = new PolygonShape();
  Vector2 size = new Vector2((rectangle.x + rectangle.width * 0.5f) / units, (rectangle.y + rectangle.height * 0.5f ) / units);
  polygon.setAsBox(rectangle.width * 0.5f / units,  rectangle.height * 0.5f / units, size, 0.0f);
  return polygon;
}

The same technique is followed by her circular counterpart:

private Shape getCircle(CircleMapObject circleObject) {
  Circle circle = circleObject.getCircle();
  CircleShape circleShape = new CircleShape();
  circleShape.setRadius(circle.radius / units);
  circleShape.setPosition(new Vector2(circle.x / units, circle.y / units));
  return circleShape;
}

Polygons might also be part of this special fauna. They will be built from a set of vertices:

private Shape getPolygon(PolygonMapObject polygonObject) {
  PolygonShape polygon = new PolygonShape();
  float[] vertices = polygonObject.getPolygon().getTransformedVertices();

  float[] worldVertices = new float[vertices.length];

  for (int i = 0; i < vertices.length; ++i)
    worldVertices[i] = vertices[i] / units;

  polygon.set(worldVertices);
  return polygon;
}

The last possible kind of shape to deal with is the polyline, which commonly appears in this chapter:

private Shape getPolyline(PolylineMapObject polylineObject) {
  float[] vertices = polylineObject.getPolyline().getTransformedVertices();
  Vector2[] worldVertices = new Vector2[vertices.length / 2];

  for (int i = 0; i < vertices.length / 2; ++i) {
    worldVertices[i] = new Vector2();
    worldVertices[i].x = vertices[i * 2] / units;
    worldVertices[i].y = vertices[i * 2 + 1] / units;
  }

  ChainShape chain = new ChainShape();
  chain.createChain(worldVertices);
  return chain;
}

Once the auxiliary functions are clear, we can carry on with our peculiar family's story.

Since we have made the effort of defining special properties for each type of material, the function must associate the retrieved shapes with its corresponding material from the generated list. If this does not match or its attached material is simply not valid, set the default one:

…
MapProperties properties = object.getProperties();
String material = properties.get("material", "default", String.class);

FixtureDef fixtureDef = materials.get(material);

if (fixtureDef == null) {
  logger.error("material does not exist " + material + " using default");
  fixtureDef = materials.get("default");
}

logger.info("Body: " + object.getName() + ", material: " + material);
…

Finally, create the body as usual from the generated auxiliary FixtureDef instance and, of course, do not forget to clean the house:

…
  fixtureDef.shape = shape;

  Body body = world.createBody(bodyDef);
  body.createFixture(fixtureDef);

  bodies.add(body);

  fixtureDef.shape = null;
  shape.dispose();
  }
}

There's more…

As there is strength in numbers, you could make use of your recently acquired knowledge and add support for collision filtering properties to your custom MapBodyManager.

See also

  • The Implementing a deferred raycaster recipe
  • The The fixed timestep approach recipe
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.220.124.177