Let Them Move Wide
Mini Game Tutorial > Let Them Move

You enjoyed the tank moving around? I always like to see the things moves in my games. To make it more fun, let other characters move as well.

In this section, you learn how to:

  • implement more characters
  • implement a timer handler
  • move characters on a timer event

More Characters

There are some other characters in the TankGame world - enemies, aiming sight, and cannon balls. As you already implemented the code for drawing images in ImageObject, you can concentrate on writing code for manipulating these characters.

A Tank needs its enemies

Let's make the Enemy class that represents enemies that try to invade the tank's territory. There are two things different from the Tank class in the previous chapter. One is the move method and another is the reached? method.

As enemies have to move without key events, this method decreases a random number from @point.y (an enemy goes downward). The move method should be called from TankGameModel when a timer event occurs.

To make a game playable, TankGameModel needs to know if a player wins or loses. According to the rule of this game, a player loses when an enemy crosses the border line. So this class has the reached? method to check if an enemy crosses the line. To make it simple, reached? returns true if a center point of an enemy passes the border line (as @point represents the center point of a character).

class Enemy < ImageObject
  WIDTH = 125
  MAX_ENEMIES = 5
  
  # sets the position of each enemy according to step (0-4)
  def initialize(step, speed = 4)
    @step = step
    x = setXPosition()
    super(NSPoint.new(x, Field.height * 0.8), "Enemy")
    @speed = speed
  end

  def setXPosition()
    return (Field.width - WIDTH * (MAX_ENEMIES - 1)) / 2 + @step * WIDTH
  end 
  
  # moves down by random offset between 0 and @speed (=4 by default)
  def move()
    @point.x = setXPosition()
    diff = rand(@speed)
    @point.y -= diff
  end
  
  # returns true if the center point of an enemy reaches the boarder line
  def reached?(line)
    return true if (@point.y <= line)
    return false
  end
  
  attr_reader :point
  private :setXPosition
end

An Aiming Sight

An AimingSight object moves left and right as the tank moves. Pressing up or down key moves it up or down. The move method restricts the range of y-axis so an aiming object won't go off the view. The View part of this class does not exist since ImageObject does what all this class needs.

class AimingSight < ImageObject
  def initialize(point)
    super(point, "Aiming")
  end
  
  def move(direction)
    upperLimit = Field.height * 0.9
    lowerLimit = Field.height * 0.2
    @point.x += direction.x
    @point.y += direction.y
    @point.y = upperLimit if (@point.y >= upperLimit)
    @point.y = lowerLimit if (@point.y <= lowerLimit)
  end
end

Make a Cannon Ball

The CannonBall class represents a cannon ball. When you press the space key, it flies from the tank to the point where the aiming sight is at the launch time. When it reaches its target point, it disappears (by setting @active to false).

As this class doesn't use any image files, it is not a subclass of ImageObject even though it has similar methods. it draws a circle to represents a cannon ball instead. Cannon.move let a cannon ball fly toward its target point. Once it is launched, TankGameModel.tick calls move until it disappears.

As same as ImageObject, CannonBall is separated into a Model part and a View part. The Model part is shown below:

class CannonBall
  SIZE = 20
  def initialize(start, target)
    # It's better not use @point = start.clone; This will raise exception due to null data on RubyCocoa-0.11.1
    @point = NSPoint.new(start.x, start.y)
    @target = NSPoint.new(target.x, target.y)
    @active = true
  end

  def move()
    @point.y += @speed
    @active = false if (@point.y > @target.y)
  end

The View part of CannonBall is shown below:

class CannonBall
  def draw()
    if (@active)
      NSColor.blueColor.set
      point = NSPoint.new(@point.x - SIZE / 2, @point.y - SIZE / 2)
      rect = NSRect.new(point, NSSize.new(SIZE, SIZE))
      path = NSBezierPath.bezierPathWithOvalInRect(rect)
      path.fill
    end
  end
end

Get the Tank loaded

Now we have cannon balls and an aiming sight. Let's stick these together with Tank. Update the Tank class so it:

  • has an AimingSight object. (at initialize)
  • launches a cannon ball. (at launch)
  • moves an AimingSight object (at move)
  • waits one second for the next cannon launch. (at move and launch)

Here is the updated code.

class Tank < ImageObject
  def initialize()
    point = Field.centerizedPoint(NSSize.new(0,0))
    point.y = 120
    super(point, "Tank")

    # updated
    @aiming = AimingSight.new(NSPoint.new(point.x, Field.height / 2))
    @launchTimer = 0
  end

  def move(difference)
    prevX = @point.x
    @point.x += difference.x
    @point.x = Field.width * 0.2 if (@point.x <= Field.width * 0.2)
    @point.x = Field.width * 0.8 if (@point.x >= Field.width * 0.8)

    # updated
    difference.x = @point.x - prevX
    @aiming.move(difference)
    @launchTimer -= 1 if (@launchTimer > 0)
  end 

  # updated
  def launch()
    if (@launchTimer <= 0)
      @launchTimer = 10
      return CannonBall.new(@point, @aiming.point)
    end
  end

  attr_reader :aiming
end

Create the World of Your Game

There are pretty much drawing objects in your game. Every time you create some drawing objects, these should be stored either in this class or in other characters as TankGameModel represents the world of your game.

Let's change the code like shown below so that TankGameModel can move all the characters on some events.

class TankGameModel
  def init()
    @tank = Tank.new()
    # updated
    @enemies = []
    @cannons = []
    Enemies::MAX_ENEMIES.times {|i| @enemies << Enemy.new(i) }
  end

  # invoked on timer event (direction is set by referring to the key status)
  def moveTank(direction)
    direction.x *= 10
    direction.y *= 10
    @tank.move(direction)
  end
 
  # updated
  # invoked on timer event
  # This method moves all the characters but the Tank
  def moveCharacters()
    @borderline = 200
    @cannons.each do |cannon|
      cannon.move()
      cannon.checkCollision(@enemies)
      @cannons.delete(cannon) if (!cannon.active)
    end

    @enemies.each do |enemy|
      enemy.move()
      @enemies.delete(enemy) if (!enemy.active)
      if (enemy.reached?(@borderline))
        init()
      end
    end
    if (@enemies.size == 0)
      init()
    end
  end

  # updated
  # invoked on a space key event
  def launch()
    cannon = @tank.launch()
    @cannons << cannon if (cannon)
  end

  attr_reader :tank, :enemies, :cannons, :borderline
end

First of all, you need to initialize the world of TankGame. The init method clears all the characters, and then it makes a tank object and five enemies.

The next thing this class does is to move enemies and cannons (if any). The moveCharacters method, which is called by a timer event handler, moves Enemy objects and CannonBall objects. It also asks each Enemy abject if it reaches the border line. if the center point of an Enemy object crosses the boarder line, you are dead. At this point, let the model call init when you die just to prevent Enemy objects from going off the window.

The launch method asks a tank object to launch a Cannon object. This method is called by the key event handler in TankGamePlayingState when you press the space key.

Events Rule the World

As you added some characters into Model, its relevant State object needs to manipulate the Model object so it can move these characters. Moving these is very simple. add the following code to the tick method in TankGamePlayingState.

@model.moveCharacters()

Another thing you need to do is to let the tank launch a cannon ball when you press the space key. In this case, I chose to use EventContext. Change the enter method to receive the space key event.

 def enter()
   super()
   @model.init()

   contexts = [[NSKeyDown, KeyEvent::SPACE, proc {|event| @model.launch(); false } ],
                 [TimerEvent::Fired, TANKGAME_TIMER, proc {|id| tick(); true }]]
   contexts.each { |context| EventContext.addContext(*context) }
   @timer = TimerEvent.new(0.1, true, TANKGAME_TIMER)
 end

As State.enter already add an event context for Ctrl-Q (see State.enter in State.rb), I added an event context for space key event. The statement:

contexts = [[NSKeyDown, KeyEvent::SPACE, proc {|event| @model.launch(); false} ],
              [TimerEvent::Fired, TANKGAME_TIMER, proc {|id| tick(); true }]]

makes an Array object that represents the event context. The first event context indicates When NSKeyDown event occurs for KeyEvent::SPACE (space key is hit), call a given Ruby Proc object {|event| @model.launch(); false } with an NSEvent object (event). The proc object, then, calls @model.launch(), returning false (updating the window is not needed),

In this case, "event" that is being passed to Ruby Proc object is not used. If you need to do some complex task, you may need the content of this event object.

The next line add the event contexts to the EventContext class.

 contexts.each { |context| EventContext.addContext(*context) }

Draw the World of Your Game

It's time for drawing your game world. As TankGameModel holds all the characters, TankGameView asks TankGameModel to get these. TankGameView will ask all these characters to draw at drawRect (Remember, you must draw everything in drawRect or in the functions that are called from drawRect).

One thing you should know is that a character drawn later comes front side (it may overlaps another object that is drawn earlier). So be careful about the z-order of your game world.

Anyway, the drawRect method draws the border line, the Tank object, Enemy objects, CannonBall objects, and the AimingSight object. The updated drawRect is shown below:

 def drawRect()
   NSColor.whiteColor.set
   NSRectFill(@bounds)
   
   # Use ShadowMaker class to easily draw shadow for objects
   setShadow(NSColor.lightGrayColor, [6.0, -6.0], 3.7, 0.6)

   # draw the border line
   NSColor.blackColor.set
   path = NSBezierPath.bezierPath
   point = NSPoint.new(0, @model.deadline)
   path.moveToPoint(point)
   point.x = Field.size.width
   path.lineToPoint(point)
   path.stroke

   # draw Tank
   @model.tank.draw()
   # draw Enemies and Cannons
   (@model.enemies + @model.cannons).each {|obj| obj.draw()}
   # draw an aiming sight (This becomes the top-most object in z-order)
   @model.tank.aiming.draw()
 end

See what you have made so far

LetThemMove.jpg SIZE:800x622(?KB)

They move, the tank launches cannons. Wow, it looks like a real game. It's now way much better than the previous one..... We'll make it more playable in the next chapter.

Inside the Game Framework - Event Context

I'll tell you a little about the event context mechanism. The event context has a set of array objects. Each array object contains a pair of "a concrete event you want to receive," and "an action when you receive the event."

In TankGamePlayingState.enter, NSKeyEvent and KeyEvent::SPACE specify an event that you receive, and the Ruby Proc object { |event| @model.launch(); false } specifies an action for the event.

When an event occurs, an Event object asks EventContext class if there is an event context that matches the event. If matched, the Event object invokes a Ruby proc that is designated in the matched event context. If there is no matched context, the Event object tries to invoke the default listener method (e.g. keyDown for KeyDown event, or tick for a TimerEvent).

This mechanism brings switch or if - else statements in an event handler into deep inside the framework. Actually you can do the same thing that is shown in TankGamePlayingState.enter() by the following code.

def keyDown(event)
  if (event.keycode == KeyEvent::SPACE)
    @model.launch()
  end
  return false
end

See Event Handling for farther information about event handling.

What we've created so far

LetThemMove.tar.gz SIZE:x(?KB) is the file that we made so far. Here are the steps to run the game.

  1. Untar the tar.gz file at the project folder
  2. Add a group named "TankGame" to the "Resources" group at the left pane of the Xcode project window. (if not exists)
  3. Right click the group and select "Add" -> "Existing Files" to add all the tiff files
  4. Run the application by pressing the "Build & Run" button

Let It Move<Prev|Top:Mini Game Tutorial|Next>Let It Blow

Leave a comment

Begin the comment with //pukiwiki if you want to write a comment in PukiWiki format.

You must be logged in to post a comment.