Floor layer
This commit is contained in:
@@ -54,12 +54,18 @@ Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
|
|||||||
- `Solid<'a>` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player` (the player; pushable any direction), `Cell(Archetype)` (a solid grid archetype like a wall) or `Object(&ObjectDef)` (a solid object). At most one solid may occupy a cell — enforced at load time. The player is checked first, so its cell reports `Solid::Player` (and is impassable to movers).
|
- `Solid<'a>` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player` (the player; pushable any direction), `Cell(Archetype)` (a solid grid archetype like a wall) or `Object(&ObjectDef)` (a solid object). At most one solid may occupy a cell — enforced at load time. The player is checked first, so its cell reports `Solid::Player` (and is impassable to movers).
|
||||||
- `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `Crate` is solid and pushable any direction (CP437 ■, char 254, light gray on black); `HCrate`/`VCrate` are crates pushable only east/west (↔, char 29) / north/south (↕, char 18) respectively, same colors. `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red.
|
- `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `Crate` is solid and pushable any direction (CP437 ■, char 254, light gray on black); `HCrate`/`VCrate` are crates pushable only east/west (↔, char 29) / north/south (↕, char 18) respectively, same colors. `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red.
|
||||||
- `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`).
|
- `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`).
|
||||||
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells` is `pub(crate)`. `solid_at(x, y) -> Option<Solid>` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`). `in_bounds((i32, i32))` bounds-checks a (possibly negative) coordinate. Push support is split into the read-only `can_push(x, y, dir)` (does the chain of pushable solids starting here end at open space?) and the mutating `push(x, y, dir)` (shoves that chain one cell, leaving `Empty` behind); the read-only half lets a mover answer "can I move here?" via `is_passable || can_push` before committing. `is_valid()` / `load_errors()` expose nonfatal problems collected while loading (a non-serialized `Vec<LogLine>`); `report_error(msg)` appends a red-on-black line and `is_valid()` is just "no load errors".
|
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `floor: Vec<Glyph>` + `floor_spec: Option<FloorSpec>` (the visual floor layer — see `floor.rs`), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells`, `floor`, and `floor_spec` are `pub(crate)`. `display_glyph(x, y)` returns the static-layer glyph for a cell — the grid archetype's glyph, or the `floor[idx]` glyph when the cell is `Empty` (front-ends call this for the non-object/non-player layer; an `Empty` cell's *own* glyph is ignored, so a cell vacated by a pushed crate reveals the floor). `solid_at(x, y) -> Option<Solid>` returns the cell's single solid occupant (object checked before grid archetype); `is_passable(x, y)` is the convenience inverse (`solid_at(...).is_none()`). `in_bounds((i32, i32))` bounds-checks a (possibly negative) coordinate. Push support is split into the read-only `can_push(x, y, dir)` (does the chain of pushable solids starting here end at open space?) and the mutating `push(x, y, dir)` (shoves that chain one cell, leaving `Empty` behind); the read-only half lets a mover answer "can I move here?" via `is_passable || can_push` before committing. `is_valid()` / `load_errors()` expose nonfatal problems collected while loading (a non-serialized `Vec<LogLine>`); `report_error(msg)` appends a red-on-black line and `is_valid()` is just "no load errors".
|
||||||
- `Player` — `x: i32, y: i32`
|
- `Player` — `x: i32, y: i32`
|
||||||
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `solid: bool`, `opaque: bool`, `pushable: bool`, `script_name: Option<String>`. `solid` and `opaque` default `true`, `pushable` defaults `false` in map files. Its `init()`/`tick(dt)`/`bump(id)` hooks are run by the scripting runtime (see `script.rs`).
|
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `solid: bool`, `opaque: bool`, `pushable: bool`, `script_name: Option<String>`. `solid` and `opaque` default `true`, `pushable` defaults `false` in map files. Its `init()`/`tick(dt)`/`bump(id)` hooks are run by the scripting runtime (see `script.rs`).
|
||||||
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
|
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
|
||||||
- `GameState` — holds `board: Rc<RefCell<Board>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. The board sits behind `Rc<RefCell<…>>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. Front-ends/logic reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (there is no `pub board` field). `try_move(dir: Direction)` moves the player (pushing a crate/solid out of the way if possible) and fires `bump(-1)` on a solid object it walks into; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` advances each object's cooldown then runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue (`take_board_queue()`) and applies each `Action`: `Log` → `log`, `SetTile` → the source object's glyph, `Move(dir)` → the free fn `step_object` (gates on `in_bounds` then `is_passable || can_push`, pushing before it relocates, and returns the index of any solid object it bumped). Resolution is two-phase — mutate the board collecting `(bumped, bumper)` pairs, then drop the board borrow and fire `run_bump` for each (a `bump` script reads `Board.*`, so no `board_mut` borrow may be held while it runs). A bumped object's own emitted actions wait for the next tick's pump (no same-tick cascade). `drain_errors()` moves the script error sink into `log`. This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
|
- `GameState` — holds `board: Rc<RefCell<Board>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. The board sits behind `Rc<RefCell<…>>` as a sibling of the `ScriptHost` so script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. Front-ends/logic reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (there is no `pub board` field). `try_move(dir: Direction)` moves the player (pushing a crate/solid out of the way if possible) and fires `bump(-1)` on a solid object it walks into; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` advances each object's cooldown then runs `tick(dt)` hooks every frame. Both end by calling `resolve()`, which drains the board queue (`take_board_queue()`) and applies each `Action`: `Log` → `log`, `SetTile` → the source object's glyph, `Move(dir)` → the free fn `step_object` (gates on `in_bounds` then `is_passable || can_push`, pushing before it relocates, and returns the index of any solid object it bumped). Resolution is two-phase — mutate the board collecting `(bumped, bumper)` pairs, then drop the board borrow and fire `run_bump` for each (a `bump` script reads `Board.*`, so no `board_mut` borrow may be held while it runs). A bumped object's own emitted actions wait for the next tick's pump (no same-tick cascade). `drain_errors()` moves the script error sink into `log`. This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
|
||||||
|
|
||||||
|
**`kiln-core/src/floor.rs`** — the visual floor layer:
|
||||||
|
- The floor is a cosmetic per-cell background drawn wherever a cell would render as `Archetype::Empty` (it is *how* `Empty` is drawn; the cell's own `Empty` glyph is ignored). Computed once at load and cached on `Board::floor`, so there is no per-frame cost / flicker; the raw `FloorSpec` is also kept (`Board::floor_spec`) so `map_file::save` round-trips it.
|
||||||
|
- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name; `generate(&mut StdRand)` picks a dark, low-saturation ground color (green/brown/gray) and, with the generator's probability, scatters a lighter texture char (grass `, . \` '`; dirt `. : , ;`; stone `. ,`). Uses `tinyrand` (already a workspace dep, WASM-safe) seeded with a fixed `FLOOR_SEED` for deterministic, testable output.
|
||||||
|
- `FloorSpec` — `#[serde(untagged)]` enum for the `[floor]` declaration: `Generator(String)` (whole board), `Single(FloorGlyph)` (one fixed glyph everywhere), or `Grid { content, palette: HashMap<String, FloorTile> }` (a grid with its own palette). Untagged ordering matters: a string → `Generator`; a table with `tile` → `Single`; a table with `content` → `Grid`. `FloorTile` (untagged) is a grid-palette entry: a generator name (string) or a `FloorGlyph` (table). `FloorGlyph { tile: TileIndex, fg, bg }` reuses `map_file::TileIndex` (now `pub`) and `parse_color`.
|
||||||
|
- `build_floor(&Option<FloorSpec>, w, h, &mut Vec<LogLine>) -> Vec<Glyph>` — best-effort/nonfatal (like the map loader): unknown generators/chars and grid-dimension mismatches are recorded on the error vec and that cell falls back to the canonical black-on-black space; `None` → an all-empty floor (the historical look).
|
||||||
|
|
||||||
**`kiln-core/src/log.rs`** — styled log messages:
|
**`kiln-core/src/log.rs`** — styled log messages:
|
||||||
- `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time.
|
- `LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }` and `LogLine { spans: Vec<LogSpan> }` — a UI-agnostic styled message (colors are core `Rgba8`, not a front-end type). `LogLine::raw()`, a chainable `push()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time.
|
||||||
|
|
||||||
@@ -184,6 +190,26 @@ tile_h = 16
|
|||||||
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } # crate, pushable east/west only
|
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" } # crate, pushable east/west only
|
||||||
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # crate, pushable north/south only
|
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # crate, pushable north/south only
|
||||||
|
|
||||||
|
# Optional visual floor layer (drawn under every empty cell). Four forms:
|
||||||
|
# floor = "grass" # 1. one generator for the whole board
|
||||||
|
# [floor] # 2. one fixed glyph everywhere
|
||||||
|
# tile = "." fg = "#1c3a1c" bg = "#0a1a0a"
|
||||||
|
# [floor] # 3. a grid with its own palette
|
||||||
|
# content = """...""" # (must match width/height)
|
||||||
|
# [floor.palette] # entries are generator names OR glyphs
|
||||||
|
# "g" = "grass" # generator: "grass" | "dirt" | "stone"
|
||||||
|
# "." = { tile = ".", fg = "#222", bg = "#000" }
|
||||||
|
# (Omitting [floor] entirely = black-on-black space, the default.)
|
||||||
|
[floor]
|
||||||
|
content = """
|
||||||
|
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggddddddddddddddddddddddddddddddggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
|
||||||
|
"""
|
||||||
|
[floor.palette]
|
||||||
|
"g" = "grass"
|
||||||
|
"d" = "dirt"
|
||||||
|
|
||||||
[grid]
|
[grid]
|
||||||
content = """
|
content = """
|
||||||
############################################################
|
############################################################
|
||||||
@@ -229,11 +255,14 @@ fn bump(id) { log(`bumped by ${id}`); }
|
|||||||
"""
|
"""
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The optional `[floor]` section declares the visual floor layer (drawn under every empty cell, and revealed when a crate/object is pushed away); see `floor.rs`. It takes one of four forms: omitted (black-on-black space, the default), a generator name (`floor = "grass"`), a single fixed glyph (`[floor]` with `tile`/`fg`/`bg`), or a grid (`[floor] content` + `[floor.palette]`, whose entries are generator names or fixed glyphs). The three built-in generators are `grass`/`dirt`/`stone`. Floor parsing is best-effort: an unknown generator/char or a grid-dimension mismatch is recorded on `Board::load_errors` and those cells fall back to the default empty glyph.
|
||||||
|
|
||||||
Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`. Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object (a blocked move still costs the cooldown), so objects can't act infinitely fast. When two objects move into the same cell on one tick, **array order wins** (the earlier object lands; the later is pushed or blocked) and the object that was moved into receives `bump(id)`.
|
Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`, `tick(dt)`, and `bump(id)` functions; they **read** the world through `Board.*` (e.g. `Board.player_x`), check `blocked(dir) -> bool`, inspect/empty their pending actions via `Queue.length()`/`Queue.clear()`, and **write** via host functions `move(dir)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, and `log(s)`. Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object (a blocked move still costs the cooldown), so objects can't act infinitely fast. When two objects move into the same cell on one tick, **array order wins** (the earlier object lands; the later is pushed or blocked) and the object that was moved into receives `bump(id)`.
|
||||||
|
|
||||||
### Key design decisions
|
### Key design decisions
|
||||||
|
|
||||||
- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`solid`, `opaque`, `pushable`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site.
|
- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`solid`, `opaque`, `pushable`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site.
|
||||||
|
- **The floor layer is how `Empty` is drawn** — `Board::display_glyph` returns the per-cell `floor` glyph for any `Empty` cell, so an `Empty` cell's *own* glyph is ignored (and a cell vacated by a pushed crate automatically shows the floor with no movement-code changes). The floor is computed once at load and cached (`Board::floor`); the raw `FloorSpec` is kept (`Board::floor_spec`) only so saves round-trip. One consequence: a map that colored an `Empty` cell's bg no longer shows it — empties always come from the floor layer (default black-on-black). See `floor.rs`.
|
||||||
- **One solid per cell** — at most one solid entity (the player, a solid grid archetype, *or* a solid object) may occupy a cell. The player is a first-class solid: `Board::solid_at` reports `Solid::Player` for the player's cell (checked first), it blocks movers, and it is **pushable in any direction** — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a solid object landing on an already-solid cell is dropped (recorded via `report_error`); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when `player_start` is unresolvable) silently clears any solid terrain under it and drops a conflicting object. `Board::solid_at` relies on this invariant.
|
- **One solid per cell** — at most one solid entity (the player, a solid grid archetype, *or* a solid object) may occupy a cell. The player is a first-class solid: `Board::solid_at` reports `Solid::Player` for the player's cell (checked first), it blocks movers, and it is **pushable in any direction** — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a solid object landing on an already-solid cell is dropped (recorded via `report_error`); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when `player_start` is unresolvable) silently clears any solid terrain under it and drops a conflicting object. `Board::solid_at` relies on this invariant.
|
||||||
- **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient.
|
- **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient.
|
||||||
- **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. Adding a variant: the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but **`TryFrom<&str>` and `ALL_ARCHETYPES` are not** — forget the `TryFrom` arm and the archetype silently loads as `ErrorBlock`. Also update the map-format example.
|
- **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. Adding a variant: the `match`es in `behavior()`/`name()`/`default_glyph()` are exhaustive (the compiler flags them), but **`TryFrom<&str>` and `ALL_ARCHETYPES` are not** — forget the `TryFrom` arm and the archetype silently loads as `ErrorBlock`. Also update the map-format example.
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
//! The floor layer: a per-cell visual background drawn wherever a board cell
|
||||||
|
//! would otherwise render as [`Archetype::Empty`](crate::game::Archetype::Empty).
|
||||||
|
//!
|
||||||
|
//! The floor is purely cosmetic — it carries no behavior. It exists to give
|
||||||
|
//! boards some non-distracting visual flavor (textured ground) with little
|
||||||
|
//! authoring effort, including randomly-generated "grass" / "dirt" / "stone".
|
||||||
|
//!
|
||||||
|
//! A board's floor is computed once at load time ([`build_floor`]) and cached as
|
||||||
|
//! a `Vec<Glyph>` on the [`Board`](crate::game::Board); generators are run during
|
||||||
|
//! that single pass and their results stored, so there is no per-frame cost and
|
||||||
|
//! no tile flicker. The raw [`FloorSpec`] is also kept on the board so a save
|
||||||
|
//! round-trips the declaration (generators re-randomize on reload; literal glyph
|
||||||
|
//! grids are preserved exactly).
|
||||||
|
|
||||||
|
use crate::game::{Archetype, Glyph};
|
||||||
|
use crate::log::LogLine;
|
||||||
|
use crate::map_file::{TileIndex, parse_color};
|
||||||
|
use color::Rgba8;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use tinyrand::{Probability, Rand, Seeded, StdRand};
|
||||||
|
|
||||||
|
/// Fixed seed for the floor PRNG, so a board's generated floor is deterministic
|
||||||
|
/// for a given spec (stable across reloads within a run, and testable).
|
||||||
|
const FLOOR_SEED: u64 = 0x_C0FF_EE15_F100_0001;
|
||||||
|
|
||||||
|
/// A procedural floor texture. Each generator differs only in its color scheme
|
||||||
|
/// and the probability/character set of its scattered "texture" glyphs; the
|
||||||
|
/// colors are deliberately dark and low-saturation so foreground objects stay
|
||||||
|
/// readable against them.
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum FloorGenerator {
|
||||||
|
/// Random green-to-greenish-yellow ground with a fairly high chance of grassy
|
||||||
|
/// characters (comma, period, backquote, apostrophe).
|
||||||
|
Grass,
|
||||||
|
/// Random brown ground with a lower chance of `.`, `:`, `,`, `;`.
|
||||||
|
Dirt,
|
||||||
|
/// Random gray ground with a lower chance of `.` or `,`.
|
||||||
|
Stone,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FloorGenerator {
|
||||||
|
/// Parses a generator from its map-file name, or `None` if unrecognized.
|
||||||
|
pub fn from_name(name: &str) -> Option<FloorGenerator> {
|
||||||
|
match name {
|
||||||
|
"grass" => Some(FloorGenerator::Grass),
|
||||||
|
"dirt" => Some(FloorGenerator::Dirt),
|
||||||
|
"stone" => Some(FloorGenerator::Stone),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates one random floor [`Glyph`] for this generator.
|
||||||
|
///
|
||||||
|
/// Picks a background ground color within the generator's scheme, then with
|
||||||
|
/// the generator's probability scatters a lighter "texture" char on top
|
||||||
|
/// (otherwise a blank space, whose fg is irrelevant).
|
||||||
|
fn generate(&self, rng: &mut StdRand) -> Glyph {
|
||||||
|
// (background color ranges, texture probability, texture chars).
|
||||||
|
let (bg, chars, prob) = match self {
|
||||||
|
// Green → greenish-yellow: g dominant, a touch of r for the yellow tilt.
|
||||||
|
FloorGenerator::Grass => (
|
||||||
|
Rgba8 {
|
||||||
|
r: shade(rng, 20, 55),
|
||||||
|
g: shade(rng, 45, 85),
|
||||||
|
b: shade(rng, 20, 35),
|
||||||
|
a: 255,
|
||||||
|
},
|
||||||
|
[',', '.', '`', '\''].as_slice(),
|
||||||
|
0.35,
|
||||||
|
),
|
||||||
|
// Brown: r highest, g mid, b low.
|
||||||
|
FloorGenerator::Dirt => (
|
||||||
|
Rgba8 {
|
||||||
|
r: shade(rng, 45, 75),
|
||||||
|
g: shade(rng, 30, 50),
|
||||||
|
b: shade(rng, 18, 32),
|
||||||
|
a: 255,
|
||||||
|
},
|
||||||
|
['.', ':', ',', ';'].as_slice(),
|
||||||
|
0.20,
|
||||||
|
),
|
||||||
|
// Gray: all channels share one shade.
|
||||||
|
FloorGenerator::Stone => {
|
||||||
|
let v = shade(rng, 40, 70);
|
||||||
|
(
|
||||||
|
Rgba8 {
|
||||||
|
r: v,
|
||||||
|
g: v,
|
||||||
|
b: v,
|
||||||
|
a: 255,
|
||||||
|
},
|
||||||
|
['.', ','].as_slice(),
|
||||||
|
0.20,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Decide whether this cell shows a texture char or just bare ground.
|
||||||
|
if rng.next_bool(Probability::new(prob)) {
|
||||||
|
let ch = chars[rng.next_lim_usize(chars.len())];
|
||||||
|
Glyph {
|
||||||
|
tile: ch as u32,
|
||||||
|
fg: lighten(bg, 35), // a lighter shade of the same ground
|
||||||
|
bg,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Bare ground: a space (its fg never shows).
|
||||||
|
Glyph {
|
||||||
|
tile: 32,
|
||||||
|
fg: bg,
|
||||||
|
bg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a random channel value in `[lo, hi]` (inclusive).
|
||||||
|
fn shade(rng: &mut StdRand, lo: u8, hi: u8) -> u8 {
|
||||||
|
lo + rng.next_lim_usize((hi - lo) as usize + 1) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brightens each color channel by `amt`, saturating at 255 — used to draw a
|
||||||
|
/// texture char as a lighter version of its ground color.
|
||||||
|
fn lighten(c: Rgba8, amt: u8) -> Rgba8 {
|
||||||
|
Rgba8 {
|
||||||
|
r: c.r.saturating_add(amt),
|
||||||
|
g: c.g.saturating_add(amt),
|
||||||
|
b: c.b.saturating_add(amt),
|
||||||
|
a: c.a,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fixed floor glyph declared in a map file: visual only, no archetype.
|
||||||
|
///
|
||||||
|
/// Reuses [`TileIndex`] (so `tile` accepts a char or an int) and `"#RRGGBB"`
|
||||||
|
/// color strings, exactly like a `[palette]` entry minus the archetype.
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
pub struct FloorGlyph {
|
||||||
|
/// Tile index into the board's font. Accepts an integer or single-char string.
|
||||||
|
pub tile: TileIndex,
|
||||||
|
/// Foreground color as an `"#RRGGBB"` hex string.
|
||||||
|
pub fg: String,
|
||||||
|
/// Background color as an `"#RRGGBB"` hex string.
|
||||||
|
pub bg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FloorGlyph {
|
||||||
|
/// Converts this declaration into a concrete [`Glyph`].
|
||||||
|
fn to_glyph(&self) -> Glyph {
|
||||||
|
Glyph {
|
||||||
|
tile: self.tile.into_u32(),
|
||||||
|
fg: parse_color(&self.fg),
|
||||||
|
bg: parse_color(&self.bg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry in a floor grid's palette: either a generator name or a fixed glyph.
|
||||||
|
///
|
||||||
|
/// `#[serde(untagged)]`: a TOML string parses as [`FloorTile::Generator`]; a TOML
|
||||||
|
/// table parses as [`FloorTile::Glyph`].
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum FloorTile {
|
||||||
|
/// A generator name, e.g. `"grass"`.
|
||||||
|
Generator(String),
|
||||||
|
/// A fixed glyph definition.
|
||||||
|
Glyph(FloorGlyph),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The optional `[floor]` declaration in a map file, in one of four shapes.
|
||||||
|
///
|
||||||
|
/// `#[serde(untagged)]`, so the variants are distinguished by TOML shape — order
|
||||||
|
/// matters: a string hits [`FloorSpec::Generator`]; a table with a `tile` key hits
|
||||||
|
/// [`FloorSpec::Single`]; a table with a `content` key hits [`FloorSpec::Grid`].
|
||||||
|
/// (Absence of a `[floor]` section is represented by `Option::None` on the map
|
||||||
|
/// file, not a variant here, and yields a default black-on-black floor.)
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum FloorSpec {
|
||||||
|
/// A single generator name covering the whole board, e.g. `floor = "grass"`.
|
||||||
|
Generator(String),
|
||||||
|
/// A single fixed glyph filling every floor cell.
|
||||||
|
Single(FloorGlyph),
|
||||||
|
/// A grid (like `[grid]`) with its own palette of generators and/or glyphs.
|
||||||
|
Grid {
|
||||||
|
/// Multi-line grid string; one char per cell, looked up in `palette`.
|
||||||
|
content: String,
|
||||||
|
/// Char → [`FloorTile`] lookup for the grid.
|
||||||
|
palette: HashMap<String, FloorTile>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canonical "empty" floor glyph: a black-on-black space. This is the default
|
||||||
|
/// floor (when a map declares no `[floor]`) and the fallback for any cell that
|
||||||
|
/// can't be resolved.
|
||||||
|
fn empty_glyph() -> Glyph {
|
||||||
|
Archetype::Empty.default_glyph()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the cached per-cell floor `Vec<Glyph>` (row-major, `width * height`)
|
||||||
|
/// from an optional [`FloorSpec`].
|
||||||
|
///
|
||||||
|
/// Best-effort and nonfatal: any problem (unknown generator/char, grid-dimension
|
||||||
|
/// mismatch) is recorded on `errors` and that cell falls back to [`empty_glyph`],
|
||||||
|
/// so the board still loads. With no spec, the floor is all black-on-black space
|
||||||
|
/// (the previous behavior, where `Empty` cells rendered as nothing).
|
||||||
|
pub fn build_floor(
|
||||||
|
spec: &Option<FloorSpec>,
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
errors: &mut Vec<LogLine>,
|
||||||
|
) -> Vec<Glyph> {
|
||||||
|
let mut rng = StdRand::seed(FLOOR_SEED);
|
||||||
|
let count = width * height;
|
||||||
|
match spec {
|
||||||
|
// No declaration: the historical default of black-on-black whitespace.
|
||||||
|
None => vec![empty_glyph(); count],
|
||||||
|
// One generator for the whole board: run it once per cell.
|
||||||
|
Some(FloorSpec::Generator(name)) => match FloorGenerator::from_name(name) {
|
||||||
|
Some(generator) => (0..count).map(|_| generator.generate(&mut rng)).collect(),
|
||||||
|
None => {
|
||||||
|
errors.push(LogLine::error(format!("unknown floor generator '{name}'")));
|
||||||
|
vec![empty_glyph(); count]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// One fixed glyph: parse once, fill every cell.
|
||||||
|
Some(FloorSpec::Single(glyph)) => vec![glyph.to_glyph(); count],
|
||||||
|
// A grid with its own palette of generators and/or fixed glyphs.
|
||||||
|
Some(FloorSpec::Grid { content, palette }) => {
|
||||||
|
build_grid_floor(content, palette, width, height, &mut rng, errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the floor for a [`FloorSpec::Grid`]: each grid character is looked up in
|
||||||
|
/// `palette` and resolved to a glyph (generators run per-cell). Dimension
|
||||||
|
/// mismatches and unknown chars/generators are reported once and fall back to the
|
||||||
|
/// empty glyph.
|
||||||
|
fn build_grid_floor(
|
||||||
|
content: &str,
|
||||||
|
palette: &HashMap<String, FloorTile>,
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
rng: &mut StdRand,
|
||||||
|
errors: &mut Vec<LogLine>,
|
||||||
|
) -> Vec<Glyph> {
|
||||||
|
// Key the palette by char for O(1) per-cell lookup.
|
||||||
|
let by_char: HashMap<char, &FloorTile> = palette
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, v)| k.chars().next().map(|c| (c, v)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows: Vec<&str> = content.lines().collect();
|
||||||
|
if rows.len() != height {
|
||||||
|
errors.push(LogLine::error(format!(
|
||||||
|
"floor grid has {} rows but the board is {height} tall; missing cells use empty floor",
|
||||||
|
rows.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report each unknown char / bad generator only once, not per occurrence.
|
||||||
|
let mut reported: HashSet<char> = HashSet::new();
|
||||||
|
let mut floor = Vec::with_capacity(width * height);
|
||||||
|
for y in 0..height {
|
||||||
|
// `chars().nth(x)` is O(x) but floors are small; this keeps the lookup
|
||||||
|
// correct for multi-byte chars (a byte index would not).
|
||||||
|
let row: Vec<char> = rows.get(y).map(|r| r.chars().collect()).unwrap_or_default();
|
||||||
|
for x in 0..width {
|
||||||
|
let glyph = match row.get(x) {
|
||||||
|
Some(&ch) => match by_char.get(&ch) {
|
||||||
|
Some(FloorTile::Glyph(g)) => g.to_glyph(),
|
||||||
|
Some(FloorTile::Generator(name)) => match FloorGenerator::from_name(name) {
|
||||||
|
Some(generator) => generator.generate(rng),
|
||||||
|
None => {
|
||||||
|
if reported.insert(ch) {
|
||||||
|
errors.push(LogLine::error(format!(
|
||||||
|
"floor palette '{ch}' names unknown generator '{name}'; using empty floor"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
empty_glyph()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
if reported.insert(ch) {
|
||||||
|
errors.push(LogLine::error(format!(
|
||||||
|
"unknown floor grid character '{ch}'; using empty floor"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
empty_glyph()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Row missing or too short: padded with empty floor (already reported above).
|
||||||
|
None => empty_glyph(),
|
||||||
|
};
|
||||||
|
floor.push(glyph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
floor
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Parses a `FloorSpec` from a standalone TOML snippet of the form
|
||||||
|
/// `floor = ...` / `[floor]\n...`, returning the parsed spec.
|
||||||
|
fn parse_spec(toml: &str) -> FloorSpec {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Wrap {
|
||||||
|
floor: FloorSpec,
|
||||||
|
}
|
||||||
|
toml::from_str::<Wrap>(toml)
|
||||||
|
.expect("parse floor spec")
|
||||||
|
.floor
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_spec_yields_all_empty_floor() {
|
||||||
|
let mut errs = Vec::new();
|
||||||
|
let floor = build_floor(&None, 3, 2, &mut errs);
|
||||||
|
assert_eq!(floor.len(), 6);
|
||||||
|
assert!(floor.iter().all(|g| *g == empty_glyph()));
|
||||||
|
assert!(errs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_glyph_fills_every_cell() {
|
||||||
|
let spec = parse_spec("[floor]\ntile = \".\"\nfg = \"#112233\"\nbg = \"#445566\"\n");
|
||||||
|
let mut errs = Vec::new();
|
||||||
|
let floor = build_floor(&Some(spec), 2, 2, &mut errs);
|
||||||
|
let expected = Glyph {
|
||||||
|
tile: '.' as u32,
|
||||||
|
fg: parse_color("#112233"),
|
||||||
|
bg: parse_color("#445566"),
|
||||||
|
};
|
||||||
|
assert_eq!(floor.len(), 4);
|
||||||
|
assert!(floor.iter().all(|g| *g == expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grass_generator_stays_in_scheme_and_is_deterministic() {
|
||||||
|
let mut errs = Vec::new();
|
||||||
|
let a = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs);
|
||||||
|
let b = build_floor(&Some(FloorSpec::Generator("grass".into())), 8, 8, &mut errs);
|
||||||
|
assert_eq!(a.len(), 64);
|
||||||
|
// Same seed → identical floors across builds.
|
||||||
|
assert!(a.iter().zip(&b).all(|(x, y)| x == y));
|
||||||
|
// Every cell's ground (bg) sits inside grass's channel ranges.
|
||||||
|
for g in &a {
|
||||||
|
assert!((20..=55).contains(&g.bg.r), "r out of range: {}", g.bg.r);
|
||||||
|
assert!((45..=85).contains(&g.bg.g), "g out of range: {}", g.bg.g);
|
||||||
|
assert!((20..=35).contains(&g.bg.b), "b out of range: {}", g.bg.b);
|
||||||
|
}
|
||||||
|
assert!(errs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_generator_falls_back_and_reports() {
|
||||||
|
let mut errs = Vec::new();
|
||||||
|
let floor = build_floor(&Some(FloorSpec::Generator("lava".into())), 2, 1, &mut errs);
|
||||||
|
assert!(floor.iter().all(|g| *g == empty_glyph()));
|
||||||
|
assert_eq!(errs.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grid_resolves_glyph_and_generator_entries() {
|
||||||
|
let spec = parse_spec(
|
||||||
|
"[floor]\ncontent = \"\"\"\ng.\n.g\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n\".\" = { tile = \"#\", fg = \"#010203\", bg = \"#040506\" }\n",
|
||||||
|
);
|
||||||
|
let mut errs = Vec::new();
|
||||||
|
let floor = build_floor(&Some(spec), 2, 2, &mut errs);
|
||||||
|
assert!(errs.is_empty());
|
||||||
|
let fixed = Glyph {
|
||||||
|
tile: '#' as u32,
|
||||||
|
fg: parse_color("#010203"),
|
||||||
|
bg: parse_color("#040506"),
|
||||||
|
};
|
||||||
|
// (0,0) and (1,1) are grass; (1,0) and (0,1) are the fixed glyph.
|
||||||
|
assert_eq!(floor[1], fixed);
|
||||||
|
assert_eq!(floor[2], fixed);
|
||||||
|
assert!((20..=55).contains(&floor[0].bg.r)); // grass ground
|
||||||
|
assert!((20..=55).contains(&floor[3].bg.r));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grid_dimension_mismatch_is_nonfatal() {
|
||||||
|
// Declares 1 row for a 2-tall board: missing row is padded with empty floor.
|
||||||
|
let spec = parse_spec(
|
||||||
|
"[floor]\ncontent = \"\"\"\ngg\n\"\"\"\n[floor.palette]\n\"g\" = \"grass\"\n",
|
||||||
|
);
|
||||||
|
let mut errs = Vec::new();
|
||||||
|
let floor = build_floor(&Some(spec), 2, 2, &mut errs);
|
||||||
|
assert_eq!(floor.len(), 4);
|
||||||
|
assert_eq!(errs.len(), 1); // the row-count mismatch was reported
|
||||||
|
assert_eq!(floor[2], empty_glyph()); // padded second row
|
||||||
|
assert_eq!(floor[3], empty_glyph());
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
-7
@@ -20,7 +20,7 @@ use std::time::Duration;
|
|||||||
/// `Glyph` values come from the map file palette and are set at load time.
|
/// `Glyph` values come from the map file palette and are set at load time.
|
||||||
/// The player is the only entity whose glyph is hardcoded at runtime
|
/// The player is the only entity whose glyph is hardcoded at runtime
|
||||||
/// (see [`Glyph::player`]).
|
/// (see [`Glyph::player`]).
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
pub struct Glyph {
|
pub struct Glyph {
|
||||||
/// Tile index into the board's bitmap font (left-to-right, top-to-bottom).
|
/// Tile index into the board's bitmap font (left-to-right, top-to-bottom).
|
||||||
pub tile: u32,
|
pub tile: u32,
|
||||||
@@ -428,6 +428,16 @@ pub struct Board {
|
|||||||
/// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to
|
/// Row-major grid of `(Glyph, Archetype)` pairs. Use [`Board::get`] to
|
||||||
/// access by `(x, y)` coordinates.
|
/// access by `(x, y)` coordinates.
|
||||||
pub(crate) cells: Vec<(Glyph, Archetype)>,
|
pub(crate) cells: Vec<(Glyph, Archetype)>,
|
||||||
|
/// Row-major cache of the visual floor layer: the glyph drawn for a cell when
|
||||||
|
/// it would otherwise render as [`Archetype::Empty`]. Computed once at load
|
||||||
|
/// from [`floor_spec`](Board::floor_spec) (see [`crate::floor::build_floor`]).
|
||||||
|
/// When a map declares no `[floor]`, every entry is black-on-black space (the
|
||||||
|
/// historical look of an empty cell). Read it via [`Board::display_glyph`].
|
||||||
|
pub(crate) floor: Vec<Glyph>,
|
||||||
|
/// The raw `[floor]` declaration, kept so [`crate::map_file::save`] can
|
||||||
|
/// round-trip it. `None` when the map declared no floor. (Generators
|
||||||
|
/// re-randomize on reload; literal glyph grids are preserved exactly.)
|
||||||
|
pub(crate) floor_spec: Option<crate::floor::FloorSpec>,
|
||||||
/// Current player position. See [`Player`] for caveats about its future.
|
/// Current player position. See [`Player`] for caveats about its future.
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
/// Scripted objects on this board. Parsed from the map file; not yet active.
|
/// Scripted objects on this board. Parsed from the map file; not yet active.
|
||||||
@@ -472,6 +482,23 @@ impl Board {
|
|||||||
&mut self.cells[y * self.width + x]
|
&mut self.cells[y * self.width + x]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the glyph to draw for the **static layer** of cell `(x, y)` — the
|
||||||
|
/// grid archetype, or the floor underneath an [`Archetype::Empty`] cell.
|
||||||
|
///
|
||||||
|
/// This is the single source of truth front-ends use for the non-object,
|
||||||
|
/// non-player layer: a wall/crate draws its own per-cell glyph, while an empty
|
||||||
|
/// cell draws its [`floor`](Board::floor) glyph. An `Empty` cell's *own* glyph
|
||||||
|
/// is intentionally ignored — the floor layer supersedes it (so a cell vacated
|
||||||
|
/// by a pushed crate reveals the floor, not black). Panics if out of bounds.
|
||||||
|
pub fn display_glyph(&self, x: usize, y: usize) -> Glyph {
|
||||||
|
let (glyph, arch) = self.get(x, y);
|
||||||
|
if *arch == Archetype::Empty {
|
||||||
|
self.floor[y * self.width + x]
|
||||||
|
} else {
|
||||||
|
*glyph
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
|
/// Returns `true` if `(x, y)` is a valid cell coordinate on this board.
|
||||||
///
|
///
|
||||||
/// Takes signed coords so callers can pass a raw `pos + delta` without first
|
/// Takes signed coords so callers can pass a raw `pos + delta` without first
|
||||||
@@ -836,6 +863,8 @@ mod tests {
|
|||||||
width: 1,
|
width: 1,
|
||||||
height: 1,
|
height: 1,
|
||||||
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
|
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty)],
|
||||||
|
floor: vec![Archetype::Empty.default_glyph()],
|
||||||
|
floor_spec: None,
|
||||||
player: Player { x: 0, y: 0 },
|
player: Player { x: 0, y: 0 },
|
||||||
objects: vec![object],
|
objects: vec![object],
|
||||||
portals: Vec::new(),
|
portals: Vec::new(),
|
||||||
@@ -872,6 +901,8 @@ mod tests {
|
|||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
|
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
|
||||||
|
floor: vec![Archetype::Empty.default_glyph(); w * h],
|
||||||
|
floor_spec: None,
|
||||||
player: Player {
|
player: Player {
|
||||||
x: player.0,
|
x: player.0,
|
||||||
y: player.1,
|
y: player.1,
|
||||||
@@ -1352,16 +1383,25 @@ mod tests {
|
|||||||
let mut game = GameState::new(board);
|
let mut game = GameState::new(board);
|
||||||
game.run_init();
|
game.run_init();
|
||||||
// First (eastward) move is blocked by the wall: object hasn't moved.
|
// First (eastward) move is blocked by the wall: object hasn't moved.
|
||||||
assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 1));
|
assert_eq!(
|
||||||
|
(game.board().objects[0].x, game.board().objects[0].y),
|
||||||
|
(1, 1)
|
||||||
|
);
|
||||||
|
|
||||||
// The blocked move charged the cooldown, so South is still pending here.
|
// The blocked move charged the cooldown, so South is still pending here.
|
||||||
game.tick(Duration::from_millis(100));
|
game.tick(Duration::from_millis(100));
|
||||||
game.tick(Duration::from_millis(100));
|
game.tick(Duration::from_millis(100));
|
||||||
assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 1));
|
assert_eq!(
|
||||||
|
(game.board().objects[0].x, game.board().objects[0].y),
|
||||||
|
(1, 1)
|
||||||
|
);
|
||||||
|
|
||||||
// Past 250 ms the queued South move resolves.
|
// Past 250 ms the queued South move resolves.
|
||||||
game.tick(Duration::from_millis(100));
|
game.tick(Duration::from_millis(100));
|
||||||
assert_eq!((game.board().objects[0].x, game.board().objects[0].y), (1, 2));
|
assert_eq!(
|
||||||
|
(game.board().objects[0].x, game.board().objects[0].y),
|
||||||
|
(1, 2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1373,7 +1413,10 @@ mod tests {
|
|||||||
1,
|
1,
|
||||||
(0, 0),
|
(0, 0),
|
||||||
vec![scripted_object(1, 0, "q")],
|
vec![scripted_object(1, 0, "q")],
|
||||||
&[("q", "fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }")],
|
&[(
|
||||||
|
"q",
|
||||||
|
"fn init() { set_tile(5); set_tile(6); log(`len=${Queue.length()}`); }",
|
||||||
|
)],
|
||||||
);
|
);
|
||||||
let mut game = GameState::new(board);
|
let mut game = GameState::new(board);
|
||||||
game.run_init();
|
game.run_init();
|
||||||
@@ -1405,7 +1448,10 @@ mod tests {
|
|||||||
1,
|
1,
|
||||||
(0, 0),
|
(0, 0),
|
||||||
vec![scripted_object(1, 0, "b")],
|
vec![scripted_object(1, 0, "b")],
|
||||||
&[("b", "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }")],
|
&[(
|
||||||
|
"b",
|
||||||
|
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||||
|
)],
|
||||||
);
|
);
|
||||||
wall_at(&mut board, 2, 0);
|
wall_at(&mut board, 2, 0);
|
||||||
let mut game = GameState::new(board);
|
let mut game = GameState::new(board);
|
||||||
@@ -1418,7 +1464,10 @@ mod tests {
|
|||||||
1,
|
1,
|
||||||
(0, 0),
|
(0, 0),
|
||||||
vec![scripted_object(1, 0, "b")],
|
vec![scripted_object(1, 0, "b")],
|
||||||
&[("b", "fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }")],
|
&[(
|
||||||
|
"b",
|
||||||
|
"fn init() { if blocked(East) { set_tile(9); } else { set_tile(7); } }",
|
||||||
|
)],
|
||||||
);
|
);
|
||||||
let mut game = GameState::new(board);
|
let mut game = GameState::new(board);
|
||||||
game.run_init();
|
game.run_init();
|
||||||
@@ -1507,6 +1556,63 @@ mod tests {
|
|||||||
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1"));
|
assert!(log_texts(&game).iter().any(|t| t == "bumped by -1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_glyph_uses_floor_for_empty_and_grid_for_solid() {
|
||||||
|
// A board with a distinctive floor glyph; empty cells show the floor, while
|
||||||
|
// a wall keeps drawing its own grid glyph (floor ignored under solids).
|
||||||
|
let mut board = open_board(2, 1, (1, 0), vec![], &[]);
|
||||||
|
let floor_glyph = Glyph {
|
||||||
|
tile: '.' as u32,
|
||||||
|
fg: Rgba8 {
|
||||||
|
r: 10,
|
||||||
|
g: 20,
|
||||||
|
b: 30,
|
||||||
|
a: 255,
|
||||||
|
},
|
||||||
|
bg: Rgba8 {
|
||||||
|
r: 1,
|
||||||
|
g: 2,
|
||||||
|
b: 3,
|
||||||
|
a: 255,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
board.floor = vec![floor_glyph; 2];
|
||||||
|
wall_at(&mut board, 0, 0);
|
||||||
|
assert_eq!(board.display_glyph(0, 0), Archetype::Wall.default_glyph()); // solid: grid glyph
|
||||||
|
assert_eq!(board.display_glyph(1, 0), floor_glyph); // empty: floor glyph
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pushing_a_crate_reveals_the_floor_underneath() {
|
||||||
|
// The cell a crate is pushed off of becomes Empty, so its `display_glyph`
|
||||||
|
// should show the floor glyph rather than black-on-black.
|
||||||
|
let mut board = open_board(4, 1, (0, 0), vec![], &[]);
|
||||||
|
let floor_glyph = Glyph {
|
||||||
|
tile: ',' as u32,
|
||||||
|
fg: Rgba8 {
|
||||||
|
r: 40,
|
||||||
|
g: 60,
|
||||||
|
b: 40,
|
||||||
|
a: 255,
|
||||||
|
},
|
||||||
|
bg: Rgba8 {
|
||||||
|
r: 5,
|
||||||
|
g: 10,
|
||||||
|
b: 5,
|
||||||
|
a: 255,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
board.floor = vec![floor_glyph; 4];
|
||||||
|
crate_at(&mut board, 1, 0);
|
||||||
|
let mut game = GameState::new(board);
|
||||||
|
game.try_move(Direction::East); // player at (0,0) pushes the crate east
|
||||||
|
let b = game.board();
|
||||||
|
assert_eq!(b.get(2, 0).1, Archetype::Crate); // crate moved one east
|
||||||
|
// The vacated cell (1,0) is now Empty and reveals the floor.
|
||||||
|
assert_eq!(b.get(1, 0).1, Archetype::Empty);
|
||||||
|
assert_eq!(b.display_glyph(1, 0), floor_glyph);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn solid_at_reports_player() {
|
fn solid_at_reports_player() {
|
||||||
// The player's own cell is solid (and thus impassable to movers).
|
// The player's own cell is solid (and thus impassable to movers).
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/// The optional per-cell visual floor layer ([`floor::FloorSpec`], [`floor::build_floor`]).
|
||||||
|
pub mod floor;
|
||||||
/// Core game types: [`game::Board`], [`game::Glyph`], [`game::Archetype`], etc.
|
/// Core game types: [`game::Board`], [`game::Glyph`], [`game::Archetype`], etc.
|
||||||
pub mod game;
|
pub mod game;
|
||||||
/// Styled log messages ([`log::LogLine`]) for the in-game message feed.
|
/// Styled log messages ([`log::LogLine`]) for the in-game message feed.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::floor::{FloorSpec, build_floor};
|
||||||
use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef};
|
use crate::game::{Archetype, Board, FontSpec, Glyph, ObjectDef, Player, PortalDef};
|
||||||
use crate::log::LogLine;
|
use crate::log::LogLine;
|
||||||
use color::Rgba8;
|
use color::Rgba8;
|
||||||
@@ -25,6 +26,10 @@ pub struct MapFile {
|
|||||||
/// Optional `[font]` section specifying a bitmap font for this board.
|
/// Optional `[font]` section specifying a bitmap font for this board.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub font: Option<FontHeader>,
|
pub font: Option<FontHeader>,
|
||||||
|
/// Optional `[floor]` declaration: the visual floor layer (a generator name,
|
||||||
|
/// a single glyph, or a grid with its own palette). See [`FloorSpec`].
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub floor: Option<FloorSpec>,
|
||||||
/// Any `[[objects]]` entries. Optional; defaults to empty.
|
/// Any `[[objects]]` entries. Optional; defaults to empty.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub(crate) objects: Vec<MapFileObjectEntry>,
|
pub(crate) objects: Vec<MapFileObjectEntry>,
|
||||||
@@ -76,9 +81,9 @@ pub struct FontHeader {
|
|||||||
/// `tile = " "` for convenience (the char is converted to its Unicode scalar).
|
/// `tile = " "` for convenience (the char is converted to its Unicode scalar).
|
||||||
/// This means existing maps that used `ch = "#"` can be migrated by renaming
|
/// This means existing maps that used `ch = "#"` can be migrated by renaming
|
||||||
/// the key to `tile`; the char form keeps working.
|
/// the key to `tile`; the char form keeps working.
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize, Clone, Copy)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub(crate) enum TileIndex {
|
pub enum TileIndex {
|
||||||
/// A direct tile index (e.g. `tile = 35`).
|
/// A direct tile index (e.g. `tile = 35`).
|
||||||
Num(u32),
|
Num(u32),
|
||||||
/// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar.
|
/// A single-character shorthand (e.g. `tile = "#"`); converted to its Unicode scalar.
|
||||||
@@ -195,7 +200,7 @@ fn is_zoom_default(z: &u32) -> bool {
|
|||||||
|
|
||||||
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`].
|
/// Parses an `"#RRGGBB"` hex color string into an [`Rgba8`].
|
||||||
/// Returns opaque black on any parse failure.
|
/// Returns opaque black on any parse failure.
|
||||||
fn parse_color(hex: &str) -> Rgba8 {
|
pub(crate) fn parse_color(hex: &str) -> Rgba8 {
|
||||||
let hex = hex.trim_start_matches('#');
|
let hex = hex.trim_start_matches('#');
|
||||||
if hex.len() != 6 {
|
if hex.len() != 6 {
|
||||||
return Rgba8 {
|
return Rgba8 {
|
||||||
@@ -212,7 +217,7 @@ fn parse_color(hex: &str) -> Rgba8 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Converts an [`Rgba8`] to an `"#RRGGBB"` hex string (alpha is ignored).
|
/// Converts an [`Rgba8`] to an `"#RRGGBB"` hex string (alpha is ignored).
|
||||||
fn color_to_hex(color: Rgba8) -> String {
|
pub(crate) fn color_to_hex(color: Rgba8) -> String {
|
||||||
format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b)
|
format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +414,12 @@ impl TryFrom<MapFile> for Board {
|
|||||||
tile_h: f.tile_h,
|
tile_h: f.tile_h,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build the cached floor layer from the (optional) `[floor]` declaration.
|
||||||
|
// Best-effort: any problem is recorded on `load_errors`, not fatal. The
|
||||||
|
// raw spec is kept on the board so a save can round-trip it.
|
||||||
|
let floor_spec = mf.floor;
|
||||||
|
let floor = build_floor(&floor_spec, w, h, &mut load_errors);
|
||||||
|
|
||||||
// Convert MapFileObjectEntry → ObjectDef, enforcing one solid per cell.
|
// Convert MapFileObjectEntry → ObjectDef, enforcing one solid per cell.
|
||||||
// `solid_occupied` is seeded from the grid (solid archetypes); each solid
|
// `solid_occupied` is seeded from the grid (solid archetypes); each solid
|
||||||
// object then claims its cell, and a second solid on a cell is dropped.
|
// object then claims its cell, and a second solid on a cell is dropped.
|
||||||
@@ -513,6 +524,8 @@ impl TryFrom<MapFile> for Board {
|
|||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
cells,
|
cells,
|
||||||
|
floor,
|
||||||
|
floor_spec,
|
||||||
player: Player {
|
player: Player {
|
||||||
x: px as i32,
|
x: px as i32,
|
||||||
y: py as i32,
|
y: py as i32,
|
||||||
@@ -648,6 +661,9 @@ impl From<&Board> for MapFile {
|
|||||||
palette,
|
palette,
|
||||||
grid: GridData { content },
|
grid: GridData { content },
|
||||||
font,
|
font,
|
||||||
|
// Round-trip the original floor declaration verbatim (generators
|
||||||
|
// re-randomize on the next load; literal glyph grids are exact).
|
||||||
|
floor: board.floor_spec.clone(),
|
||||||
objects,
|
objects,
|
||||||
portals,
|
portals,
|
||||||
scripts: board.scripts.clone(),
|
scripts: board.scripts.clone(),
|
||||||
@@ -1026,4 +1042,52 @@ bg = "#000000"
|
|||||||
assert_eq!(obj2.glyph.fg, obj.glyph.fg);
|
assert_eq!(obj2.glyph.fg, obj.glyph.fg);
|
||||||
assert_eq!(obj2.glyph.bg, obj.glyph.bg);
|
assert_eq!(obj2.glyph.bg, obj.glyph.bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn floor_spec_round_trips_through_toml() {
|
||||||
|
// A 2×2 board with a grid floor (a generator and a fixed glyph) must survive
|
||||||
|
// save→load with its `[floor]` declaration intact, and the reloaded board's
|
||||||
|
// computed floor must place the fixed glyph back at its declared cell.
|
||||||
|
let toml = r##"
|
||||||
|
[map]
|
||||||
|
name = "Test"
|
||||||
|
width = 2
|
||||||
|
height = 2
|
||||||
|
player_start = [0, 0]
|
||||||
|
|
||||||
|
[palette]
|
||||||
|
"." = { archetype = "empty", tile = 46, fg = "#000000", bg = "#000000" }
|
||||||
|
|
||||||
|
[grid]
|
||||||
|
content = """
|
||||||
|
..
|
||||||
|
..
|
||||||
|
"""
|
||||||
|
|
||||||
|
[floor]
|
||||||
|
content = """
|
||||||
|
g.
|
||||||
|
.g
|
||||||
|
"""
|
||||||
|
|
||||||
|
[floor.palette]
|
||||||
|
"g" = "grass"
|
||||||
|
"." = { tile = "#", fg = "#010203", bg = "#040506" }
|
||||||
|
"##;
|
||||||
|
let board = load_board(toml);
|
||||||
|
assert!(board.floor_spec.is_some());
|
||||||
|
let fixed = Glyph {
|
||||||
|
tile: '#' as u32,
|
||||||
|
fg: parse_color("#010203"),
|
||||||
|
bg: parse_color("#040506"),
|
||||||
|
};
|
||||||
|
// (1,0) is the fixed `.` floor glyph (the cell archetype is Empty).
|
||||||
|
assert_eq!(board.display_glyph(1, 0), fixed);
|
||||||
|
|
||||||
|
// Save back to TOML and reload; the floor declaration must be preserved.
|
||||||
|
let toml_out = toml::to_string_pretty(&MapFile::from(&board)).unwrap();
|
||||||
|
let board2 = load_board(&toml_out);
|
||||||
|
assert!(board2.floor_spec.is_some());
|
||||||
|
assert_eq!(board2.display_glyph(1, 0), fixed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-20
@@ -212,16 +212,16 @@ impl ScriptHost {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
failed.insert(name.clone());
|
failed.insert(name.clone());
|
||||||
errors
|
errors.borrow_mut().push(LogLine::raw(format!(
|
||||||
.borrow_mut()
|
"script '{name}' failed to compile: {err}"
|
||||||
.push(LogLine::raw(format!("script '{name}' failed to compile: {err}")));
|
)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
failed.insert(name.clone());
|
failed.insert(name.clone());
|
||||||
errors
|
errors.borrow_mut().push(LogLine::raw(format!(
|
||||||
.borrow_mut()
|
"object references unknown script '{name}'"
|
||||||
.push(LogLine::raw(format!("object references unknown script '{name}'")));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,12 +294,17 @@ impl ScriptHost {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let options = CallFnOptions::default().with_tag(object_index as i64);
|
let options = CallFnOptions::default().with_tag(object_index as i64);
|
||||||
if let Err(err) =
|
if let Err(err) = engine.call_fn_with_options::<()>(
|
||||||
engine.call_fn_with_options::<()>(options, &mut obj.scope, &compiled.ast, "bump", (bumper,))
|
options,
|
||||||
{
|
&mut obj.scope,
|
||||||
errors
|
&compiled.ast,
|
||||||
.borrow_mut()
|
"bump",
|
||||||
.push(LogLine::raw(format!("script '{}' bump error: {err}", obj.script_name)));
|
(bumper,),
|
||||||
|
) {
|
||||||
|
errors.borrow_mut().push(LogLine::raw(format!(
|
||||||
|
"script '{}' bump error: {err}",
|
||||||
|
obj.script_name
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +352,10 @@ impl ScriptHost {
|
|||||||
if cost > 0.0 {
|
if cost > 0.0 {
|
||||||
self.objects[i].ready_timer = cost;
|
self.objects[i].ready_timer = cost;
|
||||||
}
|
}
|
||||||
self.board_queue.borrow_mut().push(BoardAction { source: oid, action });
|
self.board_queue.borrow_mut().push(BoardAction {
|
||||||
|
source: oid,
|
||||||
|
action,
|
||||||
|
});
|
||||||
// A timed action ends the pump; zero-cost ones keep draining.
|
// A timed action ends the pump; zero-cost ones keep draining.
|
||||||
if cost > 0.0 {
|
if cost > 0.0 {
|
||||||
break;
|
break;
|
||||||
@@ -360,7 +368,12 @@ impl ScriptHost {
|
|||||||
/// source), then pump that object so backlogged actions drain and later objects'
|
/// source), then pump that object so backlogged actions drain and later objects'
|
||||||
/// `blocked` checks can see earlier movers. Runtime errors are captured rather than
|
/// `blocked` checks can see earlier movers. Runtime errors are captured rather than
|
||||||
/// aborting the batch.
|
/// aborting the batch.
|
||||||
fn run<A: FuncArgs + Copy>(&mut self, hook: &str, defined: fn(&CompiledScript) -> bool, args: A) {
|
fn run<A: FuncArgs + Copy>(
|
||||||
|
&mut self,
|
||||||
|
hook: &str,
|
||||||
|
defined: fn(&CompiledScript) -> bool,
|
||||||
|
args: A,
|
||||||
|
) {
|
||||||
for i in 0..self.objects.len() {
|
for i in 0..self.objects.len() {
|
||||||
// Scope the split borrow so it ends before the `pump` reborrow below.
|
// Scope the split borrow so it ends before the `pump` reborrow below.
|
||||||
{
|
{
|
||||||
@@ -377,9 +390,13 @@ impl ScriptHost {
|
|||||||
{
|
{
|
||||||
// The tag carries this object's index to the host functions.
|
// The tag carries this object's index to the host functions.
|
||||||
let options = CallFnOptions::default().with_tag(obj.object_index as i64);
|
let options = CallFnOptions::default().with_tag(obj.object_index as i64);
|
||||||
if let Err(err) =
|
if let Err(err) = engine.call_fn_with_options::<()>(
|
||||||
engine.call_fn_with_options::<()>(options, &mut obj.scope, &compiled.ast, hook, args)
|
options,
|
||||||
{
|
&mut obj.scope,
|
||||||
|
&compiled.ast,
|
||||||
|
hook,
|
||||||
|
args,
|
||||||
|
) {
|
||||||
errors.borrow_mut().push(LogLine::raw(format!(
|
errors.borrow_mut().push(LogLine::raw(format!(
|
||||||
"script '{}' {hook} error: {err}",
|
"script '{}' {hook} error: {err}",
|
||||||
obj.script_name
|
obj.script_name
|
||||||
@@ -461,9 +478,16 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let q = queues.clone();
|
let q = queues.clone();
|
||||||
engine.register_fn("log", move |ctx: NativeCallContext, msg: ImmutableString| {
|
engine.register_fn(
|
||||||
emit(&q, source_of(&ctx), Action::Log(LogLine::raw(msg.to_string())));
|
"log",
|
||||||
});
|
move |ctx: NativeCallContext, msg: ImmutableString| {
|
||||||
|
emit(
|
||||||
|
&q,
|
||||||
|
source_of(&ctx),
|
||||||
|
Action::Log(LogLine::raw(msg.to_string())),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers the `Queue` object: a handle to an object's output queue with `length()`
|
/// Registers the `Queue` object: a handle to an object's output queue with `length()`
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ impl Widget for BoardWidget<'_> {
|
|||||||
} else if let Some(obj) = board.object_at(bx, by) {
|
} else if let Some(obj) = board.object_at(bx, by) {
|
||||||
obj.glyph
|
obj.glyph
|
||||||
} else {
|
} else {
|
||||||
board.get(bx, by).0
|
// Static layer: the grid archetype's glyph, or the floor layer
|
||||||
|
// for an empty cell (see `Board::display_glyph`).
|
||||||
|
board.display_glyph(bx, by)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Paint the resolved glyph into the terminal cell.
|
// Paint the resolved glyph into the terminal cell.
|
||||||
|
|||||||
@@ -12,6 +12,46 @@ player_start = "@" # placed by the '@' grid char (convention)
|
|||||||
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" }
|
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" }
|
||||||
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" }
|
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" }
|
||||||
|
|
||||||
|
# Optional visual floor layer, drawn under every empty cell (and revealed when a
|
||||||
|
# crate is pushed away). This is a grid form with its own palette: `g`/`d`/`s`
|
||||||
|
# name the built-in generators (random dark, low-saturation texture cached at
|
||||||
|
# load); a palette entry could also be a fixed glyph like
|
||||||
|
# `{ tile = ".", fg = "#222", bg = "#000" }`.
|
||||||
|
[floor]
|
||||||
|
content = """
|
||||||
|
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggg gggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||||
|
gggggg gggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||||
|
gggggg gggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||||
|
gggggg gggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||||
|
gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||||
|
gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||||
|
gggggggggggggggggggggggggsggggdddddddddddddddddddddggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggsgggggggggggggggggggggggggggggggggg
|
||||||
|
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
|
||||||
|
"""
|
||||||
|
|
||||||
|
[floor.palette]
|
||||||
|
"g" = "grass"
|
||||||
|
"d" = "dirt"
|
||||||
|
"s" = "stone"
|
||||||
|
" " = { tile = "~", fg = "#6666ff", bg = "#000066" }
|
||||||
|
|
||||||
[grid]
|
[grid]
|
||||||
content = """
|
content = """
|
||||||
############################################################
|
############################################################
|
||||||
|
|||||||
Reference in New Issue
Block a user