# Tetris in Ruby with Gosu. I made this in October 2018 at Swinburne Uni as part of my High Distinction custom project.
# The main limitation was that writing code in an object-oriented fashion was strongly discouraged, so that led to some "interesting" workarounds.
# A self-imposed limitation was that I didn't want any external assets, all data was to be generated from code or be part of a lookup-table.
# At the time, the only way I knew how to render an image was to make a class that acted as a kind of interface to Gosu that I could pass a raw bitmap into.
# Thankfully, at the demonstration, my tutors were kind enough to look past the block rendering code as an artefact of using Ruby with Gosu.
# The Tetris logo was created with a basic tool I wrote (also using Gosu) for placing 2d quads on a grid and saving the result as a text file.
# As for the actual game, I based the scoring system off the NES version, but everything else I made up as I went along.
require 'gosu'
module Mode
MENU, GAME = *0..1
end
class ImageFactory
attr_reader :columns, :rows
def initialize(cols, rows, blob)
@columns = cols
@rows = rows
@blob = blob
end
def press
Gosu::Image.new(self)
end
def to_blob
@blob
end
end
def pixel_layer(x, y, size)
centre = size / 2
x_off = (centre - x).abs
y_off = (centre - y).abs
layer = x_off > y_off ? x : y
if layer > centre
layer = size - layer - 1
end
layer
end
def create_tile(size, colour)
a = ((colour >> 24) & 0xff)
r = ((colour >> 16) & 0xff)
g = ((colour >> 8) & 0xff)
b = (colour & 0xff)
cl = r.chr + g.chr + b.chr + a.chr
shade = (size / 4).to_i
grad = (256 / shade).to_i
tile = "#{cl * size * size}"
for i in 0..size-1
for j in 0..size-1
layer = pixel_layer(j, i, size)
if layer < shade
lum = layer * grad
pr = (r * lum) / 256
pg = (g * lum) / 256
pb = (b * lum) / 256
off = (i * size + j) * 4
tile[off..off+3] = pr.chr + pg.chr + pb.chr + 255.chr
end
end
end
ImageFactory.new(size, size, tile).press
end
class TetrisGame < Gosu::Window
# Who needs a global variable when you can have a class method instead, right?
def get_piece(idx)
p = nil
case idx
when 0 # O shape
p = [
[1, 1, 0, 0],
[1, 1, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0]
]
when 1 # J shape
p = [
[2, 2, 0, 0],
[2, 0, 0, 0],
[2, 0, 0, 0],
[0, 0, 0, 0]
]
when 2 # L shape
p = [
[3, 3, 0, 0],
[0, 3, 0, 0],
[0, 3, 0, 0],
[0, 0, 0, 0]
]
when 3 # Z shape
p = [
[0, 2, 0, 0],
[2, 2, 0, 0],
[2, 0, 0, 0],
[0, 0, 0, 0]
]
when 4 # S shape
p = [
[3, 0, 0, 0],
[3, 3, 0, 0],
[0, 3, 0, 0],
[0, 0, 0, 0],
]
when 5 # T shape
p = [
[0, 1, 0, 0],
[1, 1, 0, 0],
[0, 1, 0, 0],
[0, 0, 0, 0]
]
when 6 # I shape
p = [
[1, 0, 0, 0],
[1, 0, 0, 0],
[1, 0, 0, 0],
[1, 0, 0, 0]
]
end
p
end
def bonus(n_cleared)
score = 0
case n_cleared
when 1
score = 40
when 2
score = 100
when 3
score = 300
when 4
score = 1200
end
score
end
def input_pause
Gosu::KB_RETURN
end
def input_clockwise
Gosu::KB_X
end
def input_anticlockwise
Gosu::KB_Z
end
def input_move_right
Gosu::KB_RIGHT
end
def input_move_left
Gosu::KB_LEFT
end
def input_drop
Gosu::KB_DOWN
end
def input_cheat
Gosu::KB_UP
end
def move_rate
6
end
def move_wait
24
end
def default_rate
# super arbitrary
inc = (@level * @level / 8).to_i
(2000 / (40 + (@level * 10) + inc)).to_i
end
def drop_rate
4
end
def unit
35
end
def n_rows
20
end
def n_cols
10
end
def curtain_speed
(unit / 5).to_i
end
def left_margin
4 * unit
end
def right_margin
5 * unit
end
def width
left_margin + (unit * n_cols) + right_margin
end
def height
unit * n_rows
end
def font_size
((unit * 3) / 4).to_i
end
def info_x
unit
end
def ptext_y
unit * 3
end
def points_y
ptext_y + font_size
end
def ltext_y
unit * 5
end
def level_y
ltext_y + font_size
end
def next_x
width - right_margin + unit
end
def ntext_y
unit * 3
end
def next_y
ntext_y + font_size
end
def npiece_x
next_x + (unit / 2).to_i
end
def npiece_y
next_y + (unit / 2).to_i
end
def paused_size
unit * 5
end
def initialize
# A series of quads that present the Tetris logo
@logo = [
[0.795, 0.305, 0.81, 0.315, 0.795, 0.37, 0.81, 0.37],
[0.81, 0.315, 0.95, 0.315, 0.81, 0.37, 0.95, 0.37],
[0.885, 0.305, 0.95, 0.305, 0.89, 0.315, 0.95, 0.315],
[0.795, 0.14, 0.86, 0.14, 0.885, 0.305, 0.95, 0.305],
[0.795, 0.135, 0.855, 0.135, 0.795, 0.14, 0.86, 0.14],
[0.915, 0.09, 0.945, 0.09, 0.915, 0.135, 0.925, 0.15],
[0.795, 0.09, 0.915, 0.09, 0.795, 0.135, 0.915, 0.135],
[0.725, 0.15, 0.785, 0.15, 0.725, 0.26, 0.785, 0.36],
[0.59, 0.18, 0.67, 0.18, 0.705, 0.37, 0.785, 0.37],
[0.635, 0.125, 0.675, 0.125, 0.59, 0.18, 0.635, 0.18],
[0.725, 0.09, 0.785, 0.09, 0.725, 0.14, 0.785, 0.14],
[0.59, 0.09, 0.7, 0.09, 0.59, 0.125, 0.675, 0.125],
[0.535, 0.09, 0.59, 0.09, 0.535, 0.37, 0.59, 0.37],
[0.365, 0.09, 0.53, 0.09, 0.365, 0.14, 0.53, 0.14],
[0.415, 0.14, 0.475, 0.14, 0.415, 0.37, 0.475, 0.37],
[0.27, 0.31, 0.375, 0.31, 0.27, 0.37, 0.41, 0.37],
[0.27, 0.175, 0.335, 0.175, 0.27, 0.22, 0.3, 0.22],
[0.27, 0.09, 0.36, 0.09, 0.27, 0.14, 0.33, 0.14],
[0.21, 0.09, 0.27, 0.09, 0.21, 0.37, 0.27, 0.37],
[0.09, 0.14, 0.15, 0.14, 0.09, 0.37, 0.15, 0.37],
[0.035, 0.09, 0.205, 0.09, 0.035, 0.14, 0.205, 0.14]
]
super(width, height, false)
self.caption = "Tetris"
@font = Gosu::Font.new(font_size)
@paused_font = Gosu::Font.new(paused_size)
@palette = [0, 1, 2]
@colours = [
0xffff0000, # Red
0xff00ff00, # Green
0xff0000ff, # Blue
0xff00ffff, # Cyan
0xffff00ff, # Magenta
0xffffff00, # Yellow
0xffff8000, # Orange
0xff8000ff, # Purple
0xff00ff80, # Turquoise
0xff808080, # Grey
0xffffffff, # Whites
]
@board = Array.new(n_rows) { Array.new(n_cols) {0} }
@tileset = []
@colours.each { |colour|
@tileset << create_tile(unit, colour)
}
@mode = Mode::MENU
@paused = false
reset_board(false)
end
def reset_board(erase)
if erase
@board.length.times do |i|
@board[i].fill(0)
end
end
@demo_hold = 0
@demo_next = 0
@game_over = false
@curtain = nil
@end_timer = nil
@level = 0
@cleared = 0
@points = 0
@drop_timer = 0
@dropped = 0
@move_timer = 0
@move_dir = 0
@fall_rate = default_rate
@fall_dir = 1
@next_piece_idx = rand(7) # 7 pieces in Tetris
next_piece()
end
def generate_palette
set = []
@colours.each_with_index { |cl, idx| set << idx }
for i in 0..3
idx = rand(set.length)
@palette[i] = set[idx]
set.delete_at(idx)
end
end
def calc_piece_edges(axis)
left = -1
right = -1
for i in 0..3
# loop unrolling ftw
if axis == 0
empty = (@piece[0][i] != 0 or @piece[1][i] != 0 or @piece[2][i] != 0 or @piece[3][i] != 0)
else
empty = (@piece[i][0] != 0 or @piece[i][1] != 0 or @piece[i][2] != 0 or @piece[i][3] != 0)
end
if empty
if left == -1
left = i
else
right = i
end
end
end
if left == -1
left = 0
if right == -1
right = 3
end
elsif right == -1
right = left
end
[left, right]
end
def next_piece
if @game_over
return
end
@piece_idx = @next_piece_idx
@next_piece_idx = rand(7) # 7 pieces in Tetris
@next_piece = get_piece(@next_piece_idx)
@piece_dir = 0
@col = 4
@row = 0
# if it's an I shape, make it horizontal (because I'm nice like that)
rotate_piece(@piece_idx == 6 ? 1 : 0)
@col = (n_cols - @piece_w) / 2
for i in 0..@piece_h
for j in 0..@piece_w
if @board[@row+i][@col+j] != 0 and @piece[i][j] != 0
@game_over = true
end
end
end
end
def rotate_piece(dir)
@piece_dir = ((@piece_dir + dir) + 4) % 4
rot = Array.new(4) { Array.new(4) {0} }
# Rotate the piece
# 12:00: x -> x, y -> y
# 03:00: x -> -y, y -> x
# 06:00: x -> -x, y -> -y
# 09:00: x -> y, y -> -x
p = get_piece(@piece_idx)
for i in 0..3
for j in 0..3
x = j
y = i
if @piece_dir == 1
x = 3-i
y = j
elsif @piece_dir == 2
x = 3-j
y = 3-i
elsif @piece_dir == 3
x = i
y = 3-j
end
rot[y][x] = p[i][j]
end
end
# Move the piece inside its box so that it occupies the left-most position
for i in 0..3
if rot[0][0] == 0 and rot[1][0] == 0 and rot[2][0] == 0 and rot[3][0] == 0
for j in 0..3
rot[j][0] = rot[j][1]
rot[j][1] = rot[j][2]
rot[j][2] = rot[j][3]
rot[j][3] = 0
end
else
break
end
end
# Move the piece inside its box so that it occupies the top-most position
for i in 0..3
if rot[0][0] == 0 and rot[0][1] == 0 and rot[0][2] == 0 and rot[0][3] == 0
for j in 0..3
rot[0][j] = rot[1][j]
rot[1][j] = rot[2][j]
rot[2][j] = rot[3][j]
rot[3][j] = 0
end
else
break
end
end
i = 3
while i >= 0 and rot[0][i] == 0 and rot[1][i] == 0 and rot[2][i] == 0 and rot[3][i] == 0
i -= 1
end
w_new = i + 1
i = 3
while i >= 0 and rot[i][0] == 0 and rot[i][1] == 0 and rot[i][2] == 0 and rot[i][3] == 0
i -= 1
end
h_new = i + 1
row_new = @row
col_new = @col
if @col + w_new > n_cols
col_new = n_cols - w_new
end
# Test if the piece once rotated will overlap with the board
overlap = false
if dir != 0
for i in 0..3
for j in 0..3
if rot[i][j] != 0 and (row_new + i >= n_rows or @board[row_new+i][col_new+j] != 0)
overlap = true
break
end
end
if overlap
break
end
end
end
if !overlap
@piece = rot
@piece_w = w_new
@piece_h = h_new
@row = row_new
@col = col_new
end
end
def check_collision
@left_wall = false
@right_wall = false
@bottom_wall = false
for i in 0..3
if @row+i < 0
next
end
for j in 0..3
if @piece[i][j] <= 0
next
end
if @col+j > 0 and @board[@row+i][@col+j-1] > 0
@left_wall = true
end
if @col+j < n_cols-1 and @board[@row+i][@col+j+1] > 0
@right_wall = true
end
if @row+i < n_rows-1 and @board[@row+i+1][@col+j] > 0
@bottom_wall = true
end
end
end
end
def bake_piece
for i in 0..3
for j in 0..3
if @piece[i][j] > 0
@board[@row+i][@col+j] = @piece[i][j]
end
end
end
end
def clear_full_rows
n_cleared = 0
for i in 0..n_rows-1
full = true
for j in 0..n_cols-1
if @board[i][j] <= 0
full = false
break
end
end
unless full
next
end
i.downto(1) { |r| @board[r].replace(@board[r-1]) }
@board[0].fill(0)
i -= 1
n_cleared += 1
end
n_cleared
end
def demo_get_num(size)
n = @demo_input % size
@demo_input = (@demo_input / size).to_i
n
end
def demo_next_input
if @demo_input == nil
@demo_input = rand(36000)
end
actions = [
input_anticlockwise,
input_clockwise,
input_move_left,
input_move_right,
input_drop
]
@demo_action = actions[demo_get_num(5)]
@demo_hold = demo_get_num(60) + 2
@demo_next = demo_get_num(120) + 4
if @demo_hold >= @demo_next
@demo_hold = @demo_next - 2
end
button_down(@demo_action, true)
@demo_input = rand(36000)
end
def update
if @paused
return
end
if @mode == Mode::MENU
if @demo_hold == -1
button_up(@demo_action, true)
end
if @demo_next == 0
demo_next_input()
end
@demo_next -= 1
@demo_hold -= 1
end
if @game_over
if @curtain == nil
@curtain = 0
elsif @curtain != height
@curtain += curtain_speed
if @curtain > height
@curtain = height
end
else
if @end_timer == nil
@end_timer = 0
else
@end_timer += 1
if @mode == Mode::MENU and @end_timer >= 120
reset_board(true)
end
end
end
return
end
check_collision()
ready = (@move_timer == 0 or ((@move_timer % move_rate) == 0 and @move_timer > move_wait))
if ready and ((!@left_wall and @move_dir < 0) or (!@right_wall and @move_dir > 0))
@col += @move_dir
end
if @col < 0
@col = 0
end
if @col > n_cols - @piece_w
@col = n_cols - @piece_w
end
landed = false
if @drop_timer == @fall_rate-1
@row += @fall_dir
if @drop
@dropped += 1
end
@drop_timer = 0
if @bottom_wall
landed = true
end
end
if @row > n_rows-@piece_h or landed
@row -= 1
bake_piece()
rows = clear_full_rows()
@cleared += rows
next_level = (@cleared / 10).to_i
if next_level > @level
@level = next_level
generate_palette()
end
@points += bonus(rows) * (@level + 1)
@points += @dropped
@dropped = 0
next_piece()
@fall_rate = default_rate
end
@move_timer += 1
@drop_timer += 1
end
def draw_cell(idx, x, y)
if idx > 0 and idx <= 3
@tileset[@palette[idx-1]].draw(x, y, 0)
end
end
def draw
if @mode == Mode::GAME and @paused
@paused_font.draw_rel("PAUSED", width/2, height/2, 0, 0.5, 0.5)
return
end
back_cl = 0xff404040
draw_rect(0, 0, left_margin, height, back_cl)
draw_rect(width-right_margin, 0, right_margin, height, back_cl)
for i in 0..3
for j in 0..3
draw_cell(@piece[i][j], left_margin + (@col + j) * unit, (@row + i) * unit)
end
end
@board.each_with_index { |row, r_idx|
row.each_with_index { |cell, c_idx|
draw_cell(cell, left_margin + c_idx * unit, r_idx * unit)
}
}
draw_rect(next_x, next_y, unit * 3, unit * 5, 0xff202020)
for i in 0..3
for j in 0..1
draw_cell(@next_piece[i][j], npiece_x + j * unit, npiece_y + i * unit)
end
end
@font.draw("Points", info_x, ptext_y, 0)
@font.draw("#{@points}", info_x, points_y, 0)
@font.draw("Level", info_x, ltext_y, 0)
@font.draw("#{@level}", info_x, level_y, 0)
if @curtain != nil
draw_rect(left_margin, 0, unit * n_cols, @curtain, 0xff800000)
end
if @mode == Mode::MENU
draw_rect(0, 0, width, height, 0x60000000)
cl = 0xffe0e0e0
@logo.each { |q|
x1 = (q[0] * width.to_f).to_i
y1 = (q[1] * height.to_f).to_i
x2 = (q[2] * width.to_f).to_i
y2 = (q[3] * height.to_f).to_i
x3 = (q[4] * width.to_f).to_i
y3 = (q[5] * height.to_f).to_i
x4 = (q[6] * width.to_f).to_i
y4 = (q[7] * height.to_f).to_i
draw_quad(x1, y1, cl, x2, y2, cl, x3, y3, cl, x4, y4, cl, 0)
}
end
end
def move_piece(dir)
@move_dir = @move_dir == 0 ? dir : 0
@move_timer = 0
end
def button_down(id, demo = false)
if @mode == Mode::MENU and demo == false
if id == input_pause
@mode = Mode::GAME
reset_board(true)
end
return
end
case id
when input_anticlockwise
rotate_piece(-1)
when input_clockwise
rotate_piece(1)
when input_move_left
move_piece(-1)
when input_move_right
move_piece(1)
when input_drop
@drop = true
@fall_rate = drop_rate
@drop_timer = @fall_rate-1
=begin
when input_cheat # ;)
@fall_dir = -1
@drop_timer = @fall_rate-1
=end
when input_pause
if !@game_over
@paused = !@paused
elsif @end_timer != nil
reset_board(true)
@mode = Mode::MENU
end
end
end
def button_up(id, demo = false)
if @mode == Mode::MENU and demo == false
return
end
case id
when input_move_left
move_piece(0)
when input_move_right
move_piece(0)
when input_drop
@drop = false
@fall_rate = default_rate
@dropped = 0
=begin
when input_cheat # ;)
@fall_dir = 1
=end
end
end
end
TetrisGame.new.show