Amazingly Raku

Part 4: The Class

by Arne Sommer

Amazingly Raku - Part 4: The Class

[94.4] Published 9. December 2020.

See also: The Introduction | Part 1: The Maze | Part 2: The Path | Part 3: The Wall.

In this part we'll rewrite the code making and traversing mazes (from the first three parts) into a module, and reimplement the programs with that module.

Originality, and the lack thereof

The module is called «Game::Amazing» (as a natural consequence of the name of this article series), and is available on CPAN and GitHub. See the «Links» box on the right for links.

While searching for a suitable name for the module this autumn, I discovered the Games::Maze module by Mohammad S Anwar, the person behind the Perl Weekly Challenge. Further research uncovered that he had written about this in last year's Raku Advent Calendar; Day 17: Maze Maker for Fun. Which I obviously had read (and forgotten about - possibly because I was busy with the final touches on my own Advent Calendar entry: Day 22: Off Course).

Here is an example of how to use his module:

$ zef install Games::Maze
> use Games::Maze;
> my $maze = Games::Maze.new( :height(10), :width(10) );
> $maze.make.render.say;
 _ _ _ _ _ _ _ _ _ _ 
| |_   _ _  |  _ _  |
| |  _|_  | | |   | |
| | |     | | | |_| |
|_ _|_| |_| |_  |  _|
|  _   _|  _|  _|_  |
|   |_|  _| |_  |  _|
| |_  |_  |_ _ _|_  |
|_  |_  |_ _ _  |  _|
|  _| | |  _  | |_  |
|_ _ _|_ _ _|_ _ _ _|

Fortunately his approach was different from mine, so I don't feel too bad about reusing the idea.

Note that I will only comment code that is different from the original non-module code in part 1 to 3. So you should see those parts if you require more information.

The first part of the module looks like this:

File: lib/Game/Amazing.pm6 (partial)
unit class Game::Amazing:ver<0.9.0>;   # [1]

has @.maze is rw;                      # [2]
has $.rows is rw;                      # [2a]
has $.cols is rw;                      # [2b]

[1] The version number may not be correct anymore when you read this. In that case the module code and the programs may have changed as well. If you explicitly want the same version, download the very same version.

[2] A Maze object has three variables; the first one is the maze itself (as a two-dimentional array). The second and third is the number of rows [2a] and columns [2b].

Derived Size

It is possible to replace the variables holding the number of rows and columns with method calls. E.g.:
method rows { return self.maze.elems; }
method cols { return self.maze[0].elems; }
It does not matter from a user perspecitve, as public methods and variables look the same from the outside:
my $rows = $m.rows;  # Either a variable or a method.
I have chosen not to do this, as we use these values a lot in the programs. Raku may choose to cache them, but why take the risk.

mazemaker

This is a reimplementation of the original «mazemaker2» program, that removes spurious exits out of the mazes. I have added an option to ensure that the generated maze is traversable, borrowing code from the «maze-solver-spa» program from Part 2.

File: mazemaker
#! /usr/bin/env raku

use Game::Amazing;                                             # [1]

unit sub MAIN (:b(:$box)   = 25,                               # [2]
               :r(:$rows)  = $box,                             # [3]
               :c($cols)   = $box,                             # [4]
               :s(:$scale) = 7,                                # [5]
               :f(:$file),                                     # [8]  
               :e(:$ensure-traversable) = False);              # [6]

my $m = Game::Amazing.new(:$rows, :$cols, :$scale,
                          :$ensure-traversable);               # [7]

$m.save($file && $file.ends-with('.maze') ?? $file !! $*OUT);  # [8]

[1] The module. Install it with «zef» like this: «zef install Game::Amazing».

[2] The size of the maze (box = both rows and columns), defaulting to 25.

[3] The number of rows in the maze, defaulting to the box value.

[4] The number of columns in the maze, defaulting to the box value.

[5] The scale (or overrepresentation of certain symbols). See Part 2: The Path for details.

[6] The flag to ensure that the generated maze is traversable.

[7] Generate the maze.

[8] Print it to a file, if given a filename (ending with «.maze»), or STDOUT.

Let us look at the «new» method. There are two versions, one where we specify a file name, and one where we do not (and thus want a randomly generated maze).

File: lib/Game/Amazing.pm6 (partial)
multi method new ($file)
{
  die "Unable to read $file" unless $file.IO.f && $file.IO.r;
  my $m = self.bless;
  $m.maze = $file.IO.lines>>.comb>>.Array;
  $m.rows = $m.maze.elems;
  $m.cols = $m.maze[0].elems;
  
  return $m;
}

multi method new (:$rows  = 25,
                  :$cols  = 25,
                  :$scale = 7,
                  :$ensure-traversable = False)
{
  my $m = self.defined ?? self !! self.bless;          # [1]

  my %weight = (
   '╗'  => 1,
   '═'  => 1,
   '╝'  => 1,
   '╔'  => 1,
   '║'  => 1,
   '╚'  => 1,
   '╦'  => $scale,
   '╠'  => $scale,
   '╣'  => $scale,
   '╩'  => $scale,
   '╬'  => $scale
  );

  my @maze;
  my @symbols = %weight.keys.map({ $_ xx %weight{$_} }).flat;   

  repeat                                                # [2]
  {
    @maze = ();
    @maze.push: @symbols.roll($cols).join for ^$rows;

    remove-direction(@maze[0].substr-rw($_,1),       "N") for ^$cols;
    remove-direction(@maze[$_].substr-rw(0,1),       "W") for ^$rows;
    remove-direction(@maze[$_].substr-rw($cols-1,1), "E") for ^$rows;
    remove-direction(@maze[$rows-1].substr-rw($_,1), "S") for ^$cols;

    @maze[0].substr-rw(0,1) = @maze[$rows-1].substr-rw($cols-1,1) = $end;

    $m.maze = @maze>>.comb>>.list;
    $m.rows = $m.maze.elems;
    $m.cols = $m.maze[0].elems;
  }
  while $ensure-traversable && !$m.is-traversable;      # [2a]
  
  sub remove-direction($symbol is rw, $direction)
  {
    my $desc = %symbol2desc{$symbol} // return $symbol;
    $desc ~~ s/$direction//;
    $symbol = %desc2symbol{$desc} // ' ';               # [3]
  }

  return $m;
}

[1] This one makes it possible to call «new» on an existing Maze object to create a new maze.

[2] Generate mazes until we get a traversable one, if the user has requested a traversable maze. (The «is-traversable» method will be shown later.)

[3] The // part gives a space symbol if we end up with a symbol with only one exit - as that does not exist as a Unicode symbol.

The «save» method comes in three flavours:

File: lib/Game/Amazing.pm6 (partial)
use File::Temp;

multi method save (IO::Handle $fh)                              # [4]
{
  $fh.spurt: self.maze>>.join.join("\n") ~ "\n";
}

multi method save ($file where $file.ends-with('.maze'))        # [5]
{
  spurt $file, self.maze>>.join.join("\n") ~ "\n";
}

multi method save                                               # [6]
{
  my ($fname, $fh) = tempfile( :!unlink, :suffix(".maze") );
  $fh.spurt: self.maze>>.join.join("\n") ~ "\n";
  return $fname;
}

[4] This version is used when we pass it a filehandle. The «mazemaker» program uses «$*OUT» to get the maze printed to the screen.

[5] This version takes a file name. Note the requirement of the «.maze» file name extension.

[6 ] This version is used when there are no arguments. It prints the maze to a temporary file (with a random name), using «:!unlink» to tell Raku to leave the file after program termination (as it would have been removed then otherwise.)

See modules.raku.org/dist/File::Temp for more information about the «File::Temp» module.

maze-solver-spa

This is a reimplementation of the program with the same name, using The Shortest Path Algorithm.

File: maze-solver-spa
#! /usr/bin/env raku

use Game::Amazing;

unit sub MAIN ($maze where $maze.IO.e && $maze.IO.r,
               :v(:$verbose),
               :s(:$show),
               :c(:$coverage),
               :h(:$html));

my $m = Game::Amazing.new: $maze;                                    # [1]

my ($traversable, $traversable-path) = $m.is-traversable(:get-path); # [2]

my @visited = $traversable ?? () !! @($traversable-path);            # [3]

my $col-blue  = "\e[44m";
my $col-green = "\e[42m";
my $col-red   = "\e[101m";
my $col-stop  = "\e[0m";

if ($html)
{
  $col-blue  = '<span class="text-primary">';
  $col-green = '<span class="text-success">';
  $col-red   = '<span class="text-danger">';
  $col-stop  = '</span>';
}

if $verbose
{
  say ": Rows: $m.rows";
  say ": Cols: $m.cols";
  say ": Row: { @$_.join }" for $m.maze;
}

if $traversable                                                      # [4]
{
  say "Path: $traversable-path (with length { $traversable-path.chars })";
  show-path($traversable-path) if $show;
}
else                                                                 # [5]
{
  say "No path";
  show-coverage if $show;
}

sub show-path ($path)                                                # [4a]
{
  my @maze = $m.maze.clone;  
  my $row  = 0;
  my $col  = 0;

  for $path.comb -> $direction
  {  
    @maze[$row][$col] = $col-green ~ @maze[$row][$col] ~ $col-stop;
    if    $direction eq "N" { $row--; }
    elsif $direction eq "S" { $row++; }
    elsif $direction eq "W" { $col--; }
    elsif $direction eq "E" { $col++; }
  }

  @maze[$row][$col] = $col-green ~ @maze[$row][$col] ~ $col-stop;
 
  say @$_.join for @maze;
}

sub show-coverage                                                     # [5a]
{
  my @matrix;

  for ^$m.rows -> $row
  {
    for ^$m.cols -> $col
    {
      @matrix[$row][$col] = @visited[$row][$col]
       ?? $col-red ~ $m.maze[$row][$col] ~ $col-stop
       !! $m.maze[$row][$col];
    }
  }

  say @$_.join for @matrix;
}

[1] Load the maze from the specified file.

[2] Is it traversable? Note the «:get-path» argument that tells the method to give us the path as well as the answer. The path is a string with directions («N» for north, «E» for east, «S» for south and «W» for west; e.g. «SEEEEESENEEEESSSS»), if it is traversable. If not, we get an array with the same dimensions as the maze, where the value is «True» if the method visited that cell on the search for the exit - giving a coverage map. See also [3].

[3] Get the visited cells, if the maze is untraversable.

[4] Show the path, if it is traversable. Note the for-loop building up the array of visited cells (on the shortest path).

[5] Show the coverage. We print one cell at a time, with or without the highlight treatment depending on the visitation status.

I am not showing the «is-traversable» method. See part 2 for the original procedure.

maze-solver-wall

This is a reimplementation of the program using The Wall Follower Algorithm.

File: maze-solver-wall
#! /usr/bin/env raku

use Game::Amazing;

unit sub MAIN ($maze where $maze.IO.e && $maze.IO.r,
               :l(:$left),
               :v(:$verbose),
               :s(:$show),
               :h(:$html));

my $m = Game::Amazing.new: $maze;

my ($traversable, $visited) = $m.is-traversable-wall(:get-path, :$verbose);

my $col-blue  = "\e[44m";
my $col-green = "\e[42m";
my $col-red   = "\e[101m";
my $col-stop  = "\e[0m";

if ($html)
{
  $col-blue  = '<span class="text-primary">';
  $col-green = '<span class="text-success">';
  $col-red   = '<span class="text-danger">';
  $col-stop  = '</span>';
}

if $verbose
{
  say ": Turn: { $left ?? 'left' !! 'right' }";
  say ": Rows: { $m.rows }";
  say ": Cols: { $m.cols }";
  say ": Row: { @$_.join }" for $m.maze;
}

sub show-path
{
  my $col-start = $traversable ?? $col-green !! $col-red;

  for ^$m.rows -> $row
  {
    for ^$m.cols -> $col
    {
      print @($visited)[$row][$col]
       ?? $col-start ~ $m.maze[$row][$col] ~ $col-stop
       !! $m.maze[$row][$col];
    }
    say '';
  }
}

say $traversable
  ?? "yes"
  !! "no";

show-path if $show;

I am not showing the «is-traversable-wall» method. See part 3 for the original procedure.

maze-solver-summary

This is a new program, printing summaries for one or more maze files. It shows the size (rows and columns), as well as an indication of how difficult it would be to solve the maze.

File: maze-solver-summary
#! /usr/bin/env raku

use Game::Amazing;

sub MAIN (*@files)               # [1]
{
  check-it($_) for @files;       # [2]
}

sub check-it ($file)             # [2]
{
  if $file.IO.f
  {
    next unless $file.ends-with('.maze');
    
    my $m = Game::Amazing.new: $file;
    my ($traversable, $traversable-path) = $m.is-traversable(:get-path);

    say $traversable
      ?? "$file [{ $m.rows },{ $m.cols }] is traversable (difficuly: { \
         ($traversable-path.chars / ( $m.rows + $m.cols -2)).round(0.1) })."
      !! "$file [{ $m.rows },{ $m.cols }] is not traversable.";
  }
  elsif $file.IO.d
  {
    check-it($_) for dir($file).sort;
  }
}

The difficulty is defined at 1 (100%) for the easiest maze, and is the absolute minimum number of steps required to traverse the maze. The number is: «(rows -1) + (columns -1)». The actual number of steps in the shortest path is then compared to this value, giving the difficulty rating. Beware that it is only a guess, and the perceived difficulty may vary a lot.

Some examples:

$ ./maze-solver-summary mazes/
mazes/10x10-fail.maze [10,10] is not traversable.
mazes/10x10-hard.maze [10,10] is traversable (difficuly: 3.4).
mazes/10x10-ok.maze [10,10] is traversable (difficuly: 1.1).
mazes/25x25-hard.maze [25,25] is traversable (difficuly: 1.6).
mazes/25x25-ok.maze [25,25] is traversable (difficuly: 1.1).
mazes/50x25.maze [25,50] is traversable (difficuly: 1.1).
mazes/50x50-01-fail.maze [50,50] is not traversable.
mazes/50x50-02-fail.maze [50,50] is not traversable.
mazes/50x50-03-fail.maze [50,50] is not traversable.
mazes/50x50-04.maze [50,50] is traversable (difficuly: 1.2).
mazes/50x50-05-fail.maze [50,50] is not traversable.
mazes/50x50-06.maze [50,50] is traversable (difficuly: 1.2).
mazes/50x50-07-fail.maze [50,50] is not traversable.
mazes/50x50-08-fail.maze [50,50] is not traversable.
mazes/50x50-09.maze [50,50] is traversable (difficuly: 1.3).
mazes/50x50-10-fail.maze [50,50] is not traversable.
mazes/5x5-fail.maze [5,5] is not traversable.
mazes/5x5-ok.maze [5,5] is traversable (difficuly: 1).
$ ./maze-solver-summary mazes/BUjILKJrMV-25x25.maze 
mazes/BUjILKJrMV-25x25.maze [25,25] is traversable (difficuly: 3.8).

Note the «-fail.maze» ending on non-traversable mazes. That is done so that the automated test can check a maze for traversability - and check the result against an authoritative answer.

That brings us to the last program implemented so far, a single test file. It reads all the maze files in the «mazes/» directory, and checks that the ones that have names ending with «-fail.maze» are untraversable.

File: t/0-basic.t
use v6;
use Test;

use lib "lib";       # [1]
use Game::Amazing;

for dir("mazes").sort -> $file
{
  my $pass = ! so($file ~~ /\-fail\.maze$/);
  my $maze = Game::Amazing.new: $file;

  ok($maze, "Loaded maze $file");

  ok($maze.is-traversable.so == $pass,
       "Maze $file is { $pass ?? '' !! 'not' } traversable");
}

done-testing;

[1] As tesing is done before module installation, so the module is not available in the standard locations yet. The test program («prove6») must be run from the top level directory of the module.

Part 5: The First Game

See the next part; Part 5: The First Game where I present a game where the user can have a go at traversing mazes.