fix some emotion bugs
This commit is contained in:
parent
bd28e201a2
commit
989e20757b
11 changed files with 1203 additions and 190 deletions
|
|
@ -4,10 +4,15 @@
|
|||
//! - Background canvas: Static, drawn once when scene loads
|
||||
//! - Avatar canvas: Dynamic, redrawn when members change
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
|
||||
|
||||
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
||||
|
||||
/// Parse bounds WKT to extract width and height.
|
||||
///
|
||||
/// Expected format: "POLYGON((0 0, WIDTH 0, WIDTH HEIGHT, 0 HEIGHT, 0 0))"
|
||||
|
|
@ -59,6 +64,8 @@ pub fn RealmSceneViewer(
|
|||
#[prop(into)]
|
||||
members: Signal<Vec<ChannelMemberWithAvatar>>,
|
||||
#[prop(into)]
|
||||
active_bubbles: Signal<HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>>,
|
||||
#[prop(into)]
|
||||
on_move: Callback<(f64, f64)>,
|
||||
) -> impl IntoView {
|
||||
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
||||
|
|
@ -225,11 +232,12 @@ pub fn RealmSceneViewer(
|
|||
});
|
||||
|
||||
// =========================================================
|
||||
// Avatar Effect - runs when members change, redraws avatars only
|
||||
// Avatar Effect - runs when members or bubbles change
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track members signal - this Effect reruns when members change
|
||||
// Track both signals - this Effect reruns when either changes
|
||||
let current_members = members.get();
|
||||
let current_bubbles = active_bubbles.get();
|
||||
|
||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
||||
return;
|
||||
|
|
@ -265,8 +273,21 @@ pub fn RealmSceneViewer(
|
|||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
// Draw avatars
|
||||
// Draw avatars first
|
||||
draw_avatars(&ctx, ¤t_members, sx, sy, ox, oy);
|
||||
|
||||
// Draw speech bubbles on top
|
||||
let current_time = js_sys::Date::now() as i64;
|
||||
draw_speech_bubbles(
|
||||
&ctx,
|
||||
¤t_members,
|
||||
¤t_bubbles,
|
||||
sx,
|
||||
sy,
|
||||
ox,
|
||||
oy,
|
||||
current_time,
|
||||
);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
|
|
@ -419,3 +440,166 @@ fn draw_avatars(
|
|||
let _ = ctx.fill_text(&member.member.display_name, x, y + 10.0 * scale_y);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw speech bubbles above avatars.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn draw_speech_bubbles(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
members: &[ChannelMemberWithAvatar],
|
||||
bubbles: &HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>,
|
||||
scale_x: f64,
|
||||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
current_time_ms: i64,
|
||||
) {
|
||||
let scale = scale_x.min(scale_y);
|
||||
let avatar_size = 48.0 * scale;
|
||||
let max_bubble_width = 200.0 * scale;
|
||||
let padding = 8.0 * scale;
|
||||
let font_size = 12.0 * scale;
|
||||
let line_height = 16.0 * scale;
|
||||
let tail_size = 8.0 * scale;
|
||||
let border_radius = 8.0 * scale;
|
||||
|
||||
for member in members {
|
||||
let key = (member.member.user_id, member.member.guest_session_id);
|
||||
|
||||
if let Some(bubble) = bubbles.get(&key) {
|
||||
// Skip expired bubbles
|
||||
if bubble.expires_at < current_time_ms {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use member's CURRENT position, not message position
|
||||
let x = member.member.position_x * scale_x + offset_x;
|
||||
let y = member.member.position_y * scale_y + offset_y;
|
||||
|
||||
// Get emotion colors
|
||||
let (bg_color, border_color, text_color) =
|
||||
emotion_bubble_colors(bubble.message.emotion);
|
||||
|
||||
// Measure and wrap text
|
||||
ctx.set_font(&format!("{}px sans-serif", font_size));
|
||||
let lines = wrap_text(ctx, &bubble.message.content, max_bubble_width - padding * 2.0);
|
||||
|
||||
// Calculate bubble dimensions
|
||||
let bubble_width = lines
|
||||
.iter()
|
||||
.map(|line: &String| -> f64 {
|
||||
ctx.measure_text(line)
|
||||
.map(|m: web_sys::TextMetrics| m.width())
|
||||
.unwrap_or(0.0)
|
||||
})
|
||||
.fold(0.0_f64, |a: f64, b: f64| a.max(b))
|
||||
+ padding * 2.0;
|
||||
let bubble_width = bubble_width.max(60.0 * scale); // Minimum width
|
||||
let bubble_height = (lines.len() as f64) * line_height + padding * 2.0;
|
||||
|
||||
// Position bubble above avatar
|
||||
let bubble_x = x - bubble_width / 2.0;
|
||||
let bubble_y = y - avatar_size - bubble_height - tail_size - 5.0 * scale;
|
||||
|
||||
// Draw bubble background with rounded corners
|
||||
draw_rounded_rect(ctx, bubble_x, bubble_y, bubble_width, bubble_height, border_radius);
|
||||
ctx.set_fill_style_str(bg_color);
|
||||
ctx.fill();
|
||||
ctx.set_stroke_style_str(border_color);
|
||||
ctx.set_line_width(2.0);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw tail (triangle pointing down)
|
||||
ctx.begin_path();
|
||||
ctx.move_to(x - tail_size, bubble_y + bubble_height);
|
||||
ctx.line_to(x, bubble_y + bubble_height + tail_size);
|
||||
ctx.line_to(x + tail_size, bubble_y + bubble_height);
|
||||
ctx.close_path();
|
||||
ctx.set_fill_style_str(bg_color);
|
||||
ctx.fill();
|
||||
ctx.set_stroke_style_str(border_color);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw text
|
||||
ctx.set_fill_style_str(text_color);
|
||||
ctx.set_text_align("left");
|
||||
ctx.set_text_baseline("top");
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let _ = ctx.fill_text(
|
||||
line,
|
||||
bubble_x + padding,
|
||||
bubble_y + padding + (i as f64) * line_height,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap text to fit within max_width.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64) -> Vec<String> {
|
||||
let words: Vec<&str> = text.split_whitespace().collect();
|
||||
let mut lines = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
|
||||
for word in words {
|
||||
let test_line = if current_line.is_empty() {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("{} {}", current_line, word)
|
||||
};
|
||||
|
||||
let width = ctx
|
||||
.measure_text(&test_line)
|
||||
.map(|m: web_sys::TextMetrics| m.width())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
if width > max_width && !current_line.is_empty() {
|
||||
lines.push(current_line);
|
||||
current_line = word.to_string();
|
||||
} else {
|
||||
current_line = test_line;
|
||||
}
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
lines.push(current_line);
|
||||
}
|
||||
|
||||
// Limit to 4 lines max
|
||||
if lines.len() > 4 {
|
||||
lines.truncate(3);
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.push_str("...");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty text
|
||||
if lines.is_empty() {
|
||||
lines.push(text.to_string());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Draw a rounded rectangle path.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn draw_rounded_rect(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
radius: f64,
|
||||
) {
|
||||
ctx.begin_path();
|
||||
ctx.move_to(x + radius, y);
|
||||
ctx.line_to(x + width - radius, y);
|
||||
ctx.arc_to(x + width, y, x + width, y + radius, radius).ok();
|
||||
ctx.line_to(x + width, y + height - radius);
|
||||
ctx.arc_to(x + width, y + height, x + width - radius, y + height, radius).ok();
|
||||
ctx.line_to(x + radius, y + height);
|
||||
ctx.arc_to(x, y + height, x, y + height - radius, radius).ok();
|
||||
ctx.line_to(x, y + radius);
|
||||
ctx.arc_to(x, y, x + radius, y, radius).ok();
|
||||
ctx.close_path();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue