In this lab and homework, we will create a game with an explicit world using a logical table stored in a file and creating dynamic content in a tile map.
Ex. 1. In this lab we'll create a maze-like game where a character moves in a maze pushing pipes to create a good flow of water.
Create a project called PipeDream and a scene called main inside. Create a Node2D object called Root in the scene. Switch the scene editor to 2D.
a. Maze
Download the files
Add both files to the project.
In Project Settings - Window, set the size of the window as 1024x768, or something smaller if it doesn't fit on your screen. Also in Project Settings, Rendering, Environment, set the clear color as a dark green.
Create a TileMap object as child of the root. Click on empty next to the tile set and create a new tile set. Click on it to expand it, then set the size of the tiles to 32 x 32.
Then click on TileSet at the bottom of the screen and drag the tile image over the gray rectangle to create the atlas. The size of the tiles should be 32x32 in the atlas.
Then under Physics Layers click to add an element. In the bottom panels, click on the TileSet tab, then on the Paint button, then under Paint Properties, select Physics Layer 0, which is the collision layer. Paint over all the tiles.
In the Transform of the tile object, set the scale to 2 x 2.
Then click on the TileMap tab at the bottom of the window, and on the pencil (paint) button, and you can start creating the layout of the scene. Use one of the brick tiles for the outer layer. Create a small water area in one corner, and a dry area in another. The goal will be for the water to reach the dry area.
You can use another brick to create some inside walls and a gray pipeline connecting the water to the dry area, but leave a few wholes that the plumber will need to fill in. Place the missing pieces in orange on the board so that they can be pushed towards the wholes.
b. Player
Create a CharacterBody2D object call Player. Add the plumber image to the scene to create a Sprite2D object. Move it to the top left corner, scale it so that it fits on one cell of the maze, then add it to the Player as child. Add another child to the Player node of type CollisionShape2D and create a rectangle shape in it. Adjust the position and size of the collider to fit the plumber tight.
Then add a script to the Player. Remove the jump velocity and gravitation, as well as falling and jumping. Then duplicate the code moving it horizontally, and in the copy, replace "ui_left" and "ui_right" with "ui_up" and "ui_down", and the .x in the velocity with .y. Now the player should be able to move in all 4 directions with the arrow keys.
Save the Player object (tree) as a scene, then move it somewhere in the middle of the screen.
Add a script to the Player object (not the sprite) derived from CharacterBody2D. Remove the jump and the horizontal move based on input from the user. Basically, we just set the velocity of the object based on gravity and then call move_and_slide.
Move the bubble object towards to center of the area.
c. Movement
Let's calibrate the movement first, to make it a but smoother, and second, to snap it to the tile grid if it gets close enough.
To make the movement more smooth, change the parameter SPEED into 0.25*SPEED in the call to move_towards, in both places (for x and for y).
To snap the player's position to the grid, first, we need to find the current cell of the player. Since the tile map starts at position 0x0 on the screen, in the formulas, Tx = 0 and Ty - 0. The cell size is 64, which is equal to the size of the tiles times the scale of the map. Let's compute this first at the top. Add a reference to the tile map with DRAG-CTRL-RELEASE in the script, or add the code
@onready var tile_map = $"../TileMap"
Declare a static variable in the class called cell_size, initialized as 0, as well as two variables called col and row. Then add the following functions:
func _ready(): cell_size = tile_map.scale.x * tile_map.tile_set.tile_size.x func cell(value): return int(value/cell_size) func snap_grid(): col = cell(position.x) row = cell(position.y) if abs(position.x - col * cell_size) < 6: position.x = col * cell_size + 1 if abs(position.y - row * cell_size) < 6: position.y = row * cell_size + 1
Now, in the function physics_process, call the function snap_grid after the function move_and_slide. Also call this function inside _ready.
Declare a class variable called target_position and assign it the value of position in the function _ready after the call to snap_grid. Then after getting the direction left and right, delete the else, and replace the whole conditional with the code
if direction: col = cell(position.x + cell_size/2) target_position.x = (col + direction) * cell_size + 1 target_position.y = position.y
Make similar changes to the code for the up and down movement.
Then just before the call to move_and_slide, add the following lines:
var dir = global_position.direction_to(target_position) velocity = dir * SPEED
c. Game Manager Script
Add a script to the root object. We will create a logical table in this class. Add the following variables in the class.
var table := []
Then, we want to populate the table in the function _ready. First, we need a reference to the tile map object and other class variables. Add the following lines at the top of the class:
const empty = -1 @onready var tile_map = $TileMap @onready var tile_source_id = tile_map.get_cell_source_id(0, Vector2i(0, 0)) var atlas_size = Vector2i(7, 3) var cell_size = 0
Then in the function _ready, let's create a table of the size of the tile map, filled with the value 0. Add the following code:
var rect = tile_map.get_used_rect() var nc = rect.size.x var nr = rect.size.y table.resize(nc) for i in range(nc): table[i] = [] table[i].resize(nr) table[i].fill(-1)
Add the following two functions to the class:
func int_value(cell): return cell.x * 10 + cell.y func cell_vector(value): return Vector2i(int(value/10), value % 10)
These will allow us to store the coordinates of a cell in the atlas as a single integer value in the table. Now we can set the values in the table. Add the following to the function _ready, after the for loop (not inside it).
var cells = tile_map.get_used_cells(0) for cell in cells: var cell_data = tile_map.get_cell_atlas_coords(0, cell) table[cell.x][cell.y] = int_value(cell_data) print(table)Next, we want to keep track of which tiles are movable. Those would be the orange tinted tiles. Those have column numbers in the atlas equal to 5 or 6. Add the following function:
func movable(value): var col = cell_vector(value).x return col == 5 or col == 6
Then we need to be able to dynamically set a tile in the tile set to a given value. That will allow us to push the movable pipes around. Add the following function:
func set_cell(col, row, value): if table[col][row] != value: table[col][row] = value var cell = Vector2i(col, row) if value == empty: tile_map.erase_cell(0, cell) else: tile_map.set_cell(0, cell, tile_source_id, cell_vector(value))
To test this function, add a call in the function _ready at the end with the cell 1,1 and the value empty first to see that the cell is properly deleted, then some other value like 12 to see that the proper pipe is displayed in that cell. Once you made sure it works, comment out that function call.
d. Pushing Pipes
Still in the main script, add the following function:
func check_move_x(col, row, direction): if movable(table[col][row]): if table[col+direction][row] == empty: var val = table[col][row] set_cell(col, row, empty) set_cell(col+direction, row, val)
Now, let's go back to the Player script. Add a reference to the Root node at the top:
@onready var root = $".."
Add a call to the function above from the root object in the function _physics_process inside the conditional moving left and right, after setting the target position:
root.check_move_x(col+direction, row, direction)
The movable tiles should now be able to be moved horizontally. This will be continues in the homework.
Ex. 2. a. Vertical movement
Add a similar function to the one from Ex. 1. d. to deal with the vertical movement of tiles, and a call to it in the appropriate place in the Player script.
b. Reset Button
Add a button to the scene to reset the game, and link it to a function in the main script doing
get_tree().reload_current_scene()
This will be very useful if you get stuck.
c. Winning Condition
Add a class variable in the main script to keep count of how many pipes still need to be placed in their right place and initialize it with whatever value you have in your arena.
Then add a function check_placed(col, row, value) that will be called after a pipe was moved. In this function, check if the pipe that was moved there matches either the pipes above and below or left and right. For example, if the moved pipe is 50 (horizontal pipe) and the one to its left (col-1) is either 30, or 12, 31, 32, 50, 51, 52 then the moved pipe is matched on the left. You can check more easily if a value n is equal to either one of those by checking if n in [30, 12, 31, 32, 50, 51, 52].
Then when you move a pipe in the functions check_move, check if the moved value val was "placed" at the origin position, and if it was, increase the count of pipes to be placed.
Then check if the same value is placed at the destination position, and if it is, decrease the number of pipes to be placed.
After this, check if the number of pipes to be placed is now 0, and replace the cells in the end position of the pipes with water. Like, in the image at the top, I would replace the 4 white tiles in the bottom right corner with water.
Add a label at the top of the screen displaying the number of pipes left to place and update it every time it changes. When the number gets to 0, you can display "Completed" instead.
Take a screen shot of your running program, showing the content of the screen while the game is running, and save it as png or jpg. Create a zip file of the project folder (the screenshot can be inside). Submit both of them to Canvas, Assignments - Homework 9.