#!/usr/bin/ruby require "io/console" _ = I = [[0, -1], [0, 1], [1, 0], [-1, 0], nil] K = "\e[" A = [K + "0m", K + "1m"] + "#,'h_(e(#kek#df/)de/`(f)f.#j".scan(/../).map{|z| K + "#{z.ord - 5};#{z[1].ord}m" } + "## []()@@OO%%XX++".scan(/../) R = "arrrzzesarrzgtarrzessarzgttarzfsssazhtttazesssazgtttazdqzzcrzzfszeszgtzhtzfuzhvzkwzlqziwzjqzixzjyzmuznvzdszzdtzza".split(/z/).map{|z| z == "" ? _ : _ = z.bytes.map{|a| A[a - 97]} * "" } U, G = A # Check window dimensions. Game grid will be sized accordingly. # # We need at least enough columns to hold the status bar, and enough # rows such that there will be room to place items. # # By the way, you might think that a really small grid will make an easy # game, but what that really provides is a short game. It's actually # more difficult to score long chains with a small grid because there is # less room to maneuver. h, w = IO.console.winsize if h < 12 || w < 40 print "Console too small\n" exit end # Convert console dimensions to game grid dimensions. # - Width minus 1 is to avoid margins, otherwise things may wrap poorly. # - Height minus 2 is to account for for margin and status bar. # # The divide by 2 is to account for 1:2 aspect ratio of the character # cell. Well, my terminal fonts are close enough to 1:2, yours might # be vastly different, but there isn't a cheap way to detect and # adjust for this. w = ~-w / 2 h -= 2 # Game states. r = # True if game is still in progress. c = 1 # Current player color (0 or 1). a = # Current direction. v = 2 # Desired length of player. b = # Game field. d = [] # Previous player positions. e = 8 # Color of last item eaten (7 or 8). s = # Player score. o = # Number of items in current chain. n = # Current chain length. m = # Maximum achieved chain length. tf = # 1 bit frame counter, for blinking effect. j = # Next position to write to in input_buffer. k = # Next position to read in input_buffer. g = 0 # Shuffled positions read index. f = # Next item to be placed. qp = nil # True if endgame is in effect. # Movement speed in number of units moved per second. Use command line # argument if that's specified, otherwise default to 5 cells per second. # # Note that slowest allowed speed is 0.1 cells per second, so players can # wait up to 10 seconds to make a move. We used to allow even longer # delays when there is an explicit quit function, but since that's # removed and player must rely on death to end the game, we want to put # some bounds on how long the player must wait for games to end. t = ARGV[0].to_f td = t >= 0.1 ? 1 / t : 0.2 # Ring buffer of next directions, written by input_thread and read by # main thread. We buffer inputs so that quick consecutive keystrokes # are not lost. l = [0] * 8 # Shuffled list of positions, used for item generation. z = [*1..(w - 2)].product([*1..(h - 2)]).shuffle # Initialize game field. h.times{|i| b += [[5] + [i > 0 && i < h - 1 ? 6 : 5] * (w - 2) + [5]] } # Mark initial player location. b[q = h / 2][p = w / 4] = 9 # Place the first item just in front of the player. This guarantees that # there is at least one item. # # First item is white, since player starts off in white. b[q][w - p] = 8 # Item serial number starts at 4. It doesn't start at 0 because of the # first item that was just placed above, and it doesn't start at 1 because # how we have arranged the item sequence to be [black*3, white*3]. t = 4 # Check if a position is eligible for placing new items, based on whether # it's near the player's head and whether it is adjacent to items of # the same color. # # Note that the heuristics here will also cause us to avoid placing # new items near walls, or near player's tail. # # Sets new_item to be the next item to be generated. ef = ->(x, y) { # Items are generated in groups of 3s. This is so that player can # complete chains even when the number of available items is small. f = t % 6 / 3 + 7 i = *-1..1 i.product(i).all?{|p, q| i = b[y + q][x + p] i == f || i == 6 } } # Try assigning initial set of items. z.each{|x, y| if # Avoid the rows near player's starting position. (y - q).abs > 1 && # Unconditionally skip some of the cells, otherwise the initial # grid is too dense. rand < 0.1 && # Update new_item, and verify that the selected position is eligible. # # We used to also enforce that the initial set of items do not # form excessively large clusters, but that is mostly covered by # "rand < 0.1" constraint above. ef.call(x, y) b[y][x] = f t += 1 end } # Render function. rf = -> { # Build status line. # 1. Draw current chain status (6 characters wide) on the left. # 2. Draw score on the right (right-aligned). # 3. Fill the middle bits with current chain length. # # The middle bit used to only grow up to 9 units (i.e. max chain length), # but it looks nicer to make use of the full width, so that's what we have # done. To indicate that the max chain has been reached, the bar color # changes from cyan to bright cyan, and chain text changes to uppercase. x = " #{s}" y = " " * (w * 2 - 6 - x.size) if n > 0 y = " %-#{w * 2 - 8 - x.size}s" % "#{n} chain" xy = A[n > 9 ? (y.upcase!; 15) : 14] i = [y.size, n * 2].min y = xy + y[0, i] + K + "0m" + y[i, y.size] end print K + "#{h + 1}A\r" + R[o * 2 + e - 7] + y + G + x + U + "\n\r" + b.map{|p| p.map{|q| R[q * 2 + c]} * "" } * "#{U}\n\r" + "\n" } # Input loop. This happens in a separate thread so that the main # display loop runs more or less at constant rate. ti = Thread.new{ while r # Wait for keystroke. # # Alternatively, can read this without a timeout so that we can # poll for game status once in a while (and thus exit cleanly # when game has ended), but on windows that causes a serious # input lag, so now we just do a blocking read. # # But this leads to a separate problem where if we just kill the # input thread while it's waiting for keystroke, the terminal # might be left in a bad state (e.g. with echo off), so we force # the player to feed an extra keystroke on exit. ik = STDIN.getch id = K.index(ik) ? nil : ("AwBsCdDa".index(ik) || 8) / 2 # Add input to ring buffer. We used to try to apply the action # right away, but no matter how fast we do it, some keys just # always get lost, so now we buffer them. # # Also, it used to be that moving in the same direction causes # player to change color, but that leads to some unpredictable # color changes, so now we require the dedicated key for that # purpose. (Previously this was a nice feature because it made # it easier to play one-handed, but I suppose that's still # possible depending on keyboard layout). if id l[iw = -~j % 8] = id # Update index after array element has been written, to avoid a # race with reading the array contents. j = iw end end } # Disable echo, hide cursor, and clear window. STDIN.echo = false print "\e[?25l\n" * h # Draw initial frame. rf.call # Game loop. t0 = Time::now while r # Make chain status blink if we are currently at max chain. if o > 2 print K + "#{h + 1}A\r#{R[e - 1 + tf]}\e[#{h + 1}B" tf ^= 2 end # Apply buffered action. if k != j i = I[x = l[k = -~k % 8]] y = i ? b[q + i[1]][p + i[0]] : (c ^= 1) # Do not allow player to turn back onto themselves, or into a wall. # For a color change, y evaluates to 0 or 2, which is outside # of the range of the allowed turn destinations. # # Accepted cells are 6=space, 7=black item, 8=white item. if y > 5 && y < 9 a = x # Cause direction changes to take effect right away. t0 = Time.at(0) end # Force immediate redraw on input. This is really only needed # on color change, but we save 4 bytes if we do it here. rf.call end # Apply movement based on elapsed time, as opposed to using the # frame counter. This appears to be more reliable than trying to # maintain a constant frame rate via sleep. # # On the other hand, not doing any sleep means this script will peg # one CPU core, which is unfortunate. Again, blame it on windows, # where any kind of sleep or timeout just don't get the same level # of precision we get with linux. t1 = Time::now if t1 - t0 < td # Wait a bit before polling input queue again. sleep 0.02 next end t0 = t1 # Mark old player positions. d += u = [[p, q]] u += [d[d.size / 2], d[d.size / 4]] 3.times{|i| b[u[i][1]][u[i][0]] = 10 + i} # Update player position. x = I[a] p += x[0] q += x[1] i = b[q][p] if i != 6 # Collision check. if i == c + 7 # Update score. s += 10 o = o % 3 > 0 ? e == i ? o == 2 ? (n += 1; m = [m, n].max; s += 2 ** [n - 1, 8].min * 100; 3) : o + 1 : (e = i; n = 0; 1) : (e = i; 1) v += 2 # Mark new player position. This also removes the item from # current cell. b[q][p] = 9 # Add new item. Do this in three passes: # - First pass avoids placing items near the player head or # existing items. # - Second pass avoids placing items near the player head. # - Third pass takes whatever spot is available. pq = nil 3.times{|i| z.size.times{ g = -~g % z.size x, y = z[g] # Conditions for accepting new position: # [0] Item has not been placed yet. # [1] Grid cell is space. # [2] New position is not near player (pass 0 and pass 1). # [3] New position is not near existing items (pass 0). if [!pq, b[y][x] == 6, (x - p).abs > 3 || (y - q).abs > 3, ef.call(x, y)].take(4 - i).all? # Enter endgame if position is assigned in pass 1 or 2, # and update hint display. # # If we are already in endgame, these steps would be # redundant, but it's OK since item color would already # be fixed. qp ||= i > 0 && (b[h - 1][w - 1] = f + 7) # Place item. b[y][x] = f # Mark that an item has been placed, but also occasionally # try to place more than 1 item. All variants of snake # games I have played will generate a single item for # every item eaten, so this behavior differs from most # classical snake games, and that's exactly why I do it. # # This also means the grid gets congested slightly faster # than players might expect it. The effect is somewhat # subtle at 0.1, more obvious at 0.2, and quite unsettling # at 0.4. We will go with the subtle threshold here. pq = rand > 0.1 # Increase serial number if we have not entered endgame # yet. This means item colors will alternate every 3 # items before endgame, and remain fixed during endgame. t += qp ? 0 : 1 end } } # Redraw grid after applying movement. rf.call next end # Mark mark tombstone. b[q][p] = 13 rf.call # Game over. # 12345678901234567 # " GAME OVER " # " (press any key) " print K + "#{h / 2 + 2}A\r\e[#{w - 8}C#{U} GAME OVER \e[17D\e[2B (press any key) ", "\n\r" * (h / 2 - 1) r = nil next end # Mark new player position. b[q][p] = 9 u += [[p, q]] # Grow player. if d.size > v x, y = d.shift b[y][x] = 6 u += [[x, y]] # Stop flashing chain status once player has stopped growing. if o == 3 o = 0 # Need to do a full redraw to clear the status line. rf.call # Don't need to draw the dirty pixels since we did a full redraw, # but we will draw them anyways, since it saves 4 bytes. end end # Redraw the dirty pixels. print K + "#{h}A" + u.map{|x, y| "\e[#{y}B\r\e[#{x * 2}C#{R[b[y][x] * 2 + c]}\e[#{y}A" } * "" + K + "#{h}B" end # Wait for input thread to consume the extra keystroke after the game # over message. ti.join # Draw extra frame to erase the game over message. rf.call # Ikaruga gives out a grade at the end of each level purely based on # score. We can also do a score-based thing for assigning grades, but: # # - Due to exponential bonus from chains, getting high scores will # require maximizing number of consecutive chains. # # - Snakes is hard enough as it is, maximizing for consecutive chains # means most players will not survive very long. # # In other words, even though we would like to reward both grid # coverage and high score, those two are somewhat mutually exclusive. # # Instead of choosing one, high grades are given if players have a # high completion ratio *or* a high score. # # Players who did not make any chains are always assigned "dot eater" # grade. Due to how item colors are fixed in endgame phase, players # will inevitably form a few chains if they covered enough area, so # it's impossible to reach 100% while maintaining dot eater grade. a = (h - 2) * (w - 2) g = "Dot eater" if m > 0 # max_items is calculated as half of area since we grow 2 cells per item # eaten, so this number should provide sufficient growth for the player # to cover the entire grid. # # But this is actually just an approximation, sometimes we don't generate # any new items, and sometimes we generate more than 1 item. For grading # purposes, it's a good enough approximation. i = a / 2 4.times{|f| g = d.size > a * (f * 0.3 - 0.1) || s > i * 10 + (i / 3) * 100 * 2 ** (f * 3 - 2) ? "CBAS"[f] : g } end # Restore cursor and output final game stats. print "#{U}\e[?25h\nArea: #{G}#{d.size * 100 / a}%#{U}\nScore: #{G}#{s}#{U}\nMax: #{G}#{m} chain#{U}\nGrade: #{G}#{g}#{U}\n" # Restore echo. STDIN.echo = true