Files
kiln/kiln-tui/src/speech.rs
T

491 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::{Color, Line, Style, Widget};
use ratatui::widgets::{Block, Fill, Paragraph};
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 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,
/// The active speech bubbles to render.
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 }
}
/// Places a bubble for a speaker at `(obj_sx, obj_sy)`.
///
/// 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,
box_w: u16,
box_h: u16,
placed: &mut Vec<Rect>,
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);
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),
);
placed.push(rect);
(rect, dir)
}
}
impl Widget for SpeechBubblesWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let bubble_style = Style::default()
.fg(Color::White)
.bg(Color::Rgb(20, 20, 40));
let border_style = Style::default()
.fg(Color::Rgb(160, 160, 220))
.bg(Color::Rgb(20, 20, 40));
// 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 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 });
}
draw_tails(&placements, buf, border_style);
draw_boxes(&placements, buf, bubble_style, border_style);
draw_join_chars(&placements, buf, border_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> = 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 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);
}
}