############################################################################ # # File: othello.icn # # Subject: Program to play the board game Othello (or Reversi) # # Author: Gregg M. Townsend # # Date: June 1, 2013 # ############################################################################ # # This file is in the public domain. # ############################################################################ # # Othello is a board game based on the older English game Reversi. # This program provides a computerized opponent for a human player. # # To make a move, click on a square to place a piece. The program # flips the appropriate pieces, cogitates a bit, and then makes # its own move. If you have no legal move, click anywhere to pass. # # Play ends when neither side can move; the player with the larger # number of of pieces is the winner. # # Commands are issued by pressing a key: # # N new game # G go (the program makes a move; to play white, start with this) # H hints (toggle the display of legal moves) # Q quit # # U undo last move (which is cheating, of course) # P pass, even if not legal, changing the side to move # # W write game history to standard output # S save current game to a file # O open game from a file # (or: pass a filename argument when starting the program) # # 1 to 9 set program lookahead depth (the default is 5) # E display an evaluation of possible move choices # D start demo mode; press space bar to end demo # # In addition to the control commands and the click-to-move action, # selecting a square by right-clicking or control-clicking makes # a move in isolation without prompting a computer response. # ############################################################################ # # Requires: Version 9 graphics # ############################################################################ # # Links: graphics, random # ############################################################################ link graphics link random $define DEPTH 5 # default search depth $define DEFNAME "savedgame.oth" # default filename for save global game # current game state global depth # current search depth setting in half-moves global hints # non-null if to show hints (legal moves) global savename # filename of saved game ######################### OVERALL CONTROL ######################### procedure main(args) local c, n, e, f, newg, dlogwin # initialize globals depth := DEPTH savename := DEFNAME randomize() # initialize graphics Window("size=800,640", "resize=on", args) # open the main window dlogwin := Clone("font=sans,bold,12") # make a separate GC for dialog use layout() # configure the game window # load game if passed a filename argument, or set up a new name if *args > 0 then { f := open(args[1]) | stop("cannot open ", args[1]) game := load(f) | stop("cannot load game from ", args[1]) close(f) } else game := newgame() redisplay(game) # show initial game state on display # main loop while e := Event() do case e of { &lrelease: # left click: make move and respond if game := selectmove(game, whichcell(&x, &y)) then if not &control then # treat control-click as right-click game := automove(game) &rrelease: # right click: make move, then wait game := selectmove(game, whichcell(&x, &y)) &resize: { # resize window if Pending()[1] === &resize then next # ignore this resize; another follows layout() # reconfigure the new window redisplay(game) # redraw the current game } !"123456789": # 1 to 9: set search depth depth := integer(e) !"dD": # d: run in demo mode game := demo(game) !"eE": # e: evaluate possible moves showevals(game, depth) !"gG": # g: generate and make a move (game := automove(game)) | Alert() !"hH": { # h: toggle hints display (legal moves) (/hints := e) | (\hints := &null) redisplay(game) } !"nN": # n: start new game redisplay(game := newgame()) !"oO": # o: open and read game from file while f := getfile(dlogwin, "Load game from file:", "r") do { if game := load(f) then { close(f) redisplay(game) break } close(f) Notice(dlogwin, "Cannot load game from " || savename) } !"pP": { # p: pass game := domove(game, &null) redisplay(game) } !"qQ": # q: quit exit() !"sS": # s: save history to file if f := getfile(dlogwin, "Save game to file:", "w") then { save(f, game) close(f) } !"uU": # u : undo last move if game := \game.pred then redisplay(game) else Alert() !"wW": # w: write history to stdout save(&output, game) } end # demo(game) -- run in demo mode (full automatic) until interrupted procedure demo(game) local oldh oldh := hints hints := 1 while *Pending() = 0 do { if game := automove(game) then WDelay(500) else { # write("black ", game.bcount, ", white ", game.wcount, # " (depth=", depth, ")") WDelay(5000) redisplay(game := newgame()) WDelay(1000) } } hints := oldh return game end # automove(game) -- find the best move, and make it procedure automove(game) local newg, t1, t2 if gameover(game) then fail t1 := &time showthink(game) newg := bestmove(game, depth) t2 := &time # write(game.bcount + game.wcount, ". ", game.nextup, " at ", # \newg.move | "--", ": ", t2 - t1, " ms, depth=", depth) if game.bcount > 0 & game.wcount > 0 & (game.bcount+game.wcount) < 64 then { showmove(game, newg.move) # show animated move, if game not over WDelay(500) # pause a moment } redisplay(newg) # show the new board configuration return newg end # selectmove(game, n) -- accept or reject move at cell n procedure selectmove(game, n) local newg, move if canmove(game, n) then # if clicked on a legal move move := n else if \allmoves(game).move then { # if didn't, but one was available Alert() fail } else # clicked when no move is legal move := &null # treat as a pass newg := domove(game, move) # make the move showmove(game, move) # show it in animation WDelay(100) # pause very briefly redisplay(newg) # show the new board configuration return newg end # getfile(win, prompt, mode) -- get filename using dialog, and open file procedure getfile(win, prompt, mode) local f repeat { case OpenDialog(win, prompt, savename) of { "Okay": { if f := open(savename := dialog_value, mode) then return f Notice(win, "Cannot open " || savename) } "Cancel": fail } } end ######################### GAME DISPLAY ######################### $define ASPECT 1.25 $define BOARDCOLOR "dark weak green" $define SIDECOLOR "dark moderate red-yellow" $define SCORECOLOR "gray" $define BLACKHINT "deep weak green" $define WHITEHINT "weak green" $define ACTCOLOR "dark red" global cw # cell width global c2 # cell width / 2 global r # token radius global sw # scoreboard width global tc # text context # layout() -- configure display based on current window size procedure layout() local w, h, a # reshape window to closest rectangle with proper aspect ratio h := WAttrib("height") # get current height and width w := WAttrib("width") a := w * h / ASPECT # compute area for the board itself h := 8 * integer(sqrt(a) / 8 + 0.5) # closest corresponding legal height w := integer(ASPECT * h) # and corresponding width WAttrib("width=" || w, "height=" || h) # resize the window sw := w - h # calculate scoreboard width WAttrib("dx=" || sw, "dy=0") # set gameboard origin beyond scoreboard cw := h / 8 # calculate cell width c2 := cw / 2 # calculate half of that r := integer(0.4 * cw) # calculate counter radius WAttrib("font=sans,bold," || integer(0.4 * cw)) # set font size Uncouple(\tc) tc := Clone("dx=0", "fg=black", "bg=" || SIDECOLOR, "font=sans,bold," || integer(0.2 * cw)) # new gpx context for text help return end procedure xloc(n) # get x-coordinate from cell number return (n % 10) * cw - cw / 2 end procedure yloc(n) # get y-coordinate from cell number return (n / 10) * cw - cw / 2 end # whichcell(x, y) -- translate mouse click to cell number procedure whichcell(x, y) local i, j i := integer(x / cw) + 1 j := integer(y / cw) + 1 return 10 * j + i end # showevals(gs, depth) -- show evaluation scores of all legal moves procedure showevals(gs, depth) local rs, x, y Fg(if gs.nextup == "b" then BLACKHINT else WHITEHINT) every rs := !evaluate(gs, depth) do { x := xloc(\rs.move | 44) y := yloc(\rs.move | 44) EraseArea(x - r - 2, y - r - 2, 2 * r + 4, 2 * r + 4) CenterString(x, y, rs.score) } end # showmove(gs, n) -- show animation of transitions caused by move at cell n procedure showmove(gs, n) local v, f, d, c, x, y if gameover(gs) then # don't show anything for "pass" at end return if /n then { # show pass move by flashing PASS EraseArea(3 * cw, 4 * cw - r, 2 * cw, 2 * r) Fg(ACTCOLOR) CenterString(4 * cw, 4 * cw, "PASS") return } # this is a normal flip move c := if gs.nextup == "b" then "black" else "white" # color to play x := xloc(n) # coordinates of center of cell to play y := yloc(n) Fg(c) FillCircle(x, y, r) # draw new piece in proper color Fg(ACTCOLOR) FillCircle(x, y, r / 2) # add a red center WAttrib("linewidth=5") # plus red lines along flip rows every v := nvectors() do if f := canflip(gs, n, v) then { WDelay(250) # pause briefly before each row d := n + f * v + v FillCircle(xloc(d), yloc(d), r / 2) DrawLine(xloc(d), yloc(d), xloc(n), yloc(n)) } WDelay(250) # pause for reflection Fg(c) every v := nvectors() do if f := canflip(gs, n, v) then { WDelay(50) # slight additional pause per row while f > 0 do { WDelay(50) # and between pieces d := n + f * v FillCircle(xloc(d), yloc(d), r) # redraw piece in new color f -:= 1 } } return end # redisplay(gs) -- redraw the entire game display procedure redisplay(gs) Bg(SIDECOLOR) EraseArea(-sw, 0, sw, 8 * cw) drawhelp() drawscores(gs) Bg(BOARDCOLOR) EraseArea(0, 0) drawboard(gs) return end # drawhelp() -- draw textual help column procedure drawhelp() static helptext initial helptext := ["", "Ithello 1.0", "", "click to move", "", "N new game", "O open game", "S save game", "W write history", "Q quit", "", "G go", "P pass", "U undo", "D demo", "_ pause", "", "H hints", "E evaluate", "1 or 3 or 5 ...", " set lookahead", "", "control-click to", " move and freeze", ] GotoRC(tc, 1, 1) every write(tc, " ", \!helptext) return end # drawscores(gs) -- draw the scoreboard showing piece counts and next to play procedure drawscores(gs) local x, y # show counts drawcounter(-1, 7, "black", gs.bcount, SCORECOLOR) drawcounter(-1, 8, "white", gs.wcount, SCORECOLOR) # show who is to move if gameover(gs) then { if gs.bcount > gs.wcount then drawlabel(0, 7, SIDECOLOR, "WIN", ACTCOLOR) else if gs.wcount > gs.bcount then drawlabel(0, 8, SIDECOLOR, "WIN", ACTCOLOR) else drawlabel(-0.1, 7.5, SIDECOLOR, "DRAW", ACTCOLOR) } else drawlabel(0, if gs.nextup == "b" then 7 else 8, SIDECOLOR, "next", ACTCOLOR) return end # showthink(gs) -- replace "who's up" with search depth while computing move procedure showthink(gs) drawlabel(0, if gs.nextup == "b" then 7 else 8, SIDECOLOR, "[" || depth || "]", ACTCOLOR) end # drawboard(gs) -- draw the game board proper, with pieces and hints procedure drawboard(gs) local c, i, j, n WAttrib("fg=black", "linewidth=2") every i := 0 to 8 do { DrawLine(i * cw, 0, i * cw, 8 * cw) DrawLine(0, i * cw, 8 * cw, i * cw) } every i := 1 to 8 do { every j := 1 to 8 do { n := i + 10 * j case gs.cells[n + 1] of { "b": drawcounter(i, j, "black") "w": drawcounter(i, j, "white") " ": if \hints & canmove(gs, n) then { Fg(if gs.nextup == "b" then BLACKHINT else WHITEHINT) DrawCircle(i * cw - c2, j * cw - c2, r - 1) } } } } return end # drawcounter(i, j, color, label, labelcolor) -- draw one piece at (i,j) procedure drawcounter(i, j, color, label, lcolor) local x, y Fg(color) FillCircle(x := i * cw - c2, y := j * cw - c2, r) if Fg(\lcolor) then CenterString(x, y, \label) return end # drawlabel(i, j, bgcolor, label, fgcolor) -- draw label at (i,j) procedure drawlabel(i, j, bgcolor, label, fgcolor) local x, y x := i * cw - c2 y := j * cw - c2 Fg(bgcolor) FillRectangle(x - c2 + 1, y - c2 + 1, cw - 2, cw - 2) Fg(fgcolor) CenterString(x, y, label) return end ######################### GAME MODEL ######################### $define HEADER "-- Ithello v1.0" record board( # game state record: cells, # 10x10 string of cell values (many(' bw*')) nextup, # who's up next ("b" or "w") bcount, # count of black cells wcount, # count of white cells score, # evaluation from standpoint of nextup player move, # last move (cell number in 11 to 88, null to pass) pred) # predecessor state # offsets to eight neighbors given a square number $define ALLNEIGHBORS (1 | 11 | 10 | 9 | -1 | -11 | -10 | -9) procedure newgame() # generate a new game static iboard initial iboard := # square numbers (&pos-1) "**********" || # 00 - 09 "* *" || # 10 - 19 "* *" || # 20 - 29 "* *" || # 30 - 39 "* wb *" || # 40 - 49 "* bw *" || # 50 - 50 "* *" || # 60 - 69 "* *" || # 70 - 79 "* *" || # 80 - 89 "**********" # 90 - 99 return board(iboard, "b", 2, 2, 0) end procedure nvectors() # generate offsets to eight neighbors suspend ALLNEIGHBORS end procedure getcell(gs, n) # get cell number n (00 <= n <= 99) return gs.cells[n + 1] end procedure setcell(gs, n, c) # set cell number n to char c return gs.cells[n + 1] := c end procedure opposite(c) # map(c,"bw","wb") static opp initial { opp := table(); opp["b"] := "w"; opp["w"] := "b"; } return \opp[c] end # trymove(gs, n) -- return result of move at cell n, if legal procedure trymove(gs, n) if canmove(gs, n) then return domove(gs, n) else fail end # domove(gs, n) -- return result of move in cell n, or of pass move if n null procedure domove(gs, n) local new new := copy(gs) doboard(new, \n) new.nextup := opposite(gs.nextup) new.move := n new.pred := gs return new end # doboard(gs, n) -- perform a flip move in cell n procedure doboard(gs, n) local o, f, i, t setcell(gs, n, gs.nextup) t := 0 every o := ALLNEIGHBORS do { if f := canflip(gs, n, o) then { every i := 1 to f do setcell(gs, n + i * o, gs.nextup) t +:= f } } if gs.nextup == "b" then { gs.wcount -:= t gs.bcount +:= t + 1 } else { # == "w" gs.bcount -:= t gs.wcount +:= t + 1 } return gs end # allmoves(gs) -- generate legal moves, including pass, from gs procedure allmoves(gs) local new suspend new := flipmoves(gs) if /new then return domove(gs, &null) else fail end # flipmoves(gs) -- generate moves that flip pieces procedure flipmoves(gs) static seq # for alpha-beta pruning, try most important first initial seq := [ 11, 18, 88, 81, 22, 27, 77, 72, 12, 17, 28, 78, 87, 82, 71, 21, 13, 16, 38, 68, 86, 83, 61, 31, 14, 15, 48, 58, 85, 84, 51, 41, 23, 26, 37, 67, 76, 73, 62, 32, 24, 25, 47, 57, 75, 74, 52, 42, 33, 34, 35, 36, 46, 56, 66, 65, 64, 63, 53, 43 ] suspend trymove(gs, !seq) end # canmove(gs, n) -- succeed if can move at square n (n not null) procedure canmove(gs, n) if getcell(gs, n) ~== " " then fail return canflip(gs, n, ALLNEIGHBORS) end # canflip(gs, n, o) -- return flip count from square n along offset o, or fail procedure canflip(gs, n, o) local f, opp opp := opposite(gs.nextup) f := 0 while getcell(gs, n +:= o) == opp do f +:= 1 if getcell(gs, n) ~== gs.nextup then fail return 0 < f # return count flipped, or fail if none end # gameover(game) -- succeed if neither side can move procedure gameover(gs) if gs.bcount = 0 | gs.wcount = 0 then return # one side is wiped out: game over if gs.bcount + gs.wcount = 64 then return # board is full: game over if flipmoves(gs) then fail # next player has a move: not over gs := domove(gs, &null) if flipmoves(gs) then fail # after pass, other player can move else return # no moves for either: game over end # save(f, gs) -- write game (with history) to file procedure save(f, gs) write(f, HEADER) rsave(f, gs) write(f) return end procedure rsave(f, gs) # recursively save oldest first rsave(f, \gs.pred) writes(f, \gs.move | "--", " ") return end # load(f) -- read game from file procedure load(f) local g, s, n g := newgame() read(f) == HEADER | fail s := read(f) | fail s ? { ="-- " | fail while n := move(2) do { =" " | pos(0) | fail if n == "--" then g := domove(g, &null) else g := trymove(g, integer(n)) | fail } pos(0) | fail } return g end ######################### POSITION EVALUATION ######################### $define INFSCORE 1000000 # infinite score # bestmove(gs, n) -- find best move to depth n, and return resulting game state # # Selects randomly from equally good choices. procedure bestmove(gs, n) local a, g, best, nbest a := evaluate(gs, n) every g := !a do if /best | g.score > best.score then { best := g nbest := 1 } else if g.score = best.score then { nbest +:= 1 if ?0 < (1. / nbest) then best := g } return best end # evaluate(gs, n) -- return list of evaluated states from all legal moves procedure evaluate(gs, n) local a, g a := [] every g := allmoves(gs) do { g.score := -alphabeta(g, n - 1, -INFSCORE, +INFSCORE) put(a, g) } return a end # alphabeta(gs, n, alpha, beta) -- return score from minmax search with pruning procedure alphabeta(gs, n, alpha, beta) local g if n <= 0 then return calcscore(gs) every g := allmoves(gs) do { alpha <:= -alphabeta(g, n - 1, -beta, -alpha) if beta <= alpha then return alpha } if /g then return calcscore(gs) else return alpha end # calcscore(gs) -- evaluate state of board from viewpoint of nextup player procedure calcscore(gs) local score, bonus, c # calculate from standpoint of black if gs.wcount = 0 then score := 60 + gs.bcount # win by annihilation else if gs.bcount = 0 then score := -60 - gs.wcount # loss by annihilation else { # start with the difference in counters score := gs.bcount - gs.wcount # add a bonus (that decreases as game progresses) for corners bonus := (60 - gs.bcount - gs.wcount) / 2 if bonus > 0 then every c := getcell(gs, 11 | 18 | 81 | 88) do if c == "b" then score +:= bonus else if c == "w" then score -:= bonus } # save and return according to who is really to go next if gs.nextup == "b" then return gs.score := +score else return gs.score := -score end