#!/usr/local/bin/perl # ############################################################################# # this program generates mazes on a text terminal and allows you to traverse # them. it requires the Curses module (available from www.cpan.org) # # the current version of this program is available at: # # http://www.robobunny.com/projects/textmaze # # run textmaze -h for usage information # ############################################################################# # Author: # Kirk Baucom # # Contributors: # Kyle Guilbert : maze solve code # Juan Orlandini : move count, additional move keys # # License: # # Copyright (C) 2001 Kirk Baucom (kbaucom@schizoid.com) # # 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. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. ############################################################################# use strict; use Curses; $SIG{'INT'} = 'quit'; $SIG{'KILL'} = 'quit'; $| = 1; ##### GLOBAL VARIABLES ##### my $VERSION = "1.2"; # the maze itself my @maze = (); # the current and previous cursor locations my $cursor; my $prev_cursor; # used for mapping our path when solving the maze my @tried = (); # all of our configuration (keys, colors, etc) my %c = (); # what each array element is my ($north, $south, $east, $west, $set, $setlist) = (0..5); # set values after generating maze: my ($trail, $maze_end, $cursor_pos, $empty) = (-3..0); #### MAIN LOOP #### init(); $c{'mazestart'} = time(); while(1) { handle_input(getch()); } ############ SUBROUTINES ############### # set up the screen, read the config, generate the maze sub init { read_config("$ENV{'HOME'}/.textmaze"); initscr(); # find the width and height of the terminal $c{'screen_width'} = getmaxx(); $c{'screen_height'} = getmaxy(); if($c{'screen_width'} and $c{'screen_height'}) { $c{'autosize'} = 1; # so we know if we can limit the max size or not } else { $c{'autosize'} = 0; $c{'screen_width'} = 80; $c{'screen_height'} = 24; } if(($c{'screen_width'} < 11) || ($c{'screen_height'} < 5)) { help("Your terminal screen must be at least 11 x 5"); } # reset the defaults to the maximum that will fit on the screen if the # screen is smaller than the default size from the config file if(($c{'default_width'} * 2) > $c{'screen_width'}) { $c{'default_width'} = int($c{'screen_width'} / 2); } if($c{'default_height'} +1 > $c{'screen_height'}) { $c{'default_height'} = $c{'screen_height'} - 1; } # get the width, height, and random seed from the command line ($c{'width'}, $c{'height'}, $c{'seed'}) = getinput(); $c{'size'} = $c{'width'} * $c{'height'}; # the arrow keys should always work push(@{$c{'up'}}, KEY_UP); push(@{$c{'down'}}, KEY_DOWN); push(@{$c{'left'}}, KEY_LEFT); push(@{$c{'right'}}, KEY_RIGHT); # always make ctrl-l redraw the screen push(@{$c{'redraw'}}, chr(12)); if(curs_set(0) >= 0) { $c{'hideable_cursor'} = 1; } else { $c{'hideable_cursor'} = 0; } cbreak(); noecho(); keypad(1); init_colors(); initialize_maze(); # if we are in dissolve mode, show the full grid firstF if($c{'dissolve'}) { tty_display(); } generate_maze(); tty_display(); } ## create the grid (although it is stored as a flat list) sub initialize_maze { my $size = $c{'size'}; for my $i (0..$size - 1) { $maze[$i][$set] = $i; @{$maze[$i][$setlist]} = ("$i"); $maze[$i][$north] = $maze[$i][$south] = $maze[$i][$west] = $maze[$i][$east] = "NULL"; } } ## yank walls out until we have a real maze sub generate_maze { srand($c{'seed'}); my $cell_num; # current cell that is being looked at my $dir; # direction to check my $entry; # where the maze starts my $exit; # where the maze ends my $unified = 0; # maze is finished when this is equal to the # of cells # in the maze $c{'starttime'} = time(); my $dissolve = $c{'dissolve'}; my $size = $c{'size'}; my $w = $c{'width'}; my $h = $c{'height'}; while($unified < $size -1) { $cell_num = int(rand($size)); #pick a cell $dir= int(rand(4)); #pick a direction if($dir == $north) { if(($cell_num >= $w) && ($maze[$cell_num][$set] != $maze[$cell_num-$w][$set])) { $maze[$cell_num][$north] = $cell_num - $w; $maze[$cell_num - $w][$south] = $cell_num; $unified = unify($maze[$cell_num][$set], $maze[$cell_num - $w][$set], $unified); if($dissolve) { clearwall($cell_num - $w, $south); } } } elsif($dir == $south) { if( ($cell_num<($w*($h-1)) ) && ($maze[$cell_num][$set] != $maze[$cell_num+$w][$set]) ) { $maze[$cell_num][$south] = $cell_num + $w; $maze[$cell_num + $w][$north] = $cell_num; $unified = unify($maze[$cell_num][$set], $maze[$cell_num + $w][$set], $unified); if($dissolve) { clearwall($cell_num, $south); } } } elsif($dir == $east) { if( ($cell_num % $w) != ($w - 1) && ($maze[$cell_num][$set] != $maze[$cell_num+1][$set])) { $maze[$cell_num][$east] = $cell_num + 1; $maze[$cell_num + 1][$west] = $cell_num; $unified = unify($maze[$cell_num][$set], $maze[$cell_num +1][$set], $unified); if($dissolve) { clearwall($cell_num+1, $west); } } } elsif($dir == $west) { if( (($cell_num % $w) != 0) && ($maze[$cell_num][$set] != $maze[$cell_num-1][$set])) { $maze[$cell_num][$west] = $cell_num - 1; $maze[$cell_num - 1][$east] = $cell_num; $unified = unify($maze[$cell_num][$set], $maze[$cell_num-1][$set], $unified); if($dissolve) { clearwall($cell_num, $west); } } } } # the maze is built, now set up the beginning, end and cursor positions $entry = ( int(rand($h)) * $w ) + ($w - 1); # beginning of maze $exit = int(rand($h)) * $w; # end of maze $maze[$entry][$east] = $entry; # entry and exit point to themselves $maze[$exit][$west] = $exit; # so that you can't leave the maze $maze[$exit][$set] = $maze_end; $cursor = $entry; # put the cursor at the entry point $maze[$cursor][$set] = $cursor_pos; $c{'endtime'} = time(); $c{'dissolve'} = 0; # don't want to dissolve twice... } # puts two cells in the same set, used when generating the maze sub unify { my ($j, $k, $unified) = @_; # set numbers for the two sets to combine $unified++; my ($x, $y); if($j < $k) { $x = $j; $y = $k; } else { $y = $j; $x = $k; } for(@{$maze[$y][$setlist]}) { $maze[$_][$set] = $x; } push(@{$maze[$x][$setlist]}, @{$maze[$y][$setlist]}); @{$maze[$y][$setlist]} = (); return($unified); } # redraw current and last cursor positions sub update { my $size = $c{'size'}; my $w = $c{'width'}; my $h = $c{'height'}; my $color; foreach my $i (@_) { my $c = (2 * ($i % $w)) + 1; my $r = int($i / $w) + 1; if($maze[$i][$set] == $trail) { $color = $c{'trail'}; } else { $color = $c{'unseen'}; } if ($maze[$i][$set] == $cursor_pos) { if($maze[$i][$south] eq "NULL") { cprint($r, $c, "*", $c{'cursor'}, ::A_UNDERLINE); } else { cprint($r, $c, "*", $c{'cursor'}); } } elsif($maze[$i][$south] eq "NULL") { cprint($r, $c, "_", $color); } else { cprint($r, $c, " ", $color); } if($maze[$i][$east] eq "NULL") { cprint($r, $c+1, "|", $c{'unseen'}); } elsif($maze[$i][$set] == $trail or $maze[$i][$set] == $cursor_pos) { cprint($r, $c+1, "_", $c{'trail'}); } else { cprint($r, $c+1, "_", $c{'unseen'}); } } move($c{'screen_height'}-1, $c{'screen_width'}-1); refresh(); } # this routine is used in "dissolve" (-d) mode to remove walls sub clearwall { my ($cell, $dir) = @_; my $w = $c{'width'}; my $h = $c{'height'}; my $c = 2 * ($cell % $w); my $r = int($cell / $w) +1; if($dir eq $west) { cprint($r, $c, "_", $c{'unseen'}); } elsif($dir eq $south) { cprint($r, $c+1, " ", $c{'unseen'}); } refresh(); # now that we have updated, we might need to sleep some select(undef,undef,undef,$c{'dissolve_delay'}); } ## display the maze sub tty_display { my $size = $c{'size'}; my $w = $c{'width'}; my $h = $c{'height'}; cprint(0, 0, " " . "_"x((2*$w)-1), $c{'unseen'}); for my $i (0..$size - 1) { my $c = (2 * ($i % $w)) + 1; my $r = int($i / $w) + 1; if(($i%$w) == 0) { cprint($r, $c - 1, "|", $c{'unseen'}); } if($maze[$i][$set] == $maze_end) { cprint($r, $c, "%", $c{'finish'}); } elsif ( $maze[$i][$set] == $cursor_pos) { if($maze[$i][$south] eq "NULL") { cprint($r, $c, "*", $c{'cursor'}, ::A_UNDERLINE); } else { cprint($r, $c, "*", $c{'cursor'}); } } elsif($maze[$i][$south] eq "NULL") { if($maze[$i][$set] == $trail) { cprint($r, $c, "_", $c{'trail'}); } else { cprint($r, $c, "_", $c{'unseen'}); } } else { if($maze[$i][$set] == $trail) { cprint($r, $c, " ", $c{'trail'}); } else { cprint($r, $c, " ", $c{'unseen'}); } } if($maze[$i][$east] eq "NULL") { cprint($r, $c+1, "|", $c{'unseen'}); } else { if($maze[$i][$set] == $trail) { cprint($r, $c+1, "_", $c{'trail'}); } else { cprint($r, $c+1, "_", $c{'unseen'}); } } } move($c{'screen_height'}-1, $c{'screen_width'}-1); refresh(); } ## take input, mostly for moving your little dude around in the maze sub handle_input { my $key = shift; my $moved = 0; $prev_cursor = $cursor; if (compare_key($key, $c{'up'}) && $maze[$cursor][$north] ne "NULL") { $maze[$cursor][$set] = $trail; $cursor = $maze[$cursor][$north]; $moved = 1; } elsif (compare_key($key, $c{'left'}) && $maze[$cursor][$west] ne "NULL") { $maze[$cursor][$set] = $trail; $cursor = $maze[$cursor][$west]; $moved = 1; } elsif (compare_key($key, $c{'right'}) && $maze[$cursor][$east] ne "NULL") { $maze[$cursor][$set] = $trail; $cursor = $maze[$cursor][$east]; $moved = 1; } elsif (compare_key($key, $c{'down'}) && $maze[$cursor][$south] ne "NULL") { $maze[$cursor][$set] = $trail; $cursor = $maze[$cursor][$south]; $moved = 1; } elsif(compare_key($key, $c{'toggle_color'})) { # toggle the color $c{'color'} = 1 - $c{'color'}; init_colors(); tty_display(); } elsif (compare_key($key, $c{'redraw'})) { clear(); refresh(); tty_display(); } elsif (compare_key($key, $c{'quit'})) { quit(); } elsif (compare_key($key, $c{'solve'})) { solve(1); addstr($c{'height'}, 0, "Press any key to exit"); refresh(); getch(); quit(); } if($moved == 1) { $c{'movesdone'}++ unless($prev_cursor == $cursor); if( $maze[$cursor][$set] == $maze_end ) { win($c{'seed'}); } $maze[$cursor][$set] = $cursor_pos; } update($prev_cursor, $cursor); } ################## BEGIN MAZE SOLVING FUNCTIONS ########################## ## count the number of exits. used by solve(). sub numExits { my $exits = 0; for my $i (0..3) { if ( $maze[$cursor][$i] ne "NULL" ) { $exits++; } } return $exits; } ## whether or not a given path from an intersection has been tried already sub beenThere { my $pos = shift; my $found = 0; foreach my $ele (@tried) { if( $ele == $pos ) { $found = 1; } } return $found; } ## figure out if we've got nowhere new to go from the current intersection sub noMoreOptions { # if a new path still exists, return 0 (i.e. "more options") for my $i (0..3) { if( $maze[$cursor][$i] ne "NULL" && $maze[$maze[$cursor][$i]][$set] != $trail && !beenThere($maze[$cursor][$i]) ) { return 0; } } return 1; } ## back-track to the last intersection sub gotoLastIntersect { my ($display) = @_; my $backtrack = 0; do { for my $i (0..3) { if( $maze[$cursor][$i] ne 'NULL' && $maze[$maze[$cursor][$i]][$set] == $trail ) { $prev_cursor = $cursor; $maze[$cursor][$set] = $empty; $cursor = $maze[$cursor][$i]; update($prev_cursor, $cursor) if($display); $backtrack++; last; } } } while( numExits() < 3 ); return $backtrack; } ## solve the maze sub solve { my ($display) = @_; my $min_moves = 0; @tried = (); # reset the maze initialize_maze(); generate_maze(); tty_display() if($display); $c{'solvestart'} = time() if($display); while(1) { # are we at a dead end? if( numExits() == 1 ) { $min_moves -= gotoLastIntersect($display); } # pick a random direction my $dir = int(rand(4)); # don't try moving into a wall if( $maze[$cursor][$dir] eq "NULL") { next; } # don't go where we just came from if( $maze[$maze[$cursor][$dir]][$set] == $trail ) { next; } # are we at an intersection? if( numExits() > 2 ) { # have we been in that direction already? if( beenThere($maze[$cursor][$dir]) ) { # if we've been to all the exits, go to the last intersection if( noMoreOptions() ) { $min_moves -= gotoLastIntersect($display); } next; } else { # add on to the list of exits we've taken push(@tried, $maze[$cursor][$dir]); } } # make the move $min_moves++ unless($prev_cursor == $cursor); $maze[$cursor][$set] = $trail; $prev_cursor = $cursor; $cursor = $maze[$cursor][$dir]; # did we win? if( $maze[$cursor][$set] == $maze_end ) { $c{'solveend'} = time(); $maze[$cursor][$set] = $cursor_pos; return $min_moves; } $maze[$cursor][$set] = $cursor_pos; update($prev_cursor, $cursor) if($display); } } #################### END MAZE SOLVING FUNCTIONS ########################## sub quit { endwin(); print "\n\nQuitting...\n\n"; stats(); exit(0); } sub stats { print "\nYou just played: -r $c{'height'} -c $c{'width'} -s $c{'seed'}\n"; print "Maze generated in ", $c{'endtime'} - $c{'starttime'}, " seconds.\n"; if($c{'solvestart'}) { print "Maze solved in ", $c{'solveend'} - $c{'solvestart'}, " seconds.\n"; } elsif($c{'mazefinish'}) { my $min_moves = solve(0); my $finish_time = $c{'mazefinish'} - $c{'mazestart'}; my $score = score($c{'height'}, $c{'width'}, $min_moves, $finish_time, $c{'movesdone'}); print "---------------------\n"; print " Height: $c{'height'}\n"; print " Width: $c{'width'}\n"; print "Minimum moves: $min_moves\n"; print " Your moves: $c{'movesdone'}\n"; print " Finish time: $finish_time seconds\n"; print " Your score: $score\n"; } } # calculate score based on the size # of the maze, min number of moves, # actual moves made, and how long it # took to solve sub score { my ($h, $w, $min, $time, $moves) = @_; my $area = $h * $w; my $perc = $min / $moves; my $time_bonus = $min / ($time * 2); my $score = $area * $perc * $time_bonus; return int($score); } ## read the command line arguments sub getinput { my ($height, $width, $random_seed); while(my $arg = shift @ARGV) { if($arg eq "--help" || $arg eq "-h") { help(); } elsif($arg eq "-v") { version(); exit; } elsif($arg eq "-d") { $c{'dissolve'} = 1 - $c{'dissolve'}; } elsif($arg eq "-a") { $c{'color'} = 1 - $c{'color'}; } elsif($arg eq "-m") { $width = int(($c{'screen_width'}-1)/2); $height = $c{'screen_height'}-1; } elsif($arg eq "-c") { $width = shift @ARGV; if($width < 1) { help("Maze width must be at least 1"); } elsif($c{'autosize'} && ($width > $c{'screen_width'})) { help("Maze width must be less than half of the screen width (Max: $c{'screen_width'})\n"); } } elsif($arg eq "-r") { $height = shift @ARGV; if($height < 1) { help("Maze height must be at least 1"); } elsif($c{'autosize'} && ($height > $c{'screen_height'})) { help("Maze height must be one less than the screen height (Max: $c{'screen_height'})\n"); } } elsif($arg eq "-s") { $random_seed = shift @ARGV; } } unless($width) { $width = $c{'default_width'}; } unless($height) { $height = $c{'default_height'}; } if(($width + $height) < 3) { help("A 1 x 1 maze is too small."); } unless($random_seed) { $random_seed = (time); } return($width, $height, $random_seed); } ## hot dog! you won! ## sub win { endwin(); print q; _ |###| ( ) \###/ |H| YOU (o^o) _|=|_ WON! / \ =|/:M:\|= \_/ U{ :W: }U TOM and CROW ___=___ \___/ [|=======|] TOM SERVO congratulate you | CROOOOW | /___8___\ | / \ | (_________) L_J www.robobunny.com ;; $c{'mazefinish'} = time(); stats(); exit; } sub help { my $msg = shift; endwin(); if($msg) { print "\n$msg\n\n"; } my $max_height = $c{'screen_height'} -1; my $max_width = int(($c{'screen_width'}-1)/2); print <] [-c ] [-s ] -m set maze size to the maximum allowed by your screen -d toggle \"dissolve\" mode, slower but fun to watch -h print this help text -r set the number of rows in the maze -c set the number of columns in the maze -s provide a seed value for generating a maze -a toggle ANSI color -v print version Default Keys: Movement: Arrow keys, or vi movement keys (h,j,k,l) Redraw: r Solve Maze: S Quit: q Toggle ANSI color: c END printf "Default height: %3d Max height: %3d\n", $c{'default_height'}, $max_height; printf "Default width: %3d Max Width: %3d\n", $c{'default_width'}, $max_width; exit; } sub version { print "TextMaze v$VERSION by Kirk Baucom \n"; } # read in the configuration file sub read_config { my $conf = shift; my $pos = tell(*DATA); parse_config(*DATA); # generate a default config file if one doesn't exist. if(-f "$conf") { if(-r "$conf") { open(C, "<$conf"); parse_config(*C); } } else { open(C, ">$conf") || return; # can't create config file print C "# This is a config file for textmaze by Kirk Baucom \n"; print C "# Automatically generated by version $VERSION ", scalar(localtime), "\n"; seek(DATA,$pos,0); while() { print C; } close(C); } } # parse a config file ( like the one in __DATA__ ) sub parse_config { my $config = shift; my %valid_colors = map { $_, 1 } ( 'BLACK', 'BLUE', 'CYAN', 'GREEN', 'MAGENTA', 'RED', 'WHITE', 'YELLOW' ); my @field_types = map { $_, 'list' } ( 'up', 'down', 'left', 'right', 'redraw', 'quit', 'solve', 'toggle_color' ); push(@field_types, map { $_, 'single' } ( 'default_height', 'default_width', 'dissolve_delay' )); push(@field_types, map { $_, 'color' } ( 'trail', 'finish', 'cursor', 'unseen' )); push(@field_types, map { $_, 'boolean' } ( 'color', 'dissolve' )); my %field_types = @field_types; while(<$config>) { chomp(my $line = $_); $line =~ s/([:,])\s/$1/g; $line =~ s/\#.*$//; if(!$line) { next; } my ($field, $value) = split(':', $line); chomp($value); my $type = $field_types{$field}; if(!$type) { print "Malformed config file line:\n$line\n"; } elsif($type eq 'list') { @{$c{$field}} = split(",", $value); } elsif($type eq 'single') { $c{$field} = $value; } elsif($type eq 'boolean') { $c{$field} = boolstring($value); } elsif($type eq 'color') { $value = uc($value); my ($fg, $bg, $bold) = split(' ', $value); if($valid_colors{$fg}) { $c{"${field}_fg"} = $fg; } else { print "$value is not a valid ANSI color\n"; exit 1; } if($valid_colors{$bg}) { $c{"${field}_bg"} = $bg; } else { print "$value is not a valid ANSI color\n"; exit 1; } if(defined($bold) and $bold eq 'BOLD') { $c{"${field}_bold"} = 1; } else { $c{"${field}_bold"} = 0; } } } close(C); } # set up the colors sub init_colors { if($c{'color'}) { start_color(); my $cid = 1; foreach my $type ('trail', 'unseen', 'finish', 'cursor') { init_pair($cid, eval "::COLOR_$c{\"${type}_fg\"}", eval "::COLOR_$c{\"${type}_bg\"}"); push(@{$c{$type}}, COLOR_PAIR($cid)); if($c{"${type}_bold"}) { push(@{$c{$type}}, ::A_BOLD); } $cid++; } } else { foreach my $type ('trail', 'unseen', 'finish', 'cursor') { @{$c{$type}} = (); } push(@{$c{'cursor'}}, ::A_BOLD); push(@{$c{'finish'}}, ::A_BOLD); } } # return 1 or 0 when handed a yes/no or on/off type string sub boolstring { my $string = shift; if($string eq "on" || $string eq "ON" || $string eq "yes" || $string eq "YES") { return 1; } else { return 0; } } # check the key that was pressed against a list to see what we should do sub compare_key { my ($key, $set) = @_; for(@$set) { if($key eq $_) { return 1; } } return 0; } # print something to the screen with the specified attributes sub cprint { my ($row, $col, $str, $attr, @xattr) = @_; foreach my $attr (@{$attr}) { attron($attr); } foreach my $attr (@xattr) { attron($attr); } addstr($row, $col, $str); foreach my $attr (@xattr) { attroff($attr); } foreach my $attr (@{$attr}) { attroff($attr); } } # i decided this would be the easiest way to distribute a default config file # don't edit this stuff! edit your own config file after you run textmaze # the first time. it will be ~/.textmaze __DATA__ # key values are case sensitive. arguments should be comma separated lists. up: k, w down: j, s left: h, a right: l, d redraw: r quit: q solve: S toggle_color: c # some default behavior default_height: 20 default_width: 20 color: on dissolve: off # higher = slower dissolve_delay: .02 ## colors to use, if the maze is in color # # available colors: # # black # red # green # yellow # blue # magenta # cyan # white # # the first color is the foreground, second color is background # adding a third argument of 'bold' makes the foreground bold trail: cyan green bold cursor: black yellow finish: red black unseen: cyan black