speech bubbles and delays

This commit is contained in:
2026-06-08 22:15:44 -05:00
parent f5ed8f2edf
commit e5c406e42d
12 changed files with 354 additions and 190 deletions
+3 -1
View File
@@ -14,7 +14,7 @@ mod term;
use kiln_core::game::GameState;
use kiln_core::log::LogLine;
use kiln_core::map_file;
use kiln_core::script::Direction;
use kiln_core::Direction;
use ratatui::Frame;
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
@@ -215,6 +215,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
let inner = block.inner(board_area);
frame.render_widget(block, board_area);
frame.render_widget(BoardWidget::new(board), inner);
render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles);
// Newest message on top: reverse the log into ratatui lines.
let log_block = Block::bordered()
@@ -236,6 +237,7 @@ fn draw(frame: &mut Frame, game: &GameState, ui: &Ui) {
let inner = block.inner(frame.area());
frame.render_widget(block, frame.area());
frame.render_widget(BoardWidget::new(board), inner);
render::draw_speech_bubbles(frame.buffer_mut(), inner, board, &game.speech_bubbles);
}
}
+123 -1
View File
@@ -8,6 +8,7 @@
use crate::cp437::tile_to_char;
use color::Rgba8;
use kiln_core::Board;
use kiln_core::game::SpeechBubble;
use kiln_core::log::LogLine;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -70,7 +71,7 @@ impl<'a> BoardWidget<'a> {
/// - When the board is larger it **scrolls** to follow the player: `pad` is
/// 0, `off` centers on the player clamped into `[0, board_len - view]`, and
/// exactly `view` cells are shown — never any empty space past the edge.
fn axis(board_len: usize, view: usize, player: i32) -> (usize, u16, usize) {
pub fn axis(board_len: usize, view: usize, player: i32) -> (usize, u16, usize) {
if board_len <= view {
let pad = (view - board_len) / 2;
(0, pad as u16, board_len)
@@ -112,3 +113,124 @@ impl Widget for BoardWidget<'_> {
}
}
}
/// Converts a board cell coordinate to terminal screen coordinates within `area`,
/// accounting for centering/scrolling. Returns `None` if the cell is off-screen.
pub 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))
}
/// Draws speech bubbles for all active [`SpeechBubble`]s over the board area.
///
/// 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 fn draw_speech_bubbles(buf: &mut Buffer, area: Rect, board: &Board, bubbles: &[SpeechBubble]) {
// Determine the screen position of each visible bubble's speaker.
let mut items: Vec<(u16, u16, &SpeechBubble)> = bubbles
.iter()
.filter_map(|b| {
let obj = board.objects.get(&b.object_id)?;
let (sx, sy) = board_screen_pos(area, 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));
for (obj_sx, obj_sy, bubble) in items {
let text_w = bubble.text.chars().count() as u16;
// Box: ╭ space text space ╮ → width = text + 4
let box_w = text_w + 4;
// 4 rows: top border, text, bottom border, tail
let box_h: u16 = 4;
// Center the box over the object; clamp to area bounds.
let raw_left = obj_sx.saturating_sub(box_w / 2);
let left = raw_left.min(area.right().saturating_sub(box_w));
let left = left.max(area.left());
// Desired top: 4 rows above the object (rows: top, text, bottom, tail → speaker).
let desired_top = obj_sy.saturating_sub(box_h);
// Shift upward until no overlap with already-placed bubbles.
let mut top = desired_top;
let mut iterations = 0u16;
'place: loop {
if iterations > 20 || top < area.top() { break; }
let candidate = Rect { x: left, y: top, width: box_w, height: box_h };
for p in &placed {
if rects_overlap(candidate, *p) {
top = top.saturating_sub(1);
iterations += 1;
continue 'place;
}
}
break;
}
if top < area.top() { continue; } // no room; skip this bubble
placed.push(Rect { x: left, y: top, width: box_w, height: box_h });
// Row 0: ╭──...──╮
write_cell(buf, left, top, '╭', border_style);
for cx in (left + 1)..(left + box_w - 1) {
write_cell(buf, cx, top, '─', border_style);
}
write_cell(buf, left + box_w - 1, top, '╮', border_style);
// Row 1: │ text │ (pad text to box_w - 2 inner width)
let inner_w = (box_w - 2) as usize;
let padded: String = format!(" {:<width$} ", bubble.text, width = inner_w - 2);
write_cell(buf, left, top + 1, '│', border_style);
for (i, ch) in padded.chars().enumerate() {
write_cell(buf, left + 1 + i as u16, top + 1, ch, bubble_style);
}
write_cell(buf, left + box_w - 1, top + 1, '│', border_style);
// Row 2: ╰──...─╮─...──╯ with the tail join at obj_sx (clamped inside the box)
let tail_col = obj_sx.clamp(left + 1, left + box_w - 2);
write_cell(buf, left, top + 2, '╰', border_style);
for cx in (left + 1)..(left + box_w - 1) {
let ch = if cx == tail_col { '╮' } else { '─' };
write_cell(buf, cx, top + 2, ch, border_style);
}
write_cell(buf, left + box_w - 1, top + 2, '╯', border_style);
// Row 3: vertical tail descending to the object
write_cell(buf, tail_col, top + 3, '│', border_style);
}
}
/// Returns true if two `Rect`s share at least one cell.
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x < b.x + b.width
&& b.x < a.x + a.width
&& a.y < b.y + b.height
&& b.y < a.y + a.height
}
/// Writes a single styled character into the buffer at `(x, y)`.
fn write_cell(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char(ch).set_style(style);
}
}