Expand description
A generic basis for grids: square, hex, and whatever else your game needs.
Board games and tactics games have the same skeleton — a set of cells, a notion of which cells
touch which, a distance, and a way to find a route. Only the shape changes. spacewalk is
that skeleton, with the shape left open: chess and draughts on squares, a clone-and-jump
capture game on hexes, a tactical battle on either, a three-layer chess board on something you
define yourself.
§The grid holds no game state
A grid is cells, their indices, and which cell each direction leads to. It knows nothing of terrain, pieces, or players — you keep those, in whatever shape suits you, and hand the grid a closure when you want a path:
use spacewalk::{Adjacency, FullGrid, Grid, Movement, Sq};
let g = FullGrid::square(8, 8, Adjacency::Four);
let mud: Vec<Sq> = vec![Sq::new(3, 3), Sq::new(3, 4)]; // your data, your types
let walk = Movement::scan(&g, |s| {
Some(if mud.contains(&g.coord(s.to)) { 30 } else { 10 })
});
let from = g.at(Sq::new(0, 0));
let to = g.at(Sq::new(7, 7));
assert_eq!(g.path(from, to, &walk).unwrap().len(), 14);That keeps the grid immutable and shareable, and keeps it out of your borrow checker’s way:
&grid and &mut your_state are different objects, so an AI search can read the board while
it mutates the position, and cloning a position never copies the board.
When “whatever shape suits you” is simply one value per cell, CellMap is that Vec, sized
from the board and subscripted by an Idx rather than by a hand-written cast. It holds no
grid, so the two objects above stay two objects.
Indices go in; coordinates come back out with Grid::coords_of, which is what you draw and
what you save. Grid::at is the way in for a cell you already know is on the board, and
Grid::index_of the way in when being off it is an answer rather than a mistake.
§Cost is what it costs to enter a cell, from a direction
Not the cost of the cell. That difference is what lets a grid hold a river: entering it
downstream is cheap, upstream is dear. A conveyor belt, and a ledge you can drop off but not
climb back up, are the same shape. See path — and mind that it makes the graph directed,
so a Path cannot simply be reversed.
§Four shapes ship; the rest is yours
FullGrid::square, FullGrid::disc, FullGrid::hexagon and FullGrid::hex_rect are
the common cases — a disc is a round board of square cells centred on the origin, and a hex
rectangle is the shape a tilemap editor authors. Beyond them, FullGrid::new takes any set of
cells at all — a checkers board is the dark squares of a square grid (FullGrid::filtered),
and a game with a genuinely different geometry implements Coord. That is a few dozen lines;
tests/chess3d.rs builds a three-layer chess board without touching this crate.
§A region of a board is a board
Grid is the vocabulary — every question above, asked of anything that is a board. Two
things answer it. FullGrid is a board you built. SubGrid is part of one, and it is a
board in its own right: it has its own edges, its own components, its own paths.
That is what a highlighted range is. Ask for one, and you get the thing you draw and the thing you then reason over:
use spacewalk::{Adjacency, FullGrid, Grid, Sq};
let g = FullGrid::square(16, 16, Adjacency::Eight);
let eye = g.at(Sq::new(8, 8));
let wall = |i| g.coord(i).x == 10;
let seen = g.visible_from(eye, 5, wall); // a board of what this unit can see
assert!(seen.contains(Sq::new(9, 8)));
assert!(!seen.contains(Sq::new(12, 8)), "the wall is in the way");
// And it is a board: a route inside a field of view cannot leave it.
for i in seen.indices() {
assert!(g.coord(seen.to_root(i)).x <= 10);
}Grid::within, Grid::ring, Grid::component and Grid::visible_from all hand one
back; Grid::subset makes one from any cells you name. A SubGrid borrows rather than copies,
so asking for one costs a sort, not a board.
§Height is game state, like everything else
A cell’s ground level is not geometry, so the grid does not hold it. It goes in a CellMap
beside your terrain, and two gates in height read it: height_gate
for what a hill hides, climb_gate for what a ledge refuses.
use spacewalk::height::height_gate;
use spacewalk::{Adjacency, CellMap, FullGrid, Grid, Sq};
let g = FullGrid::square(9, 3, Adjacency::Eight);
let mut ground = CellMap::new(&g, 0i32);
ground[g.at(Sq::new(4, 1))] = 4; // a ridge across the middle
let eye = g.at(Sq::new(0, 1));
let sight = height_gate(&g, |i| ground[i], |i| ground[i] + 2);
let seen = g.visible_from_by(eye, 8, &sight);
assert!(seen.contains(Sq::new(4, 1)), "the ridge is in plain view");
assert!(!seen.contains(Sq::new(8, 1)), "and the dead ground behind it is not");Sight needed one thing a plain blocker could not give it. A hill hides what is lower than the
line passing over it, so whether a cell blocks depends on the target as much as on the cell.
Grid::los_by and Grid::visible_from_by hand the predicate a Sight — the whole
question, the way a cost function is handed a whole Step. Grid::los and
Grid::visible_from remain as compatibility wrappers that throw the target away.
Movement needed nothing new. A climb is priced by the cell entered and the direction of arrival,
which is the river and the one-way ledge above; climb_gate only adds the
limit past which a step is refused outright.
§Routes without a grid
Some maps have no geometry: rooms and the doors between them, towns and their roads. Graph
runs the same searches over nodes that you name with a type of your own, from a plain list of
edges. It needs no Coord and no board. See graph.
§Drawing it, and clicking on it
The lattice does not know what a pixel is, and does not need to — until you want to show it.
layout is that, and only that: where a cell lands on screen, and which cell the mouse is
over. It is the one part of the crate that speaks f32, and it is a one-way street — no float
ever reaches a Cost or a Metric, so pathfinding stays integer and stays reproducible.
use spacewalk::layout::{HexLayout, Pt};
use spacewalk::{FullGrid, Grid};
let g = FullGrid::hexagon(4);
let layout = HexLayout::pointy(Pt::new(32.0, 32.0)).at(Pt::new(400.0, 300.0));
// Which cell did they click? Off the board is `None`, with no special case.
let hovered = g.index_of(layout.hex_at(Pt::new(430.0, 310.0)));
assert!(hovered.is_some());Offset converts to the (col, row) a tilemap editor stores, which is what you need the
moment you load a map someone else authored; FullGrid::hex_rect builds the board that file
describes, in the same convention.
§Two things to know before you build one
Indices are not addresses. Idx is a dense index, valid only within the board that issued
it. Two boards with the same cells may number them differently; FullGrid::filtered renumbers,
and a SubGrid numbers its own cells from zero. Serialize coordinates, never indices.
A subset makes this sharper than a filter does, because both numberings stay live: the
region’s index 0 and the board’s index 0 are each valid and each a different cell. No bounds
check can separate them — every index is in range for one of them. Grid::to_root and
Grid::of_root are the bridge, and the only correct one. See SubGrid for the two ways it
bites.
So an index carries the board that issued it, and a debug build checks it:
use spacewalk::{Adjacency, FullGrid, Grid, Sq};
let board = FullGrid::square(8, 8, Adjacency::Four);
let dark = board.filtered(|c| (c.x + c.y) % 2 == 0);
let light = board.filtered(|c| (c.x + c.y) % 2 == 1);
assert_eq!(dark.len(), light.len(), "so no bound can tell them apart");
let cell = dark.at(Sq::new(2, 2));
assert_eq!(dark.coord(cell), Sq::new(2, 2));
// `light.coord(cell)` is index 9 either way — a real cell of both boards, and a different one.
// In a debug build it panics: "issued by a different grid". Shipped, it quietly answers
// Sq::new(3, 2), which is why the rule below is still the rule.That is the index’s tag, and in release it is zero-sized: the checks vanish and an Idx is a bare
u32 again. Equality, ordering, and hashing compare the number alone in both profiles, so
nothing you observe changes with the build — only whether the mistake is reported. Treat it as
it is meant: a development aid, not a runtime guarantee. The rule is still serialize
coordinates.
Two boards that number the same cells the same way share a tag and are interchangeable, which
is what makes rebuilding a board from Grid::cells restore the same indices rather than
merely an equivalent board.
The metric must agree with the adjacency. An eight-way board measured with Manhattan
distance is the classic tactics bug: a unit can step to the enemy diagonally beside it, but
measures it as two cells away and so cannot attack it. Adjacency picks both together, so
there is no second knob to get wrong; a hand-built grid must uphold it (see FullGrid::new).
§Feature flags
serde(off by default):Serialize/Deserializeon every plain-data type —Sq,Hex,Dir8,Dir6,Adjacency,Offset, thelayouttypes, andCellMap<T>wheneverTis.FullGridandMetricare deliberately not serializable: a grid is rebuilt from its cells, and a metric holds function pointers.tests/save.rsshows what to persist instead — coordinates, never indices, and aCellMaponly alongside the cells that fix its order.
§It does not need std
The crate is #![no_std], unconditionally, and builds for a bare-metal target. Nothing in it
wants an operating system — no files, no threads, no clock — so alloc is the whole of what it
asks for, and that is present wherever std is. There is no feature to turn on and nothing an
ordinary user does differently.
Two things follow that are worth knowing. core has no f64::round, floor, cos, or sin,
so layout carries its own — the hexagon corner angles were always twelve fixed numbers, and
the rounding is integer arithmetic once a value is known to be finite. Each one is checked
against std’s over a few million values, because hand-written floating point deserves the
suspicion.
And the dependency list is one crate: hashbrown, which is the table std’s own HashMap is
built from. The searches behind Grid::path and Grid::reachable are the crate’s own — a
general graph library must key its bookkeeping on whatever a node happens to be, and a board’s
cells are already numbered 0..len, which turns that map into two vectors read by subscript.
§Where to start
Import Grid first. It is a trait, so it must be in scope before any method of a board can be
called, and that is the one import nobody guesses. Grid is also where the vocabulary lives,
and its own documentation says which questions hand back an iterator, which a board, and which a
Vec.
Re-exports§
pub use cells::CellMap;pub use coord::Coord;pub use coord::Dir6;pub use coord::Dir8;pub use coord::Hex;pub use coord::Idx;pub use coord::Metric;pub use coord::Sq;pub use full::Adjacency;pub use full::FullGrid;pub use full::GridError;pub use full::MAX_CELLS;pub use graph::Graph;pub use graph::GraphError;pub use grid::Dir;pub use grid::Grid;pub use grid::MAX_SIGHT;pub use grid::Sight;pub use layout::Offset;pub use path::Cost;pub use path::Movement;pub use path::MovementError;pub use path::Path;pub use path::Step;pub use sub::SubGrid;
Modules§
- cells
- One value per cell, indexed by
Idxand nothing else. - coord
- Cell addresses, and the directions you can leave one by.
- full
- A whole board, stored: a set of cells, and a dense index over them.
- graph
- Routes through nodes that you name yourself, with no grid.
- grid
- What every board can answer, whatever it is made of.
- height
- Elevation: what a hill hides, and what a ledge refuses.
- layout
- Where a cell sits on screen, and which cell the mouse is over.
- path
- Paths and reach, over a cost function you supply.
- square
- Corner-cutting: whether a diagonal may squeeze past an obstacle.
- sub
- A region of a board, which is a board.