A few weeks ago I wrote a post about the Dragonruby game engine. I mentioned that there was a month-long game jam to make a 20 second game. This post is about my entry: Ghost Trap.
Try it out on itch. If the html version is a little wonky you can download your OS specific version. I tried it on 3 different computers and it was fine on 2 of them, but the older Windows machine could not handle it.
When I was a kid I really liked the Ghostbusters game for the Sega Master System. It's not a great game, but I liked stopping the ghosts from entering buildings, driving and catching the ghosts, and trapping the ghosts at the homes they were haunting. I decided I would make a game that was similar to trapping the ghosts.
In the Sega game, you get to your destination and you have 2 ghostbusters to move. You move one where you want him, then use the other to guide the ghosts between the two of them toward the trap.
I didn't want to make a clone, so I decided to make it so a single "ghostcatcher" catches ghosts in their beam, which get sucked into their backpack. The backpack can only hold 10 ghosts, so the ghostcatcher has to periodically deposit ghosts into the ghost disposal canister. The objective is to trap as many ghosts as you can in 20 seconds. You can earn a combo the more ghosts you catch on your beam at once and your beam energy depletes over time to keep the player from holding the button down the entire 20 seconds. You have to stop shooting to regenerate energy.
Oh yeah, this is a world of dogs instead of people, because why not? Truthfully I didn't want to draw a ghost that looked like a traditional emoji looking 👻 ghost, so I made a dog-ghost. Plus, this gave me an excuse to use my dog Pixel as a reference for my ghostcatcher.
Artwork
I made the art with a combination of Affinity Designer and Procreate on the iPad and Affinity Photo on the Mac. I never used the Affinity software before so I was learning as I went along. Affinity Designer is really cool and I enjoyed working with it once I started to get the hang of it, but I really miss having access to Photoshop which is something I'm quite comfortable with.
Code Organization
I broke my code down into 6 classes and a few helper methods. I think some of the code I wrote was possibly handled by Dragonruby, but since I'm still learning how it all works I didn't take advantage of all of its features.
Entity
The Entity
class is the parent for my other game object classes. It has properties for sprite dimensions and attributes, collision, and rendering.
# an excerpt of the Entity class
class Entity
attr_accessor :x, :y, :w, :h, :sprite_path, :flip, :alpha
def initialize(x, y, w, h, sprite_path, flip)
@w = w
@h = h
@x = x
@y = y
@sprite_path = sprite_path
@flip = flip
@alpha = 255
end
def rect
[x, y, w, h]
end
def render
[
x, y, w, h, sprite_path,
0, # ANGLE
self.alpha, # ALPHA
255, # RED SATURATION
255, # GREEN SATURATION
255, # BLUE SATURATION
0, # TILE X
0, # TILE Y
self.w, # TILE W
self.h, # TILE H
self.flip, # FLIP HORIZONTALLY
false # FLIP VERTICALLY
]
end
def is_colliding_with?(obj)
self.rect.intersect_rect?(obj.rect)
end
#... etcc
end
I made the rect
method to pass to the built-in intersect_rect
to check for collision, but I believe it might be something I could have handled with Dragonruby natively. That's something I have to research further.
Player
The Player
class is a subclass of Entity
and handles all the player specific stuff like moving, shooting, catching ghosts, etc.
# an excerpt of the Player class
class Player < Entity
attr_accessor :total_ghosts_held, :backpack_limit, :beam, :is_shooting, :ghosts_on_beam, :beam_power, :beam_cooldown, :speed, :is_walking, :sprite_frame, :shoot_sound_playing
MAX_BEAM_POWER = 200
BEAM_COOLDOWN = 1
def initialize
w = 114
h = 300
super($WIDTH/2-w/2, 90, w, h, "sprites/player_green_1.png", false)
@total_ghosts_held = 0 # total ghosts in pack
@backpack_limit = 10
@beam = {x: ((self.x + self.w)/2).to_i, y: self.y+h, h: 300, w: 23}
@is_shooting = false
@ghosts_on_beam = []
@beam_power = MAX_BEAM_POWER
@beam_cooldown = 0
@speed = 6
@is_walking = false
@sprite_frame = 0
@shoot_sound_playing = false
end
def set_sprite
status_color = self.space_in_pack? ? "green" : "red"
self.sprite_path = "sprites/player_#{status_color}_#{self.sprite_frame+1}.png"
end
def calc(outputs, tick_count)
self.sprite_frame = self.is_walking ? tick_count.idiv(6).mod(2) : 0
if self.can_shoot? && self.is_shooting
play_sound(:shoot) if !shoot_sound_playing
self.shoot_sound_playing = true if !self.shoot_sound_playing
self.shoot(outputs, tick_count)
elsif self.has_ghosts_on_beam?
self.shoot_sound_playing = false
add_score(self.total_ghosts_on_beam)
self.store_ghosts_from_beam_to_pack
self.ghosts_on_beam.clear
end
self.refill_beam if !self.is_shooting && self.beam_power != MAX_BEAM_POWER
end
end
Ghost
The ghost
class handles spawning ghosts, and each instance can keep track of whether or not it has free will (if it's caught in the player's beam or can move freely) and if it's invulnerable. Ghosts flicker in and out and are invulnerable to being caught if they are transparent.
# an excerpt of the Ghost class
class Ghost < Entity
attr_accessor :is_flickering, :is_invulnerable, :has_free_will, :is_in_beam, :id
def calc(tick_count)
# use different sprite if ghost is on beam
self.sprite_path = !self.is_in_beam ? "sprites/ghost80.png" : "sprites/ghost_on_beam.png"
# keep in bounds
self.y = $HEIGHT if self.y < 0 || self.y > $HEIGHT
self.x = 0 if self.x < 0
self.x = $WIDTH - self.w if self.x + self.w > $WIDTH
self.toggle_flickering if tick_count % 60 == 0
self.flicker if self.is_flickering
self.move_freely(tick_count) if self.has_free_will
end
def stop_flickering
# play_sound(:flicker_in)
self.is_flickering = false
self.is_invulnerable = false
self.alpha = 255
end
def start_flickering
# play_sound(:flicker_out)
if self.has_free_will
self.is_flickering = true
self.is_invulnerable = true
end
end
def self.spawn
x = random_int(20, $WIDTH - 100)
y = random_int(400, $HEIGHT - 100)
Ghost.new(x, y)
end
Disposal
Disposal
is the canister where the player deposits ghosts. It keeps track of how many ghosts it contains and its open/closed status to handle which sprite should display.
# An excerpt of the Disposal class
class Disposal < Entity
attr_accessor :total_ghosts, :is_open, :timer
def initialize
super(566, 221, 99, 154, 'sprites/canister.png', false)
@is_open = false
@total_ghosts = 0
@timer = 1
end
def deposit_ghosts(total_to_add)
play_sound(:dispose)
self.is_open = true
self.total_ghosts += total_to_add
end
def calc(tick_count)
self.timer -=1 if self.is_open
self.is_open = false if timer <= 0 && tick_count % 10 == 0
end
def open_canister
self.sprite_path = OPEN[:sprite_path]
self.w = OPEN[:w]
self.h = OPEN[:h]
end
def close_canister
self.timer = 1
self.sprite_path = CLOSED[:sprite_path]
self.w = CLOSED[:w]
self.h = CLOSED[:h]
end
def render
# set sprite based on open status
self.is_open ? open_canister : close_canister
super
end
end
GhostTrap
Finally, there is the GhostTrap
class, which is the game class that handles all the game functionality and pieces everything together.
The defaults
method sets up all of the properties in state
. Normally I would use instance variables, but Dragonruby has a built-in variable called state
that is passed from tick to tick.
def defaults
state.player ||= Player.new
state.disposal ||= Disposal.new
state.ghosts ||= MAX_GHOSTS.map { Ghost.spawn }
state.mode ||= :title
state.timer ||= 20
state.score ||= 0
state.countdown ||= 3
end
All rendering (drawing to the screen) are organized within the render
method, and all updates are handled within the calc
method. User inputs are handled in process_inputs
. I broke everything down further depending on what game screen the user is on.
def render
render_title if state.mode == :title
render_instructions if state.mode == :instructions
render_credits if state.mode == :credits
render_play if state.mode == :play
render_game_over if state.mode == :game_over
end
Problems
I had the most trouble working with sound effects. I was able to get basic sound effects working, but I couldn't figure out how to get an effect that wasn't music to loop and stop when I wanted it to. I wanted a whirring effect for the player's beam but I settled for an initial effect each time the player first presses the spacebar to shoot. I did this by adding an additional variable, shoot_sound_playing
which I set to true when the player shoots, as long as it's not already true. Then I set it to false when the player stops shooting.
The Dragonruby docs recommend you go through each sample game thoroughly to really learn the engine, so I went through what I could but I didn't have time to really dig into everything. I think if I go back and look through the code when I get a chance I can probably do some refactoring and make some improvements to my game.
if self.can_shoot? && self.is_shooting
play_sound(:shoot) if !shoot_sound_playing
self.shoot_sound_playing = true if !self.shoot_sound_playing
self.shoot(outputs, tick_count)
# ... etc
You can find the full game code on my github
Side note: I know I used self
sometimes and not other times. Personally I prefer to use it because then I know exactly where a method is coming from and to me that's more readable, but Dragonruby doesn't use it and I know Ruby is all about being free of clutter. I'm still in the process of cleaning everything up and making it consistent, but I wanted to get this post out asap. :)
Top comments (0)