This commit is contained in:
2026-06-13 21:33:38 -05:00
parent 299570bec8
commit 77eba1a09c
6 changed files with 127 additions and 18 deletions
+31 -1
View File
@@ -1,6 +1,6 @@
use color::Rgba8; use color::Rgba8;
use crate::glyph::Glyph; use crate::glyph::Glyph;
use crate::utils::{Behavior, Pushable}; use crate::utils::{Behavior, Direction, Pushable};
/// A class of board cell, encoding its default behavior and appearance. /// A class of board cell, encoding its default behavior and appearance.
/// ///
@@ -32,6 +32,10 @@ pub enum Archetype {
HCrate, HCrate,
/// A crate that can only be pushed north/south (vertical axis). Glyph ↕. /// A crate that can only be pushed north/south (vertical axis). Glyph ↕.
VCrate, VCrate,
/// A directional pusher — solid, opaque, non-pushable. Driven by a global
/// 500 ms heartbeat in [`GameState::tick`]: each fire shoves the occupant of
/// the adjacent cell in the facing direction one step.
Pusher(Direction),
/// Sentinel for map files that reference an unknown archetype name. /// Sentinel for map files that reference an unknown archetype name.
/// Renders as a yellow `?` on red to make the error visible in-game. /// Renders as a yellow `?` on red to make the error visible in-game.
ErrorBlock, ErrorBlock,
@@ -68,6 +72,11 @@ impl Archetype {
opaque: true, opaque: true,
pushable: Pushable::Vertical, pushable: Pushable::Vertical,
}, },
Archetype::Pusher(_) => Behavior {
solid: true,
opaque: true,
pushable: Pushable::No,
},
Archetype::ErrorBlock => Behavior { Archetype::ErrorBlock => Behavior {
solid: true, solid: true,
opaque: true, opaque: true,
@@ -84,6 +93,10 @@ impl Archetype {
Archetype::Crate => "crate", Archetype::Crate => "crate",
Archetype::HCrate => "hcrate", Archetype::HCrate => "hcrate",
Archetype::VCrate => "vcrate", Archetype::VCrate => "vcrate",
Archetype::Pusher(Direction::North) => "pusher_north",
Archetype::Pusher(Direction::South) => "pusher_south",
Archetype::Pusher(Direction::East) => "pusher_east",
Archetype::Pusher(Direction::West) => "pusher_west",
Archetype::ErrorBlock => "error_block", Archetype::ErrorBlock => "error_block",
} }
} }
@@ -120,6 +133,19 @@ impl Archetype {
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}, },
Archetype::Pusher(dir) => {
let tile = match dir {
Direction::North => 30, // CP437 ▲
Direction::South => 31, // CP437 ▼
Direction::East => 16, // CP437 ►
Direction::West => 17, // CP437 ◄
};
Glyph {
tile,
fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 },
bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 },
}
}
// Visually distinct so malformed map files are immediately obvious. // Visually distinct so malformed map files are immediately obvious.
Archetype::ErrorBlock => Glyph { Archetype::ErrorBlock => Glyph {
tile: 63, tile: 63,
@@ -146,6 +172,10 @@ impl TryFrom<&str> for Archetype {
"vcrate" => Ok(Archetype::VCrate), "vcrate" => Ok(Archetype::VCrate),
// "object" is intentionally absent: objects are not valid palette // "object" is intentionally absent: objects are not valid palette
// entries in map files. They live in [[objects]] with their own glyph. // entries in map files. They live in [[objects]] with their own glyph.
"pusher_north" => Ok(Archetype::Pusher(Direction::North)),
"pusher_south" => Ok(Archetype::Pusher(Direction::South)),
"pusher_east" => Ok(Archetype::Pusher(Direction::East)),
"pusher_west" => Ok(Archetype::Pusher(Direction::West)),
_ => Err(format!("unknown archetype: {name}")), _ => Err(format!("unknown archetype: {name}")),
} }
} }
+27
View File
@@ -254,6 +254,33 @@ impl Board {
} }
} }
/// Advances a [`Archetype::Pusher`] cell at `(x, y)` one step in its facing
/// direction. Reads the direction from the cell itself.
///
/// - If the target is passable: slides the pusher in.
/// - If the target has a pushable chain: pushes it first, then slides in.
/// - If blocked or out of bounds: no-op, returns `false`.
pub(crate) fn advance_pusher(&mut self, x: usize, y: usize) -> bool {
let Archetype::Pusher(dir) = self.get(x, y).1 else { return false; };
let (dx, dy): (i32, i32) = dir.into();
let tx = x as i32 + dx;
let ty = y as i32 + dy;
if !self.in_bounds((tx, ty)) {
return false;
}
let (tx, ty) = (tx as usize, ty as usize);
if self.is_passable(tx, ty) {
self.shift_solid(x, y, dx, dy);
true
} else if self.can_push(tx, ty, dir) {
self.push(tx, ty, dir); // clear the chain first
self.shift_solid(x, y, dx, dy);
true
} else {
false
}
}
/// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`. /// Moves the single solid occupant of `(x, y)` one step by `(dx, dy)`.
/// ///
/// A solid object is relocated; otherwise the grid archetype (a crate) is /// A solid object is relocated; otherwise the grid archetype (a crate) is
+38
View File
@@ -1,4 +1,5 @@
use crate::action::Action; use crate::action::Action;
use crate::archetype::Archetype;
use crate::board::Board; use crate::board::Board;
use crate::log::LogLine; use crate::log::LogLine;
use crate::script::ScriptHost; use crate::script::ScriptHost;
@@ -10,6 +11,9 @@ use crate::utils::{Direction, ObjectId, Player, ScriptArg, Solid};
/// How long a `say()` speech bubble stays on screen, in seconds. /// How long a `say()` speech bubble stays on screen, in seconds.
pub const SAY_DURATION: f64 = 3.0; pub const SAY_DURATION: f64 = 3.0;
/// How often pusher archetypes fire, in seconds.
const PUSHER_INTERVAL: f64 = 0.5;
// Re-export ScrollLine so kiln-tui can pattern-match scroll content without // Re-export ScrollLine so kiln-tui can pattern-match scroll content without
// accessing the private `action` module directly. // accessing the private `action` module directly.
pub use crate::action::ScrollLine; pub use crate::action::ScrollLine;
@@ -61,6 +65,9 @@ pub struct GameState {
/// by [`enter_board`](GameState::enter_board). Front-ends tick this down and /// by [`enter_board`](GameState::enter_board). Front-ends tick this down and
/// may block input or show a visual effect while it is `Some(t)` where `t > 0`. /// may block input or show a visual effect while it is `Some(t)` where `t > 0`.
pub board_transition: Option<f64>, pub board_transition: Option<f64>,
/// Countdown to the next global pusher heartbeat. When it reaches zero all
/// [`Archetype::Pusher`] cells fire and the timer resets to [`PUSHER_INTERVAL`].
pusher_timer: f64,
} }
impl GameState { impl GameState {
@@ -84,6 +91,7 @@ impl GameState {
speech_bubbles: Vec::new(), speech_bubbles: Vec::new(),
active_scroll: None, active_scroll: None,
board_transition: None, board_transition: None,
pusher_timer: PUSHER_INTERVAL,
}; };
state.drain_errors(); state.drain_errors();
state state
@@ -161,9 +169,39 @@ impl GameState {
// Expire speech bubbles before resolving new actions so a fresh say() // Expire speech bubbles before resolving new actions so a fresh say()
// this frame isn't immediately culled. // this frame isn't immediately culled.
self.speech_bubbles.retain_mut(|b| { b.remaining -= secs; b.remaining > 0.0 }); self.speech_bubbles.retain_mut(|b| { b.remaining -= secs; b.remaining > 0.0 });
self.tick_pushers(secs);
self.resolve(); self.resolve();
} }
/// Fires the global pusher heartbeat: every [`PUSHER_INTERVAL`] seconds each
/// [`Archetype::Pusher`] cell shoves the occupant directly in front of it.
///
/// Two-phase to avoid holding the board borrow while calling `push()` (which
/// needs `&mut Board`): phase A collects target coordinates, phase B applies them.
fn tick_pushers(&mut self, secs: f64) {
self.pusher_timer -= secs;
if self.pusher_timer > 0.0 {
return;
}
self.pusher_timer += PUSHER_INTERVAL;
let pushers: Vec<(usize, usize)> = {
let board = self.board();
let mut v = Vec::new();
for y in 0..board.height {
for x in 0..board.width {
if matches!(board.get(x, y).1, Archetype::Pusher(_)) {
v.push((x, y));
}
}
}
v
};
let mut board = self.board_mut();
for (x, y) in pushers {
board.advance_pusher(x, y);
}
}
/// Drains errors collected by the script host into the game log. /// Drains errors collected by the script host into the game log.
fn drain_errors(&mut self) { fn drain_errors(&mut self) {
// TODO: errors are only logged for now. This is the place to halt execution / // TODO: errors are only logged for now. This is the place to halt execution /
+25 -13
View File
@@ -101,21 +101,25 @@ pub enum PlayerStart {
/// ///
/// Each entry is keyed by a single character (e.g. `"#"` or `" "`). /// Each entry is keyed by a single character (e.g. `"#"` or `" "`).
/// That character is used in the `[grid]` content string to place tiles. /// That character is used in the `[grid]` content string to place tiles.
/// The entry defines both how the tile looks (`tile`, `fg`, `bg`) and which /// The entry defines which [`Archetype`] the cell uses for behavior; `tile`,
/// [`Archetype`] it uses for behavior. /// `fg`, and `bg` are optional and fall back to [`Archetype::default_glyph`]
/// when absent. On save, fields that match the archetype default are omitted.
/// ///
/// `tile` accepts either an integer (`tile = 35`) or a char (`tile = "#"`). /// `tile` accepts either an integer (`tile = 35`) or a char (`tile = "#"`).
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct PaletteEntry { pub struct PaletteEntry {
/// The archetype name for this tile, e.g. `"wall"` or `"empty"`. /// The archetype name for this tile, e.g. `"wall"` or `"empty"`.
pub archetype: String, pub archetype: String,
/// The tile index into the board's bitmap font. /// Tile index into the board's bitmap font. Omit to use the archetype default.
/// Accepts either an integer or a single-character string. /// Accepts either an integer or a single-character string.
tile: TileIndex, #[serde(default, skip_serializing_if = "Option::is_none")]
/// Foreground color as an `"#RRGGBB"` hex string. tile: Option<TileIndex>,
pub fg: String, /// Foreground color as an `"#RRGGBB"` hex string. Omit to use the archetype default.
/// Background color as an `"#RRGGBB"` hex string. #[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: String, pub fg: Option<String>,
/// Background color as an `"#RRGGBB"` hex string. Omit to use the archetype default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
} }
/// The `[grid]` section of a map file. /// The `[grid]` section of a map file.
@@ -216,12 +220,16 @@ fn make_glyph(tile: u32, fg: &str, bg: &str) -> Glyph {
} }
/// Constructs a serializable [`PaletteEntry`] from core board types. /// Constructs a serializable [`PaletteEntry`] from core board types.
///
/// Fields that match the archetype's [`default_glyph`](Archetype::default_glyph)
/// are omitted (`None`) so saved files stay minimal and round-trip cleanly.
fn make_palette_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry { fn make_palette_entry(glyph: Glyph, arch: Archetype) -> PaletteEntry {
let default = arch.default_glyph();
PaletteEntry { PaletteEntry {
archetype: arch.name().to_string(), archetype: arch.name().to_string(),
tile: TileIndex::Num(glyph.tile), tile: (glyph.tile != default.tile).then_some(TileIndex::Num(glyph.tile)),
fg: color_to_hex(glyph.fg), fg: (glyph.fg != default.fg ).then_some(color_to_hex(glyph.fg)),
bg: color_to_hex(glyph.bg), bg: (glyph.bg != default.bg ).then_some(color_to_hex(glyph.bg)),
} }
} }
@@ -324,10 +332,14 @@ impl TryFrom<MapFile> for Board {
for (key, entry) in mf.palette { for (key, entry) in mf.palette {
let key_char = key.chars().next().unwrap_or(' '); let key_char = key.chars().next().unwrap_or(' ');
let tile = entry.tile.into_u32();
match Archetype::try_from(entry.archetype.as_str()) { match Archetype::try_from(entry.archetype.as_str()) {
Ok(archetype) => { Ok(archetype) => {
let glyph = make_glyph(tile, &entry.fg, &entry.bg); // Fall back to default_glyph() for any absent visual field.
let default = archetype.default_glyph();
let tile = entry.tile.map(|t| t.into_u32()).unwrap_or(default.tile);
let fg = entry.fg.as_deref().map(parse_color).unwrap_or(default.fg);
let bg = entry.bg.as_deref().map(parse_color).unwrap_or(default.bg);
let glyph = Glyph { tile, fg, bg };
palette.insert(key_char, (glyph, archetype)); palette.insert(key_char, (glyph, archetype));
} }
Err(e) => { Err(e) => {
+1 -1
View File
@@ -162,7 +162,7 @@ pub(crate) enum ScriptArg {
} }
/// A cardinal movement direction. /// A cardinal movement direction.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Direction { pub enum Direction {
North, North,
South, South,
+5 -3
View File
@@ -180,6 +180,8 @@ player_start = "@" # placed by the '@' grid char (convention)
"o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } "o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }
"-" = { 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" }
">" = { archetype = "pusher_east" }
"^" = { archetype = "pusher_north" }
# Optional visual floor layer, drawn under every empty cell (and revealed when a # 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` # crate is pushed away). This is a grid form with its own palette: `g`/`d`/`s`
@@ -239,10 +241,10 @@ content = """
# # # #
# # # #
# # # #
# >| #
# # # #
# # # - #
# # # ^ #
# #
# M # # M #
# # # #
# # # #