diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs index a3c3830..4efcbec 100644 --- a/kiln-core/src/archetype.rs +++ b/kiln-core/src/archetype.rs @@ -1,6 +1,6 @@ use color::Rgba8; 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. /// @@ -32,6 +32,10 @@ pub enum Archetype { HCrate, /// A crate that can only be pushed north/south (vertical axis). Glyph ↕. 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. /// Renders as a yellow `?` on red to make the error visible in-game. ErrorBlock, @@ -68,6 +72,11 @@ impl Archetype { opaque: true, pushable: Pushable::Vertical, }, + Archetype::Pusher(_) => Behavior { + solid: true, + opaque: true, + pushable: Pushable::No, + }, Archetype::ErrorBlock => Behavior { solid: true, opaque: true, @@ -84,6 +93,10 @@ impl Archetype { Archetype::Crate => "crate", Archetype::HCrate => "hcrate", 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", } } @@ -120,6 +133,19 @@ impl Archetype { fg: Rgba8 { r: 0xAA, g: 0xAA, b: 0xAA, a: 255 }, // same colors as Crate 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. Archetype::ErrorBlock => Glyph { tile: 63, @@ -146,6 +172,10 @@ impl TryFrom<&str> for Archetype { "vcrate" => Ok(Archetype::VCrate), // "object" is intentionally absent: objects are not valid palette // 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}")), } } diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 2425114..361750d 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -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)`. /// /// A solid object is relocated; otherwise the grid archetype (a crate) is diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index c0e3578..4a5e87f 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -1,4 +1,5 @@ use crate::action::Action; +use crate::archetype::Archetype; use crate::board::Board; use crate::log::LogLine; 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. 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 // accessing the private `action` module directly. pub use crate::action::ScrollLine; @@ -61,6 +65,9 @@ pub struct GameState { /// 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`. pub board_transition: Option, + /// 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 { @@ -84,6 +91,7 @@ impl GameState { speech_bubbles: Vec::new(), active_scroll: None, board_transition: None, + pusher_timer: PUSHER_INTERVAL, }; state.drain_errors(); state @@ -161,9 +169,39 @@ impl GameState { // Expire speech bubbles before resolving new actions so a fresh say() // this frame isn't immediately culled. self.speech_bubbles.retain_mut(|b| { b.remaining -= secs; b.remaining > 0.0 }); + self.tick_pushers(secs); 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. fn drain_errors(&mut self) { // TODO: errors are only logged for now. This is the place to halt execution / diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 07cbb73..7f0ee95 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -101,21 +101,25 @@ pub enum PlayerStart { /// /// Each entry is keyed by a single character (e.g. `"#"` or `" "`). /// 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 -/// [`Archetype`] it uses for behavior. +/// The entry defines which [`Archetype`] the cell uses for behavior; `tile`, +/// `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 = "#"`). #[derive(Deserialize, Serialize)] pub struct PaletteEntry { /// The archetype name for this tile, e.g. `"wall"` or `"empty"`. 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. - tile: TileIndex, - /// Foreground color as an `"#RRGGBB"` hex string. - pub fg: String, - /// Background color as an `"#RRGGBB"` hex string. - pub bg: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + tile: Option, + /// Foreground color as an `"#RRGGBB"` hex string. Omit to use the archetype default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fg: Option, + /// Background color as an `"#RRGGBB"` hex string. Omit to use the archetype default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bg: Option, } /// 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. +/// +/// 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 { + let default = arch.default_glyph(); PaletteEntry { archetype: arch.name().to_string(), - tile: TileIndex::Num(glyph.tile), - fg: color_to_hex(glyph.fg), - bg: color_to_hex(glyph.bg), + tile: (glyph.tile != default.tile).then_some(TileIndex::Num(glyph.tile)), + fg: (glyph.fg != default.fg ).then_some(color_to_hex(glyph.fg)), + bg: (glyph.bg != default.bg ).then_some(color_to_hex(glyph.bg)), } } @@ -324,10 +332,14 @@ impl TryFrom for Board { for (key, entry) in mf.palette { let key_char = key.chars().next().unwrap_or(' '); - let tile = entry.tile.into_u32(); match Archetype::try_from(entry.archetype.as_str()) { 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)); } Err(e) => { diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 67bcaa5..a8ba989 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -162,7 +162,7 @@ pub(crate) enum ScriptArg { } /// A cardinal movement direction. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Direction { North, South, diff --git a/maps/start.toml b/maps/start.toml index fe8f1e3..c661998 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -180,6 +180,8 @@ player_start = "@" # placed by the '@' grid char (convention) "o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" } "-" = { archetype = "hcrate", tile = 29, 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 # crate is pushed away). This is a grid form with its own palette: `g`/`d`/`s` @@ -239,10 +241,10 @@ content = """ # # # # # # +# >| # # # -# # -# # -# # +# - # +# ^ # # M # # # # #