Before I get started with this, I would like to state that this project is a combination of the Heartbeast randomly generated dungeon tutorial and the Shaun Spalding Isometric tutorial. I have done the leg work that allows these two different tutorials to work together and I am writing this to have the information in 1 consolidated place.
Both Heartbeast and Shaun are better game developers and teachers than I am. I would highly suggest that you go and watch their tutorials because if you were to do that you would be able to do this all on your own. If you feel like you want to continue with me then we can get started.
The start of this tutorial will seem very familiar if you have followed the Heartbeast tutorial, or if you read my summary of getting this same script to work in Godot.
In a new Gamemaker Studio 2 project we are going to import 2 sprite sheets, a top down sprite sheet that we will use to make a tileset and an isometric sprite sheet that we will convert into frames. My top down sprites have a resolution of 64 by 64 and my isometric sprites have a resolution of 64 by 96. The isometric sprite should have an origin of top center. (You can download the sprites that I used for this tutorial here.)
Next we can make 2 objects, a level object and a render object. The create event of the level object is what will execute the code to generate the level and the render object is going to draw (render) the isometric level in its draw event.
Once these objects are created we can make a new script to declare the macros that we are going to be using. We will need to make a macro for the width and height of our map when it is top down and the width and height of the isometric tiles as well as references to when a cell is void, floor or wall
// Macros
#macro CELL_WIDTH 64
#macro CELL_HEIGHT 64
#macro TILE_W 64
#macro TILE_H 32
#macro FLOOR -5
#macro WALL -6
#macro VOID -7
With these initial macros taken care of we can go and code the Drunken Walk algorithm in the level object create event. If you remember how we did it the last time, we start by getting a random seed, setting the width and height of a DS_GRID, creating the DS_GRID, filling the grid with void and creating our controller. We are also need a reference to the tilemap layer in the room and a variable to store our odds for changing direction.
// obj_level create event
// Get random seed
randomize();
// Reference to Tilemap Layer
var tile_map_layer = layer_tilemap_get_id("Map");
// Set Grid width and height
global.grid_width = room_width div CELL_WIDTH;
global.grid_height = room_height div CELL_HEIGHT;
// Create DS_GRID
global.grid = ds_grid_create(global.grid_width, global.grid_height);
// FILL Grid with Void
ds_grid_set_region(global.grid, 0, 0, global.grid_width, global.grid_height, VOID);
// Create the controller
var controller_x = global.grid_width div 2;
var controller_y = global.grid_height div 2;
var controller_direction = irandom(3);
var direction_odds = 1;
Next we run a loop several hundred times times to carve out the floor from all of the void space. This is done by taking the current X and Y position of our controller, flagging that location as a floor, then we check our odds to see if we change our direction. Finally we update our controller location by moving it towards our direction. The direction is a random int from 0-3 which we multiply by 90 to get a value of 0, 90, 180 or 270 degrees. Don’t forget to clamp your controller to stay inside your grid or your game will crash.
// Assign floor tiles
repeat (400)
{
// Set the current position to floor
global.grid[# controller_x, controller_y] = FLOOR;
// Randomize the direction
if (irandom(direction_odds) == direction_odds)
controller_direction = irandom(3);
// Move the controller
var x_direction = lengthdir_x(1, controller_direction * 90);
var y_direction = lengthdir_y(1, controller_direction * 90);
controller_x += x_direction;
controller_y += y_direction;
// Clamp the controller to the grid
if (controller_x < 2 || controller_x >= global.grid_width -2)
controller_x += -x_direction * 2;
if (controller_y < 2 || controller_y >= global.grid_height -2)
controller_y += -y_direction * 2;
}
After the floor has been created, we can run a nested for loop through the DS_GRID and assign wall tiles. Walls are assigned by checking if a floor tile is beside a void tile and changing that void to wall; however, since this is an isometric game, you only need to assign walls if the void tile is above or to the left of a floor, leaving the rest alone.
// Flag Walls
for (var yy = 1; yy < global.grid_height - 1; yy++)
{
for (var xx = 1; xx < global.grid_width - 1; xx++)
{
if (global.grid[# xx, yy] == FLOOR)
{
if (global.grid[# xx, yy-1] == VOID)
global.grid[# xx, yy-1] = WALL;
if (global.grid[# xx-1, yy] == VOID)
global.grid[# xx-1, yy] = WALL;
}
}
}
The final step in this process is to draw the tilemap to the screen. This is done with another nested for loop through the DS_GRID and drawing the co-responding tile. We don’t need to worry about running so many nested for loops here, this is the create event and it is only run once.
// Draw the level
for (var yy = 1; yy < global.grid_height - 1; yy++)
{
for (var xx = 1; xx < global.grid_width - 1; xx++)
{
if (global.grid[# xx, yy] == FLOOR)
tilemap_set(tile_map_layer, 1, xx, yy);
if (global.grid[# xx, yy] == WALL)
tilemap_set(tile_map_layer, 2, xx, yy);
}
}
If you were to take your level object, put it into a room, double the room’s size from the starting value and create a tileset layer called “Map”, you should get output similar to what I have.
With our level object creating new levels at execution, we are ready to start rendering them in an isometric perspective. First we need to add an enum to our macros script, which will store which isometric sprite to draw and its height data.
enum TILE
{
SPRITE = 0,
Z = 1
}
We can now create the render object and start working on it’s create event. We can start by disabling the Map tileset layer in the room so that it doesn’t render at the same time as the isometric level. Then create another DS_GRID to be able to store all of the isometric data and run another nested for loop to loop through all of the tiles and store their data in the new grid.
// obj_render Create Event
// Set the tilemap to not be visible
layer_set_visible("Map", false);
// Create a DS Grid to store tile information
global.theMap = ds_grid_create(global.grid_width, global.grid_height);
// Get Tileset ID
var tileMap = layer_tilemap_get_id("Map");
// Start nested for loop
for (var tx = 0; tx < global.grid_width; tx++)
{
for (var ty = 0; ty < global.grid_height; ty++)
{
// Make local variable for the cell I am on
var tile_map_data = tilemap_get(tileMap, tx, ty);
// Format : [Sprite, Z]
var thistile = [-1, 0];
// Assign the sprite
thistile[TILE.SPRITE] = tile_map_data;
// Assign the Z Height for walls
if (global.grid[# tx, ty] == WALL)
thistile[TILE.Z] = -40;
else
thistile[TILE.Z] = 0;
// Assign this information to the new DS_GRID
global.theMap[# tx, ty] = thistile;
}
}
For the next part, we are going to need 4 of Shaun Spalding’s scripts. tile_to_screen_x, tile_to_screen_y, screen_to_tile_x, screen_to_tile_y. The tile_to_screen scripts are able to take a location from our new DS_GRID and render it to the screen. The screen_to_tile scripts are able to take an x and y position in the room and determine which cell in the DS_GRID they are on. Please go watch his video to see the code and to get an explanation of how they work.
Once those 4 scripts are in your possession we can proceed by rendering the level in an isometric view. This work is going to be done in the draw event of our obj_render object. What we do is create a collection of temporary variables to store our tiles data, screen position, which sprite to render and its height. We loop through the DS_GRID and draw it to the screen.
// obj_render draw event
// Create local variables for the tileData, X Y and Z position as well as the sprite
var tileData, screenX, screenY, tileIndex, tileZ;
// Start nested for loop for the ground
for (var tx = 0; tx < global.grid_width; tx++)
{
for (var ty = 0; ty < global.grid_height; ty++)
{
// Assign cell data to local variables
tileData = global.theMap[# tx, ty];
screenX = tile_to_screen_x(tx, ty);
screenY = tile_to_screen_y(tx, ty);
tileIndex = tileData[TILE.SPRITE];
tileZ = tileData[TILE.Z];
// Draw the floor
if (tileIndex != 0 && global.grid[# tx, ty] == FLOOR)
draw_sprite(spr_isometric, tileIndex-1, screenX, screenY + tileZ);
// Draw the walls
if (tileIndex != 0 && global.grid[# tx, ty] == WALL)
draw_sprite(spr_isometric, tileIndex-1, screenX, screenY + tileZ);
}
}
To get the render object working, you can add 1 line to the very end of the obj_level create event to create this object on the instances layer.
instance_create_layer(x, y, "Instances", obj_render);
When you run the game you should get something similar to what I have.
These levels are pretty great but we need some way to get our player inside of them, which isn’t as tricky as it would seem. First you can go into your room and enable viewports, and set your camera to whatever resolution fits your game and make sure it is set to follow your player. I have my camera resolution set to 512 by 288 and the viewport at 1024 by 576. Then inside the obj_level create event we can run a piece of code to spawn the player in the middle of the room.
instance_create_layer(room_width div 2, room_height div 2, "Instances", obj_player);
This line of code will not put the player inside of your isometric map, the player will be created very far away. If we use the tile_to_screen functions we can update our player’s position to put him directly in the middle of the isometric map.
// obj_player create event
x = tile_to_screen_x(x div TILE_W, y div TILE_W);
x = tile_to_screen_y(x div TILE_W, y div TILE_W);
Now that the player is showing up inside of our isometric level, we need a way to be able to get him to be able to collide with the walls. Unfortunately our walls are not game objects, and they do not have hit boxes.
Heartbeast did a tutorial about collisions with tiles, we can modify that script to get the player to collide with the walls. We are going to need to use the screen_to_tile functions to be able to do that.
Inside of this updated grid_place_meeting script we pass in the player’s x and y position + an hspd and vspd modification. The current X and Y positions are stored in temporary variables so that we can move back, then we add the hspd and vspd to the x and y position. After the hspd and vspd have been applied we use the screen_to_tile functions to see if the player is in a wall. Then we move the player back to their original x and y position and return if there was a collision.
// Assign argument to temp variables
var xx = argument[0];
var yy = argument[1];
// Remember current position
var xp = x;
var yp = y;
// Update position for bounding box calculations
x = xx;
y = yy;
// Check for X meeting
//var x_meeting = (global.grid[# bbox_right div CELL_WIDTH, bbox_top div CELL_HEIGHT]!= FLOOR) ||
// (global.grid[# bbox_left div CELL_WIDTH, bbox_top div CELL_HEIGHT] != FLOOR);
var x_meeting = (global.grid[# screen_to_tile_x(x + 8, y), screen_to_tile_y(x + 8, y)] != FLOOR) ||
(global.grid[# screen_to_tile_x(x, y - 4), screen_to_tile_y(x, y - 4)] != FLOOR);
// Check for Y meeting
var y_meeting = (global.grid[# screen_to_tile_x(x - 8, y), screen_to_tile_y(x - 8, y)] != FLOOR) ||
(global.grid[# screen_to_tile_x(x, y + 4), screen_to_tile_y(x, y + 4)] != FLOOR);
// Check for Center Meeting
var center_meeting = global.grid[# screen_to_tile_x(xx, yy), screen_to_tile_y(xx, yy)] != FLOOR;
// Move back
x = xp;
y = yp;
// Return true or false;
return (x_meeting || y_meeting || center_meeting);
Using this updated grid_place_meeting script we can now check if a player object has collided with the isometric walls.
There we have it, randomly generated isometric dungeons and working grid based collisions.
Until Next Time
– Steven