new layer model

This commit is contained in:
2026-07-10 23:16:28 -05:00
parent 325d2c27dd
commit ad95a9cd8d
36 changed files with 1040 additions and 897 deletions
+67 -40
View File
@@ -135,7 +135,7 @@ impl GameState {
board_transition: None,
player
};
state.drain_errors();
state.drain_log();
state
}
@@ -208,7 +208,7 @@ impl GameState {
// Fire any bump/send reactions, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
self.drain_log();
}
/// Advances real-time game state by `dt` (the elapsed time since the last tick).
@@ -238,15 +238,16 @@ impl GameState {
// Fire the bump/send reactions those ticks triggered, then flush errors.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
self.drain_log();
}
/// Drains errors collected by the script host into the game log.
fn drain_errors(&mut self) {
/// Drains the log lines collected by the script host — script `log()` output
/// plus engine/runtime errors — into the game log.
fn drain_log(&mut self) {
// TODO: errors are only logged for now. This is the place to halt execution /
// set an error state when a script faults.
let errors = self.scripts.take_errors();
self.log.extend(errors);
let lines = self.scripts.take_logs();
self.log.extend(lines);
}
/// Applies one object's drained `actions` to the board and returns the `bump`
@@ -259,9 +260,12 @@ impl GameState {
/// reactions are returned rather than fired here, so the caller can run them once
/// all object hooks in this pass have applied.
fn apply_actions(&mut self, actions: Vec<BoardAction>) -> Events {
// Logs are collected here rather than pushed inline, since the board borrow
// below also borrows `self`.
let mut logs: Vec<LogLine> = Vec::new();
// Application-time errors (teleport/push/shift failures) go straight onto
// the shared LogSink — the same immediate channel as script `log()` output,
// so everything lands in the log in one emission order and is flushed by
// `drain_log`. A cheap Rc clone lets us push while the board borrow (which
// also borrows `self`) is held.
let log_sink = self.scripts.log_sink().clone();
let mut bumps: Vec<(ObjectId, Direction)> = Vec::new();
// Net change to player stats from AddGems / AlterHealth actions; applied
// to `self` after the board borrow drops.
@@ -289,7 +293,6 @@ impl GameState {
obj.glyph.tile = tile;
}
}
Action::Log(line) => logs.push(line),
Action::SetTag {
target,
tag,
@@ -340,9 +343,9 @@ impl GameState {
}
Action::Teleport { target, x, y } => {
if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!(
log_sink.error(format!(
"teleport({target},{x},{y}): out of bounds"
)));
));
} else if target == -1 {
// Move the player. A solid *other than the player itself*
// blocks the destination.
@@ -352,9 +355,9 @@ impl GameState {
Some(s) if !s.player()
);
if blocked {
logs.push(LogLine::error(format!(
log_sink.error(format!(
"teleport(player,{x},{y}): destination is solid"
)));
));
} else {
board.player.x = ux as i64;
board.player.y = uy as i64;
@@ -364,9 +367,9 @@ impl GameState {
// a solid mover, unless the occupant is that same object.
let (ux, uy) = (x as usize, y as usize);
match board.objects.get(&tid) {
None => logs.push(LogLine::error(format!(
None => log_sink.error(format!(
"teleport({target},{x},{y}): no such object"
))),
)),
Some(obj) => {
let source_solid = obj.solid;
let blocked = source_solid
@@ -375,9 +378,9 @@ impl GameState {
Some(s) if s.object_id() != Some(tid)
);
if blocked {
logs.push(LogLine::error(format!(
log_sink.error(format!(
"teleport({target},{x},{y}): destination is solid"
)));
));
} else if let Some(obj) = board.objects.get_mut(&tid) {
obj.x = ux;
obj.y = uy;
@@ -385,22 +388,24 @@ impl GameState {
}
}
} else {
logs.push(LogLine::error(format!(
log_sink.error(format!(
"teleport({target},{x},{y}): invalid target id"
)));
));
}
}
// push() self-checks can_push, so an in-bounds guard is all we add.
Action::Push { x, y, dir } => {
if !board.in_bounds((x, y)) {
logs.push(LogLine::error(format!("push({x},{y}): out of bounds")));
log_sink.error(format!("push({x},{y}): out of bounds"));
} else {
board.push(x as usize, y as usize, dir);
}
}
// apply_shift moves the named cells.
// apply_shift moves the named cells, returning any error lines.
Action::Shift(cells) => {
logs.extend(board.apply_shift(&cells));
for line in board.apply_shift(&cells) {
log_sink.line(line);
}
}
// Accumulated and applied to `self.player.gems` after the borrow drops.
Action::AddGems(n) => gem_delta += n,
@@ -415,7 +420,6 @@ impl GameState {
}
}
}
self.log.extend(logs);
for bubble in new_bubbles {
// One bubble per object: replace the existing one if present.
self.speech_bubbles
@@ -435,7 +439,7 @@ impl GameState {
}
for (color, present) in key_changes {
if !self.player.borrow_mut().keys.set_by_name(&color, present) {
self.log.push(LogLine::error(format!("set_key: unknown color {color:?}")));
log_sink.error(format!("set_key: unknown color {color:?}"));
}
}
// Return the reactions for the caller's settle pass rather than firing them here.
@@ -490,7 +494,7 @@ impl GameState {
let ev = self.apply_actions(actions);
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
self.drain_log();
}
}
@@ -612,7 +616,7 @@ impl GameState {
// Settle the grab/bump reactions (and any they cascade into) before returning.
let mut called = CalledSet::new();
self.settle(ev, &mut called);
self.drain_errors();
self.drain_log();
}
}
@@ -724,8 +728,8 @@ mod tests {
);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty);
assert_eq!(b.get(0, 3, 0).1, Archetype::Crate);
assert_eq!(b.get(2, 0).1, Archetype::Empty);
assert_eq!(b.get(3, 0).1, Archetype::Crate);
}
#[test]
@@ -754,6 +758,29 @@ mod tests {
assert_eq!(lines, vec!["empty:yes", "crate:no", "off:no"]);
}
#[test]
fn log_is_immediate_and_not_paced_by_the_queue() {
// `delay(5.0)` parks the object's action queue for 5 seconds, then `log()`
// fires. Because logging bypasses the queue entirely, the line must appear
// right after run_init (dt = 0) — under the old queued-Action behavior it
// would have been stuck behind the delay and absent here.
let mut obj = ObjectDef::new(0, 0);
obj.solid = false;
obj.script_name = Some("s".to_string());
let board = open_board(4, 1, (3, 0), vec![obj]);
let src = "fn init(m) { delay(5.0); log(\"immediate\"); }";
let scripts = HashMap::from([("s".to_string(), src.to_string())]);
let mut game = GameState::with_scripts(board, scripts);
game.run_init();
assert!(
game.log
.iter()
.any(|l| l.spans.iter().any(|s| s.text == "immediate")),
"log() should hit the game log immediately, even behind a delay"
);
}
/// Builds a 3×3 board with a non-solid clockwise spinner object (running the
/// real `scripts/spinner.rhai`) at the centre and the player parked on it.
fn spinner_board(crates: &[(usize, usize)], walls: &[(usize, usize)]) -> GameState {
@@ -797,9 +824,9 @@ mod tests {
let mut game = spinner_board(&[(1, 0), (2, 0)], &[(2, 1)]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(0, 2, 1).1, Archetype::Wall); // wall kept
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N kept
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE kept (not destroyed)
assert_eq!(b.get(2, 1).1, Archetype::Wall); // wall kept
}
#[test]
@@ -809,10 +836,10 @@ mod tests {
let mut game = spinner_board(&[(0, 1), (0, 0), (1, 0)], &[]);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(0, 1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(0, 0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
assert_eq!(b.get(2, 0).1, Archetype::Crate); // NE: filled by N
assert_eq!(b.get(1, 0).1, Archetype::Crate); // N: filled by NW
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: filled by W
assert_eq!(b.get(0, 1).1, Archetype::Empty); // W: vacated (hole moved here)
}
#[test]
@@ -822,9 +849,9 @@ mod tests {
let mut game = spinner_board_dir(&[(1, 0)], &[], true);
game.tick(Duration::from_millis(16));
let b = game.board();
assert_eq!(b.get(0, 0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(0, 2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(0, 1, 0).1, Archetype::Empty); // N: vacated
assert_eq!(b.get(0, 0).1, Archetype::Crate); // NW: crate rotated counter-clockwise
assert_eq!(b.get(2, 0).1, Archetype::Empty); // NE: untouched
assert_eq!(b.get(1, 0).1, Archetype::Empty); // N: vacated
}
#[test]