speech bubble fixes, multi objects

This commit is contained in:
2026-06-13 13:59:13 -05:00
parent f327e77e3d
commit 887e1fefea
7 changed files with 675 additions and 178 deletions
+4 -2
View File
@@ -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"
+35 -27
View File
@@ -163,9 +163,11 @@ pub(crate) struct MapFileObjectEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
y: Option<usize>,
/// 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<String>,
/// Tile index into the board's bitmap font. Accepts integer or single-char string.
@@ -364,12 +366,12 @@ impl TryFrom<MapFile> 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<char, (usize, usize)> = HashMap::new();
let mut obj_palette_count: HashMap<char, usize> = HashMap::new();
// All grid positions (in reading order) at which each object palette char appears.
let mut obj_palette_positions: HashMap<char, Vec<(usize, usize)>> = 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<MapFile> 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,24 +396,18 @@ impl TryFrom<MapFile> 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 => {
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;
}
1 => {}
n => load_errors.push(LogLine::error(format!(
"object palette char '{ch}' appears {n} times in the grid; using the first"
))),
}
}
let font = mf.font.map(|f| FontSpec {
@@ -479,13 +474,14 @@ impl TryFrom<MapFile> 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,12 +496,17 @@ impl TryFrom<MapFile> for Board {
}
}
};
// 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.
let name = e.name.and_then(|n| {
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) }
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",
@@ -514,7 +515,11 @@ impl TryFrom<MapFile> for Board {
None
}
}
});
})
} else {
None
};
let obj = ObjectDef {
id: 0, // stamped by Board::add_object
x,
@@ -527,10 +532,11 @@ impl TryFrom<MapFile> for Board {
solid: e.solid,
opaque: e.opaque,
pushable: e.pushable,
script_name: e.script_name,
tags: e.tags.into_iter().collect(),
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;
@@ -546,9 +552,11 @@ impl TryFrom<MapFile> for Board {
}
solid_occupied[idx] = true;
}
objects.insert(next_object_id, obj);
next_object_id += 1;
}
}
Ok(Board {
name: mf.map.name,
@@ -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]
+3 -1
View File
@@ -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!(
+1 -1
View File
@@ -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,
+428 -91
View File
@@ -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<Rect>,
) -> Option<Rect> {
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<Rect> = 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<Rect> = Vec::new();
let mut placements: Vec<Placement> = 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);
// ─── Draw passes ─────────────────────────────────────────────────────────────
/// 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); }
}
}
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); }
}
}
}
}
}
/// 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<Line> = lines.iter()
let para_lines: Vec<Line> = p.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);
}
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);
}
/// 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);
}
}
}
}
}
/// 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))
// ─── 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 1418),
// 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 1819)
}
// ── 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 1113, 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);
}
}
+132 -1
View File
@@ -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"