2
\$\begingroup\$

After some feedback on my last Ruby Code I decided to start another one with two big concerns in mind:

  • Trying to adequate my coding to the OOP paradigm
  • Trying to follow the Ruby Style-Guideliness

I started a small Asteroids remake, trying to make it as simple as I could so I would focus on those things rather than some complicated feature, GUI or anything else.

I used Gtk3 and Cairo to handle the graphics and keyboard input. Had to figure some things out, like how to handle keyboard events on Gtk3 and how to draw shapes with Cairo, so if anyone more experienced notice something wrong about my approach feel free to throw some critics on it too.

The code requires a small file called HighScore in the same directory as the script. You can download it from the GitHub repo, but I'll keep the template in the question as well.

Any feedback on any aspects of the code are welcome!

GitHub Repo

EDIT:

Since my main focus is on the programming paradigm approach, I took out some parts of the code that wasn't so pertinent to it (like some Gtk/Cairo setting up and events handling). I also found some ifs and whens that could be rewritten in one line. For the complete code, check the GitHub repository!

Code:

# Asteroids Game made in Ruby
# Author: Ian Caio

require 'gtk3'

# Classes:
class Game
  SCREEN_WIDTH = 600
  SCREEN_HEIGHT = 400

  # State of the keys to move the ship
  attr_accessor :key_up, :key_down, :key_left, :key_right

  def initialize
    mainMenu
  end

  def createWindow      # Creates the window where the game will be draw
     # Some Gtk and Cairo methods setting the window and the drawing context

     SpaceObject.SpaceObjectInstances.each { |spaceObj|
        spaceObj.updatePosition
        spaceObj.draw(cairoContext)
     }

    GLib::Timeout.add(50){
      updateControls
      updateGame
      @drawingArea.queue_draw
    }
  end

  def updateControls
    if @key_up
      Ship.ShipInstances.each { |ship|
        ship.accelerate(5)
      }
    elsif @key_down
      Ship.ShipInstances.each { |ship|
        ship.accelerate(-5)
      }
    end

    if @key_left
      Ship.ShipInstances.each { |ship|
        ship.rotate(-0.3)
      }
    elsif @key_right
      Ship.ShipInstances.each { |ship|
        ship.rotate(0.3)
      }
    end
  end

  def key_press_callback(widget, event)
    # Some Gtk methods handling the keys to move the ship

    # If space key is pressed call Ship.shoot
    if key == Gdk::Keyval::KEY_space
      Ship.ShipInstances.each { |ship|
        ship.shoot
      }
    end
  end

  def key_release_callback(widget, event)
    # Gtk methods handling keyboard events
  end

  def mainMenu  # Goes to the main menu of the game (Start/HighScores/Quit)
    puts '========     ASTEROIDS     ========'
    puts '==================================='
    puts "Type 'start' to start playing"
    puts "     'highscore' to view the highscore"
    puts "     'exit' to leave the game"
    puts '==================================='
    puts 'Controls:'
    puts "'W', 'A', 'S', 'D' = Turn/Move ship"
    puts "'space' = Shoot"
    puts '==================================='
    print "> "

    choice = gets.chomp

    case choice
    when 'start' then startGame
    when 'highscore'
      showHighscore
      exit 0
    when 'quit' then exit 0
    else
      puts "Invalid command!"
      exit 0
    end
  end

  def startGame # Starts the game
    createWindow
    @score = 1_000_000        # Current game score (starts at 1 million and goes down with time)
    @lastScoreUpdate = Time.now.to_f    # The last time the score was updated
  end

  def showHighscore     # Shows the highscore table
    if (File.exists?(File.realpath("./") + "/HighScore") && File.readable?(File.realpath("./HighScore")))
      highscoreFile = File.open(File.realpath("./HighScore"), "r")

      system("clear")
      puts "============================================="
      puts "========     ASTEROIDS HIGHSCORE     ========"
      puts "============================================="

      # Opens file and show highscore
    else
      puts "Couldn't open Highscore file!"
    end
    return
  end

  def showWinScreen     # Shows the winning screen
    system("clear")
    puts "==================================="
    puts "========     ASTEROIDS     ========"
    puts "==================================="
    puts "========     YOU  WIN!     ========"
    puts "==================================="
    puts "Score: #{@score}"

    # Open highscore file, check if the score is higher than any of the scores there
    # and prompt the user for a name if it is.
    if (File.exists?(File.realpath("./") + "/HighScore") && File.readable?(File.realpath("./HighScore")) &&
        File.writable?(File.realpath("./HighScore")))
      highscoreFile = File.open(File.realpath("./HighScore"), "r")
      highscoreArray = [] # Array containing arrays with the position, name and score

      # Checks the highscore player position and add it if necessary

      showHighscore
    else
      puts "Couldn't open Highscore file!"
    end
    return
  end

  def showLoseScreen    # Shows the losing screen
    system("clear")
    puts "==================================="
    puts "========     ASTEROIDS     ========"
    puts "==================================="
    puts "========     YOU LOSE!     ========"
    puts "==================================="
    return
  end

  def updateGame        # Updates the game
    timeSinceLastUpdate = Time.now.to_f - @lastScoreUpdate
    @score = (@score - timeSinceLastUpdate * 100).to_i
    @lastScoreUpdate = Time.now.to_f

    if Ship.ShipInstances.empty?       # No ships, meaning player lost the game
      GLib::Timeout.add(2000) { # 2 seconds and we show the lose screen and quit
        Gtk.main_quit
        showLoseScreen
      }
    elsif Asteroid.AsteroidInstances.empty?        # No asteroids, meaning the player won the game
      GLib::Timeout.add(2000) { # 2 seconds and we show the lose screen and quit
        Gtk.main_quit
        showWinScreen
      }
    end
  end
end

class Vector
  attr_accessor :x
  attr_accessor :y

  def initialize(x,y)
    @x = x
    @y = y
  end

  def vectormodule      # Calculates the vector module
    vecmod = Math::sqrt(@x**2 + @y**2)
  end

  def distance(x,y)     # Calculates the distance between this object and the given point
    dist = Math::sqrt((x - @x)**2 + (y - @y)**2)
  end
end

class SpaceObject
  @@SpaceObjectInstances = []      # Array holding the class instances (to avoid using ObjectSpace::each_object to iterate
                        # through the objects, since it could iterate through dead objects that were not yet
                        # removed from memory by the Garbage Collector)

  attr_accessor :position     # A vector representing the position of the object
  attr_accessor :speed        # A vector representing the speed of the object
  attr_reader :collisionRadius      # The radius we will use to calculate the collision with

  @lastPositionUpdate   # Time when the position was last updated (in millisecs)

  def initialize(x, y, speedx, speedy, collisionradius)
    @position = Vector.new(x,y)
    @speed = Vector.new(speedx, speedy)
    @collisionRadius = collisionradius
    @lastPositionUpdate = Time.now.to_f

    @@SpaceObjectInstances.push(self)      # Add the instance to the list of objects
  end

  # Instance methods:
  def updatePosition    # Updates the position according to the current speed
    if @speed.x != 0 or @speed.y != 0
      @position.x += @speed.x * (Time.now.to_f - @lastPositionUpdate)
      @position.y += @speed.y * (Time.now.to_f - @lastPositionUpdate)
    end

    if @position.x > (Game::SCREEN_WIDTH + @collisionRadius + 10)    # We add 10 pixels so the object don't bounce infinitely on both edges
      @position.x = 0 - @collisionRadius
    elsif @position.x < (0 - @collisionRadius - 10)     # We subtract 10 pixels so the object don't bounce infinitely on both edges
      @position.x = Game::SCREEN_WIDTH + @collisionRadius
    end

    if @position.y > (Game::SCREEN_HEIGHT + @collisionRadius + 10)
      @position.y = 0 - @collisionRadius
    elsif @position.y < (0 - @collisionRadius - 10)
      @position.y = Game::SCREEN_HEIGHT + @collisionRadius
    end

    @lastPositionUpdate = Time.now.to_f
  end

  def collision?(spaceObj2)  # Checks if this object collides with another one
    if @position.distance(spaceObj2.position.x, spaceObj2.position.y) < (@collisionRadius + spaceObj2.collisionRadius)
      true
    else
      false
    end
  end

  def draw(cairoContext); end # Draws the space object (will be overriden by the inherited classes)

  # Class methods
  # Method to read the space object instances array
  def self.SpaceObjectInstances
    @@SpaceObjectInstances
  end
end

class Asteroid < SpaceObject
  @@AsteroidInstances = []      # Array containing asteroid instances

  def initialize(x, y, speedx, speedy, collisionradius)
    super

    @@AsteroidInstances.push(self)      # Adds object to the asteroid instances array
  end

  # Overriding the update position to include collision check
  def updatePosition
    super

    Bullet.BulletInstances.each { |bullet|
      if collision?(bullet)
        bullet.destroy
        explode
      end
    }

    Ship.ShipInstances.each { |ship|
      if collision?(ship)
        ship.destroy
        explode
      end
    }
  end

  def explode
    if @collisionRadius > 5
      childasteroid = Asteroid.new(@position.x, @position.y, rand(-60..60), rand(-60..60), @collisionRadius/2)
      @collisionRadius = @collisionRadius/2
    else
      destroy
    end
  end

  def destroy
    @@AsteroidInstances.delete(self)
    @@SpaceObjectInstances.delete(self)
  end

  def draw(cairoContext)
    # Draw circles for asteroids with the collisionRadius size
  end

  def self.AsteroidInstances
    @@AsteroidInstances
  end
end

class Ship < SpaceObject
  MAX_SPEED = 80
  SHOOT_TIMEOUT = 0.5   # Half a second between each shoot

  @@ShipInstances = []  # Array holding the ship instances

  def initialize(x, y, speedx, speedy, collisionradius)
    super

    @angle = 0.5        # The ship angle in radians (not necessary on Asteroids, since they are circles)

    @lastShootTime = Time.now.to_f       # Shooting cooldown

    @@ShipInstances.push(self)  # Also add the ship to the ship instances array (besides the space objects arrays)
  end

  def rotate(ang)
    @angle += ang
  end

  def accelerate(accel)
    if @speed.vectormodule < MAX_SPEED
      @speed.x += Math::cos(@angle)*accel
      @speed.y += Math::sin(@angle)*accel
    else
      # Check if the acceleration will result in a lower module speed.
      # If not, keep the old speed.
      oldspeedx = @speed.x
      oldspeedy = @speed.y
      oldvectormodule = @speed.vectormodule

      @speed.x += Math::cos(@angle)*accel
      @speed.y += Math::sin(@angle)*accel

      if oldvectormodule < @speed.vectormodule
        @speed.x = oldspeedx
        @speed.y = oldspeedy
      end
    end
  end

  def shoot
    if (Time.now.to_f - @lastShootTime) > 0.5
      bullet = Bullet.new(@position.x, @position.y, Math::cos(@angle)*100, Math::sin(@angle)*100, 3, @angle)
      @lastShootTime = Time.now.to_f
    end
  end

  def destroy
    @@ShipInstances.delete(self)
    @@SpaceObjectInstances.delete(self)
  end

  def draw(cairoContext)
    # Draws ship and the collisionRadius for reference (with some alpha)
  end

  def self.ShipInstances
    @@ShipInstances
  end
end

class Bullet < SpaceObject
  TIME_TO_LIVE = 5.0    # Bullet lives for 5 seconds

  @@BulletInstances = []        # Array holding the bullet instances

  def initialize(x, y, speedx, speedy, collisionradius, angle)
    super(x, y, speedx, speedy, collisionradius)

    @angle = angle

    @birthtime = Time.now.to_f

    @@BulletInstances.push(self) # Also add the bullet to the bullet instances array (besides the space objects arrays)
  end

  def updatePosition
    super

    destroy if (Time.now.to_f - @birthtime) > TIME_TO_LIVE
  end

  def destroy
    @@BulletInstances.delete(self)
    @@SpaceObjectInstances.delete(self)
  end

  def draw(cairoContext)
    # Draws bullet
  end

  def self.BulletInstances
    @@BulletInstances
  end
end

# Code flow start:
Gtk.init

game = Game.new

randomGen = Random.new(Time.now.to_i)

ship = Ship.new(randomGen.rand(50..550),randomGen.rand(50..350),0,0,10)

for i in (1..10)
  xDirection = randomGen.rand(1..2)
  yDirection = randomGen.rand(1..2)
  speedx = randomGen.rand(30..60)
  speedy = randomGen.rand(30..60)

  speedx *= -1 if xDirection == 1

  speedy *= -1 if yDirection == 1

  # Half asteroids are large, half are medium
  if i >= 5
    asteroid = Asteroid.new(rand(30..570),rand(30..370),speedx,speedy,20)
  else
    asteroid = Asteroid.new(rand(30..570),rand(30..370),speedx,speedy,10)
  end
end

Gtk.main

HighScore:

<empty>
0
<empty>
0
<empty>
0
<empty>
0
<empty>
0
<empty>
0
<empty>
0
<empty>
0
<empty>
0
<empty>
0
\$\endgroup\$
  • \$\begingroup\$ just wanted to say that while this is a great question, it's a lot of code, so it's a big ask. you may want to try breaking it down, asking individual questions about self-contained parts of the code. if there are no self-contained parts, then that refactoring is the first thing you should do. \$\endgroup\$ – Jonah Nov 30 '16 at 15:47
  • \$\begingroup\$ @Jonah, I'm mostly interested in the paradigm approach, if I'm doing it the OOP way! I think I could take out some parts of the code that manages the graphical and keyboard events handling to make it smaller (just leaving a comment saying what the method does). And I'll also try to refactor the code to see if I can make it smaller (one line if/unless/when). \$\endgroup\$ – IanC Dec 1 '16 at 11:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.