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

@ -34,10 +34,10 @@ enum CommandMode {
ShowingList,
}
/// Parse an emote command and return the emotion index if valid.
/// Parse an emote command and return the emotion name if valid.
///
/// Supports `:e name`, `:emote name` with partial matching.
fn parse_emote_command(cmd: &str) -> Option<u8> {
fn parse_emote_command(cmd: &str) -> Option<String> {
let cmd = cmd.trim().to_lowercase();
// Strip the leading colon if present
@ -52,9 +52,8 @@ fn parse_emote_command(cmd: &str) -> Option<u8> {
name.and_then(|n| {
EMOTIONS
.iter()
.enumerate()
.find(|(_, ename)| ename.starts_with(n) || n.starts_with(**ename))
.map(|(idx, _)| idx as u8)
.find(|ename| ename.starts_with(n) || n.starts_with(**ename))
.map(|ename| (*ename).to_string())
})
}
@ -97,12 +96,10 @@ pub fn ChatInput(
// Apply emotion via WebSocket
let apply_emotion = {
move |emotion_idx: u8| {
move |emotion: String| {
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::UpdateEmotion {
emotion: emotion_idx,
});
send_fn(ClientMessage::UpdateEmotion { emotion });
}
});
// Clear input and close popup
@ -199,8 +196,8 @@ pub fn ChatInput(
};
// Popup select handler
let on_popup_select = Callback::new(move |emotion_idx: u8| {
apply_emotion(emotion_idx);
let on_popup_select = Callback::new(move |emotion: String| {
apply_emotion(emotion);
});
let on_popup_close = Callback::new(move |_: ()| {
@ -265,10 +262,11 @@ pub fn ChatInput(
fn EmoteListPopup(
emotion_availability: Signal<Option<EmotionAvailability>>,
skin_preview_path: Signal<Option<String>>,
on_select: Callback<u8>,
on_close: Callback<()>,
on_select: Callback<String>,
#[prop(into)] on_close: Callback<()>,
) -> impl IntoView {
// Get list of available emotions
let _ = on_close; // Suppress unused warning
// Get list of available emotions (name, preview_path)
let available_emotions = move || {
emotion_availability
.get()
@ -279,7 +277,7 @@ fn EmoteListPopup(
.filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false))
.map(|(idx, name)| {
let preview = avail.preview_paths.get(idx).cloned().flatten();
(idx as u8, *name, preview)
((*name).to_string(), preview)
})
.collect::<Vec<_>>()
})
@ -296,24 +294,26 @@ fn EmoteListPopup(
<div class="grid grid-cols-3 gap-1 max-h-64 overflow-y-auto">
<For
each=move || available_emotions()
key=|(idx, _, _): &(u8, &str, Option<String>)| *idx
children=move |(emotion_idx, emotion_name, preview_path): (u8, &str, Option<String>)| {
key=|(name, _): &(String, Option<String>)| name.clone()
children=move |(emotion_name, preview_path): (String, Option<String>)| {
let on_select = on_select.clone();
let skin_path = skin_preview_path.get();
let emotion_name_for_click = emotion_name.clone();
let _skin_path = skin_preview_path.get();
let _emotion_path = preview_path.clone();
view! {
<button
type="button"
class="flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
on:click=move |_| on_select.run(emotion_idx)
on:click=move |_| on_select.run(emotion_name_for_click.clone())
role="option"
>
<EmotionPreview
skin_path=skin_path.clone()
emotion_path=preview_path.clone()
skin_path=_skin_path.clone()
emotion_path=_emotion_path.clone()
/>
<span class="text-white text-sm">
":e "
{emotion_name}
{emotion_name.clone()}
</span>
</button>
}

View file

@ -0,0 +1,101 @@
//! Chat message types for client-side state management.
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use uuid::Uuid;
/// Maximum messages to keep in the local log.
pub const MAX_MESSAGE_LOG_SIZE: usize = 2000;
/// Default speech bubble timeout in milliseconds.
pub const DEFAULT_BUBBLE_TIMEOUT_MS: i64 = 60_000;
/// A chat message for display and logging.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub message_id: Uuid,
pub user_id: Option<Uuid>,
pub guest_session_id: Option<Uuid>,
pub display_name: String,
pub content: String,
/// Emotion name (e.g., "happy", "sad", "neutral").
pub emotion: String,
pub x: f64,
pub y: f64,
/// Timestamp in milliseconds since epoch.
pub timestamp: i64,
}
/// Message log with bounded capacity for future replay support.
#[derive(Debug, Clone, Default)]
pub struct MessageLog {
messages: VecDeque<ChatMessage>,
}
impl MessageLog {
pub fn new() -> Self {
Self {
messages: VecDeque::with_capacity(MAX_MESSAGE_LOG_SIZE),
}
}
pub fn push(&mut self, msg: ChatMessage) {
if self.messages.len() >= MAX_MESSAGE_LOG_SIZE {
self.messages.pop_front();
}
self.messages.push_back(msg);
}
/// Get messages within a time range (for replay).
pub fn messages_in_range(&self, start_ms: i64, end_ms: i64) -> Vec<&ChatMessage> {
self.messages
.iter()
.filter(|m| m.timestamp >= start_ms && m.timestamp <= end_ms)
.collect()
}
/// Get the latest message from a specific user.
pub fn latest_from_user(
&self,
user_id: Option<Uuid>,
guest_id: Option<Uuid>,
) -> Option<&ChatMessage> {
self.messages
.iter()
.rev()
.find(|m| m.user_id == user_id && m.guest_session_id == guest_id)
}
/// Get all messages.
pub fn all_messages(&self) -> &VecDeque<ChatMessage> {
&self.messages
}
}
/// Active speech bubble state for a user.
#[derive(Debug, Clone)]
pub struct ActiveBubble {
pub message: ChatMessage,
/// When the bubble should expire (milliseconds since epoch).
pub expires_at: i64,
}
/// Get bubble colors based on emotion name.
/// Returns (background_color, border_color, text_color).
pub fn emotion_bubble_colors(emotion: &str) -> (&'static str, &'static str, &'static str) {
match emotion {
"neutral" => ("#374151", "#4B5563", "#F9FAFB"), // gray
"happy" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber
"sad" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue
"angry" => ("#EF4444", "#DC2626", "#F9FAFB"), // red
"surprised" => ("#A855F7", "#9333EA", "#F9FAFB"), // purple
"thinking" => ("#6366F1", "#4F46E5", "#F9FAFB"), // indigo
"laughing" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber
"crying" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue
"love" => ("#EC4899", "#DB2777", "#F9FAFB"), // pink
"confused" => ("#8B5CF6", "#7C3AED", "#F9FAFB"), // violet
"sleeping" => ("#1F2937", "#374151", "#9CA3AF"), // dark gray
"wink" => ("#10B981", "#059669", "#F9FAFB"), // emerald
_ => ("#374151", "#4B5563", "#F9FAFB"), // default - gray
}
}

View file

@ -0,0 +1,295 @@
//! Inventory popup component.
use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
use uuid::Uuid;
use chattyness_db::models::InventoryItem;
#[cfg(feature = "hydrate")]
use chattyness_db::ws_messages::ClientMessage;
use super::ws_client::WsSender;
/// Inventory popup component.
///
/// Shows a grid of user-owned props with drop functionality.
///
/// Props:
/// - `open`: Signal controlling visibility
/// - `on_close`: Callback when popup should close
/// - `ws_sender`: WebSocket sender for dropping props
#[component]
pub fn InventoryPopup(
#[prop(into)] open: Signal<bool>,
on_close: Callback<()>,
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
) -> impl IntoView {
let (items, set_items) = signal(Vec::<InventoryItem>::new());
let (loading, set_loading) = signal(false);
let (error, set_error) = signal(Option::<String>::None);
let (selected_item, set_selected_item) = signal(Option::<Uuid>::None);
let (dropping, set_dropping) = signal(false);
// Fetch inventory when popup opens
#[cfg(feature = "hydrate")]
{
use gloo_net::http::Request;
use leptos::task::spawn_local;
Effect::new(move |_| {
if !open.get() {
// Reset state when closing
set_selected_item.set(None);
return;
}
set_loading.set(true);
set_error.set(None);
spawn_local(async move {
let response = Request::get("/api/inventory").send().await;
match response {
Ok(resp) if resp.ok() => {
if let Ok(data) =
resp.json::<chattyness_db::models::InventoryResponse>().await
{
set_items.set(data.items);
} else {
set_error.set(Some("Failed to parse inventory data".to_string()));
}
}
Ok(resp) => {
set_error.set(Some(format!("Failed to load inventory: {}", resp.status())));
}
Err(e) => {
set_error.set(Some(format!("Network error: {}", e)));
}
}
set_loading.set(false);
});
});
}
// Handle escape key to close
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::{closure::Closure, JsCast};
Effect::new(move |_| {
if !open.get() {
return;
}
let on_close_clone = on_close.clone();
let closure =
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
on_close_clone.run(());
}
});
if let Some(window) = web_sys::window() {
let _ = window.add_event_listener_with_callback(
"keydown",
closure.as_ref().unchecked_ref(),
);
}
// Intentionally not cleaning up - closure lives for session
closure.forget();
});
}
// Handle drop action via WebSocket
#[cfg(feature = "hydrate")]
let handle_drop = {
move |item_id: Uuid| {
set_dropping.set(true);
// Send drop command via WebSocket
ws_sender.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::DropProp {
inventory_item_id: item_id,
});
// Optimistically remove from local list
set_items.update(|items| {
items.retain(|i| i.id != item_id);
});
set_selected_item.set(None);
} else {
set_error.set(Some("Not connected to server".to_string()));
}
});
set_dropping.set(false);
}
};
#[cfg(not(feature = "hydrate"))]
let handle_drop = |_item_id: Uuid| {};
let on_close_backdrop = on_close.clone();
let on_close_button = on_close.clone();
view! {
<Show when=move || open.get()>
<div
class="fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="inventory-modal-title"
>
// Backdrop
<div
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
on:click=move |_| on_close_backdrop.run(())
aria-hidden="true"
/>
// Modal content
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full mx-4 p-6 border border-gray-700 max-h-[80vh] flex flex-col">
// Header
<div class="flex items-center justify-between mb-4">
<h2 id="inventory-modal-title" class="text-xl font-bold text-white">
"Inventory"
</h2>
<button
type="button"
class="text-gray-400 hover:text-white transition-colors"
on:click=move |_| on_close_button.run(())
aria-label="Close inventory"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
// Loading state
<Show when=move || loading.get()>
<div class="flex items-center justify-center py-12">
<p class="text-gray-400">"Loading inventory..."</p>
</div>
</Show>
// Error state
<Show when=move || error.get().is_some()>
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4">
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p>
</div>
</Show>
// Empty state
<Show when=move || !loading.get() && error.get().is_none() && items.get().is_empty()>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<p class="text-gray-400">"Your inventory is empty"</p>
<p class="text-gray-500 text-sm mt-1">"Collect props to see them here"</p>
</div>
</Show>
// Grid of items
<Show when=move || !loading.get() && !items.get().is_empty()>
<div class="flex-1 overflow-y-auto">
<div
class="grid grid-cols-4 sm:grid-cols-6 gap-2"
role="listbox"
aria-label="Inventory items"
>
<For
each=move || items.get()
key=|item| item.id
children=move |item: InventoryItem| {
let item_id = item.id;
let item_name = item.prop_name.clone();
let is_selected = move || selected_item.get() == Some(item_id);
let asset_path = if item.prop_asset_path.starts_with('/') {
item.prop_asset_path.clone()
} else {
format!("/static/{}", item.prop_asset_path)
};
view! {
<button
type="button"
class=move || format!(
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
if is_selected() {
"border-blue-500 bg-blue-900/30"
} else {
"border-gray-600 hover:border-gray-500 bg-gray-700/50"
}
)
on:click=move |_| {
set_selected_item.set(Some(item_id));
}
role="option"
aria-selected=is_selected
aria-label=item_name
>
<img
src=asset_path
alt=""
class="w-full h-full object-contain"
/>
</button>
}
}
/>
</div>
</div>
</Show>
// Selected item details and actions
{move || {
let item_id = selected_item.get()?;
let item = items.get().into_iter().find(|i| i.id == item_id)?;
let handle_drop = handle_drop.clone();
let is_dropping = dropping.get();
Some(view! {
<div class="mt-4 pt-4 border-t border-gray-700">
<div class="flex items-center justify-between">
<div>
<h3 class="text-white font-medium">{item.prop_name.clone()}</h3>
<p class="text-gray-400 text-sm">
{if item.is_transferable { "Transferable" } else { "Not transferable" }}
{if item.is_portable { " \u{2022} Portable" } else { "" }}
</p>
</div>
<div class="flex gap-2">
// Drop button
<button
type="button"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors disabled:opacity-50"
on:click=move |_| handle_drop(item_id)
disabled=is_dropping
>
{if is_dropping { "Dropping..." } else { "Drop" }}
</button>
// Transfer button (disabled for now)
<Show when=move || item.is_transferable>
<button
type="button"
class="px-4 py-2 bg-gray-600 text-gray-400 rounded-lg cursor-not-allowed"
disabled=true
title="Transfer functionality coming soon"
>
"Transfer"
</button>
</Show>
</div>
</div>
</div>
})
}}
</div>
</div>
</Show>
}
}

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);
}
}
}

View file

@ -6,7 +6,7 @@
use leptos::prelude::*;
use leptos::reactive::owner::LocalStorage;
use chattyness_db::models::ChannelMemberWithAvatar;
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState, LooseProp};
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
use super::chat_types::ChatMessage;
@ -41,6 +41,9 @@ pub fn use_channel_websocket(
channel_id: Signal<Option<uuid::Uuid>>,
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
on_chat_message: Callback<ChatMessage>,
on_loose_props_sync: Callback<Vec<LooseProp>>,
on_prop_dropped: Callback<LooseProp>,
on_prop_picked_up: Callback<uuid::Uuid>,
) -> (Signal<WsState>, WsSenderStorage) {
use std::cell::RefCell;
use std::rc::Rc;
@ -133,6 +136,9 @@ pub fn use_channel_websocket(
let members_for_msg = members_clone.clone();
let on_members_update_clone = on_members_update.clone();
let on_chat_message_clone = on_chat_message.clone();
let on_loose_props_sync_clone = on_loose_props_sync.clone();
let on_prop_dropped_clone = on_prop_dropped.clone();
let on_prop_picked_up_clone = on_prop_picked_up.clone();
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
let text: String = text.into();
@ -145,6 +151,9 @@ pub fn use_channel_websocket(
&members_for_msg,
&on_members_update_clone,
&on_chat_message_clone,
&on_loose_props_sync_clone,
&on_prop_dropped_clone,
&on_prop_picked_up_clone,
);
}
}
@ -187,6 +196,9 @@ fn handle_server_message(
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
on_chat_message: &Callback<ChatMessage>,
on_loose_props_sync: &Callback<Vec<LooseProp>>,
on_prop_dropped: &Callback<LooseProp>,
on_prop_picked_up: &Callback<uuid::Uuid>,
) {
let mut members_vec = members.borrow_mut();
@ -239,7 +251,11 @@ fn handle_server_message(
if let Some(m) = members_vec.iter_mut().find(|m| {
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
}) {
m.member.current_emotion = emotion as i16;
// Convert emotion name to index for internal state
m.member.current_emotion = emotion
.parse::<EmotionState>()
.map(|e| e.to_index() as i16)
.unwrap_or(0);
m.avatar.emotion_layer = emotion_layer;
}
on_update.run(members_vec.clone());
@ -275,6 +291,19 @@ fn handle_server_message(
};
on_chat_message.run(chat_msg);
}
ServerMessage::LoosePropsSync { props } => {
on_loose_props_sync.run(props);
}
ServerMessage::PropDropped { prop } => {
on_prop_dropped.run(prop);
}
ServerMessage::PropPickedUp { prop_id, .. } => {
on_prop_picked_up.run(prop_id);
}
ServerMessage::PropExpired { prop_id } => {
// Treat expired props the same as picked up (remove from display)
on_prop_picked_up.run(prop_id);
}
}
}
@ -285,6 +314,9 @@ pub fn use_channel_websocket(
_channel_id: Signal<Option<uuid::Uuid>>,
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
_on_chat_message: Callback<ChatMessage>,
_on_loose_props_sync: Callback<Vec<LooseProp>>,
_on_prop_dropped: Callback<LooseProp>,
_on_prop_picked_up: Callback<uuid::Uuid>,
) -> (Signal<WsState>, WsSenderStorage) {
let (ws_state, _) = signal(WsState::Disconnected);
let sender: WsSenderStorage = StoredValue::new_local(None);