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!
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 if
s and when
s 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
if
/unless
/when
). \$\endgroup\$