Sign in to follow this  
  • entries
    10
  • comments
    9
  • views
    49218

Tetris in Ruby

Sign in to follow this  
ne0_kamen

3664 views

Hello,

My exams are over (finally).
One of the courses I took this semester was introduction to the Ruby language.
For the final exam, we had to make some project of moderate complexity.
The result was like 90% web apps (written using Rails), several console apps and several games (most of which were chess simulators, they seem to be beloved among the academia).

So I figured I would do a very basic game I never did before - Tetris.
tetris.png

Ruby is a really cool language for prototyping and its amazing how much time it could save you with its "blocks passed as arguments" and extensive Enumerable class methods.

For the graphics front-end/window creation and sound, I used gosu.
Its amazingly simple and fast enough for the job.

It took me a few hours to program the whole thing (+unit tests).
The result is not bad, but apparently not good enough (I got a 4 out of 6 score).

The biggest remark I got from my lecturer was that my rendering and game logic was too tightly coupled together.
Which is true, of course.

However, I believe that decoupling them would complicate things quite a bit (and thus make the project bigger, andwould take me more time).
I just don't get why the professors always think an over-engineered, but "extensible and maintainable" solution isbetter than a simple one. Its not like I'm going to bring my course work into a full blown product.

Source

require 'gosu'

class Block
attr_accessor :falling
attr_accessor :x, :y, :width, :height, :color

@@image = nil

def initialize(game)
# Image is loaded only once for all blocks
if @@image == nil
@@image = Gosu::Image.new(game, "block.png", false)
end

@x = 0
@y = 0
@width = @@image.width;
@height = @@image.height
@game = game
@color = 0xffffffff
end

def draw
@@image.draw(@x, @y, 0, 1, 1, @color)
end

def collide(block)
# Two blocks collide only when they are at the same position, since the world is a grid
return (block.x == @x && block.y == @y)
end

def collide_with_other_blocks
@game.blocks.each do |block|
if collide(block)
return block
end
end
nil
end
end

class Shape
attr_accessor :rotation
def initialize(game)
@game = game
@last_fall_update = Gosu::milliseconds
@last_move_update = Gosu::milliseconds

@blocks = [Block.new(game), Block.new(game), Block.new(game), Block.new(game) ]

@x = @y = 0
@falling = true

# Rotation is done about this block
@rotation_block = @blocks[1]
# How many rotations we can do before a full cycle?
@rotation_cycle = 1
# Current rotation state
@rotation = 0
end

def apply_rotation
# Each rotation is a 90 degree in the clockwise direction
if @rotation_block != nil
(1..@rotation.modulo(@rotation_cycle)).each do |i|
@blocks.each do |block|
old_x = block.x
old_y = block.y
block.x = @rotation_block.x + (@rotation_block.y - old_y)
block.y = @rotation_block.y - (@rotation_block.x - old_x)
end
end
end
end

# Note that the following function is defined properly only when the object is unrotated
# Otherwise the line of symmetry will be misplaced and wrong results will be produced
def reverse
# Mirror the shape by the y axis, effectively creating shape counterparts such as 'L' and 'J'
center = (get_bounds[2] + get_bounds[0]) / 2.0
@blocks.each do |block|
block.x = 2*center - block.x - @game.block_width
end
end

def get_bounds
# Go throug all blocks to find the bounds of this shape
x_min = []
y_min = []
x_max = []
y_max = []
@blocks.each do |block|
x_min << block.x
y_min << block.y

x_max << block.x + block.width
y_max << block.y + block.height
end

return [x_min.min, y_min.min, x_max.max, y_max.max]
end

# Updates to movement are done periodically to allow the player time for reaction
def needs_fall_update?
if ( @game.button_down?(Gosu::KbDown) )
updateInterval = 100
else
updateInterval = 500 - @game.level*50
end
if ( Gosu::milliseconds - @last_fall_update > updateInterval )
@last_fall_update = Gosu::milliseconds
end
end

def needs_move_update?
if ( Gosu::milliseconds - @last_move_update > 100 )
@last_move_update = Gosu::milliseconds
end
end

def draw
get_blocks.each { |block| block.draw }
end

def update
if ( @falling )
# After a movement or gravity update, we check if the moved shape collides with the world.
# If it does, we restore its position to the last known good position
old_x = @x
old_y = @y

if needs_fall_update?
@y = (@y + @game.block_height)
end

# Important to note is that we do 2 collision checks - once we moved on the x axis and once we moved on the y axis
# This way we can determine which of the 2 movements is responisble for the collision and learn on which side of the colliding block
# the collision occured.
if ( collide )
@y = (old_y)
@falling = false
@game.spawn_next_shape
@game.delete_lines_of(self)
else
if needs_move_update?
if (@game.button_down?(Gosu::KbLeft))
@x = (@x - @game.block_width)
end
if (@game.button_down?(Gosu::KbRight))
@x = ( @x + @game.block_width)
end

if ( collide )
@x = (old_x)
end
end
end
end
end

def collide
get_blocks.each do |block|
collision = block.collide_with_other_blocks;
if (collision)
return true
end
end

bounds = get_bounds

if ( bounds[3] > @game.height )
return true
end

if ( bounds[2] > @game.width )
return true
end

if ( bounds[0] < 0 )
return true
end
return false
end

end

class ShapeI < Shape
def initialize(game)
super(game)

@rotation_block = @blocks[1]
@rotation_cycle = 2
end

def get_blocks
@blocks[0].x = @x
@blocks[1].x = @x
@blocks[2].x = @x
@blocks[3].x = @x
@blocks[0].y = @y
@blocks[1].y = @blocks[0].y + @blocks[0].height
@blocks[2].y = @blocks[1].y + @blocks[1].height
@blocks[3].y = @blocks[2].y + @blocks[2].height

apply_rotation

@blocks.each { |block| block.color = 0xffb2ffff }
end
end

class ShapeL < Shape
def initialize(game)
super(game)

@rotation_block = @blocks[1]
@rotation_cycle = 4
end

def get_blocks
@blocks[0].x = @x
@blocks[1].x = @x
@blocks[2].x = @x
@blocks[3].x = @x + @game.block_width
@blocks[0].y = @y
@blocks[1].y = @blocks[0].y + @game.block_height
@blocks[2].y = @blocks[1].y + @game.block_height
@blocks[3].y = @blocks[2].y

apply_rotation

@blocks.each { |block| block.color = 0xffff7f00 }
end
end

class ShapeJ < ShapeL
def get_blocks
# Reverse will reverse also the direction of rotation that's applied in apply_rotation
# This will temporary disable rotation in the super method, so we can handle the rotation here after the reverse
old_rotation = @rotation
@rotation = 0

super
reverse

@rotation = old_rotation
apply_rotation

@blocks.each { |block| block.color = 0xff0000ff}
end
end

class ShapeCube < Shape
def get_blocks
@blocks[0].x = @x
@blocks[1].x = @x
@blocks[2].x = @x + @game.block_width
@blocks[3].x = @x + @game.block_width
@blocks[0].y = @y
@blocks[1].y = @blocks[0].y + @game.block_height
@blocks[2].y = @blocks[0].y
@blocks[3].y = @blocks[2].y + @game.block_height

@blocks.each { |block| block.color = 0xffffff00}
end
end

class ShapeZ < Shape
def initialize(game)
super(game)

@rotation_block = @blocks[1]
@rotation_cycle = 2
end

def get_blocks
@blocks[0].x = @x
@blocks[1].x = @x + @game.block_width
@blocks[2].x = @x + @game.block_width
@blocks[3].x = @x + @game.block_width*2
@blocks[0].y = @y
@blocks[1].y = @y
@blocks[2].y = @y + @game.block_height
@blocks[3].y = @y + @game.block_height

apply_rotation
@blocks.each { |block| block.color = 0xffff0000}
end
end

class ShapeS < ShapeZ
def get_blocks
# Reverse will reverse also the direction of rotation that's applied in apply_rotation
# This will temporary disable rotation in the super method, so we can handle the rotation here after the reverse
old_rotation = @rotation
@rotation = 0

super
reverse

@rotation = old_rotation
apply_rotation

@blocks.each { |block| block.color = 0xff00ff00}
end
end

class ShapeT < Shape
def initialize(game)
super(game)

@rotation_block = @blocks[1]
@rotation_cycle = 4
end

def get_blocks
@blocks[0].x = @x
@blocks[1].x = @x + @game.block_width
@blocks[2].x = @x + @game.block_width*2
@blocks[3].x = @x + @game.block_width
@blocks[0].y = @y
@blocks[1].y = @y
@blocks[2].y = @y
@blocks[3].y = @y + @game.block_height

apply_rotation
@blocks.each { |block| block.color = 0xffff00ff}
end
end

class TetrisGameWindow < Gosu::Window
attr_accessor :blocks
attr_reader :block_height, :block_width
attr_reader :level
attr_reader :falling_shape

STATE_PLAY = 1
STATE_GAMEOVER = 2

def initialize
super(320, 640, false)

@block_width = 32
@block_height = 32

@blocks = []

@state = STATE_PLAY

spawn_next_shape

@lines_cleared = 0
@level = 0

self.caption = "Tetris : #{@lines_cleared} lines"

@song = Gosu::Song.new("TetrisB_8bit.ogg")
end

def update
if ( @state == STATE_PLAY )
if ( @falling_shape.collide )
@state = STATE_GAMEOVER
else
@falling_shape.update
end

@level = @lines_cleared / 10
self.caption = "Tetris : #{@lines_cleared} lines"
else
if ( button_down?(Gosu::KbSpace) )
@blocks = []
@falling_shape = nil
@level = 0
@lines_cleared = 0
spawn_next_shape

@state = STATE_PLAY
end
end

if ( button_down?(Gosu::KbEscape) )
close
end
@song.play(true)
end

def draw
@blocks.each { |block| block.draw }
@falling_shape.draw

if @state == STATE_GAMEOVER
text = Gosu::Image.from_text(self, "Game Over", "Arial", 40)
text.draw(width/2 - 90, height/2 - 20, 0, 1, 1)
end
end

def button_down(id)
# Rotate shape when space is pressed
if ( id == Gosu::KbSpace && @falling_shape != nil )
@falling_shape.rotation += 1
if ( @falling_shape.collide )
@falling_shape.rotation -= 1
end
end
end

def spawn_next_shape
# Spawn a random shape and add the current falling shape' blocks to the "static" blocks list
if (@falling_shape != nil )
@blocks += @falling_shape.get_blocks
end

generator = Random.new
shapes = [ShapeI.new(self), ShapeL.new(self), ShapeJ.new(self), ShapeCube.new(self), ShapeZ.new(self), ShapeT.new(self), ShapeS.new(self)]
shape = generator.rand(0..(shapes.length-1))
@falling_shape = shapes[shape]
end

def line_complete(y)
# Important is that the screen resolution should be divisable by the block_width, otherwise there would be gap
# If the count of blocks at a line is equal to the max possible blocks for any line - the line is complete
i = @blocks.count{|item| item.y == y}
if ( i == width / block_width )
return true;
end
return false;
end

def delete_lines_of( shape )
# Go through each block of the shape and check if the lines they are on are complete
deleted_lines = []
shape.get_blocks.each do |block|
if ( line_complete(block.y) )
deleted_lines.push(block.y)
@blocks = @blocks.delete_if { |item| item.y == block.y }
end
end

@lines_cleared += deleted_lines.length

# This applies the standard gravity found in classic Tetris games - all blocks go down by the
# amount of lines cleared
@blocks.each do |block|
i = deleted_lines.count{ |y| y > block.y }
block.y += i*block_height
end

end

end

# This global prevents creation of the window and start of the simulation when we are doing testing
if ( !$testing )
window = TetrisGameWindow.new
window.show
end


And unit tests (they were required)

$testing = true

require "./tetris.rb"
require "test/unit"

class TestTetris < Test::Unit::TestCase
def setup
@game = TetrisGameWindow.new
@w = @game.block_width
@h = @game.block_height
end

def test_shapes_construction
assert_equal(4, ShapeI.new(@game).get_blocks.length, "ShapeI must be constructed of 4 blocks")
assert_equal(4, ShapeT.new(@game).get_blocks.length, "ShapeT must be constructed of 4 blocks")
assert_equal(4, ShapeJ.new(@game).get_blocks.length, "ShapeJ must be constructed of 4 blocks")
assert_equal(4, ShapeZ.new(@game).get_blocks.length, "ShapeZ must be constructed of 4 blocks")
assert_equal(4, ShapeCube.new(@game).get_blocks.length, "ShapeO must be constructed of 4 blocks")
assert_equal(4, ShapeS.new(@game).get_blocks.length, "ShapeS must be constructed of 4 blocks")
assert_equal(4, ShapeL.new(@game).get_blocks.length, "ShapeL must be constructed of 4 blocks")

assert_not_equal(nil, @game.falling_shape, "Falling shape shoudn't be nil")
end

def test_shapes_rotation
shape = ShapeI.new(@game)
shape.rotation = 1
assert(shape_contain_block(shape, -2*@w, @h), "Rotation of I failed!")
assert(shape_contain_block(shape, -@w, @h), "Rotation of I failed!")
assert(shape_contain_block(shape, 0, @h), "Rotation of I failed!")
assert(shape_contain_block(shape, @w, @h), "Rotation of I failed!")

shape = ShapeL.new(@game)
shape.rotation = 2
assert(shape_contain_block(shape, -@w, 0), "Rotation of L failed!")
assert(shape_contain_block(shape, 0, 0), "Rotation of L failed!")
assert(shape_contain_block(shape, 0, @h), "Rotation of L failed!")
assert(shape_contain_block(shape, 0, 2*@h), "Rotation of L failed!")

shape = ShapeJ.new(@game)
shape.rotation = 2
assert(shape_contain_block(shape, 2*@w, 0), "Rotation of J failed!")
assert(shape_contain_block(shape, @w, 0), "Rotation of J failed!")
assert(shape_contain_block(shape, @w, @h), "Rotation of J failed!")
assert(shape_contain_block(shape, @w, 2*@h), "Rotation of J failed!")

shape = ShapeZ.new(@game)
shape.rotation = 2
assert(shape_contain_block(shape, 0, 0), "Rotation of Z failed!")
assert(shape_contain_block(shape, @w, 0), "Rotation of Z failed!")
assert(shape_contain_block(shape, @w, @h), "Rotation of Z failed!")
assert(shape_contain_block(shape, 2*@w, @h), "Rotation of Z failed!")

shape = ShapeS.new(@game)
shape.rotation = 1
assert(shape_contain_block(shape, 0, -@h), "Rotation of S failed!")
assert(shape_contain_block(shape, 0, 0), "Rotation of S failed!")
assert(shape_contain_block(shape, @w, 0), "Rotation of S failed!")
assert(shape_contain_block(shape, @w, @h), "Rotation of S failed!")

shape = ShapeT.new(@game)
shape.rotation = 3
assert(shape_contain_block(shape, @w, -@h), "Rotation of T failed!")
assert(shape_contain_block(shape, @w, 0), "Rotation of T failed!")
assert(shape_contain_block(shape, 2*@w, 0), "Rotation of T failed!")
assert(shape_contain_block(shape, @w, @h), "Rotation of T failed!")
end

def test_block_collision
block1 = Block.new(@game)
block2 = Block.new(@game)
block2.x = @w
block2.y = 0

assert_equal(false, block1.collide(block2), "Blocks should not collide")

block2.x = 0
block2.y = 0

assert_equal(true, block1.collide(block2), "Blocks should collide")
end

def test_line_complete
(0.. (@game.width/@w - 1)).each do |i|
add_block(i*@w, 0)
end

(0.. (@game.width/@w - 2)).each do |i|
add_block(i*@w, @h)
end

assert_equal(true, @game.line_complete(0), "Line should be complete")
assert_equal(false, @game.line_complete(@h), "Line should not be complete")

shapeI = ShapeI.new(@game)
@game.delete_lines_of(shapeI)

(0.. (@game.width/@w - 1)).each do |i|
assert_equal(false, contain_block(@game.blocks, i*@w, 0), "Line 0 should be deleted ")
end

end

def add_block(x,y)
block = Block.new(@game)
block.x = x
block.y = y
@game.blocks << block
end

def contain_block(array, x, y)
array.index { |block| block.x == x && block.y == y } != nil
end

def shape_contain_block(shape, x, y)
contain_block(shape.get_blocks,x, y)
end

end



The game (the ruby source file) and a Windows .exe (allowing you to run the game without ruby or gosu installed) can be downloaded from here :

tetris.rar

If you run the game in the ruby 1.9 interpreter, be sure to install gosu :
gem install gosu
Sign in to follow this  


0 Comments


Recommended Comments

There are no comments to display.

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now