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