You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

575 lines
18 KiB

#! /usr/bin/env tclsh
# The game 2048 implemented in Tcl.
# Version 1.0.0.
#
# This code is released under the terms of the MIT license.
# See the file LICENSE for details.
#
# More at:
# - https://github.com/dbohdan/2048.tcl -- Git repository
# - https://wiki.tcl-lang.org/40557 -- discussion
package require Tcl 8.6 9
namespace eval 2048 {
namespace ensemble create
namespace export *
# Utility procs.
proc vars args {
foreach varname $args {
uplevel [list variable $varname]
}
}
# Pick a random item from the given list.
proc pick list {
lindex $list [expr {int(rand() * [llength $list])}]
}
# An abstraction representing the game board. Operates at the level of cells
# containing numerical values, not movable game tiles.
namespace eval board {
namespace ensemble create
namespace export *
variable data {}
variable size 0
# Iterate over every cell of the game board and run $script for each.
#
# The game board is a 2D matrix of a fixed size that consists of
# elements called "cells" that each can contain a number.
#
# - $cellList is a list of cell indexes (coordinates), which are
# themselves lists of two numbers each. They each represent the location
# of a given cell on the board.
# - $varName1 and $varName2 are the names of the variables that will be
# assigned the current cell's indexes when running the script.
# - $cellVarName is the name of the variable that at each step will
# contain the numerical value of the current cell. Assigning to it
# will change the cell's value.
# - $script is the script to run.
proc forcells {cellList varName1 varName2 cellVarName script} {
upvar $varName1 i
upvar $varName2 j
upvar $cellVarName c
foreach cell $cellList {
lassign $cell i j
set c [get-cell $cell]
try {
uplevel $script
} on ok {res opts} - \
on error {res opts} - \
on break {res opts} - \
on continue {res opts} {
return -options $opts $res
} on return {res opts} {
return -options [dict replace $opts -level 2] $res
}
set-cell [list $i $j] $c
}
}
# Generate a list of cell indexes for every cell of the board. The order
# in which the cell indexes appear depends on the value of
# $directionVect. E.g., if $directionVect is {1 1} the list will be
# {{0 0} {0 1} ... {0 size-1} {1 0} {1 1} ... {size-1 size-1}}
proc indexes {{directionVect {1 1}}} {
variable size
lassign $directionVect delta(i) delta(j)
foreach varName {i j} {
switch -exact -- $delta($varName) {
1 {
set start($varName) 0
set end($varName) $size
}
-1 {
set start($varName) [expr {$size - 1}]
set end($varName) -1
}
default {
error "direction vector must be {?-?1 ?-?1}"
}
}
}
set list {}
for {set i $start(i)} {$i != $end(i)} {incr i $delta(i)} {
for {set j $start(j)} {$j != $end(j)} {incr j $delta(j)} {
lappend list [list $i $j]
}
}
return $list
}
# Check if the list $cell represents a valid pair of cell coordinates.
proc valid-cell? cell {
variable size
lassign $cell i j
return [expr {(0 <= $i) && ($i < $size) &&
(0 <= $j) && ($j < $size)}]
}
# Prepare the board for use. Must be called before any other board
# procs.
proc init boardSize {
variable data
variable size
set size $boardSize
for {set i 0} {$i < $size} {incr i} {
for {set j 0} {$j < $size} {incr j} {
set-cell [list $i $j] 0
}
}
}
# Get the value of a game board cell.
proc get-cell cell {
variable data
dict get $data $cell
}
# Set the value of a game board cell.
proc set-cell {cell value} {
variable data
dict set data $cell $value
}
# Filter the list of board cell indexes $cellList to only have those
# indexes that correspond to empty board cells.
proc empty cellList {
set resultList {}
foreach cell $cellList {
if {[get-cell $cell] == 0} {
lappend resultList $cell
}
}
return $resultList
}
# Pretty-print the board. Specify an index in $highlight to highlight a
# cell with a "*" after its contents.
proc print {{highlight {-1 -1}}} {
forcells [indexes] i j cell {
if {$j == 0} {
append res \n
}
append res [
if {$cell != 0} {
if {($i == [lindex $highlight 0]) &&
($j == [lindex $highlight 1])} {
format {[%3s*]} $cell
} else {
format {[%4s]} $cell
}
} else {
lindex "......"
}
]
}
append res \n
}
} ;# namespace board
# Game logic.
namespace eval game-logic {
namespace ensemble create
namespace export *
}
# Put a "2" into an empty cell on the board.
proc spawn-new-tile {} {
set emptyCell [pick [board empty [board indexes]]]
if {[llength $emptyCell] > 0} {
board set-cell $emptyCell 2
}
return $emptyCell
}
# If $checkOnly is false try to shift all cells one step in the direction of
# $directionVect. If $checkOnly is true just say if that move is possible.
proc move-all-tiles {directionVect {checkOnly 0}} {
set changedCells 0
lassign $directionVect di dj
# Traverse the board in such a way that those tiles that are closer to
# the edges are merged first.
set indexDirVect [list \
[expr {$di == 0 ? 1 : -$di}] [expr {$dj == 0 ? 1 : -$dj}]]
board forcells [board indexes $indexDirVect] i j cell {
set newIndex [list [expr {$i + $di}] [expr {$j + $dj}]]
set removedStar 0
# For every nonempty source cell and valid destination cell...
if {$cell != 0 && [board valid-cell? $newIndex]} {
if {[board get-cell $newIndex] == 0} {
# The destination is empty.
if {$checkOnly} {
return true
} else {
# Move the tile to the empty cell.
board set-cell $newIndex $cell
set cell 0
incr changedCells
}
} elseif {([board get-cell $newIndex] eq $cell) &&
[string first + $cell] == -1} {
# The destination is the same number as the source.
if {$checkOnly} {
return -level 2 true
} else {
# When merging two tiles into one, mark the new tile
# with the marker of "+" to ensure it doesn't get
# combined again this turn.
board set-cell $newIndex [expr {2 * $cell}]+
set cell 0
incr changedCells
}
}
}
}
if {$checkOnly} {
return false
}
# Remove "changed this turn" markers at the end of the turn.
if {$changedCells == 0} {
board forcells [board indexes] i j cell {
set cell [string trim $cell +]
}
}
return $changedCells
}
# Return the sum of the numbers on all tiles.
proc score {} {
set score 0
board forcells [board indexes] i j cell {
incr score $cell
}
return $score
}
# Is it possible to move any tiles in the direction of $directionVect?
proc can-move? directionVect {
move-all-tiles $directionVect 1
}
# Check the win condition. The player wins when there is a 2048 tile.
proc check-win {} {
board forcells [board indexes] i j cell {
if {$cell == 2048} {
variable output "You win!\n"
quit-game 0
}
}
}
# Check the lose condition. The player loses when the win condition isn't
# met and there are no possible moves.
proc check-lose possibleMoves {
# If not all board cells are empty and no possible moves remain...
if {![llength $possibleMoves] &&
([board empty [board indexes]] ne [board indexes])} {
variable output "You lose.\n"
quit-game 0
}
}
# Exit the game with an exit status.
proc quit-game status {
vars done inputMethod inputModeSaved output playing stty_save turns
if [info exists output] {
puts $output[set output {}]
# Print the total number of turns played.
set turnsMessage [list $turns turn]
if {($turns % 10 != 1) || ($turns % 100 == 11)} {
append turnsMessage s
}
puts "$turnsMessage. [score] points."
}
switch $inputMethod {
twapi {
twapi::modify_console_input_mode stdin {*}$inputModeSaved
}
raw {
if {$inputModeSaved ne {}} {
exec stty $inputModeSaved 2>@stderr
}
}
}
set done $status
exit $status
}
# Event-driven input. Called when the player pressed a key.
proc input {} {
vars inputMethod output playing
variable playerInput [read stdin 1]
if {[set charcode [scan $playerInput %c]] in [list 10 {}]} {
if {$charcode eq 10 && $inputMethod ne {}} {
#this only happens in raw/twapi mode. add a newline to stdout
append output \n
}
set playerInput {}
}
after cancel $playing
play-user
}
# Process the user input at the start of the game or during play.
proc play-user {} {
vars controls inputMethod output playerInput playerMove \
playType possibleMoves preferences size
if {!$size} {
# The game is starting.
set size $playerInput
# Handle zero, one and non-digit input.
if {$size eq "q"} {
quit-game 0
}
if {![string is digit $size] || $size == 1} {
set size 0
}
if {$size == 0} {
return
}
# Choose the default size on <enter>.
if {$size eq {}} {
set size 4
}
# Generate an empty board of a given size.
board init $size
after idle [namespace code start-turn]
return
}
switch [scan $playerInput %c] {
3 {
if {$playType eq {random}} {
set playType user
} else {
quit-game 0
}
}
}
if {[dict exists $preferences $playerInput]} {
switch $playerInput {
q {
quit-game 0
}
r {
set playType random
after idle [namespace code {play-random 0}]
return
}
R {
set playType random
after idle [namespace code {play-random 1}]
return
}
s {
append output "Score: [score]\n"
}
? {
proc print-msg dictionary {
upvar 1 output output
foreach {key message} $dictionary {
append output "$key: $message\n"
}
}
append output \
"[join [dict keys $controls] {, }]: movement\n"
print-msg $preferences
}
}
} elseif {$playerInput in $possibleMoves} {
set playerMove [dict get $controls $playerInput]
}
complete-turn
}
# Set the user input to a random possible move.
proc play-random {continuous} {
vars controls playing playerInput possibleMoves
variable delay 1000
set playerInput [pick $possibleMoves]
play-user
if {$continuous} {
set playing [after $delay [namespace code {play-random 1}]]
}
}
# Apply the player's move, if any, and increment the turn counter.
proc complete-turn {} {
vars playerMove turns
if {$playerMove eq {}} {
flush stdout
start-turn 0
} else {
incr turns
# Apply the current move until no changes occur on the board.
while true {
if {[move-all-tiles $playerMove] == 0} break
}
start-turn
}
}
# Render board, find possible moves, add new tile, check win/lose.
proc start-turn {{makeNewTile 1}} {
vars controls inputMethod output ingame newTile
variable playerMove {}
variable possibleMoves {}
# Buffer the output to speed up the rending on slower terminals.
if {!$ingame} {
puts {Press "?" at any time after entering the board size for help.}
puts {Press "q" to quit.}
puts {Select board size (4)}
set ingame 1
return
}
switch $inputMethod {
twapi {
twapi::clear_console stdout
twapi::set_console_cursor_position stdout {0 0}
}
raw {
::term::ansi::send::clear
}
}
# Add a new tile to the board and print the board highlighting that
# tile.
if {$makeNewTile} {
set newTile [spawn-new-tile]
} elseif {![info exists newTile]} {
set newTile {}
}
append output \n[board print {*}[list $newTile]]
check-win
# Find the possible moves.
foreach {button vector} $controls {
if {[can-move? $vector]} {
lappend possibleMoves $button
}
}
check-lose $possibleMoves
append output "\nMove ("
foreach {button vector} $controls {
if {$button in $possibleMoves} {
append output $button
}
}
append output {)? }
puts -nonewline $output[set output {}]
flush stdout
}
# Set up the game board and the controls.
proc init {} {
# Board size.
variable size 0
variable playmode play-user
variable cell
variable delay 0
variable ingame 0
variable playing {}
variable playType user
variable turns 0
variable inputModeSaved {}
variable inputMethod {}
chan configure stdin -blocking 0
try {
package require twapi
set inputModeSaved [twapi::get_console_input_mode stdin]
twapi::modify_console_input_mode stdin -lineinput false \
-echoinput false
set inputMethod twapi
} on error _ {
catch {
set inputModeSaved [exec stty -g 2>@stderr]
# TODO: Find other ways to save the state of the
# terminal.
package require term::ansi::ctrl::unix
package require term::ansi::send
term::ansi::ctrl::unix::raw
set inputMethod raw
}
}
variable controls {
h {0 -1}
j {1 0}
k {-1 0}
l {0 1}
}
variable preferences {
q quit
r {random move}
R {automatic random play (input anything to stop)}
s {show score}
? help
}
start-turn
chan event stdin readable [namespace code input]
}
proc main {} {
variable done
interp bgerror {} [namespace code bgerror]
after idle [namespace code init]
vwait done
exit $done
}
# Output error and quit.
proc bgerror args {
puts stderr $::errorInfo
quit-game 1
}
}
# Check if we were run as the primary script by the interpreter.
# From https://wiki.tcl-lang.org/40097.
proc main-script? {} {
global argv0
if {[info exists argv0] && [file exists [info script]] &&
[file exists $argv0]} {
file stat $argv0 argv0Info
file stat [info script] scriptInfo
# Adjust for running from a network drive on Windows.
package require platform
set platform [::platform::generic]
set windows [string match *win* $platform]
expr {($argv0Info(dev) == $scriptInfo(dev)) &&
($windows || ($argv0Info(ino) == $scriptInfo(ino)))}
} else {
return 0
}
}
if {[main-script?]} {
2048 main
}