/tetris_wip.rb Secret
Last active
March 21, 2023 09:17
WIP DragonRuby Tetris implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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