557 lines
28 KiB
Rust
557 lines
28 KiB
Rust
//! A* over the current map's walkable tiles, and what counts as a way out of the map.
|
|
//!
|
|
//! `docs/design/macros.md` section 4: "overworld movement uses A* over the current map's walkable
|
|
//! tiles (collision from the tileset's collision table and the map blocks, warps and connections
|
|
//! from the tables the adapter already reads) with one step per tile and a per-step check that the
|
|
//! player moved; three failed steps abort with `MacroAbort::Blocked`." This module is the search
|
|
//! half of that sentence; the step timing and the moved check are in [`super::executor`], because
|
|
//! they are about frames rather than tiles.
|
|
//!
|
|
//! Two things shape the search.
|
|
//!
|
|
//! **It is multi-goal.** `GO OUT` wants the *nearest* of several doors, so the heuristic is the
|
|
//! minimum Manhattan distance to any goal. That stays admissible because a step costs one and
|
|
//! moves one tile.
|
|
//!
|
|
//! **It plans over the whole map when the map can be decoded** (`docs/design/macros.md` section
|
|
//! 15, the operator 2026-09-22: "the frontier and warp macros need to be map aware: A* over
|
|
//! walkable tiles"). [`MacroState::map_grid`] is every tile of the loaded map, walkability and
|
|
//! directed walls, decoded from the block and collision tables the cartridge has loaded
|
|
//! ([`crate::pokemon_red::mapgrid`]). With it, one plan crosses a town: `GO WARP` routes to its
|
|
//! warp tile, `GO OUT` and `GO ROUTE` to their door or connection tile, and the frontier is the
|
|
//! nearest unstood ground *anywhere on the map* rather than the nearest on screen.
|
|
//!
|
|
//! **Without it, the window is still the fallback.** Agent A's [`Walkable::Unknown`] is
|
|
//! load-bearing on a frame the grid cannot be decoded — no cartridge behind the seam, a battle or
|
|
//! a text box over the map, a header that is not loaded
|
|
//! ([`crate::pokemon_red::state::GridRefusal`] says which): the tile ids a walkability test needs
|
|
//! live in the screen buffer then, so only the tiles around the player can be answered at all. An
|
|
//! unknown tile is *expensive* to path through rather than forbidden ([`UNKNOWN_STEP`]): a
|
|
//! known-walkable way round is always preferred, and the search steps into the unknown only when
|
|
//! nothing known gets any closer. That is what a map edge six tiles away needed — it is off the
|
|
//! screen by definition, so a search that refused every unknown tile could not plan a single step
|
|
//! toward Route 1 from the middle of Pallet Town, which was half of why the fly never took it
|
|
//! (`infra/docs/macros-bench.md`, 2026-09-16).
|
|
//!
|
|
//! Either way the walk is re-planned only when the ground says so — a refusal, or the player not
|
|
//! where the plan expects it — which is [`super::executor`]'s committed route and not this
|
|
//! module's business.
|
|
//!
|
|
//! The search still has its second answer for a goal it cannot reach at all: when no goal is
|
|
//! reachable, it returns the route to the reachable tile that gets closest to one.
|
|
|
|
use std::cmp::Reverse;
|
|
use std::collections::{BinaryHeap, HashMap};
|
|
|
|
use super::cartridge::{
|
|
Edge, ExitId, LAST_MAP, MacroState, TalkTarget, Tile, destination_outdoors, outdoors,
|
|
};
|
|
use super::geography;
|
|
use super::state::{Facing, MapGrid, Walkable};
|
|
|
|
/// Whether the player could stand on a tile of the current map: the grid's answer, or the
|
|
/// window's when there is no grid.
|
|
///
|
|
/// One place asks the question so that the search, the exit list and the frontier cannot disagree
|
|
/// about which reading they are on (`docs/design/macros.md` section 15).
|
|
fn walkable_at(
|
|
state: &mut dyn MacroState,
|
|
grid: Option<&MapGrid>,
|
|
x: u8,
|
|
y: u8,
|
|
) -> Walkable {
|
|
match grid {
|
|
Some(grid) => grid.walkable(x, y),
|
|
None => state.walkable(x, y),
|
|
}
|
|
}
|
|
|
|
/// What one step onto a tile the walkable predicate cannot answer for costs.
|
|
///
|
|
/// Eight, which is longer than any detour worth taking inside one screen, so a known way round
|
|
/// always wins and the unknown is only ever stepped into when nothing known gets closer. A step is
|
|
/// still a step on the ground: the cost is a preference, not a claim about the tile.
|
|
const UNKNOWN_STEP: u32 = 8;
|
|
|
|
/// A route found by [`route`]: what it leads to, and the presses that walk it.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Route {
|
|
/// Index into the `goals` slice, or `None` for a route that only gets closer to one.
|
|
pub goal: Option<usize>,
|
|
/// One direction per tile. Empty only when the player already stands on a goal.
|
|
pub steps: Vec<Facing>,
|
|
}
|
|
|
|
/// One way out of the current map: a tile to stand on, and sometimes one last press.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Exit {
|
|
/// How the exploration ledger names this exit.
|
|
pub id: ExitId,
|
|
/// The tile to walk to.
|
|
pub tile: Tile,
|
|
/// A press to make once standing there, for an exit that does not fire on the step onto it.
|
|
pub press: Option<Facing>,
|
|
/// What kind of move this is: out of a building, between its floors, or on to the next area.
|
|
pub way: Way,
|
|
/// The map on the other side, when it is known.
|
|
///
|
|
/// For a warp, the destination byte of the warp table. For a step off a map edge, the
|
|
/// connection table's own answer ([`geography::connected`]) -- the connection *headers* carry
|
|
/// the id in WRAM but this crate has no reviewed symbol for them, and a static table is
|
|
/// enough for a question about which map is north of this one. `None` means nobody knows:
|
|
/// a building's own front door, whose destination pokered writes as `LAST_MAP`, and any map
|
|
/// the geography table has no row for.
|
|
pub into: Option<u8>,
|
|
}
|
|
|
|
impl Exit {
|
|
/// The map this exit leads to, `LAST_MAP` front doors resolved, or `None` when unknown.
|
|
///
|
|
/// `here` is the map the fly is standing on, which is what makes a front door answerable:
|
|
/// `LAST_MAP` means "the map outside", and which map that is, is the one thing a building
|
|
/// knows about itself ([`geography::outdoor_of`]).
|
|
///
|
|
/// This is what "visited" means for an exit (`docs/design/macros.md` section 9.2): the *map
|
|
/// on the other side* has been stood on, never that the fly has stood on the boundary tile.
|
|
/// An unknown destination is not visited, so an exit nobody can resolve stays a candidate,
|
|
/// which is the exploratory answer rather than the convenient one.
|
|
pub fn destination(&self, here: u8) -> Option<u8> {
|
|
self.into.or_else(|| match self.way {
|
|
Way::Exit => geography::outdoor_of(here),
|
|
Way::Passage | Way::Route => None,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// What a way out of the current map *is* (`docs/design/macros.md` section 9.1, the operator: "make a
|
|
/// distinction between building exit and something like stairs").
|
|
///
|
|
/// Classified from the destination map id the warp table already carries, because the three are
|
|
/// different moves in the game and one macro over all of them could only ever aim at the nearest.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub enum Way {
|
|
/// Out of a building: the destination is an outdoor map (`LAST_MAP` included).
|
|
Exit,
|
|
/// Between the floors or rooms of one building: the destination is another interior map.
|
|
Passage,
|
|
/// On to the next area, from an outdoor map: a connected map edge, or a door into a building.
|
|
Route,
|
|
}
|
|
|
|
/// One step the cartridge refused: the tile it was made from, and the direction it went.
|
|
///
|
|
/// A *directed* wall, which is the only shape that fits what the game encodes. Two of its rules
|
|
/// are invisible to the collision table [`super::state::walkable`] reads:
|
|
///
|
|
/// - **ledges.** `HandleLedges` (`home/overworld.asm`) matches a triple of facing, the tile the
|
|
/// player stands on and the tile in front against `LedgeTiles`, and hops the player *two* tiles
|
|
/// that way. The ledge tile itself is absent from every tileset's passable list, so the
|
|
/// predicate already calls it [`Walkable::No`] and the search never plans through one in either
|
|
/// direction — a hop is a move the walk declines to use rather than one it gets wrong.
|
|
/// - **tile-pair collisions.** `TilePairCollisionsLand` and `...Water`
|
|
/// (`data/tilesets/tile_pair_collisions.asm`) refuse a step *between* two tiles that are each
|
|
/// passable on their own — the water/land boundary, a forest's tree line, a gym's floor edge.
|
|
/// Both tiles read `Yes`, so nothing in the collision table can predict the refusal.
|
|
///
|
|
/// So the walk measures instead of guessing: a step that spent its whole window with the player
|
|
/// where it started is recorded here and the re-plan treats it as a wall **in that direction from
|
|
/// that tile only**, which is exactly what the two rules are. A person in the way is recorded the
|
|
/// same way and costs the walk one detour, which is the honest price for a wall that moves. The
|
|
/// entries are per walk and travel with a suspended route (`super::executor`), never longer: a
|
|
/// refusal is a fact about the frame it happened in.
|
|
pub type Refusal = (Tile, Facing);
|
|
|
|
/// The cheapest route to the nearest of `goals`, or failing that the one that gets closest.
|
|
///
|
|
/// The player's own tile is passable whether or not the predicate says so: the player is standing
|
|
/// on it, and a doormat the game will not let you stop on would otherwise make a route out of the
|
|
/// room impossible to plan from the tile the fly is actually on.
|
|
pub fn route(state: &mut dyn MacroState, goals: &[Tile]) -> Option<Route> {
|
|
route_avoiding(state, goals, &[])
|
|
}
|
|
|
|
/// [`route`], with the steps this walk has already found the cartridge refuses treated as walls.
|
|
///
|
|
/// `refused` is directed ([`Refusal`]): it forbids one step out of one tile and says nothing about
|
|
/// the reverse, because a ledge and a tile-pair collision are both one-way facts. Without it the
|
|
/// search re-plans the same refused step after every failure and the walk spends its three
|
|
/// failures on one tile; with it the second plan goes round.
|
|
pub fn route_avoiding(
|
|
state: &mut dyn MacroState,
|
|
goals: &[Tile],
|
|
refused: &[Refusal],
|
|
) -> Option<Route> {
|
|
if goals.is_empty() {
|
|
return None;
|
|
}
|
|
let player = state.player()?;
|
|
let size = state.map_size()?;
|
|
// One decode per plan, from the cache the sim loop keeps: with a grid the search is over the
|
|
// whole map, without one it is over the ten-by-nine window as it always was.
|
|
let grid = state.map_grid();
|
|
let grid = grid.as_deref();
|
|
let start = Tile::new(player.x, player.y);
|
|
if let Some(goal) = goals.iter().position(|tile| *tile == start) {
|
|
return Some(Route { goal: Some(goal), steps: Vec::new() });
|
|
}
|
|
let heuristic = |tile: Tile| goals.iter().map(|goal| tile.distance(*goal)).min().unwrap_or(0);
|
|
|
|
// tile -> (cost so far, the tile stepped from, the direction stepped)
|
|
let mut best: HashMap<Tile, (u32, Tile, Facing)> = HashMap::new();
|
|
let mut open = BinaryHeap::new();
|
|
open.push(Reverse((heuristic(start), 0u32, start)));
|
|
best.insert(start, (0, start, Facing::Down));
|
|
// The closest the search has got to a goal, for the approach answer.
|
|
let mut nearest = (heuristic(start), 0u32, start);
|
|
|
|
while let Some(Reverse((_, cost, tile))) = open.pop() {
|
|
if best.get(&tile).is_some_and(|(known, _, _)| *known < cost) {
|
|
continue;
|
|
}
|
|
if let Some(goal) = goals.iter().position(|candidate| *candidate == tile) {
|
|
return Some(Route { goal: Some(goal), steps: unwind(&best, start, tile) });
|
|
}
|
|
let distance = heuristic(tile);
|
|
if (distance, cost) < (nearest.0, nearest.1) {
|
|
nearest = (distance, cost, tile);
|
|
}
|
|
for facing in super::cartridge::FACINGS {
|
|
let Some(next) = tile.step(facing) else { continue };
|
|
if next.x >= size.width || next.y >= size.height {
|
|
continue;
|
|
}
|
|
// A step this walk has already watched the game refuse. Directed, so the way back
|
|
// stays open.
|
|
if refused.contains(&(tile, facing)) {
|
|
continue;
|
|
}
|
|
// A step the *tables* refuse: a tile-pair collision, which is passable ground on both
|
|
// sides and a wall between them (`mapgrid::TILE_PAIRS_LAND`). The walk used to learn
|
|
// each of these by spending a step on it; with the grid the first plan goes round.
|
|
if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) {
|
|
continue;
|
|
}
|
|
// **A tile the cartridge pushes the fly off is not a tile to walk through**, either
|
|
// (row 37 of `infra/docs/macros-traps.md`). Excluding it as a *goal* was half the fix
|
|
// and the measurement said so: Viridian City's (19, 9) went from 53,266 text-box
|
|
// frames in twenty brain minutes to 12,919, because the A* still routed *across* it on
|
|
// the way to somewhere else and the script fires on any frame the fly stands there.
|
|
// A wall is the honest model of a tile the game will not let the fly stand on.
|
|
if next != start && state.pushed_tile(next.x, next.y) {
|
|
continue;
|
|
}
|
|
let step = match walkable_at(state, grid, next.x, next.y) {
|
|
_ if next == start => 1,
|
|
Walkable::Yes => 1,
|
|
// Off the screen buffer, or a block the blockset was read short of: plausible
|
|
// ground, priced so that anything known beats it.
|
|
Walkable::Unknown => UNKNOWN_STEP,
|
|
Walkable::No => continue,
|
|
};
|
|
let step_cost = cost + step;
|
|
if best.get(&next).is_none_or(|(known, _, _)| step_cost < *known) {
|
|
best.insert(next, (step_cost, tile, facing));
|
|
open.push(Reverse((step_cost + heuristic(next), step_cost, next)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nothing reachable is a goal. Walk to whatever gets closest, so the predicate's window has
|
|
// somewhere new to look from.
|
|
if nearest.2 == start {
|
|
return None;
|
|
}
|
|
Some(Route { goal: None, steps: unwind(&best, start, nearest.2) })
|
|
}
|
|
|
|
/// Walk the parent links back from `tile` to `start` and reverse them into presses.
|
|
fn unwind(
|
|
best: &HashMap<Tile, (u32, Tile, Facing)>,
|
|
start: Tile,
|
|
tile: Tile,
|
|
) -> Vec<Facing> {
|
|
let mut steps = Vec::new();
|
|
let mut cursor = tile;
|
|
while cursor != start {
|
|
let Some((_, from, facing)) = best.get(&cursor) else { break };
|
|
steps.push(*facing);
|
|
cursor = *from;
|
|
}
|
|
steps.reverse();
|
|
steps
|
|
}
|
|
|
|
/// Every tile on the current map the game prints something for when it is faced.
|
|
///
|
|
/// `GO ITEM`'s targets (`docs/design/macros.md` section 3, 2026-09-16), from the two places the
|
|
/// cartridge keeps them, both already in WRAM:
|
|
///
|
|
/// - **the object sprites.** The map's `object_event` list occupies the same sixteen sprite slots
|
|
/// the villagers do, and the ones that are not people are the things on the floor and the
|
|
/// tables: item balls, the starter Pokéballs in Oak's lab, a fossil, a boulder, a sleeping
|
|
/// Snorlax. [`super::state::Npc::person`] is the split, and it is pokered's own
|
|
/// (`FIRST_STILL_SPRITE`).
|
|
/// - **the signs.** `bg_event` tiles: signposts, bookshelves, televisions, notice boards. They are
|
|
/// not sprites and not walkable, so nothing else in this module can see them, and they are the
|
|
/// half of the old `LOOK` slot that was worth keeping.
|
|
///
|
|
/// The tile reported is the thing's own tile, which is a tile to *face* rather than one to stand
|
|
/// on — an object sprite blocks its tile and a sign is part of a wall. Turning that into somewhere
|
|
/// to walk is [`super::executor`]'s job, because it is the same "stand beside it and turn" that
|
|
/// `GO NPC` already does.
|
|
///
|
|
/// Deduplicated and in tile order, so the nearest of two things on one tile is one goal and the
|
|
/// choice between equally near ones does not depend on which table they came out of.
|
|
pub fn interactables(state: &mut dyn MacroState) -> Vec<Tile> {
|
|
let mut out: Vec<Tile> = state
|
|
.npcs()
|
|
.iter()
|
|
.filter(|npc| !npc.person())
|
|
.map(|npc| Tile::new(npc.x, npc.y))
|
|
.collect();
|
|
out.extend(state.signs().iter().map(|sign| Tile::new(sign.x, sign.y)));
|
|
out.sort_unstable();
|
|
out.dedup();
|
|
out
|
|
}
|
|
|
|
/// Every object and sign on the map with the key the talked ledger records it under.
|
|
///
|
|
/// [`interactables`]'s own two tables, paired with their identities rather than flattened to
|
|
/// tiles: a sprite by its slot, a sign by its text id ([`TalkTarget`]). This is what makes
|
|
/// "untalked" a question about the *thing* rather than about the ground beside it.
|
|
pub fn interactable_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> {
|
|
let mut out: Vec<(Tile, TalkTarget)> = state
|
|
.npcs()
|
|
.iter()
|
|
.filter(|npc| !npc.person())
|
|
.map(|npc| (Tile::new(npc.x, npc.y), TalkTarget::Sprite(npc.slot)))
|
|
.collect();
|
|
out.extend(
|
|
state.signs().iter().map(|sign| (Tile::new(sign.x, sign.y), TalkTarget::Sign(sign.text_id))),
|
|
);
|
|
out.sort_unstable();
|
|
out.dedup();
|
|
out
|
|
}
|
|
|
|
/// Every person on the map with the key the talked ledger records them under.
|
|
pub fn person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> {
|
|
let mut out: Vec<(Tile, TalkTarget)> = state
|
|
.npcs()
|
|
.iter()
|
|
.filter(|npc| npc.person())
|
|
.map(|npc| (Tile::new(npc.x, npc.y), TalkTarget::Sprite(npc.slot)))
|
|
.collect();
|
|
out.sort_unstable();
|
|
out.dedup();
|
|
out
|
|
}
|
|
|
|
/// The people of this map the cartridge is not drawing only because they are off the screen,
|
|
/// keyed as [`person_targets`] keys the drawn ones (row 58).
|
|
///
|
|
/// Kept apart from [`person_targets`] on purpose: that list is what `GO NPC`, `TALK` and the
|
|
/// talked ledger's facing test read, and a sprite outside the window may also be a toggleable
|
|
/// object the cartridge has switched off ([`GameState::offscreen_npcs`]). The one reader is the
|
|
/// ladder's own target list, which has to know the leader is in the room before the fly can see
|
|
/// him.
|
|
///
|
|
/// [`GameState::offscreen_npcs`]: super::state::GameState::offscreen_npcs
|
|
pub fn offscreen_person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> {
|
|
let mut out: Vec<(Tile, TalkTarget)> = state
|
|
.offscreen_npcs()
|
|
.iter()
|
|
.filter(|npc| npc.person())
|
|
.map(|npc| (Tile::new(npc.x, npc.y), TalkTarget::Sprite(npc.slot)))
|
|
.collect();
|
|
out.sort_unstable();
|
|
out.dedup();
|
|
out
|
|
}
|
|
|
|
/// What is on `tile`: the thing a press at it would talk to, or `None` for bare ground.
|
|
///
|
|
/// People first, because a person standing on a sign's tile is what the press would reach.
|
|
pub fn target_at(state: &mut dyn MacroState, tile: Tile) -> Option<TalkTarget> {
|
|
person_targets(state)
|
|
.into_iter()
|
|
.chain(interactable_targets(state))
|
|
.find(|(at, _)| *at == tile)
|
|
.map(|(_, target)| target)
|
|
}
|
|
|
|
/// Every exit the current map has: its warps, and a step off each connected edge.
|
|
///
|
|
/// Two behaviours of the cartridge are encoded here, both measured rather than assumed
|
|
/// (`docs/design/room-escape.md` section 3, "What the room actually is"):
|
|
///
|
|
/// - An interior warp — a staircase — fires on the step onto it, so it needs no extra press.
|
|
/// - A warp on the map's bottom row — a doormat — fires on the step *off* it, downward. Red's
|
|
/// ground floor has two of them side by side and "the front door needs DOWN, and only DOWN". A
|
|
/// sideways step onto a mat does nothing, which is why the press belongs to the exit rather than
|
|
/// being something the walker can leave out.
|
|
///
|
|
/// Stepping down onto a mat happens to fire it too, so a route that ends on one can finish either
|
|
/// way; the extra press is what makes it work when the route arrived sideways.
|
|
pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
|
|
let Some(size) = state.map_size() else { return Vec::new() };
|
|
let Some(player) = state.player() else { return Vec::new() };
|
|
let grid = state.map_grid();
|
|
let grid = grid.as_deref();
|
|
let here_outdoors = outdoors(player.map);
|
|
let mut out = Vec::new();
|
|
for (index, warp) in state.warps().iter().enumerate() {
|
|
let Ok(id) = u8::try_from(index) else { continue };
|
|
let tile = Tile::new(warp.x, warp.y);
|
|
// Section 9.1's classification, from the destination map id and nothing else. Outdoors
|
|
// every warp is a door into somewhere new, which is a route; indoors the destination
|
|
// decides whether it is the way out of the building or the way to another of its floors.
|
|
let way = if here_outdoors {
|
|
Way::Route
|
|
} else if destination_outdoors(warp.destination_map) {
|
|
Way::Exit
|
|
} else {
|
|
Way::Passage
|
|
};
|
|
out.push(Exit {
|
|
id: ExitId::Warp(id),
|
|
tile,
|
|
press: outward(tile, size.height),
|
|
way,
|
|
into: (warp.destination_map != LAST_MAP).then_some(warp.destination_map),
|
|
});
|
|
}
|
|
let connections = state.connections();
|
|
for (edge, facing) in Edge::ALL {
|
|
let connected = match edge {
|
|
Edge::North => connections.north,
|
|
Edge::South => connections.south,
|
|
Edge::East => connections.east,
|
|
Edge::West => connections.west,
|
|
};
|
|
// A connection the headers name and no step on foot crosses -- Pallet Town's shore --
|
|
// is not a way out (`geography::NO_CROSSING`, row 59).
|
|
if !connected || !geography::crossable(player.map, edge) {
|
|
continue;
|
|
}
|
|
// A step off the edge of an outdoor map is the next area; off an interior one -- which
|
|
// the cartridge does not normally do -- it is still a way out of the building.
|
|
let way = if here_outdoors { Way::Route } else { Way::Exit };
|
|
let into = geography::connected(player.map, edge);
|
|
for tile in edge_tiles(facing, size.width, size.height) {
|
|
// Not `== Yes`: without a grid the walkable predicate's window is the screen, so the
|
|
// far edge of an outdoor map reads `Unknown` from anywhere but next to it, and
|
|
// filtering on `Yes` left a town's connections out of the exit list entirely -- which
|
|
// is half of why nothing could ever aim at Route 1 from the middle of Pallet Town. An
|
|
// unknown tile is a goal worth walking towards; the route search's own approach answer
|
|
// handles a goal it cannot reach yet, and a tile that turns out to be a wall costs one
|
|
// blocked walk. With a grid the answer is `Yes` or `No` for every edge tile of the map
|
|
// and this rejects the walls, which is what lets one plan reach the right end of a
|
|
// connection instead of the nearest of twenty tiles along it.
|
|
if walkable_at(state, grid, tile.x, tile.y) != Walkable::No {
|
|
out.push(Exit { id: ExitId::Edge(edge), tile, press: Some(facing), way, into });
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Every tile of the current map that borders ground this run has never stood on.
|
|
///
|
|
/// `GO FRONTIER` (`docs/design/macros.md` section 9): "walk to the nearest tile bordering ground
|
|
/// this run has never stood on, using the exploration ledger, and face it. No random steps remain
|
|
/// anywhere in the palette."
|
|
///
|
|
/// Each answer is a tile to stand on paired with the direction the new ground lies in, which is
|
|
/// the same shape `GO NPC` and `GO ITEM` use -- and the same press, which in the overworld walks
|
|
/// onto the tile when it is walkable, so the frontier the fly is looking at becomes ground it has
|
|
/// stood on. Both tiles have to be walkable: an unreachable one is not ground.
|
|
///
|
|
/// **With a grid this is the whole map** (`docs/design/macros.md` section 15): the nearest unstood
|
|
/// walkable tile anywhere on it, which is what the operator asked for and what the route search
|
|
/// then plans one walk to. Without a grid it is what it always was -- the ten-by-nine window, so
|
|
/// the answer is local to the player and the long walk is done by re-planning.
|
|
///
|
|
/// Deduplicated by the tile to stand on, in tile order, so the choice between two equally near
|
|
/// frontiers does not depend on iteration order.
|
|
///
|
|
/// **A tile a sprite is standing on is not ground.** The collision table knows nothing about
|
|
/// people or objects — that is what the executor's per-step "did the player move" check is for —
|
|
/// so a villager standing in the open, or an item ball on a floor tile, reads [`Walkable::Yes`]
|
|
/// underneath. Such a tile can never be stood on while the sprite is there, so it can never enter
|
|
/// the visited ledger either: it is a frontier for as long as the sprite stands in it, and the
|
|
/// arrival press faces it, reports `Done` and changes nothing. The blocked ledger bounds that at
|
|
/// one walk per ten brain minutes (row 5); leaving the tile out of the list is what makes the
|
|
/// frontier *exhaust*, which is what the button leaving the pad needs. The gate house at
|
|
/// Viridian Forest's south end had one of each (`infra/docs/macros-traps.md` row 32).
|
|
pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> {
|
|
let Some(size) = state.map_size() else { return Vec::new() };
|
|
let Some(player) = state.player() else { return Vec::new() };
|
|
let grid = state.map_grid();
|
|
let grid = grid.as_deref();
|
|
let here = Tile::new(player.x, player.y);
|
|
let held: Vec<Tile> = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect();
|
|
let mut out: Vec<(Tile, Facing)> = Vec::new();
|
|
for y in 0..size.height {
|
|
for x in 0..size.width {
|
|
let tile = Tile::new(x, y);
|
|
if tile != here && walkable_at(state, grid, x, y) != Walkable::Yes {
|
|
continue;
|
|
}
|
|
if tile != here && held.contains(&tile) {
|
|
continue;
|
|
}
|
|
for facing in super::cartridge::FACINGS {
|
|
let Some(next) = tile.step(facing) else { continue };
|
|
if next.x >= size.width || next.y >= size.height || next == here {
|
|
continue;
|
|
}
|
|
if walkable_at(state, grid, next.x, next.y) != Walkable::Yes {
|
|
continue;
|
|
}
|
|
// A step the tables refuse is not a way onto that ground, so the tile it leads to
|
|
// is not this tile's frontier -- somebody else's, if anything reaches it.
|
|
if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) {
|
|
continue;
|
|
}
|
|
if held.contains(&next) {
|
|
continue;
|
|
}
|
|
if state.tile_visited(next.x, next.y) {
|
|
continue;
|
|
}
|
|
out.push((tile, facing));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
out.sort_by_key(|(tile, _)| *tile);
|
|
out.dedup_by_key(|(tile, _)| *tile);
|
|
out
|
|
}
|
|
|
|
/// The press a warp on `tile` needs after it is stood on, or `None` when it fires by itself.
|
|
///
|
|
/// Only the bottom row answers, and it answers `Down`. That is the measured behaviour rather than
|
|
/// a rule about geometry: `docs/design/room-escape.md` section 3 walked Red's ground floor with
|
|
/// real presses and found the front door needs DOWN, while the staircase — which is against the
|
|
/// right-hand wall, so a rule about outer columns would have claimed it — fires the instant it is
|
|
/// stepped on. A warp that turns out to need some other press is not pressed into one: the walk
|
|
/// settles on it, reports `done`, and the fly chooses again.
|
|
fn outward(tile: Tile, height: u8) -> Option<Facing> {
|
|
(height > 0 && tile.y == height - 1).then_some(Facing::Down)
|
|
}
|
|
|
|
/// Every tile on the edge a step in `facing` would cross.
|
|
fn edge_tiles(facing: Facing, width: u8, height: u8) -> Vec<Tile> {
|
|
if width == 0 || height == 0 {
|
|
return Vec::new();
|
|
}
|
|
match facing {
|
|
Facing::Up => (0..width).map(|x| Tile::new(x, 0)).collect(),
|
|
Facing::Down => (0..width).map(|x| Tile::new(x, height - 1)).collect(),
|
|
Facing::Left => (0..height).map(|y| Tile::new(0, y)).collect(),
|
|
Facing::Right => (0..height).map(|y| Tile::new(width - 1, y)).collect(),
|
|
}
|
|
}
|