#!/usr/bin/ruby require "io/console" # Tile indices, also used as direction enumerations. SPACE = 0 WALL = 1 ITEM_BLACK = 2 ITEM_WHITE = 3 PLAYER_HEAD = 4 PLAYER_UP = 5 PLAYER_DOWN = 6 PLAYER_RIGHT = 7 PLAYER_LEFT = 8 PLAYER_BLACK = 0 PLAYER_WHITE = 2 # Color palette. COLOR_SPACE = "\e[44m" # 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_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 2) plus current frame (0 or 1). TILES = [ [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. [COLOR_BLACK_TAIL + "AA"] * 2 + [COLOR_WHITE_TAIL + "AA"] * 2, [COLOR_BLACK_TAIL + "VV"] * 2 + [COLOR_WHITE_TAIL + "VV"] * 2, [COLOR_BLACK_TAIL + ">>"] * 2 + [COLOR_WHITE_TAIL + ">>"] * 2, [COLOR_BLACK_TAIL + "<<"] * 2 + [COLOR_WHITE_TAIL + "<<"] * 2, ] # Item chain images: # - First index is chain length (0..3) # - Second index is last item color (0 or 2) plus current frame (0 or 1). CHAIN_TILES = [ [], [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"], ] # 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 } if Time::now - start_time > 0.1 frame_delay = 0 end # 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. # # If we start with a grid that's too small, there is a risk that no # items will be generated on start, and the only move is suicide. The # probability that we will start with no items at all is: # # 0.9 ** ((height - 5) * (width - 2) / 2) # # For 10 rows and 40 columns, odds are about 45 in a million that we # would start with a completely empty grid. You might think that's a # very small number, and I used to think that too until I ran into an # empty grid myself. If we increase this to 16 rows, the odds would # be less than 1 in a billion, which seems like a good minimum. # # 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 < 16 || width < 40 print "Window size too small, need at least 16 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 = PLAYER_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. # 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. # # Basically we generate items with the same color in groups of 3s. next_item_tile = -> { generated_item_count % 6 / 3 + 2 } # 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} # Try assigning initial set of items. 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. 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 creating 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. This is a lambda so that we can just access # everything as globals. render = -> { # Build status line. 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) * 2 + frame_parity] else current_chain = "\e[0m" + " " * 3 end if chain_length > 0 chain_status = " %-18s" % [chain_length.to_s + " chain"] if chain_length > 9 prefix = COLOR_MAX_CHAIN i = 9 else prefix = COLOR_SHORT_CHAIN i = chain_length end chain_status = prefix + chain_status[0, i * 2] + "\e[0m" + chain_status[i * 2, chain_status.size] else chain_status = " " * 20 end score_status = "\e[1m%#{width * 2 - 6 - 20}d\e[0m" % score status = current_chain + chain_status + score_status image = "\e[#{height + 1}A\r#{status}\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| parity1 ^= 1 TILES[cell][color + parity1] } * "" } * "\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 = PLAYER_UP when 'B', 's'; new_direction = PLAYER_DOWN when 'C', 'd'; new_direction = PLAYER_RIGHT when 'D', 'a'; new_direction = PLAYER_LEFT when ' '; color ^= 2 when 'q'; running = false; break end if new_direction # Change color if player tries to go in the exact direction # that they were already going in. # # Alternatively, we can have the opposite heuristic where we # change color if player wants to go in the exact opposite # direction of where they were going, but this costs a # cognitive overhead where players needs to remember the # opposite direction. Honestly this part is still OK since # players will get used to that quickly, but the real problem # comes with players wanting to in reverse direction on an # adjacent row or column (e.g. if they were going right, they # might tap up+left to go left on the adjacent row above). If # player get this timing wrong, they will experience an # accidental color change. And due to how the threads # interact, it's very easy to get this timing wrong. # # So, the safest way to switch colors, besides having a # separate key for this function, is to trigger on when the # player wants to go in the exact same direction that they were # already going before. if new_direction == direction color ^= 2 else # Do not allow player to turn onto themselves. # # Previously we have only a primitive check, which is to # disallow players from going in the direct opposite # direction of where they were going. This is helpful, but # there are still many instances of dodgy thread interaction # where player could get the timing of the turns wrong, so # we try to be more lenient and block more accidental turns. # # Note that this only blocks accidental turns, player can # still run into walls due to inertia. case new_direction when PLAYER_UP; next_cell = grid[py - 1][px] when PLAYER_DOWN; next_cell = grid[py + 1][px] when PLAYER_RIGHT; next_cell = grid[py][px + 1] when PLAYER_LEFT; next_cell = grid[py][px - 1] end if next_cell == SPACE || next_cell == ITEM_BLACK || next_cell == ITEM_WHITE direction = new_direction # Commit the new direction right away, instead of # waiting for the next timed update. This appears to # make it slightly easier to make quick consecutive # turns, although in practice there is still some level # of timing that players needs to deal with. last_movement = start_time end end end end } # Disable echo and hide cursor. STDIN.echo = false print "\e[?25l" # Game loop. last_movement = Time::now while running render.call sleep frame_delay frame_parity ^= 1 # 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 < 0.2 last_movement = current_time # Mark old player position. history += [[px, py]] grid[py][px] = direction # Update player position. case direction when PLAYER_UP; py -= 1 when PLAYER_DOWN; py += 1 when PLAYER_RIGHT; px += 1 when PLAYER_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 # Add new item. Do this in two passes: first pass avoids # placing items near the player head or existing items, and # second pass just take whatever spot that is available. new_item = next_item_tile.call new_item_position = nil 2.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 # Avoid generating items near player head. next if (x - px).abs < 4 && (y - py).abs < 4 # Avoid creating clusters of non-matching color. 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 else # Always generate items matching current player color. # This is to avoid having the player killed due to a # newly generated item that lands just in front of # them. This behavior also matches classic snake # games, and it's required for endgame. if color == PLAYER_WHITE new_item = ITEM_WHITE else new_item = ITEM_BLACK 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 # 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 frame_parity = 0 end # Mark new player position. grid[py][px] = PLAYER_HEAD 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 # 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. 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 # Output final game stats. print "Score: \e[1m#{score}\e[0m\n", "Max: \e[1m#{max_chain_length} chain\e[0m\n", "Grade: \e[1m#{grade}\e[0m\n"