pushers
This commit is contained in:
@@ -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}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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 #
|
||||||
# #
|
# #
|
||||||
# #
|
# #
|
||||||
|
|||||||
Reference in New Issue
Block a user