-
-
Save subratrout/189057f54f54148b4355e3a32db76830 to your computer and use it in GitHub Desktop.
CLI tetris
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
#!/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 |
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
#!/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