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
- Faithful to the book: ░ walls, @ for the player, X for the exit; W/A/S/D moves and the player slides along the corridor until it reaches the exit, a wall ahead, or a junction (an opening at right angles); "You cannot move in that direction." for a blocked first step; reaching the exit prints "You have reached the exit! Good job!" / "Thanks for playing!"; QUIT ends the game.
- Divergence: the maze is this port's own small maze in
data/maze.txt, read directly rather than chosen through the book's file picker frommazemakerrec.pyoutput. - Idioms:
- The maze is a
Mazeclass over aDict of (Coord, Cell):Coordis a record key compared by value, andCell(Wall,Open,Start,Exit) is an enumeration. A square off the map reads asCell.WallthroughgetOrDefault, so there are no bounds checks. The width and height come from the file. Coord's constructor copies its arguments: the loader builds keys from counters that keep changing, and a key that shared them would change inside theDict.- Moves are a
Directionenumeration;stepis an exhaustiveswitch, anddirectionOfparses a key to an unsetDirectionfor anything else, which the game guards on.nextDirectionreturns an unsetDirectionwhen the player quits or the input ends, and the game loop guards on it (if dir <- console.nextDirection(maze, player)). slide,step,opensSidewaysandkeepsSlidingareas pure: the sliding rule is separate from the console.slideloops by re-assigning only its return value, which a pure function may do.- Each character read is a
Character; assigning it to aString(glyph as String: ch) promotes it.
- The maze is a