speech bubbles and delays
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user