more scripting
This commit is contained in:
+245
-30
@@ -1,9 +1,11 @@
|
||||
use crate::log::LogLine;
|
||||
use crate::script::ScriptHost;
|
||||
use crate::script::{Direction, GameCommand, ScriptHost};
|
||||
use color::Rgba8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cell::{Ref, RefCell, RefMut};
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The visual representation of a single board cell.
|
||||
@@ -43,6 +45,7 @@ impl Glyph {
|
||||
/// This is the only hardcoded glyph; all other glyphs come from the map
|
||||
/// file palette. It will be removed once the player becomes a scripted
|
||||
/// object with its own palette entry.
|
||||
#[rustfmt::skip]
|
||||
pub const fn player() -> Self {
|
||||
Self {
|
||||
tile: 64,
|
||||
@@ -145,6 +148,7 @@ impl Archetype {
|
||||
///
|
||||
/// This glyph is used only for new cells created in the editor; existing
|
||||
/// cells retain their own per-cell glyph.
|
||||
#[rustfmt::skip]
|
||||
pub fn default_glyph(&self) -> Glyph {
|
||||
match self {
|
||||
Archetype::Empty => Glyph {
|
||||
@@ -229,6 +233,7 @@ pub struct ObjectDef {
|
||||
|
||||
impl ObjectDef {
|
||||
/// Returns the default glyph for a newly placed object: tile 63 (`?`) in yellow on black.
|
||||
#[rustfmt::skip]
|
||||
pub fn default_glyph() -> Glyph {
|
||||
Glyph {
|
||||
tile: 63,
|
||||
@@ -368,7 +373,9 @@ impl Board {
|
||||
/// Panics if `x` or `y` are out of bounds.
|
||||
pub fn is_passable(&self, x: usize, y: usize) -> bool {
|
||||
// An object on this cell blocks if it's unpassable
|
||||
if let Some(obj) = self.object_at(x, y) && !obj.passable {
|
||||
if let Some(obj) = self.object_at(x, y)
|
||||
&& !obj.passable
|
||||
{
|
||||
false
|
||||
} else {
|
||||
self.get(x, y).1.behavior().passable
|
||||
@@ -386,16 +393,30 @@ impl Board {
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the active game board and provides game-logic operations.
|
||||
/// The scriptable world: the current [`Board`] plus (in the future) runtime-only
|
||||
/// state such as a shared variable blackboard.
|
||||
///
|
||||
/// `BoardState` is a sibling of the [`ScriptHost`] inside [`GameState`] and is
|
||||
/// held behind an `Rc<RefCell<…>>`. Keeping it separate from the script engine is
|
||||
/// what lets host functions hold a shared handle to the world without aliasing the
|
||||
/// borrow that is currently running the engine.
|
||||
pub struct BoardState {
|
||||
/// The active game board.
|
||||
pub board: Board,
|
||||
}
|
||||
|
||||
/// Holds the active game world and provides game-logic operations.
|
||||
///
|
||||
/// `GameState` is the boundary between the engine (rendering, input) and the
|
||||
/// game data ([`Board`]). It owns the current board, the message log, and the
|
||||
/// [`ScriptHost`] driving the board's object scripts, and exposes methods for
|
||||
/// actions that involve game rules: player movement, the per-frame
|
||||
/// [`tick`](GameState::tick), and the one-time [`run_init`](GameState::run_init).
|
||||
/// game data. It owns the world ([`BoardState`], behind a shared `Rc<RefCell>`),
|
||||
/// the message log, and the [`ScriptHost`] driving object scripts. Front-ends and
|
||||
/// internal logic reach the board through [`board`](GameState::board) /
|
||||
/// [`board_mut`](GameState::board_mut). Scripts read the board directly and
|
||||
/// request mutations as commands, applied by [`apply_commands`](GameState::apply_commands)
|
||||
/// after each script batch.
|
||||
pub struct GameState {
|
||||
/// The currently active board.
|
||||
pub board: Board,
|
||||
/// The scriptable world, shared with the script host's read getters.
|
||||
board_state: Rc<RefCell<BoardState>>,
|
||||
/// The in-game message log, oldest first (newest pushed at the end).
|
||||
pub log: Vec<LogLine>,
|
||||
/// The Rhai scripting runtime driving this board's scripted objects.
|
||||
@@ -409,42 +430,89 @@ impl GameState {
|
||||
/// call [`GameState::run_init`] once the game is ready to start. Any script
|
||||
/// compile errors are surfaced into the log here.
|
||||
pub fn new(board: Board) -> Self {
|
||||
let scripts = ScriptHost::new(&board);
|
||||
let board_state = Rc::new(RefCell::new(BoardState { board }));
|
||||
let scripts = ScriptHost::new(&board_state);
|
||||
let mut state = Self {
|
||||
board,
|
||||
board_state,
|
||||
log: Vec::new(),
|
||||
scripts,
|
||||
};
|
||||
// Surface any compile-time script errors collected during ScriptHost::new.
|
||||
state.flush_script_log();
|
||||
state.apply_commands();
|
||||
state
|
||||
}
|
||||
|
||||
/// Borrows the active board for reading (e.g. by a front-end renderer).
|
||||
pub fn board(&self) -> Ref<'_, Board> {
|
||||
Ref::map(self.board_state.borrow(), |bs| &bs.board)
|
||||
}
|
||||
|
||||
/// Borrows the active board for mutation.
|
||||
pub fn board_mut(&self) -> RefMut<'_, Board> {
|
||||
RefMut::map(self.board_state.borrow_mut(), |bs| &mut bs.board)
|
||||
}
|
||||
|
||||
/// Appends a styled message to the log.
|
||||
pub fn log(&mut self, line: LogLine) {
|
||||
self.log.push(line);
|
||||
}
|
||||
|
||||
/// Runs the `init()` hook of every scripted object. Call once, after the
|
||||
/// whole map is loaded and the game is about to start — never during map
|
||||
/// deserialization, since a script may inspect the board.
|
||||
/// Runs the `init()` hook of every scripted object, then applies the commands
|
||||
/// they queued. Call once, after the whole map is loaded and the game is about
|
||||
/// to start — never during map deserialization, since a script may inspect the
|
||||
/// board.
|
||||
pub fn run_init(&mut self) {
|
||||
self.scripts.run_init();
|
||||
self.flush_script_log();
|
||||
self.apply_commands();
|
||||
}
|
||||
|
||||
/// Advances real-time game state by `dt` (the elapsed time since the last
|
||||
/// tick). Called once per frame by the front-end's game loop; drives every
|
||||
/// scripted object's `tick(dt)` hook.
|
||||
/// scripted object's `tick(dt)` hook, then applies the commands they queued.
|
||||
pub fn tick(&mut self, dt: Duration) {
|
||||
self.scripts.run_tick(dt.as_secs_f64());
|
||||
self.flush_script_log();
|
||||
self.apply_commands();
|
||||
}
|
||||
|
||||
/// Moves any messages queued by scripts (via the `log` host function) into
|
||||
/// the real game log.
|
||||
fn flush_script_log(&mut self) {
|
||||
self.log.extend(self.scripts.take_pending());
|
||||
/// Drains the script command queue and applies each command. Runs *after* a
|
||||
/// script batch, when nothing holds a borrow of the board — so the mutations
|
||||
/// here can't conflict with the read getters scripts use during execution.
|
||||
fn apply_commands(&mut self) {
|
||||
for cmd in self.scripts.take_commands() {
|
||||
match cmd.kind {
|
||||
GameCommand::Log(line) => self.log.push(line),
|
||||
// TODO: errors are only logged for now. This is the place to halt
|
||||
// execution / set an error state when a script faults.
|
||||
GameCommand::Error(msg) => self.log.push(LogLine::raw(msg)),
|
||||
GameCommand::SetTile(tile) => {
|
||||
if let Some(obj) = self.board_mut().objects.get_mut(cmd.source) {
|
||||
obj.glyph.tile = tile;
|
||||
}
|
||||
}
|
||||
GameCommand::Move(dir) => self.move_object(cmd.source, dir),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves object `idx` one cell in `dir`, if the target is in bounds and
|
||||
/// passable. This is where movement rules live (a no-op when blocked).
|
||||
fn move_object(&mut self, idx: usize, dir: Direction) {
|
||||
let (dx, dy): (i32, i32) = dir.into();
|
||||
let mut board = self.board_mut();
|
||||
let Some((ox, oy)) = board.objects.get(idx).map(|o| (o.x, o.y)) else {
|
||||
return;
|
||||
};
|
||||
let nx = ox as i32 + dx;
|
||||
let ny = oy as i32 + dy;
|
||||
if nx < 0 || ny < 0 {
|
||||
return;
|
||||
}
|
||||
let (nx, ny) = (nx as usize, ny as usize);
|
||||
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
|
||||
let obj = &mut board.objects[idx];
|
||||
obj.x = nx;
|
||||
obj.y = ny;
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to move the player by `(dx, dy)` cells.
|
||||
@@ -452,14 +520,15 @@ impl GameState {
|
||||
/// The move is ignored if the target cell is out of bounds or its behavior
|
||||
/// is not passable. No-ops silently (the caller does not need to check).
|
||||
pub fn try_move(&mut self, dx: i32, dy: i32) {
|
||||
let nx = self.board.player.x + dx;
|
||||
let ny = self.board.player.y + dy;
|
||||
let mut board = self.board_mut();
|
||||
let nx = board.player.x + dx;
|
||||
let ny = board.player.y + dy;
|
||||
if nx >= 0 && ny >= 0 {
|
||||
let nx = nx as usize;
|
||||
let ny = ny as usize;
|
||||
if nx < self.board.width && ny < self.board.height && self.board.is_passable(nx, ny) {
|
||||
self.board.player.x = nx as i32;
|
||||
self.board.player.y = ny as i32;
|
||||
if nx < board.width && ny < board.height && board.is_passable(nx, ny) {
|
||||
board.player.x = nx as i32;
|
||||
board.player.y = ny as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,6 +561,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An `ObjectDef` at `(x, y)` bound to the named script.
|
||||
fn scripted_object(x: usize, y: usize, script: &str) -> ObjectDef {
|
||||
let mut o = ObjectDef::new(x, y);
|
||||
o.script_name = Some(script.to_string());
|
||||
o
|
||||
}
|
||||
|
||||
/// Builds an all-empty (passable) `w×h` board with the given player position,
|
||||
/// objects, and `(name, source)` scripts — room for movement, unlike the 1×1
|
||||
/// `board_with_object`.
|
||||
fn open_board(
|
||||
w: usize,
|
||||
h: usize,
|
||||
player: (i32, i32),
|
||||
objects: Vec<ObjectDef>,
|
||||
scripts: &[(&str, &str)],
|
||||
) -> Board {
|
||||
Board {
|
||||
name: "test".into(),
|
||||
width: w,
|
||||
height: h,
|
||||
cells: vec![(Archetype::Empty.default_glyph(), Archetype::Empty); w * h],
|
||||
player: Player {
|
||||
x: player.0,
|
||||
y: player.1,
|
||||
},
|
||||
objects,
|
||||
portals: Vec::new(),
|
||||
font: None,
|
||||
zoom: 1,
|
||||
scripts: scripts
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect(),
|
||||
board_script_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flattens each log line into a single string for easy assertions.
|
||||
fn log_texts(game: &GameState) -> Vec<String> {
|
||||
game.log
|
||||
@@ -502,7 +609,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn init_runs_only_on_run_init_not_at_construction() {
|
||||
let board = board_with_object(Some("greet"), &[("greet", r#"fn init() { log("hello"); }"#)]);
|
||||
let board = board_with_object(
|
||||
Some("greet"),
|
||||
&[("greet", r#"fn init() { log("hello"); }"#)],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
// init must not fire during construction / deserialization.
|
||||
assert!(game.log.is_empty());
|
||||
@@ -538,8 +648,10 @@ mod tests {
|
||||
assert!(game.log.is_empty());
|
||||
|
||||
// Script defines neither init nor tick: also a no-op.
|
||||
let mut game =
|
||||
GameState::new(board_with_object(Some("e"), &[("e", "fn other() { log(\"x\"); }")]));
|
||||
let mut game = GameState::new(board_with_object(
|
||||
Some("e"),
|
||||
&[("e", "fn other() { log(\"x\"); }")],
|
||||
));
|
||||
game.run_init();
|
||||
game.tick(Duration::from_millis(33));
|
||||
assert!(game.log.is_empty());
|
||||
@@ -555,4 +667,107 @@ mod tests {
|
||||
let game = GameState::new(board_with_object(Some("bad"), &[("bad", "fn init( {")]));
|
||||
assert!(log_texts(&game)[0].contains("failed to compile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_reads_board_through_view() {
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(3, 1),
|
||||
vec![scripted_object(2, 1, "r")],
|
||||
&[("r", "fn init() { log(board.player_x.to_string()); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
// The view reported the player's x (3).
|
||||
assert_eq!(log_texts(&game), vec!["3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_relocates_the_source_object() {
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(2, 1, "m")],
|
||||
&[("m", "fn init() { move(east); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
// East increments x by one; the object started at (2, 1).
|
||||
assert_eq!((b.objects[0].x, b.objects[0].y), (3, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_into_a_wall_or_edge_is_a_noop() {
|
||||
// Object at the west edge moving west: out of bounds, ignored.
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(0, 1, "m")],
|
||||
&[("m", "fn init() { move(west); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[0].x, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_tile_command_changes_the_source_glyph() {
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(2, 1, "s")],
|
||||
&[("s", "fn init() { set_tile(7); }")],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
assert_eq!(game.board().objects[0].glyph.tile, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_map_greeter_runs_init() {
|
||||
// End-to-end against the shipped example map: load it, run init, and
|
||||
// confirm the greeter read the board (interpolated message) and wrote to
|
||||
// itself (set_tile). Also guards the example from drifting out of sync.
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../maps/start.toml");
|
||||
let board = crate::map_file::load(path).expect("load start.toml");
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
assert!(
|
||||
log_texts(&game)
|
||||
.iter()
|
||||
.any(|t| t.contains("hello from object")),
|
||||
"greeter init should log a greeting"
|
||||
);
|
||||
assert!(
|
||||
game.board().objects.iter().any(|o| o.glyph.tile == 2),
|
||||
"greeter set_tile(2) should change its glyph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_routed_to_their_own_source_object() {
|
||||
// Two objects with different scripts move in opposite directions; each
|
||||
// must affect only itself (the per-call tag routes the command source).
|
||||
// Targets are kept apart so the impassable objects don't block each other.
|
||||
let board = open_board(
|
||||
5,
|
||||
3,
|
||||
(0, 0),
|
||||
vec![scripted_object(0, 1, "e"), scripted_object(4, 1, "w")],
|
||||
&[
|
||||
("e", "fn init() { move(east); }"),
|
||||
("w", "fn init() { move(west); }"),
|
||||
],
|
||||
);
|
||||
let mut game = GameState::new(board);
|
||||
game.run_init();
|
||||
let b = game.board();
|
||||
assert_eq!(b.objects[0].x, 1); // moved east from 0
|
||||
assert_eq!(b.objects[1].x, 3); // moved west from 4
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user