pushers
This commit is contained in:
@@ -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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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 /
|
||||
|
||||
+25
-13
@@ -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<TileIndex>,
|
||||
/// Foreground color as an `"#RRGGBB"` hex string. Omit to use the archetype default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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.
|
||||
@@ -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<MapFile> 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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user