Project 10
It’s time to take the leap into graphical programs and user interfaces. Up until now, you’ve programmed a series of projects that used output to the terminal to interact with the code’s user. ASCII art is fun, but making a project with colorful graphics that the user can interact with is even better.
In this project, you create a simple maze exploration and treasure gathering game. You can design any maze that you like using text strings that describe the level. The text looks a little like ASCII art, in fact! But in this project, your code converts it into a graphical display with different color tiles. The user moves around in the maze and collects as many treasures as she can in the least time possible.
In this project, you’ll use Atom to create and edit your program. Unlike other projects, this program’s source code will be stored in five different files, one for each class you create. Each file will be named after the class it contains, and all the files will be stored in the same project directory. You’ll use the terminal program to run and test the code, but this time the project will create its own window in which the game is played.
$ cd development
$ mkdir project10
$ cd project10
$ gem list
You should see a number of item lists, and a version of Gosu should be listed (see Figure 10-1). If it isn’t, go back to Project 1 and follow the instructions there to install it.
Get ready to search the maze for some awesome treasure!
As your projects have grown, you may have noticed that the file you were writing your Ruby code in was getting a bit long. Experienced programmers who work on larger projects, alone or with teammates, usually break the code into separate files, each of which contains one specific bit of functionality. In this project, you’ll start to use that technique and put (mostly) one class in each source code file.
The goal of this project is for you to create a simple, 2D game in which the player moves a piece around a mazelike board, collecting treasures and reaching the exit in the least amount of time possible. Let’s figure out what objects we need for such a project:
To keep this project as simple as possible, I’ll keep it to the objects I listed here, but as you go, you’ll probably start thinking of different ways you could improve the objects you code.
This project uses a handful of classes to coordinate all the objects needed to make a simple, but very flexible game. The main starting point for your code will create all the objects needed and set up the Gosu library.
#
# Ruby For Kids Project 10: A-maze-ing
# Programmed By: Chris Haupt
# A mazelike treasure search game
#
# To run the program, use:
# ruby amazing.rb
#
require 'gosu'
Ruby doesn’t automatically know about code in the other files you create or extra Ruby gems you may have loaded. The require line tells Ruby to look for and load gosu in the standard system locations.
class Amazing < Gosu::Window
def initialize
super(640, 640)
self.caption = "Amazing"
# More code will go here
end
# Even more code will go here
end
The class’s initialize method creates a square window 640 pixels on a side. It also sets the window title (caption).
window = Amazing.new
window.show
To quit, just close the window or press Ctrl+C in the terminal.
There are four other classes besides the Amazing class you’ve already coded. Each of these classes will be placed in its own file and then connected using Ruby’s require functionality.
The Game class is responsible for setting everything up and managing all the updates and drawings that may be required from the other classes.
require 'gosu'
require_relative 'level'
require_relative 'player'
class Game
LEVEL1 = []
You’ve already seen the require function. The require_relative function is used to tell Ruby to load up and use code that is in the same directory as the current file. You’ll fill in the constant LEVEL1 later with your maze design.
def initialize(window)
@window = window
@player = Player.new(@window, 0, 0)
@level = Level.new(@window, @player, LEVEL1)
@font = Gosu::Font.new(32)
@time_start = Time.now.to_i
end
This method prepares a number of instance variables based on classes you have yet to write. The @font variable assignment looks odd — it’s creating a new object using the Gosu library. Font objects are how Gosu draws text. You’ll use that for some of the user interface (UI) later on. The @time_start instance variable is using Ruby’s Time class to get the current time and convert it into an integer (the to_i method). That number represents the current time in seconds since the beginning of computer time!
def button_down(id)
end
def update
end
def draw
end
end
These methods are standard functions when using Gosu. The button_down method is used to detect if the user has pressed a button on her keyboard. You’ll use update to make changes to the game’s data (including updating tiles based on user input). The draw method will be used to tell everything to display itself.
The Level class is responsible for the game board and its pieces. The class will draw the maze based on a textual description you pass in.
require 'gosu'
require_relative 'tile'
require_relative 'player'
class Level
def initialize(window, player, level_data)
@window = window
@tiles = []
@player = player
@level_data = level_data
@total_rows = 0
@total_columns = 0
@exit_reached = false
if @level_data
@total_rows = @level_data.length
if @total_rows > 0
@total_columns = @level_data.first.length
end
setup_level
end
end
Most of the variable setup should be self-explanatory (even if you don’t know what the variables are used for yet). The last part is a little complicated. It’s checking to see if any level data has been supplied and, if it has, calculates the total number of rows and columns for the data before setting up the board.
The playing board for the A-maze-ing project is a grid. Think of it like a chessboard or piece of imaginary graph paper. You’ll fill in each square of the grid with a wall, an empty space, or your other playing pieces (entrance, exit, treasure, and player). The layout is a little different from graphing you might have done in math class. The rows are the y-axis and run down vertically on your imaginary graph paper. The first row is numbered zero (0) and gets larger as you move down the paper. The columns of the grid run horizontally across the imaginary graph paper. This is your x-axis, and it, too, starts counting at zero (0) and increases as you move to the right. This coordinate system is how Gosu works in general (see Figure 10-3).
def setup_level
end
def button_down(id)
end
def update
end
def draw
end
end
The Tile class represents a visible piece on the playing board (level). It will know how to draw itself based on its type. In this project, you use a simple code to represent the type of the tile (for example, wall, empty, exit, treasure, and so on).
require 'gosu'
class Tile
PLAYER_TYPE = 'P'
START_TYPE = 'S'
EXIT_TYPE = 'E'
TREASURE_TYPE = 'T'
EMPTY_TYPE = '.'
WIDTH = 32
HEIGHT = 32
In your level design, you’ll use these symbols to place objects on a map. Any unrecognized symbol will be treated as a wall.
attr_reader :row, :column, :type
def initialize(window, column, row, type)
@@colors ||= {red: Gosu::Color.argb(0xaaff0000),
green: Gosu::Color.argb(0xaa00ff00),
gold: Gosu::Color.argb(0xaaffff00),
blue: Gosu::Color.argb(0xaa0000ff)}
@@font ||= Gosu::Font.new(24)
@@window ||= window
@row = row
@column = column
@type = type
@hidden = false
end
Not only does this method set up some basic instance variables needed for any individual class (like @row and @column), but it also creates some shared class variables.
The symbol ||= is made up of two vertical bars and one equal sign. The vertical bars are sometimes called pipes.
Ruby’s class variables start with two at signs (@@) and are shared by all instances of a class. In this program, you need only one hash of colors for looking up appearance. Likewise, the font and window targeted for drawing can be shared across all tile objects. Using class variables is a convenience and saves a little memory. Perhaps the most complicated variable here is @@colors. The symbol ||= means that if the variable doesn’t already have a value, set it up; otherwise, don’t do anything. The @@colors variable is being assigned a Ruby hash where the keys are names of colors, and the values are using Gosu to define a color using a hexadecimal code. You can place numbers and letters A through F in that value to change the color.
def draw
end
end
The Player class is a specialized kind of tile object. You could break out many different special tiles, but I want to show you one that you’ll use to track the user’s location and score.
require 'gosu'
require_relative 'tile'
class Player < Tile
Because player is a child class of Tile, you’ll use the subclass syntax that you’ve seen before.
attr_reader :score
def initialize(window, column, row)
super(window, column, row, Tile::PLAYER_TYPE)
@score = 0
end
end
Mostly this just calls its parent class using the super keyword. You pass the parent class the tile type (using the appropriate constant you set up earlier), and then set the starting score to zero.
For this project, you’ll work top-down to get a feel for how the Gosu library’s game loop works. The Amazing class kicks things off, so start there.
require_relative 'game'
self.caption = "Amazing"
@game = Game.new(self)
def update
@game.update
end
def draw
@game.draw
end
def button_down(id)
@game.button_down(id)
end
The Game class will contain the data that describes the level and a variety of methods to help out with the game interface.
LEVEL1 = [
"+------------------+",
"|S.|….T……….|",
"|..|.--------..|++.|",
"|..|.|….|T|..|T..|",
"|.+|.|.E|.|….|…|",
"|..|.|---.|.|--|…|",
"|..|.|….|.|……|",
"|+.|.|……|..|-|.|",
"|..|.|-----.|..|+|.|",
"|..|T…….|..|+|.|",
"|.++--------+..|+|.|",
"|.+….+++…..|+|.|",
"|…++…..+++.|+|.|",
"|---------------+..|",
"|T+|……|…..|.||",
"|..|..|……+T.|.||",
"|+…+|---------+..|",
"|..|………….+.|",
"|T+|..++++++++++…|",
"+------------------+"
]
The critical symbols here are the period (.) for blank spaces, S for where the player starts, E for the exit from the maze, and T for treasure markers. You can use any other symbol you like for the walls. I did a little ASCII art here using |, -, and +.
You can change this map however you like, but remember that there needs to be 20 strings of 20 characters! There also needs to be a comma at the end of each string in the array except for the last one.
def button_down(id)
@level.button_down(id)
end
def update
@level.update
if [email protected]_over?
@time_now = Time.now.to_i
end
end
def draw
@level.draw
draw_hud
end
Mostly this just calls down to the level object to take care of things. In the update method, you’ll also keep track of the current time until the player reaches the exit of the maze. You’ll use the elapsed time in the heads-up display (HUD), which displays the user interface for important game information. In Amazing, the HUD will contain the current score and the clock.
def draw_hud
if @level.level_over?
@font.draw("GAME OVER!", 170, 150, 10, 2, 2)
@font.draw("Collected #{@player.score} treasure in #{time_in_seconds} seconds!",
110, 300, 10)
else
@font.draw("Time: #{time_in_seconds}", 4, 2, 10)
@font.draw("Score: #{@player.score}", 510, 2, 10)
end
end
This method changes what it draws depending on whether the level is over (the player reached the exit). If the game is still being played, the method uses Gosu’s font draw method to draw text on the upper corners of the screen. If the level is over, the method displays a Game Over message and the final score.
The first three number arguments of the @font.draw method calls are the x-, y-, and z-axis locations (no, this game isn’t 3D, but the z-axis is used for determining how items stack up when drawn). The other two numbers used in the Game Over message are used to scale up the size of the message. In this case, the text will be twice as high and twice as wide.
def time_in_seconds
@time_now - @time_start
end
The Level class is the workhorse of the game and manages all the objects needed to display the playing board.
def setup_level
@level_data.each_with_index do |row_data, row|
column = 0
row_data.each_char do |cell_type|
tile = Tile.new(@window, column, row, cell_type)
# Change behavior depending on cell_type
if tile.is_start?
@player.move_to(column, row)
end
@tiles.push(tile)
column += 1
end
end
end
This code uses a couple of new methods, but the concepts will be familiar. The each_with_index method is a looping method like the plain each method you’ve used before. Besides passing the next object to the block of code that follows, it also passes the index number of the object (its position within the array). You need to know what row number you’re on, and this is a handy way to get that info.
Inside of the outer loop, you need to also track the column number (remember the graph paper metaphor I used earlier?) as you look at each row’s string.
Once again, you use the each_char method of the string to loop through the characters that make up that row. Each specific character represents one of the types of tiles you want to build.
After creating the tile, you check to see if it’s the starting location for the player. If it is, you get the tile’s coordinates and move the player object to them.
Finally, you add the tile to the @tiles array using the array push method. Then you add one to the column count and start on the next character in the string.
def button_down(id)
if level_over?
return
end
column_delta = 0
row_delta = 0
First, check to see if the level is actually over. If the player reached the exit of the maze, you’ll ignore any other moves since the game is done. If the level isn’t over, you’ll calculate the direction of the movement. Setting the variables to zero means “no movement in that direction.”
if id == Gosu::KbLeft
column_delta = -1
elsif id == Gosu::KbRight
column_delta = 1
end
if id == Gosu::KbUp
row_delta = -1
elsif id == Gosu::KbDown
row_delta = 1
end
If the player pressed one of the arrow keys on her keyboard, Gosu will pass your method an ID of that button. You use constants provided by Gosu to see which button was pressed. The numbers for the movement are based on a bit of math. If the player wants to move left, the column she wants to move to is one less than the current one, so you use –1. If the player wants to move right, it’s one more (refer to Figure 10-2 if you’re still uncertain about the coordinates used in the project).
The same technique is used for moving up or down a row in the maze.
Delta is a word that programmers use to mean a change in something. Here I use it to mean a change in columns or rows.
if move_valid?(@player, column_delta, row_delta)
@player.move_by(column_delta, row_delta)
tile = get_tile(@player.column, @player.row)
if tile.is_exit?
@exit_reached = true
tile.hide!
else
@player.pick_up(tile)
end
end
end
Note that if the player moves on to the exit tile, you remember that fact in an instance variable (and hide the exit tile so it looks better).
def get_tile(column, row)
if column < 0 || column >= @total_columns || row < 0 || row >= @total_rows
nil
else
@tiles[row * @total_columns + column]
end
end
The condition has logic that checks to see if the requested coordinates are outside the grid. If the request isn’t correct, the method just returns nil. If the request is okay, then it calculates which tile to grab out of the @tiles array. The math is a little funky, but it’s what is required to find an item in a single array that is holding a grid like yours.
def move_valid?(player, column_delta, row_delta)
destination = get_tile(player.column + column_delta, player.row + row_delta)
if destination && destination.tile_can_be_entered?
true
else
false
end
end
This method calculates where the player wants to move by adding her move’s changes (deltas) to her current position and then using a helper method from the tile object to see if it is somewhere that can be moved into.
def level_over?
@exit_reached
end
def draw
@tiles.each do |tile|
tile.draw
end
@player.draw
end
The Tile class mostly just knows where it is located and how to draw itself. You’ll provide a number of helpers to also figure out what kind of tile it is.
def draw
if tile_is_drawn? && !hidden?
x1 = @column * WIDTH
y1 = @row * HEIGHT
x2 = x1 + WIDTH
y2 = y1
x3 = x2
y3 = y2 + HEIGHT
x4 = x1
y4 = y3
c = color
@@window.draw_quad(x1, y1, c, x2, y2, c, x3, y3, c, x4, y4, c, 2)
x_center = x1 + (WIDTH / 2)
x_text = x_center - @@font.text_width("#{@type}") / 2
y_text = y1 + 4
@@font.draw("#{@type}", x_text, y_text, 1)
end
end
This looks complicated, but it’s almost entirely code used to draw a square with text in the middle of it. First, it checks whether it should even be drawn. There are some tiles, like the empty tile type, that should be blank. Other tiles may be hidden, so the code skips those, too.
Otherwise, the Gosu library method draw_quad is used to render (draw or display) a rectangle. You need to give it the coordinates for each corner of the shape. x1 and y1 are the coordinates for the upper-left corner of the square, and the rest of the variables work their way around clockwise.
The text coordinate variables try to figure out the center of the tile and draw the letter used for its type.
Watch all the punctuation on this method — there are a lot of symbols, and it’s easy to make a typo. If you get errors later when testing the code, check that your code exactly matches.
def color
if is_player?
@@colors[:red]
elsif is_exit?
@@colors[:green]
elsif is_treasure?
@@colors[:gold]
else
@@colors[:blue]
end
end
This is just a big condition statement to figure out which color to pick from the @@colors class variable’s hash structure.
def move_to(column, row)
@column = column
@row = row
end
def move_by(column_delta, row_delta)
move_to(@column + column_delta, @row + row_delta)
end
The first method sets the tile’s instance variables to the exact location provided. The latter method does the calculation of where the tile should move based on the delta numbers.
def is_treasure?
@type == TREASURE_TYPE
end
def is_start?
@type == START_TYPE
end
def is_exit?
@type == EXIT_TYPE
end
def is_player?
@type == PLAYER_TYPE
end
def is_empty?
@type == EMPTY_TYPE || @type == ' '
end
def hidden?
@hidden
end
def hide!
@hidden = true
end
def make_empty
@type = EMPTY_TYPE
end
def tile_is_drawn?
!is_empty? && !is_start?
end
def tile_can_be_entered?
is_empty? || is_start? || is_treasure? || is_exit?
end
In many ways, these represent the “rules” of the game and how it allows movement and determines what to draw.
The Player class is a special kind of tile, so it can use all the code you just wrote for tiles to display itself. Player objects also need to track a score and figure out if they can pick up another tile like a treasure.
def pick_up(tile)
if tile.is_treasure?
@score += 1
tile.make_empty
end
end
If it’s a treasure tile, the player’s score will be updated and the old treasure tile will be cleared out.
When you get the hang of even the most basic features of Gosu, your imagination is the limit for the kinds of games and graphical programs you may create. The Ruby community has all kinds of free and open-source gems for pretty much any kind of coding need. Gosu is a great example of the kinds of code people create for the benefit of all.
There are many things you can try with the A-maze-ing code. Give a few a try:
13.59.9.236