jengine-crawler
A first-person grid dungeon crawler: stepwise movement, a view assembled from depth slices, doors and levers, traps, and fights fought from the front rank.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-crawler |
| Version | 0.1.0 |
| Entry point | jengine_crawler::CrawlerPackage |
| Script namespace | Crawler |
| Listed by | projects/arcade-classics, projects/rpg-classics |
Using it
Section titled “Using it”List it in the top-level "packages" array of project.jengine:
{ "packages": [ { "name": "jengine-crawler", "source": { "type": "path", "path": "../../packages/jengine-crawler" } } ]}Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the
editor’s Rebuild viewport banner). Native and web builds link it on their own.
A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field
for a package component and keeps it as inert custom data.
A script can check that the package is linked with if Crawler == nil then return end.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
crawl_game | CrawlGame | package only: needs a v2 scene |
Script commands
Section titled “Script commands”Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.
| Call | Flat name |
|---|---|
Crawler.new_game(e, seed) | crawl_new_game |
Crawler.walk(e, dx, dy) | crawl_walk |
Crawler.turn(e, steps) | crawl_turn |
Crawler.use(e) | crawl_use |
Crawler.attack(e) | crawl_attack |
Crawler.wait(e) | crawl_wait |
Crawler.rest(e) | crawl_rest |
Crawler.resolve(e, won) | crawl_resolve |
Crawler.reveal(e, radius) | crawl_reveal |
Crawler.load(e, json) | crawl_load |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/warren.sceneprojects/rpg-classics/assets/scenes/crawl.scene
From packages/jengine-crawler/README.md.
A first-person grid dungeon crawler: stepwise movement, doors and levers, things in the dark, and a view built out of depth slices.
Used by projects/arcade-classics (Umber Warren), packages/jengine-goldbox
(Gold Box), and projects/rpg-classics (Seven Stairs, a Wizardry: a party the
game keeps itself, fought in menus, through three floors of stairs both ways).
| Module | What it is |
|---|---|
view | The picture, as a list of quads. Pure geometry, no engine. |
map | Levels as ASCII drawings in a data table, and what each cell does. |
crawl | The crawl: movement, doors, chests, pits, stairs, and whatever lives down there. |
A 2D engine cannot rasterise a corridor
Section titled “A 2D engine cannot rasterise a corridor”So it does what Eye of the Beholder and Dungeon Master did: the view is a fixed set of depth slices. Every cell the party can see occupies a known rectangle on screen, decided by how far ahead it is and how far to the side, and the picture is those rectangles drawn far to near.
let quads = view::build(&dungeon, x, y, facing, &frame);Each quad carries its face (front wall, left wall, right wall, floor,
ceiling, or a thing in the cell), its rect, a draw layer within its
depth, and a shade that falls off with distance — which is the whole of the
lighting and most of what makes a corridor read as deep. A game places a
sprite per quad and is done.
The geometry follows one convention, and everything else falls out of it:
- Planes, not cells. Plane p is the cell boundary p + ½ cells
ahead; cell d runs from plane d − 1 to plane d, and the party’s own
cell from plane −1 (the edge of the picture) to plane 0. The frame’s
cell_w×cell_his plane 0 — the wall directly in front — and each plane back isfalloffsmaller. So the wall closing off a cell is drawn on the plane where that cell’s floor stops, and the two meet. - Every corner is projected on its own plane. A boundary b cells to
the side sits at
b * width(near)on the near plane andb * width(far)on the far one. That difference is what makes an off-centre wall lean toward the middle of the screen; computed from one plane, the outermost wall of a room leaned the wrong way. - Every quad carries its corners, a perspective weight
qper corner (1 on the near plane,far / nearon the far one) and a shade per corner, and the package draws them asMesh2Dentities from a pool it owns (draw): the texture is mapped to the trapezoid in perspective by the renderer, and nothing crosses the script bridge. Thex, y, w, hbox is kept beside the corners for anything that still places sprites. Until the engine could draw a textured polygon the box was all there was, and the side-wall art carried the wedge in its transparent corners - which tied the art’s taper tofalloffand is gone. - Faces belong to the open cell that sees them. Every cell in view emits its floor and ceiling, a side wall where a solid cell stands on the side that faces the party, the wall ahead if the next cell is solid, and whatever stands in it. A solid cell emits nothing of its own, which is what keeps a wall beside the party from being drawn face-on and filling the screen with brick — and it is why a wall across a room is drawn all the way across, rather than only in the middle.
- What is in view is what a ray can reach: forward and outward through open cells, never back toward the centre. A pillar hides the cells behind it and not the ones beside those; and a cell reached that is wholly hidden is painted over by the nearer face, because every solid cell beside a cell in view has its face drawn.
- Within a depth, the order is the layer: floor and ceiling, side walls, the wall ahead, things, monsters. Depth alone is not an order — a chest and the floor it stands on share one.
A chest stands on the floor half-way through its cell, as wide as it is
tall; a pit and the stairs lie in the floor and take its shape. A door
that has been opened keeps its frame (arch), drawn on the plane the door
stood on and over whatever is in the doorway. The
party’s own cell flares out past plane 0, so a game crops the picture to
plane −1 (cell / falloff) with a frame, the way the genre’s window did.
The picture is drawn by the package: crawl_game.sheet names the art per
face and per thing (the defaults are Umber Warren’s regions), view_origin
is where the corridor’s middle is on screen, and each quad is a Mesh2D
at z_base + its index, unlit (the shade is the lighting) and marked
BulkEntity. "draw": false turns that off for a game that reads
CrawlView.quads and places its own sprites.
Because none of this touches the engine, the part that is impossible to
check by eye — and very easy to get subtly wrong — is a unit test rather
than a screenshot. cargo run -p jengine-crawler --example picture prints
the quads for a diagnostic dungeon, and
projects/arcade-classics/tools/render_crawler_picture.py draws them, in
debug colours and through the arcade’s sheet.
Time only moves when the party does
Section titled “Time only moves when the party does”A step, a turn, a swing or a wait is one tick, and the dungeon takes its turn after each one. A player can stand in a doorway and think for an hour, and the thing two cells away is still two cells away when they stop. That is the difference between this and a real-time crawler, and it is why a fight down here can be tested at all.
Monsters wake when the party comes within their sight and in their line
of sight - Dungeon::line_of_sight, which is gameplay sight and not the
picture’s looser rule - and having woken they do not forget — which is what makes walking away a decision rather
than a reset.
The formation is the tactics
Section titled “The formation is the tactics”The front two fight; the back two need the reach for it. A spear in the third slot reaches past the front rank, a short sword does not, and that is the whole of the party’s shape:
{ "name": "Vess", "reach": 3 }Levels are ASCII drawings in a data table
Section titled “Levels are ASCII drawings in a data table”######### . floor # wall + a shut door#..C..M.# = locked A archway L a lever#.#####.# > stairs C a chest O a pit#.#...#.# M where a monster starts#<..^...# < stairs up ^ a trap (floor until it goes off)The tables are checked when they load: a level with no way down, a party that starts inside a wall and a monster nobody defined all fail loudly at startup instead of fifty steps in - and so do stairs up behind a key, a fight on a cell nobody can reach, and random fights with no groups to draw from.
Floors that remember, and stairs both ways
Section titled “Floors that remember, and stairs both ways”> goes down and comes out of the next floor’s < (its start if it has
none); < goes back up and comes out of that floor’s >, facing the way on
rather than the wall behind the stair. From the top floor < leads out: the
package says exit and leaves the party on the stair, and the game decides
what is outside (a town). A floor the party comes back to is the floor it
left - chests emptied, doors open, traps sprung, fights won - and the automap
remembers it, in the save too (Save::seen, a string per floor).
Fights the game fights
Section titled “Fights the game fights”A game with its own combat (menus, a party of six) does not want the package’s monsters swinging at it. It gets encounters instead, from the level’s row:
"encounters": [{ "at": [6, 11], "group": "chief", "text": "A KOBOLD CHIEF guards the stair!", "art": "m_chief" }],"encounter_rate": 0.11, "groups": ["rats", "kobolds"], "encounter_grace": 4A fixed encounter waits on its cell (and stands in the picture, drawn
with its art, so it can be seen down the corridor); stepping on it starts
it. A random one comes with encounter_rate per step onto open floor,
after encounter_grace quiet steps, its group drawn from groups - names
the game defines. Either way the package says encounter, publishes it as
CrawlState.encounter, and nothing moves until the game settles it with
Crawler.resolve(e, won): won, a fixed one is gone for good; run from, the
party is put back on the cell it stepped from. A fixed encounter with
"goal": true on the last floor is that floor’s way out - it needs no
stairs down.
^ is a trap: floor to the picture, the automap and ahead, until the
party steps on it; then it goes off once, the save remembers, and the
package says trap with what the level’s traps list calls it (cycled in
the order the traps appear). What it does is the game’s.
A chest’s loot can be anything: gold:, key: and heal: are the
package’s, and any other kind:value (chest:2, item:healing potion) is
announced (loot with what and value) for the game to hand out.
A game that keeps its own party leaves tables.crawlers empty: the crawl
then has nobody to hurt and is never lost by the package. tables.monsters
may be empty too.
The component
Section titled “The component”crawl_game on one entity, in a "version": 2 scene. The frame decides
how the view is laid out, so a game can widen it without the package
knowing anything about the screen.
"crawl_game": { "tables": { "levels": "data/crawl_levels", "monsters": "data/crawl_monsters", "crawlers": "data/crawl_party" }, "frame": { "cell_w": 470.0, "cell_h": 400.0, "falloff": 0.56, "gloom": 0.8 }, "seed": 11}"publish_view": false stops CrawlView.quads going out every frame - a
game the package draws for does not need the picture as JSON, and it is the
largest thing a script would otherwise be handed.
Script commands
Section titled “Script commands”| Call | Does |
|---|---|
Crawler.new_game(e, seed) | back to the top of the dungeon (a new seed if given) |
Crawler.walk(e, dx, dy) | a step: dy forward and back, dx sideways |
Crawler.turn(e, steps) | turn right by that many quarters |
Crawler.use(e) | use what is in front, or the stairs underfoot |
Crawler.attack(e) | the party swings at what is in front |
Crawler.wait(e) | stand still and let the dungeon come |
Crawler.rest(e) | heal a little, at the cost of four ticks |
Crawler.load(e, json) | restore a save |
Crawler.resolve(e, won) | settle the encounter in progress (see above) |
Crawler.reveal(e, radius) | show the party the floor within radius cells (0: all of it) |
What scripts read
Section titled “What scripts read”CrawlView.quadsevery frame: the whole picture, back to front, with the monsters folded in so one ordered list draws correctly.CrawlState: where the party is, its health, gold and keys, what is in front of it, whether it is standing on the stairs (on_stairs,on_stairs_up), the encounter in progress (encounter), the number oflevels— so a game can label its own buttons rather than working it out.CrawlMapwhen something new is seen: the level with everything unseen blanked out, for an automap; a fixed encounter not yet won shows asE, and an unsprung trap as floor.CrawlEvents.events:level,door,unlocked,locked,lever,loot,empty,pit,hit,slain,bitten,wait,rested,dead,escaped,exit,encounter,resolved,trap,revealed.