A Simple Shot 'em Up in Tcl/Tk

It could be observed very quickly, that Tcl/Tk is suitable to program games as well -- just look at the Tcl/Tk games page, or check out the references of Category Games.

Last year, I've tried to implement a simple arcade/action game in Tcl/Tk. The objective of the game is fairly simple: shot everything you've see floating in the sky, thus raising your score. Beware: every missed object fallen on the ground decreases your HP, and every shot increases the temperature of your laser gun, which needs the time to cool off.

Recently, I've revised the code and adjusted the appearance, but it's still implemented not just the right way, and the game process is rather boring. There're a lot of hard-coded values, which could be made configurable (with some presets like difficulty levels.) However, I've decided to release it anyway. May be it would be useful for someone.


 #!/bin/sh
 ### shooter-1.tk --- A Simple Arcade/Action Game  -*- Tcl -*-
 ## $Id: 16305,v 1.1 2006-08-21 18:00:34 jcw Exp $

 ## the next line restarts using tclsh \
 exec tclsh "$0" "$@"

 ### Copyright (C) 2005, 2006 Ivan Shmakov

 ## Permission to copy this software, to modify it, to redistribute it,
 ## to distribute modified versions, and to use it for any purpose is
 ## granted, subject to the following restrictions and understandings.

 ## 1.  Any copy made of this software must include this copyright notice
 ## in full.

 ## 2.  I have made no warrantee or representation that the operation of
 ## this software will be error-free, and I am under no obligation to
 ## provide any services, by way of maintenance, update, or otherwise.

 ## 3.  In conjunction with products arising from the use of this
 ## material, there shall be no use of my name in any advertising,
 ## promotional, or sales literature without prior written consent in
 ## each case.

 ### Code:

 package require Tcl 8.5
 package require Tk  8.5

 namespace eval ::shoot { }

 ### UI Configuration

 foreach { pattern value } {
     *Background                 "NavyBlue"
     *Foreground                 "Wheat"
     *HighlightBackground        "NavyBlue"
     *HighlightColor             "SkyBlue"
     *left.relief                "flat"
     *left.buttons.relief        "flat"
     *left.buttons.Button.relief "groove"
     *left.buttons.new.text      "New"
     *left.buttons.pause.text    "Pause"
     *left.buttons.quit.text     "Quit"
     *left.status.relief         "groove"
     *left.status.Label.anchor   "se"
     *left.status.health.text    "HP"
     *left.status.score.text     "SC"
     *left.status.heat.text      "HT"
     *shooting.relief            "flat"
     *shooting.canvas.relief     "flat"
     *shooting.canvas.cursor     "target"
     *shooting.canvas.width      "384"
     *shooting.canvas.height     "512"
 } {
     option add $pattern $value "startupFile"
 }
 unset pattern value

 . configure -background [ option get . background Background ]

 ### Miscellaneous utility functions
 namespace eval ::shoot::util {
     namespace export \
         random-flat-int random-circle \
         coords-circle coords-offset coords-star
 }

 ## Random Numbers

 proc ::shoot::util::random-flat-int { a b } {
     ## .
     expr { int ($a + ($b - $a) * rand ()) }
 }

 proc ::shoot::util::random-circle { r } {
     set ri [ expr { - $r } ]
     while { 1 } {
         set x [ random-flat-int $ri $r ]
         set y [ random-flat-int $ri $r ]
         if { hypot ($x, $y) <= $r } {
             ## .
             return [ list $x $y ]
         }
     }
     error "unreachable"
 }

 ## Simple geometric calculations

 proc ::shoot::util::coords-circle { x y r } {
     ## .
     list \
         [ expr { $x - $r } ] [ expr { $y - $r } ] \
         [ expr { $x + $r } ] [ expr { $y + $r } ]
 }

 proc ::shoot::util::coords-offset { dx dy coords } {
     set result [ list ]
     foreach { x y } $coords {
         lappend result \
             [ expr { $x + $dx } ] [ expr { $y + $dy } ]
     }
     ## .
     set result
 }

 proc ::shoot::util::coords-star { x y } {
     ## .
     coords-offset $x $y {
         -4 0  -1 +1  0 +4  +1 +1  +4 0  +1 -1  0 -4  -1 -1
     }
 }

 ### Ray vs. Boundary Box intersections
 namespace eval ::shoot::util::intersect {
     namespace export ray-keyed-boxes
 }

 proc ::shoot::util::intersect::ray-0-seg { idx dy sx sy1 sy2 } {
     if { $idx * $sx < 0 } { return }
     set y [ expr { $dy * $idx * $sx } ]
     if { $y < $sy1 || $y > $sy2 } { return }
     ## .
     list $sx $y
 }

 proc ::shoot::util::intersect::ray-box { ray box } {
     set result [ list ]
     lassign $ray rx1 ry1 rx2 ry2
     lassign $box bx1 by1 bx2 by2
     set rdx [ expr { $rx2 - $rx1 } ]
     set rdy [ expr { $ry2 - $ry1 } ]
     set bdx1 [ expr { $bx1 - $rx1 } ]
     set bdx2 [ expr { $bx2 - $rx1 } ]
     set bdy1 [ expr { $by1 - $ry1 } ]
     set bdy2 [ expr { $by2 - $ry1 } ]
     if { $rdx != 0 } {
         set irdx [ expr { 1. / $rdx } ]
         foreach bdx [ list $bdx1 $bdx2 ] {
             set l [ ray-0-seg $irdx $rdy $bdx $bdy1 $bdy2 ]
             if { [ llength $l ] } {
                 lassign $l dx dy
                 lappend result \
                     [ expr { $dx + $rx1 } ] [ expr { $dy + $ry1 } ]
             }
         }
     }
     if { $rdy != 0 } {
         set irdy [ expr { 1. / $rdy } ]
         foreach bdy [ list $bdy1 $bdy2 ] {
             set l [ ray-0-seg $irdy $rdx $bdy $bdx1 $bdx2 ]
             if { [ llength $l ] } {
                 lassign $l dy dx
                 lappend result \
                     [ expr { $dx + $rx1 } ] [ expr { $dy + $ry1 } ]
             }
         }
     }
     ## .
     set result
 }

 proc ::shoot::util::intersect::ray-keyed-boxes { ray pairs } {
     lassign $ray rx ry
     foreach { id box } $pairs {
         foreach { x y } [ ray-box $ray $box ] {
             set rho [ expr { hypot ($x - $rx, $y - $ry) } ]
             if { ! [ info exists c-rho ]
                  || $rho < ${c-rho} } {
                 set c-x $x
                 set c-y $y
                 set c-id $id
                 set c-rho $rho
             }
         }
     }

     if { ! [ info exists c-rho ] } { return }

     ## .
     list ${c-id} ${c-x} ${c-y} ${c-rho}
 }

 ### FIXME: the following is to be improved

 namespace import \
     ::shoot::util::coords-circle \
     ::shoot::util::coords-offset \
     ::shoot::util::coords-star \
     ::shoot::util::random-circle

 interp alias \
     { } intersect-ray-keyed-boxes \
     { } ::shoot::util::intersect::ray-keyed-boxes

 ## Canvas Utility

 proc item-center { c tagOrId } {
     set n 0
     set xs 0
     set ys 0
     foreach item [ $c find withtag $tagOrId ] {
         foreach { x y } [ $c coords $item ] {
             set xs [ expr { $xs + $x } ]
             set ys [ expr { $ys + $y } ]
             incr n
         }
     }
     if { $n == 0 } { return }
     set m [ expr { 1. / $n } ]

     ## .
     list [ expr { $m * $xs } ] [ expr { $m * $ys } ]
 }

 proc on-canvas { c x y args } {
     eval $args [ list [ $c canvasx $x ] [ $c canvasy $y ] ]
 }

 ## User Interface

 proc make-buttons-column { w buttons } {
     frame $w
     grid columnconfigure $w { 0 } -weight 1
     foreach { name command } $buttons {
         set b ${w}.${name}
         button $b -command $command
         grid $b -sticky new
     }
 }

 proc make-values-column { w pairs } {
     frame $w
     grid columnconfigure $w { 1 } -weight 1
     ## .
     foreach { wn varn } $pairs {
         set t2 ${w}.${wn}
         set t1 ${t2}val
         label $t1 -textvariable $varn
         label $t2
         grid $t2 -sticky ws
         grid ^ $t1 -sticky ews
     }
 }

 ## The Canvas

 proc make-shooting { w } {
     frame $w
     set c $w.canvas
     grid rowconfigure $w { 0 } -weight 1
     grid columnconfigure $w { 0 } -weight 1
     canvas $c
     bind $c <Motion> [ list on-canvas $c %x %y shooter-orient $c ]
     bind $c <1> [ list shooter-shoot $c ]
     bind $c <2> [ list on-canvas $c %x %y make-simple-target $c ]
     grid $c -sticky news
     ## .
     return
 }

 proc setup-shooting-background { c } {
     set cw [ $c cget -width ]
     set ch [ $c cget -height ]

     set dark [ expr { int (2. / 3. * $ch) } ]

     $c create rectangle \
         0 0 $cw $ch \
         -tags { boundary } \
         -width 0

     $c create rectangle \
         0 0 $cw $dark \
         -tags { background background-dark } \
         -fill gray10 \
         -width 0

     set gradient [ expr { $dark < 12 ? $dark : 12 } ]
     set v-mult   [ expr { 1. / $gradient * ($ch - $dark) } ]
     set c-mult   [ expr { 1. / ($gradient - 1) } ]
     set r* 127
     set g* 127
     set b* 191
     set g  [ expr { 1. / 1.6 } ]
     for { set i 0 } { $i < $gradient } { incr i } {
         set y1 [ expr { $dark + int (${v-mult} * $i) } ]
         set y2 [ expr { $dark + int (${v-mult} * ($i + 1)) } ]
         set value [ expr { pow (${c-mult} * $i, $g) } ]
         set color \
             [ format "\#%02x%02x%02x" \
                   [ expr { int (${r*} * $value) } ] \
                   [ expr { int (${g*} * $value) } ] \
                   [ expr { int (${b*} * $value) } ] ]
         $c create rectangle 0 $y1 $cw $y2 \
             -tags { background background-gradient } \
             -fill $color -width 0
     }

     $c lower background boundary

     ## .
     return
 }

 ## Sparks

 proc make-sparks { c r x y { n 7 } } {
     global sparks
     for { } { $n > 0 } { incr n -1 } {
         lassign [coords-offset $x $y [random-circle $r]] ox oy
         set id [ $c create poly [ coords-star $ox $oy ] \
                -tags spark -outline Yellow -fill White ]
         set sparks($id) [ expr { int (13 * rand ()) + 3 } ]
     }
 }

 proc spark-delete { c id } {
     global sparks
     if { ! [ info exists sparks($id) ] } { return }
     $c delete $id
     unset sparks($id)
 }

 ## Targets

 proc make-simple-target { c x y } {
     global targets
     set score [ expr { int (3 + 7 * rand ()) } ]
     set radius [ expr { int (5 + $score) } ]
     set id [ $c create oval [ coords-circle $x $y $radius ] \
               -tags target -fill Red ]
     set targets($id) $score
 }

 proc target-shot { c id } {
     global targets
     set varn targets($id)
     if { ! [ info exists $varn ] } { return }
     lassign [ item-center $c $id ] x y
     make-sparks $c 3 $x $y
     $c delete $id
     set score [ set $varn ]
     unset $varn
     ## .
     set score
 }

 ## Laser Beams

 proc make-beam { c ttl args } {
     global beams
     set beams([ eval $c create line $args ]) $ttl
 }

 proc beam-delete { c id } {
     global beams
     set varn beams($id)
     if { ! [ info exists $varn ] } { return }
     $c delete $id
     unset $varn
 }

 ## The Shooter

 proc shooter-create { c } {
     global shooter-radius
     global shooter-x
     global shooter-y

     $c create arc \
         [ coords-circle ${shooter-x} ${shooter-y} ${shooter-radius} ] \
         -start 0 -extent 180 \
         -fill gray80 \
         -tags [ list shooter shooter-base ]
     $c create line \
         ${shooter-x} \
         [ expr { ${shooter-y} - .5 * ${shooter-radius} } ] \
         ${shooter-x} \
         [ expr { ${shooter-y} - ${shooter-radius} } ] \
         -width 3 \
         -tags [ list shooter shooter-gun ]
     $c raise shooter-gun shooter-base
 }

 proc shooter-dead? { c } {
     global shooter-health
     expr { ${shooter-health} <= 0 }
 }

 proc shooter-orient { c x y } {
     global game-paused
     global shooter-radius
     global shooter-x
     global shooter-y

     if { ${game-paused} || [ shooter-dead? $c ] || $y >= ${shooter-y} } {
         return
     }
     set dx [ expr { $x - ${shooter-x} } ]
     set dy [ expr { $y - ${shooter-y} } ]
     set dr [ expr { hypot ($dx, $dy) } ]
     set mult \
         [ expr { ($dr < 1e-3) ? 0 : (${shooter-radius} / $dr) } ]

     $c coords shooter-gun \
         [ expr { ${shooter-x} + .5 * $mult * $dx } ] \
         [ expr { ${shooter-y} + .5 * $mult * $dy } ] \
         [ expr { ${shooter-x} + $mult * $dx } ] \
         [ expr { ${shooter-y} + $mult * $dy } ]
 }

 proc shooter-target-ground { c id } {
     if { ! [ string length [ set damage [ target-shot $c $id ] ] ]
          || [ shooter-dead? $c ] } {
         ## .
         return
     }

     global shooter-health
     global shooter-radius

     if { [ incr shooter-health [ expr { - $damage } ] ] <= 0 } {
         set shooter-health 0
         lassign [ item-center $c shooter ] x y
         after idle [ list make-sparks $c ${shooter-radius} $x $y 131 ]
         after idle [ list $c delete shooter ]
     }
 }

 proc shooter-shoot { c } {
     global game-paused
     global shooter-heat
     global shooter-heat-inc
     global shooter-heat-max

     ## check for overheat, etc.
     if { ${game-paused}
          || [ shooter-dead? $c ]
          || (${shooter-heat} 
             > ${shooter-heat-max} - ${shooter-heat-inc}) } {
         return
     }
     incr shooter-heat ${shooter-heat-inc}

     ## get the ray
     set ray [ $c coords shooter-gun ]
     lassign $ray rx ry

     ## get the bboxes
     global shooter-y
     global targets
     set bboxes \
         [ list scene-boundary [ $c coords boundary ] ]
     foreach id [ array names targets ] {
         lappend bboxes $id [ $c bbox $id ]
     }

     ## find closest intersection
     lassign [ intersect-ray-keyed-boxes $ray $bboxes ] c-id c-x c-y c-rho
     if { ! [ info exists c-id ] } {
         ## NB: no values returned, should not happen
         return
     }

     ## draw some laser beam
     make-beam $c [ expr { int (3 + 3 * rand ()) } ] \
         $rx $ry ${c-x} ${c-y} \
         -tags beam -fill Red

     ## increase the score
     if { [ string equal ${c-id} scene-boundary ] } { return }
     global shooter-score
     incr shooter-score [ target-shot $c ${c-id} ]
 }

 ## Animation

 proc animate-shooter { c } {
     if { [ shooter-dead? $c ] } { return }
     ## cool the weapon
     global shooter-heat
     if { ${shooter-heat} > 0 } {
         incr shooter-heat -1
     }

     return
 }

 proc animate-beams { c } {
     global beams

     foreach id [ array names beams ] {
         set new [ incr beams($id) -1 ]
         if { $new < 0 } {
             after idle [ list beam-delete $c $id ]
         } else {
             after idle \
                 [ list $c itemconfigure $id \
                       -fill [ expr { ($new % 2)
                                      ? "Red" : "Black" } ] ]
         }
     }
 }

 proc animate-sparks { c } {
     global sparks
     set dy 2
     foreach id [ array names sparks ] {
         set dx [ expr { 4 * (rand () - .5) } ]
         $c move $id $dx $dy
         if { [ incr sparks($id) -1 ] < 0 } {
             after idle [ list spark-delete $c $id ]
         }
     }
 }

 proc animate-targets { c } {
     global targets
     global shooter-y

     ## animate existing targets
     set dy 3
     foreach id [ array names targets ] {
         set dx [ expr { 6 * (rand () - .5) } ]
         $c move $id $dx $dy
         lassign [ $c bbox $id ] x1 y1 x2 y2
         if { $y2 > ${shooter-y} } {
             after idle [ list shooter-target-ground $c $id ]
         }
     }

     ## create new ones
     if { [ array size targets ] < 32
          && rand () > 0.95 } {
         lassign [ $c coords boundary ] bx1 by1 bx2 by2
         make-simple-target $c \
             [ expr { $bx1 + ($bx2 - $bx1) * rand () } ] $by1
     }

     return
 }

 proc animate { c { delay 100 } } {
     animate-shooter $c
     animate-beams $c
     animate-sparks $c
     animate-targets $c

     ## schedule next update
     global animate-id
     global game-paused
     if { ! ${game-paused} } {
         set animate-id \
             [ after $delay [ list animate $c $delay ] ]
     } else {
         set animate-id ""
     }
 }

 ## Main

 proc game-new { wc } {
     global animate-id
     if { [ info exists animate-id ]
          && [ string length ${animate-id} ] } {
         after cancel ${animate-id}
     }
     set animate-id ""

     global sparks
     global targets

     array unset sparks
     array set sparks [ list ]

     array unset targets
     array set targets [ list ]

     $wc delete all
     setup-shooting-background $wc

     global beams
     array unset beams
     array set beams [ list ]

     global shooter-health
     global shooter-heat
     global shooter-heat-inc
     global shooter-heat-max
     global shooter-score

     set shooter-health 100
     set shooter-heat 0
     set shooter-heat-inc 15
     set shooter-heat-max 100
     set shooter-score 0

     lassign [ $wc coords boundary ] bx1 by1 bx2 by2

     global shooter-radius
     global shooter-x
     global shooter-y

     set shooter-radius 31
     set shooter-x [ expr { .5 * ($bx2 + $bx1) } ]
     set shooter-y $by2

     global game-paused
     set game-paused 1
     after idle [ list shooter-create $wc ]
     after idle [ list game-pause-toggle $wc ]

     return
 }

 proc game-pause-toggle { wc } {
     global game-paused
     if { ${game-paused} } {
         set game-paused 0
         after idle [ list animate $wc ]
     } else {
         set game-paused 1
     }
 }

 set w .
 set wl .left
 set wb $wl.buttons
 set wt $wl.status
 set ws .shooting
 set wc $ws.canvas

 set game-paused 1

 grid rowconfigure $w { 0 } -weight 1
 grid columnconfigure $w { 0 } -weight 0
 grid columnconfigure $w { 1 } -weight 1

 frame $wl
 grid $wl -row 0 -column 0 -sticky news
 grid rowconfigure $wl 1 -weight 1

 make-buttons-column $wb \
     [ list \
           new   [ list game-new $wc ] \
           pause [ list game-pause-toggle $wc ] \
           quit  [ list destroy $w ] ]
 grid $wb -row 0 -column 0 -sticky new

 make-values-column $wt {
     health ::shooter-health
     score  ::shooter-score
     heat   ::shooter-heat
 }
 grid $wt -row 2 -column 0 -sticky new

 make-shooting $ws
 grid $ws -row 0 -column 1 -sticky news

 ### Emacs stuff
 ## Local variables:
 ## fill-column: 72
 ## indent-tabs-mode: nil
 ## ispell-local-dictionary: "english"
 ## mode: outline-minor
 ## outline-regexp: "###\\|proc"
 ## End:
 ## LocalWords:  
 ### shooter-1.tk ends here

Screenshots Section

figure 1.

A Simple Shot

gold added pix