Using py_maze as a library
Every part of the game is a function or a class you can call. The grid it all passes around, a worked example, and the whole public surface tabled.
Every part of the game is a function or a class that can be called from your
own code. import py_maze reaches every public name whichever module it
lives in, so nothing has to be imported out of a submodule:
import py_maze
grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
msvcrt, tty and termios are imported by py_maze.keys alone, so
every module that generates, solves, draws or saves a maze leaves the
terminal alone. Importing the package re-exports the game as well, so it
brings that one module with it, but no console is read or written at
import time, and a script that wants none of the terminal at all can
import py_maze.generation and py_maze.solving on their own.
The Grid
One type is passed between every name below. A maze is a grid: a list of
rows, each row a list of booleans, True for a wall and False for a cell
the player can stand on. grid[y][x] addresses row y, column x, and a
cell is always the pair (x, y).
A maze of W by H cells is carved into H * 2 + 1 rows of W * 2 + 1
booleans, the extra line in each direction being the wall between one cell
and the next. Every row is the same length: a ragged grid is not a maze.
>>> grid = py_maze.MazeGenerator(width=2, height=2, seed=1).generate()
>>> len(grid), len(grid[0]) # rows, then columns
(5, 5)
>>> grid[0][1] # the entrance, carved into the top row
False
The grid is built from nothing but lists and booleans, so it can be copied,
compared, pickled or written out with json.dumps as it stands. A maze
carved by MazeGenerator is the same object the solver walks, the renderer
draws and the save file writer writes, so nothing is converted between one
step and the next.
The entrance and the exit are not stored beside the grid, they are found in
it: find_entrance returns the open cell in the top row and find_exit the
one in the bottom row. That is why a maze read back from a file is used
exactly like a carved one, having nothing but its grid to go on.
A Worked Example
Generating a maze, solving it and drawing the solution, with no game and no keyboard involved:
import py_maze
# carve a 6 by 6 maze from a seed, so the same maze comes back every run
grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
# the entrance and the exit are read out of the grid, not stored beside it
entrance = py_maze.find_entrance(grid)
exit_cell = py_maze.find_exit(grid)
# breadth-first search from the entrance to the exit, shortest route first
path = py_maze.solve_maze(grid)
print("%s to %s in %d cells" % (entrance, exit_cell, len(path)))
# draw the maze with the solution laid over it, one string per row
for line in py_maze.maze_lines(grid, py_maze.solution_overlay(path)):
print(line)
Output:
(1, 0) to (11, 12) in 35 cells
*.***********
*.* * *
*.*** * *** *
*...* * *
***.* *******
*...* *
*.*** ***** *
*...* * * *
***.* * *** *
*...* *...*
*.*******.*.*
*.........*.*
***********.*
That is the maze python -m py_maze -d easy --seed 2024 --solve prints,
since easy is 6 by 6 cells and the seed decides the rest.
solve_maze takes start and end cells too, each defaulting to the
entrance and the exit, so a route can be solved from wherever a player has
wandered to. It returns None when there is no way through rather than
raising, which is worth checking on a maze that came from a file:
path = py_maze.solve_maze(grid)
if path is None:
print("no way through")
Carving and Braiding
MazeGenerator looks the algorithm up by name and holds the seed, but a
carver can be called on its own: a width, a height and a random number
generator in, a carved grid out.
import random
import py_maze
# the names --algorithm reads from: ['backtracker', 'division', 'prim']
print(sorted(py_maze.ALGORITHMS))
# a carver on its own, and the same maze through the generator
grid = py_maze.carve_prim(8, 8, random.Random(7))
generator = py_maze.MazeGenerator(width=8, height=8, seed=7, algorithm='prim')
grid = generator.generate()
print(len(py_maze.solve_maze(grid))) # 35 cells, the only way through
# open every dead end, so there is more than one way through
py_maze.braid_maze(grid, 1.0, generator.random)
print(len(py_maze.solve_maze(grid))) # 31 cells, the shortest of them
py_maze.carver(name) returns the function a name stands for and raises
ValueError for a name no algorithm answers to, which is the same check
MazeGenerator makes when it is built. braid_maze modifies the grid it is
given and hands it back, so it can be wrapped around generate() or called
on a maze read out of a file.
Collectibles and Save Files
place_collectibles picks the cells to scatter pickups over, and drawing
the places from the generator’s own random numbers keeps them wherever the
seed put them. write_save and read_save round-trip a maze and its
pickups through a file:
import py_maze
generator = py_maze.MazeGenerator(width=6, height=6, seed=2024)
grid = generator.generate()
# the same places --collectibles would pick for this seed
collectibles = py_maze.place_collectibles(grid, 4, generator.random)
py_maze.write_save('maze.txt', grid, collectibles, seed=generator.seed)
grid, collectibles, seed = py_maze.read_save('maze.txt')
read_save hands back the grid, the cells holding a collectible and the
seed the file records, the seed being None for a file that records none. A
file that is not a maze this build can read raises SaveFileError, a
subclass of ValueError, with a message naming what was wrong. The format
is specified in the save file format.
The Names
Every name below is re-exported from the package, so py_maze.solve_maze
and py_maze.solving.solve_maze are the same function. Each carries a
docstring, so help(py_maze) and help(py_maze.solve_maze) describe the
surface, and py_maze.__all__ lists it in full.
Reading a grid (py_maze.grid)
| Name | What it does |
|---|---|
walled_grid(width, height) |
Build the solid block of wall a maze is carved out of |
find_entrance(grid) |
The (x, y) of the entrance, in the top row |
find_exit(grid) |
The (x, y) of the exit, in the bottom row |
open_cells(grid) |
Yield every cell the player can stand on, in reading order |
open_neighbors(grid, x, y) |
Yield the open cells one step from (x, y) |
open_ends(grid) |
Cut the entrance and the exit into a carved maze |
has_ends(grid) |
Whether a maze is wide enough to have an entrance and an exit |
MOVES |
The four steps a player, and the solver, can make |
MIN_DIMENSION |
The smallest maze with an interior path, 2 cells |
MIN_GRID_WIDTH |
The narrowest a maze can be drawn and still have both ends, 3 characters |
Generating (py_maze.generation)
| Name | What it does |
|---|---|
MazeGenerator(width, height, seed, algorithm) |
Carve a maze; generate() returns the grid |
braid_maze(grid, share, rng) |
Open a share of the dead ends, for more than one way through |
place_collectibles(grid, count, rng) |
The set of cells to scatter pickups over |
maze_seed(value) |
Read a seed from text, as --seed and a save file both do |
MAX_SEED |
The bound a seed is drawn from when none is given |
Carving (py_maze.algorithms)
Every carver is the same call, carve(width, height, rng), returning a
carved grid with its entrance and exit already opened:
| Name | What it does |
|---|---|
carve_backtracker(width, height, rng) |
Recursive backtracking: one winding route, long dead ends |
carve_prim(width, height, rng) |
Randomized Prim’s: a more open maze, short dead ends |
carve_division(width, height, rng) |
Recursive division: straight corridors and rooms |
carver(name) |
The carving function a name stands for, or ValueError |
ALGORITHMS |
The name --algorithm takes, mapped to the function that carves it |
ALGORITHM_NOTES |
What each one carves, in the words the help text uses |
DEFAULT_ALGORITHM |
The algorithm a bare run carves with, backtracker |
Solving (py_maze.solving)
| Name | What it does |
|---|---|
solve_maze(grid, start, end) |
The shortest route as a list of cells, or None |
search_frames(grid, start, end) |
Yield (visited, frontier, path), one wave at a time |
solution_runs(path) |
The straight runs a solution is made of, as their lengths |
maze_progress(grid, cell, path) |
How far along the solution a cell stands, from 0 to 1, or None |
UNSEARCHED |
The path of a caller that has not looked for a route |
A solution is measured as the straight runs it is made of rather than as a count of cells, which is what lets a share of it be pointed at. A route of four runs of 4, 2, 5 and 3 totals 14 steps, so 55% of it is 7.7, which falls in the third run:
>>> grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
>>> path = py_maze.solve_maze(grid)
>>> sum(py_maze.solution_runs(path)) == len(path) - 1
True
>>> py_maze.maze_progress(grid, path[0]), py_maze.maze_progress(grid, path[-1])
(0.0, 1.0)
Every step belongs to exactly one run, so the lengths always sum to the steps
the whole route takes. A cell that is not on the solution has no share of it
and maze_progress answers None, which is also what it answers for a maze
with no way through.
How a path is read. The calls that search for one when they are not
handed one - maze_progress here, maze_stats under Measuring a maze
below and Chaser.chasing under Playing, and the Modes - read it the
same three ways, so a caller says what it is holding once and is believed
by all of them:
- A route: the caller searched and found the way through, and it is measured as it stands. Pass it when measuring cell after cell against the one maze and it is not solved again for each.
None: the caller searched and there is no way through. That is the answer the search came back with rather than the absence of a search, so it is taken at its word rather than run again.- Left out, or
UNSEARCHED: the caller has not looked, and the maze is solved here.
That third reading is why the default is not None: a run forwarding a route
it may or may not be holding passes UNSEARCHED and pays for one search,
while a run that already knows there is no way through pays for none. The
calls that only measure or draw a route handed to them - solution_runs,
solution_overlay and MazeGenerator.to_string - never search, so they read
None as nothing to work on and the sentinel is not theirs to take.
>>> grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
>>> path = py_maze.solve_maze(grid)
>>> py_maze.maze_progress(grid, path[-1], py_maze.UNSEARCHED)
1.0
>>> py_maze.maze_progress(grid, path[-1], None) is None
True
Measuring a maze (py_maze.analysis)
| Name | What it does |
|---|---|
maze_stats(grid, path, dead_ends_before) |
Every measurement below as one dictionary |
dead_ends(grid) |
Yield each cell inside the maze with one way in and no way on |
junctions(grid) |
Yield each cell where three or more ways meet |
longest_corridor(grid) |
Cells in the longest straight run, across a row or down a column |
solution_share(path, open_count) |
The share of the open cells a route stands on, from 0 to 1 |
difficulty_score(open_count, junctions_count, path) |
How hard the maze reads, out of 100 |
DIFFICULTY_PARTS |
The three shares that score adds up, by name |
JUNCTION_CEILING |
The junctions per open cell at which its decisions part is full |
Nothing here changes the maze: a measurement is taken from the grid as it
stands, so a carved maze and one read out of a file are measured the same
way. maze_stats is the whole of it in one call, and the keys are what
--stats prints:
>>> grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
>>> stats = py_maze.maze_stats(grid)
>>> stats['cells'], stats['open']
(169, 73)
>>> stats['dead_ends'] == sum(1 for _ in py_maze.dead_ends(grid))
True
>>> sorted(stats)
['cells', 'dead_ends', 'dead_ends_opened', 'difficulty', 'junctions', 'longest_corridor', 'open', 'solution', 'solution_share']
cells counts every position of the grid and open the ones the player can
stand on, so the second is a share of the first. solution is the steps the
shortest route takes, measured as solution_runs measures them, and None
for a maze with no way through.
solution_share is how much of the maze that route stands on, counted in
cells because the maze it is a share of is counted in cells. It is the one
measurement held as a fraction rather than a tally, and the printed line
rounds it to a whole percent:
>>> grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
>>> path = py_maze.solve_maze(grid)
>>> py_maze.solution_share(path, 73) == len(path) / 73
True
>>> round(py_maze.maze_stats(grid)['solution_share'], 3)
0.479
difficulty reads a score out of the measurements around it, and it is
published as what it is made of rather than as a number to trust.
DIFFICULTY_PARTS names the three shares it averages, each worth the same as
the other two, and Reading a maze sets out what each one is:
>>> py_maze.DIFFICULTY_PARTS
('wrong turns', 'decisions', 'corners')
>>> grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
>>> py_maze.difficulty_score(73, 3, py_maze.solve_maze(grid))
36
>>> py_maze.difficulty_score(73, 3, None) is None
True
dead_ends_opened is the one measurement a grid cannot be asked for. A
braided maze looks exactly like a maze that was carved that way, so only the
caller that braided knows what it opened, and it hands over the count it took
beforehand:
>>> import random
>>> grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
>>> before = sum(1 for _ in py_maze.dead_ends(grid))
>>> _ = py_maze.braid_maze(grid, 1.0, random.Random(1))
>>> py_maze.maze_stats(grid, dead_ends_before=before)['dead_ends_opened']
3
>>> py_maze.maze_stats(grid)['dead_ends_opened'] is None
True
The solution is the one measurement that has to be searched for, and path is
read here exactly as Solving above reads it - UNSEARCHED is re-exported
from py_maze.analysis for the callers that import it there - so a caller that
has already searched is believed by this reader too:
>>> grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
>>> py_maze.maze_stats(grid, py_maze.UNSEARCHED)['solution']
34
>>> py_maze.maze_stats(grid, None)['solution'] is None
True
dead_ends is the reader braid_maze opens a share of, so what a braid acts
on and what --stats counts are the one reading. Both it and junctions
yield in reading order, left to right and then down the rows.
Drawing (py_maze.rendering)
| Name | What it does |
|---|---|
maze_lines(grid, overlays) |
The maze as one string per row |
print_maze(grid, overlays, stream) |
The same, written between the start and end markers |
solution_overlay(path) |
The overlay that draws a solution over a maze |
collectible_overlay(collectibles) |
The overlay that draws pickups over a maze |
quest_overlay(doors, boxes, guide) |
The overlays that draw a quest’s shut doors, its boxes and its guide |
animate_search(grid, start, end, ...) |
Step the search across a terminal, frame by frame |
status_line(...), summary_lines(...) |
The tallies shown during play and at the end |
stats_lines(stats) |
What --stats prints under a maze, in the status line’s style |
format_duration(seconds) |
A length of time written the way a stopwatch would |
terminal_size() |
The screen the maze will be drawn in |
fit_to_terminal(...), fit_dimension(...) |
Cap a maze to the space there is for it, the rows the play screen’s foot occupies included |
line_fills_row(line, size) |
Whether a line ends flush with the last column, where clearing to the end of the row would erase it |
fit_frame(lines, size, focus, reserve) |
Cut a play screen to the rows the terminal has, around a row to keep and any kept back |
cut_marker(marker, rows, where) |
An edge marker that names the maze rows the window stops short of |
wrap_on_spaces(text, size) |
Pad a line so the console breaks it between two words, taking a run of spaces over a single one where the row holds both |
wipe_rows(first, last, size) |
Clear a run of rows a shorter frame gave up, stopping at the bottom of the screen |
frame_text(lines, home, stream, size) |
A whole frame as the one string that draws it |
frame_diff(previous, current, whole, size) |
The same for the lines that changed, or every row when whole |
frame_wraps(lines, size) |
Whether a frame runs past the terminal’s last column |
frame_height(lines, size) |
The screen rows a frame occupies, counting a wrapped line as the rows it is carried onto |
can_display(text, stream) |
Whether a destination draws the text as written, the encoding and the screen buffer cell both asked |
clear_screen, ansi_enabled, can_encode |
The rest of the escape sequence machinery behind a redraw |
An overlay is a (marker, cells) pair, and maze_lines takes a sequence of
them running from the most important marker to the least: the first pair
holding a cell decides what is drawn there. The markers themselves are named
constants, so a caller need not repeat the characters: WALL_MARKER,
OPEN_MARKER, PLAYER_MARKER, SOLUTION_MARKER, VISITED_MARKER,
FRONTIER_MARKER, HINT_MARKER, COLLECTIBLE_MARKER, CHASER_MARKER,
DOOR_MARKER, BOX_MARKER and GUIDE_MARKER.
The two labels around the maze are not overlays. start and end name the
ends of the maze, and fit_frame draws a window onto the maze whenever the
console cannot hold all of it, so an edge the window stops short of is named
with the rows past it instead: cut_marker('end', 3, CUT_BELOW) reads
end 3 rows below. CUT_FORMAT is the form it is written in, and
CUT_ABOVE and CUT_BELOW are the two sides.
Save files (py_maze.saves)
| Name | What it does |
|---|---|
write_save(path, grid, collectibles, seed, solution, form, stream, stats, quest) |
Write a maze to a file, in either form |
read_save(path, chars, stream) |
Read one back as (grid, collectibles, seed) |
save_lines(grid, collectibles, seed) |
The file’s lines, without writing them |
save_json(grid, collectibles, seed, solution, stats, quest) |
The document it would write under form='json' |
SavedMaze(grid, collectibles, seed, quest) |
What a reader hands back: that triple, with a document’s quest beside it |
parse_save(text, source, chars) |
The same read, from text already in hand |
parse_json_save(text, source) |
The same again for a document, which parse_save calls for one |
picture_chars(wall, open_cell) |
The chars map a picture with no header is read with |
SaveFileError |
Raised for a file this build cannot read |
SAVE_FORMAT, SAVE_HEADER, SAVE_CHARS |
The format number, its header line and the characters it allows |
FORMATS, TEXT_FORMAT, JSON_FORMAT, DEFAULT_FORMAT |
The two forms a maze is written in, and the one written unasked |
JSON_FORMAT_KEY |
The key a document carries the format number under |
JSON_QUEST_KEY |
The key a document carries a quest’s doors and boxes under |
STDIO_PATH |
The file name that means standard input or standard output, - |
STDIN_NAME |
What a message calls that stream, <stdin>, there being no file name |
write_save and read_save take the file name - for standard output and
standard input, or a stream of your own in its place, which is what makes
them testable without a pipe. parse_save reads a document, a picture under
its header or a picture with no header at all, and hands back the same three
things whichever it was:
import py_maze
grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
# the same maze as a document, with a route through it recorded
document = py_maze.save_json(grid, seed=2024,
solution=py_maze.solve_maze(grid))
grid, collectibles, seed = py_maze.parse_save(document)
# and a picture somebody else drew, on the terms it was drawn on
grid, _, _ = py_maze.parse_save("#.#\n#.#\n",
chars=py_maze.picture_chars('#', '.'))
What comes back is a SavedMaze: that same triple, with the quest a
document carried on its quest attribute. A picture can draw a door and
cannot say which item opens it, so only a document ever writes one and only
a document ever reads one back:
>>> import random
>>> import py_maze
>>> grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
>>> quest = py_maze.place_quest(grid, 2, random.Random(2024))
>>> py_maze.parse_save(py_maze.save_json(grid, quest=quest)).quest.doors
{(1, 7): 1, (5, 11): 2}
>>> py_maze.parse_save('\n'.join(py_maze.save_lines(grid))).quest is None
True
Playing, and the Modes
A mode is the game that is already there played differently: the same grid,
the same solver, the same renderer and the same key loop. MODES maps the
name --mode takes to the function that builds the game, exactly as
ALGORITHMS maps a carving name to its carver, so calling one is the whole
of setting a mode up:
| Name | What it does |
|---|---|
MODES |
The name --mode takes, mapped to the function that builds that game |
MODE_NOTES |
What each one plays like, in the words the help text uses |
MODE_OPTIONS |
The options belonging to each mode, for a mode that has any |
MODE_CONTENT |
What each mode lays into the maze itself, for the modes that lay anything |
MODE_CONTROLS |
The controls line each mode prints, for the modes that print another one |
MODE_ITEMS |
Where each mode reads its item count from, for the modes that fill an inventory |
DEFAULT_MODE, PLAIN_MODE, CHASE_MODE, QUEST_MODE |
The mode a bare run plays, and the names of the three there are |
game_mode(name) |
The building function a name stands for, or ValueError |
mode_content(name, grid, ...) |
Lay whatever that mode puts in the maze, and None for a mode that puts nothing |
mode_controls(name) |
The controls line that mode prints, and CONTROLS_LINE for a mode adding no key of its own |
mode_status(name, collectibles, ...) |
The running tally that mode’s first frame will draw, which is what a maze is carved against |
plain_game(grid, collectibles, ...) |
The walk from the entrance to the exit |
chase_game(grid, collectibles, chase_point, chase_speed) |
The same walk, with a chaser behind it |
quest_game(grid, collectibles, content, quest_stages) |
The same walk, with the way on shut until what opens it is found |
quest_content(grid, rng, collectibles, quest_stages) |
The chain quest mode lays into a maze before it is played |
Every mode is handed every mode setting and reads the ones that belong to it, which is why a setting for a mode you are not playing is ignored rather than refused.
The chaser itself is a cell and a clock. Where it steps comes from the same breadth-first search everything else uses, solved from the chaser to the player, so it never walks into a wall and needs no second algorithm:
| Name | What it does |
|---|---|
Chaser(cell, point, speed) |
An antagonist waiting on a cell until the chase begins |
Chaser.chasing(now, grid, cell, path) |
Start it if the player has walked far enough in, and say whether it is running |
Chaser.advance(now, grid, target) |
Take every step the clock says it is owed, and report how many of them moved it |
Chaser.catches(cell) |
Whether it is standing where the player is |
CHASE_SPEEDS |
Each preset speed, as the moves it makes in a second |
MIN_CHASE_POINT, MAX_CHASE_POINT, DEFAULT_CHASE_POINT |
The range a chase point is held inside, and the reasonable point |
MIN_CHASE_SPEED, MAX_CHASE_SPEED, DEFAULT_CHASE_SPEED |
The presets --chase-speed takes, and the reasonable speed |
MAX_CHASE_CATCH_UP |
The most moves one advance reaches for after a stall |
chase_setting(number, low, high) |
Round a chase option’s number and hold it inside its range, whatever number it is given |
import py_maze
grid = py_maze.MazeGenerator(width=9, height=11, seed=2024).generate()
game = py_maze.chase_game(grid, chase_point=70, chase_speed=4)
game.play()
A chase setting is read the same way wherever it comes from. chase_setting
rounds to the nearest whole number and holds the result inside the range, and
Chaser reads both its point and its speed with it, so a front end
reading its numbers out of a file is handed the int the table promises
rather than an exception: a value past either end resolves to that end, an
infinity to the end it runs past, and a nan, which names no place on the
range in either direction, to the bottom of it. --chase-point and
--chase-speed never reach that far, naming a value they cannot read in a
notice and carrying on without it.
The range is the chaser’s rather than the option’s, which is what makes
chase_game(grid, chase_point=0) a game that begins at MIN_CHASE_POINT
instead of one already caught: the chaser waits on the entrance the player
starts on, so a point of nought would have the chase begun and won before the
first keypress.
Chaser.advance reports the steps that moved the chaser rather than the
moves the clock owed it. The two differ when there is nowhere to step - a
chaser standing on the player it has caught - and MAX_CHASE_CATCH_UP bounds
the moves reached for, so a stall over a chaser that cannot move is not a
walk through everything the stall passed over.
Some of a mode is in the maze rather than in the game. Quest mode’s doors
and boxes are laid into the grid before a word is drawn, written into a JSON
document beside the collectibles and read back out of one, so MODE_CONTENT
is where a mode says what it puts there and mode_content is what lays it.
mode_controls and mode_status answer the other two questions asked
before there is a game: which controls line the mode will print, and what
its first tally will say. Those are the two lines of the play screen’s foot
that can be longer than the console is wide, so how many rows they occupy
decides how tall a maze the console can show whole, and fit_to_terminal
takes them as its controls and status arguments. A game answers both
questions for itself with MazeGame.controls and MazeGame.status.
The foot is the part of the frame the console’s width is measured against
nowhere, which is why its lines are the ones that wrap. fit_dimension
caps a maze row to the terminal’s columns, so an 80 column console draws
maze rows 79 characters wide against a 60 character controls line - the
controls line is the longest line of the foot, not of the screen. The
two lines the foot has left are the end marker and a blank spacer, and
neither is wide enough to reach the edge of a console with room for a maze
in it.
place_quest is the call behind quest mode’s entry, and Quest is the
inventory the player fills:
| Name | What it does |
|---|---|
place_quest(grid, stages, rng, path, taken) |
Lay a chain of doors and boxes into a maze, or None for one with no way through |
Quest(boxes, doors, guide) |
The doors, the boxes that open them and what is being carried |
Quest.unlock(cell) |
Whether the cell may be stepped onto, opening a door whose item is carried |
Quest.open_box(cell) |
The item in the box on a cell, which is then in the inventory |
Quest.closed_doors() |
The doors still shut, in reading order |
Quest.drawn() |
The (doors, boxes, guide) triple quest_overlay takes |
Quest.stage(), Quest.finished() |
The errand in front of the player, and whether there is one left |
Quest.target(), Quest.task() |
Where that errand is, and what it is in words |
reachable(grid, start, blocked) |
Flood fill a maze with some of its cells shut, which is what a door needs |
door_cells(path, stages) |
Space a quest’s doors evenly along the route through a maze |
MIN_QUEST_STAGES, MAX_QUEST_STAGES, DEFAULT_QUEST_STAGES |
The range --quest-stages takes, and the reasonable chain |
Each box is hidden in the part of the maze that is reachable at the time it
is wanted - box one before door one, box two between door one and door two -
so a quest laid by place_quest can always be finished, and never before
the stage it belongs to. The chain only holds for a maze with one route
through it: braid_maze opens a second way round, and a door with a way
round it gates nothing.
>>> import random
>>> import py_maze
>>> grid = py_maze.MazeGenerator(width=6, height=6, seed=2024).generate()
>>> quest = py_maze.place_quest(grid, 3, random.Random(2024))
>>> quest.stages
3
>>> quest.stage()
1
>>> sorted(quest.doors.values())
[1, 2, 3]
>>> quest.unlock(quest.closed_doors()[0])
False
>>> quest.open_box(quest.target())
1
>>> sorted(quest.carried)
[1]
The terminal half is public too: MazeGame plays a maze at the console,
read_key and read_response take single keypresses, read_key_timed waits
a given moment for one and answers None when the moment passes empty, or
waits however long it takes when the moment it is given is None itself, and
build_parser, build_maze, build_game and main are the command line
itself, along
with the EXIT_OK, EXIT_USAGE, EXIT_SAVE_FILE, EXIT_FILE_ERROR and
EXIT_NO_WAY_THROUGH codes it exits with. build_maze hands back
(grid, collectibles, seed, dead_ends_before, content, chain_seed). The
fourth is what maze_stats needs to report a braid, and None where
nothing was braided, which is every loaded maze and every run that asked
for no braid. The fifth is whatever the mode laid into the maze - a Quest
for quest mode and None for every other. The last is the seed that
content was laid from.
The two seeds are one number for a generated maze, which lays its content
from the generator the seed built. They part over a loaded maze: the grid is
in the file and only the chain over it can still be seeded, so a run naming
--seed plays a chain the file’s own seed does not reproduce. chain_seed
is None where nothing was laid from a seed at all - a mode that puts
nothing in a maze, a document played back with the chain it was saved with,
and a picture played with no seed on either side, whose chain is fresh
randomness. content_seed(given, recorded) is the rule it lays that content
from: the seed the run named, else the seed the save file records, else
None for the fresh randomness a seedless picture gets. Those are the names
that want a terminal. Everything above runs without one.