Tag Archives: Gamemaker Studio

Randomly Generated 3d Dungeons in Gamemaker Studio 1.4

While working on new projects, or little experiments / prototypes, I will post footage on my Youtube Channel. Somebody commented on one of these experiments (that I have since abandoned) that they wanted me explain how I created it.

This prototype was a 3d dungeon crawler with randomly generated levels. I had success with getting all of the features working, I ended up giving up on the project because I couldn’t make the game any fun to play. Perhaps somebody else can do something with this concept.

This tutorial is built upon the 3d Dungeon Tutorial that Heartbeast did on his YouTube channel. So you will need to have finished that tutorial as a prerequisite to starting this one. Also, this tutorial will only work with Gamemaker Studio 1.4 as the required functions (the d3d functions) were removed from Gamemaker Studio 2.

This is going to be our starting point

First, I want to get all of these events consolidated. Ben’s tutorial has an event for each key stroke, we can put all of this into a single script named move_player(). The script will have 4 variables to listen for the keystrokes and we will copy the existing code from the key-press events into matching if statements inside of this script.

/// Player Input

// Get Keyboard Input
var up = keyboard_check(vk_up);
var down = keyboard_check(vk_down);
var left = keyboard_check(vk_left);
var right = keyboard_check(vk_right);

if (up)
{
    // Move Forward
    
    var vect_x = lengthdir_x(32, target_dir);
    var vect_y = lengthdir_y(32, target_dir);
    
    // Move Forward
    if (!place_meeting(target_x+vect_x, target_y+vect_y, obj_wall))
    {
        target_x += vect_x;
        target_y += vect_y;
    }
}
if (down)
{
    // Move Backwards
    
    var vect_x = lengthdir_x(32, target_dir);
    var vect_y = lengthdir_y(32, target_dir);
    
    // Move Forward
    if (!place_meeting(target_x-vect_x, target_y-vect_y, obj_wall))
    {
        target_x -= vect_x;
        target_y -= vect_y;
    }
}
if (left)
{
    // Turn Left
    target_dir += 90;
}
if (right)
{
    // Turn Right
    target_dir -= 90;
}

To execute this script, you will need to create a step event for the player object and call the move_player(); script. If you have done this your game should run ALMOST exactly the same as it did before; but, because we are using the keyboard_check functions, these keystrokes will validate constantly causing your turning and moving to be VERY fast. To resolve this we will create turn and movement speed variables inside of the player’s create event to slow this down. With these variables set, we can apply them to our move_player script.

// Set Movement and Turn Speeds
mspeed = 2;
tspeed = 2;

Inside of the move forward and move backwards if blocks, we are moving 32 pixels (1 Tile) at a time, so we can update this to our mspeed variable. Inside of the turning if blocks, we are updating our direction by 90 degrees, change the value with our tspeed. With these simple changes we should have smooth movement.

Smooth as butter

Next we can start to build our enemy. I find it is easier to test enemy objects and their AI in the room that is hand crafted instead of randomly generated. To start we need some sort of enemy sprite that is 32 by 32 pixels. This is because our rooms are 32 pixels tall (and because we need all of our sprites to be a power of 2). You can go to Open Game Art and find any number of sprites that are free to use. When you add the enemy sprite to Gamemaker make sure you change its origin to be the center of the sprite so that it stands properly in the room.

With our sprite selected we can make a new object for our enemy. The enemy is going to need a bunch of the same variables that the player has, so we can make a create event to initialize variables like the target_x, target_y, dir, target_dir, mspeed.

/// Enemy Create Event
enemy_sprite = spr_enemy;
target_x = x;
target_y = y;
dir = 0;
target_dir = 0;
agro = 75;
mspeed = 0.5;

Currently the enemy doesn’t draw itself in the 3d space. If you were to put one of these enemies into the room and run the game you will not see anything yet. To get the enemy drawing itself we need to use a bunch of the d3d functions inside of the enemy’s draw event to get it drawing itself in the 3d perspective.

/// Enemy Draw Event
d3d_transform_set_identity();
d3d_transform_add_rotation_x(90);
d3d_transform_add_translation(x, y, 16)
draw_set_alpha_test(1)
draw_sprite_ext(spr_enemy, 0, 0, 0, 1, 1, 0, c_white, 1);
draw_set_alpha_test(false);
d3d_transform_set_identity();

Here is what we are doing with all of these lines of code.

  • d3d_transform_set_identity needs to be run at the start of the event to render it and render any changes to the object.
  • d3d_transform_add_rotation_x rotates the sprite so that is is standing up right, otherwise it will be drawn horizontally and look like a rug.
  • d3d_transform_add_translation will move the sprite up by 16 pixels so that it is not clipping through the floor.
  • draw_set_alpha_test(1) will have the sprite not draw any alpha pixels, otherwise the alpha will be drawn as white instead of being transparent.
  • After the sprite has been drawn we need to reset the alpha_test back to its default value, and run transform_set_identity again.
A spooky ghost

As you can see there is a little bit of a problem with this. Why is the enemy so flat and the walls are not? The walls are being drawn using d3d_draw_block, which essentially draws a cube with the same sprite on all 6 sides. We can’t do that with our enemy, it would just look silly. So instead we are drawing the 2 dimensional sprite in a 3d space.

Drawing a 2d sprite in a 3d world is sort of like putting a picture frame in a room, it will be incredibly thin when you look at it from the side, and you will only see the actual picture when you are looking at it straight on. A solution is to rotate the sprite on its z axis so that it is always facing the direction of the player. This can be achieved by adding the following line of code right after we rotate the sprite on its X axis.

d3d_transform_add_rotation_z(point_direction(x, y, o_player.x, obj_player.y)+90);

Now the enemy object is always going to be facing the player. This is a similar method to how games like Wolf3d, Spear of Destiny and Doom would draw their objects in 3d space.

Its creepy when its always looking at you

The last thing we need to do with the enemy is add a bit of AI so that it will move towards the player when the player enters its agro range. Don’t feel intimidated because this is a 3d game, the logic for this is still 2 dimensional logic.

To get this to work, we need to check the distance between the enemy and the player, if it is less than the agro range we update the position of the enemy by moving the enemy in the player’s direction by the movement speed.

// Set VectX and VectY
var VectX = 0;
var VectY = 0;

// Get Player Position
var px = o_player.x;
var py = o_player.y;

// Check Distance to Player
var distance_to_player = point_distance(x, y, px, py);

// Check if player is in agro range
if (distance_to_player <= agro)
{
    target_dir = point_direction(x, y, o_player.x, o_player.y);
    VectX = lengthdir_x(mspeed, target_dir);
    VectY = lengthdir_y(mspeed, target_dir);
}

// Update Position
target_x += VectX;
target_y += VectY;

// Move Object
dir = lerp(dir, target_dir, .1);
x = lerp(x, target_x, .1);
y = lerp(y, target_y, .1);
Moving straight into my nightmares

Now that our enemy has a very basic AI, we can build the randomly generated levels. The levels generation will be handled by a new object. The logic and code is going to be nearly identical to the random walk from the Isometric Tutorial.

Start by setting the width and height of the room, then get the number of tiles in the room by dividing that by the Cell width and cell height (32 in this case). Use those values to create the DS_GRID, fill the DS_Grid with void.

// Define constants
cell_height = 32;
cell_width = 32;
FLOOR = -5
WALL = -6
VOID = -7

// Set room size
room_width = 1024;
room_height = 768;

// Set the grid width and height
var width = room_width div cell_width;
var height = room_height div cell_height;

// Create the grid
grid = ds_grid_create(width, height);

// Fill grid with void
ds_grid_set_region(grid, 0, 0, width-1, height-1, VOID);

// Randomize
randomize();

Create the controller vector, set it in the middle of the ds_grid, give it the odds to change direction and run a loop to carve out the floor.

// Create the controller in the centre of the grid
var cx = width div 2;
var cy = height div 2;

// Give the controller a random direction
var cdir = irandom(3);
first_move = cdir;

// Place floor at controller position
grid[# cx, cy] = FLOOR;

// The odds of moving direction
var odds = 1;

// Create the level with 400 steps
repeat(400)
{
    // Move the controller
    var xdir = lengthdir_x(1, cdir*90);
    var ydir = lengthdir_y(1, cdir*90);
    cx += xdir;
    cy += ydir;

    // Clamp the controller to the grid
    cx = clamp(cx, 1, width -2);
    cy = clamp(cy, 1, height -2);
    
    // Place floor at controller position
    grid[# cx, cy] = FLOOR;
    
    // Randomize the direction of the controller
    if (irandom(odds) == odds)
        cdir = irandom(3);
}

After the floor tiles have been assigned you need to run another loop to assign walls, and another after that to spawn the walls

// Create the Walls
for (var yy = 1; yy < height -1; yy++)
{
    for (var xx = 1; xx < width -1; xx++)
    {
        if (grid[# xx, yy] == FLOOR)
        {
            // Assign walls
            if (grid[# xx+1, yy] != FLOOR) grid[# xx+1, yy] = WALL;
            if (grid[# xx-1, yy] != FLOOR) grid[# xx-1, yy] = WALL;
            if (grid[# xx, yy+1] != FLOOR) grid[# xx, yy+1] = WALL;
            if (grid[# xx, yy-1] != FLOOR) grid[# xx, yy-1] = WALL;
        }
    }
}

// Draw the level (Spawn Game Objects)
for (var yy = 0; yy < height; yy++)
{
    for (var xx = 0; xx < width; xx++)
    {
        // Spawn Walls
        if (grid[# xx, yy] == WALL)
            instance_create(xx * cell_width, yy * cell_height, obj_wall);
    }
}

Finally you need to spawn the player object and the ceiling and floor object.

instance_create(room_width div 2, room_height div 2, obj_ceiling_and_floor);
instance_create((room_width div 2) + 16, (room_height div 2) + 16, obj_player);

If you create a new room, add this object to it and then run the new room, you should get a randomly generated 3d level. The code doesn’t spawn enemies yet so it will be completely empty of enemies.

Different every time

To spawn the enemies into the level randomly we are going to create a shuffle bag. The shuffle bag is going to use a DS_LIST. We can initialize the DS_LIST right after we initialize the DS_GRID. We also need a counter for our floors and the number of enemies that we want to spawn.

// Create the enemy bag
enemy_bag = ds_list_create();
var floortiles = 0;
var enemycount = 20;

We need to have the exact amount of floor tiles to know how many items to add into our bag. Thankfully we are already keeping track of where our floor tiles are. When we assign the walls, we have an if statement that checks if the current tile is a floor. Inside of that if statement we just need to run floortiles++.

Now that we have the number of floor tiles in the room we add enemy flags and false flags into the bag. The total number of flags in the bag needs to equal the number of floor tiles in the map. We add these flags with a for loop. After everything has been added to the bag, we shuffle it.

// Add enemies into the enemy bag
for (var i = 0; i < floortiles; i++)
{
    if (i<= enemycount)
        ds_list_add(enemy_bag, true);
    else
        ds_list_add(enemy_bag, false);
}

// Shuffile Enemy Locations
ds_list_shuffle(enemy_bag);

Now while we are spawning the wall objects into the room we can check if our for loop is on a floor and check if our bag has an enemy inside of it or not. After you check the bag you need to delete the top item from the bag.

// Spawn enemies
else if (grid[# xx, yy] == FLOOR)
{
    if (enemy_bag[| 0])
        instance_create (xx * cell_width, yy * cell_height, obj_enemy);
    ds_list_delete(enemy_bag, 0);
}
Multiplying like rabbits

With that we have a randomly generated 3d dungeon with enemies spawning that will chase the player when he gets too close. Currently the enemies ignore collisions with the walls. You can add collisions on the enemies by using the place_meeting if statement from the player’s move code.

My minimap tutorial works with this system because we are using a ds_grid to generate the levels, you can try to add that system here as an extra challenge to yourself.

Until Next Time
– Steven

Making a randomly generated isometric dungeon in Gamemaker Studio

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.)

Sprites and Tileset has been created

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.

Drunken Walk with walls

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.

Randomly Generated Isometric Levels

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.

Working grid collisions in an isometric map

There we have it, randomly generated isometric dungeons and working grid based collisions.

Until Next Time
– Steven