#!/usr/bin/ruby require "io/console" # Tile indices. SPACE = 4 WALL = 5 ITEM_BLACK = 6 ITEM_WHITE = 7 PLAYER_HEAD = 8 PLAYER_TAIL1 = 9 PLAYER_TAIL2 = 10 PLAYER_TAIL3 = 11 PLAYER_TOMBSTONE = 12 HINT_BLACK = 13 HINT_WHITE = 14 PLAYER_BLACK = 0 PLAYER_WHITE = 2 # Direction constants. DIRECTION_UP = 0 DIRECTION_DOWN = 1 DIRECTION_RIGHT = 2 DIRECTION_LEFT = 3 # Color palette. COLOR_SPACE = "\e[30;44m" # Black on dark blue. COLOR_WALL = "\e[34;104m" # Dark blue on bright blue. COLOR_BLACK0 = "\e[90;40m" # Dark gray on black. COLOR_BLACK1 = "\e[96;40m" # Bright cyan on black. COLOR_WHITE0 = "\e[30;107m" # Black on white. COLOR_WHITE1 = "\e[96;107m" # Bright cyan on white. COLOR_BLACK_TAIL = "\e[30;100m" # Black on dark gray. COLOR_WHITE_TAIL = "\e[97;47m" # White on light gray. COLOR_BLACK_TAIL1 = "\e[36;100m" # Cyan on dark gray. COLOR_WHITE_TAIL1 = "\e[96;47m" # Bright cyan on light gray. COLOR_BLACK_TOMBSTONE = "\e[91;40m" # Bright red on black. COLOR_WHITE_TOMBSTONE = "\e[97;41m" # Bright white on dark red. COLOR_SHORT_CHAIN = "\e[97;46m" # Bright white on dark cyan. COLOR_MAX_CHAIN = "\e[30;106m" # Black on bright cyan. SPRITES = [ # Chain images: # - First index is chain length (0..3) # - Second index is last item color (0 or 2) plus current frame (0 or 1). ["\e[0m "] * 4, [COLOR_BLACK0 + "[]\e[0m "] * 2 + [COLOR_WHITE0 + "()\e[0m "] * 2, [COLOR_BLACK0 + "[][]\e[0m "] * 2 + [COLOR_WHITE0 + "()()\e[0m "] * 2, [COLOR_BLACK0 + "[][][]\e[0m", COLOR_BLACK1 + "[][][]\e[0m", COLOR_WHITE0 + "()()()\e[0m", COLOR_WHITE1 + "()()()\e[0m"], # Tile images: # - First index is grid cell value (matches tile indices constants). # - Second index is player color (0 or 2) plus current frame (0 or 1). [COLOR_SPACE + " "] * 4, [COLOR_WALL + "##"] * 4, # Make the items blink when they match player's current color. [COLOR_BLACK0 + "[]", COLOR_BLACK1 + "[]"] + [COLOR_BLACK0 + "[]"] * 2, [COLOR_WHITE0 + "()"] * 2 + [COLOR_WHITE0 + "()", COLOR_WHITE1 + "()"], # Draw player with current color. Do not blink. [COLOR_BLACK1 + "@@"] * 2 + [COLOR_WHITE1 + "OO"] * 2, # Draw player tail with a slightly different color from the items, # otherwise it's difficult to tell the player apart from the items. # # We used to also use different tiles for different turn directions, # but that honestly did not improve the visuals by much. It might # have been useful if we use unicode line drawing characters, but for # compatibility reasons we don't use those. # # It is helpful to have different characters for different segment # of the player's tail, so that they can gauge which direction of # an enclosed area is more likely to open up soon. 3 color/character # combinations are used below for that purpose. [COLOR_BLACK_TAIL1 + "%%"] * 2 + [COLOR_WHITE_TAIL1 + "##"] * 2, [COLOR_BLACK_TAIL + "%%"] * 2 + [COLOR_WHITE_TAIL + "##"] * 2, [COLOR_BLACK_TAIL + "XX"] * 2 + [COLOR_WHITE_TAIL + "++"] * 2, # Highlight where player died. [COLOR_BLACK_TOMBSTONE + "@@"] * 2 + [COLOR_WHITE_TOMBSTONE + "OO"] * 2, # Debugging hint for when we have entered endgame. [COLOR_WALL + "[]"] * 4, [COLOR_WALL + "()"] * 4 ] # We would like a bit of delay between each frame so that we don't peg # the CPU, but on windows this causes a significant drop in frame # rate. Thus we do a small measurement here, and for systems that # don't have the sleep resolution needed, we just render in a busy # loop without sleeps. frame_delay = 0.01 start_time = Time::now 8.times{ sleep frame_delay } frame_delay = Time::now - start_time > 0.1 ? 0 : frame_delay # 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. height, width = IO.console.winsize if height < 12 || width < 40 print "Window size too small, need at least 12 rows and 40 columns.\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. width = (width - 1) / 2 height -= 2 # Game states. running = true # True if game is still in progress. color = PLAYER_WHITE # Current player color. direction = DIRECTION_RIGHT # Current direction. px = width / 4 # Player position. py = height / 2 history = [] # Previous player positions. growth_target = 2 # Desired length of player. score = 0 # Player score. last_item_color = ITEM_WHITE # Color of last item eaten (6 or 7). chain_item_count = 0 # Number of items in current chain. chain_length = 0 # Current chain length. max_chain_length = 0 # Maximum achieved chain length. generated_item_count = 0 # Number of items generated. frame_parity = 0 # 1 bit frame counter, for blinking effect. fixed_item_color = nil # Always generate items of this color. new_item = nil # Next item to be placed. # Movement speed in number of units moved per second. Use command line # argument if that's specified, otherwise default to 5 cells per second. speed = ARGV[0].to_f movement_delay = speed > 0 ? 1 / speed : 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. input_buffer = [0] * 8 input_write_index = 0 # Next position to write to. input_read_index = 0 # Next position to read from. # Shuffled list of positions, used for item generation. positions = [*1..(width - 2)].product([*1..(height - 2)]).shuffle position_index = 0 # Function to determine next item tile based on number of items # generated so far. Returns ITEM_BLACK or ITEM_WHITE. # # In normal phase, we generate items with the same color in groups of 3s. # During endgame phase, items are always generated with the same color. # # First item is ITEM_WHITE, since player start off in white. next_item_tile = -> { fixed_item_color ? fixed_item_color : 7 - generated_item_count % 6 / 3 } # Initialize game field. grid = [] height.times{grid += [[SPACE] * width]} width.times{|i| grid[0][i] = grid[height - 1][i] = WALL} height.times{|i| grid[i][0] = grid[i][width - 1] = WALL} # Place the first item just in front of the player. This guarantees that # there is at least one item. grid[py][width - px] = next_item_tile.call generated_item_count += 1 # Check if a position is sufficiently far from player's head, returns true # if so. far_away_position = ->(x, y) { (x - px).abs > 3 || (y - py).abs > 3 } # 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 too many 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. eligible_for_placement = ->(x, y, same_neighbor_threshold) { eligible = far_away_position.call(x, y) new_item = next_item_tile.call same_neighbor_count = 0 [*-1..1].each{|dx| [*-1..1].each{|dy| eligible &= grid[y + dy][x + dx] == new_item ? same_neighbor_count += 1 : grid[y + dy][x + dx] == SPACE } } eligible && same_neighbor_count < same_neighbor_threshold } # Try assigning initial set of items. # # Here we might try to generate items such that we end up with with a # multiple of 3. This means players who maintained chain status # perfectly will not be forced to break any chains during endgame # regardless of which color is chosen. But this requires that player # plays perfectly and also that endgame triggers when item count is a # multiple of 3. Since we can't guarantee both to be true, we make a best # effort selection of item color at endgame to avoid breaking chains. positions.each{|x, y| if # Avoid the rows near player's starting position. (y - py).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. # # For initial generation, we do not allow more than 1 adjacent neighbor. # This is to avoid starting off with excessively large clusters of # the same color. eligible_for_placement.call(x, y, 2) grid[y][x] = new_item generated_item_count += 1 end } # Mark initial player location. grid[py][px] = PLAYER_HEAD # Render function. render = -> { # 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. current_chain = SPRITES[chain_item_count][(last_item_color - 6) * 2 + frame_parity] score_status = " " + score.to_s if chain_length > 0 chain_status = " %-#{width * 2 - 8 - score_status.size}s" % "#{chain_length} chain" prefix = chain_length > 9 ? (chain_status.upcase!; COLOR_MAX_CHAIN) : COLOR_SHORT_CHAIN i = [chain_status.size, chain_length * 2].min chain_status = prefix + chain_status[0, i] + "\e[0m" + chain_status[i, chain_status.size] else chain_status = " " * (width * 2 - 6 - score_status.size) end image = "\e[#{height + 1}A\r" + current_chain + chain_status + "\e[1m" + score_status + "\e[0m\n\r" # Build grid image. # # Within the loop, we update two parity bits such that the items # blink in a checkerboard fashion. Alternatively, we can do away # with the parity bits and just use frame_parity for all cells, which # means all items blink synchronously, but it doesn't look as nice. parity0 = frame_parity image += grid.map{|row| parity1 = parity0 ^= 1 row.map{|cell| SPRITES[cell][color + (parity1 ^= 1)]} * "" } * "\e[0m\n\r" + "\n" print image } # Input loop. This happens in a separate thread so that the main # display loop runs more or less at constant rate. input_thread = Thread.new{ while true # 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. key = STDIN.getch if not running break end new_direction = nil case key when "A", "w"; new_direction = DIRECTION_UP when "B", "s"; new_direction = DIRECTION_DOWN when "C", "d"; new_direction = DIRECTION_RIGHT when "D", "a"; new_direction = DIRECTION_LEFT when "q"; running = false; break when "\e", "["; # Silently ignore partial escape sequences. else; new_direction = -1 # Treat most keys as color change. end # 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 new_direction input_buffer[input_write_index] = new_direction input_write_index = (input_write_index + 1) % input_buffer.size end end } # Disable echo, hide cursor, and clear window. STDIN.echo = false print "\e[?25l\n" * height # Game loop. last_movement = Time::now while running # Draw image. # # Given that we are basically alternating between two frames most # of the time (until next player action is applied), we could # conceivably cache the two frames instead of having to reassemble # each frame from scratch every time. Doing that costs merely a # few extra lines of code (and thus few extra lines of complexity), # but we found zero advantage in doing so -- the CPU costs weren't # lower and the flashing dots weren't glittering harder. render.call sleep frame_delay frame_parity ^= 1 # Apply buffered action. if input_read_index != input_write_index next_action = input_buffer[input_read_index] input_read_index = (input_read_index + 1) % input_buffer.size next_cell = PLAYER_TAIL1 case next_action when DIRECTION_UP; next_cell = grid[py - 1][px] when DIRECTION_DOWN; next_cell = grid[py + 1][px] when DIRECTION_RIGHT; next_cell = grid[py][px + 1] when DIRECTION_LEFT; next_cell = grid[py][px - 1] when -1; color ^= 2 end # Do not allow player to turn back onto themselves, or into a wall. if next_cell == SPACE || next_cell == ITEM_BLACK || next_cell == ITEM_WHITE direction = next_action # Cause direction changes to take effect right away. last_movement = Time.at(0) end 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. current_time = Time::now next if current_time - last_movement < movement_delay last_movement = current_time # Mark old player positions. history += [[px, py]] grid[py][px] = PLAYER_TAIL1 i = history[history.size / 2] grid[i[1]][i[0]] = PLAYER_TAIL2 i = history[history.size / 4] grid[i[1]][i[0]] = PLAYER_TAIL3 # Update player position. case direction when DIRECTION_UP; py -= 1 when DIRECTION_DOWN; py += 1 when DIRECTION_RIGHT; px += 1 when DIRECTION_LEFT; px -= 1 end if grid[py][px] != SPACE # Collision check. if (grid[py][px] == ITEM_WHITE && color == PLAYER_WHITE) || (grid[py][px] == ITEM_BLACK && color == PLAYER_BLACK) # Update score. score += 10 if chain_item_count == 0 || chain_item_count == 3 # Starting chain. last_item_color = grid[py][px] chain_item_count = 1 else if last_item_color == grid[py][px] # Continuing current chain. chain_item_count += 1 if chain_item_count == 3 chain_length += 1 max_chain_length = [max_chain_length, chain_length].max score += 2 ** [chain_length - 1, 8].min * 100 end else # Broke current chain. last_item_color = grid[py][px] chain_item_count = 1 chain_length = 0 end end growth_target += 2 # Mark new player position. This also removes the item from # current cell. It's important to do this before generating # the new item, so that the item counts before setting # fixed_item_color at endgame will be correct. # # A different way of doing this is to not actually count the # items during the transition to endgame, but just maintain a # running count of items as we go. In both cases, we will # need extra logic to take the in-progress chain into # account, so the difference comes out to be a wash. grid[py][px] = PLAYER_HEAD # 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. new_item_position = nil 3.times{|pass| if not new_item_position positions.size.times{ position_index = (position_index + 1) % positions.size x, y = positions[position_index] next if grid[y][x] != SPACE if pass == 0 # In steady state, we allow an item of the same # color to be adjacent less than 9 neighbors. In # other words, we allow same-color clusters of # any size during steady state. next unless eligible_for_placement.call(x, y, 9) else # In the second pass, enforce the constraint that # we are not generating near player. # # This is skipped for the final third pass, where # new item is placed at whatever space is available. next if pass == 1 && !far_away_position.call(x, y) # We couldn't place an item during the first pass, # so we are at or near endgame. Decide if we # want to commit to generating the same colored # item throughout the remainder of the game, if # we haven't committed to one already. if !fixed_item_color # Count item colors. cell_count = [0] * 15 grid.each{|row| row.each{|cell| cell_count[cell] += 1 } } if chain_item_count > 0 cell_count[last_item_color] += chain_item_count end black_count = cell_count[ITEM_BLACK] white_count = cell_count[ITEM_WHITE] # Only commit to fixed item selection if we won't need # to break any chains. This is done by ensuring that # at least one of the colors is a multiple of 3, and # players will be able to complete all chains of that # color with possibly a final incomplete chain in the # other color. if black_count % 3 == 0 || white_count % 3 == 0 # Fix item color to be whichever color that is # currently not a multiple of 3. This is so that if # player currently selected the wrong color, # they might still have a chance to complete # all chains. fixed_item_color = black_count % 3 != 0 ? ITEM_BLACK : white_count % 3 != 0 ? ITEM_WHITE : # Break ties by preferring majority. black_count > white_count ? ITEM_BLACK : black_count < white_count ? ITEM_WHITE : # Further break ties by preferring current color. color == PLAYER_BLACK ? ITEM_BLACK : ITEM_WHITE new_item = fixed_item_color # Show which item is selected to be the fixed item. if fixed_item_color == ITEM_BLACK grid[height - 1][width - 1] = HINT_BLACK else grid[height - 1][width - 1] = HINT_WHITE end end end end new_item_position = [x, y] break } end } if new_item_position grid[new_item_position[1]][new_item_position[0]] = new_item generated_item_count += 1 end else # Mark mark tombstone. grid[py][px] = PLAYER_TOMBSTONE render.call # Game over. print "\e[#{height / 2 + 2}A\r\e[#{width - 8}C\e[0m GAME OVER ", "\n\n\r\e[#{width - 8}C (press any key) ", "\n\r" * (height / 2 - 1) running = false end else # Mark new player position. grid[py][px] = PLAYER_HEAD # Grow player. if history.size > growth_target erase_x, erase_y = history.shift grid[erase_y][erase_x] = SPACE # Stop flashing chain status once player has stopped growing. if chain_item_count == 3 chain_item_count = 0 end end end end # Wait for input thread to consume the extra keystroke after the game # over message. input_thread.join # Draw extra frame to erase the game over message. render.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. # # Note that player's area coverage is measured strictly using only the # tail history, which does not include the head. This means player # must die (as opposed to quit) to cover that final cell if they are # going for 100% coverage. The alternative is to add a few extra # bytes to handle that off-by-one case, but it didn't seem worthwhile # because death comes naturally, especially at default speed settings. max_area = (height - 2) * (width - 2) grade = "Dot eater" if max_chain_length > 0 max_items = max_area / 2 4.times{|i| if history.size > max_area * (i * 0.3 - 0.1) || score > max_items * 10 + (max_items / 3) * 100 * 2 ** (i * 3 - 2) grade = "CBAS"[i] end } end # Restore cursor and output final game stats. print "\e[0m\e[?25h\n", "Area: \e[1m#{history.size * 100 / max_area}%\e[0m\n", "Score: \e[1m#{score}\e[0m\n", "Max: \e[1m#{max_chain_length} chain\e[0m\n", "Grade: \e[1m#{grade}\e[0m\n" # Restore echo. STDIN.echo = true