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 23 — Etching Drawer

This is project 23 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:

SSDDWWAA
QUIT

Run it with:

ek9 etching-drawer.ek9 < input.txt

It prints:

#         
          
          
          
          
WASD keys to move, H for help, C to clear, or QUIT.
#─┐       
│ │       
└─┘       
          
          
WASD keys to move, H for help, C to clear, or QUIT.
Thanks for playing!

The program

#!ek9
defines module bigbook.etching

  defines constant

    Width <- 10
    Height <- 5
    //The drawing's own mark for the cursor, data like the line characters.
    CursorMark <- "#"

  defines type

    //A canvas coordinate is not just any Integer - it is bounded to the canvas.
    //A constraint takes literals only, so these repeat Width - 1 and Height - 1.
    XCoord as Integer constrain as
      >= 0 and <= 9
    YCoord as Integer constrain as
      >= 0 and <= 4

    //The four moves are a closed set, so "is this a direction?" becomes a guard on a
    //parse rather than a four-way character comparison.
    Direction
      Up
      Left
      Down
      Right

    //The three commands. The Console turns the word the player types into one of these; anything
    //else is an UNSET Command, and that unset value is how a run of moves is told apart.
    Command
      Quit
      Help
      Clear

    Dict of (Coord, Connections)
    DictEntry of (Coord, Connections)

  defines record

    //A canvas position, used both as the cursor and as the canvas's Dict KEY - compared by
    //value, so no "x,y" packing. Its fields are the bounded coordinate types, so an off-canvas
    //position cannot be built at all. Fields default UNSET so ':=?' actually assigns.
    Coord
      x as XCoord: XCoord()
      y as YCoord: YCoord()

      default Coord() as pure

      //SNAPSHOT by value: a key that shared the caller's objects would change if they did,
      //its hash would go stale inside the Dict, and every later lookup would miss.
      Coord() as pure
        ->
          px as XCoord
          py as YCoord

        x :=? XCoord(px)
        y :=? YCoord(py)

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

    //Which line-ends meet in one cell. The canonical W,A,S,D spelling the glyph table is
    //keyed by is DERIVED by '$', so it cannot drift from the data.
    //The fields default UNSET, and that is the honest model: for a cell nobody has drawn in
    //we do not know its connections at all. 'noConnections()' is the separate, deliberate
    //claim "known: none".
    Connections
      up as Boolean: Boolean()
      left as Boolean: Boolean()
      down as Boolean: Boolean()
      right as Boolean: Boolean()

      default Connections() as pure

      Connections() as pure
        ->
          isUp as Boolean
          isLeft as Boolean
          isDown as Boolean
          isRight as Boolean

        up :=? isUp
        left :=? isLeft
        down :=? isDown
        right :=? isRight

      operator $ as pure
        <- rtn as String: `${up <- "W" : ""}${left <- "A" : ""}${down <- "S" : ""}${right <- "D" : ""}`

      default operator ?

  defines class

    //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

      //The canvas is drawn, not worded: computed from the game's cells, a row at a time, with
      //the cursor's mark over the cell it is in.
      showCanvas()
        ->
          canvas as Dict of (Coord, Connections)
          cursor as Coord

        for row in 0 ... Height - 1
          line <- ""
          for col in 0 ... Width - 1
            at <- Coord(XCoord(col), YCoord(row))
            line += at == cursor <- CursorMark : lineChar(canvas.getOrDefault(at, Connections()))
          stdout.println(line)

      //What the player typed, in upper case; unset only when the input has ended.
      nextResponse()
        <- rtn as String: String()
        stdout.println(say.askResponse())
        if line <- input.next()
          rtn: line.upperCase()

      //The command a response names; unset for anything else, which is a run of moves.
      commandIn()
        -> response as String
        <- rtn as Command: Command()
        if response == say.quit()
          rtn: Command.Quit
        else if response == say.help()
          rtn: Command.Help
        else if response == say.clear()
          rtn: Command.Clear

      showHelp()
        stdout.println(say.instructions())

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

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

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

  defines function

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

    //The move letters are the game's own data. Any other key yields an UNSET Direction, so the
    //caller guards on the parse.
    directionOf() as pure
      -> key as Character
      <- rtn as Direction: (key == 'W' <- Direction.Up
        : key == 'A' <- Direction.Left
        : key == 'S' <- Direction.Down
        : key == 'D' <- Direction.Right
        : Direction())

    opposite() as pure
      -> dir as Direction
      <- rtn as Direction: Direction()
      switch dir
        case Direction.Up
          rtn: Direction.Down
        case Direction.Down
          rtn: Direction.Up
        case Direction.Left
          rtn: Direction.Right
        case Direction.Right
          rtn: Direction.Left
        default
          rtn: Direction()

    //Range-check FIRST, construct second: an XCoord or YCoord built out of range panics.
    //Off the canvas there is no position, which is an unset Coord.
    onCanvas() as pure
      ->
        col as Integer
        row as Integer
      <- rtn as Coord: Coord()

      if col >= 0 and col < Width and row >= 0 and row < Height
        rtn: Coord(XCoord(col), YCoord(row))

    //The position one step away; unset when that step would leave the canvas.
    //'#^' promotes a coordinate to a plain Integer, which may step out of range.
    neighbour() as pure
      ->
        from as Coord
        dir as Direction
      <- rtn as Coord: Coord()

      col <- #^from.x
      row <- #^from.y
      switch dir
        case Direction.Up
          rtn: onCanvas(col, row - 1)
        case Direction.Down
          rtn: onCanvas(col, row + 1)
        case Direction.Left
          rtn: onCanvas(col - 1, row)
        case Direction.Right
          rtn: onCanvas(col + 1, row)
        default
          rtn: Coord()

    //"Known: no connections" - the state of a cell we are about to start drawing in.
    noConnections() as pure
      <- rtn as Connections: Connections(isUp: false, isLeft: false, isDown: false, isRight: false)

    //Add one direction to a cell's connections, returning the new value.
    connect() as pure
      ->
        conn as Connections
        dir as Direction
      <- rtn as Connections: Connections(
        isUp: conn.up or dir == Direction.Up,
        isLeft: conn.left or dir == Direction.Left,
        isDown: conn.down or dir == Direction.Down,
        isRight: conn.right or dir == Direction.Right)

    //The first cell of a drawing gets the through-line for the axis being drawn.
    seedFor() as pure
      -> dir as Direction
      <- rtn as Connections: Connections(isUp: false, isLeft: true, isDown: false, isRight: true)
      if dir == Direction.Up or dir == Direction.Down
        rtn: Connections(isUp: true, isLeft: false, isDown: true, isRight: false)

    //Draw one step: the outgoing line in this cell, the incoming line in the next. Returns
    //where the cursor ends up - where it was, if the step would leave the canvas.
    drawStep()
      ->
        canvas as Dict of (Coord, Connections)
        cursor as Coord
        dir as Direction
      <- rtn as Coord: cursor

      if next <- neighbour(cursor, dir)
        canvas += DictEntry(cursor, connect(canvas.getOrDefault(cursor, noConnections()), dir))
        canvas += DictEntry(next, connect(canvas.getOrDefault(next, noConnections()), opposite(dir)))
        rtn: next

    //Every W, A, S or D in a response is one step, and anything else in it is passed over. As in
    //the book, the first move on an empty canvas seeds the starting cell. Returns the new cursor.
    drawMoves()
      ->
        canvas as Dict of (Coord, Connections)
        cursor as Coord
        response as String
      <- rtn as Coord: cursor

      for key in response
        if dir <- directionOf(key)
          if canvas.empty()
            canvas += DictEntry(rtn, seedFor(dir))
          rtn: drawStep(canvas, rtn, dir)

  defines function

    //The Console's drawing: computed output, with no words in it.

    //Map a cell's connections to its box-drawing character.
    lineChar() as pure
      -> conn as Connections
      <- rtn as String: " "
      table <- {
        "W": "│", "S": "│", "WS": "│",
        "A": "─", "D": "─", "AD": "─",
        "SD": "┌", "AS": "┐", "WD": "└", "WA": "┘",
        "WSD": "├", "WAS": "┤", "ASD": "┬", "WAD": "┴",
        "WASD": "┼"
      }
      rtn: table.getOrDefault($conn, " ")

    //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 drawing is not here: the Console draws
  //it from the game's cells, and the WASD move letters are the game's own data.
  defines text for "en"

    Say

      askResponse()
        "WASD keys to move, H for help, C to clear, or QUIT."

      quit()
        "QUIT"

      help()
        "H"

      clear()
        "C"

      instructions()
        `Enter W, A, S, and D characters to move the cursor and
draw a line behind it as it moves. For example, ddd
draws a line going right and sssdddwwwaaa draws a box.`

      goodbye()
        "Thanks for playing!"

  defines program

    //Etching Drawer (Big Book project 23). An Etch-A-Sketch: WASD move a cursor (#) that draws a
    //continuous box-drawing line behind it on a 10x5 canvas. The game decides; the Console asks and
    //tells, in the player's words.
    EtchingDrawer()
      //A language with no words of its own is unset: ?: falls back to English.
      console <- Console(Say(language()) ?: Say("en"))

      canvas <- Dict() of (Coord, Connections)
      cursor <- Coord(XCoord(0), YCoord(0))
      running <- true
      while running and not console.ended()
        console.showCanvas(canvas, cursor)
        if response <- console.nextResponse()
          switch console.commandIn(response)
            case Command.Quit
              console.showGoodbye()
              running: false
            case Command.Help
              console.showHelp()
            case Command.Clear
              canvas: Dict() of (Coord, Connections)
            default
              //Not a command, so every W, A, S or D in it is a move.
              cursor: drawMoves(canvas, cursor, response)

About this port