#!/usr/bin/ruby1.6
#
# tictactoe.rb - Tic Tac Toe for Ruby/Gtk
# 
# Written by Daniel Lichtenberger, 06/2002
# contact: daniel.lichtenberger@gmx.net
#
# Project homepage: http://perplex.schmumpf.de/dev/tictactoe/ruby
#
#***************************************************************************
#*                                                                         *
#*   This program is free software; you can redistribute it and/or modify  *
#*   it under the terms of the GNU General Public License as published by  *
#*   the Free Software Foundation; either version 2 of the License, or     *
#*   (at your option) any later version.                                   *
#*                                                                         *
#***************************************************************************

require 'gtk'

EASY = 1
HARD = 2
Inf = 9999 # pseudo-infinity for AI evaluations

# Player 1: human, Player 2: AI

# class GameField: the internal representation of the 3x3 field. also implements
# the negamax algorithm.
class GameField
  attr_reader :field, :player
  attr_writer :field, :player

  def initialize
    # init a 3x3 field with 0 (= empty)
    @field = Array.new
    0.upto(8) do |i|
      @field[i] = 0 
    end
    @player = 1
  end

  def reset
    0.upto(8) do |i|
      @field[i] = 0
    end
    @player = 1
  end

  # sets (x,y) to @player
  def set(x,y)
    @field[y*3+x] = @player
  end

  # set (x,y) to val
  def setv(x,y,val)
    @field[y*3+x] = val
  end

  # returns value of (x,y)
  def get(x,y)
    @field[y*3+x]
  end

  # returns the opponent of @player
  def other_player
    3 - @player
  end

  # toggles @player between 1 and 2
  def toggle_player
    @player = other_player
  end

  # returns number of player that wins on 
  # current gamefield (or 0 if noone has 3 in a line)
  def win
    0.upto(2) do |ctr|
      # check horizontals
      i = ctr*3
      f = @field.at(i)
      return f if ((f > 0) && 
		   (f == @field.at(i+1)) &&
		   (f == @field.at(i+2)))

      # check verticals
      f = @field.at(ctr)
      return f if ((f > 0) &&
		   (f == @field.at(ctr+3)) &&
		   (f == @field.at(ctr+6)))
    end

    # check diagonals
    f = @field.at(0)
    return f if ((f > 0) &&
		 (f == @field.at(4)) &&
		 (f == @field.at(8)))
    f = @field.at(2)
    return f if ((f > 0) &&
		 (f == @field.at(4)) &&
		 (f == @field.at(6)))

    0 # noone won
  end

  # returns true if all nine positions are occupied
  def full
    @field.each do |f|
      return false if f == 0
    end
    true
  end

  # implements the negamax algorithm, a variation of minimax
  # it always searches until it reaches a leaf node 
  def negamax
    # return 1 if current player wins, return 0 if opponent wins
    if (w = win) == @player 
      return 1
    elsif w > 0 
      return -1
    end

    best = -Inf
    p = @player
    toggle_player
    0.upto(8) do |i|          # try to maximize best
      if @field.at(i) == 0
	@field[i] = p         # move...
	value = -negamax      # ...and calculate negamax value recursively
	best = value if value > best
	@field[i] = 0         # undo move
      end
    end
    @player = p
    if best == -Inf
      return 0
    else
      return best
    end
  end

  # for debugging purposes only
  def to_s
    for y in 0..2
      for x in 0..2
	print @field[y*3+x]
      end
      print "\n"
    end
    print "\n"
  end
end

# class InfoBox: utility class for a simple Gtk "ok" dialog
class InfoBox < Gtk::Dialog
  attr_reader :okbutton

  def initialize(message)
    super()
    set_modal(true)
    set_position(Gtk::WIN_POS_CENTER)
    set_default_size(300,120)

    vbox.add(Gtk::Label.new(message))

    @okbutton = Gtk::Button.new('OK')
    action_area.add(@okbutton)

    @okbutton.grab_focus
    show_all
  end
end

class AboutBox < Gtk::Dialog

  def initialize
    super()
    set_modal(true)
    set_position(Gtk::WIN_POS_CENTER)
    set_default_size(400,300)
    set_title('About Tic-Tac-Toe for Ruby/GTK')
    
    text = Gtk::Text.new(nil, nil)
    text.set_editable(false)

    font = Gdk::Font.font_load("-adobe-courier-medium-r-normal--*-120-*-*-*-*-*-*")

    text.insert(font, nil, nil,
		"\nTic Tac Toe for Ruby/Gtk\n" +
		"Version 0.8.1\n\n" +
		"(c) 06/2002, 11/2003 Daniel Lichtenberger\n\n" +
		"Visit http://perplex.schmumpf.de/dev/tictactoe/ruby\n" + 
		"for more information about this program\n\n" + 
		"Mail me at daniel.lichtenberger@gmx.net\n" +
		"for comments and bugs\n\n" +
		"'Easy' implements a simple win-block AI\n" +
		"'Hard' uses a full-depth negamax tree search\n\n" +
		"This program is distributed under\n" +
		"the terms of the GPL v2:\n" +
		"http://www.gnu.org/licenses/gpl.txt\n")

    vbox.add(text)

    okbutton = Gtk::Button.new('OK')
    action_area.add(okbutton)
    okbutton.signal_connect('clicked') do
      destroy
    end
    
    show_all
  end

end

# class Pixmaps: utility class that manages the pixmaps for the gamefield buttons
class Pixmaps
  def initialize(window)
    @pix = Hash.new
    @mask = Hash.new
    paths = [File::dirname($0) + "/", "/usr/share/games/tictactoe/"] # additional search paths
    prefix = ""
    begin
      @pixmaps = {"player1" => "tac.xpm", "player2" => "tic.xpm", "empty" => "toe.xpm"}

      @pixmaps.each do |id, fn|
	@pix[id], @mask[id] = Gdk::Pixmap::create_from_xpm(window, nil, prefix + fn)
      end
    rescue ArgumentError
      if paths.length > 0 
	prefix = paths.delete_at(0)
	retry
      end
      raise
    end
  end

  # id should be "player1", "player2" or "empty"
  def create_pixmap(id)
    Gtk::Pixmap::new(@pix[id], @mask[id])
  end
end


# class AIPlayer: all AI methods (except negamax, which is in GameField)
class AIPlayer
  def initialize(gf)
    @gamefield = gf
    @nmoves = 0
    @difficulty = EASY
  end

  def reset
    @nmoves = 0
  end

  def set_difficulty(diff)
    @difficulty = diff
  end

  # blockWin: returns index of field the AI would have to move to
  # a) win on the current gamefield (@gamefield)
  # b) prevent the enemy from winning in one move (i.e.
  #    when he already has two in a row, block the third 
  #    position)
  # (in this order)
  # returns -1 if there is no such move
  def blockWin
    block = false
    blocki = 0
    0.upto(8) do |i|
      if @gamefield.field.at(i) == 0 # possible move
	# test if AI can win
	@gamefield.field[i] = 2
	if @gamefield.win == 2 then
	  @gamefield.field[i] = 0
	  return i
	end
	
	# check if AI has to block a move
	@gamefield.field[i] = 1
	if @gamefield.win == 1 then
	  block = true
	  blocki = i
	end
	@gamefield.field[i] = 0
      end
    end
    return blocki if block
    return -1
  end

  # implements a simple win/block/random-move AI
  def move_ai_easy
    if (m = blockWin) >= 0
      @gamefield.field[m] = 2
    else
      # set random point
      while true
	x = rand(3)
	y = rand(3)
	if @gamefield.get(x,y) == 0
	  @gamefield.set(x,y)
	  break
	end
      end
    end
    @nmoves += 1
  end

  # implements a more clever AI using the negamax tree search method
  def move_ai_hard
    best = -Inf
    besti = -1
    @gamefield.player = 1

    order = [4,0,2,6,8,1,3,5,7] # see text
    0.upto(8) do |ctr|
      i = order[ctr]
      if @gamefield.field[i] == 0
	@gamefield.field[i] = 2
	value = -@gamefield.negamax
	@gamefield.field[i] = 0
	if value > best
	  best = value
	  besti = i
	end
      end
      
      break if (@nmoves == 0) && (best == 0) # see text
    end
    
    @gamefield.player = 2
    @gamefield.field[besti] = 2

    @nmoves += 1
  end

  def move_ai
    case @difficulty 
    when EASY
      move_ai_easy
    when HARD
      move_ai_hard
    else
      move_ai_hard
    end
  end
end

# class GButton: a pushbutton on the gamefield, filled with a 64x64 pixmap
# also handles most of the game logic
class GButton < Gtk::Button
  def initialize(x, y, gamefield, gfbuttons, aip, pixmaps)
    super()
    set_usize(67, 67)

    @gx = x; @gy = y
    @gamefield = gamefield
    @gfbuttons = gfbuttons
    @aiplayer = aip

    @pixmaps = pixmaps
    
    set_pixmap('empty')

    # callback when button is pressed
    signal_connect('clicked') do  
      if (@gamefield.get(@gx, @gy) == 0)    # move possible
	set_pixmap('player1')
	@gamefield.set(@gx, @gy)

	# check if player has won through this move
	unless checkWin
	  @gamefield.toggle_player   # if not, proceed with AI move
	  @aiplayer.move_ai
	  @gamefield.toggle_player
	  @gfbuttons.update
	  checkWin                   # check if AI has won through this move
	end
      end
    end
  end

  def set_pixmap(id)
    return unless @pixid != id     # check if update is necessary

    if @pixbutton != nil
      remove(@pixbutton)           # remove old widget
      @pixbutton.destroy
    end
    @pixbutton = @pixmaps.create_pixmap(id)
    add(@pixbutton)                # add new one
    @pixbutton.show
    @pixid = id
  end

  # Display messagebox, returns true if a player won or 
  # gamefield is full
  def checkWin
    if ((win=@gamefield.win) > 0) || @gamefield.full
      case win
      when 0
	box = InfoBox.new('Game Over! Better luck next time.')
      when 1
	box = InfoBox.new('Congratulations! You won!')
      when 2
	box = InfoBox.new('You lost. Try harder!')
      end
      box.okbutton.signal_connect('clicked') do
	# after clicking "ok", reset gamestate and destroy dialog box
	newGame
	box.destroy
      end
      return true
    end
    false
  end
end

# class GFButtons: holds an array of all 9 button objects for the field
class GFButtons

  def initialize(gamefield)
    @buttons = Array.new
    @gamefield = gamefield
  end

  # get button object at (x,y)
  def get(x,y)
    @buttons[y*3+x]
  end

  # set button object for (x,y)
  def set(x,y,button)
    @buttons[y*3+x] = button
  end

  # update all buttons
  def update
    for y in 0..2
      for x in 0..2
	case @gamefield.get(x,y) 
	when 0
	  get(x,y).set_pixmap('empty')
	when 1
	  get(x,y).set_pixmap('player1')
	when 2
	  get(x,y).set_pixmap('player2')
	end
      end
    end
  end
end

# create global game variables

$gf = GameField.new
$gf_b = GFButtons.new($gf)
$aiplayer = AIPlayer.new($gf)

# newGame: resets gamestate
def newGame
  $gf.reset
  $gf_b.update
  $aiplayer.reset
end

# main program

# create main window
window = Gtk::Window.new(Gtk::WINDOW_TOPLEVEL)
window.set_position(Gtk::WIN_POS_CENTER)
window.set_default_size(200,200)
window.set_policy(false, false, false)
window.show

# load pixmaps
pixmaps = Pixmaps.new(window.window)

# create main layout table
table = Gtk::Table.new(5, 3, false)
window.add table
# create 3x3 button field
for x in 1..3
  for y in 1..3
    button = GButton::new(x-1, y-1, $gf, $gf_b, $aiplayer, pixmaps)
    table.attach(button, x-1, x, y-1, y)
    $gf_b.set(x-1,y-1,button)
  end
end

# create difficulty selector
box = Gtk::HBox.new(true, 5)
r1 = Gtk::RadioButton.new(nil, 'easy')
r2 = Gtk::RadioButton.new(r1, 'hard')
r1.signal_connect('toggled') do
  if r1.active
    $aiplayer.set_difficulty(EASY) 
    newGame
  end
end
r2.signal_connect('toggled') do
  if r2.active
    $aiplayer.set_difficulty(HARD) 
    newGame
  end
end
box.add(Gtk::Label.new('Difficulty:'))
box.add(r1)
box.add(r2)

table.attach(box, 0, 3, 3, 4)

# create new game/about buttons
box = Gtk::HBox.new(true,0)
button = Gtk::Button.new('New game')
button.signal_connect('clicked') do
  newGame
end
box.add button
button = Gtk::Button.new('About')
button.signal_connect('clicked') do
  dlg = AboutBox.new()
end
box.add button

table.attach(box, 0, 3, 4, 5)


table.show_all

# finally, add basic window event handling
window.signal_connect('delete_event') do
  false
end

window.signal_connect('destroy') do
  exit
end

# enter main event loop
Gtk.main
