I have recently been working on my first game with an isometric perspective. For those who don't know, an isometric perspective is a pseudo 3D view created from 2D tiles. I am working off of this tutorial about isometric graphics.
I believe my approach is fairly clean and efficient. I have tried to do things in an object oriented way. For some of the math involved, I am relying on the code found in the tutorial linked above. I will include this code for completeness, but it is not really my code.
Edit: I changed the rendering to load all of the textures into a Map
and then create one of each type of IsoTile
out of those textures at game start. When the IsoTile
is instantiated, a texture is passed into it by the libGDX game. The IsoTile
splits the texture into the appropriate number of TextureRegion
s that are all the same size. After that, the world map of IsoTile
s is created. During rendering, the game checks whether or not the integer in the world data matches the type of the IsoTile
in the world map, and if not then it changes that IsoTile
to the appropriate type. Then the renderer renders this world map each cycle. For each IsoTile
its array of TextureRegions
is iterated over and drawn to the screen.
One benefit of doing things this way is that I can flip a switch and disable rendering beyond the initial TextureRegion
in the array, and thereby clip the texture such that you can see the tiles behind it easily. I could also apply a different alpha value to these TextureRegion
s instead to make them see through.
I should mention that I am already familiar with how to use Screen
objects in order to separate the rendering logic into a MainMenu
and GameScene
, etc. This is why the camera, batch, and textures are made public by the libGDX Game
class. Eventually the different Screen
objects will have their own render()
and draw()
methods and will need access to the loaded resources.
IsoTile.java
public class IsoTile {
private int unitHeight;
private int tileType;
public List<TextureRegion> textures = new ArrayList<TextureRegion>();
public IsoTile(Texture texture, int tileType, int unitHeight) {
this.tileType = tileType;
this.unitHeight = unitHeight;
this.textures = this.splitTextureIntoRegions(texture);
}
public int getTileType() {
return this.tileType;
}
private List<TextureRegion> splitTextureIntoRegions(Texture texture) {
TextureRegion textureRegion = new TextureRegion(texture);
List<TextureRegion> textures = new ArrayList<TextureRegion>();
if (texture.getHeight() > this.unitHeight) {
TextureRegion[][] textureMatrix = textureRegion.split(texture.getWidth(), this.unitHeight);
for (int i = textureMatrix.length - 1; i >= 0; i--) {
TextureRegion[] textureArray = textureMatrix[i];
for (int j = 0; j < textureArray.length; j++) {
TextureRegion region = textureArray[j];
textures.add(region);
}
}
} else {
textures.add(textureRegion);
}
return textures;
}
}
IsoTileType.java
public enum IsoTileType {
GROUND(0, "ground02.png"),
HOUSE01(1, "house02.png"),
SCRAPER01(2, "scraper03.png"),
LIGHTCOM(3, "lightCommercial02.png"),
MEDCOM(4, "mediumCommercial02.png"),
HIGHCOM(5, "highCommercial02.png"),
LIGHTRES(6, "lightResidential02.png"),
MEDRES(7, "mediumResidential02.png"),
HIGHRES(8, "highResidential02.png"),
LIGHTIND(9, "lightIndustrial02.png"),
MEDIND(10, "mediumIndustrial02.png"),
HIGHIND(11, "highIndustrial02.png");
private int id;
private String fileName;
private IsoTileType(int id, String fileName) {
this.id = id;
this.fileName = fileName;
}
public int id() {
return this.id;
}
public String fileName() {
return this.fileName;
}
}
IsoWorldGame.java
public class IsoWorldGame extends ApplicationAdapter {
public static float STAGE_WIDTH;
public static float STAGE_HEIGHT;
public CameraPanner cameraPanner;
public PinchZoomer pinchZoomer;
public InputMultiplexer inputHandler = new InputMultiplexer();
public OrthographicCamera camera;
public Stage stage;
public SpriteBatch batch;
private int defaultHeight;
private boolean clippingEnabled = false;
private List<ArrayList<IsoTile>> worldTiles;
private Map<Integer, Texture> allTextures;
private Map<Integer, IsoTile> allTiles;
private MainGame mainGame;
private double currentTime = 0;
private double timeSinceLastUpdate = 0;
private double timeBetweenUpdates = 1000;
@Override
public void create () {
//configure screen size
if (Gdx.app.getType() == ApplicationType.Desktop ||
Gdx.app.getType() == ApplicationType.WebGL) {
IsoWorldGame.STAGE_WIDTH = 1400; //Gdx.graphics.getWidth();
IsoWorldGame.STAGE_HEIGHT = 900; //Gdx.graphics.getHeight();
} else {
//good for phone shape
IsoWorldGame.STAGE_WIDTH = 480; //Gdx.graphics.getWidth();
IsoWorldGame.STAGE_HEIGHT = 800; //Gdx.graphics.getHeight();
}
mainGame = new MainGame();
camera = new OrthographicCamera(STAGE_WIDTH, STAGE_HEIGHT);
camera.setToOrtho(false, STAGE_WIDTH, STAGE_HEIGHT);
batch = new SpriteBatch();
stage = new Stage(new StretchViewport(STAGE_WIDTH, STAGE_HEIGHT, camera), batch);
//controls
inputHandler.addProcessor(stage);
this.cameraPanner = new CameraPanner(camera);
this.cameraPanner.setEnabled(true);
this.pinchZoomer = new PinchZoomer(camera);
this.pinchZoomer.setEnabled(true);
inputHandler.addProcessor(new GestureDetector(this.cameraPanner));
inputHandler.addProcessor(new GestureDetector(this.pinchZoomer));
Gdx.input.setInputProcessor(inputHandler);
this.allTextures = this.loadTextures();
Texture defaultTile = this.allTextures.get(0);
this.defaultHeight = defaultTile.getHeight();
this.allTiles = this.createTileSet();
this.worldTiles = this.createWorldTiles(this.mainGame.world.levelData);
}
private Map<Integer, Texture> loadTextures() {
Map<Integer, Texture> textures = new HashMap<Integer, Texture>();
for (IsoTileType type : IsoTileType.values()) {
Texture texture = new Texture(type.fileName());
textures.put(type.id(), texture);
}
return textures;
}
private Map<Integer, IsoTile> createTileSet() {
Map<Integer, IsoTile> tileSet = new HashMap<Integer, IsoTile>();
for (IsoTileType type : IsoTileType.values()) {
tileSet.put(type.id(), new IsoTile(this.allTextures.get(type.id()), type.id(), this.defaultHeight));
}
return tileSet;
}
private List<ArrayList<IsoTile>> createWorldTiles(int[][] worldData) {
List<ArrayList<IsoTile>> isoTiles = new ArrayList<ArrayList<IsoTile>>();
for (int i = this.mainGame.world.levelData.length - 1; i >= 0 ; i--) {
ArrayList<IsoTile> row = new ArrayList<IsoTile>();
for (int j = this.mainGame.world.levelData[i].length - 1; j >= 0; j--) {
int tileType = this.mainGame.world.levelData[i][j];
row.add(this.allTiles.get(tileType));
}
isoTiles.add(row);
}
return isoTiles;
}
@Override
public void render () {
Gdx.gl.glClearColor(0.2f, 0.5f, 0.8f, 1);
Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT);
camera.update();
stage.act(Math.min(Gdx.graphics.getDeltaTime(), 1 / 30f));
stage.draw();
batch.setProjectionMatrix(camera.combined);
batch.begin();
super.render();
this.updateWorld(this.worldTiles);
int scaleReducer = 12;
float gapReducer = 0.78f; //smaller is a smaller gap
for (int i = this.worldTiles.size() - 1; i >= 0; i--) {
List<IsoTile> row = this.worldTiles.get(i);
for (int j = row.size() - 1; j >= 0; j--) {
IsoTile tile = row.get(j);
IsoPoint point = new IsoPoint((int)(i * this.defaultHeight * gapReducer / scaleReducer),
(int)(j * this.defaultHeight * gapReducer / scaleReducer));
IsoPoint convertedPoint = IsoHelper.twoDToIso(point);
int count = 0;
for (int h = 0; h < tile.textures.size(); h++) {
//clipping for visiblity of tiles behind tall tiles
if (this.clippingEnabled) {
if (count > 0) {
break;
} else {
count++;
}
}
TextureRegion textureRegion = tile.textures.get(h);
batch.draw(textureRegion,
convertedPoint.x,
convertedPoint.y + h * this.defaultHeight / scaleReducer,
textureRegion.getRegionWidth() / scaleReducer,
textureRegion.getRegionHeight() / scaleReducer);;
}
}
}
this.batch.end();
double newTime = TimeUtils.millis();
double timeElapsed = newTime - this.currentTime;
this.currentTime = newTime;
this.timeSinceLastUpdate += timeElapsed;
if (this.timeSinceLastUpdate > this.timeBetweenUpdates) {
this.timeSinceLastUpdate = 0;
this.mainGame.world.updateWorld();
}
}
/**
* Depends on the world size not changing
*/
private void updateWorld(List<ArrayList<IsoTile>> world) {
for (int i = this.mainGame.world.levelData.length - 1; i >= 0 ; i--) {
ArrayList<IsoTile> row = world.get(i);
for (int j = this.mainGame.world.levelData[i].length - 1; j >= 0; j--) {
int tileType = this.mainGame.world.levelData[i][j];
IsoTile currentTile = row.get(j);
if (currentTile.getTileType() != tileType) {
row.set(j, this.allTiles.get(tileType));
}
}
}
}
}
Here is the helper class that I ported from the original ActionScript. Other than porting it to Java and changing some variable names I have not made any other changes.
IsoHelper.java
public class IsoHelper {
/**
* convert an isometric point to 2D
* */
public static IsoPoint isoTo2D (IsoPoint point) {
//gx=(2*isoy+isox)/2;
//gy=(2*isoy-isox)/2
IsoPoint newPoint = new IsoPoint(0, 0);
newPoint.x=(2*point.y+point.x)/2;
newPoint.y=(2*point.y-point.x)/2;
return(newPoint);
}
/**
* convert a 2d point to isometric
* */
public static IsoPoint twoDToIso (IsoPoint point) {
//gx=(isox-isoxy;
//gy=(isoy+isox)/2
IsoPoint newPoint = new IsoPoint(0, 0);
newPoint.x=point.x-point.y;
newPoint.y=(point.x+point.y)/2;
return(newPoint);
}
/**
* convert a 2d point to specific tile row/column
* */
public static IsoPoint getTileCoordinates(IsoPoint point, int tileHeight) {
IsoPoint newPoint = new IsoPoint(0, 0);
newPoint.x=(int)Math.floor(point.x/tileHeight);
newPoint.y=(int)Math.floor(point.y/tileHeight);
return newPoint;
}
/**
* convert specific tile row/column to 2d point
* */
public static IsoPoint get2dFromTileCoordinates(IsoPoint point, int tileHeight) {
IsoPoint newPoint = new IsoPoint(0, 0);
newPoint.x=point.x*tileHeight;
newPoint.y=point.y*tileHeight;
return(newPoint);
}
}
Here is an image of the game without the clipping enabled:
Here is an image with clipping enabled:
World.java
public class World {
private int tileTypes = 11;
public int[][] levelData = {{1, 1, 1, 1, 1, 1, 1, 2, 1},
{2, 0, 0, 2, 0, 0, 1, 0, 1},
{1, 0, 2, 2, 2, 0, 1, 10,1},
{1, 0, 0, 7, 7, 0, 1, 0, 1},
{1, 1, 9, 7, 7, 7, 1, 0, 1},
{1, 1,11, 0, 1, 1, 1, 0, 1},
{1, 8, 5, 3, 1, 6, 6, 0, 1},
{1, 1, 4, 2, 1, 6, 6, 0, 1},
{1, 1, 1, 2, 1, 1, 1, 0, 1}};
public void updateWorld() {
for (int i = 0; i < this.levelData.length; i++) {
int[] row = this.levelData[i];
for (int j = 0; j < row.length; j++) {
int currentNumber = row[j];
int nextNumber = currentNumber + 1;
if (nextNumber > this.tileTypes) {
nextNumber = 0;
}
this.levelData[i][j] = nextNumber;
}
}
}
}
The World
object is really simple right now and will be more complicated later. The updateWorld()
is simply for fun/testing so that I can generate this output:
As always, any feedback is greatly appreciated!
IsoPoint
class something that you have made or is that something you import from LibGDX? \$\endgroup\$