From 87979ac610d91a2e98a2527a302e78d95f1e4bf0 Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sun, 21 Jun 2026 22:04:10 -0500 Subject: [PATCH] wip --- kiln-core/src/action.rs | 15 ++- kiln-core/src/archetype.rs | 2 +- kiln-core/src/board.rs | 210 +++++++++-------------------- kiln-core/src/game.rs | 7 +- kiln-core/src/script.rs | 48 ++++--- kiln-core/src/scripts/spinner.rhai | 90 +++---------- maps/start.toml | 17 ++- 7 files changed, 134 insertions(+), 255 deletions(-) diff --git a/kiln-core/src/action.rs b/kiln-core/src/action.rs index 243ab0f..b2a603e 100644 --- a/kiln-core/src/action.rs +++ b/kiln-core/src/action.rs @@ -71,10 +71,11 @@ pub(crate) enum Action { /// 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: i32, y: i32, dir: Direction }, - /// Apply a batch of one-way solid moves simultaneously (read-all, then - /// write-all, so cycles/swaps work). Each tuple is `(src_x, src_y, dst_x, - /// dst_y)`. Zero time cost. - Swap(Vec<(i32, i32, i32, i32)>), + /// 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<(i32, i32)>), /// 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), @@ -182,9 +183,9 @@ pub(crate) fn action_to_map(action: &Action) -> rhai::Map { m.insert("y".into(), Dynamic::from(*y as i64)); m.insert("dir".into(), ds(dir_to_str(*dir))); } - // Swap pairs are not inspectable via the queue map API; emit type only. - Action::Swap(_) => { - m.insert("type".into(), ds("Swap")); + // 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")); diff --git a/kiln-core/src/archetype.rs b/kiln-core/src/archetype.rs index 4c14fc0..b06a184 100644 --- a/kiln-core/src/archetype.rs +++ b/kiln-core/src/archetype.rs @@ -157,7 +157,7 @@ impl Archetype { pub fn default_glyph(&self) -> Glyph { match self { Archetype::Empty => Glyph { - tile: 32, + tile: 0, fg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, bg: Rgba8 { r: 0, g: 0, b: 0, a: 255 }, }, diff --git a/kiln-core/src/board.rs b/kiln-core/src/board.rs index 1d24c53..49c91d7 100644 --- a/kiln-core/src/board.rs +++ b/kiln-core/src/board.rs @@ -96,6 +96,15 @@ impl Board { &mut self.layers[z].cells[y * w + x] } + /// Replace the solid (if any) at `(x, y)` with `Empty` + pub fn clear_solid(&mut self, x: usize, y: usize) { + if self.in_bounds((x as i32, y as i32)) { + if let Some(z) = self.solid_cell_layer(x, y) { + *self.get_mut(z, x, y) = (Archetype::Empty.default_glyph(), Archetype::Empty) + } + } + } + /// Returns the glyph to display at `(x, y)`, honoring layer draw order. /// /// The player is always drawn on top (it is not part of the layer stack yet). @@ -524,165 +533,74 @@ impl Board { self.add_object(obj); } } - - /// Applies a batch of one-way solid moves **simultaneously**, returning any - /// nonfatal error lines (out-of-bounds entries / a write blocked by the player). - /// - /// Each tuple is `(src_x, src_y, dst_x, dst_y)`: the solid occupant of `(src_x, - /// src_y)` — the player, a solid object, or a terrain crate/wall — moves to - /// `(dst_x, dst_y)`. A source with no solid moves an "empty", which **removes** - /// whatever solid was at the destination. Every source is read before any - /// destination is written, so cyclic permutations and two-cell swaps resolve - /// correctly (e.g. `[a→b],[b→a]` swaps `a` and `b`). - /// - /// Displacement rules: a destination's prior solid that isn't itself being moved - /// is removed (terrain cleared; a scripted object despawned via - /// [`remove_object`](Board::remove_object)). The **player is never destroyed** — - /// a write that would overwrite the player without relocating it is skipped and - /// logged (the player wins its cell, per the one-solid-per-cell invariant). - /// - /// A solid refused onto the player is left at its source cell, which another - /// entry may also target; a final sweep over the swapped cells resolves any - /// such **overlap** — it keeps one solid, deletes the rest (never the player), - /// and logs an error per deletion. - pub fn apply_swap(&mut self, pairs: &[(i32, i32, i32, i32)]) -> Vec { - let mut errors = Vec::new(); - - // 1. Validate: keep only entries whose source and destination are in bounds. - let mut valid: Vec<((i32, i32), (i32, i32))> = Vec::new(); - for &(sx, sy, dx, dy) in pairs { - if !self.in_bounds((sx, sy)) || !self.in_bounds((dx, dy)) { - errors.push(LogLine::error(format!( - "swap: out-of-bounds entry ({sx},{sy})->({dx},{dy})" - ))); - continue; - } - valid.push(((sx, sy), (dx, dy))); + + /// Shifts a set of cells, given as `(x, y)` coordinates. Backs the script + /// `shift()` fn. Returns any errors as [`LogLine`]s for the caller to log. + pub fn apply_shift(&mut self, cells: &[(i32, i32)]) -> Vec { + // Validate all the cells are in bounds, error if not: + if cells.iter().any(|&c| !self.in_bounds(c)) { + return vec![LogLine::error("Called shift() with a cell out of bounds")] } - // 2. Snapshot the solid at each unique source (read phase). Reading every - // source before any write is what lets cycles/swaps resolve. `None` is an - // empty source (the old `SolidSnapshot::Empty`). - let mut snapshots: HashMap<(i32, i32), Option> = HashMap::new(); - for &(src, _) in &valid { - snapshots.entry(src).or_insert_with(|| { - self.solid_at(src.0 as usize, src.1 as usize) - }); - } + // Get all the Solids at these cells: + let solids: Vec<_> = cells.iter().map(|&c| self.solid_at(c.0 as usize, c.1 as usize)).collect(); - // 3. Compute the final occupant of every affected cell. A source that is not - // anyone's destination is vacated (None); each entry writes its source's - // snapshot into its destination (a later entry wins a repeated destination). - let dsts: HashSet<(i32, i32)> = valid.iter().map(|&(_, d)| d).collect(); - let mut final_state: HashMap<(i32, i32), Option> = HashMap::new(); - for &(src, _) in &valid { - if !dsts.contains(&src) { - final_state.insert(src, None); - } - } - for &(src, dst) in &valid { - final_state.insert(dst, snapshots[&src]); - } + // Tell whether these are normally pushable in this direction. This doesn't count whether + // their target zone will be empty, just whether they would be willing to move that way at + // all. The direction comes in because of hcrates / vcrates: if their target cell is adjacent, + // then we'll check directions + //let mut pushable: Vec = Vec::with_capacity(solids.len()); - // The player's final cell: where a Player snapshot is installed, else its - // current cell (it stays put). Used to protect the player from being - // overwritten — computed up front so it's stable across the write loop. - let player_final = final_state - .iter() - .find(|(_, s)| s.is_some_and(|s| s.player())) - .map(|(&c, _)| c) - .unwrap_or((self.player.x, self.player.y)); + let mut immobile = HashSet::new(); + for (curr_idx, curr) in solids.iter().enumerate() { + let origin = cells[curr_idx]; + let target = cells[(curr_idx + 1) % cells.len()]; - // 4a. Clear each affected cell's current occupant, despawning any object that - // doesn't survive (isn't reused in final_state). The player and surviving - // objects keep their entity and are repositioned by the install step. - let survivors: HashSet = final_state - .values() - .filter_map(|s| s.and_then(|s| s.object_id())) - .collect(); - let affected: HashSet<(i32, i32)> = snapshots - .keys() - .copied() - .chain(final_state.keys().copied()) - .collect(); - for &(cx, cy) in &affected { - let (ux, uy) = (cx as usize, cy as usize); - match self.solid_at(ux, uy) { - Some(s) if s.object_id().is_some_and(|id| !survivors.contains(&id)) => { - self.remove_object(s.object_id().unwrap()); - } - Some(s) if s.archetype().is_some() => { - if let Some(z) = self.solid_cell_layer(ux, uy) { - *self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty); - } - } - _ => {} + // Whether these represent a single-cell h or v move. + let hmove = target.1 == origin.1 && (target.0 - origin.0).abs() == 1; + let vmove = target.0 == origin.0 && (target.1 - origin.1).abs() == 1; + + // We are never allowed to push a thing that won't push + // We won't push a horizontal-only thing vertically + // We won't push a vertical-only thing horizontally + let pushable = curr.as_ref().map_or(Pushable::Any, |c| c.pushable()); + if pushable == Pushable::No || + pushable == Pushable::Vertical && hmove || + pushable == Pushable::Horizontal && vmove { + immobile.insert(curr_idx); } } - // 4b. Install each cell's computed occupant. - for (&(cx, cy), snap) in &final_state { - let (ux, uy) = (cx as usize, cy as usize); - // Empty cells were already cleared in phase 4a. - let Some(solid) = snap else { continue }; - // The player wins its cell: never overwrite player_final with anything - // other than the player itself. A refused solid stays at its source - // cell; the overlap sweep below cleans up if that cell is now also - // someone else's destination. - if (cx, cy) == player_final && !solid.player() { - errors.push(LogLine::error(format!( - "swap: cannot overwrite the player at ({cx},{cy})" - ))); - continue; - } - solid.place(self, ux, uy); - } - - // 5. Overlap sweep: a solid refused onto the player (above) stays at its - // source cell, which may now also be a destination another entry wrote - // into. For any swapped cell holding more than one solid, keep a single - // occupant and delete the rest (never the player). Logs an error per - // deletion. - for &(cx, cy) in &affected { - let (ux, uy) = (cx as usize, cy as usize); - let player_here = self.player.x == cx && self.player.y == cy; - // Solid objects on this cell; keep the first, delete the rest. - let objs: Vec = self - .objects - .iter() - .filter(|(_, o)| o.x == ux && o.y == uy && o.solid) - .map(|(&id, _)| id) - .collect(); - let has_terrain = self.solid_cell_layer(ux, uy).is_some(); - - // How many solids share the cell (player + solid objects + solid terrain). - let total = player_here as usize + objs.len() + has_terrain as usize; - if total <= 1 { - continue; - } - errors.push(LogLine::error(format!( - "swap: {total} solids overlap at ({cx},{cy}); deleting extras" - ))); - - // Pick the survivor (priority: player > first object > terrain) and - // delete every other solid. The player is only ever a survivor, so it - // is never deleted. `keep_obj` is the object we keep, if any. - let keep_obj = (!player_here).then(|| objs.first().copied()).flatten(); - for id in &objs { - if Some(*id) != keep_obj { - self.remove_object(*id); + // Trace back from each immobile until we find an empty: + let mut blocked = HashSet::new(); + for curr_idx in immobile { + let mut prev_idx = curr_idx; + loop { + if solids[prev_idx].is_some() && !blocked.contains(&prev_idx) { + blocked.insert(prev_idx); + prev_idx = (prev_idx + cells.len() - 1) % cells.len(); + } else { + break } } - // Clear terrain unless it's the sole survivor (no player, no kept object). - if has_terrain - && (player_here || keep_obj.is_some()) - && let Some(z) = self.solid_cell_layer(ux, uy) - { - *self.get_mut(z, ux, uy) = (Glyph::transparent(), Archetype::Empty); + } + + // Clear all the cells so everything can be placed: + for (curr_idx, curr) in cells.iter().enumerate() { + if !blocked.contains(&curr_idx) { + self.clear_solid(curr.0 as usize, curr.1 as usize); } } - errors + // Now, move anything that we've decided is not blocked: + for (curr_idx, curr) in solids.iter().enumerate() { + if let Some(solid) = curr && !blocked.contains(&curr_idx) { + let target = cells[(curr_idx + 1) % cells.len()]; + solid.place(self, target.0 as usize, target.1 as usize); + } + } + + vec![] } /// Returns the index of the layer whose terrain cell at `(x, y)` is non-`Empty` diff --git a/kiln-core/src/game.rs b/kiln-core/src/game.rs index 33b1606..7ea0377 100644 --- a/kiln-core/src/game.rs +++ b/kiln-core/src/game.rs @@ -308,10 +308,9 @@ impl GameState { board.push(x as usize, y as usize, dir); } } - // apply_swap reads all sources then writes all destinations, so a - // whole batch of moves resolves simultaneously (cycles included). - Action::Swap(pairs) => { - logs.extend(board.apply_swap(&pairs)); + // apply_shift moves the named cells. + Action::Shift(cells) => { + logs.extend(board.apply_shift(&cells)); } // Accumulated and applied to `self.player_gems` after the borrow drops. Action::AddGems(n) => gem_delta += n, diff --git a/kiln-core/src/script.rs b/kiln-core/src/script.rs index 9a8ad05..774984c 100644 --- a/kiln-core/src/script.rs +++ b/kiln-core/src/script.rs @@ -1107,37 +1107,35 @@ fn register_write_api(engine: &mut Engine, queues: &QueueMap) { }, ); - // swap(pairs): apply a batch of one-way solid moves simultaneously. `pairs` is - // an array whose elements are four-int arrays `[src_x, src_y, dst_x, dst_y]`. A - // malformed entry is skipped with a logged error. Zero time cost. + // shift([[x, y], ...): Emits a shift action which moves the contents of each given cell in + // a loop (the last cell is moved to the first coord). Doesn't move things that aren't pushable, + // and won't move anything into a cell that's not vacant (or vacated by this shift). let q = queues.clone(); - engine.register_fn("swap", move |ctx: NativeCallContext, arr: Array| { + engine.register_fn("shift", move |ctx: NativeCallContext, arr: Array| { let src = source_of(&ctx); - let mut pairs: Vec<(i32, i32, i32, i32)> = Vec::new(); - for elem in &arr { - // Each entry must be an array of exactly four integers. - let coords: Option> = elem.read_lock::().and_then(|inner| { - if inner.len() == 4 { - inner.iter().map(|v| v.as_int().ok()).collect() - } else { - None - } - }); - match coords { - Some(c) => pairs.push((c[0] as i32, c[1] as i32, c[2] as i32, c[3] as i32)), - None => emit( - &q, - src, - Action::Log(LogLine::error( - "swap: each entry must be [src_x, src_y, dst_x, dst_y]".to_string(), - )), - ), - } + match read_coord_array(&arr) { + Ok(pairs) => emit(&q, src, Action::Shift(pairs)), + Err(_) => emit(&q, src, Action::Log(LogLine::error("shift: each entry must be [x, y]".to_string()))) } - emit(&q, src, Action::Swap(pairs)); }); } +/// Read a `Vec<(i32, i32)>` from a Rhai array, to receive a list of coordinates from a script. +/// Returns Err if the array isn't `[[x, y], ...]` +fn read_coord_array(arr: &Array) -> Result, ()> { + let mut pairs: Vec<(i32, i32)> = Vec::new(); + for elem in arr { + let coords: Option> = elem.read_lock::().and_then(|inner| { + inner.iter().map(|v| v.as_int().ok()).collect() + }); + if let Some(coords) = coords && coords.len() == 2 { + pairs.push((coords[0] as i32, coords[1] as i32)) + } else { + return Err(()); + } + } + Ok(pairs) +} // ── Queue API ───────────────────────────────────────────────────────────────── fn register_queue_api(engine: &mut Engine) { diff --git a/kiln-core/src/scripts/spinner.rhai b/kiln-core/src/scripts/spinner.rhai index 4983218..d2ccea3 100644 --- a/kiln-core/src/scripts/spinner.rhai +++ b/kiln-core/src/scripts/spinner.rhai @@ -16,30 +16,30 @@ fn tick(dt) { // Direction from the builtin tag; clockwise unless explicitly counter-clockwise. let cw = !Me.has_tag("BUILTIN_spinner_ccw"); - let ox = Me.x; - let oy = Me.y; - // The 8 neighbour offsets in clockwise order, starting at North. - let ring = [ - [ 0, -1], // N - [ 1, -1], // NE - [ 1, 0], // E - [ 1, 1], // SE - [ 0, 1], // S - [-1, 1], // SW - [-1, 0], // W - [-1, -1], // NW + let cw_ring = [ + [Me.x, Me.y-1], // N + [Me.x+1, Me.y-1], // NE + [Me.x+1, Me.y], // E + [Me.x+1, Me.y+1], // SE + [Me.x, Me.y+1], // S + [Me.x-1, Me.y+1], // SW + [Me.x-1, Me.y], // W + [Me.x-1, Me.y-1], // NW ]; - // The cardinal step that carries each ring cell to the *next* slot in the spin - // direction, and the index offset to that slot (+1 clockwise, -1 == +7 counter). - // CW: N->NE is East, NE->E is South, ... CCW: N->NW is West, NE->N is West, ... - let dirs = if cw { - [East, South, South, West, West, North, North, East] + // For CCW, it's the same list in reverse + let ccw_ring = []; + for neighbor in cw_ring { + ccw_ring.insert(0, neighbor); + } + + // Rotate the neighbor cells: + if cw { + shift(cw_ring); } else { - [West, West, North, North, East, East, South, South] - }; - let step = if cw { 1 } else { 7 }; + shift(ccw_ring); + } // Animate the glyph one frame per rotation (changing only the character): the // line spins '/'-'\'-, slash-swapped for counter-clockwise. Frame state lives in @@ -50,55 +50,5 @@ fn tick(dt) { set_tile(frames[f % 4]); Registry.set(fkey, (f + 1) % 4); - // moves[i]: will ring cell i rotate into its destination slot? Seed with - // can_shift, which is true only when cell i holds a pushable whose immediate - // next cell isn't a wall (so it could move *if* that next cell ends up free). - let moves = []; - for i in 0..8 { - let sx = ox + ring[i][0]; - let sy = oy + ring[i][1]; - moves.push(can_shift(sx, sy, dirs[i])); - } - - // Cascade: a cell may only move if its destination is a hole (`passable`), or - // the occupant of that destination also moves. Demote until stable. A broken - // arc collapses back from the jam; a full pushable cycle never demotes (every - // destination's occupant moves) and rotates whole via swap's cycle handling. - loop { - let changed = false; - for i in 0..8 { - let n = (i + step) % 8; - let pn = (i + step - 1) % 8; - let dx = ox + ring[n][0]; - let dy = oy + ring[n][1]; - let sx = ox + ring[pn][0]; - let sy = oy + ring[pn][1]; - - let blocked = !passable(dx, dy) && !combinable(sx, sy, dx, dy); - - if moves[i] && blocked && !moves[n] { - moves[i] = false; - changed = true; - } - } - if !changed { break; } - } - - // Emit the surviving rotations as one simultaneous batch, then pace to twice - // a second. Coordinates are pulled into locals first to keep each expression - // simple (Rhai caps expression complexity). - let batch = []; - for i in 0..8 { - if moves[i] { - let n = (i + step) % 8; - let sx = ox + ring[i][0]; - let sy = oy + ring[i][1]; - let dx = ox + ring[n][0]; - let dy = oy + ring[n][1]; - batch.push([sx, sy, dx, dy]); - } - } - - swap(batch); delay(0.5); } diff --git a/maps/start.toml b/maps/start.toml index 6f56325..9fb73ba 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -167,6 +167,18 @@ fn tick(dt) { } """ +shifter = """ +fn tick(dt) { + let x = Me.x; + let y = Me.y; + + if Queue.length() == 0 { + shift([[x-1, y], [x, y-1], [x+1, y], [x, y+1]]); + delay(0.25); + } +} +""" + [boards.start.map] name = "Starting Room" width = 60 @@ -214,8 +226,8 @@ gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg content = """ ############################################################ # # -# # -# # +# o # +# oS # # # # 1 # # ###### B # @@ -252,6 +264,7 @@ content = """ "V" = { kind = "object", tile = 1, fg = "#33aa33", bg = "#000000", script_name = "mover" } "M" = { kind = "object", tile = 15, fg = "#ffdd88", bg = "#000000", solid = true, name = "muffin", script_name = "muffin" } "B" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "noticeboard", script_name = "noticeboard" } +"S" = { kind = "object", tile = 240, fg = "#cc9944", bg = "#000000", solid = true, name = "shifter", script_name = "shifter" } "/" = { kind = "spinner_cw" } "t" = { kind = "gem" }