#!/usr/bin/ruby require "io/console" # Grid dimensions. Standard Puyo Puyo is 6x12. W = 6 H = 12 # Delay between each frame. D = 0.03 # Horizontal text position, relative to upper left corner of grid. T = W * 2 + 4 # Offsets for the second tile. 0 is the initial orientation, with the # second tile just beyond top of screen. Subsequent rotations are # clockwise such that first rotation would make the block flat and centered. X = [0, 1, 0, -1] Y = [1, 0, -1, 0] # Block data. class B attr_accessor :x, :y, :o, :c end # Draw text at specified offset, and return cursor to starting position. def p(x, y, z) i = z.gsub(/\e\[[^m]+m/, "").length print (y > 0 ? "\e[#{x}C\e[#{y}B#{z}\e[0m\e[#{x + i}D\e[#{y}A" : "\e[#{x}C#{z}\e[0m\e[#{x + i}D") end # Return color and pattern for a single tile. def m(t, c) ["\e[97;#{101 + c}m", "\e[#{91 + c};40m", "\e[#{31 + c};107m"][t] + "<>(){}[]"[c * 2, 2] end # If additional command line arguments are available, use those as the # input stream. Otherwise, determine whether to read from keyboard or # pipe based on whether stdin is a TTY. $h = ARGV.length > 1 ? ARGV[1, ARGV.length - 1] * "" : STDIN.isatty ? nil : STDIN.read # Generate grid image with overlays. def q(g, o, z) # Convert column-major bottom-up grid to row-major top-down cells. # g uses [x][y] indices where y=0 is bottommost row. # h uses [y][x] indices where y=0 is top of screen. h = [] (H - 1).downto(0) {|y| h.push([]) W.times{|x| t = g[x][y] h[-1].push(t ? m(z[[x, y]] ? 2 : 0, t) : nil) } } # Draw pending tile. if o # Make sure we draw the bottom cell first. t = [ [o.x, o.y, o.c[0]], [o.x + X[o.o], o.y + Y[o.o], o.c[1]] ] t[0], t[1] = t[1], t[0] if t[0][1] > t[1][1] 2.times{|i| x = t[i][0] y = t[i][1] next if x < 0 or x >= W # Draw in-flight cell. h[H - y - 1][x] = m(0, t[i][2]) if y >= 0 and y < H # Draw drop position. y.times{|d| if d >= 0 and d < H and not h[H - d - 1][x] h[H - d - 1][x] = m(1, t[i][2]) break end } } end # Flatten cells, joining rows with escape code to move cursor to next line. t = h.map{|r| # Flatten each row, replacing empty cells with two spaces. r.map{|c| c or "\e[40m "} * "" } * ("\e[1B\e[#{W * 2}D") # Move cursor back to original position t += "\e[#{H - 1}A\e[#{W * 2}D" end # Test for collision with grid, returns nil if there is no collision. def w(o, g) return 1 if o.x < 0 or o.x >= g.length or g[o.x][o.y] x1 = o.x + X[o.o] y1 = o.y + Y[o.o] (x1 < 0 or x1 >= g.length or g[x1][y1]) ? 1 : nil end # Initialize PRNG seed to first command line argument if it's defined, # otherwise use current time. S = s = (ARGV.length > 0 ? ARGV[0] : Time.now).to_i # Hide cursor, disable echo, draw screen decorations, and leave cursor at # upper left corner of grid. STDIN.echo=false print "\e[?25l#{(("#" * (W * 2 + 20)) + "\n") * (H + 2)}\e[#{H + 1}A\e[2C" p(T, 0, " Next: ") p(T, 4, " Block: ") p(T, 7, " Score: ") # Initialize game state. g = [] W.times{ g.push([]) } # Generate blocks with equal distribution. l = [] 6.times{ 4.times{|i| 4.times{|j| l.push([i, j]) } } } # Add a few more single-color ones so that we get exactly 100 blocks. 4.times{|i| l.push([i, i]) } # Fisher-Yates shuffle. (l.length - 1).downto 1 do |i| # Generate a 31bit random number. This is almost identical to the # algorithm described here: # https://en.wikipedia.org/wiki/Xorshift # # Except it's 31bits because we drop the sign bit, and we drop the # sign bit because right shift may or may not do sign extension # depending on language, and we want to have a portable random number # sequence that we can use elsewhere. s ^= s << 13 s &= 0x7fffffff s ^= s >> 17 s ^= s << 5 s &= 0x7fffffff j = s % (i + 1) l[i], l[j] = l[j], l[i] end f = [] u = 0 e = 0 h = "" # Run game for each block. l.length.times{|a| # Display next two blocks. 2.times{|i| b = l[a + i + 1] t = b ? ["\e[40m #{m(0, b[0])}\e[40m ", "\e[40m #{m(0, b[1])}\e[40m "] : ["####"] * 2 # Note that because the initial orientation has tile[1] on top # of tile[0], we will render the tiles upside down here as well. p(T + i * 4, 1, t[1]) p(T + i * 4, 2, t[0]) } # Display block index. p(T, 5, "\e[97;40m #{a + 1} / #{l.length} ") # Display score. p(T, 8, "\e[97;40m #{e} ") # Get next block to be placed. o = B.new o.x = (W - 1) / 2 o.y = H - 1 o.o = 0 o.c = l[a] # If we got a collision immediately after generating a new # block, it means grid is completely filled, i.e. game over. break if w(o, g) # If we didn't see a collision right after the block is spawned, # we will not see another collision for the lifetime of this # block, since the various movement functions will enforce no # collisions. That said, it may very well be the case that the # block can't be moved at all, and can only be dropped. while 1 print q(g, o, {}) # Read next input command from either keyboard or $h. c = nil # Repeat until we got a valid key. This also skips over all the # extra bytes from arrow keys encoded as escape sequences. while not c if $h break if $h.length < 1 n = $h[0] $h = $h[1, $h.length - 1] else n = STDIN.getch end # Convert arrow keys to VI keys. c = { 'A' => 'k', 'k' => 'k', 'B' => 'j', 'j' => 'j', 'C' => 'l', 'l' => 'l', 'D' => 'h', 'h' => 'h', 'x' => 'q', 'q' => 'q' }[n] end case c when 'k' # Up = rotate block n = o.clone n.o = (n.o + 1) % 4 # Check if the second tile collides with the grid after rotation. if w(n, g) if X[n.o] == 0 # Colliding vertically, try shift the block up just # above the grid. n.y += 1 else # Colliding horizontally. Try shift the block in the # opposite direction to resolve the collision, i.e. # translate the rotation to a "kick" off the wall. n.x -= X[n.o] n = nil if w(n, g) end end if n h += c o = n end when 'j' # Down = drop block # Mark grid location with pending block. g[o.x][o.y] = o.c[0] g[o.x + X[o.o]][o.y + Y[o.o]] = o.c[1] h += c break when 'h', 'l' # Left or right = lateral movement. n = o.clone n.x += c == 'h' ? -1 : 1 if not w(n, g) # Try bring the block down to usual height if possible. if n.y != H - 1 d = n.clone d.y = H - 1 n = d if not w(d, g) end h += c o = n end else c = nil break end end # Check if user wants to quit. break if not c # One point for each block dropped. # # Puyo Puyo has some opaque formula on how drop bonus is computed and # I can't find any documentation on it. It's probably something based # on time, but since this version doesn't have any time pressure for # dropping blocks, we will just give a constant score all the time. e += 1 # Repeat until there are no blocks to be cleared. n = 0 while 1 # Apply gravity until blocks fell to the bottom. print q(g, nil, {}) while 1 r = [] v = false g.length.times{|x| y = g[x].index(nil) if y r.push(g[x][0, y] + g[x][y + 1, g[x].length - 1]) v = true else r.push(g[x]) end } break if not v g = r sleep D print q(g, nil, {}) end # Mark clusters that are suitable for removal in grid. r = [] # Set of tiles that have already been marked from previous depth-first # search expansions. This is to prevent double-counting tiles. v = {} g.length.times{|i| g[i].length.times{|j| # Perform depth-first search starting at current cell. c = g[i][j] t = [] z = [[i, j]] while z.length > 0 x, y = z.pop next if x < 0 or y < 0 or x >= g.length or y >= g[x].length or v[[x, y]] or g[x][y] != c v[[x, y]] = 1 t.push([x, y]) z.push([x + 1, y]) z.push([x - 1, y]) z.push([x, y + 1]) z.push([x, y - 1]) end # Add sub-cluster to list of clusters if it's sufficiently large. r.push(t) if t.length > 3 } } # Remove clusters. break if r.length < 1 n += 1 p(T, H - 1, "\e[97;40m #{n} chain ") v = {} r.each{|t| t.each{|x| v[[x[0], x[1]]] = 1 } } x = q(g, nil, {}) y = q(g, nil, v) 10.times{ print x sleep D print y sleep D } # Compute score based on list of clusters cleared. # # Rules taken from this page: # https://puyonexus.com/wiki/Scoring x = 0 y = 0 z = {} r.each{|t| z[g[t[0][0]][t[0][1]]] = 1 x += t.length y += t.length > 4 ? t.length > 10 ? 10 : t.length - 3 : 0 } z = (n > 1 ? 2 * 2 ** n : 0) + [nil, 0, 3, 6, 12][z.keys.length] + y e += 10 * x * [[1, z].max, 999].min p(T, 8, "\e[97;40m #{e} ") # Remove tiles. Do this after computing score since we need # the tiles to compute color bonus. v.each_key{|t| g[t[0]][t[1]] = nil } u += x end f.push(n) # Overwrite chain label text. p(T, H - 1, "\e[0m" + "#" * 12) } if f.length < l.length x = "" y = "\e[91mGAME OVER" else # Give a ranking based on score. # # To get the "minimalist" ranking (inspired by Ikaruga's "Dot Eater" # ranking), you will need to score the minimum number of points # possible: # # (total number of tiles - tiles that can be held in the field) * 10 # + (points for dropping all the tiles) # # It's actually possible to get slightly lower score by making use of # the overflow rows, but they only help a little bit. The main # challenge is to make sure to not make accidental chains, which is # counter intuitive to how people usually play Puyo Puyo. x = "MINIMALIST" [[(l.length * 2 - W * H) * 10 + l.length, "NOVICE"], [9999, "HOBBYIST"], [19999, "ENTHUSIAST"], [49999, "PROFESSIONAL"], [99999, "MASTER"], [499999, "GRANDMASTER"]].each{|s| x = s[1] if e > s[0] } x = "rank = \e[4;1m#{x}\e[0m\n" y = "\e[92mYOU WIN :)" end # Output final text. STDIN.echo=true print "\e[0m\n" * (H + 2), "\e[?25h#{y}\e[0m\n\nscore = #{e}\nblocks = #{f.length} / #{l.length}\ntiles cleared = #{u}\n", x # Output chain histogram for each block. if f.length > 0 and f.max > 0 print "\nChain history:\n" y = f.max + 2 y += 1 if y % 2 == 1 until y == 0 y -= 2 x = 0 until x >= f.length v = f[x, 2].max print "8o_ "[v > y + 1 ? 0 : v == y + 1 ? 1 : y == 0 ? 2 : 3] x += 2 end print "\n" end # Mark block index to first occurrence of maximum chain. v = f.index(f.max) print " " * (v / 2), "^\n", " " * (v / 2), "block #{v + 1} = #{f.max} chain\n" end print "\nReplay:\nruby #{$0} #{S} #{h}\n"