diff --git a/CLAUDE.md b/CLAUDE.md index 9545828..2bd3ae1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,8 +307,10 @@ content = """ [[boards.room1.objects]] # optional; array of tables # Placement: either `x`/`y`, OR a `palette` char that appears in the grid. # Convention: object palette chars are uppercase letters (here `G`). The char -# must appear exactly once in the grid, be unique across objects, and not be a -# [palette] key; the cell under it loads as Empty. +# may appear multiple times — one ObjectDef is spawned per occurrence in reading +# order. Only one [[objects]] entry may use a given char, and it must not be a +# [palette] key. Each matching cell loads as Empty. If the entry has a `name`, +# only the first spawned instance keeps it (names must be board-unique). palette = "G" tile = "#" fg = "#aa3333" diff --git a/kiln-core/src/map_file.rs b/kiln-core/src/map_file.rs index 22abd55..8b302dd 100644 --- a/kiln-core/src/map_file.rs +++ b/kiln-core/src/map_file.rs @@ -163,9 +163,11 @@ pub(crate) struct MapFileObjectEntry { #[serde(default, skip_serializing_if = "Option::is_none")] y: Option, /// Single grid character marking where this object sits, as an alternative to - /// `x`/`y`. By convention an uppercase letter. The char must appear exactly - /// once in the grid, be unique across objects, and not collide with a - /// `[palette]` key; the cell under it is loaded as `Empty`. + /// `x`/`y`. By convention an uppercase letter. The char may appear multiple + /// times in the grid — one `ObjectDef` is spawned per occurrence in reading + /// order. Only one `[[objects]]` entry may use a given char, and it must not + /// collide with a `[palette]` key. Each cell under the char loads as `Empty`. + /// If the entry has a `name`, only the first spawned instance keeps it. #[serde(default, skip_serializing_if = "Option::is_none")] palette: Option, /// Tile index into the board's bitmap font. Accepts integer or single-char string. @@ -364,12 +366,12 @@ impl TryFrom for Board { } // Pass 2: walk the validated grid and build cells. Object palette chars - // become `Empty` floor (with their position remembered); truly unknown + // become `Empty` floor (with their positions remembered); truly unknown // chars become a visible `ErrorBlock` so the cell count stays correct. let canonical_empty = (Archetype::Empty.default_glyph(), Archetype::Empty); let mut cells: Vec<(Glyph, Archetype)> = Vec::with_capacity(w * h); - let mut obj_palette_pos: HashMap = HashMap::new(); - let mut obj_palette_count: HashMap = HashMap::new(); + // All grid positions (in reading order) at which each object palette char appears. + let mut obj_palette_positions: HashMap> = HashMap::new(); // Where the player's char was first seen, and how many times (for recovery). let mut player_grid_pos: Option<(usize, usize)> = None; let mut player_grid_count: usize = 0; @@ -384,8 +386,7 @@ impl TryFrom for Board { player_grid_count += 1; } else if palette_char_owner.contains_key(&ch) { cells.push(canonical_empty); - obj_palette_pos.entry(ch).or_insert((x, y)); - *obj_palette_count.entry(ch).or_insert(0) += 1; + obj_palette_positions.entry(ch).or_default().push((x, y)); } else { load_errors.push(LogLine::error(format!( "unknown grid character '{ch}' at ({x}, {y}); using error block" @@ -395,23 +396,17 @@ impl TryFrom for Board { } } - // Resolve each surviving object palette char: missing → drop; multiple → - // keep the object at the first occurrence (already recorded) and report. + // Resolve each surviving object palette char: missing → drop the entry. + // Multiple occurrences are valid — each spawns its own ObjectDef below. for (&ch, &owner) in &palette_char_owner { if skip[owner] { continue; } - match obj_palette_count.get(&ch).copied().unwrap_or(0) { - 0 => { - load_errors.push(LogLine::error(format!( - "object palette char '{ch}' not found in the grid; skipping object" - ))); - skip[owner] = true; - } - 1 => {} - n => load_errors.push(LogLine::error(format!( - "object palette char '{ch}' appears {n} times in the grid; using the first" - ))), + if obj_palette_positions.get(&ch).map_or(true, Vec::is_empty) { + load_errors.push(LogLine::error(format!( + "object palette char '{ch}' not found in the grid; skipping object" + ))); + skip[owner] = true; } } @@ -479,13 +474,14 @@ impl TryFrom for Board { if skip[i] { continue; } - // Resolve the object's cell from either its palette char or x/y. - let (x, y) = if let Some(p) = e.palette.as_deref() { - // Single-char and uniqueness already validated; position recorded. - obj_palette_pos[&p.chars().next().unwrap()] + // Collect all grid positions for this entry. Palette entries produce one + // position per occurrence (in reading order); x/y entries produce exactly one. + let positions: Vec<(usize, usize)> = if let Some(p) = e.palette.as_deref() { + let ch = p.chars().next().unwrap(); + obj_palette_positions.get(&ch).cloned().unwrap_or_default() } else { match (e.x, e.y) { - (Some(x), Some(y)) if x < w && y < h => (x, y), + (Some(x), Some(y)) if x < w && y < h => vec![(x, y)], (Some(x), Some(y)) => { load_errors.push(LogLine::error(format!( "object {i}: x/y ({x}, {y}) out of bounds; skipping object" @@ -500,54 +496,66 @@ impl TryFrom for Board { } } }; - // Validate name uniqueness: first claimant keeps the name, later ones - // have their name cleared and a nonfatal error is recorded. - let name = e.name.and_then(|n| { - use std::collections::hash_map::Entry; - match seen_names.entry(n.clone()) { - Entry::Vacant(v) => { v.insert(i); Some(n) } - Entry::Occupied(o) => { - load_errors.push(LogLine::error(format!( - "object {i}: name {n:?} already used by object {}; clearing name", - o.get() - ))); - None + + // Spawn one ObjectDef per position. Only the first instance may claim the + // entry's name; subsequent copies are anonymous (names must be board-unique). + for (instance_idx, &(x, y)) in positions.iter().enumerate() { + let name = if instance_idx == 0 { + // Validate name uniqueness: first claimant keeps the name, later ones + // have their name cleared and a nonfatal error is recorded. + e.name.as_ref().and_then(|n| { + use std::collections::hash_map::Entry; + match seen_names.entry(n.clone()) { + Entry::Vacant(v) => { v.insert(i); Some(n.clone()) } + Entry::Occupied(o) => { + load_errors.push(LogLine::error(format!( + "object {i}: name {n:?} already used by object {}; clearing name", + o.get() + ))); + None + } + } + }) + } else { + None + }; + + let obj = ObjectDef { + id: 0, // stamped by Board::add_object + x, + y, + glyph: Glyph { + tile: e.tile.into_u32(), + fg: parse_color(&e.fg), + bg: parse_color(&e.bg), + }, + solid: e.solid, + opaque: e.opaque, + pushable: e.pushable, + script_name: e.script_name.clone(), + tags: e.tags.iter().cloned().collect(), + name, + }; + + // A solid object may not share a cell with another solid. + if obj.solid { + let idx = y * w + x; + if solid_occupied[idx] { + // The player silently wins its own cell (by design); any other + // solid collision is a reportable mistake. + if idx != pidx { + load_errors.push(LogLine::error(format!( + "object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object" + ))); + } + continue; } + solid_occupied[idx] = true; } - }); - let obj = ObjectDef { - id: 0, // stamped by Board::add_object - x, - y, - glyph: Glyph { - tile: e.tile.into_u32(), - fg: parse_color(&e.fg), - bg: parse_color(&e.bg), - }, - solid: e.solid, - opaque: e.opaque, - pushable: e.pushable, - script_name: e.script_name, - tags: e.tags.into_iter().collect(), - name, - }; - // A solid object may not share a cell with another solid. - if obj.solid { - let idx = y * w + x; - if solid_occupied[idx] { - // The player silently wins its own cell (by design); any other - // solid collision is a reportable mistake. - if idx != pidx { - load_errors.push(LogLine::error(format!( - "object {i}: solid object at ({x}, {y}) conflicts with an existing solid; skipping object" - ))); - } - continue; - } - solid_occupied[idx] = true; + + objects.insert(next_object_id, obj); + next_object_id += 1; } - objects.insert(next_object_id, obj); - next_object_id += 1; } Ok(Board { diff --git a/kiln-core/src/tests/map_file/object_placement.rs b/kiln-core/src/tests/map_file/object_placement.rs index 2793486..eb346f7 100644 --- a/kiln-core/src/tests/map_file/object_placement.rs +++ b/kiln-core/src/tests/map_file/object_placement.rs @@ -35,13 +35,30 @@ fn palette_char_absent_from_grid_drops_object() { } #[test] -fn palette_char_appearing_twice_uses_first() { - // `G` appears at (0,0) and (2,0): the object lands at the first, and the - // map is flagged invalid (an extra appearance was reported). - let board = load_board(&map_3x1("G.G", &obj_palette("G", ""))); - assert_eq!(board.objects.len(), 1); +fn palette_char_appearing_twice_spawns_two_objects() { + // `G` appears at (0,0) and (1,0); player is at (2,0) so neither G conflicts. + // Two independent ObjectDefs are spawned. + let board = load_board(&map_3x1("GG.", &obj_palette("G", ""))); + assert_eq!(board.objects.len(), 2); + // IDs are assigned in reading order (left-to-right). assert_eq!((board.objects[&1].x, board.objects[&1].y), (0, 0)); - assert!(!board.is_valid()); + assert_eq!((board.objects[&2].x, board.objects[&2].y), (1, 0)); + // Both cells load as Empty in the grid. + assert_eq!(board.get(0, 0).1, Archetype::Empty); + assert_eq!(board.get(1, 0).1, Archetype::Empty); + assert!(board.is_valid(), "multiple occurrences are not an error"); +} + +#[test] +fn palette_char_multi_occurrence_only_first_keeps_name() { + // Two occurrences of a named entry (solid = false to avoid player conflict at (2,0)). + // First instance gets the name; second is anonymous. + let entry = obj_palette("G", "solid = false\nname = \"guard\"\n"); + let board = load_board(&map_3x1("GGG", &entry)); + assert_eq!(board.objects.len(), 3); + assert_eq!(board.objects[&1].name.as_deref(), Some("guard")); + assert_eq!(board.objects[&2].name, None); + assert_eq!(board.objects[&3].name, None); } #[test] diff --git a/kiln-core/src/tests/scripting.rs b/kiln-core/src/tests/scripting.rs index d43ea59..3911f13 100644 --- a/kiln-core/src/tests/scripting.rs +++ b/kiln-core/src/tests/scripting.rs @@ -106,7 +106,9 @@ fn start_map_greeter_runs_init() { // 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 world = crate::world::load(path).expect("load start.toml"); + let mut world = crate::world::load(path).expect("load start.toml"); + // Pin to the "start" board regardless of the world's current default entry point. + world.start = "start".to_string(); let mut game = GameState::from_world(world); game.run_init(); assert!( diff --git a/kiln-core/src/utils.rs b/kiln-core/src/utils.rs index 0e7aa0b..58b2685 100644 --- a/kiln-core/src/utils.rs +++ b/kiln-core/src/utils.rs @@ -142,7 +142,7 @@ pub(crate) enum ScriptArg { } /// A cardinal movement direction. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub enum Direction { North, South, diff --git a/kiln-tui/src/speech.rs b/kiln-tui/src/speech.rs index 5116ffc..a803481 100644 --- a/kiln-tui/src/speech.rs +++ b/kiln-tui/src/speech.rs @@ -2,16 +2,18 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::{Color, Line, Style, Widget}; use ratatui::widgets::{Block, Fill, Paragraph}; -use kiln_core::Board; use kiln_core::game::SpeechBubble; +use kiln_core::{Board, Direction}; use crate::render::BoardWidget; use crate::utils::rects_overlap; /// A ratatui widget that draws speech bubbles for all active [`SpeechBubble`]s over the board. /// -/// Each bubble appears as a box-drawing frame with a tail pointing down at the speaker's cell. -/// Bubbles that would collide (same horizontal range, adjacent vertically) are shifted upward -/// one at a time until there is no overlap. +/// Each bubble is placed in the direction (North/South/East/West) that minimises tail length; +/// ties break in that preference order. Bubbles that would cover the player or an +/// already-placed bubble are shifted until they clear. If no clear position exists in any +/// direction the bubble is forced to the top-left of the viewport (overlap tolerated, tail +/// suppressed). Off-screen speakers still get a bubble; their tails are suppressed. pub struct SpeechBubblesWidget<'a> { /// The board, used to look up object positions and viewport layout. pub board: &'a Board, @@ -19,75 +21,66 @@ pub struct SpeechBubblesWidget<'a> { pub bubbles: &'a [SpeechBubble], } +/// A fully-resolved bubble placement ready for the three draw passes. +struct Placement<'a> { + /// Speaker's screen column. + sx: u16, + /// Speaker's screen row. + sy: u16, + /// False when the speaker is off-screen; tail is suppressed in that case. + on_screen: bool, + /// Wrapped text lines to display inside the box. + lines: Vec<&'a str>, + /// Collision rect — equal to the visual box (no extra padding). + rect: Rect, + /// Which side the box is on relative to the speaker. + dir: Direction, +} + impl<'a> SpeechBubblesWidget<'a> { /// Creates a widget for the given board and bubble slice. pub fn new(board: &'a Board, bubbles: &'a [SpeechBubble]) -> Self { Self { board, bubbles } } - /// Finds a non-overlapping screen position for a bubble and records it in `placed`. + /// Places a bubble for a speaker at `(obj_sx, obj_sy)`. /// - /// Sizes the bubble from `lines`, centers it horizontally over `(obj_sx, obj_sy)`, - /// then shifts upward until it clears all already-placed rects (or runs out of room). - /// Returns the full bubble rect (including the tail row) on success, or `None` if - /// the bubble can't fit within `area`. Appends to `placed` on success. + /// Tries all four directions and picks the one whose non-overlapping, player-safe + /// placement has the shortest tail. Falls back to a forced top-left placement if all + /// four directions fail. Appends the chosen rect to `placed`. fn place_bubble( area: Rect, obj_sx: u16, obj_sy: u16, - lines: &[&str], + box_w: u16, + box_h: u16, placed: &mut Vec, - ) -> Option { - let text_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16; - let box_w = text_w + 4; - let box_h = lines.len() as u16 + 3; + player: Option<(u16, u16)>, + ) -> (Rect, Direction) { + let dirs = [Direction::North, Direction::South, Direction::East, Direction::West]; + // Collect (rect, tail_length, dir) for every direction that can be placed. + // stable min_by_key preserves the first element on ties, so N > S > E > W wins. + let best = dirs + .iter() + .filter_map(|&dir| { + try_place_direction(dir, area, obj_sx, obj_sy, box_w, box_h, placed, player) + .map(|(r, len)| (r, len, dir)) + }) + .min_by_key(|&(_, len, _)| len); - // Center horizontally over the speaker, clamped inside the viewport. - let left = obj_sx.saturating_sub(box_w / 2) - .min(area.right().saturating_sub(box_w)) - .max(area.left()); + let (rect, dir) = best.map_or_else( + // Forced fallback: place at top-left, caller suppresses tail via `on_screen`. + || (Rect { x: area.x + 1, y: area.y + 1, width: box_w, height: box_h }, Direction::North), + |(r, _, d)| (r, d), + ); - // Start directly above the speaker and shift upward until no overlap. - let mut top = obj_sy.saturating_sub(box_h); - let mut iterations = 0u16; - 'place: loop { - if iterations > 20 || top < area.top() { return None; } - let candidate = Rect { x: left, y: top, width: box_w, height: box_h }; - for p in placed.iter() { - if rects_overlap(candidate, *p) { - top = top.saturating_sub(1); - iterations += 1; - continue 'place; - } - } - break; - } - - let rect = Rect { x: left, y: top, width: box_w, height: box_h }; placed.push(rect); - Some(rect) + (rect, dir) } } impl Widget for SpeechBubblesWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { - // Determine the screen position of each visible bubble's speaker. - let mut items: Vec<(u16, u16, &SpeechBubble)> = self.bubbles - .iter() - .filter_map(|b| { - let obj = self.board.objects.get(&b.object_id)?; - let (sx, sy) = board_screen_pos(area, self.board, obj.x, obj.y)?; - Some((sx, sy, b)) - }) - .collect(); - - // Process bubbles lowest on screen first so upward-shift doesn't interfere - // with already-placed higher bubbles. - items.sort_by_key(|&(_, sy, _)| std::cmp::Reverse(sy)); - - // Track the rects of already-placed bubbles to detect collisions. - let mut placed: Vec = Vec::new(); - let bubble_style = Style::default() .fg(Color::White) .bg(Color::Rgb(20, 20, 40)); @@ -95,59 +88,403 @@ impl Widget for SpeechBubblesWidget<'_> { .fg(Color::Rgb(160, 160, 220)) .bg(Color::Rgb(20, 20, 40)); - // Pass 1: collect placements in the same bottom-first order. - let mut placements: Vec<(u16, u16, Vec<&str>, Rect)> = Vec::new(); - for (obj_sx, obj_sy, bubble) in items { + // Determine the player's screen position for bubble avoidance. + let (px, py, player_vis) = board_screen_pos( + area, + self.board, + self.board.player.x.max(0) as usize, + self.board.player.y.max(0) as usize, + ); + let player = player_vis.then_some((px, py)); + + // Map each bubble to its speaker's (clamped) screen position. + let mut items: Vec<(u16, u16, bool, &SpeechBubble)> = self.bubbles + .iter() + .filter_map(|b| { + let obj = self.board.objects.get(&b.object_id)?; + let (sx, sy, on_screen) = board_screen_pos(area, self.board, obj.x, obj.y); + Some((sx, sy, on_screen, b)) + }) + .collect(); + + // Process bottom-most speakers first so upward shifts don't displace higher bubbles. + items.sort_by_key(|&(_, sy, _, _)| std::cmp::Reverse(sy)); + + let mut placed: Vec = Vec::new(); + let mut placements: Vec = Vec::new(); + + for (sx, sy, on_screen, bubble) in items { let lines: Vec<&str> = bubble.text.split('\n').collect(); - let Some(r) = Self::place_bubble(area, obj_sx, obj_sy, &lines, &mut placed) else { continue }; - placements.push((obj_sx, obj_sy, lines, r)); + let text_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16; + let box_w = text_w + 4; + let box_h = lines.len() as u16 + 2; + let (rect, dir) = + SpeechBubblesWidget::place_bubble(area, sx, sy, box_w, box_h, &mut placed, player); + placements.push(Placement { sx, sy, on_screen, lines, rect, dir }); } - // Pass 2: draw top-most bubbles first so lower boxes naturally cover extended tails. - for (obj_sx, obj_sy, lines, r) in placements.iter().rev() { - let (obj_sx, obj_sy) = (*obj_sx, *obj_sy); + draw_tails(&placements, buf, border_style); + draw_boxes(&placements, buf, bubble_style, border_style); + draw_join_chars(&placements, buf, border_style); + } +} - // Render the bordered box (top border + one row per line + bottom border). - let box_rect = Rect { height: r.height - 1, ..*r }; - let block = Block::bordered() - .border_style(border_style) - .style(bubble_style); - let text_rect = block.inner(box_rect); - block.render(box_rect, buf); +// ─── Draw passes ───────────────────────────────────────────────────────────── - Fill::new(" ").style(bubble_style).render(text_rect, buf); - - let para_lines: Vec = lines.iter() - .map(|l| Line::styled(format!(" {l}"), bubble_style)) - .collect(); - Paragraph::new(para_lines).render(text_rect, buf); - - // Overwrite the tail-join cell on the bottom border, then draw the full tail - // from below the box down to the source object. Lower bubbles are drawn after - // us (pass 2 iterates top-first), so their boxes naturally cover any tail chars - // that pass through them — no explicit occlusion check needed. - let tail_col = obj_sx.clamp(r.x + 1, r.x + r.width - 2); - let border_bottom = r.y + r.height - 2; - if let Some(cell) = buf.cell_mut((tail_col, border_bottom)) { - cell.set_char('┬').set_style(border_style); +/// Pass 1: `│`/`─` tail stem characters. +/// +/// Drawn first so box borders (pass 2) overwrite any stem that crosses another box. +/// Skipped for off-screen speakers. +fn draw_tails(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) { + for p in placements { + if !p.on_screen { continue; } + let r = p.rect; + match p.dir { + Direction::North => { + let col = tail_col(p.sx, r); + for y in (r.y + r.height)..p.sy { + if let Some(c) = buf.cell_mut((col, y)) { c.set_char('│').set_style(style); } + } } - for y in (border_bottom + 1)..obj_sy { - if let Some(cell) = buf.cell_mut((tail_col, y)) { - cell.set_char('│').set_style(border_style); + Direction::South => { + let col = tail_col(p.sx, r); + for y in (p.sy + 1)..r.y { + if let Some(c) = buf.cell_mut((col, y)) { c.set_char('│').set_style(style); } + } + } + Direction::East => { + let row = join_row(p.sy, r); + for x in (p.sx + 1)..r.x { + if let Some(c) = buf.cell_mut((x, row)) { c.set_char('─').set_style(style); } + } + } + Direction::West => { + // Box right border is at r.x + r.width - 1; first tail col is r.x + r.width. + let row = join_row(p.sy, r); + for x in (r.x + r.width)..p.sx { + if let Some(c) = buf.cell_mut((x, row)) { c.set_char('─').set_style(style); } } } } } } -/// Converts a board cell coordinate to terminal screen coordinates within `area`, -/// accounting for centering/scrolling. Returns `None` if the cell is off-screen. -fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> Option<(u16, u16)> { - let (off_x, pad_x, cols) = BoardWidget::axis(board.width, area.width as usize, board.player.x); - let (off_y, pad_y, rows) = BoardWidget::axis(board.height, area.height as usize, board.player.y); - if bx < off_x || bx >= off_x + cols { return None; } - if by < off_y || by >= off_y + rows { return None; } - let sx = area.x + pad_x + (bx - off_x) as u16; - let sy = area.y + pad_y + (by - off_y) as u16; - Some((sx, sy)) +/// Pass 2: bordered boxes with text. +/// +/// Overwrites any stray tail chars that passed through another box's area. +/// Order within this pass doesn't matter since placed rects are non-overlapping. +fn draw_boxes(placements: &[Placement<'_>], buf: &mut Buffer, bubble_style: Style, border_style: Style) { + for p in placements { + let box_rect = p.rect; + let block = Block::bordered().border_style(border_style).style(bubble_style); + let text_rect = block.inner(box_rect); + block.render(box_rect, buf); + Fill::new(" ").style(bubble_style).render(text_rect, buf); + let para_lines: Vec = p.lines.iter() + .map(|l| Line::styled(format!(" {l}"), bubble_style)) + .collect(); + Paragraph::new(para_lines).render(text_rect, buf); + } +} + +/// Pass 3: single join characters where the tail meets the box border. +/// +/// Applied last so they are never hidden by the box border drawn in pass 2. +/// Skipped for off-screen speakers. +fn draw_join_chars(placements: &[Placement<'_>], buf: &mut Buffer, style: Style) { + for p in placements { + if !p.on_screen { continue; } + let r = p.rect; + match p.dir { + Direction::North => { + let col = tail_col(p.sx, r); + let bottom_border = r.y + r.height - 1; + if let Some(c) = buf.cell_mut((col, bottom_border)) { + c.set_char('┬').set_style(style); + } + } + Direction::South => { + let col = tail_col(p.sx, r); + if let Some(c) = buf.cell_mut((col, r.y)) { + c.set_char('┴').set_style(style); + } + } + Direction::East => { + // Tail arrives from the left; ┤ (branch facing left) on the left border. + let row = join_row(p.sy, r); + if let Some(c) = buf.cell_mut((r.x, row)) { + c.set_char('┤').set_style(style); + } + } + Direction::West => { + // Tail departs to the right; ├ (branch facing right) on the right border. + let row = join_row(p.sy, r); + if let Some(c) = buf.cell_mut((r.x + r.width - 1, row)) { + c.set_char('├').set_style(style); + } + } + } + } +} + +// ─── Placement helpers ──────────────────────────────────────────────────────── + +/// Tries to find a non-overlapping, player-safe position for the box in direction `dir`. +/// +/// Returns `(rect, tail_length)` on success, or `None` if no clear spot found within +/// 20 shift iterations. +#[allow(clippy::too_many_arguments)] +fn try_place_direction( + dir: Direction, + area: Rect, + obj_sx: u16, + obj_sy: u16, + box_w: u16, + box_h: u16, + placed: &[Rect], + player: Option<(u16, u16)>, +) -> Option<(Rect, u32)> { + let bw = box_w as i32; + let bh = box_h as i32; + + // Fixed (non-shifting) dimension: center on the speaker along the perpendicular axis. + let fixed_left = center_left(obj_sx, box_w, area) as i32; + let fixed_top = center_top(obj_sy, box_h, area) as i32; + + // Starting position on the shifting axis and the per-iteration step. + let (mut pos, delta): (i32, i32) = match dir { + Direction::North => (obj_sy as i32 - bh - 1, -1), + Direction::South => (obj_sy as i32 + 2, 1), + Direction::East => (obj_sx as i32 + 2, 1), + Direction::West => (obj_sx as i32 - bw - 1, -1), + }; + + for _ in 0..=20 { + let (left, top) = match dir { + Direction::North | Direction::South => (fixed_left, pos), + Direction::East | Direction::West => (pos, fixed_top), + }; + + // Out-of-viewport: shifting further in this direction only makes it worse. + if left < area.left() as i32 + || top < area.top() as i32 + || left + bw > area.right() as i32 + || top + bh > area.bottom() as i32 + { + break; + } + + let r = Rect { x: left as u16, y: top as u16, width: box_w, height: box_h }; + + let overlaps_placed = placed.iter().any(|p| rects_overlap(r, *p)); + let blocks_player = player.is_some_and(|(px, py)| { + (px >= r.x && px < r.x + r.width && py >= r.y && py < r.y + r.height) + || tail_covers(dir, r, obj_sx, obj_sy, px, py) + }); + + if !overlaps_placed && !blocks_player { + return Some((r, tail_length(dir, r, obj_sx, obj_sy))); + } + + pos += delta; + } + + None +} + +/// Returns the number of stem characters the tail would have for a given placement. +fn tail_length(dir: Direction, r: Rect, obj_sx: u16, obj_sy: u16) -> u32 { + // Each value is the count of │ or ─ chars between the join char and the speaker. + match dir { + Direction::North => (obj_sy as i32 - r.y as i32 - r.height as i32).max(0) as u32, + Direction::South => (r.y as i32 - obj_sy as i32 - 1).max(0) as u32, + Direction::East => (r.x as i32 - obj_sx as i32 - 1).max(0) as u32, + Direction::West => (obj_sx as i32 - (r.x as i32 + r.width as i32)).max(0) as u32, + } +} + +/// Returns `true` if the tail for `(dir, r)` would pass through the cell `(px, py)`. +fn tail_covers(dir: Direction, r: Rect, obj_sx: u16, obj_sy: u16, px: u16, py: u16) -> bool { + match dir { + Direction::North => { + let col = tail_col(obj_sx, r); + px == col && py >= r.y + r.height && py < obj_sy + } + Direction::South => { + let col = tail_col(obj_sx, r); + px == col && py > obj_sy && py < r.y + } + Direction::East => { + let row = join_row(obj_sy, r); + py == row && px > obj_sx && px < r.x + } + Direction::West => { + let row = join_row(obj_sy, r); + // Tail runs from r.x + r.width (first col after right border) to obj_sx - 1. + py == row && px >= r.x + r.width && px < obj_sx + } + } +} + +/// The column of the vertical tail, clamped to the box interior (not on a corner). +fn tail_col(obj_sx: u16, r: Rect) -> u16 { + obj_sx.clamp(r.x + 1, r.x + r.width - 2) +} + +/// The row of the horizontal tail, clamped to the box interior (not on a corner). +fn join_row(obj_sy: u16, r: Rect) -> u16 { + let lo = r.y + 1; + let hi = (r.y + r.height).saturating_sub(2).max(lo); + obj_sy.clamp(lo, hi) +} + +/// Centers `box_w` horizontally over `obj_sx`, clamped inside `area`. +fn center_left(obj_sx: u16, box_w: u16, area: Rect) -> u16 { + obj_sx + .saturating_sub(box_w / 2) + .min(area.right().saturating_sub(box_w)) + .max(area.left()) +} + +/// Centers `box_h` vertically at `obj_sy`, clamped inside `area`. +/// +/// Uses `(box_h - 1) / 2` as the offset so the visual centre of the box aligns +/// with the speaker row for both odd and even heights. +fn center_top(obj_sy: u16, box_h: u16, area: Rect) -> u16 { + obj_sy + .saturating_sub((box_h - 1) / 2) + .min(area.bottom().saturating_sub(box_h)) + .max(area.top()) +} + +/// Converts a board cell coordinate to terminal screen coordinates within `area`. +/// +/// Returns `(sx, sy, on_screen)`. Off-screen coordinates are clamped to the viewport edge; +/// `on_screen` is `false` when clamping occurred (tail should be suppressed for that bubble). +fn board_screen_pos(area: Rect, board: &Board, bx: usize, by: usize) -> (u16, u16, bool) { + let (off_x, pad_x, _) = BoardWidget::axis(board.width, area.width as usize, board.player.x); + let (off_y, pad_y, _) = BoardWidget::axis(board.height, area.height as usize, board.player.y); + let raw_sx = area.x as i32 + pad_x as i32 + (bx as i32 - off_x as i32); + let raw_sy = area.y as i32 + pad_y as i32 + (by as i32 - off_y as i32); + let sx = raw_sx.clamp(area.left() as i32, area.right() as i32 - 1) as u16; + let sy = raw_sy.clamp(area.top() as i32, area.bottom() as i32 - 1) as u16; + (sx, sy, raw_sx == sx as i32 && raw_sy == sy as i32) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn big_area() -> Rect { + Rect { x: 0, y: 0, width: 100, height: 40 } + } + + // ── Initial positions: each direction leaves exactly 1 tail cell ────────── + + #[test] + fn north_places_box_above_with_one_tail_row() { + let (r, len) = try_place_direction( + Direction::North, big_area(), 50, 20, 10, 5, &[], None, + ).unwrap(); + assert_eq!(r.y, 14); // 20 - 5 - 1 + assert_eq!(r.y + r.height, 19); // tail range 19..20 = 1 row + assert_eq!(len, 1); + } + + #[test] + fn south_places_box_below_with_one_tail_row() { + let (r, len) = try_place_direction( + Direction::South, big_area(), 50, 20, 10, 5, &[], None, + ).unwrap(); + assert_eq!(r.y, 22); // 20 + 2; tail range 21..22 = 1 row + assert_eq!(len, 1); + } + + #[test] + fn east_places_box_right_with_one_tail_col() { + let (r, len) = try_place_direction( + Direction::East, big_area(), 50, 20, 10, 5, &[], None, + ).unwrap(); + assert_eq!(r.x, 52); // 50 + 2; tail range 51..52 = 1 col + assert_eq!(len, 1); + } + + #[test] + fn west_places_box_left_with_one_tail_col() { + let (r, len) = try_place_direction( + Direction::West, big_area(), 50, 20, 10, 5, &[], None, + ).unwrap(); + assert_eq!(r.x, 39); // 50 - 10 - 1 + assert_eq!(r.x + r.width, 49); // box right at 49; tail at col 49; speaker at 50 + assert_eq!(len, 1); + } + + // ── Obstacle shifting ───────────────────────────────────────────────────── + + #[test] + fn north_shifts_up_when_initial_position_blocked() { + // Blocking rect touches only the bottom row of the initial box (rows 14–18), + // so the box needs to shift up by exactly 1 to clear it. + let blocking = Rect { x: 0, y: 18, width: 100, height: 1 }; + let (r, len) = try_place_direction( + Direction::North, big_area(), 50, 20, 10, 5, &[blocking], None, + ).unwrap(); + assert_eq!(r.y, 13); // shifted up one row from initial 14 + assert_eq!(len, 2); // tail is now 2 rows (rows 18–19) + } + + // ── Out of room ─────────────────────────────────────────────────────────── + + #[test] + fn north_returns_none_when_no_room_above() { + // Speaker at row 4; North needs box_h(5) + 1(tail gap) = 6 rows, only 4 available. + let result = try_place_direction( + Direction::North, big_area(), 50, 4, 10, 5, &[], None, + ); + assert!(result.is_none()); + } + + // ── Direction preference via place_bubble ───────────────────────────────── + + #[test] + fn place_bubble_prefers_north_when_all_equal() { + // All four directions have tail_length = 1; North wins by preference order. + let mut placed = vec![]; + let (_, dir) = SpeechBubblesWidget::place_bubble( + big_area(), 50, 20, 10, 5, &mut placed, None, + ); + assert_eq!(dir, Direction::North); + } + + #[test] + fn place_bubble_falls_back_to_south_when_north_blocked() { + // Speaker at row 4: North can't fit (same condition as the None test above). + let mut placed = vec![]; + let (_, dir) = SpeechBubblesWidget::place_bubble( + big_area(), 50, 4, 10, 5, &mut placed, None, + ); + assert_eq!(dir, Direction::South); + } + + // ── join_row clamping ───────────────────────────────────────────────────── + + #[test] + fn join_row_clamps_speaker_to_box_interior() { + // Box: y=10, height=5 → top border row 10, interior rows 11–13, bottom border row 14. + let r = Rect { x: 0, y: 10, width: 10, height: 5 }; + assert_eq!(join_row(8, r), 11); // speaker above box → first interior row + assert_eq!(join_row(12, r), 12); // speaker inside box → unchanged + assert_eq!(join_row(20, r), 13); // speaker below box → last interior row + } + + // ── center_top centering ────────────────────────────────────────────────── + + #[test] + fn center_top_aligns_box_centre_with_speaker() { + let area = big_area(); + // box_h=3 (1 line): offset = (3-1)/2 = 1 → top = 19, centre row = 20. ✓ + assert_eq!(center_top(20, 3, area), 19); + // box_h=5 (3 lines): offset = (5-1)/2 = 2 → top = 18, centre row = 20. ✓ + assert_eq!(center_top(20, 5, area), 18); + } } diff --git a/maps/start.toml b/maps/start.toml index d8e10b9..b04d465 100644 --- a/maps/start.toml +++ b/maps/start.toml @@ -1,6 +1,6 @@ [world] name = "Kiln Demo" -start = "start" +start = "house" # Named Rhai scripts shared across all boards in this world. # Objects reference a script by name via `script_name`. @@ -90,6 +90,58 @@ fn bump(id) { } """ +bookshelf = """ +fn bump(id) { + scroll([ + " THE BOOKSHELF", + " ", + "A well-worn shelf packed with old volumes. You scan the spines:", + " ", + " 'Practical Herbalism and Country Remedies'", + " 'A History of the Seven Kingdoms, Vol. II'", + " 'Monsters of the Northern Reach'", + " 'The Innkeeper's Handbook'", + " 'Common Stars and Their Names'", + " 'Letters to No One in Particular' (handwritten)", + " ", + "One has a pressed flower tucked in as a bookmark.", + ]); +} +""" + +fireplace = """ +fn bump(id) { + say("The fire crackles warmly.\\nYou feel at ease."); +} +""" + +chest = """ +fn bump(id) { + scroll([ + " THE CHEST", + " ", + "A sturdy oak chest with iron fittings. The latch is unlocked.", + " ", + "Inside you find:", + "", + "- A wool blanket, slightly moth-eaten", + "- Three copper coins", + "- A half-empty bottle of something medicinal", + "- A child's wooden toy horse", + "- A folded letter, sealed but never sent", + ]); +} +""" + +yammerer = """ +fn tick(dt) { + if Queue.length() == 0 { + say("blahblahblah"); + delay(1); + } +} +""" + [boards.start.map] name = "Starting Room" width = 60 @@ -212,3 +264,82 @@ bg = "#000000" solid = true name = "noticeboard" script_name = "noticeboard" + +[boards.house.map] +name = "The House" +width = 60 +height = 25 +player_start = "@" + +[boards.house.palette] +" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" } +"#" = { archetype = "wall", tile = 178, fg = "#888888", bg = "#555555" } + +[boards.house.floor] +tile = 176 +fg = "#888888" +bg = "#444444" + +[boards.house.grid] +content = """ +############################################################ +# # +# K ###F### # +# # # # +# ####### # +# # +# # +# ######### T # +# # # T # +# ######### # +# T T # +# # +# @ T # +# # +# T # +# ######C###### # +# T # # # +# ############# # +# # +# # +# # +# # +# # +# # +############################################################ +""" + +[[boards.house.objects]] +palette = "K" +tile = 240 +fg = "#aa7733" +bg = "#3d1c00" +solid = true +name = "bookshelf" +script_name = "bookshelf" + +[[boards.house.objects]] +palette = "F" +tile = 15 +fg = "#ff8800" +bg = "#220000" +solid = true +name = "fireplace" +script_name = "fireplace" + +[[boards.house.objects]] +palette = "C" +tile = 240 +fg = "#ccaa44" +bg = "#222200" +solid = true +name = "chest" +script_name = "chest" + +[[boards.house.objects]] +palette = "T" +tile = 1 +fg = "#99ff44" +bg = "#000000" +solid = true +script_name = "yammerer"