The Big Book of Small EK9 Programs

Adapted from Al Sweigart's The Big Book of Small Python Projects: 81 Easy Practice Programs (No Starch Press, 2021; copyright © 2021 Al Sweigart), which he makes freely available under a Creative Commons Attribution-NonCommercial-ShareAlike licence at inventwithpython.com/bigbookpython. Changes were made: each program was ported from Python to EK9 and remodelled in EK9's idioms.

Project 44 — Maze Runner 2D

This is project 44 of the book, ported to EK9. Its chapter is still being written; until then, this page shows the port itself: a run of it, the whole program, and how it keeps to the original.

A sample run

The player's answers are piped in:

D
S
A
S
D

Run it with:

ek9 maze-runner-2d.ek9 < input.txt

It prints:

Maze Runner 2D, by Al Sweigart al@inventwithpython.com
░░░░░░░░░
░@      ░
░░░░░░░ ░
░       ░
░ ░░░░░░░
░      X░
░░░░░░░░░
Enter direction (W A S D) or QUIT:
░░░░░░░░░
░      @░
░░░░░░░ ░
░       ░
░ ░░░░░░░
░      X░
░░░░░░░░░
Enter direction (W A S D) or QUIT:
░░░░░░░░░
░       ░
░░░░░░░ ░
░      @░
░ ░░░░░░░
░      X░
░░░░░░░░░
Enter direction (W A S D) or QUIT:
░░░░░░░░░
░       ░
░░░░░░░ ░
░@      ░
░ ░░░░░░░
░      X░
░░░░░░░░░
Enter direction (W A S D) or QUIT:
░░░░░░░░░
░       ░
░░░░░░░ ░
░       ░
░ ░░░░░░░
░@     X░
░░░░░░░░░
Enter direction (W A S D) or QUIT:
░░░░░░░░░
░       ░
░░░░░░░ ░
░       ░
░ ░░░░░░░
░      @░
░░░░░░░░░
You have reached the exit! Good job!
Thanks for playing!

The program

#!ek9
defines module bigbook.mazerunner

  defines constant

    MazeFile <- "data/maze.txt"

    //The drawing's own marks, data like the maze file's letters: they are drawn, not worded.
    PlayerMark <- "@"
    ExitMark <- "X"
    WallMark <- "░"
    Blank <- " "

  defines type

    //What a square of the maze is. The start and the exit are open squares the program also
    //needs to find, so they keep their own names rather than being overwritten with spaces.
    Cell
      Wall
      Open
      Start
      Exit

    //The four moves are a closed set. The keystroke is compared with letters once, in
    //directionOf; everything after that works with the enumeration.
    Direction
      Up
      Left
      Down
      Right

    //Why a reply is not a move the player can make, in the order the book checks.
    MoveProblem
      NotADirection
      Blocked

    Dict of (Coord, Cell)
    DictEntry of (Coord, Cell)

  defines record

    //A square's position, used as a Dict KEY and compared by value (no "x,y" strings to build
    //and split again). The fields default UNSET because a pure constructor may only assign with
    //':=?', which assigns only to an unset field.
    Coord
      x as Integer: Integer()
      y as Integer: Integer()

      default Coord() as pure

      //SNAPSHOT the arguments. Keeping the caller's objects would let a key already stored in
      //the Dict change as the loader's counters move on, and every later lookup would miss.
      Coord() as pure
        ->
          px as Integer
          py as Integer

        x :=? Integer(px)
        y :=? Integer(py)

      default operator ?
      default operator <=>
      default operator #?

  defines class

    //THE GAME's state: the maze, built from the lines of its file, one Cell per Coord. Anything off the map
    //reads as a Wall, so no move needs a bounds check. Its size comes from the lines, not from
    //constants. It is given the lines: reading the file is input, and stays out of the game.
    Maze
      cells as Dict of (Coord, Cell): Dict() of (Coord, Cell)
      width as Integer: 0
      height as Integer: 0

      Maze()
        -> lines as List of String
        for line in lines
          x <- 0
          for ch in line
            glyph as String: ch
            cells += DictEntry(Coord(x, height), cellOf(glyph))
            x += 1
          width: length line
          height += 1

      columns() as pure
        <- rtn as Integer: Integer(width)

      rows() as pure
        <- rtn as Integer: Integer(height)

      cellAt() as pure
        -> at as Coord
        <- rtn as Cell: cells.getOrDefault(at, Cell.Wall)

      isOpen() as pure
        -> at as Coord
        <- rtn as Boolean: cellAt(at) <> Cell.Wall

      locate()
        -> kind as Cell
        <- rtn as Coord: Coord()
        for entry in cells
          if entry.value() == kind
            rtn: entry.key()

      default operator ?

    //THE input idiom of this book, copied unchanged from lib/LineInput.ek9 (the reasons are
    //there): read with next(), and every input loop also stops when ended() is true.
    LineInput
      stdin as Stdin: Stdin()
      inputEnded as Boolean: false

      next()
        <- rtn as String: stdin.next()
        if not rtn?
          inputEnded: true

      ended() as pure
        <- rtn as Boolean: Boolean(inputEnded)

      override operator ? as pure
        <- rtn as Boolean: true

    //THE INPUT AND OUTPUT: everything the player sees or types goes through here, worded by Say.
    Console
      stdout as Stdout: Stdout()
      input as LineInput: LineInput()
      say as Say: Say("en")

      Console()
        -> words as Say
        say: words

      showIntroduction()
        stdout.println(say.introduction())

      //The maze is drawn, not worded: computed from the game's cells, a row at a time, with the
      //player's mark over the square they stand on.
      showMaze()
        ->
          maze as Maze
          player as Coord

        for y in 0 ... maze.rows() - 1
          row <- ""
          for x in 0 ... maze.columns() - 1
            at <- Coord(x, y)
            row += glyphOf(maze.cellAt(at), at == player)
          stdout.println(row)

      //Asks until the player gives a direction they can move in. Unset means they quit or the
      //input ended.
      nextDirection()
        ->
          maze as Maze
          position as Coord
        <- rtn as Direction: Direction()

        quitting <- false
        while not rtn? and not quitting and not input.ended()
          stdout.println(say.askDirection())
          if line <- input.next()
            response <- line.upperCase().trim()
            if response == say.quit()
              quitting: true
            else if problem <- problemWith(maze, position, response)
              showProblem(problem)
            else
              rtn: directionOf(response)

      showEscaped()
        stdout.println(say.reachedExit())

      showGoodbye()
        stdout.println(say.goodbye())

      ended() as pure
        <- rtn as Boolean: input.ended()

      private showProblem()
        -> problem as MoveProblem
        switch problem
          case MoveProblem.NotADirection
            stdout.println(say.notADirection())
          case MoveProblem.Blocked
            stdout.println(say.blocked())
          default
            //problemWith returns a set problem or none; the compiler requires this branch (E07320).
            stdout.println(say.notADirection())

      override operator ? as pure
        <- rtn as Boolean: true

  defines function

    //THE GAME: its rules, with no input or output.

    //The letters of the maze file are the game's own data.
    cellOf() as pure
      -> glyph as String
      <- rtn as Cell: glyph == "#" <- Cell.Wall : glyph == "S" <- Cell.Start : glyph == "E" <- Cell.Exit : Cell.Open

    //Parse a keystroke; anything else is an UNSET Direction, which the caller guards on.
    directionOf() as pure
      -> letter as String
      <- rtn as Direction: (letter == "W" <- Direction.Up
        : letter == "A" <- Direction.Left
        : letter == "S" <- Direction.Down
        : letter == "D" <- Direction.Right
        : Direction())

    //Every Direction must be a case (E07310) and the default is required too (E07320): it is
    //the branch an unset Direction takes, and it stays where it is.
    step() as pure
      ->
        at as Coord
        dir as Direction
      <- rtn as Coord?

      switch dir
        case Direction.Up
          rtn: Coord(at.x, at.y - 1)
        case Direction.Down
          rtn: Coord(at.x, at.y + 1)
        case Direction.Left
          rtn: Coord(at.x - 1, at.y)
        case Direction.Right
          rtn: Coord(at.x + 1, at.y)
        default
          rtn: Coord(at.x, at.y)

    //Unset when the reply is a direction the player can move in; otherwise what is wrong with it.
    problemWith() as pure
      ->
        maze as Maze
        at as Coord
        response as String
      <- rtn as MoveProblem: MoveProblem()

      if dir <- directionOf(response)
        if not maze.isOpen(step(at, dir))
          rtn: MoveProblem.Blocked
      else
        rtn: MoveProblem.NotADirection

    //A junction: an opening at right angles to the direction of travel.
    opensSideways() as pure
      ->
        maze as Maze
        at as Coord
        dir as Direction
      <- rtn as Boolean: false

      if dir == Direction.Left or dir == Direction.Right
        rtn: maze.isOpen(step(at, Direction.Up)) or maze.isOpen(step(at, Direction.Down))
      else
        rtn: maze.isOpen(step(at, Direction.Left)) or maze.isOpen(step(at, Direction.Right))

    keepsSliding() as pure
      ->
        maze as Maze
        at as Coord
        dir as Direction
      <- rtn as Boolean: false

      if maze.cellAt(at) <> Cell.Exit and maze.isOpen(step(at, dir))
        rtn: not opensSideways(maze, at, dir)

    //The player slides along a corridor until the exit, a wall ahead or a junction. A pure
    //function may re-assign only its return value, which is all this loop needs.
    slide() as pure
      ->
        maze as Maze
        start as Coord
        dir as Direction
      <- rtn as Coord: step(start, dir)

      while keepsSliding(maze, rtn, dir)
        rtn: step(rtn, dir)

  defines function

    //THE MAZE FILE: reading it is input, so the program reads it here and hands the game the lines.
    mazeLines()
      -> path as FileSystemPath
      <- rtn as List of String: List() of String
      file <- TextFile(path)
      try
        -> reader <- file.input()
        while reader.hasNext()
          rtn += reader.next()

  defines function

    //The Console's drawing: computed output, with no words in it.
    glyphOf() as pure
      ->
        cell as Cell
        isPlayer as Boolean
      <- rtn as String: isPlayer <- PlayerMark : cell == Cell.Exit <- ExitMark : cell == Cell.Wall <- WallMark : Blank

    //THE language of the words, copied unchanged from lib/Language.ek9: BIGBOOK_LANG when it
    //is set (fr, zh, ...), otherwise en.
    language()
      <- rtn as String: EnvVars().get("BIGBOOK_LANG") ?: "en"

  //THE WORDS: every word the player sees or types. The maze is not here: it is data from its
  //file, and the Console draws it; the W A S D move letters are the game's own data.
  defines text for "en"

    Say

      introduction()
        "Maze Runner 2D, by Al Sweigart al@inventwithpython.com"

      askDirection()
        "Enter direction (W A S D) or QUIT:"

      quit()
        "QUIT"

      blocked()
        "You cannot move in that direction."

      notADirection()
        "Invalid direction. Enter one of W, A, S, or D."

      reachedExit()
        "You have reached the exit! Good job!"

      goodbye()
        "Thanks for playing!"

  defines program

    //Maze Runner 2D (Big Book project 44). Slide along the corridors of a maze, a junction at a
    //time, until you reach the exit. The game decides; the Console asks and tells, in the
    //player's words.
    MazeRunner()
      //A language with no words of its own is unset: ?: falls back to English.
      console <- Console(Say(language()) ?: Say("en"))

      console.showIntroduction()
      maze <- Maze(mazeLines(FileSystemPath().inCurrentWorkingDirectory(MazeFile)))
      player <- maze.locate(Cell.Start)
      exitAt <- maze.locate(Cell.Exit)
      console.showMaze(maze, player)

      running <- true
      while running and not console.ended()
        if dir <- console.nextDirection(maze, player)
          player: slide(maze, player, dir)
          console.showMaze(maze, player)
          running: player <> exitAt
        else
          running: false

      if player == exitAt
        console.showEscaped()
      console.showGoodbye()

About this port