2026-06-11 21:31:37 -05:00
|
|
|
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 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.
|
|
|
|
|
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],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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`.
|
|
|
|
|
///
|
|
|
|
|
/// 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.
|
|
|
|
|
fn place_bubble(
|
|
|
|
|
area: Rect,
|
|
|
|
|
obj_sx: u16,
|
|
|
|
|
obj_sy: u16,
|
|
|
|
|
lines: &[&str],
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// 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());
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
let border_style = Style::default()
|
|
|
|
|
.fg(Color::Rgb(160, 160, 220))
|
|
|
|
|
.bg(Color::Rgb(20, 20, 40));
|
|
|
|
|
|
2026-06-11 22:40:50 -05:00
|
|
|
// Pass 1: collect placements in the same bottom-first order.
|
|
|
|
|
let mut placements: Vec<(u16, u16, Vec<&str>, Rect)> = Vec::new();
|
2026-06-11 21:31:37 -05:00
|
|
|
for (obj_sx, obj_sy, 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 };
|
2026-06-11 22:40:50 -05:00
|
|
|
placements.push((obj_sx, obj_sy, lines, r));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-06-11 21:31:37 -05:00
|
|
|
|
|
|
|
|
// Render the bordered box (top border + one row per line + bottom border).
|
2026-06-11 22:40:50 -05:00
|
|
|
let box_rect = Rect { height: r.height - 1, ..*r };
|
2026-06-11 21:31:37 -05:00
|
|
|
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()
|
|
|
|
|
.map(|l| Line::styled(format!(" {l}"), bubble_style))
|
|
|
|
|
.collect();
|
|
|
|
|
Paragraph::new(para_lines).render(text_rect, buf);
|
|
|
|
|
|
2026-06-11 22:40:50 -05:00
|
|
|
// 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.
|
2026-06-11 21:31:37 -05:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-11 22:40:50 -05:00
|
|
|
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);
|
|
|
|
|
}
|
2026-06-11 21:31:37 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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))
|
|
|
|
|
}
|