Files
kiln/kiln-core/src/action.rs
T
2026-06-28 00:12:52 -05:00

283 lines
11 KiB
Rust

//! The [`Action`] enum and its Rhai-facing conversions.
//!
//! This module owns the `Action` type (which scripts enqueue via write host
//! functions), the rate-limiting delay constant, and [`action_to_map`] which
//! converts an `Action` to a Rhai map for `Queue.peek()` / `Queue.pop()`.
use std::fmt::Debug;
use crate::log::LogLine;
use crate::map_file::color_to_hex;
use crate::utils::{Direction, ObjectId};
use color::Rgba8;
use rhai::Dynamic;
/// How long a move occupies an object before it can act again, in seconds.
pub(crate) const MOVE_COST: f64 = 0.25;
/// One line of content in a [`Action::Scroll`] overlay.
///
/// Each element of the array passed to the `scroll()` Rhai function becomes a
/// `ScrollLine`: a plain string becomes [`Text`](ScrollLine::Text); a two-element
/// array `[choice, display]` becomes a [`Choice`](ScrollLine::Choice).
#[derive(Clone)]
pub enum ScrollLine {
/// Plain text, word-wrapped to the scroll window width.
Text(String),
/// A selectable option: `choice` is the event name sent back to the source
/// object via `send`, and `display` is the label shown to the player.
Choice { choice: String, display: String },
}
#[derive(Clone, PartialEq)]
pub enum SendArg {
None,
Int(i64),
Float(f64),
String(String),
}
impl From<Dynamic> for SendArg {
fn from(value: Dynamic) -> Self {
if value.is_int() {
Self::Int(value.as_int().unwrap())
} else if value.is_float() {
Self::Float(value.as_float().unwrap())
} else if value.is_string() {
Self::String(value.into_string().unwrap())
} else {
Self::None
}
}
}
impl Into<Dynamic> for SendArg {
fn into(self) -> Dynamic {
match self {
SendArg::None => Dynamic::UNIT,
SendArg::Int(value) => Dynamic::from(value),
SendArg::Float(value) => Dynamic::from(value),
SendArg::String(value) => Dynamic::from(value),
}
}
}
/// One deferred mutation emitted by a script, applied by [`crate::game::GameState`]
/// after it is promoted onto the board queue.
///
/// [`Action::Delay`] is the exception: it is **never** promoted to the board queue.
/// It lives only in the per-object output queue and is consumed by `ScriptHost::drain`
/// to pace how quickly other actions are released.
#[derive(Clone)]
pub enum Action {
/// Move the source object one cell in a direction (subject to passability).
Move(Direction),
/// Set the source object's glyph tile index.
SetTile(u32),
/// Append a styled line to the game log.
Log(LogLine),
/// Add (`present = true`) or remove (`present = false`) `tag` on `target`.
SetTag {
target: ObjectId,
tag: String,
present: bool,
},
/// Display a speech bubble above the source object for `duration` seconds.
Say(String, f64),
/// Pause draining this object's queue for `secs` seconds. Never reaches the board queue.
Delay(f64),
/// Set the source object's foreground and/or background color.
SetColor {
fg: Option<Rgba8>,
bg: Option<Rgba8>,
},
/// Call `fn_name` on `target` object, optionally passing `arg`.
Send {
target: ObjectId,
fn_name: String,
arg: SendArg,
},
/// Open a scrollable text overlay. Pauses game ticks while shown; a choice
/// selection dispatches [`Send`](Action::Send) to the source object.
Scroll(Vec<ScrollLine>),
/// Teleport the source object to `(x, y)`. Blocked (with a logged error) if
/// the source is solid and the destination already has a solid occupant.
/// Zero time cost — multiple teleports may fire in one frame.
Teleport { x: i64, y: i64 },
/// Shove the pushable chain starting at `(x, y)` one step in `dir` (acts on
/// arbitrary cells, not the source object). Zero time cost.
Push { x: i64, y: i64, dir: Direction },
/// Shift a set of cells, given as `(x, y)` coordinates: each cell moves to the
/// next coordinate in the list, unless it can't move, or that cell is blocked.
/// Rotates the contents of the last cell back to the beginning.
/// Zero time cost.
Shift(Vec<(i64, i64)>),
/// Add `n` to the player's gem count (negative subtracts; the count is
/// clamped at 0). Zero time cost. Applied to `GameState::player.gems`.
AddGems(i64),
/// Add `dh` to the player's health (clamped to `[0, max_health]`). Zero time
/// cost. Applied to `GameState::player.health`.
AlterHealth(i64),
/// Give (`true`) or take (`false`) the named key color from the player.
///
/// Color must be one of `"blue"`, `"green"`, `"cyan"`, `"red"`, `"purple"`,
/// `"orange"`, `"yellow"`, `"white"`. An unrecognized name is logged and
/// ignored. Zero time cost. Applied to `GameState::player.keys`.
SetKey(String, bool),
/// Remove the source object from the board. Zero time cost. Used by grab
/// things (e.g. gems) to despawn themselves from their `grab()` hook.
Die,
}
impl Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Action::Move(dir) => write!(f, "Move({:?})", dir),
Action::SetTile(i) => write!(f, "SetTile({i})"),
Action::Log(msg) => write!(f, "Log({:?})", msg),
Action::SetTag { .. } => write!(f, "SetTag"),
Action::Say(_, _) => write!(f, "Say"),
Action::Delay(t) => write!(f, "Delay({t})"),
Action::SetColor { .. } => write!(f, "SetColor"),
Action::Send { .. } => write!(f, "Send"),
Action::Scroll(_) => write!(f, "Scroll"),
Action::Teleport { .. } => write!(f, "Teleport"),
Action::Push { .. } => write!(f, "Push"),
Action::Shift(_) => write!(f, "Shift"),
Action::AddGems(n) => write!(f, "AddGems({n}"),
Action::AlterHealth(health) => write!(f, "AlterHealth({health})"),
Action::SetKey(_, _) => write!(f, "SetKey"),
Action::Die => write!(f, "Die")
}
}
}
// ── Direction helper ──────────────────────────────────────────────────────────
fn dir_to_str(d: Direction) -> &'static str {
match d {
Direction::North => "North",
Direction::South => "South",
Direction::East => "East",
Direction::West => "West",
}
}
// ── action_to_map ─────────────────────────────────────────────────────────────
/// Converts an [`Action`] to a Rhai map for `Queue.peek()` / `Queue.pop()`.
///
/// The map always has a `"type"` key naming the variant; other keys carry the
/// payload. [`Action::Log`] is flattened to its first span's text.
pub(crate) fn action_to_map(action: &Action) -> rhai::Map {
// Dynamic::from(String) avoids rhai 1.x's From<&str> lifetime restriction.
let ds = |v: &'static str| Dynamic::from(v.to_string());
let mut m = rhai::Map::new();
match action {
Action::Move(dir) => {
m.insert("type".into(), ds("Move"));
m.insert("dir".into(), ds(dir_to_str(*dir)));
}
Action::SetTile(n) => {
m.insert("type".into(), ds("SetTile"));
m.insert("tile".into(), Dynamic::from(*n as i64));
}
Action::Log(line) => {
let text = line
.spans
.first()
.map(|s| s.text.as_str())
.unwrap_or("")
.to_string();
m.insert("type".into(), ds("Log"));
m.insert("msg".into(), Dynamic::from(text));
}
Action::SetTag {
target,
tag,
present,
} => {
m.insert("type".into(), ds("SetTag"));
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("tag".into(), Dynamic::from(tag.clone()));
m.insert("present".into(), Dynamic::from(*present));
}
Action::Say(text, dur) => {
m.insert("type".into(), ds("Say"));
m.insert("msg".into(), Dynamic::from(text.clone()));
m.insert("duration".into(), Dynamic::from(*dur));
}
Action::Delay(secs) => {
m.insert("type".into(), ds("Delay"));
m.insert("secs".into(), Dynamic::from(*secs));
}
Action::SetColor { fg, bg } => {
m.insert("type".into(), ds("SetColor"));
if let Some(c) = fg {
m.insert("fg".into(), Dynamic::from(color_to_hex(*c)));
}
if let Some(c) = bg {
m.insert("bg".into(), Dynamic::from(color_to_hex(*c)));
}
}
Action::Send {
target,
fn_name,
arg,
} => {
m.insert("type".into(), ds("Send"));
m.insert("target".into(), Dynamic::from(*target as i64));
m.insert("name".into(), Dynamic::from(fn_name.clone()));
match arg {
SendArg::Int(i) => m.insert("arg".into(), Dynamic::from(*i)),
SendArg::Float(f) => m.insert("arg".into(), Dynamic::from(*f)),
SendArg::String(s) => m.insert("arg".into(), Dynamic::from(s.clone())),
SendArg::None => m.insert("arg".into(), Dynamic::UNIT)
};
}
// Scroll lines are not inspectable via the queue map API; emit type only.
Action::Scroll(_) => {
m.insert("type".into(), ds("Scroll"));
}
Action::Teleport { x, y } => {
m.insert("type".into(), ds("Teleport"));
m.insert("x".into(), Dynamic::from(*x as i64));
m.insert("y".into(), Dynamic::from(*y as i64));
}
Action::Push { x, y, dir } => {
m.insert("type".into(), ds("Push"));
m.insert("x".into(), Dynamic::from(*x as i64));
m.insert("y".into(), Dynamic::from(*y as i64));
m.insert("dir".into(), ds(dir_to_str(*dir)));
}
// Shift cells are not inspectable via the queue map API; emit type only.
Action::Shift(_) => {
m.insert("type".into(), ds("Shift"));
}
Action::AddGems(n) => {
m.insert("type".into(), ds("AddGems"));
m.insert("n".into(), Dynamic::from(*n));
}
Action::AlterHealth(dh) => {
m.insert("type".into(), ds("AlterHealth"));
m.insert("dh".into(), Dynamic::from(*dh));
}
Action::SetKey(color, present) => {
m.insert("type".into(), ds("SetKey"));
m.insert("color".into(), Dynamic::from(color.clone()));
m.insert("present".into(), Dynamic::from(*present));
}
Action::Die => {
m.insert("type".into(), ds("Die"));
}
}
m
}
/// An action promoted from an object's output queue onto the board queue, tagged
/// with the object that issued it.
pub struct BoardAction {
/// The stable [`ObjectId`] of the object that issued the action.
pub(crate) source: ObjectId,
/// The action to apply.
pub(crate) action: Action,
}