14
\$\begingroup\$

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 TextureRegions that are all the same size. After that, the world map of IsoTiles 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 TextureRegions 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:

city_no_clipping

Here is an image with clipping enabled:

city_with_clipping

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:

enter image description here

As always, any feedback is greatly appreciated!

\$\endgroup\$
  • \$\begingroup\$ Is the IsoPoint class something that you have made or is that something you import from LibGDX? \$\endgroup\$ – Simon Forsberg May 28 '15 at 23:48
  • \$\begingroup\$ It is my class. It's just a simple point class with x and y integer members. \$\endgroup\$ – bazola May 28 '15 at 23:49
5
\$\begingroup\$

When you're using a double for-loop, I'd stay away from i and j names for the variables. I think x and y is better, even if we're dealing with Isometric coordinates, perhaps you can come up with something even better.


There is one main concern I have in your code, and that is this part in the render method:

IsoPoint point = new IsoPoint((int)(i * this.defaultHeight * gapReducer / scaleReducer), 
                              (int)(j * this.defaultHeight * gapReducer / scaleReducer));
IsoPoint convertedPoint = IsoHelper.twoDToIso(point);

This creates new objects in each and every call to render. The render method is called, as you know, continuously. This is causes a lot of extra work for the device and puts a lot of extra work on the garbage collector. Avoid creating objects in a continuous loop. There are several SO questions about this.

In this case, you could actually perform the operations using only one IsoPoint object that you re-use every time.

// Outside render method
private IsoPoint point = new IsoPoint();

// In render method
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);
        point.set((int)(i * this.defaultHeight * gapReducer / scaleReducer), 
                  (int)(j * this.defaultHeight * gapReducer / scaleReducer));
        point.convertTwoDToIso();

        int count = 0;
        for (int h = 0; h < tile.textures.size(); h++) {
            ...

            TextureRegion textureRegion = tile.textures.get(h);
            batch.draw(textureRegion, 
                       point.x, 
                       point.y + h * this.defaultHeight / scaleReducer, 
                       textureRegion.getRegionWidth() / scaleReducer, 
                       textureRegion.getRegionHeight() / scaleReducer);;
        }
    }
}

Note that this way, the IsoPoint object point essentially becomes a storage place for two variables, x and y and now you continuously mutate those variables, instead of creating a bunch of new variables every time.

Alternatively, you could have each IsoTile store a permanent reference to the IsoPoint object that can be used for the calculation.

Either way, Avoid creating objects in a continuous loop

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.