#!/usr/bin/ruby require "io/console" # Tile indices. SPACE = 0 WALL = 1 ITEM_BLACK = 2 ITEM_WHITE = 3 PLAYER_HEAD = 4 PLAYER_TAIL1 = 5 PLAYER_TAIL2 = 6 PLAYER_TAIL3 = 7 PLAYER_TOMBSTONE = 8 HINT_BLACK = 9 HINT_WHITE = 10 PLAYER_BLACK = 0 PLAYER_WHITE = 1 # 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. # Tile images: # - First index is grid cell value (matches tile indices constants). # - Second index is player color (0 or 1). # # We use to make these blink by refreshing the entire screen at a very # high frame rate, which eats up a lot of bandwidth, and the blinking # isn't always consistent since the framerate isn't consistent, so we # stopped doing that. TILES = [ [COLOR_SPACE + " "] * 2, [COLOR_WALL + "##"] * 2, # Use brighter item colors when they match player's current color. [COLOR_BLACK1 + "[]", COLOR_BLACK0 + "[]"], [COLOR_WHITE0 + "()", COLOR_WHITE1 + "()"], # Draw player with current color. [COLOR_BLACK1 + "@@", COLOR_WHITE1 + "OO"], # 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 + "%%", COLOR_WHITE_TAIL1 + "##"], [COLOR_BLACK_TAIL + "%%", COLOR_WHITE_TAIL + "##"], [COLOR_BLACK_TAIL + "XX", COLOR_WHITE_TAIL + "++"], # Highlight where player died. [COLOR_BLACK_TOMBSTONE + "@@", COLOR_WHITE_TOMBSTONE + "OO"], # Debugging hint for when we have entered endgame. [COLOR_WALL + "[]"] * 2, [COLOR_WALL + "()"] * 2 ] # Item chain images: # - First index is chain length (0..3). # - Second index is last item color (0 or 1). CHAIN_TILES = [ [], [COLOR_BLACK0 + "[]\e[0m ", COLOR_WHITE0 + "()\e[0m "], [COLOR_BLACK0 + "[][]\e[0m ", COLOR_WHITE0 + "()()\e[0m "], [COLOR_BLACK1 + "[][][]\e[0m", COLOR_WHITE1 + "()()()\e[0m"], # Extra set of sprites for blinking. We don't blink the rest # of the grid, but these ones seem worth it. [COLOR_BLACK0 + "[][][]\e[0m", COLOR_WHITE0 + "()()()\e[0m"], ] # 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 # Clear window. print "\n" * height # 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 = nil # Color of last item eaten (2 or 3). 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. # 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 : 3 - 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 # 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| # Avoid the rows near player's starting position. next if (y - py).abs < 2 # Unconditionally skip some of the cells, otherwise the initial # grid is too dense. next if rand > 0.1 # Check neighbor cells of the candidate position. New tiles can be # placed if either all neighbors are empty, or if they are adjacent # to items with the same color. The second constraint is to avoid # placing items of different colors next to each other, since that # will make it difficult to form chains. # # Note that the heuristics here will also cause us to avoid placing # new items near walls, or near player's tail. selected_tile = next_item_tile.call eligible = true same_neighbor_count = 0 [*-1..1].each{|dx| [*-1..1].each{|dy| if grid[y + dy][x + dx] == selected_tile same_neighbor_count += 1 else eligible &= grid[y + dy][x + dx] == SPACE end } } # We allow new items to be placed next to items of the same color, # but only if there aren't already more than a few such items # nearby. This is to avoid starting off with excessively large # clusters of the same color. eligible &= same_neighbor_count <= 1 next if not eligible grid[y][x] = selected_tile generated_item_count += 1 } # 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. if chain_item_count > 0 fail unless last_item_color == ITEM_BLACK || last_item_color == ITEM_WHITE fail unless chain_item_count <= 3 current_chain = CHAIN_TILES[chain_item_count][last_item_color - 2] else current_chain = "\e[0m" + " " * 3 end score_status = " " + score.to_s if chain_length > 0 chain_status = " %-#{width * 2 - 8 - score_status.size}s" % "#{chain_length} chain" if chain_length > 8 prefix = COLOR_MAX_CHAIN chain_status.upcase! else prefix = COLOR_SHORT_CHAIN end 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 status = current_chain + chain_status + "\e[1m" + score_status + "\e[0m" image = "\e[#{height + 1}A\r#{status}\n\r" # Build grid image. image += grid.map{|row| row.map{|cell| TILES[cell][color]} * "" } * "\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 # Change color if we are going in the exact opposite direction # of where we are currently going. 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 and hide cursor. STDIN.echo = false print "\e[?25l" # Draw initial frame. render.call # Game loop. last_movement = Time::now while running # Make chain status blink if we are currently at max chain. if chain_item_count == 3 image = "\e[#{height + 1}A\r" + CHAIN_TILES[3 + frame_parity][last_item_color - 2] + "\e[#{height + 1}B" print image frame_parity ^= 1 end # 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 ^= 1 # Force immediate redraw on color change. render.call end # Do not allow player to turn back onto themselves, or into a wall. if next_cell != PLAYER_TAIL1 && next_cell != PLAYER_TAIL2 && next_cell != PLAYER_TAIL3 && next_cell != WALL && 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. current_time = Time::now if current_time - last_movement < movement_delay # Wait a bit before polling input queue again. sleep 0.02 next end last_movement = current_time # Mark old player positions. history += [[px, py]] dirty_pixels = [[px, py]] grid[py][px] = PLAYER_TAIL1 i = history[history.size / 2] dirty_pixels += [i] grid[i[1]][i[0]] = PLAYER_TAIL2 i = history[history.size / 4] dirty_pixels += [i] 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. 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 = next_item_tile.call 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 # Avoid generating items near player head for first 2 passes. next if pass < 2 && (x - px).abs < 4 && (y - py).abs < 4 if pass == 0 # Avoid creating clusters of non-matching color # on first pass. eligible = true [*-1..1].each{|dx| [*-1..1].each{|dy| eligible &= grid[y + dy][x + dx] == SPACE || grid[y + dy][x + dx] == new_item } } next if not eligible end # Decide if we want to commit to a fixed item color now. # This is only ever done near endgame, and game is # not considered to be in the endgame phase if we # are able to place items that are sufficiently far # away. This means skipping the decision if we are # still in the first pass of placing items. if pass > 0 && !fixed_item_color # Count item colors. black_count = white_count = 0 grid.each{|row| row.each{|cell| black_count += cell == ITEM_BLACK ? 1 : 0 white_count += cell == ITEM_WHITE ? 1 : 0 } } if chain_item_count > 0 if last_item_color == ITEM_BLACK black_count += chain_item_count else white_count += chain_item_count end end # 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 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 # Redraw grid after applying movement. render.call 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 dirty_pixels += [[px, py]] # Grow player. if history.size > growth_target erase_x, erase_y = history.shift grid[erase_y][erase_x] = SPACE dirty_pixels += [[erase_x, erase_y]] # Stop flashing chain status once player has stopped growing. if chain_item_count == 3 chain_item_count = 0 # Need to do a full redraw to clear the status line. render.call # Don't need to draw the dirty pixels since we did a full redraw. next end end # Redraw only the dirty pixels. image = "\e[#{height}A" dirty_pixels.each{|x, y| image += "\e[#{y}B\r\e[#{x * 2}C" + TILES[grid[y][x]][color] + "\e[#{y}A" } image += "\e[#{height}B" print image 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 # Restore echo and show cursor. STDIN.echo = true print "\e[0m\e[?25h\n" # 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. # # 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) max_items = max_area / 2 grade = "C" if history.size > max_area * 0.2 || score > max_items * 10 + (max_items / 3) * 200 grade = "B" if history.size > max_area * 0.5 || score > max_items * 10 + (max_items / 3) * 1600 grade = "A" if history.size > max_area * 0.8 || score > max_items * 10 + (max_items / 3) * 12800 grade = "S" end end end # Players who did not make any chains are always assigned "dot eater" grade. if max_chain_length == 0 grade = "Dot eater" end # Output final game stats. print "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"