make emotions named instead, add drop prop

This commit is contained in:
Evan Carroll 2026-01-13 16:49:07 -06:00
parent 989e20757b
commit ea3b444d71
19 changed files with 1429 additions and 150 deletions

View file

@ -9,7 +9,7 @@ use std::collections::HashMap;
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
@ -53,9 +53,10 @@ fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
/// Scene viewer component for displaying a realm scene with avatars.
///
/// Uses two layered canvases:
/// Uses three layered canvases:
/// - Background canvas (z-index 0): Static background, drawn once
/// - Avatar canvas (z-index 1): Transparent, redrawn on member updates
/// - Props canvas (z-index 1): Loose props, redrawn on drop/pickup
/// - Avatar canvas (z-index 2): Transparent, redrawn on member updates
#[component]
pub fn RealmSceneViewer(
scene: Scene,
@ -66,7 +67,11 @@ pub fn RealmSceneViewer(
#[prop(into)]
active_bubbles: Signal<HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>>,
#[prop(into)]
loose_props: Signal<Vec<LooseProp>>,
#[prop(into)]
on_move: Callback<(f64, f64)>,
#[prop(into)]
on_prop_click: Callback<Uuid>,
) -> impl IntoView {
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
@ -81,8 +86,9 @@ pub fn RealmSceneViewer(
#[allow(unused_variables)]
let image_path = scene.background_image_path.clone().unwrap_or_default();
// Two separate canvas refs for layered rendering
// Three separate canvas refs for layered rendering
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
// Store scale factors for coordinate conversion (shared between both canvases)
@ -91,10 +97,11 @@ pub fn RealmSceneViewer(
let offset_x = StoredValue::new(0.0_f64);
let offset_y = StoredValue::new(0.0_f64);
// Handle canvas click for movement (on avatar canvas - topmost layer)
// Handle canvas click for movement or prop pickup (on avatar canvas - topmost layer)
#[cfg(feature = "hydrate")]
let on_canvas_click = {
let on_move = on_move.clone();
let on_prop_click = on_prop_click.clone();
move |ev: web_sys::MouseEvent| {
let Some(canvas) = avatar_canvas_ref.get() else {
return;
@ -118,7 +125,26 @@ pub fn RealmSceneViewer(
let scene_x = scene_x.max(0.0).min(scene_width as f64);
let scene_y = scene_y.max(0.0).min(scene_height as f64);
on_move.run((scene_x, scene_y));
// Check if click is within 32px of any loose prop
let current_props = loose_props.get();
let prop_click_radius = 32.0;
let mut clicked_prop: Option<Uuid> = None;
for prop in &current_props {
let dx = scene_x - prop.position_x;
let dy = scene_y - prop.position_y;
let distance = (dx * dx + dy * dy).sqrt();
if distance <= prop_click_radius {
clicked_prop = Some(prop.id);
break;
}
}
if let Some(prop_id) = clicked_prop {
on_prop_click.run(prop_id);
} else {
on_move.run((scene_x, scene_y));
}
}
}
};
@ -157,10 +183,12 @@ pub fn RealmSceneViewer(
let image_path = image_path_clone.clone();
let bg_drawn_inner = bg_drawn.clone();
// Use setTimeout to ensure DOM is ready before drawing
let draw_bg = Closure::once(Box::new(move || {
let display_width = canvas_el.client_width() as u32;
let display_height = canvas_el.client_height() as u32;
// If still no dimensions, the canvas likely isn't visible - skip drawing
if display_width == 0 || display_height == 0 {
return;
}
@ -226,8 +254,12 @@ pub fn RealmSceneViewer(
}
}) as Box<dyn FnOnce()>);
// Use setTimeout with small delay to ensure canvas is in DOM and has dimensions
let window = web_sys::window().unwrap();
let _ = window.request_animation_frame(draw_bg.as_ref().unchecked_ref());
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
draw_bg.as_ref().unchecked_ref(),
100, // 100ms delay to allow DOM to settle
);
draw_bg.forget();
});
@ -295,6 +327,57 @@ pub fn RealmSceneViewer(
let _ = window.request_animation_frame(draw_avatars_closure.as_ref().unchecked_ref());
draw_avatars_closure.forget();
});
// =========================================================
// Props Effect - runs when loose_props changes
// =========================================================
Effect::new(move |_| {
// Track loose_props signal
let current_props = loose_props.get();
let Some(canvas) = props_canvas_ref.get() else {
return;
};
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
let canvas_el = canvas_el.clone();
let draw_props_closure = Closure::once(Box::new(move || {
let display_width = canvas_el.client_width() as u32;
let display_height = canvas_el.client_height() as u32;
if display_width == 0 || display_height == 0 {
return;
}
// Resize props canvas to match (if needed)
if canvas_el.width() != display_width || canvas_el.height() != display_height {
canvas_el.set_width(display_width);
canvas_el.set_height(display_height);
}
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
let ctx: web_sys::CanvasRenderingContext2d =
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
// Clear with transparency
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64);
// Get stored scale factors
let sx = scale_x.get_value();
let sy = scale_y.get_value();
let ox = offset_x.get_value();
let oy = offset_y.get_value();
// Draw loose props
draw_loose_props(&ctx, &current_props, sx, sy, ox, oy);
}
}) as Box<dyn FnOnce()>);
let window = web_sys::window().unwrap();
let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref());
draw_props_closure.forget();
});
}
let aspect_ratio = scene_width as f64 / scene_height as f64;
@ -315,11 +398,18 @@ pub fn RealmSceneViewer(
style="z-index: 0"
aria-hidden="true"
/>
// Props layer - loose props, redrawn on drop/pickup
<canvas
node_ref=props_canvas_ref
class="absolute inset-0 w-full h-full"
style="z-index: 1"
aria-hidden="true"
/>
// Avatar layer - dynamic, transparent background
<canvas
node_ref=avatar_canvas_ref
class="absolute inset-0 w-full h-full"
style="z-index: 1"
style="z-index: 2"
aria-label=format!("Scene: {}", scene.name)
role="img"
on:click=move |ev| {
@ -477,7 +567,7 @@ fn draw_speech_bubbles(
// Get emotion colors
let (bg_color, border_color, text_color) =
emotion_bubble_colors(bubble.message.emotion);
emotion_bubble_colors(&bubble.message.emotion);
// Measure and wrap text
ctx.set_font(&format!("{}px sans-serif", font_size));
@ -603,3 +693,57 @@ fn draw_rounded_rect(
ctx.arc_to(x, y, x + radius, y, radius).ok();
ctx.close_path();
}
/// Draw loose props on the props canvas layer.
#[cfg(feature = "hydrate")]
fn draw_loose_props(
ctx: &web_sys::CanvasRenderingContext2d,
props: &[LooseProp],
scale_x: f64,
scale_y: f64,
offset_x: f64,
offset_y: f64,
) {
let prop_size = 48.0 * scale_x.min(scale_y);
for prop in props {
let x = prop.position_x * scale_x + offset_x;
let y = prop.position_y * scale_y + offset_y;
// Draw prop sprite if asset path available
if !prop.prop_asset_path.is_empty() {
let img = web_sys::HtmlImageElement::new().unwrap();
let img_clone = img.clone();
let ctx_clone = ctx.clone();
let draw_x = x - prop_size / 2.0;
let draw_y = y - prop_size / 2.0;
let size = prop_size;
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
&img_clone, draw_x, draw_y, size, size,
);
}) as Box<dyn FnOnce()>);
img.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
img.set_src(&normalize_asset_path(&prop.prop_asset_path));
} else {
// Fallback: draw a placeholder circle with prop name
ctx.begin_path();
let _ = ctx.arc(x, y, prop_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
ctx.set_fill_style_str("#f59e0b"); // Amber color
ctx.fill();
ctx.set_stroke_style_str("#d97706");
ctx.set_line_width(2.0);
ctx.stroke();
// Draw prop name below
ctx.set_fill_style_str("#fff");
ctx.set_font(&format!("{}px sans-serif", 10.0 * scale_x.min(scale_y)));
ctx.set_text_align("center");
ctx.set_text_baseline("top");
let _ = ctx.fill_text(&prop.prop_name, x, y + prop_size / 2.0 + 2.0);
}
}
}