tk-ludo.html

A small Ludo/Mensch ärgere Dich nicht/Pachisie game, originally developed by Richard Suchenwirth in plain Tcl (see http://wiki.tcl.tk/956). The game was rewritten as a design study in NX by Gustaf Neumann in July 2013.

Major changes:

  • object-oriented design (no global variables)

  • knowledge about the paths of the figures

  • animated moves

  • knowledge about the basic rules (e.g. need 6 to move out of the nest, have to move figures from starting position)

  • throw opponents out

  • sanity checks

  • user feedback

tk-ludo.png

Short Instructions

  • The active player (marked with the button) has to dice (click on the die, or press somewhere on the board "d").

  • If all figures are in the nest (start position), the player needs to dice a 6. The player is allowed to try three times, then the player is done (press "done" button, or type "n") and the turn moves to the next player.

  • When a player got 6 eyes, he can move out of the nest. This is done by clicking on the figure the player wants to move.

  • After dicing 6, the player can dice again and move the player on the field (always by clicking on the figure).

Implementation

package require Tk
package require nx::trait

Define an application specific converter "expr" that passes the scalar result of the expression. Since the converter is defined on nx::Slot, it is applicable to all method and configure arguments.

::nx::Slot method type=expr {name value} {return [expr $value]}

Class Figure

nx::Class create Figure {
    :property canvas:required
    :property x:double
    :property y:double
    :property size:double
    :property position:integer
    :property color
    :property no:integer
    :property board:object,required
    :variable tag ""

    :require trait nx::trait::callback

    :method init {} {
        #
        # Draw figure and define interactions
        #
        set d [expr {${:size}/6.}]
        set s [expr {${:size}/1.5}]
        set y [expr {${:y}-$d*2.5}]
        set :tag ${:color}${:no}
        set id [${:canvas} create arc [expr {${:x}-$s}] [expr {${:y}-$s}] \
                    [expr {${:x}+$s}] [expr {${:y}+$s}] -outline grey \
                    -start 250 -extent 40 -fill ${:color} \
                    -tags [list mv ${:tag}]]
        ${:canvas} create oval \
            [expr {${:x}-$d}] [expr {${:y}-$d}] \
            [expr {${:x}+$d}] [expr {${:y}+$d}] \
            -fill ${:color} -outline grey -tags [list mv ${:tag}]
        #${:board} figure set $id [self]
        ${:canvas} bind ${:tag} <B1-ButtonRelease> [:callback go]
    }

    :public method go {} {
        #
        # Start moving the figure if the draw is permitted.
        # The board knows the die and the rules.
        #
        if {![${:board} moveFigure [self]]} {
            # stay at old position
            :gotoNr ${:position}
        }
    }

    :public method gotoNr {nr {-path ""} {-afterCmd ""}} {
        #
        # Move figure to the numbered position. If a path is given it
        # moves stepwise from position to position.
        #
        set oldPos ${:position}
        set :position $nr
        if {$path eq ""} {set path $nr}
        return [:move {*}[${:board} getPointCenter $oldPos] $path \
                    -afterCmd $afterCmd]
    }

    :protected method move {x0 y0 path:integer,1..n {-afterCmd ""}} {
        #
        # Move figure from old position (x0 y0) stepwise along the
        # path using animation. At the end of the move, 'afterCmd' is
        # issued.
        #
        set t 0
        foreach pos $path {
            lassign [${:board} getPointCenter $pos] x y
            set stepx [expr {($x-$x0)/50.0}]
            set stepy [expr {($y-$y0)/50.0}]
            for {set i 0} {$i < 50} {incr i} {
                after [incr t 8] ${:canvas} move ${:tag} $stepx $stepy
            }
            lassign [list $x $y] x0 y0
            incr t 100
        }
        after $t ${:canvas} raise ${:tag}
        after $t $afterCmd
        set :x $x; set :y $y
    }

    :public object method lookup {position} {
        #
        # Return the figure at the provided position.  This function
        # could be made faster, but is efficient enough as it is.
        #
        foreach f [Figure info instances] {
            if {[$f cget -position] == $position} {
                return $f
            }
        }
        return ""
    }
}

Helper functions for the die

proc random:select L {lindex $L [expr int(rand()*[llength $L].)]}
proc lexpr {term L} {
    # map an expr term to each element \$i of a list
    set res [list]
    foreach i $L {lappend res [eval expr $term]}
    set res
}

Class Die

nx::Class create Die {
    :property canvas:required
    :property x:double
    :property y:double
    :property {size:double 25}
    :property {fg gold}
    :property {bg red}
    :property {eyes 0}

    :require trait nx::trait::callback

    :method set {n} {
        #
        # Set the eyes of the die.
        #
        ${:canvas} itemconfig ${:grouptag} -fill ${:bg} -outline ${:bg}
        foreach i [lindex [list \
               {} {d5} [random:select {{d3 d7} {d1 d9}}] \
               [random:select {{d1 d5 d9} {d3 d5 d7}}] \
               {d1 d3 d7 d9} {d1 d3 d5 d7 d9} \
               [random:select {{d1 d3 d4 d6 d7 d9} {d1 d2 d3 d7 d8 d9}}] \
              ] $n] {
            ${:canvas} itemconfig ${:id}$i -fill ${:fg} -outline ${:fg}
        }
        set :eyes $n
    }

    :public method invalidate {} {
        #
        # Invalidate the eyes to avoid double uses of the eyes.
        #
        set :eyes 0
    }

    :public method roll {} {
        #
        # Roll the dice and animate rolling
        #
        # wiggle: amount, pick one of eight wiggle directions
        set dwig [expr {${:size}/5}]
        for {set i 10} {$i<100} {incr i 10} {
            :set [expr {int(rand() * 6) + 1}]
            set wig [random:select {0,1 0,-1 1,0 -1,0 1,1 -1,1 1,-1 -1,-1}]
            set wig [lexpr \$i*$dwig [split $wig ,]]
            ${:canvas} move group${:id} {*}$wig
            update
            set wig [lexpr \$i*-1 $wig] ;# wiggle back
            ${:canvas} move group${:id} {*}$wig
            after $i
        }
    }

    :method init {} {
        #
        # initialize the widgets with tags interactions
        #
        set x [expr {${:x} - ${:size}/2.0}]
        set y [expr {${:y} - ${:size}/2.0}]
        set :id [${:canvas} create rect $x $y \
                     [expr {$x+${:size}}] [expr {$y+${:size}}] \
                     -fill ${:bg} -tags mvg]
        set :grouptag group${:id}
        ${:canvas} addtag ${:grouptag} withtag ${:id}
        set ex [expr {$x+${:size}/10.}]
        set ey [expr {$y+${:size}/10.}]
        set d  [expr {${:size}/5.}];# dot diameter
        set dotno 1 ;# dot counter
        foreach y [list $ey [expr {$ey+$d*1.5}] [expr {$ey+$d*3}]] {
            foreach x [list $ex [expr {$ex+$d*1.5}] [expr {$ex+$d*3}]] {
                ${:canvas} create oval $x $y [expr {$x+$d}] [expr {$y+$d}] \
                    -fill ${:bg} -outline ${:bg} \
                    -tags [list mvg ${:grouptag} ${:id}d$dotno]
                incr dotno
            }
        }
        :set [expr {int(rand()*6)+1}]
        :invalidate
        #
        # To dice, let people click on the die, or press <d> on the
        # board
        #
        ${:canvas} bind mvg <1> [:callback roll]
        bind . <d> [:callback roll]
    }
}

Class Board

nx::Class create Board {
    :property canvas:required
    :property {size:integer 25}
    :property {bg LightBlue1}
    :property {fg white}
    :property {colors:1..n {red green yellow blue}}

    :require trait nx::trait::callback

    :method lookup {var idx} {
        #
        # Convenience lookup function for arbitrary instance
        # variables.
        #
        set key "${var}($idx)"
        if {[info exists $key]} {return [set $key]}
        return ""
    }

    :public method getPointCenter {nr} {:lookup :pointCenter $nr}
    :public method getPointId {nr}     {:lookup :pointId $nr}

    :method line {
        x0:expr,convert y0:expr,convert x1:expr,convert y1:expr,convert
        {-width 1} {-arrow none}
    } {
        #
        # Convenience function for line drawing, evaluates passed
        # expressions.
        #
        ${:canvas} create line $x0 $y0 $x1 $y1 -width $width -arrow $arrow
    }

    :method point {x:expr,convert y:expr,convert d {-number:switch false} -fill} {
        #
        # Draw a point (a position on the game board) and keep its
        # basic data in instance variables. We could as well turn the
        # positions into objects.
        #
        if {![info exists fill]} {set fill ${:fg}}
        incr :pointCounter
        set id [${:canvas} create oval \
                    [expr {$x-$d/2.}] [expr {$y-$d/2.}] \
                    [expr {$x+$d/2.}] [expr {$y+$d/2.}] \
                    -fill $fill -tags [list point] -outline brown -width 2]
        #${:canvas} create text $x $y -text ${:pointCounter} -fill grey
        set :pointNr($id) ${:pointCounter}
        set :pointCenter(${:pointCounter}) [list $x $y]
        set :pointId(${:pointCounter}) $id
        return ${:pointCounter}
    }

    :method fpoint {x:expr,convert y:expr,convert psize fsize color no} {
        #
        # Draw a point with a figure, note the position in the board
        # in the figure
        #
        set nr [:point $x $y $psize -fill $color]
        Figure new -board [self] -canvas ${:canvas} \
            -x $x -y [expr {$y-$fsize/2.0}] \
            -size $fsize -color $color -no $no -position $nr
        return $nr
    }

    :method pnest {x:expr,convert y:expr,convert d colorNr xf yf} {
        #
        # Draw the nest with the figures in it
        #
        set fsize [expr {$d/0.75}]
        set color [lindex ${:colors} $colorNr]
        lappend :nest($colorNr) [:fpoint $x-$d $y-$d $d $fsize $color 0]
        lappend :nest($colorNr) [:fpoint $x-$d $y+$d $d $fsize $color 1]
        lappend :nest($colorNr) [:fpoint $x+$d $y-$d $d $fsize $color 2]
        lappend :nest($colorNr) [:fpoint $x+$d $y+$d $d $fsize $color 3]
        set :buttonPos($colorNr) [list [expr $x+($xf*$d)] [expr $y+($yf*$d)]]
    }

    :method pline {
        x0:expr,convert y0:expr,convert
        x1:expr,convert y1:expr,convert d {-width 1} {-arrow none}
    } {
        #
        # Draw a path of the play-field with points (potential player
        # positions) on it.
        #
        set id [${:canvas} create line $x0 $y0 $x1 $y1 \
                    -width $width -arrow $arrow -fill brown]
        if {$x0 eq $x1} {
            # vertical
            set f [expr {$y1<$y0 ? -1.25 : 1.25}]
            for {set i 0} {$i < int(abs($y1-$y0)/($d*1.25))} {incr i} {
                :point $x0 $y0+$i*$d*$f $d
            }
        } else {
            # horizontal
            set f [expr {$x1<$x0 ? -1.25 : 1.25}]
            for {set i 0} {$i < int(abs($x1-$x0)/($d*1.25))} {incr i} {
                :point $x0+$i*$d*$f $y0 $d -number
            }
        }
        ${:canvas} lower $id
    }

    :method draw {m} {
        #
        # Draw board and create figures
        #
        set d ${:size}
        set u [expr {$d * 1.25}]
        #
        # Major positions: p0 .. p1 ..m.. p2 .. p3
        #
        set p0 [expr {$u-$d/2.0}]
        set p1 [expr {$m-$u}]
        set p2 [expr {$m+$u}]
        set p3 [expr {2*$m-$u+$d/2}]

        :pline $p0 $p1 $p1 $p1 $d -width 4
        :pline $p1 $p1 $p1 $p0 $d -width 4
        :pline $p1 $p0 $p2 $p0 $d -width 4 ;# horizonal short line
        :pline $p2 $p0 $p2 $p1 $d -width 4
        :pline $p2 $p1 $p3 $p1 $d -width 4
        :pline $p3 $p1 $p3 $p2 $d -width 4 ;# vertical short line
        :pline $p3 $p2 $p2 $p2 $d -width 4
        :pline $p2 $p2 $p2 $p3 $d -width 4
        :pline $p2 $p3 $p1 $p3 $d -width 4 ;# horizonal short line
        :pline $p1 $p3 $p1 $p2 $d -width 4
        :pline $p1 $p2 $p0 $p2 $d -width 4
        :pline $p0 $p2 $p0 $p1 $d -width 4 ;# vertical short line
        :line $m+5*$d  $m+2*$d  $m+6*$d  $m+2*$d -arrow first
        :line $m-2*$d  $m+5*$d  $m-2*$d  $m+6*$d -arrow first
        :line $m-5*$d  $m-2*$d  $m-6*$d  $m-2*$d -arrow first
        :line $m+2*$d  $m-5*$d  $m+2*$d  $m-6*$d -arrow first

        set d2 [expr {$d*0.75}]
        set d15 $d2*2
        set o [expr {$u*5}]
        :pnest $m+$o-$d $m-$o+$d $d2 0 -1  3
        :pnest $m+$o-$d $m+$o-$d $d2 1 -1 -2.5
        :pnest $d15     $m+$o-$d $d2 2  1 -2.5
        :pnest $d15     $m-$o+$d $d2 3  1  3
        for {set i 0; set y [expr $d*2]} {$i<4} {incr i;set y [expr {$y+$d}]} {
            lappend p(0) [:point $m      $y      $d2 -fill [lindex ${:colors} 0]]
            lappend p(1) [:point $m*2-$y $m      $d2 -fill [lindex ${:colors} 1]]
            lappend p(2) [:point $m      $m*2-$y $d2 -fill [lindex ${:colors} 2]]
            lappend p(3) [:point $y      $m      $d2 -fill [lindex ${:colors} 3]]
        }
        #
        # Setup the path per player and color the starting points
        #
        for {set i 1} {$i < 41} {incr i} {lappend path $i}
        foreach c {0 1 2 3} pos {11 21 31 1} o {11 21 31 1} {
            ${:canvas} itemconfig [:getPointId $pos] -fill [lindex ${:colors} $c]
            set :path($c) [concat [lrange $path $o-1 end] [lrange $path 0 $o-2] $p($c)]
        }
    }

    :public method msg {text} {
        #
        # Report a message to the user.
        #
        ${:canvas} itemconfig ${:msgId} -text $text
        return 0
    }

    :public method wannaGo {obj pos {-path ""}} {
        #
        # We know that we can move the figure in principle.  We have
        # to check, whether the target position is free. If the target
        # is occupied by our own player, we give up, otherwise we
        # through the opponent out.
        #
        if {$pos eq ""} {return [:msg "beyond path"]}
        set other [Figure lookup $pos]
        set afterCmd ""
        if {$other ne ""} {
            if {[$obj cget -color] eq [$other cget -color]} {
                # On player can't have two figure at the same place.
                return [:msg "My player is already at pos $pos"]
            } else {
                # Opponent is at the target position. Find a free
                # position in the opponents nest and though her out.
                set opponent [$other cget -color]
                foreach p [set :nest([lsearch ${:colors} $opponent])] {
                    if {[Figure lookup $p] eq ""} {
                        set afterCmd [list $other gotoNr $p]
                        break
                    }
                }
            }
        }
        :msg "[$obj cget -color]-[$obj cget -no] went to $pos"
        $obj gotoNr $pos -path $path -afterCmd $afterCmd
        ${:die} invalidate
    }

    :public method moveFigure {obj} {
        #
        # Move the provided figure by the diced eyes according to the
        # rules. First we check, if we are allowed to move this
        # figure, which might be in the nest or on the run.
        #
        set currentColor [lindex ${:colors} ${:player}]
        if {[$obj cget -color] ne $currentColor} {
            return [:msg "figure is not from the current player"]
        }
        set eyes [${:die} cget -eyes]
        if {$eyes == 0} {
            return [:msg "Must dice first"]
        }
        set position [$obj cget -position]
        if {$position in [set :nest(${:player})]} {
            # Figure is in the nest, just accept eyes == 6
            if {$eyes == 6} {
                :wannaGo $obj [lindex [set :path(${:player})] 0]
            } else {
                return [:msg "Need 6 to move this figure"]
            }
        } else {
            #
            # Check, if we have still figures in the nest
            #
            set inNest ""
            foreach p [set :nest(${:player})] {
                set inNest [Figure lookup $p]
                if {$inNest ne ""} break
            }
            #
            # Check, if the actual figure is at the start position.
            #
            set startPos [lindex [set :path(${:player})] 0]
            set atStart [Figure lookup $startPos]
            if {$eyes == 6} {
                if {$inNest ne ""} {
                    # Move a figure out from the nest, if we can
                    if {$atStart ne ""} {
                        if {[$atStart cget -color] eq $currentColor} {
                            set path [set :path(${:player})]
                            set current [lsearch $path $position]
                            set targetPos [expr {$current + [${:die} cget -eyes]}]
                            :wannaGo $obj [lindex $path $targetPos] \
                                -path [lrange $path $current+1 $targetPos]
                            return 1
                        }
                    }
                    return [:msg "You have to move the figures from your nest first"]
                }
            }
            if {$atStart ne "" && $inNest ne "" && $obj ne $atStart} {
                return [:msg "You have to move the figures from the start first"]
            }
            set path [set :path(${:player})]
            set current [lsearch $path $position]
            set targetPos [expr {$current + [${:die} cget -eyes]}]
            :wannaGo $obj [lindex $path $targetPos] \
                -path [lrange $path $current+1 $targetPos]
        }
        return 1
    }

    :public method nextPlayer {} {
        #
        # Switch to the next player.
        #
        set :player [expr {(${:player}+1) % 4}]
        ${:canvas} coords ${:buttonWindow} {*}[set :buttonPos(${:player})]
    }

    :method init {} {
        set hw [expr {14 * ${:size}}]
        set center [expr {$hw / 2}]
        canvas ${:canvas} -bg ${:bg} -height $hw -width $hw
        :draw $center
        set :die [Die new -canvas ${:canvas} -x $center -y $center -size ${:size}]
        set :msgId [${:canvas} create text [expr {${:size}*4}] 10 -text ""]
        #
        # Player management (signal which player is next, etc.)
        #
        set :player 2
        button .b1 -text "Done" -command [:callback nextPlayer]
        set :buttonWindow [.p create window 22 14 -window .b1]
        :nextPlayer
        bind . <n> [:callback nextPlayer]
    }
}

Finally, create the board and pack it

Board new -canvas .p -bg beige -size 40
pack .p