make emotions named instead, add drop prop
This commit is contained in:
parent
989e20757b
commit
ea3b444d71
19 changed files with 1429 additions and 150 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
101
crates/chattyness-user-ui/src/components/chat_types.rs
Normal file
101
crates/chattyness-user-ui/src/components/chat_types.rs
Normal 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
|
||||
}
|
||||
}
|
||||
295
crates/chattyness-user-ui/src/components/inventory.rs
Normal file
295
crates/chattyness-user-ui/src/components/inventory.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ¤t_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, ¤t_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue