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
- Faithful to the book: an Etch-A-Sketch. A cursor (
#) moves with WASD and draws a continuous box-drawing line behind it, choosing the character (│ ─ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼) from the set of line directions that meet in each cell. One response can hold many moves (sssdddwwwaaadraws a box); C clears, H shows help, QUIT exits. As in the book, the first move on an empty canvas seeds the starting cell with a through-line for that axis. - Divergences: a fixed 10x5 canvas (the book uses the terminal's size); the F (save to file) command is dropped.
- Idioms:
- The canvas is a
Dict of (Coord, Connections).Coordis a record key compared by value whose fields are the boundedXCoord/YCoord, so an off-canvas position cannot be built. - The cursor is a
Coordtoo, and it is never changed in place:neighbourreturns the next position, or an unsetCoordoff the canvas, sodrawStepguards withif next <- neighbour(cursor, dir)instead of four bounds checks and amovedflag. Because positions never change, a key stored in theDictcannot change under it. onCanvasrange-checks before it constructs: building anXCoordout of range panics, so a constrained type is an invariant, not a validator.Connectionsis a record of four Booleans; its$derives the canonical"WASD"spelling the glyph table is keyed by, so the spelling cannot drift from the data. Its fields default unset, because for a cell nobody has drawn in the connections are not known;noConnections()is the deliberate claim "known: none".Directionis an enumeration anddirectionOfreturns an unsetDirectionfor any other key, so the program guards on the parse:if dir <- directionOf(key).- "Has the drawing started?" is asked of the canvas itself (
canvas.empty()), as the book does, not kept in a separate flag.
- The canvas is a