DEV Community

loading...
Cover image for Ghost Trap: My TeenyTiny DragonRuby MiniGameJam Entry

Ghost Trap: My TeenyTiny DragonRuby MiniGameJam Entry

robotspacefish profile image Jess Updated on ・7 min read

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.


ghost dog

Ghostcatcher Pixel

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.


Gameplay screenshot

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)

pic
Editor guide