About two weeks ago, I was out skiing with Gustaf and some friends of him. We ended up playing a lot of four in a row, a really funny and simple game. This game made me think about how to develop a min max AI, and suddenly I had written down the pseudo code for such an AI in my notebook. Here is a picture of the game is supposed to be played (from wikipedia):
Here is how my final version of the game looks when you run it:
C:\Documents and Settings\Christian\Desktop>ruby 4irad.rb
Run against ai (A), another player (P) or let two AIs play against eachother (X)
A
Enter AI 1 level (1 to 5):
3
Enter name of human player (default Human 1):
---------------
|0 1 2 3 4 5 6|
---------------
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
---------------
AI 1 (3) played: 3
---------------
|0 1 2 3 4 5 6|
---------------
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | |O| | | |
---------------
Human 1, make your move:
2
Human 1 played: 2
---------------
|0 1 2 3 4 5 6|
---------------
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | |X|O| | | |
---------------
AI 1 (3) played: 3
---------------
|0 1 2 3 4 5 6|
---------------
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | |O| | | |
| | |X|O| | | |
---------------
Human 1, make your move:
and so on...
I wonder how much code can you post in a blog, without the post becoming too long? I would like to post the code as an attachment, but I'm not sure you can do that in blogger, so I'll simply put it here. To try it out, copy the code to a file, i.e. 4inarow.rb, then run it using ruby 4inarow.rb. Enjoy ;)
$debugEnabled = false def debug(text) if $debugEnabled then puts ">>> " + text end end class State attr_reader :cols, :rows, :player, :winner, :lastPlayed, :isFull Player1 = "Player 1" Player2 = "Player 2" def initialize @cols = [] (0..6).each { @cols << [] } @rows = [] (0..5).each { @rows << Array.new(7, nil) } @player = Player1 @winner = nil @isFull = false end def deep_copy return Marshal.load( Marshal.dump( self ) ) end def to_s return "---------------\n" + "|" + (0..6).to_a.join(" ") + "|\n" + "---------------\n" + @rows.reverse.collect{|row| "|" + row.collect{|r| if !r then " " elsif r == Player2 then "X" else "O" end}.join("|") + "|"}.join("\n") + "\n" + "---------------" end def canPlay( i ) return @winner == nil && @cols[i].length < 6 end def play( i ) if canPlay i then rowIndex = @cols[i].length @cols[i] << @player @rows[rowIndex][i] = @player @lastPlayed = i switchPlayer updateWinner updateIsFull end end def updateIsFull for p in @rows[5] if !p then return end end @isFull = true end def findWinner( row ) for i in 0..(row.length-4) p = nil n = 0 for j in 0..3 rp = getPlayed(row[i+j]) if rp.class != Fixnum then if !p then p = rp elsif p != rp p = 0 break end if rp != 0 then n += 1 end end end if p != 0 and n == 4 then return p end end return nil end def updateWinner for row in getAllRanges p = findWinner row if p then @winner = p end end end def switchPlayer if @player == Player1 then @player = Player2 else @player = Player1 end end def getPlayed(cell) c = cell[0] r = cell[1] col = @cols[c] if r >= col.length then return r - col.length + 1 end return @cols[c][r] end def posValid( c, r ) return c >= 0 && c <= 6 && r >= 0 && r <= 5 end def getDiagonal( c, r, dc ) d = [] while posValid( c, r ) d << [c, r] c += dc r += 1 end return d end def getAllDiagonalsRanges diags = [] for r in 0..5 diags << getDiagonal( 0, r, 1 ) end for c in 1..6 diags << getDiagonal( c, 0, 1 ) end for r in 0..5 diags << getDiagonal( 6, r, -1 ) end for c in 0..5 diags << getDiagonal( c, 0, -1 ) end return diags end def getAllColsRanges return (0..6).collect{|c| (0..5).collect{|r| [c, r]}} end def getAllRowsRanges return (0..5).collect{|r| (0..6).collect{|c| [c, r]}} end def getAllRanges return getAllColsRanges + getAllRowsRanges + getAllDiagonalsRanges end end class Node attr_reader :state, :children, :isLeaf def initialize( state, player ) if !player then raise "player is nil" end @state = state @isLeaf = true @children = {} @player = player end def getPoints( row ) points = 0.0 for i in 0..(row.length-4) p = nil n = 0 divider = 1 for j in 0..3 rp = @state.getPlayed(row[i+j]) if rp.class == Fixnum then divider *= rp else if !p then p = rp elsif !rp then elsif p != rp p = nil break end if rp then n += 1 end end end if p then blockPoints = calculatePoints n / divider if p == @player then points += blockPoints else points = points - blockPoints end end end if points.infinite? then debug "Found winner: " + points.to_s + ": " + row.join("|") end return points end def calculatePoints( n ) return n.to_f**3 / (4 - [n,4].min) end def nodePoints points = 0.0 for row in state.getAllRanges p = getPoints(row) if p != 0 then #debug row.collect{|r| if r then r else " " end}.join("|") + " " + p.to_s end newPoints = points + p if newPoints.nan? then raise "points + p == NaN: points=" + points.to_s + " p=" + p.to_s end points = newPoints end #puts state #puts "Points: " + points.to_s return points end def allChildPoints return children.values.collect {|child| child.totalPoints} end def isMyTurn return state.player == @player end def totalPoints if @totalPoints then return @totalPoints end p = nil if @isLeaf then p = nodePoints else if isMyTurn then p = allChildPoints.max else p = allChildPoints.min end end if !p then raise "nil points returned" end @totalPoints = p return p end def grow if @totalPoints then #puts "Gah, totalPoints is set" @totalPoints = nil end if @isLeaf && !@state.winner #debug "Growing leaf: " + self.to_s for i in 0..6 if @state.canPlay i then childState = @state.deep_copy childState.play i child = Node.new(childState, @player) children[i] = child end end @isLeaf = false else for child in @children.values child.grow end end end def findNodeWithState( state ) if state.lastPlayed then return @children[state.lastPlayed] else return nil end end def to_s return "Played: " + @state.lastPlayed.to_s + " Points: " + totalPoints.to_s end end class AIPlayer def initialize( level, name ) @level = level @name = name end def to_s return @name + " (" + @level.to_s + ")" end def selectBestNode # Add some randomness bestChild = nil bestChildCount = nil for child in @node.children.values #puts child.state #puts child.totalPoints if !bestChild then bestChild = child bestChildCount = 1 else if child.totalPoints > bestChild.totalPoints then bestChild = child bestChildCount = 1 elsif child.totalPoints == bestChild.totalPoints then bestChildCount = bestChildCount + 1 if rand(bestChildCount) == 0 then bestChild = child end end end end #Just to make sure if !bestChild then raise "bestChild is nil" end debug "Best points: " + bestChild.totalPoints.to_s @node = bestChild end def play(state) if state.lastPlayed then debug "Finding last played node" @node = @node.findNodeWithState(state) @node.grow end debug "Finding the best node" selectBestNode @node.grow state.play @node.state.lastPlayed end def setupGame(state, player) @node = Node.new(state, player) debug "Creating min max tree (" + (7**@level).to_s + " nodes)..." (1..@level).each{ @node.grow } end end class HumanPlayer def initialize(name) @name = name end def to_s return @name end def play(state) puts @name + ", make your move:" begin toPlay = gets if toPlay == "q\n" then exit end toPlay = toPlay.to_i if !state.canPlay toPlay puts "Can't play " + toPlay.to_s + ". Select another:" selectOther = true else state.play toPlay end end while selectOther end def setupGame(state, player) end end def runGame puts "Run against ai (A), another player (P) or let two AIs play against eachother (X)?" gameType = readline.strip case gameType when "A", "a" player1 = createAIPlayer player2 = createHumanPlayer when "P", "p" player1 = createHumanPlayer player2 = createHumanPlayer when "X", "x" player1 = createAIPlayer player2 = createAIPlayer else puts "Invalid input: #{gameType}" exit end players = [player1, player2] state = State.new player1.setupGame(state, State::Player1) player2.setupGame(state, State::Player2) while (!state.winner && !state.isFull) for player in players puts state player.play(state) puts player.to_s + " played: " + state.lastPlayed.to_s if state.winner then break end end end if state.winner puts state puts "Winner: " + state.winner.to_s elsif state.isFull puts state puts "Draw!" end end $aiCounter = 0 def createAIPlayer $aiCounter = $aiCounter + 1 name = "AI " + $aiCounter.to_s puts "Enter " + name + " level (1 to 5):" level = readline.to_i return AIPlayer.new(level, name) end $humanCounter = 0 def createHumanPlayer $humanCounter = $humanCounter + 1 defaultName = "Human " + $humanCounter.to_s puts "Enter name of human player (default " + defaultName + "):" name = readline.strip if !name || name == "" then name = defaultName end return HumanPlayer.new(name) end def runTests state = State.new state.play(3) state.play(3) state.play(4) state.play(4) state.play(5) state.play(5) state.play(6) #test getPlayed if state.getPlayed([3, 0]) != State::Player1 then raise "getPlayed doesn't work" end if state.getPlayed([3, 1]) != State::Player2 then raise "getPlayed doesn't work" end if state.getPlayed([3, 2]) != 1 then raise "getPlayed doesn't work" end if state.getPlayed([3, 2]).class != Fixnum then raise "getPlayed doesn't work" end #test findWinner winningRow = (3..6).collect{|c| [c, 0]} if !state.findWinner(winningRow) then raise "findWinner doesn't work. winningRow=" + winningRow.to_s end #test state.winner if state.winner == nil then raise "winner should be set" end puts "All tests ok" end runGame #runTests
No comments:
Post a Comment