#!/usr/bin/env ruby # -*- Mode: Ruby -*- vim: sts=3 sw=3 expandtab # Description:: A simple game reminiscent of Connect Four. # Author:: Thomas Kirchner, 2005 # Homepage:: http://halffull.org/span # License:: http://creativecommons.org/licenses/by-nc-sa/2.0/ NAME = "span" VER = "0.2.1" require 'sdl' require 'optparse' require 'socket' MES = { :input => "err_in", :quit => "err_qu", :confirm => "conf", } # This class holds the game board with its state and size, # along with methods to draw it and check for spans. class Board attr_reader :width, :height, :chars, :state, :next_row attr_accessor :sdl # Create a new board for (game) with given (width) and (height). def initialize(game, width = 7, height = 6) #:doc: @game = game @width, @height = width, height @next_row = [] # lowest available row by column @state = [] # board state, i.e. pieces (0...width).each {|w| @state[w] = [] } # 2d @chars = %w{_ x o + s e & # @ ~} # player markers. 0 represents blank end # Clear the board, (re)starting Ncurses if needed. def clear @sdl.fillRect(0, 0, 640, 480, [224, 224, 224]) @next_row.fill(0, 0..width) # next avail. row = all zeros @the_win = [] # array of [x,y] locations of pieces used to win # Reset each board location to the null piece. (0...@width).each {|w| @state[w].fill(@chars[0], 0...@height) } # # Ncurses::reset_prog_mode # Ncurses::refresh # end COLORS = [ [255, 255, 255], [255, 0, 0], [ 0, 255, 0], [ 0, 0, 255], [255, 255, 0], [255, 0, 255], [ 0, 255, 255], [ 0, 0, 0], ] # Draw the board to the screen via Ncurses. def draw bx = 32 # @sdl.w / 2 - @height / 2 # center vertically by = 480 - 32 # @sdl.h / 2 - @width # and horizontally # Ncurses::move(y,x) (@height - 1).downto(0) { |h| # print each row, starting at top (0...@width).each { |w| # print each column from the left # Set color to player color, adding bold if part of winning span. # color = Ncurses::COLOR_PAIR(@chars.index(@state[w][h]) % @game.screen.num_colors) # color |= Ncurses::A_BOLD if @the_win and @the_win.include?([w,h]) # Print player's piece in the assigned color. # Ncurses::attrset(color); Ncurses::printw(@state[w][h]) # (printing the space in A_NORMAL avoids Ncurses weirdness) # Ncurses::attrset(Ncurses::A_NORMAL); Ncurses::printw(" ") color = COLORS[@chars.index(@state[w][h]) % COLORS.size] @sdl.drawAAFilledCircle(bx + w * 64 + 2, by - h * 64 + 2, 18, 0) @sdl.drawAAFilledCircle(bx + w * 64, by - h * 64, 18, 0) @sdl.drawAAFilledCircle(bx + w * 64, by - h * 64, 16, color) } # Ncurses::move(y += 1, x) # move to next screen row } # Print column numbers to make player selection easier. # (1..@width).each {|i| Ncurses::printw("#{i}#{i<11?' ':''}")} @sdl.updateRect(0, 0, 0, 0) # Ncurses::refresh # draw to screen end # Test to see if the given input is a valid, playable column. # If a row is given, make sure it is the next row. def valid?(column, row = :any) if column.is_a?(Integer) and column >= 0 and column < @width and @next_row[column] < @height and (row != :any ? @next_row[column] == row : true) true else false end end # Add (player)'s piece to (column). def play(player, column) if valid? column @state[column][@next_row[column]] = @chars[player.number + 1] || "?" @next_row[column] += 1 else raise ArgumentError, "Error in placement." end end # Test for game end. # If the board has a winner, return the winner, else false. def over? span? || tie? end # Test for a certain length span by given players. # req = the count to check for. players = array of player numbers. # for AI: will return [x,y] just beyond the span if available def span?(req = @game.REQ, players = :all) vert_span?(req, players) || horiz_span?(req, players) || diag_span?(req, players) end # Test for a vertical span of (req) length by one of (players). def vert_span?(req, players) count = 1 last_char = "" @state.each_with_index { |col, c| # traverse across rows col.each_with_index { |row, r| # and up columns # If we detect multiples by someone in (players), count it if row != @chars[0] and last_char == row and (players == :all or players.include?(@chars.index(row) - 1)) count += 1 else count = 1 end last_char = row if count >= req # a winner is you if req == @game.REQ # this was an over? check (0...count).each {|i| @the_win[i] = [c, r - i]} return row elsif @state[c][r + 1] == @chars[0] # for AI use return [c, r + 1] # the empty space just beyond the span end end } last_char = "" } false end # Test for a horizontal span of (req) length by one of (players). # See vert_span? for most implementation details. def horiz_span?(req, players) count = 1 last_char = "" (0...@state.first.size).each { |row| # traverse up columns (0...@state.size).each { |col| # and across rows cur = @state[col][row] if cur != @chars[0] and last_char == cur and (players == :all or players.include?(@chars.index(cur) - 1)) count += 1 else count = 1 end last_char = cur if count >= req if req == @game.REQ (0...count).each {|i| @the_win[i] = [col - i, row]} return cur # for AI: check right + left for available play elsif @state[col + 1] and @state[col + 1][row] == @chars[0] and (row == 0 or @state[col + 1][row - 1] != @chars[0]) return [col + 1, row] elsif col >= req and @state[col - req][row] == @chars[0] and (row == 0 or @state[col - req][row - 1] != @chars[0]) return [col - req, row] end # for AI: intercept xx_x and x_xx style patterns elsif count == req - 1 and req + 1 >= @game.REQ and req != @game.REQ if @state[col + 2] and @state[col + 1][row] == @chars[0] and @state[col + 2][row] == cur and @next_row[col + 1] == row return [col + 1, row] elsif col >= req and @state[col - req + 1][row] == @chars[0] and @state[col - req][row] == cur and @next_row[col - req + 1] == row return [col - req + 1, row] end end } last_char = "" } false end # Test for a diagonal span of (req) length by one of (players). # This traverses each space, looking up-left and up-right. If a span # would be possible in that direction, it tests length. # See vert_span? for some implementation details. def diag_span?(req, players) (0...@state.first.size).each { |row| # traverse up columns (0...@state.size).each { |col| # and across rows # If we're too high, we can't look up for a span if (row - 1 + @game.REQ) < @state.first.size cur = @state[col][row] count = 1 # Likewise, if we're too far left, can't have a leftward span if (col + 1 - @game.REQ) >= 0 (1...req).each { |i| if cur != @chars[0] and cur == @state[col - i][row + i] and (players == :all or players.include?(@chars.index(cur) - 1)) count += 1 end } if count >= req if req == @game.REQ (0...count).each {|i| @the_win[i] = [col - i, row + i]} return cur # for AI: check up-left for available play elsif col >= req and @state[col - req][row + req] == @chars[0] and @state[col - req][row + req - 1] != @chars[0] return [col - req, row + req] end # intercept xx_x and x_xx style patterns elsif req != @game.REQ and count == req - 1 and req + 1 >= @game.REQ if @state[col - req + 1][row + req - 1] == @chars[0] and @state[col - req][row + req] == cur and @next_row[col - req + 1] == row + req - 1 return [col - req + 1, row + req - 1] elsif @state[col - 1][row + 1] == @chars[0] and @state[col - 2][row + 2] == cur and @next_row[col - 1] == row + 1 return [col - 1, row + 1] end end end # up-left count = 1 # reset before testing other direction # Likewise, if we're too far right, can't have a rightward span if (col - 1 + @game.REQ) < @state.size (1...req).each { |i| if cur != @chars[0] and cur == @state[col + i][row + i] and (players == :all or players.include?(@chars.index(cur) - 1)) count += 1 end } if count >= req if req == @game.REQ (0...count).each {|i| @the_win[i] = [col + i, row + i]} return cur # for AI: check up-right for available play elsif @state[col + req] and @state[col + req][row + req] == @chars[0] and @state[col + req][row + req - 1] != @chars[0] return [col + req, row + req] end elsif req != @game.REQ and count == req - 1 and req + 1 >= @game.REQ if @state[col + req - 1][row + req - 1] == @chars[0] and @state[col + req][row + req] == cur and @next_row[col + req - 1] == row + req - 1 return [col + req - 1, row + req - 1] elsif @state[col + 1][row + 1] == @chars[0] and @state[col + 2][row + 2] == cur and @next_row[col + 1] == row + 1 return [col + 1, row + 1] end end end # up-right end # height test } # end row } false # sosad, no win end # If empty spaces remain, game on! # Only need to check top row, of course - false immediately if any blanks def tie? (0...@state.size).each { |col| # traverse top row return false if @state[col][@state.first.size - 1] == @chars[0] } @chars[0] # otherwise, winner = blank (means tie) end private :vert_span?, :horiz_span?, :diag_span?, :tie? end # class Board # The Game class holds the Board, the Player(s), and the Screen to # display with, along with methods to add and renumber players. class Game attr_reader :REQ, :board, :players, :screen # Create a new Span game with given (width), (height), and (connect) req def initialize(width, height, connect) #:doc: @board = Board.new(self, width, height) @screen = Screen.new(self) @board.sdl = @screen.sdl @REQ = connect # how many connected pieces to win? @players = [] end # Add (player) to the @players array and assign it a game and number. def add_player(player) @players << player player.game = self player.number = @players.index(player) end # Renumber the game's players, where (start) is the position that local # players should start at. This is used to align local players with the # server's @players array. def renumber(start) server_players = @players.find_all {|p| p.class == ServerPlayer} (0...start).each { |i| last_p = @players[i].dup @players[i] = server_players[i] ((i + 1)..@players[i].number).each { |j| new_p = @players[j].dup @players[j] = last_p last_p = new_p } } @players.each_with_index {|p, i| p.number = i} end end # class Game # The Screen class interfaces the game to the player. It has methods to # initialize Ncurses, draw information to the screen, get input from the # player, and provide screen metrics to other game functions. class Screen attr_reader :width, :height, :num_colors, :im, :sdl # Initialize Ncurses and set up the screen. (game) is the relevant Game. def initialize(game) #:doc: @game = game # start_ncurses # get_size # draw_logo(true) # true = random color to start # Pause Ncurses until we need it - allows for puts'ing around # Ncurses::def_prog_mode # Ncurses::endwin # trap('WINCH') { resize } # start SDL start_sdl end # Start Ncurses and set up our color array. def start_ncurses Ncurses::initscr # We don't need echo for single-character replies (i.e. small board) Ncurses::noecho if @game.board.width < 10 Ncurses::cbreak Ncurses::start_color @num_colors = 0 [ [0, Ncurses::COLOR_WHITE, Ncurses::COLOR_BLACK], [1, Ncurses::COLOR_RED, Ncurses::COLOR_BLACK], [2, Ncurses::COLOR_BLUE, Ncurses::COLOR_BLACK], [3, Ncurses::COLOR_GREEN, Ncurses::COLOR_BLACK], [4, Ncurses::COLOR_YELLOW, Ncurses::COLOR_BLACK], [5, Ncurses::COLOR_CYAN, Ncurses::COLOR_BLACK], [6, Ncurses::COLOR_MAGENTA, Ncurses::COLOR_BLACK], ].each { |color| @num_colors += 1 Ncurses::init_pair(*color) # Thanks Paul! } end # start SDL and set up viewport def start_sdl SDL.init(SDL::INIT_VIDEO) @sdl = SDL::setVideoMode(640, 480, 16, SDL::SWSURFACE) SDL::WM::setCaption('SDLSpan', 'SDLSpan') # @im = SDL::Surface.loadBMP("icon.bmp") # @im.setColorKey(SDL::SRCCOLORKEY, 0) # @im = @im.displayFormat end # Get our height/width and set up a position array for printing. def get_size Ncurses::getmaxyx(Ncurses::stdscr, h = [], w = []) @height, @width = h[0], w[0] # Ncurses is stupid @lines = { :y_base => (y_base = @height/2 + @game.board.height/2 + 1), :y_info => y_base + 1, :y_input => y_base + 3, :x_base => (x_base = @width/2 - 1), :x_input => x_base - 1, } end # Clear the screen and redraw the important bits with our new size. def resize Ncurses::def_prog_mode Ncurses::endwin Ncurses::reset_prog_mode # Ncurses is stupid get_size (0..@height).each { |line| Ncurses::mvprintw(line, 0, " "*@width) # clear each line } draw_logo @game.board.draw end # Free advertising! er, wait.. def draw_logo(random = false) logo = [ ",---.,---.,---.,---.", # sometimes ASCII doesn't suck "`---.| |,---|| |", "`---'|---'`---^` '", # logo is actually an array to fix positioning " |" ] # Use a random color on startup, but don't change afterwards. @logo_color = random ? (rand 2) + 1 : @logo_color || 1 Ncurses::attrset(Ncurses::COLOR_PAIR(@logo_color)) # Use the full logo if the screen is big enough. if @height > logo.size + @game.board.height + 5 y = @height / 4 - logo.size # centered in space above board x = @width / 2 - logo[0].length / 2 - 1 (0...logo.size).each { |i| Ncurses::mvprintw(y += 1, x, logo[i]) # print each logo line } # Otherwise, use a mini-logo. It's cute. elsif @height > @game.board.height + 3 Ncurses::mvprintw(@height/4 - 1, @width/2 - NAME.length/2, NAME) end end # Handle various methods of user input. If the board is large, we need # full-line input with a return; otherwise don't bother, use single-char. def input gets.chomp # # if @game.board.width > 9 # got = "" # # Ncurses::mvgetstr(@lines[:y_input], @lines[:x_input], got) # # Ncurses::mvprintw(@lines[:y_input], 0, " "*@width) # clear old input # else # got = Ncurses::getch # if got > 47 and got < 123 # ASCII is stupid # got = got.chr # else # got = "-1" # end # end # exit if got == "q" # got # end # Print (string) at (row) rows beyond the board. Usually, row is 1 or 2. # If (clear), clear the info area before printing. def info(row, string, clear = true) # # Ncurses::attrset(Ncurses::A_NORMAL) # (@lines[:y_info]..@height).each { |line| # Ncurses::mvprintw(line, 0, " "*@width) # clear each info line # } if clear # # y = @lines[:y_base] + row # x = @lines[:x_base] - string.length / 2 # Ncurses::mvprintw(y, x, string) # Ncurses::refresh # draw to screen # puts string end # Stop the screen at game end. Kills Ncurses with extreme prejudice. def stop # Ncurses::endwin puts "Thanks for playing!" end private :start_ncurses, :draw_logo, :get_size end # class Screen # Superclass for player types, with methods for accepting and playing input. # Each derived class only has to provide an #input method returning the column # to play (0-indexed). class Player attr_writer :game attr_accessor :number # Send given input, (column), to the Board. def play(column) @game.board.play(self, column) end # Get player input and play it, yelling if they chose an invalid column. def move play(col = input) [@number, col] # return player/column set for distribution rescue ArgumentError @game.screen.info(1, "Out of bounds!") sleep 0.5 # pause for message. can stack badly if called rapidly retry end private :play end # class Player # Average bipedal player, physically located at the local machine. Its AI # has proven foolhardy. class HumanPlayer < Player # Use Screen#input to get the desired column. def input @game.screen.info(1, "Which column, player #{@number + 1}?") col = @game.screen.input # Input must be proper length, only digits, and within board size. until col =~ /^\d{1,#{@game.board.width.to_s.length}}$/ and col.to_i > 0 and col.to_i <= @game.board.width @game.screen.info(1, "Invalid entry. Which column? (1-#{@game.board.width})") col = @game.screen.input end col.to_i - 1 # 0-index end end # class HumanPlayer # DumbAIPlayer is dumb. It plays any random playable column. Almost as # foolhardy as HumanPlayer. class DumbAIPlayer < Player # Pick a random column, provided it is not full. def input @game.screen.info(1, "Thinking...") sleep 0.5 # Randomly annoy other players with DumbAI-type messages. messages = [ "Now I've got you!", "Take that!" ] if rand(11) == 0 @game.screen.info(1, messages[rand(messages.size)]) sleep 0.5 end col = rand @game.board.width until @game.board.next_row[col] < @game.board.height col = rand @game.board.width end col end end # class DumbAIPlayer # A fairly smart AI player. Worries about stopping others' wins and advancing # its own spans. It can stop most threats using Board's play prediction, but # can be fooled and is not forward-looking. class AIPlayer < Player # Run some tests through the Board to find a good play. def input @game.screen.info(1, "Thinking...") sleep 0.5 opponents = @game.players.collect {|x| x.number}.reject {|x| x == @number} # First, play our imminent wins... if (span = @game.board.span?(@game.REQ - 1, [@number])) if rand(7) == 0 @game.screen.info(1, "All too easy.") sleep 0.7 end return span[0] end # ...then stop imminent enemy wins... if (span = @game.board.span?(@game.REQ - 1, opponents)) if rand(7) == 0 @game.screen.info(1, "I'm sorry, Dave.") sleep 0.7 end return span[0] end # ...then just increase our longest spans... if @game.REQ - 2 > 1 # don't check tiny spans (@game.REQ - 2).downto(2) { |i| if (span = @game.board.span?(i, [@number])) return span[0] end } end # ...and random if nothing else. col = rand(@game.board.width) until @game.board.next_row[col] < @game.board.height col = rand(@game.board.width) end col end end # class AIPlayer # On the server, this class represents remote players (*not* clients - you can # have multiple players per client). Its #input fetches a column from the # player's associated socket, sending an error for invalid moves. class NetPlayer < Player attr_writer :server attr_accessor :client # Receive a column from the client via the network. # :input message sent on error, :confirm on success. def input @game.screen.info(1, "Waiting for player #{@number+1}...") ret = @server.receive(@number) # Make sure the input is valid - a player and a valid column. max_move = @game.board.width.to_s.length max_player = @game.players.size.to_s.length until ret =~ /^\d{1,#{max_player}} \d{1,#{max_move}}$/ and ret.split[0].to_i == @number and @game.board.valid?(ret.split[1].to_i - 1) @server.send(MES[:input], @number) ret = @server.receive(@number) end @server.send(MES[:confirm], @number) ret.split[1].to_i - 1 # 0-index end end # class NetPlayer # On a client, this class represents all the players, other than the local # players, that are connected to the server. Its #input fetches each # remote player's move in turn, aided by the GameServer. class ServerPlayer < Player attr_writer :server # Receive a column from the server via the network. def input @game.screen.info(1, "Waiting for player #{@number+1}...") ret = @server.receive if ret.split[0].to_i == @number # received input for correct player return ret.split[1].to_i else # Bad things have happened at_exit { puts "Server has unexpectedly quit!" } exit(-1) end end end # class ServerPlayer # The server class that keeps track of network games, with methods to send and # receive information transparently from server or client, and update all # clients with recent moves. class GameServer def initialize(game, host = nil, port = 2202, clients = 1) #:doc: @game, @host = game, host port ||= 2202 # (host) being set means we're a client, and only need a server. if host @server = TCPSocket.new(host, port) # Otherwise, we need to accept and initialize clients. else @server = TCPServer.new('localhost', port) @clients = [] # array of TCPSockets to players STDOUT.sync = true # Ncurses is stupid # (net_start) is the position in (players) where remote players start. # It is used as a marker for the current client (*not* player). net_start = @game.players.find {|p| p.class == NetPlayer}.number - 1 (0...clients).each { |i| puts "Waiting for client #{@clients.compact.size + 1}..." @clients[net_start += 1] = @server.accept # (size) is the number of players for this client. size = receive(net_start).to_i # Add the appropriate number of NetPlayers for the client. (1...size).each { |i| @game.add_player NetPlayer.new net_start += 1 @clients[net_start] = @clients[net_start - 1] } } # Here we update players' clients and tell the clients their players' # positions in (players). Only send once per client. sent = [] @clients.each_with_index { |client, i| if @game.players[i].class == NetPlayer if client == @clients[i - 1] @game.players[i].client = @game.players[i - 1].client else @game.players[i].client = i end end unless client == nil or sent.include? client sent << client send("#{i} of #{@game.players.size - 1}", i) end } end end # initialize # Send (message) to (client), if given, otherwise to the server. def send(message, client = nil) if client @clients[client].send(message, 0) else @server.send(message, 0) end rescue Errno::EPIPE # player quit - die gracefully at_exit { puts "A player has unexpectedly quit!" } exit(-1) end # Receive a message from (client), if given, otherwise from the server. def receive(client = nil) if client @clients[client].recv(8).chomp else @server.recv(8).chomp end end # If we're a server (@host is unset), send (move) to all clients other than # (move)'s agent. If we're a client, send our last play to the server for # dispersal. def update(move) unless @host net_players = @game.players.find_all {|p| p.class == NetPlayer} net_players.collect {|p| p.client}.uniq.each { |c| unless @game.players[move[0]].class == NetPlayer and c == @game.players[move[0]].client send(move.join(" "), c) end } else local_players = @game.players.reject {|p| p.class == ServerPlayer} if local_players.collect {|p| p.number}.include? move[0] send "#{move[0]} #{move[1] + 1}" raise "Server receive error." unless receive == MES[:confirm] end end end end # class GameServer # Enough setup, let the fun begin! if __FILE__ == $0 begin # so we can ensure screen stop trap('INT') { exit(-1) } # Default values for game options. width, height, connect = 7, 6, 4 human, dumb, ai = 1, 0, -1 clients = 1 opts = OptionParser.new # parse command-line options opts.separator "" opts.separator "Specific options:" opts.on("-u=NUM", "--human=NUM", "Number of human players. (1)", Integer) { |num| human = num } opts.on("-a=NUM", "--ai=NUM", "Number of AI players. (1)", Integer) { |num| ai = num } opts.on("-d=NUM", "--dumb=NUM", "Number of dumb, random AI players. (0)", Integer) { |num| dumb = num } opts.on("-w=NUM", "--width=NUM", "Width of the board. (7)", Integer) { |num| width = num } opts.on("-h=NUM", "--height=NUM", "Height of the board. (6)", Integer) { |num| height = num } opts.on("-c=NUM", "--connect=NUM", "Number of connected pieces to win. (4)", Integer) { |num| connect = num } opts.separator "" opts.separator "Network options:" host = net_game = port = nil opts.on("-s [HOST]", "--server [HOST]", "Create a server, or connect to one at HOST.", String) { |h| host = h net_game = true ai = 0 if ai < 0 } opts.on("-p=PORT", "--port=PORT", "Port to serve on/connect to. (2202)", Integer) { |p| port = p } opts.on("-C=NUM", "--clients=NUM", "Number of clients, if serving. (1)", Integer) { |c| clients = c } opts.separator "" opts.separator "General options:" opts.on_tail("-H", "--help", "--about", "Prints these usage instructions.") { puts opts exit } opts.on_tail("-v", "-V", "--version", "Shows #{NAME} version.") { puts "#{NAME} version #{VER}" exit } opts.parse! game = Game.new(width, height, connect) # AI starts negative so we don't have any by default in a network game. ai = 1 if ai < 0 human.times { game.add_player HumanPlayer.new } ai.times { game.add_player AIPlayer.new } dumb.times { game.add_player DumbAIPlayer.new } if net_game clients.times { game.add_player NetPlayer.new } unless host server = GameServer.new(game, host, port, clients) # If we're a client, send server our player info and align players. if host server.send((local = human + ai + dumb).to_s) server_players = server.receive.split(' of ') server_start, total = server_players.collect {|s| s.to_i} (total - local + 1).times { game.add_player ServerPlayer.new } game.renumber(server_start) end game.players.each { |p| p.server = server if p.class == NetPlayer or p.class == ServerPlayer } end begin # until user gives quit signal game.board.clear active_player = 0 # Main loop - draw board, take input, update server. until (winner = game.board.over?) game.board.draw move = game.players[active_player].move server.update(move) if net_game active_player = (active_player + 1) % game.players.size end game.board.draw # show winning state if winner == game.board.chars[0] game.screen.info(1, "The game is a draw.") else game.screen.info(1, "The #{winner}'s have it!") end game.screen.info(2, "Play again? (y/n)", false) response = game.screen.input until %w{y Y n N q}.include? response # only take valid responses response = game.screen.input end end until %w{n N q}.include? response ensure # Ncurses must die. game.screen.stop if defined?(game) and defined?(game.screen) end end # main loop