#!/usr/bin/env ruby # Checkers in Ruby, Version 1 # # This version of the game attempts to preserve the underlying # algorithm(s) and feel of the BASIC version while using more modern # coding techniques. Specifically: # # 1. The major data structures (the board and current move, known as S # and R in the BASIC version) have been turned into classes. In # addition, I made a class for coordinates so that you don't always # have to deal with pairs of numbers. # # 2. Much of the functionality associated with this data has been moved # into methods of these classes in line with the philosophy that objects # are smart data. # # 3. While I've kept the board as a single object (not global, though), # this program will create many Move objects (i.e. copies of the move # under consideration) rather than operating on a single global # instance. # # 4. The rest of the code has been extracted into Ruby functions with # all variables as local as reasonably possible. # # 5. Pieces are now represented with Symbols instead of integers; this # made it *much* easier to understand what was going on. # # 6. There are various internal sanity checks. They fail by throwing # a string as an exception. (This is generally frowned upon if # you're going to catch the exception later but we never do that; # an exception here means a bug in the software and the way to fix # that is to fix the program.) # # And probably other stuff. # # Note: I've ordered the various major definitions here from (roughly) # general to specific so that if you read the code starting from the # beginning, you'll (hopefully) get a big-picture view first and then # get into details. Normally, I'd order things by topic and define # things before using them, which is a better ordering. So in this # case, do what I say and not what I do. # # Some global constants # BOARD_TEXT_INDENT = 30 # Number of spaces to indent the board when printing # Various constants related to the game of Checkers. # # (Yes, they're obvious but if you see BOARD_WIDTH, you know this is # related to board dimensions in a way that you wouldn't if you saw # '8'.) BOARD_WIDTH = 8 KING_ROW_X = 0 KING_ROW_Y = BOARD_WIDTH - 1 # This is the mainline routine of the program. Ruby doesn't require # that you put this in a function but this way, your local variables # are contained here. It's also neater, IMO. # # The name 'main' isn't special; it's just a function. The last line # of this program is a call to it. def main print < move.from.y return false if !is_me && move.to.y < move.from.y end # If jumping, that there's an opponent's piece between the start # and end. return false if move.jump? && (empty_at?(move.midpoint) || opponent_at?(move.midpoint) != is_me) # Otherwise, it's legal return true end # Perform 'move' on the board. 'move' must be legal; the player # performing it is determined by the move's starting ('from') # position. def make_move!(move) piece = self[move.from] # Sanity check raise "Illegal move: #{move}" unless legal_move?(piece.downcase == :x,move) # Promote the piece if it's reached the end row piece = piece.upcase if (piece == :x && move.to.y == KING_ROW_X) || (piece == :o && move.to.y == KING_ROW_Y) # And do the move self[move.to] = piece self[move.from] = :_ # Remove the piece jumped over if this is a jump self[move.midpoint] = :_ if move.jump? end # Return the best (i.e. likely to win) move possible for the # piece (mine) at 'coord'. def bestMoveFrom(coord, mustJump) so_far = Move.invalid return so_far unless coord.valid? offsets = [ [-1, -1], [1, -1]] offsets += [ [-1, 1], [1, 1]] if king_at?(coord) for ofx, ofy in offsets new_coord = coord.by(ofx, ofy) if opponent_at?(new_coord) new_coord = new_coord.by(ofx, ofy) elsif mustJump next end next unless new_coord.valid? so_far = so_far.betterOf( ratedMove(coord, new_coord) ) end return so_far end # Create and return a move for *me* from Coords 'from' to 'to' with # its 'rating' set to how good the move looks according to criteria # used by the BASIC version of this program. If the move is # illegal, returns an invalid Move object. def ratedMove(from, to) return Move.invalid unless legal_move?(true, Move.new(from, to)) rating = 0 # +2 if it promotes this piece rating += 2 if to.y == 0 # +50 if it takes the opponent's piece. (Captures are mandatory # so we ensure that a capture will always outrank a non-capture.) rating += 4 if (from.y - to.y).abs == 2 # -2 if we're moving away from the king row rating -= 2 if from.y == BOARD_WIDTH - 1 # +1 for putting the piece against a vertical boundary rating += 1 if to.x == 0 || to.x == BOARD_WIDTH - 1 # +1 for each friendly piece behind this one [-1, 1].each {|c| rating += 1 if mine_at?( to.by(c, -1) ) } # -2 for each opponent's piece that can now capture this one. # (This includes a piece that may be captured when moving here; # this is a bug.) [ -1, 1].each {|c| there = to.by(c, -1) opposite = to.by(-c, 1) rating -= 2 if opponent_at?(there) && (empty_at?(opposite) || opposite == from) } return Move.new(from, to, rating) end end # Class to hold the X and Y coordinates of a position on the board. # # Coord objects are immutable--that is, they never change after # creation. Instead, you will always get a modified copy back. class Coord # Coordinates are readable attr_reader :x, :y # Initialize def initialize(x, y) @x, @y = [x,y] end # Test if this move is on the board. def valid? return x >= 0 && y >= 0 && x < BOARD_WIDTH && y < BOARD_WIDTH end # Test if this Coord is equal to another Coord. def ==(other) return other.class == self.class && other.x == @x && other.y == y end # Return a string that describes this Coord in a human-friendly way. def to_s return "(#{@x},#{@y})" end # Return a new Coord whose x and y coordinates have been adjusted by # arguments 'x' and 'y'. def by(x, y) return Coord.new(@x + x, @y + y) end end # Class to represent a move by a player between two positions, # possibly with a rating that can be used to select the best of a # collection of moves. # # An (intentionally) invalid move will have a value of nil for both # 'from' and 'to'. Most methods other than 'valid?' assume the Move # is valid. class Move # Readable fields: attr_reader :from, :to, :rating # The initializer; -99 is the lowest rating from the BASIC version # so we use that here as well. def initialize(from, to, rating = -99) @from, @to, @rating = [from, to, rating] # Sanity check; the only invalid Move tolerated is the official # one (i.e. with nil for each endpoint.) raise "Malformed Move: #{self}" if @from && @to && !valid? end # Return an invalid Move object. def self.invalid return self.new(nil, nil, -99) end # Return true if this is a valid move (i.e. as close to legal as we # can determine without seeing the board.) def valid? # Not valid if @from or @to is nil return false unless @from && @to # Not valid unless both endpoints are on the board return false unless @from.valid? && @to.valid? # Not valid unless it's a diagonal move by 1 or 2 squares dx, dy = delta return false if dx.abs != dy.abs || (dx.abs != 1 && dx.abs != 2) # Otherwise, valid return true end # Return true if this move is a jump, false otherwise def jump? return valid? && magnitude() == 2 end # Return the coordinates of the piece being jumped over by this # move. def midpoint raise "Called 'midpoint' on a non-jump move!" unless jump? dx, dy = delta return @from.by(dx / dx.abs, dy / dy.abs) end # Return the better-rated of self or otherMove. def betterOf(otherMove) return otherMove if !valid? return rating > otherMove.rating ? self : otherMove end # Return a human-friendly string representing this move. def to_s return "[NOMOVE]" if !@from && !@to # Well-known invalid move jumpover = jump? ? "-> #{midpoint} ->" : "->" return "#{@from} #{jumpover} #{to}#{valid? ? '' : ' [INVALID]'}" end private # Return the distance (x, y) between the 'from' and 'to' locations. def delta return [to.x - from.x, to.y - from.y] end # Return the number of squares this move will take the piece (either # 1 or 2). def magnitude # Note: we assume that this move is a legal move (and therefore # diagonal); otherwise, this may not be correct. return (to.x - from.x).abs end end # Start the game main()