Skip to content

Instantly share code, notes, and snippets.

@subratrout
Forked from JoshCheek/tetris
Created November 4, 2022 18:58
Show Gist options
  • Save subratrout/189057f54f54148b4355e3a32db76830 to your computer and use it in GitHub Desktop.
Save subratrout/189057f54f54148b4355e3a32db76830 to your computer and use it in GitHub Desktop.
CLI tetris
#!/usr/bin/env ruby
require 'io/console'
class Grid
def self.from_str(str, color:)
rows = str.lines.map do |line|
line.chomp.chars.map do |c|
color if c != " ".freeze
end
end
new rows: rows
end
attr_reader :rows
def initialize(rows:)
@rows = rows
end
def height
rows.size
end
def width
return 0 if rows.empty?
rows.first.size
end
def empty?(x:, y:)
rows[y][x].nil?
end
def lay(piece, x:, y:)
piece.each_filled_spot do |value, vx, vy|
rows[y+vy][x+vx] = value || rows[y][x]
end
nil
end
def can_lay?(piece, x:, y:)
piece.each_filled_spot.each do |_, vx, vy|
vx += x
vy += y
return false if vx >= width
return false if vy >= height
return false if rows[vy][vx]
end
true
end
def each_filled_spot
return to_enum __method__ unless block_given?
rows.each_with_index do |row, y|
row.each_with_index do |value, x|
yield value, x, y if value
end
end
end
def inspect(map = Hash.new { |h,k| k.to_s[0] })
rows.map { |row|
row.slice_when { |pred, succ| pred != succ }
.map { |chunk|
color = map[chunk[0] || :background]
"#{color}#{' '*chunk.size}"
}.join("") + map[:no_color]
}.join("\n")
end
def rotate
self.class.new rows: rows.transpose.each(&:reverse!)
end
end
class Tetris
def initialize(random:)
self.random = random
self.upcoming = []
self.board = Grid.new rows: 20.times.map { [nil]*10 }
self.pieces = [
Grid.from_str("****\n", color: :cyan),
Grid.from_str("***\n"+
" * \n", color: :purple),
Grid.from_str("** \n"+
" **\n", color: :red),
Grid.from_str(" **\n"+
"** \n", color: :green),
Grid.from_str("**\n"+
"**\n", color: :yellow),
Grid.from_str("* \n"+
"***\n", color: :blue),
Grid.from_str(" *\n"+
"***\n", color: :orange),
]
fill_upcoming
self.current = random_piece
self.current_position = [5-current.width/2, 0]
end
attr_reader :board, :upcoming, :current, :current_position
def tick_duration
0.5 # second
end
def tick
x, y = current_position
if board.can_lay? current, x: x, y: y+1
self.current_position = [x, y+1]
[:current_falls, x, y, y+1]
else
board.lay current, x: x, y: y
self.current = upcoming.shift
self.current_position = [5-current.width/2, 0]
if board.can_lay? current, x: current_position[0], y: current_position[1]
clear_rows
fill_upcoming
[:current_lands, x, y]
else
[:game_over]
end
end
end
def height
board.height
end
def width
board.width
end
def left
[current, current_position, current, move(-1, 0)]
end
def right
[current, current_position, current, move(1, 0)]
end
def down
[current, current_position, current, move(0, 1)]
end
def rotate
old = current
rotated = current.rotate
self.current = rotated if can_place? rotated, *current_position
[old, current_position, current, current_position]
end
private
attr_writer :board, :pieces, :upcoming, :random, :current, :current_position
attr_reader :pieces, :random
def move(∆x, ∆y)
x, y = current_position
x += ∆x
y += ∆y
self.current_position = [x, y] if can_place?(current, x, y)
current_position
end
def clear_rows
h = height
rows = board.rows.map(&:dup).select { |row| row.include? nil }
rows.unshift rows.first.map { nil } while rows.size < h
self.board = Grid.new rows: rows
end
def can_place?(piece, x, y)
return false if x < 0 || y < 0
return false if (width - piece.width) < x
return false if (height - piece.height) < y
piece.each_filled_spot.all? do |_, ∆x, ∆y|
board.empty? x: x+∆x, y: y+∆y
end
end
def fill_upcoming
upcoming << random_piece until upcoming.size == 3
end
def random_piece
pieces[random.rand pieces.size]
end
end
class TerminalTetris
ANSI = {
white: "\e[48;2;255;255;255m",
black: "\e[48;2;0;0;0",
cyan: "\e[48;2;0;255;255m",
red: "\e[48;2;255;0;0m",
orange: "\e[48;2;255;128;0m",
blue: "\e[48;2;0;0;255m",
yellow: "\e[48;2;255;255;0m",
green: "\e[48;2;0;255;0m",
purple: "\e[48;2;150;0;150m",
background: "\e[48;2;0;0;0m",
clear_screen: "\e[H\e[2J",
no_color: "\e[0m",
up: "\e[A",
down: "\e[B",
left: "\e[C",
right: "\e[D",
hide_cursor: "\e[?25l",
show_cursor: "\e[?25h",
raw_newline: "\r\e[B",
}
ANSI.default_proc = lambda do |h, k|
raise KeyError, "key not found: #{k.inspect}"
end
attr_reader :game, :instream, :outstream, :events
def initialize(random:, instream:, outstream:)
@game = Tetris.new random: random
@instream = instream
@outstream = outstream
@events = Queue.new
end
def start
outstream.print ANSI[:hide_cursor]
display
finished = false
Thread.new do
Thread.current.abort_on_exception = true
until finished
sleep game.tick_duration
events << :tick
end
end
instream.raw!
pauser = Queue.new
Thread.new do
Thread.current.abort_on_exception = true
loop do
case instream.readpartial 100
when "\e[A" then events << :key_up
when "\e[B" then events << :key_down
when "\e[C" then events << :key_right
when "\e[D" then events << :key_left
when ?\C-c then events << :interrupt
when ?\C-d then events << :end_of_input
when "q" then events << :quit
when ?\C-l then events << :redraw
when "w" then events << :wtf
pauser.shift
end
end
end
loop do
event = events.shift
case event
when :wtf
instream.cooked!
require "pry"
binding().pry
instream.raw!
pauser << :unpause
when :tick
result, *vars = game.tick
case result
when :current_falls
x, old_y, new_y = vars
change game.current, [x, old_y], game.current, [x, new_y]
when :current_lands
draw game.current, game.current_position
display clear: false
when :game_over
break
else
raise "Handle result: #{result.inspect}"
end
when :key_up
change *game.rotate
when :key_down
change *game.down
when :key_right
change *game.right
when :key_left
change *game.left
when :interrupt, :end_of_input, :quit
break
when :redraw
display
else
raise "Handle event: #{event.inspect}"
end
end
finished = true
ensure
instream.cooked!
outstream.print ANSI[:show_cursor]
outstream.print goto(x: 1, y: 25)
end
def display(clear: true)
outstream.print ANSI[:no_color]
outstream.print ANSI[:clear_screen] if clear
outstream.print goto(x: 0, y: 0)
outstream.print ANSI[:raw_newline]
game.board.inspect(ANSI).each_line do |line|
outstream.print " #{line.chomp}#{ANSI[:raw_newline]}"
end
outstream.print ANSI[:raw_newline]
outstream.print ANSI[:raw_newline]
outstream.print "left/right to move, down to accelerate, up to rotate"
draw game.current, game.current_position
display_upcoming
end
def display_upcoming
outstream.print ANSI[:no_color]
outstream.print goto(x: 30, y: 2)
outstream.print "Next:"
width, height = 6, 1 + game.upcoming.size * 3
upcoming_grid = Grid.new rows: height.times.map { [nil] * width }
game.upcoming.each_with_index do |piece, i|
upcoming_grid.lay piece, x: 1, y: 1+i*3
end
upcoming_grid.inspect(ANSI).lines.each do |line|
outstream.print ANSI[:down]
outstream.print column(30)
outstream.print line.chomp
end
end
private
def change(old_piece, old_position, new_piece, new_position)
draw old_piece, old_position, erase: true
draw new_piece, new_position
end
def draw(piece, (x, y), erase: false)
piece.each_filled_spot do |color, px, py|
outstream.print goto(
x: 3+(x+px)*2,
y: 2+y+py,
)
color = :background if erase
outstream.print "#{ANSI[color]} "
end
end
def goto(x:, y:)
"\e[#{y};#{x}H"
end
def column(x)
"\e[#{x}G"
end
end
tetris = TerminalTetris.new(
random: Random::DEFAULT,
instream: $stdin,
outstream: $stdout,
)
tetris.start
#!/usr/bin/env ruby
require 'io/console'
PIECES = [
["****", 0, 255, 255],
["***\n * ", 150, 0, 150],
["** \n **", 255, 0, 0],
[" **\n** ", 0, 255, 0],
["**\n**", 255, 255, 0],
["* \n***", 0, 0, 255],
[" *\n***", 255, 128, 0],
].map { |str, r, g, b| str.lines.map { |line|
line.chomp.chars.map { |c| "\e[48;2;#{r};#{g};#{b}m" if c != " " }
} }
singleton_class.class_eval { attr_accessor :board, :upcoming, :current, :position }
self.upcoming = 3.times.map { PIECES.sample }
self.current = PIECES.sample
self.position = [5-current.first.size/2, 0]
self.board = 20.times.map { [nil]*10 }
def update_position(∆x, ∆y)
x, y = position
can_lay?(board, current, x+∆x, y+∆y) ? self.position = [x+∆x, y+∆y] : position
end
def can_lay?(base, piece, x, y)
return false if x < 0 || y < 0 || 10-piece[0].size < x || 20-piece.size < y
each_filled_spot(piece).none? { |_, ∆x, ∆y| base[y+∆y][x+∆x] }
end
def lay(rows, piece, x, y)
each_filled_spot(piece) { |value, vx, vy| rows[y+vy][x+vx] = value || rows[y][x] }
end
def each_filled_spot(rows)
return to_enum __method__, rows unless block_given?
rows.each_with_index do |row, y|
row.each_with_index { |val, x| yield val, x, y if val }
end
end
def grid_lines(rows)
rows.map do |row|
row.slice_when { |pred, succ| pred != succ }
.map { |chunk| "#{chunk[0] || "\e[48;2;0;0;0m"}#{' '*chunk.size}" }
.join("") << "\e[0m"
end
end
def draw_piece(piece, (x, y), erase: false)
each_filled_spot(piece) { |color, px, py| print "\e[#{2+y+py};#{3+(x+px)*2}H#{erase ? "\e[48;2;0;0;0m" : color} " }
end
def change(old_piece, old_position, new_piece, new_position)
draw_piece old_piece, old_position, erase: true
draw_piece new_piece, new_position
end
$stdin.raw!
Thread.new do
Thread.current.abort_on_exception = true
loop do
case $stdin.readpartial 100
when "\e[A"
old, rotated = current, current.transpose.each(&:reverse!)
self.current = rotated if can_lay? board, rotated, *position
change old, position, current, position
when "\e[B" then change current, position, current, update_position(0, 1)
when "\e[C" then change current, position, current, update_position(1, 0)
when "\e[D" then change current, position, current, update_position(-1, 0)
when ?\C-c, ?\C-d, "q" then exit
end
end
end
at_exit { print "\e[?25h\e[24;1H" || $stdin.cooked! }
loop do
print "\e[?25l\e[0m\e[H\e[2J\e[0;0H\r\e[B"
grid_lines(board).each { |line| print " #{line.chomp}\r\e[B" }
print "\r\e[B Left / right to move, down to accelerate, up to rotate\e[0m\e[2;30HNext:"
upcoming_grid = (1 + upcoming.size * 3).times.map { [nil] * 6 }
upcoming.each_with_index { |piece, i| lay upcoming_grid, piece, 1, 1+i*3 }
grid_lines(upcoming_grid).each { |line| print "\e[B\e[30G#{line.chomp}" }
draw_piece current, position
sleep 0.5
x, y = position
if can_lay? board, current, x, y+1
self.position = [x, y+1]
change current, [x, y], current, position
else
lay board, current, x, y
self.current, self.position = upcoming.shift, [5-current[0].size/2, 0]
exit unless can_lay? board, current, *position
self.board = board.map(&:dup).select { |row| row.include? nil }
board.unshift board.first.map { nil } while board.size < 20
upcoming << PIECES.sample
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment