6. Did you get it?

Here is a final snapshot of our project. As a bonus, I’ve made three additional enhancements:

  • You can pause the game with the ‘p’ key. Press ‘p’ again to continue.
  • I added some sound effects using NSSound.
  • I fixed the annoying “startup deaths” that occur when a rock is randomly placed too close to the ship at startup.
RubyRocks.rb [ruby]
#
#  RubyRocks.rb
#
#  Created by Tim Burks on 2/18/06.
#  Copyright (c) 2006 Neon Design Technology, Inc. Some rights reserved.
#  This sample code is licensed the same license as RubyCocoa.
#  Find more information about this file online at http://www.rubycocoa.com/ruby-rocks
#
NUMBER_OF_ROCKS = 10
MISSILE_SPEED   = 10
MISSILE_LIFE    = 50
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
KEY_P           = 35

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
    @paused = false
    @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)))}
    @rocks.delete_if{|rock| rock.collidesWith_?(@ship)}
    @missiles = []
    @sounds = {
      :shipDestroyed => OSX::NSSound.soundNamed(:Submarine),
      :rockDestroyed => OSX::NSSound.soundNamed(:Bottle),
      :shoot => OSX::NSSound.soundNamed(:Pop)
    }
  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)
    return if @paused
    @rocks.each {|rock| rock.moveWithBounds_(@bounds)}
    @ship.moveWithBounds_(@bounds) if @ship
    @missiles.each {|missile| missile.moveWithBounds_(@bounds)}
    @rocks.each {|rock|
      @missiles.each {|missile|
        if missile.collidesWith_?(rock)
          missile.ttl = rock.ttl = 0
          @sounds[:rockDestroyed].play
        end
      }
      if @ship and @ship.collidesWith_?(rock)
        @ship.ttl = rock.ttl = 0
        @sounds[:shipDestroyed].play
      end
    }

    @ship = nil if @ship and @ship.ttl == 0
    @rocks.delete_if {|rock| rock.ttl == 0}
    @missiles.delete_if {|missile| missile.ttl == 0}
  end

  def keyDown(code)
    case code
    when KEY_SPACE:
      if @ship
        @missiles << @ship.shoot
        @sounds[:shoot].play
      end
    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
    when KEY_P:
      @paused = ! @paused
    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, :ttl

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

  def moveWithBounds_(bounds)
    @ttl -= 1 if @ttl > 0
    @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

  def collidesWith_?(sprite)
    dx = @position.x - sprite.position.x
    dy = @position.y - sprite.position.y
    r = @radius + sprite.radius
    return false if dx > r or -dx > r or dy > r or -dy > r
    dx*dx + dy*dy < r*r
  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
    @ttl = MISSILE_LIFE
    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

You can download the complete Xcode project here. It’s less than 10kb.

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

Comments (4) post
  1. dave@cym.co.uk Mon Aug 21 16:15:35 +0000 2006

    Hi, I’m quite new to Ruby and RubyCocoa, but grey haired (I note your self description) with programming years. I downloaded your Ruby Rocks code to test for a problem I keep encountering which you mention in your text. The NSView – super warning. I am using the latest verion of rubyCocoa (the universal one can’t remember the version number 4.3—d3(?) and I am still getting the warning with my code and your “Rocks” demo. Can you cast any light? Enjoyed reading through the stuff on your site. The best Ruby Tutorial/info I have found to date. Thanks Dave Simpson

  2. Tim Burks Tue Aug 22 20:15:25 +0000 2006

    Hi Dave, I believe that you’ll need to build and install RubyCocoa from the CVS sources to get this fix.  It’s not difficult; you can find instructions in my introductory article.  Thanks for your comments!

  3. Martin Wed Dec 26 18:42:47 +0000 2007
    A very good tutorial indeed. A bit embarrassing though that I spent more time playing the game than I did on the tutorial…

    The game gets even more enjoyable if you exchange

    
       @rocks.delete_if {|rock| rock.ttl == 0}
    
    to
    
        @rocks, deleted_rocks = @rocks.partition {|rock| rock.ttl != 0}
        deleted_rocks.each {|old_rock|
          3.times {
              new_rock = Rock.alloc.initWithPosition_(OSX::NSPoint.new(old_rock.position.x, old_rock.position.y))
              new_rock.radius = old_rock.radius / 2        
              @rocks << new_rock
          } if old_rock.radius > 5
        }
    
    in Game.tick
  4. Martin Wed Dec 26 18:45:00 +0000 2007

    Whoops, I just realized that there was yet another chapter, sorry for the spoiler…