Fix prop renders

* Incorporate prop scaling
* Props now render to a canvas
This commit is contained in:
Evan Carroll 2026-01-23 16:00:47 -06:00
parent af89394df1
commit a2841c413d
21 changed files with 942 additions and 353 deletions

View file

@ -1419,6 +1419,50 @@ async fn handle_socket(
}
}
}
ClientMessage::UpdateProp { loose_prop_id, scale } => {
// Check if user is a moderator
let is_mod = match memberships::is_moderator(&pool, user_id, realm_id).await {
Ok(result) => result,
Err(e) => {
tracing::error!("[WS] Failed to check moderator status: {:?}", e);
false
}
};
if !is_mod {
let _ = direct_tx.send(ServerMessage::Error {
code: "NOT_MODERATOR".to_string(),
message: "You do not have permission to update props".to_string(),
}).await;
continue;
}
// Update the prop scale
match loose_props::update_loose_prop_scale(
&mut *recv_conn,
loose_prop_id,
scale,
).await {
Ok(updated_prop) => {
#[cfg(debug_assertions)]
tracing::debug!(
"[WS] User {} updated prop {} scale to {}",
user_id,
loose_prop_id,
scale
);
// Broadcast the updated prop to all users in the channel
let _ = tx.send(ServerMessage::PropRefresh { prop: updated_prop });
}
Err(e) => {
tracing::error!("[WS] Update prop failed: {:?}", e);
let _ = direct_tx.send(ServerMessage::Error {
code: "UPDATE_PROP_FAILED".to_string(),
message: format!("{:?}", e),
}).await;
}
}
}
}
}
Message::Close(close_frame) => {

View file

@ -2,6 +2,7 @@
pub mod avatar_canvas;
pub mod avatar_editor;
pub mod canvas_utils;
pub mod avatar_store;
pub mod avatar_thumbnail;
pub mod chat;
@ -17,6 +18,7 @@ pub mod keybindings;
pub mod keybindings_popup;
pub mod layout;
pub mod log_popup;
pub mod loose_prop_canvas;
pub mod modals;
pub mod notifications;
pub mod register_modal;
@ -31,6 +33,7 @@ pub mod ws_client;
pub use avatar_canvas::*;
pub use avatar_editor::*;
pub use avatar_store::*;
pub use canvas_utils::*;
pub use avatar_thumbnail::*;
pub use chat::*;
pub use chat_types::*;
@ -45,6 +48,7 @@ pub use keybindings::*;
pub use keybindings_popup::*;
pub use layout::*;
pub use log_popup::*;
pub use loose_prop_canvas::*;
pub use modals::*;
pub use notifications::*;
pub use register_modal::*;

View file

@ -9,6 +9,10 @@ use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
#[cfg(feature = "hydrate")]
pub use super::canvas_utils::hit_test_canvas;
#[cfg(feature = "hydrate")]
use super::canvas_utils::normalize_asset_path;
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
@ -802,15 +806,6 @@ pub fn AvatarCanvas(
}
}
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
#[cfg(feature = "hydrate")]
fn normalize_asset_path(path: &str) -> String {
if path.starts_with('/') {
path.to_string()
} else {
format!("/static/{}", path)
}
}
/// Draw a speech bubble using the unified CanvasLayout.
///
@ -972,68 +967,6 @@ fn wrap_text(ctx: &web_sys::CanvasRenderingContext2d, text: &str, max_width: f64
lines
}
/// Test if a click at the given client coordinates hits a non-transparent pixel.
///
/// Returns true if the alpha channel at the clicked pixel is > 0.
/// This enables pixel-perfect hit detection on avatar canvases.
#[cfg(feature = "hydrate")]
pub fn hit_test_canvas(canvas: &web_sys::HtmlCanvasElement, client_x: f64, client_y: f64) -> bool {
use wasm_bindgen::JsCast;
// Get the canvas bounding rect to transform client coords to canvas coords
let rect = canvas.get_bounding_client_rect();
// Calculate click position relative to the canvas element
let relative_x = client_x - rect.left();
let relative_y = client_y - rect.top();
// Check if click is within canvas bounds
if relative_x < 0.0
|| relative_y < 0.0
|| relative_x >= rect.width()
|| relative_y >= rect.height()
{
return false;
}
// Transform to canvas pixel coordinates (accounting for CSS scaling)
let canvas_width = canvas.width() as f64;
let canvas_height = canvas.height() as f64;
// Avoid division by zero
if rect.width() == 0.0 || rect.height() == 0.0 {
return false;
}
let scale_x = canvas_width / rect.width();
let scale_y = canvas_height / rect.height();
let pixel_x = (relative_x * scale_x) as f64;
let pixel_y = (relative_y * scale_y) as f64;
// Get the 2D context and read the pixel data using JavaScript interop
if let Ok(Some(ctx)) = canvas.get_context("2d") {
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
// Use web_sys::CanvasRenderingContext2d::get_image_data with proper error handling
match ctx.get_image_data(pixel_x, pixel_y, 1.0, 1.0) {
Ok(image_data) => {
// Get the pixel data as Clamped<Vec<u8>>
let data = image_data.data();
// Alpha channel is the 4th value (index 3)
if data.len() >= 4 {
return data[3] > 0;
}
}
Err(_) => {
// Security error or other issue with getImageData - assume no hit
return false;
}
}
}
false
}
/// Draw a rounded rectangle path.
#[cfg(feature = "hydrate")]

View file

@ -0,0 +1,77 @@
//! Shared canvas utilities for avatar and prop rendering.
//!
//! Common functions used by both AvatarCanvas and LoosePropCanvas components.
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
#[cfg(feature = "hydrate")]
pub fn normalize_asset_path(path: &str) -> String {
if path.starts_with('/') {
path.to_string()
} else {
format!("/static/{}", path)
}
}
/// Test if a click at the given client coordinates hits a non-transparent pixel.
///
/// Returns true if the alpha channel at the clicked pixel is > 0.
/// This enables pixel-perfect hit detection on canvas elements.
#[cfg(feature = "hydrate")]
pub fn hit_test_canvas(
canvas: &web_sys::HtmlCanvasElement,
client_x: f64,
client_y: f64,
) -> bool {
use wasm_bindgen::JsCast;
// Get the canvas bounding rect to transform client coords to canvas coords
let rect = canvas.get_bounding_client_rect();
// Calculate click position relative to the canvas element
let relative_x = client_x - rect.left();
let relative_y = client_y - rect.top();
// Check if click is within canvas bounds
if relative_x < 0.0
|| relative_y < 0.0
|| relative_x >= rect.width()
|| relative_y >= rect.height()
{
return false;
}
// Transform to canvas pixel coordinates (accounting for CSS scaling)
let canvas_width = canvas.width() as f64;
let canvas_height = canvas.height() as f64;
// Avoid division by zero
if rect.width() == 0.0 || rect.height() == 0.0 {
return false;
}
let scale_x = canvas_width / rect.width();
let scale_y = canvas_height / rect.height();
let pixel_x = relative_x * scale_x;
let pixel_y = relative_y * scale_y;
// Get the 2D context and read the pixel data
if let Ok(Some(ctx)) = canvas.get_context("2d") {
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
match ctx.get_image_data(pixel_x, pixel_y, 1.0, 1.0) {
Ok(image_data) => {
let data = image_data.data();
// Alpha channel is the 4th value (index 3)
if data.len() >= 4 {
return data[3] > 0;
}
}
Err(_) => {
return false;
}
}
}
false
}

View file

@ -0,0 +1,188 @@
//! Individual loose prop canvas component for per-prop rendering.
//!
//! Each loose prop gets its own canvas element positioned via CSS transforms.
//! This enables pixel-perfect hit detection using getImageData().
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::LooseProp;
#[cfg(feature = "hydrate")]
pub use super::canvas_utils::hit_test_canvas;
#[cfg(feature = "hydrate")]
use super::canvas_utils::normalize_asset_path;
use super::settings::{BASE_AVATAR_SCALE, BASE_PROP_SCALE, BASE_PROP_SIZE};
/// Get a unique key for a loose prop (for Leptos For keying).
pub fn loose_prop_key(p: &LooseProp) -> Uuid {
p.id
}
/// Individual loose prop canvas component.
///
/// Renders a single prop with:
/// - CSS transform for position (GPU-accelerated, no redraw on move)
/// - Canvas for prop sprite (redraws only on appearance change)
/// - Pixel-perfect hit detection via getImageData()
#[component]
pub fn LoosePropCanvas(
/// The prop data (as a signal for reactive updates).
prop: Signal<LooseProp>,
/// X scale factor for coordinate conversion.
scale_x: Signal<f64>,
/// Y scale factor for coordinate conversion.
scale_y: Signal<f64>,
/// X offset for coordinate conversion.
offset_x: Signal<f64>,
/// Y offset for coordinate conversion.
offset_y: Signal<f64>,
/// Base prop size in screen pixels (already includes viewport scaling).
base_prop_size: Signal<f64>,
/// Z-index for stacking order.
z_index: i32,
) -> impl IntoView {
let canvas_ref = NodeRef::<leptos::html::Canvas>::new();
// Reactive style for CSS positioning (GPU-accelerated transforms)
let style = move || {
let p = prop.get();
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
let base_size = base_prop_size.get();
// Calculate rendered prop size
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
let prop_size = base_size * prop_scale_ratio * p.scale as f64;
// Screen position (center of prop)
let screen_x = p.position_x * sx + ox;
let screen_y = p.position_y * sy + oy;
// Canvas positioned at top-left corner
let canvas_x = screen_x - prop_size / 2.0;
let canvas_y = screen_y - prop_size / 2.0;
format!(
"position: absolute; \
left: 0; top: 0; \
transform: translate({}px, {}px); \
z-index: {}; \
pointer-events: auto; \
width: {}px; \
height: {}px;",
canvas_x, canvas_y, z_index, prop_size, prop_size
)
};
#[cfg(feature = "hydrate")]
{
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
// Image cache for this prop
let image_cache: Rc<RefCell<Option<web_sys::HtmlImageElement>>> =
Rc::new(RefCell::new(None));
// Redraw trigger - incremented when image loads
let (redraw_trigger, set_redraw_trigger) = signal(0u32);
// Effect to draw the prop when canvas is ready or appearance changes
Effect::new(move |_| {
// Subscribe to redraw trigger
let _ = redraw_trigger.get();
let p = prop.get();
let base_size = base_prop_size.get();
let Some(canvas) = canvas_ref.get() else {
return;
};
// Calculate rendered prop size
let prop_scale_ratio = BASE_PROP_SCALE / BASE_AVATAR_SCALE;
let prop_size = base_size * prop_scale_ratio * p.scale as f64;
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
// Set canvas resolution
canvas_el.set_width(prop_size as u32);
canvas_el.set_height(prop_size as u32);
let Ok(Some(ctx)) = canvas_el.get_context("2d") else {
return;
};
let ctx: web_sys::CanvasRenderingContext2d = ctx.dyn_into().unwrap();
// Clear canvas
ctx.clear_rect(0.0, 0.0, prop_size, prop_size);
// Draw prop sprite if asset path available
if !p.prop_asset_path.is_empty() {
let normalized_path = normalize_asset_path(&p.prop_asset_path);
let mut cache = image_cache.borrow_mut();
if let Some(ref img) = *cache {
// Image in cache - draw if loaded
if img.complete() && img.natural_width() > 0 {
let _ = ctx.draw_image_with_html_image_element_and_dw_and_dh(
img, 0.0, 0.0, prop_size, prop_size,
);
}
} else {
// Not in cache - create and load
let img = web_sys::HtmlImageElement::new().unwrap();
let trigger = set_redraw_trigger;
let onload = Closure::once(Box::new(move || {
trigger.update(|v| *v += 1);
}) as Box<dyn FnOnce()>);
img.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
img.set_src(&normalized_path);
*cache = Some(img);
}
} else {
// Fallback: draw placeholder circle with prop name
ctx.begin_path();
let _ = ctx.arc(
prop_size / 2.0,
prop_size / 2.0,
prop_size / 2.0 - 2.0,
0.0,
std::f64::consts::PI * 2.0,
);
ctx.set_fill_style_str("#f59e0b");
ctx.fill();
ctx.set_stroke_style_str("#d97706");
ctx.set_line_width(2.0);
ctx.stroke();
// Draw prop name
let text_scale = prop_size / (BASE_PROP_SIZE * BASE_PROP_SCALE);
ctx.set_fill_style_str("#fff");
ctx.set_font(&format!("{}px sans-serif", 10.0 * text_scale));
ctx.set_text_align("center");
ctx.set_text_baseline("middle");
let _ = ctx.fill_text(&p.prop_name, prop_size / 2.0, prop_size / 2.0);
}
});
}
// Compute data-prop-id reactively
let data_prop_id = move || prop.get().id.to_string();
view! {
<canvas
node_ref=canvas_ref
style=style
data-prop-id=data_prop_id
/>
}
}

View file

@ -15,13 +15,15 @@ use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
#[cfg(feature = "hydrate")]
use super::avatar_canvas::hit_test_canvas;
use super::avatar_canvas::{AvatarCanvas, member_key};
#[cfg(feature = "hydrate")]
use super::canvas_utils::hit_test_canvas;
use super::chat_types::ActiveBubble;
use super::context_menu::{ContextMenu, ContextMenuItem};
use super::loose_prop_canvas::LoosePropCanvas;
use super::settings::{
BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH, ViewerSettings, calculate_min_zoom,
BASE_AVATAR_SCALE, BASE_PROP_SCALE, BASE_PROP_SIZE, REFERENCE_HEIGHT, REFERENCE_WIDTH,
ViewerSettings, calculate_min_zoom,
};
use super::ws_client::FadingMember;
use crate::utils::parse_bounds_dimensions;
@ -60,6 +62,12 @@ pub fn RealmSceneViewer(
/// Callback when whisper is requested on a member.
#[prop(optional, into)]
on_whisper_request: Option<Callback<String>>,
/// Whether the current user is a moderator (can edit prop scales).
#[prop(optional, into)]
is_moderator: Option<Signal<bool>>,
/// Callback when prop scale is updated (moderator only).
#[prop(optional, into)]
on_prop_scale_update: Option<Callback<(Uuid, f32)>>,
) -> impl IntoView {
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
// Use default settings if none provided
@ -103,10 +111,9 @@ pub fn RealmSceneViewer(
let has_background_image = scene.background_image_path.is_some();
let image_path = scene.background_image_path.clone().unwrap_or_default();
// Canvas refs for background and props layers
// Avatar layer now uses individual canvas elements per user
// Canvas ref for background layer
// Avatar and prop layers use individual canvas elements per user/prop
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
// Outer container ref for middle-mouse drag scrolling
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
@ -121,70 +128,153 @@ pub fn RealmSceneViewer(
// Signal to track when scale factors have been properly calculated
let (scales_ready, set_scales_ready) = signal(false);
// Context menu state
// Context menu state (for avatar whisper)
let (context_menu_open, set_context_menu_open) = signal(false);
let (context_menu_position, set_context_menu_position) = signal((0.0_f64, 0.0_f64));
let (context_menu_target, set_context_menu_target) = signal(Option::<String>::None);
// Prop context menu state (for moderator scale editing)
let (prop_context_menu_open, set_prop_context_menu_open) = signal(false);
let (prop_context_menu_position, set_prop_context_menu_position) = signal((0.0_f64, 0.0_f64));
let (prop_context_menu_target, set_prop_context_menu_target) = signal(Option::<Uuid>::None);
// Scale mode state (when dragging to resize prop)
let (scale_mode_active, set_scale_mode_active) = signal(false);
let (scale_mode_prop_id, set_scale_mode_prop_id) = signal(Option::<Uuid>::None);
let (scale_mode_initial_scale, set_scale_mode_initial_scale) = signal(1.0_f32);
let (scale_mode_preview_scale, set_scale_mode_preview_scale) = signal(1.0_f32);
// Prop center in canvas coordinates (for scale calculation)
let (scale_mode_prop_center, set_scale_mode_prop_center) = signal((0.0_f64, 0.0_f64));
// Handle overlay click for movement or prop pickup
// TODO: Add hit-testing for avatar clicks
// Uses pixel-perfect hit testing on prop canvases
#[cfg(feature = "hydrate")]
let on_overlay_click = {
let on_move = on_move.clone();
let on_prop_click = on_prop_click.clone();
move |ev: web_sys::MouseEvent| {
// Get click position relative to the target element
let target = ev.current_target().unwrap();
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
let rect = element.get_bounding_client_rect();
use wasm_bindgen::JsCast;
let click_x = ev.client_x() as f64 - rect.left();
let click_y = ev.client_y() as f64 - rect.top();
let client_x = ev.client_x() as f64;
let client_y = ev.client_y() as f64;
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
// First check for pixel-perfect prop hits
let document = web_sys::window().unwrap().document().unwrap();
let mut clicked_prop: Option<Uuid> = None;
if sx > 0.0 && sy > 0.0 {
let scene_x = (click_x - ox) / sx;
let scene_y = (click_y - oy) / sy;
// Query prop canvases in the props container
if let Some(container) = document.query_selector(".props-container").ok().flatten() {
let canvases = container.get_elements_by_tag_name("canvas");
let canvas_count = canvases.length();
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);
// Check if click is within 40px of any loose prop
let current_props = loose_props.get();
let prop_click_radius = 40.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;
for i in 0..canvas_count {
if let Some(element) = canvases.item(i) {
if let Ok(canvas) = element.dyn_into::<web_sys::HtmlCanvasElement>() {
if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") {
// Pixel-perfect hit test
if hit_test_canvas(&canvas, client_x, client_y) {
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
clicked_prop = Some(prop_id);
break;
}
}
}
}
}
}
}
if let Some(prop_id) = clicked_prop {
on_prop_click.run(prop_id);
} else {
// No prop hit - handle as movement
let target = ev.current_target().unwrap();
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
let rect = element.get_bounding_client_rect();
let click_x = client_x - rect.left();
let click_y = client_y - rect.top();
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
if sx > 0.0 && sy > 0.0 {
let scene_x = (click_x - ox) / sx;
let scene_y = (click_y - oy) / sy;
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);
if let Some(prop_id) = clicked_prop {
on_prop_click.run(prop_id);
} else {
on_move.run((scene_x, scene_y));
}
}
}
};
// Handle right-click for context menu on avatars
// Handle right-click for context menu on avatars or props (moderators only for props)
#[cfg(feature = "hydrate")]
let on_overlay_contextmenu = {
let current_user_id = current_user_id.clone();
move |ev: web_sys::MouseEvent| {
use wasm_bindgen::JsCast;
// Guests cannot message other users - don't show context menu
// Get click position
let client_x = ev.client_x() as f64;
let client_y = ev.client_y() as f64;
// Check if moderator and if click is on a prop (for scale editing)
let is_mod = is_moderator.map(|s| s.get()).unwrap_or(false);
if is_mod {
let document = web_sys::window().unwrap().document().unwrap();
// Query prop canvases for pixel-perfect hit testing
if let Some(container) = document.query_selector(".props-container").ok().flatten()
{
let canvases = container.get_elements_by_tag_name("canvas");
let canvas_count = canvases.length();
for i in 0..canvas_count {
if let Some(element) = canvases.item(i) {
if let Ok(canvas) = element.dyn_into::<web_sys::HtmlCanvasElement>() {
if let Some(prop_id_str) = canvas.get_attribute("data-prop-id") {
// Pixel-perfect hit test
if hit_test_canvas(&canvas, client_x, client_y) {
if let Ok(prop_id) = prop_id_str.parse::<Uuid>() {
// Found a prop - show prop context menu
ev.prevent_default();
set_prop_context_menu_position.set((client_x, client_y));
set_prop_context_menu_target.set(Some(prop_id));
set_prop_context_menu_open.set(true);
// Find the prop data for scale mode
if let Some(prop) = loose_props
.get()
.iter()
.find(|p| p.id == prop_id)
{
set_scale_mode_initial_scale.set(prop.scale);
// Get prop center from canvas bounding rect
let rect = canvas.get_bounding_client_rect();
let prop_canvas_x =
rect.left() + rect.width() / 2.0;
let prop_canvas_y =
rect.top() + rect.height() / 2.0;
set_scale_mode_prop_center
.set((prop_canvas_x, prop_canvas_y));
}
return;
}
}
}
}
}
}
}
}
// Guests cannot message other users - don't show avatar context menu
if is_guest.get() {
return;
}
@ -192,10 +282,6 @@ pub fn RealmSceneViewer(
// Get current user identity for filtering
let my_user_id = current_user_id.map(|s| s.get()).flatten();
// Get click position
let client_x = ev.client_x() as f64;
let client_y = ev.client_y() as f64;
// Query all avatar canvases and check for hit
let document = web_sys::window().unwrap().document().unwrap();
@ -485,117 +571,9 @@ pub fn RealmSceneViewer(
draw_bg.forget();
});
// =========================================================
// Props Effect - runs when loose_props or settings change
// =========================================================
Effect::new(move |_| {
// Track signals
let current_props = loose_props.get();
let current_pan_mode = is_pan_mode.get();
let current_zoom = zoom_level.get();
let current_enlarge = enlarge_props.get();
// Skip drawing if scale factors haven't been calculated yet
if !scales_ready.get() {
return;
}
// Read scale factors inside the Effect (reactive context) before the closure
let sx = scale_x.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.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 canvas_width = canvas_el.width();
let canvas_height = canvas_el.height();
if canvas_width == 0 || canvas_height == 0 {
return;
}
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, canvas_width as f64, canvas_height as f64);
// Calculate prop size based on mode
let prop_size = calculate_prop_size(
current_pan_mode,
current_zoom,
current_enlarge,
sx,
sy,
scene_width_f,
scene_height_f,
);
// Draw loose props
draw_loose_props(&ctx, &current_props, sx, sy, ox, oy, prop_size);
}
}) 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();
});
// =========================================================
// Sync canvas sizes when mode or zoom changes
// =========================================================
Effect::new(move |_| {
let current_pan_mode = is_pan_mode.get();
let current_zoom = zoom_level.get();
// Wait for scales to be ready (background drawn)
if !scales_ready.get() {
return;
}
if current_pan_mode {
// Pan mode: resize props and avatar canvases to match background
let canvas_width = (scene_width_f * current_zoom) as u32;
let canvas_height = (scene_height_f * current_zoom) as u32;
if let Some(canvas) = props_canvas_ref.get() {
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
if canvas_el.width() != canvas_width || canvas_el.height() != canvas_height {
canvas_el.set_width(canvas_width);
canvas_el.set_height(canvas_height);
}
}
// Note: Avatar canvases are now individual elements that manage their own sizes
} else {
// Fit mode: sync props and avatar canvases to background canvas size
if let Some(bg_canvas) = bg_canvas_ref.get() {
let bg_el: &web_sys::HtmlCanvasElement = &bg_canvas;
let canvas_width = bg_el.width();
let canvas_height = bg_el.height();
if canvas_width > 0 && canvas_height > 0 {
if let Some(canvas) = props_canvas_ref.get() {
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
if canvas_el.width() != canvas_width
|| canvas_el.height() != canvas_height
{
canvas_el.set_width(canvas_width);
canvas_el.set_height(canvas_height);
}
}
// Note: Avatar canvases are now individual elements that manage their own sizes
}
}
}
});
// Note: Props are now rendered as individual LoosePropCanvas components
// that manage their own positioning and sizing via CSS transforms.
// No shared props canvas or effect needed.
// =========================================================
// Middle mouse button drag-to-pan (only in pan mode)
@ -865,7 +843,7 @@ pub fn RealmSceneViewer(
m
});
// Calculate prop size based on current settings
// Calculate prop size based on current settings (for avatars, uses BASE_AVATAR_SCALE)
let prop_size = Signal::derive(move || {
let current_pan_mode = is_pan_mode.get();
let current_zoom = zoom_level.get();
@ -876,16 +854,17 @@ pub fn RealmSceneViewer(
// Reference scale factor for "enlarge props" mode
let ref_scale = (scene_width_f / REFERENCE_WIDTH).max(scene_height_f / REFERENCE_HEIGHT);
// Avatar size uses BASE_AVATAR_SCALE (60px cells at native size)
if current_pan_mode {
if current_enlarge {
BASE_PROP_SIZE * ref_scale * current_zoom
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * current_zoom
} else {
BASE_PROP_SIZE * current_zoom
BASE_PROP_SIZE * BASE_AVATAR_SCALE * current_zoom
}
} else if current_enlarge {
BASE_PROP_SIZE * ref_scale * sx.min(sy)
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * sx.min(sy)
} else {
BASE_PROP_SIZE * sx.min(sy)
BASE_PROP_SIZE * BASE_AVATAR_SCALE * sx.min(sy)
}
});
@ -938,13 +917,41 @@ pub fn RealmSceneViewer(
style=move || canvas_style(0)
aria-hidden="true"
/>
// Props layer - loose props, redrawn on drop/pickup
<canvas
node_ref=props_canvas_ref
class=canvas_class
style=move || canvas_style(1)
aria-hidden="true"
/>
// Props container - individual canvases per prop for pixel-perfect hit detection
<div
class="props-container absolute inset-0"
style="z-index: 1; pointer-events: none;"
>
<Show
when=move || scales_ready.get()
fallback=|| ()
>
{move || {
loose_props.get().into_iter().map(|prop| {
let prop_id = prop.id;
// Create a derived signal for this specific prop
let prop_signal = Signal::derive(move || {
loose_props.get()
.into_iter()
.find(|p| p.id == prop_id)
.unwrap_or_else(|| prop.clone())
});
view! {
<LoosePropCanvas
prop=prop_signal
scale_x=scale_x_signal
scale_y=scale_y_signal
offset_x=offset_x_signal
offset_y=offset_y_signal
base_prop_size=prop_size
z_index=5
/>
}
}).collect_view()
}}
</Show>
</div>
// Avatars container - individual canvases per user
<div
class="avatars-container absolute inset-0"
@ -1095,108 +1102,147 @@ pub fn RealmSceneViewer(
set_context_menu_target.set(None);
})
/>
// Context menu for prop interactions (moderators only)
<ContextMenu
open=Signal::derive(move || prop_context_menu_open.get())
position=Signal::derive(move || prop_context_menu_position.get())
header=Signal::derive(move || Some("Prop".to_string()))
items=Signal::derive(move || {
vec![
ContextMenuItem {
label: "Set Scale".to_string(),
action: "set_scale".to_string(),
},
]
})
on_select=Callback::new({
move |action: String| {
if action == "set_scale" {
if let Some(prop_id) = prop_context_menu_target.get() {
// Enter scale mode
set_scale_mode_prop_id.set(Some(prop_id));
set_scale_mode_preview_scale.set(scale_mode_initial_scale.get());
set_scale_mode_active.set(true);
}
}
// Close the menu
set_prop_context_menu_open.set(false);
}
})
on_close=Callback::new(move |_: ()| {
set_prop_context_menu_open.set(false);
set_prop_context_menu_target.set(None);
})
/>
// Scale mode overlay (shown when editing prop scale)
<Show when=move || scale_mode_active.get()>
{move || {
let prop_id = scale_mode_prop_id.get();
let preview_scale = scale_mode_preview_scale.get();
let (center_x, center_y) = scale_mode_prop_center.get();
// Find the prop to get its dimensions
let prop_data = prop_id.and_then(|id| {
loose_props.get().iter().find(|p| p.id == id).cloned()
});
view! {
<div
class="fixed inset-0 z-50 cursor-crosshair"
style="background: rgba(0,0,0,0.3);"
on:mousemove=move |ev| {
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::JsCast;
// Calculate scale based on distance from prop center
let ev: web_sys::MouseEvent = ev.dyn_into().unwrap();
let mouse_x = ev.client_x() as f64;
let mouse_y = ev.client_y() as f64;
let (cx, cy) = scale_mode_prop_center.get();
let dx = mouse_x - cx;
let dy = mouse_y - cy;
let distance = (dx * dx + dy * dy).sqrt();
// Scale formula: distance / 40 gives 1x at 40px
let new_scale = (distance / 40.0).clamp(0.1, 10.0) as f32;
set_scale_mode_preview_scale.set(new_scale);
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
on:mouseup=move |ev| {
#[cfg(feature = "hydrate")]
{
// Apply the scale
if let (Some(prop_id), Some(ref callback)) = (scale_mode_prop_id.get(), on_prop_scale_update.as_ref()) {
let final_scale = scale_mode_preview_scale.get();
callback.run((prop_id, final_scale));
}
// Exit scale mode
set_scale_mode_active.set(false);
set_scale_mode_prop_id.set(None);
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
on:keydown=move |ev| {
#[cfg(feature = "hydrate")]
{
use wasm_bindgen::JsCast;
let ev: web_sys::KeyboardEvent = ev.dyn_into().unwrap();
if ev.key() == "Escape" {
// Cancel scale mode
ev.prevent_default();
set_scale_mode_active.set(false);
set_scale_mode_prop_id.set(None);
}
}
#[cfg(not(feature = "hydrate"))]
let _ = ev;
}
tabindex="0"
>
// Visual feedback: dashed border around prop
{move || {
if let Some(ref _prop) = prop_data {
let prop_size = BASE_PROP_SIZE * BASE_PROP_SCALE * preview_scale as f64;
let half_size = prop_size / 2.0;
view! {
<div
class="absolute pointer-events-none"
style=format!(
"left: {}px; top: {}px; width: {}px; height: {}px; \
border: 2px dashed #fbbf24; \
transform: translate(-50%, -50%); \
box-sizing: border-box;",
center_x, center_y, prop_size, prop_size
)
/>
// Scale indicator
<div
class="absolute bg-gray-900/90 text-yellow-400 px-2 py-1 rounded text-sm font-mono pointer-events-none"
style=format!(
"left: {}px; top: {}px; transform: translate(-50%, 8px);",
center_x, center_y + half_size
)
>
{format!("{:.2}x", preview_scale)}
</div>
}.into_any()
} else {
().into_any()
}
}}
// Instructions
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-gray-900/90 text-white px-4 py-2 rounded text-sm">
"Drag to resize • Release to apply • Escape to cancel"
</div>
</div>
}
}}
</Show>
</div>
</div>
}
}
#[cfg(feature = "hydrate")]
use wasm_bindgen::JsCast;
/// Calculate prop/avatar size based on current rendering mode.
///
/// - Pan mode without enlarge: BASE_PROP_SIZE * zoom_level
/// - Pan mode with enlarge: BASE_PROP_SIZE * reference_scale * zoom_level
/// - Fit mode with enlarge: Reference scaling based on 1920x1080
/// - Fit mode without enlarge: BASE_PROP_SIZE * min(scale_x, scale_y)
#[cfg(feature = "hydrate")]
fn calculate_prop_size(
pan_mode: bool,
zoom_level: f64,
enlarge_props: bool,
scale_x: f64,
scale_y: f64,
scene_width: f64,
scene_height: f64,
) -> f64 {
// Reference scale factor for "enlarge props" mode
let ref_scale = (scene_width / REFERENCE_WIDTH).max(scene_height / REFERENCE_HEIGHT);
if pan_mode {
if enlarge_props {
BASE_PROP_SIZE * ref_scale * zoom_level
} else {
BASE_PROP_SIZE * zoom_level
}
} else if enlarge_props {
// Reference scaling: scale props relative to 1920x1080 reference
BASE_PROP_SIZE * ref_scale * scale_x.min(scale_y)
} else {
// Default: base size scaled to viewport
BASE_PROP_SIZE * scale_x.min(scale_y)
}
}
/// Normalize an asset path to be absolute, prefixing with /static/ if needed.
#[cfg(feature = "hydrate")]
fn normalize_asset_path(path: &str) -> String {
if path.starts_with('/') {
path.to_string()
} else {
format!("/static/{}", 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,
prop_size: f64,
) {
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
let text_scale = prop_size / BASE_PROP_SIZE;
ctx.set_fill_style_str("#fff");
ctx.set_font(&format!("{}px sans-serif", 10.0 * text_scale));
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

@ -8,8 +8,17 @@ use crate::utils::LocalStoragePersist;
pub const REFERENCE_WIDTH: f64 = 1920.0;
pub const REFERENCE_HEIGHT: f64 = 1080.0;
/// Base size for props and avatars in scene space.
pub const BASE_PROP_SIZE: f64 = 60.0;
/// Base size for props/avatars in scene coordinates.
/// SVG assets are 120x120 pixels - this is the native/full size.
pub const BASE_PROP_SIZE: f64 = 120.0;
/// Scale factor for avatar rendering relative to BASE_PROP_SIZE.
/// Avatars render at 50% (60px cells) to allow merit-based scaling up later.
pub const BASE_AVATAR_SCALE: f64 = 0.5;
/// Scale factor for dropped loose props relative to BASE_PROP_SIZE.
/// Props render at 75% (90px) at default scale=1.0.
pub const BASE_PROP_SCALE: f64 = 0.75;
/// Minimum zoom level (25%).
pub const ZOOM_MIN: f64 = 0.25;

View file

@ -139,6 +139,8 @@ pub enum WsEvent {
PropDropped(LooseProp),
/// A prop was picked up (by prop ID).
PropPickedUp(uuid::Uuid),
/// A prop was updated (scale changed).
PropRefresh(LooseProp),
/// A member started fading out (timeout disconnect).
MemberFading(FadingMember),
/// Welcome message received with current user info.
@ -521,6 +523,7 @@ fn handle_server_message(
LoosePropsSync(Vec<LooseProp>),
PropDropped(LooseProp),
PropPickedUp(uuid::Uuid),
PropRefresh(LooseProp),
Error(WsError),
TeleportApproved(TeleportInfo),
Summoned(SummonInfo),
@ -659,6 +662,9 @@ fn handle_server_message(
// Treat expired props the same as picked up (remove from display)
PostAction::PropPickedUp(prop_id)
}
ServerMessage::PropRefresh { prop } => {
PostAction::PropRefresh(prop)
}
ServerMessage::AvatarUpdated { user_id, avatar } => {
// Find member and update their avatar layers
if let Some(m) = state.members
@ -775,6 +781,9 @@ fn handle_server_message(
PostAction::PropPickedUp(prop_id) => {
on_event.run(WsEvent::PropPickedUp(prop_id));
}
PostAction::PropRefresh(prop) => {
on_event.run(WsEvent::PropRefresh(prop));
}
PostAction::Error(err) => {
on_event.run(WsEvent::Error(err));
}

View file

@ -476,6 +476,14 @@ pub fn RealmPage() -> impl IntoView {
}
});
}
WsEvent::PropRefresh(prop) => {
// Update the prop in the loose_props list (replace existing or ignore if not found)
set_loose_props.update(|props| {
if let Some(existing) = props.iter_mut().find(|p| p.id == prop.id) {
*existing = prop;
}
});
}
}
});
@ -1202,6 +1210,16 @@ pub fn RealmPage() -> impl IntoView {
});
});
let is_moderator_signal = Signal::derive(move || is_moderator.get());
#[cfg(feature = "hydrate")]
let ws_for_prop_scale = ws_sender_clone.clone();
let on_prop_scale_update_cb = Callback::new(move |(prop_id, scale): (Uuid, f32)| {
#[cfg(feature = "hydrate")]
ws_for_prop_scale.with_value(|sender| {
if let Some(send_fn) = sender {
send_fn(ClientMessage::UpdateProp { loose_prop_id: prop_id, scale });
}
});
});
view! {
<div class="relative w-full">
<RealmSceneViewer
@ -1223,6 +1241,8 @@ pub fn RealmPage() -> impl IntoView {
current_user_id=Signal::derive(move || current_user_id.get())
is_guest=Signal::derive(move || is_guest.get())
on_whisper_request=on_whisper_request_cb
is_moderator=is_moderator_signal
on_prop_scale_update=on_prop_scale_update_cb
/>
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
<ChatInput