4. Responding to user input

Our game is going to consist of three types of moving objects: rocks, ships, and missiles. Let’s pull some commonly-useful stuff out into a base class that we’ll call Sprite.

Sprite [ruby]
class Sprite < OSX::NSObject
  attr_accessor :position, :velocity, :radius, :color

  def initWithPosition_(position)
    @position = position
    @velocity = OSX::NSPoint.new(0, 0)
    self
  end

  def moveWithBounds_(bounds)
    @position.x += @velocity.x
    @position.y += @velocity.y    
    @position.x = bounds.width if @position.x < 0
    @position.x = 0 if @position.x > bounds.width
    @position.y = bounds.height if @position.y < 0
    @position.y = 0 if @position.y > bounds.height
  end
end

The Rock class now only needs a few specifics.

Rock (refactored) [ruby]
class Rock < Sprite
  def initWithPosition_(position)
    super(position)
    @velocity = OSX::NSPoint.new(rand-0.5,rand-0.5)
    @color = OSX::NSColor.whiteColor
    @radius = 30
    self
  end

  def draw
    @color.set
    OSX::NSBezierPath.bezierPathWithOvalInRect(
        OSX::NSRect.new(@position.x-radius, @position.y-@radius, 2*@radius, 2*@radius)).stroke
  end
end

Now let’s use that Sprite class to create our ships. We add a few attributes to help us fly our ship around and handle steering and acceleration in moveWithBounds_. We’ll draw our ship as a wedge—and stretch our brains a bit to come up with the set of coordinates used by our NSBezierPath. Don’t worry if the math doesn’t make sense to you (unless you want to write more games, then you should be ready for much harder stuff than this).

Ship [ruby]
class Ship < Sprite
  attr_accessor :direction, :angle, :acceleration

  def initWithPosition_(position)
    super(position)
    @radius = 10
    @color = OSX::NSColor.redColor
    @direction = OSX::NSPoint.new(0, 1)
    @angle = @acceleration = 0
    self
  end

  def moveWithBounds_(bounds)
    super(bounds)
    if @angle != 0
        cosA = Math::cos(@angle)
        sinA = Math::sin(@angle)
        x = @direction.x * cosA - @direction.y * sinA
        y = @direction.y * cosA + @direction.x * sinA
        @direction.x, @direction.y = x, y
    end
    if @acceleration != 0
        @velocity.x += @acceleration * @direction.x
        @velocity.y += @acceleration * @direction.y
    end
  end

  def draw
    @color.set
    x0,y0 = @position.x, @position.y
    x, y  = @direction.x, @direction.y
    r = @radius
    path = OSX::NSBezierPath.bezierPath
    path.moveToPoint(OSX::NSPoint.new(x0 + r*x, y0 + r*y))
    path.lineToPoint(OSX::NSPoint.new(x0 + r * (-x +y), y0 + r * (-x -y)))
    path.lineToPoint(OSX::NSPoint.new(x0, y0))
    path.lineToPoint(OSX::NSPoint.new(x0 + r * (-x -y), y0 + r * (+x -y)))
    path.fill  
  end
end

Create a ship by adding this line to your Game’s awakeFromNib function:

New code for Game awakeFromNib [ruby]
    @ship = Ship.alloc.initWithPosition_(OSX::NSPoint.new(bounds.width/2, bounds.height/2))

Add the following line to Game draw:

New code for Game draw [ruby]
    @ship.draw if @ship

Add this to Game tick to make sure that the ship moves:

New code for Game tick [ruby]
    @ship.moveWithBounds_(@bounds) if @ship

We need a way to respond to user input events. We’ll capture them in our GameView but delegate them to the Game. Start by adding these three methods to GameView:

New methods for GameView [ruby]
  ns_overrides :acceptsFirstResponder
  def acceptsFirstResponder
    true
  end

  ns_overrides :keyDown_
  def keyDown(event)
    @game.keyDown(event.keyCode)
  end

  ns_overrides :keyUp_
  def keyUp(event)
    @game.keyUp(event.keyCode)
  end

Make sure that you define acceptsFirstResponder. If you forget it, your view won’t get any keyboard events.

Your GameView functions call these methods that should be defined in Game:

Game event handlers [ruby]
  def keyDown(code)
    case code
    when KEY_LEFT_ARROW:
        @ship.angle = TURN_ANGLE if @ship
    when KEY_RIGHT_ARROW:
        @ship.angle = -TURN_ANGLE if @ship
    when KEY_UP_ARROW:
        @ship.acceleration = ACCELERATION if @ship
    when KEY_DOWN_ARROW:
        @ship.acceleration = -ACCELERATION if @ship
    end
  end

  def keyUp(code)
    case code
    when KEY_LEFT_ARROW, KEY_RIGHT_ARROW:
        @ship.angle = 0 if @ship    
    when KEY_UP_ARROW, KEY_DOWN_ARROW:
        @ship.acceleration = 0 if @ship    
    end
  end

Here are some more helpful constants; put them at the beginning of your source file.

Helpful constants [ruby]
TURN_ANGLE = 0.2
ACCELERATION = 1
KEY_LEFT_ARROW = 123
KEY_RIGHT_ARROW = 124
KEY_DOWN_ARROW = 125
KEY_UP_ARROW = 126

Now stop and play. You should be able to fly around inside your window, happily passing through rocks and popping from one side of the screen to another. A mathematician would say that you are moving on a surface called a torus, essentially a bagel or doughnut shape. If you feel like taking a snack break now, that’s as good a reason as any.

There’s one more kind of object that we need in our game world: missiles. They are a lot like rocks, but much smaller, and we give them the same color as the ship that shoots them.

Missile [ruby]
class Missile < Sprite
  def initWithPosition_velocity_color_(position, velocity, color)
    initWithPosition_(position)
    @velocity = velocity
    @color = color
    @radius = 3
    self
  end

  def draw
    @color.set
    OSX::NSBezierPath.bezierPathWithOvalInRect(
        OSX::NSRect.new(@position.x-@radius, @position.y-@radius, 2*@radius, 2*@radius)).fill
  end
end

We’ll create missiles by calling the shoot function which we will add to our Ship class. It launches missiles from the center of our ship in the direction that the ship is pointing. Missile velocity is relative to the ship’s velocity; missiles launched from moving ships move faster than missiles launched from stationary ones.

Ship shoot [ruby]
  def shoot
    missilePosition = OSX::NSPoint.new(position.x+direction.x, position.y+direction.y)
    missileVelocity = OSX::NSPoint.new(
        MISSILE_SPEED * direction.x + velocity.x, MISSILE_SPEED * direction.y + velocity.y)
    Missile.alloc.initWithPosition_velocity_color_(missilePosition, missileVelocity, @color)
  end

At the top of your file, set MISSILE_SPEED = 10 and KEY_SPACE = 49, then add the following to your game’s keyDown function:

New code for Game keyDown [ruby]
    when KEY_SPACE:
        @missiles << @ship.shoot if @ship

Note that we’ve added another list of objects to our Game. Make sure that you initialize it in the Game awakeFromNib. Call the following from Game tick:

New code for Game tick [ruby]
    @missiles.each {|missile| missile.moveWithBounds_(@bounds)}

and be sure to draw them in Game draw:

New code for Game draw [ruby]
    @missiles.each {|missile| missile.draw}

Got it? Now play some more. You should be able to fly around as before, and when you press the space bar, missiles shoot from the front of your ship. But we don’t detect any collisions, and the missiles never disappear, so eventually your game world gets overloaded by the hundreds or thousands of missiles flying around. We’ll take care of all this in the next chapter.

Here’s another snapshot if you’re having trouble following along.

RubyRocks.rb (second snapshot) [ruby]
NUMBER_OF_ROCKS = 10
MISSILE_SPEED   = 10
TURN_ANGLE      = 0.2
ACCELERATION    = 1
KEY_SPACE	= 49
KEY_LEFT_ARROW  = 123
KEY_RIGHT_ARROW = 124
KEY_DOWN_ARROW  = 125
KEY_UP_ARROW    = 126

class GameView < OSX::NSView
  ib_outlets :game 

  def awakeFromNib
    window.setOpaque(false)
    @game.bounds = bounds
    @timer = OSX::NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
        1.0/60.0, self, :tick, nil, true)    
  end

  ns_overrides :drawRect_
  def drawRect(rect)
    @game.draw
  end
  
  ns_overrides :acceptsFirstResponder
  def acceptsFirstResponder
    true
  end

  ns_overrides :keyDown_
  def keyDown(event)
    @game.keyDown(event.keyCode)
  end

  ns_overrides :keyUp_
  def keyUp(event)
    @game.keyUp(event.keyCode)
  end
  
  def tick(timer=nil)
    @game.tick(timer)
    setNeedsDisplay(true)
  end
end

class Game < OSX::NSDocument
  attr_accessor :bounds

  ns_overrides :windowNibName
  def windowNibName
    return "Game"
  end

  def awakeFromNib
    @ship = Ship.alloc.initWithPosition_(OSX::NSPoint.new(bounds.width/2, bounds.height/2))
    @rocks = []
    NUMBER_OF_ROCKS.times {
        @rocks << Rock.alloc.initWithPosition_(OSX::NSPoint.new(rand(bounds.width), rand(bounds.height)))
    }
    @missiles = []
  end

  def draw
    OSX::NSColor.blackColor.colorWithAlphaComponent(0.9).set
    OSX::NSRectFill(@bounds)
    @rocks.each {|rock| rock.draw}
    @ship.draw if @ship
    @missiles.each {|missile| missile.draw}
  end
  
  def tick(timer)
    @rocks.each {|rock| rock.moveWithBounds_(@bounds)}
    @ship.moveWithBounds_(@bounds) if @ship
    @missiles.each {|missile| missile.moveWithBounds_(@bounds)}
  end
  
  def keyDown(code)
    case code
    when KEY_SPACE:
        @missiles << @ship.shoot  if @ship
    when KEY_LEFT_ARROW:
        @ship.angle = TURN_ANGLE if @ship
    when KEY_RIGHT_ARROW:
        @ship.angle = -TURN_ANGLE if @ship
    when KEY_UP_ARROW:
        @ship.acceleration = ACCELERATION if @ship
    when KEY_DOWN_ARROW:
        @ship.acceleration = -ACCELERATION if @ship
    end
  end

  def keyUp(code)
    case code
    when KEY_LEFT_ARROW, KEY_RIGHT_ARROW:
        @ship.angle = 0 if @ship    
    when KEY_UP_ARROW, KEY_DOWN_ARROW:
        @ship.acceleration = 0 if @ship    
    end
  end
end

class Sprite < OSX::NSObject
  attr_accessor :position, :velocity, :radius, :color

  def initWithPosition_(position)
    @position = position
    @velocity = OSX::NSPoint.new(0, 0)
    self
  end

  def moveWithBounds_(bounds)
    @position.x += @velocity.x
    @position.y += @velocity.y    
    @position.x = bounds.width if @position.x < 0
    @position.x = 0 if @position.x > bounds.width
    @position.y = bounds.height if @position.y < 0
    @position.y = 0 if @position.y > bounds.height
  end
end

class Rock < Sprite
  def initWithPosition_(position)
    super(position)
    @velocity = OSX::NSPoint.new(rand-0.5,rand-0.5)
    @color = OSX::NSColor.whiteColor
    @radius = 30
    self
  end

  def draw
    @color.set
    OSX::NSBezierPath.bezierPathWithOvalInRect(
        OSX::NSRect.new(@position.x-@radius, @position.y-@radius, 2*@radius, 2*@radius)).stroke
  end
end

class Ship < Sprite
  attr_accessor :direction, :angle, :acceleration

  def initWithPosition_(position)
    super(position)
    @radius = 10
    @color = OSX::NSColor.redColor
    @direction = OSX::NSPoint.new(0, 1)
    @angle = @acceleration = 0
    self
  end

  def moveWithBounds_(bounds)
    super(bounds)
    if @angle != 0
	cosA = Math::cos(@angle)
	sinA = Math::sin(@angle)
	x = @direction.x * cosA - @direction.y * sinA
	y = @direction.y * cosA + @direction.x * sinA
	@direction.x, @direction.y = x, y
    end
    if @acceleration != 0
        @velocity.x += @acceleration * @direction.x
        @velocity.y += @acceleration * @direction.y
    end
  end

  def draw
    @color.set
    x0,y0 = @position.x, @position.y
    x, y  = @direction.x, @direction.y
    r = @radius
    path = OSX::NSBezierPath.bezierPath
    path.moveToPoint(OSX::NSPoint.new(x0 + r*x, y0 + r*y))
    path.lineToPoint(OSX::NSPoint.new(x0 + r * (-x +y), y0 + r * (-x -y)))
    path.lineToPoint(OSX::NSPoint.new(x0, y0))
    path.lineToPoint(OSX::NSPoint.new(x0 + r * (-x -y), y0 + r * (+x -y)))
    path.fill  
  end
  
  def shoot
    missilePosition = OSX::NSPoint.new(position.x+direction.x, position.y+direction.y)
    missileVelocity = OSX::NSPoint.new(
        MISSILE_SPEED * direction.x + velocity.x, MISSILE_SPEED * direction.y + velocity.y)
    return Missile.alloc.initWithPosition_velocity_color_(missilePosition, missileVelocity, @color)
  end
end

class Missile < Sprite
  def initWithPosition_velocity_color_(position, velocity, color)
    initWithPosition_(position)
    @velocity = velocity
    @color = color
    @radius = 3
    self
  end

  def draw
    @color.set
    OSX::NSBezierPath.bezierPathWithOvalInRect(
        OSX::NSRect.new(@position.x-@radius, @position.y-@radius, 2*@radius, 2*@radius)).fill
  end
end

Did you find an error? Is something missing? Post your comment or suggestion below!

Comments (0) post