Skip to content

Instantly share code, notes, and snippets.

@vinnydiehl
Last active March 21, 2023 09:17
WIP DragonRuby Tetris implementation
FPS = 60
DAS = 20
SPAWN_DELAY = 12
LOCK_DOWN_DELAY = 30
MINO_SIZE = 30
MATRIX_WIDTH = 10
MATRIX_HEIGHT = 20
PIECES = [
{
shape: :i,
minos: [[nil, nil, 1, nil]] * 4,
color: [0, 100, 100]
},
{
shape: :j,
minos: [[nil, 1, 1], [nil, 1, nil], [nil, 1, nil]],
color: [0, 0, 255]
},
{
shape: :l,
minos: [[nil, 1, nil], [nil, 1, nil], [nil, 1, 1]],
color: [255, 165, 0]
},
{
shape: :o,
minos: [[1, 1], [1, 1]],
color: [255, 255, 0]
},
{
shape: :s,
minos: [[nil, 1, nil], [nil, 1, 1], [nil, nil, 1]],
color: [0, 255, 0]
},
{
shape: :t,
minos: [[nil, 1, nil], [nil, 1, 1], [nil, 1, nil]],
color: [148, 0, 211]
},
{
shape: :z,
minos: [[nil, nil, 1], [nil, 1, 1], [nil, 1, nil]],
color: [255, 0, 0]
}
]
KICK_TESTS = [
[[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
[[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
[[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
[[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]]
]
KICK_TESTS_I = [
[[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
[[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
[[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
[[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]]
]
# Gravity slowly increases as level increases.
# First index nil since levels start at 1
GRAVITY_VALUES = [
nil,
0.01667,
0.021017,
0.026977,
0.035256,
0.04693,
0.06361,
0.0879,
0.1236,
0.1775,
0.2598,
0.388,
0.59,
0.92,
1.46,
2.36
]
SOFT_DROP_G = 0.5
class TetrisGame
def initialize(args)
@args = args
@level = 1
@score = 0
@gameover = false
@gravity = GRAVITY_VALUES[1]
@matrix = Array.new(MATRIX_WIDTH) { Array.new(MATRIX_HEIGHT, nil) }
@das_timeout = DAS
@as_frame_timer = 0
@bag = []
@delayed_procs = []
spawn_tetromino
end
def delay(frames, &block)
@delayed_procs << [frames, block]
end
def handle_delayed_procs
# Prune any procs that have already timed out and executed
@delayed_procs.select! { |frames, _| frames >= 0 }
# Go through each proc and decrease the timeout, executing if
# it has reached 0
@delayed_procs.map! do |frames, func|
func.call if frames == 0
[frames - 1, func]
end
end
def current_tetromino
@current_tetromino ? @current_tetromino[:minos] : nil
end
def current_tetromino_iterate(&block)
current_tetromino.each_with_index do |col, x|
col.each_with_index do |mino, y|
block.call mino, @current_tetromino[:x] + x, @current_tetromino[:y] + y
end
end
end
def current_tetromino_any?(&block)
current_tetromino.each_with_index.any? do |col, x|
col.each_with_index.any? do |mino, y|
block.call mino, @current_tetromino[:x] + x, @current_tetromino[:y] + y
end
end
end
# The random generator is fairly simple: shuffle the pieces and put them in a "bag",
# draw them in order, then reshuffle when the bag is empty. We keep the bag size > 7
# so that the queue (next 7 pieces, visible) is always filled up
def spawn_tetromino
@bag.concat PIECES.shuffle if @bag.size < 8
@current_tetromino = @bag.shift
@current_tetromino.merge!({
y: 21 - current_tetromino.first.size,
x: 5 - (current_tetromino.size / 2).ceil,
rotation: 0,
age: 0,
lock_down_timeout: LOCK_DOWN_DELAY,
lock_down_extensions: 0
})
reset_gravity_delay GRAVITY_VALUES[@level]
end
# x and y are positions in the matrix, not pixels
def render_mino(x, y, r, g, b, a=255)
matrix_x = (1280 - (MATRIX_WIDTH * MINO_SIZE)) / 2
matrix_y = (720 - (MATRIX_HEIGHT * MINO_SIZE)) / 2
@args.outputs.solids << [matrix_x + (x * MINO_SIZE), matrix_y + (y * MINO_SIZE), MINO_SIZE, MINO_SIZE, r, g, b, a]
end
def render_background
# Black background
@args.outputs.solids << [0, 0, 1280, 720, 0, 0, 0]
# Border
color = [255, 255, 255]
# Horizontal lines
(-1..MATRIX_WIDTH).each do |i|
render_mino i, -1, *color
render_mino i, MATRIX_HEIGHT, *color
end
# Vertical lines
(-1..MATRIX_HEIGHT).each do |i|
render_mino -1, i, *color
render_mino MATRIX_WIDTH, i, *color
end
end
def render_matrix
@matrix.each_with_index do |col, x|
col.each_with_index do |color, y|
render_mino x, y, *color if color && y < MATRIX_HEIGHT
end
end
end
def render_current_tetromino
current_tetromino_iterate do |mino, x, y|
render_mino x, y, *@current_tetromino[:color] if mino && y < MATRIX_HEIGHT
end
end
def render
render_background
render_matrix
render_current_tetromino if @current_tetromino
# render_score
end
def current_tetromino_colliding_x?(*directions)
unless directions.all? { |dir| %i[left right].include?(dir) }
raise ArgumentError, "expected :left or :right"
end
directions.all? do |dir|
current_tetromino_any? do |mino, x, y|
mino &&
((dir == :left &&
(x <= 0 || @matrix[x - 1][y])) ||
(dir == :right &&
(x >= MATRIX_WIDTH - 1 || @matrix[x + 1][y])))
end
end
end
def current_tetromino_colliding_y?
current_tetromino_any? do |mino, x, y|
mino && (y <= 0 || @matrix[x][y - 1])
end
end
def rotate_current_tetromino(direction)
raise ArgumentError, "expected :cw or :ccw" unless %i[cw ccw].include?(direction)
# SRS Wall kicks try 5 different translations, if any succeed, it places
# the tetromino. We'll create a simulated tetromino to try them out
sim_tetromino = @current_tetromino.dup
# All of the shapes are represented as square 2D arrays (2x2, 3x3, or 4x4)
# which are rotated directly around their centers.
sim_tetromino[:minos] = direction == :cw ?
sim_tetromino[:minos].transpose.map(&:reverse) :
sim_tetromino[:minos].map(&:reverse).transpose
kick_tests = sim_tetromino[:shape] == :i ? KICK_TESTS_I : KICK_TESTS
# The test cases are indexed based on the rotation position
i = direction == :cw ? sim_tetromino[:rotation] : (sim_tetromino[:rotation] - 1) % 4
sign = direction == :cw ? 1 : -1
success = kick_tests[i].any? do |translation|
# Translate it
sim_tetromino[:x] = @current_tetromino[:x] + translation.x * sign
sim_tetromino[:y] = @current_tetromino[:y] + translation.y * sign
# Success if none of the simulated tetromino's minos are
# overlapping the matrix, or out-of-bounds
sim_tetromino[:minos].each_with_index.all? do |col, x|
col.each_with_index.none? do |mino, y|
test_x = sim_tetromino[:x] + x
test_y = sim_tetromino[:y] + y
mino &&
(test_x < 0 || test_x >= MATRIX_WIDTH ||
test_y < 0 || @matrix[test_x][test_y])
end
end
end
if success
@current_tetromino[:minos] = sim_tetromino[:minos]
@current_tetromino[:x] = sim_tetromino[:x]
@current_tetromino[:y] = sim_tetromino[:y]
# Cycle between 0..3
@current_tetromino[:rotation] = (@current_tetromino[:rotation] + sign) % 4
reset_lock_down_delay
end
end
def clear_lines
lines_cleared = 0
MATRIX_HEIGHT.times do |y|
if @matrix.all? { |col| col[y] }
lines_cleared += 1
# Delete the line
@matrix.each { |col| col[y] = nil }
# Shift everything above it downward
@matrix.each do |col|
(y..MATRIX_HEIGHT-1).each do |y|
col[y] = col[y + 1]
end
end
end
end
end
def handle_input
if @args.inputs.left && !@args.inputs.right
if @current_tetromino && !current_tetromino_colliding_x?(:left) &&
(@das_timeout == DAS || (@das_timeout < 0 && @as_frame_timer == 0))
@current_tetromino[:x] -= 1
if locking_down?
reset_lock_down_delay
reset_gravity_delay
end
end
@das_timeout -= 1
if @das_timeout < 0
@as_frame_timer = (@as_frame_timer - 1) % 3
end
elsif @args.inputs.right && !@args.inputs.left
if @current_tetromino && !current_tetromino_colliding_x?(:right) &&
(@das_timeout == DAS || (@das_timeout < 0 && @as_frame_timer == 0))
@current_tetromino[:x] += 1
if locking_down?
reset_lock_down_delay
reset_gravity_delay
end
end
@das_timeout -= 1
if @das_timeout < 0
@as_frame_timer = (@as_frame_timer - 1) % 3
end
else
@das_timeout = DAS
@as_frame_timer = 0
end
if @current_tetromino
calculate_gravity @args.inputs
key_down = @args.inputs.keyboard.key_down
if key_down.space || key_down.e || @args.inputs.controller_one.key_down.r1
rotate_current_tetromino(:cw)
end
if key_down.q || @args.inputs.controller_one.key_down.l1
rotate_current_tetromino(:ccw)
end
end
end
def reset_lock_down_delay(reset_extensions=false)
@current_tetromino[:lock_down_timeout] = LOCK_DOWN_DELAY
@current_tetromino[:lock_down_extensions] = reset_extensions ? 0 : @current_tetromino[:lock_down_extensions] + 1
end
def lock_down
@current_tetromino[:lock_down_timeout] -= 1
if ((@current_tetromino[:lock_down_timeout] <= 0 || @current_tetromino[:hard_dropped]) && current_tetromino_colliding_y?) ||
(@current_tetromino[:lock_down_extensions] >= 15 && (current_tetromino_colliding_x?(:left, :right) || current_tetromino_colliding_y?))
# Make current tetromino part of the matrix
current_tetromino_iterate do |mino, x, y|
if mino
@matrix[x][y] = @current_tetromino[:color]
end
end
@current_tetromino = nil
delay(SPAWN_DELAY) { spawn_tetromino }
end
end
def locking_down?
@current_tetromino[:lock_down] || false
end
def reset_gravity_delay(gravity=nil)
@current_tetromino[:gravity_delay] = @current_tetromino[:age] + (1 / (gravity || @gravity))
end
def calculate_gravity(inputs)
original = @gravity
if inputs.up || inputs.controller_one.a
@current_tetromino[:hard_dropped] = true
end
# Soft drop if down input, otherwise use G value based on level
@gravity = @current_tetromino[:hard_dropped] ? 20 :
inputs.down ? SOFT_DROP_G : GRAVITY_VALUES[[@level, 15].min]
# If the gravity has changed this frame, need to reset the gravity/age
# delay to the correct interval
reset_gravity_delay if @gravity != original
end
def apply_gravity
# This works by setting 2 values when the tetromino is created; the age, which
# starts at 0, and gravity_delay, which starts at some about (lower gravity = higher
# delay), and the delay strives to always remain ahead of the age. If the age
# surpasses the delay, we need to keep dropping the tetromino until we can catch it up
while @current_tetromino[:age] > @current_tetromino[:gravity_delay]
@current_tetromino[:y] -= 1
# If you move downward, the lockdown delay AND # of extensions are reset
reset_lock_down_delay(true)
if current_tetromino_colliding_y?
break
end
# The higher the gravity, the more of a shove we give the delay; at the starting
# gravity 0.01667, this adds 59.98 frames to the delay, for example, causing a
# drop rate of roughly 1/sec at 60FPS. At 1G, this will bump the delay by only 1 frame,
# causing a 60 Hz drop. Essentially, the higher the gravity, the slower the delay catches
# up to the age, causing the tetromino to drop more cells in that frame.
@current_tetromino[:gravity_delay] += 1 / @gravity
end
@current_tetromino[:age] += 1 if @current_tetromino
end
def game_tick
handle_delayed_procs
handle_input
if @current_tetromino
apply_gravity unless current_tetromino_colliding_y?
# Setting this starts the lock down, which can no longer
# be stopped even if you shift off the stack
@current_tetromino[:lock_down] = true if current_tetromino_colliding_y? && !locking_down?
lock_down if locking_down?
end
clear_lines
end
def tick
game_tick
render
end
end
def tick(args)
args.state.game ||= TetrisGame.new(args)
args.state.game.tick
end
$gtk.reset
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment