Fix prop renders
* Incorporate prop scaling * Props now render to a canvas
This commit is contained in:
parent
af89394df1
commit
a2841c413d
21 changed files with 942 additions and 353 deletions
|
|
@ -9,6 +9,8 @@ pub mod config;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod loose_props;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
pub mod props;
|
pub mod props;
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod realms;
|
pub mod realms;
|
||||||
|
|
|
||||||
75
crates/chattyness-admin-ui/src/api/loose_props.rs
Normal file
75
crates/chattyness-admin-ui/src/api/loose_props.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
//! Loose props management API handlers for admin UI.
|
||||||
|
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::Path;
|
||||||
|
use chattyness_db::{models::LooseProp, queries::loose_props};
|
||||||
|
use chattyness_error::AppError;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::AdminConn;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// API Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Request to update loose prop scale.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateLoosePropScaleRequest {
|
||||||
|
/// Scale factor (0.1 - 10.0).
|
||||||
|
pub scale: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// API Handlers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Get a loose prop by ID.
|
||||||
|
pub async fn get_loose_prop(
|
||||||
|
admin_conn: AdminConn,
|
||||||
|
Path(loose_prop_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<LooseProp>, AppError> {
|
||||||
|
let conn = admin_conn.0;
|
||||||
|
let mut guard = conn.acquire().await;
|
||||||
|
|
||||||
|
let prop = loose_props::get_loose_prop_by_id(&mut *guard, loose_prop_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(prop))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update loose prop scale.
|
||||||
|
///
|
||||||
|
/// Server admins can update any loose prop scale.
|
||||||
|
pub async fn update_loose_prop_scale(
|
||||||
|
admin_conn: AdminConn,
|
||||||
|
Path(loose_prop_id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateLoosePropScaleRequest>,
|
||||||
|
) -> Result<Json<LooseProp>, AppError> {
|
||||||
|
let conn = admin_conn.0;
|
||||||
|
let mut guard = conn.acquire().await;
|
||||||
|
|
||||||
|
let prop = loose_props::update_loose_prop_scale(&mut *guard, loose_prop_id, req.scale).await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Updated loose prop {} scale to {}",
|
||||||
|
loose_prop_id,
|
||||||
|
req.scale
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(prop))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List loose props in a scene/channel.
|
||||||
|
pub async fn list_loose_props(
|
||||||
|
admin_conn: AdminConn,
|
||||||
|
Path(scene_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<LooseProp>>, AppError> {
|
||||||
|
let conn = admin_conn.0;
|
||||||
|
let mut guard = conn.acquire().await;
|
||||||
|
|
||||||
|
let props = loose_props::list_channel_loose_props(&mut *guard, scene_id).await?;
|
||||||
|
|
||||||
|
Ok(Json(props))
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ use axum::{
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, post, put},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users};
|
use super::{auth, avatars, config, dashboard, loose_props, props, realms, scenes, spots, staff, users};
|
||||||
use crate::app::AdminAppState;
|
use crate::app::AdminAppState;
|
||||||
|
|
||||||
/// Create the admin API router.
|
/// Create the admin API router.
|
||||||
|
|
@ -85,6 +85,19 @@ pub fn admin_api_router() -> Router<AdminAppState> {
|
||||||
"/props/{prop_id}",
|
"/props/{prop_id}",
|
||||||
get(props::get_prop).delete(props::delete_prop),
|
get(props::get_prop).delete(props::delete_prop),
|
||||||
)
|
)
|
||||||
|
// API - Loose Props (scene props)
|
||||||
|
.route(
|
||||||
|
"/scenes/{scene_id}/loose_props",
|
||||||
|
get(loose_props::list_loose_props),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/loose_props/{loose_prop_id}",
|
||||||
|
get(loose_props::get_loose_prop),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/loose_props/{loose_prop_id}/scale",
|
||||||
|
put(loose_props::update_loose_prop_scale),
|
||||||
|
)
|
||||||
// API - Server Avatars
|
// API - Server Avatars
|
||||||
.route(
|
.route(
|
||||||
"/avatars",
|
"/avatars",
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,8 @@ pub struct PropDetail {
|
||||||
pub default_layer: Option<String>,
|
pub default_layer: Option<String>,
|
||||||
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
|
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
|
||||||
pub default_position: Option<i16>,
|
pub default_position: Option<i16>,
|
||||||
|
/// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas.
|
||||||
|
pub default_scale: f32,
|
||||||
pub is_unique: bool,
|
pub is_unique: bool,
|
||||||
pub is_transferable: bool,
|
pub is_transferable: bool,
|
||||||
pub is_portable: bool,
|
pub is_portable: bool,
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ fn PropDetailView(prop: PropDetail) -> impl IntoView {
|
||||||
None => "Not set".to_string(),
|
None => "Not set".to_string(),
|
||||||
}}
|
}}
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
|
<DetailItem label="Default Scale">
|
||||||
|
{format!("{}%", (prop.default_scale * 100.0) as i32)}
|
||||||
|
</DetailItem>
|
||||||
<DetailItem label="Status">
|
<DetailItem label="Status">
|
||||||
{if prop.is_active {
|
{if prop.is_active {
|
||||||
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
view! { <span class="status-badge status-active">"Active"</span> }.into_any()
|
||||||
|
|
|
||||||
|
|
@ -889,6 +889,8 @@ pub struct LooseProp {
|
||||||
pub realm_prop_id: Option<Uuid>,
|
pub realm_prop_id: Option<Uuid>,
|
||||||
pub position_x: f64,
|
pub position_x: f64,
|
||||||
pub position_y: f64,
|
pub position_y: f64,
|
||||||
|
/// Scale factor (0.1 - 10.0) inherited from prop definition at drop time.
|
||||||
|
pub scale: f32,
|
||||||
pub dropped_by: Option<Uuid>,
|
pub dropped_by: Option<Uuid>,
|
||||||
pub expires_at: Option<DateTime<Utc>>,
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
|
@ -915,6 +917,8 @@ pub struct ServerProp {
|
||||||
pub default_emotion: Option<EmotionState>,
|
pub default_emotion: Option<EmotionState>,
|
||||||
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
|
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
|
||||||
pub default_position: Option<i16>,
|
pub default_position: Option<i16>,
|
||||||
|
/// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas.
|
||||||
|
pub default_scale: f32,
|
||||||
pub is_unique: bool,
|
pub is_unique: bool,
|
||||||
pub is_transferable: bool,
|
pub is_transferable: bool,
|
||||||
pub is_portable: bool,
|
pub is_portable: bool,
|
||||||
|
|
@ -966,6 +970,9 @@ pub struct CreateServerPropRequest {
|
||||||
/// Whether prop appears in the public Server inventory tab.
|
/// Whether prop appears in the public Server inventory tab.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub public: Option<bool>,
|
pub public: Option<bool>,
|
||||||
|
/// Default scale factor (0.1 - 10.0) applied when prop is dropped to canvas.
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_scale: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
|
|
@ -999,6 +1006,14 @@ impl CreateServerPropRequest {
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Validate scale range (0.1 - 10.0)
|
||||||
|
if let Some(scale) = self.default_scale {
|
||||||
|
if !(0.1..=10.0).contains(&scale) {
|
||||||
|
return Err(AppError::Validation(
|
||||||
|
"default_scale must be between 0.1 and 10.0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ pub async fn list_channel_loose_props<'e>(
|
||||||
lp.realm_prop_id,
|
lp.realm_prop_id,
|
||||||
ST_X(lp.position) as position_x,
|
ST_X(lp.position) as position_x,
|
||||||
ST_Y(lp.position) as position_y,
|
ST_Y(lp.position) as position_y,
|
||||||
|
lp.scale,
|
||||||
lp.dropped_by,
|
lp.dropped_by,
|
||||||
lp.expires_at,
|
lp.expires_at,
|
||||||
lp.created_at,
|
lp.created_at,
|
||||||
|
|
@ -81,6 +82,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
) -> Result<LooseProp, AppError> {
|
) -> Result<LooseProp, AppError> {
|
||||||
// Single CTE that checks existence/droppability and performs the operation atomically.
|
// Single CTE that checks existence/droppability and performs the operation atomically.
|
||||||
// Returns status flags plus the LooseProp data (if successful).
|
// Returns status flags plus the LooseProp data (if successful).
|
||||||
|
// Includes scale inherited from the source prop's default_scale.
|
||||||
let result: Option<(
|
let result: Option<(
|
||||||
bool,
|
bool,
|
||||||
bool,
|
bool,
|
||||||
|
|
@ -91,6 +93,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
Option<Uuid>,
|
Option<Uuid>,
|
||||||
Option<f32>,
|
Option<f32>,
|
||||||
Option<f32>,
|
Option<f32>,
|
||||||
|
Option<f32>,
|
||||||
Option<Uuid>,
|
Option<Uuid>,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
Option<chrono::DateTime<chrono::Utc>>,
|
Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
|
@ -99,9 +102,18 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
)> = sqlx::query_as(
|
)> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
WITH item_info AS (
|
WITH item_info AS (
|
||||||
SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
SELECT
|
||||||
FROM auth.inventory
|
inv.id,
|
||||||
WHERE id = $1 AND user_id = $2
|
inv.is_droppable,
|
||||||
|
inv.server_prop_id,
|
||||||
|
inv.realm_prop_id,
|
||||||
|
inv.prop_name,
|
||||||
|
inv.prop_asset_path,
|
||||||
|
COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale
|
||||||
|
FROM auth.inventory inv
|
||||||
|
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||||
|
LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id
|
||||||
|
WHERE inv.id = $1 AND inv.user_id = $2
|
||||||
),
|
),
|
||||||
deleted_item AS (
|
deleted_item AS (
|
||||||
DELETE FROM auth.inventory
|
DELETE FROM auth.inventory
|
||||||
|
|
@ -114,6 +126,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
server_prop_id,
|
server_prop_id,
|
||||||
realm_prop_id,
|
realm_prop_id,
|
||||||
position,
|
position,
|
||||||
|
scale,
|
||||||
dropped_by,
|
dropped_by,
|
||||||
expires_at
|
expires_at
|
||||||
)
|
)
|
||||||
|
|
@ -122,6 +135,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
di.server_prop_id,
|
di.server_prop_id,
|
||||||
di.realm_prop_id,
|
di.realm_prop_id,
|
||||||
public.make_virtual_point($4::real, $5::real),
|
public.make_virtual_point($4::real, $5::real),
|
||||||
|
(SELECT default_scale FROM item_info),
|
||||||
$2,
|
$2,
|
||||||
now() + interval '30 minutes'
|
now() + interval '30 minutes'
|
||||||
FROM deleted_item di
|
FROM deleted_item di
|
||||||
|
|
@ -132,6 +146,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
realm_prop_id,
|
realm_prop_id,
|
||||||
ST_X(position)::real as position_x,
|
ST_X(position)::real as position_x,
|
||||||
ST_Y(position)::real as position_y,
|
ST_Y(position)::real as position_y,
|
||||||
|
scale,
|
||||||
dropped_by,
|
dropped_by,
|
||||||
expires_at,
|
expires_at,
|
||||||
created_at
|
created_at
|
||||||
|
|
@ -146,6 +161,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
ip.realm_prop_id,
|
ip.realm_prop_id,
|
||||||
ip.position_x,
|
ip.position_x,
|
||||||
ip.position_y,
|
ip.position_y,
|
||||||
|
ip.scale,
|
||||||
ip.dropped_by,
|
ip.dropped_by,
|
||||||
ip.expires_at,
|
ip.expires_at,
|
||||||
ip.created_at,
|
ip.created_at,
|
||||||
|
|
@ -171,19 +187,19 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
"Unexpected error dropping prop to canvas".to_string(),
|
"Unexpected error dropping prop to canvas".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||||
// Item didn't exist
|
// Item didn't exist
|
||||||
Err(AppError::NotFound(
|
Err(AppError::NotFound(
|
||||||
"Inventory item not found or not owned by user".to_string(),
|
"Inventory item not found or not owned by user".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||||
// Item existed but is not droppable
|
// Item existed but is not droppable
|
||||||
Err(AppError::Forbidden(
|
Err(AppError::Forbidden(
|
||||||
"This prop cannot be dropped - it is an essential prop".to_string(),
|
"This prop cannot be dropped - it is an essential prop".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _)) => {
|
Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||||
// Item was droppable but delete failed (shouldn't happen)
|
// Item was droppable but delete failed (shouldn't happen)
|
||||||
Err(AppError::Internal(
|
Err(AppError::Internal(
|
||||||
"Unexpected error dropping prop to canvas".to_string(),
|
"Unexpected error dropping prop to canvas".to_string(),
|
||||||
|
|
@ -199,6 +215,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
realm_prop_id,
|
realm_prop_id,
|
||||||
Some(position_x),
|
Some(position_x),
|
||||||
Some(position_y),
|
Some(position_y),
|
||||||
|
Some(scale),
|
||||||
dropped_by,
|
dropped_by,
|
||||||
Some(expires_at),
|
Some(expires_at),
|
||||||
Some(created_at),
|
Some(created_at),
|
||||||
|
|
@ -213,6 +230,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
||||||
realm_prop_id,
|
realm_prop_id,
|
||||||
position_x: position_x.into(),
|
position_x: position_x.into(),
|
||||||
position_y: position_y.into(),
|
position_y: position_y.into(),
|
||||||
|
scale,
|
||||||
dropped_by,
|
dropped_by,
|
||||||
expires_at: Some(expires_at),
|
expires_at: Some(expires_at),
|
||||||
created_at,
|
created_at,
|
||||||
|
|
@ -313,6 +331,102 @@ pub async fn pick_up_loose_prop<'e>(
|
||||||
Ok(item)
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the scale of a loose prop.
|
||||||
|
///
|
||||||
|
/// Server admins can update any loose prop.
|
||||||
|
/// Realm admins can update loose props in their realm.
|
||||||
|
pub async fn update_loose_prop_scale<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
loose_prop_id: Uuid,
|
||||||
|
scale: f32,
|
||||||
|
) -> Result<LooseProp, AppError> {
|
||||||
|
// Validate scale range
|
||||||
|
if !(0.1..=10.0).contains(&scale) {
|
||||||
|
return Err(AppError::Validation(
|
||||||
|
"Scale must be between 0.1 and 10.0".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prop = sqlx::query_as::<_, LooseProp>(
|
||||||
|
r#"
|
||||||
|
WITH updated AS (
|
||||||
|
UPDATE scene.loose_props
|
||||||
|
SET scale = $2
|
||||||
|
WHERE id = $1
|
||||||
|
AND (expires_at IS NULL OR expires_at > now())
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
instance_id as channel_id,
|
||||||
|
server_prop_id,
|
||||||
|
realm_prop_id,
|
||||||
|
ST_X(position) as position_x,
|
||||||
|
ST_Y(position) as position_y,
|
||||||
|
scale,
|
||||||
|
dropped_by,
|
||||||
|
expires_at,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.channel_id,
|
||||||
|
u.server_prop_id,
|
||||||
|
u.realm_prop_id,
|
||||||
|
u.position_x,
|
||||||
|
u.position_y,
|
||||||
|
u.scale,
|
||||||
|
u.dropped_by,
|
||||||
|
u.expires_at,
|
||||||
|
u.created_at,
|
||||||
|
COALESCE(sp.name, rp.name) as prop_name,
|
||||||
|
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path
|
||||||
|
FROM updated u
|
||||||
|
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
|
||||||
|
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(loose_prop_id)
|
||||||
|
.bind(scale)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?;
|
||||||
|
|
||||||
|
Ok(prop)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a loose prop by ID.
|
||||||
|
pub async fn get_loose_prop_by_id<'e>(
|
||||||
|
executor: impl PgExecutor<'e>,
|
||||||
|
loose_prop_id: Uuid,
|
||||||
|
) -> Result<Option<LooseProp>, AppError> {
|
||||||
|
let prop = sqlx::query_as::<_, LooseProp>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
lp.id,
|
||||||
|
lp.instance_id as channel_id,
|
||||||
|
lp.server_prop_id,
|
||||||
|
lp.realm_prop_id,
|
||||||
|
ST_X(lp.position) as position_x,
|
||||||
|
ST_Y(lp.position) as position_y,
|
||||||
|
lp.scale,
|
||||||
|
lp.dropped_by,
|
||||||
|
lp.expires_at,
|
||||||
|
lp.created_at,
|
||||||
|
COALESCE(sp.name, rp.name) as prop_name,
|
||||||
|
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path
|
||||||
|
FROM scene.loose_props lp
|
||||||
|
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
|
||||||
|
LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id
|
||||||
|
WHERE lp.id = $1
|
||||||
|
AND (lp.expires_at IS NULL OR lp.expires_at > now())
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(loose_prop_id)
|
||||||
|
.fetch_optional(executor)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(prop)
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete expired loose props.
|
/// Delete expired loose props.
|
||||||
///
|
///
|
||||||
/// Returns the number of props deleted.
|
/// Returns the number of props deleted.
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ pub async fn get_server_prop_by_id<'e>(
|
||||||
default_layer,
|
default_layer,
|
||||||
default_emotion,
|
default_emotion,
|
||||||
default_position,
|
default_position,
|
||||||
|
default_scale,
|
||||||
is_unique,
|
is_unique,
|
||||||
is_transferable,
|
is_transferable,
|
||||||
is_portable,
|
is_portable,
|
||||||
|
|
@ -116,20 +117,23 @@ pub async fn create_server_prop<'e>(
|
||||||
|
|
||||||
let is_droppable = req.droppable.unwrap_or(true);
|
let is_droppable = req.droppable.unwrap_or(true);
|
||||||
let is_public = req.public.unwrap_or(false);
|
let is_public = req.public.unwrap_or(false);
|
||||||
|
let default_scale = req.default_scale.unwrap_or(1.0);
|
||||||
|
|
||||||
let prop = sqlx::query_as::<_, ServerProp>(
|
let prop = sqlx::query_as::<_, ServerProp>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO server.props (
|
INSERT INTO server.props (
|
||||||
name, slug, description, tags, asset_path,
|
name, slug, description, tags, asset_path,
|
||||||
default_layer, default_emotion, default_position,
|
default_layer, default_emotion, default_position,
|
||||||
|
default_scale,
|
||||||
is_droppable, is_public,
|
is_droppable, is_public,
|
||||||
created_by
|
created_by
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6::server.avatar_layer, $7::server.emotion_state, $8,
|
$6::server.avatar_layer, $7::server.emotion_state, $8,
|
||||||
$9, $10,
|
$9,
|
||||||
$11
|
$10, $11,
|
||||||
|
$12
|
||||||
)
|
)
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
|
|
@ -142,6 +146,7 @@ pub async fn create_server_prop<'e>(
|
||||||
default_layer,
|
default_layer,
|
||||||
default_emotion,
|
default_emotion,
|
||||||
default_position,
|
default_position,
|
||||||
|
default_scale,
|
||||||
is_unique,
|
is_unique,
|
||||||
is_transferable,
|
is_transferable,
|
||||||
is_portable,
|
is_portable,
|
||||||
|
|
@ -163,6 +168,7 @@ pub async fn create_server_prop<'e>(
|
||||||
.bind(&default_layer)
|
.bind(&default_layer)
|
||||||
.bind(&default_emotion)
|
.bind(&default_emotion)
|
||||||
.bind(default_position)
|
.bind(default_position)
|
||||||
|
.bind(default_scale)
|
||||||
.bind(is_droppable)
|
.bind(is_droppable)
|
||||||
.bind(is_public)
|
.bind(is_public)
|
||||||
.bind(created_by)
|
.bind(created_by)
|
||||||
|
|
@ -207,20 +213,23 @@ pub async fn upsert_server_prop<'e>(
|
||||||
|
|
||||||
let is_droppable = req.droppable.unwrap_or(true);
|
let is_droppable = req.droppable.unwrap_or(true);
|
||||||
let is_public = req.public.unwrap_or(false);
|
let is_public = req.public.unwrap_or(false);
|
||||||
|
let default_scale = req.default_scale.unwrap_or(1.0);
|
||||||
|
|
||||||
let prop = sqlx::query_as::<_, ServerProp>(
|
let prop = sqlx::query_as::<_, ServerProp>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO server.props (
|
INSERT INTO server.props (
|
||||||
name, slug, description, tags, asset_path,
|
name, slug, description, tags, asset_path,
|
||||||
default_layer, default_emotion, default_position,
|
default_layer, default_emotion, default_position,
|
||||||
|
default_scale,
|
||||||
is_droppable, is_public,
|
is_droppable, is_public,
|
||||||
created_by
|
created_by
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6::server.avatar_layer, $7::server.emotion_state, $8,
|
$6::server.avatar_layer, $7::server.emotion_state, $8,
|
||||||
$9, $10,
|
$9,
|
||||||
$11
|
$10, $11,
|
||||||
|
$12
|
||||||
)
|
)
|
||||||
ON CONFLICT (slug) DO UPDATE SET
|
ON CONFLICT (slug) DO UPDATE SET
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
|
|
@ -230,6 +239,7 @@ pub async fn upsert_server_prop<'e>(
|
||||||
default_layer = EXCLUDED.default_layer,
|
default_layer = EXCLUDED.default_layer,
|
||||||
default_emotion = EXCLUDED.default_emotion,
|
default_emotion = EXCLUDED.default_emotion,
|
||||||
default_position = EXCLUDED.default_position,
|
default_position = EXCLUDED.default_position,
|
||||||
|
default_scale = EXCLUDED.default_scale,
|
||||||
is_droppable = EXCLUDED.is_droppable,
|
is_droppable = EXCLUDED.is_droppable,
|
||||||
is_public = EXCLUDED.is_public,
|
is_public = EXCLUDED.is_public,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
|
|
@ -244,6 +254,7 @@ pub async fn upsert_server_prop<'e>(
|
||||||
default_layer,
|
default_layer,
|
||||||
default_emotion,
|
default_emotion,
|
||||||
default_position,
|
default_position,
|
||||||
|
default_scale,
|
||||||
is_unique,
|
is_unique,
|
||||||
is_transferable,
|
is_transferable,
|
||||||
is_portable,
|
is_portable,
|
||||||
|
|
@ -265,6 +276,7 @@ pub async fn upsert_server_prop<'e>(
|
||||||
.bind(&default_layer)
|
.bind(&default_layer)
|
||||||
.bind(&default_emotion)
|
.bind(&default_emotion)
|
||||||
.bind(default_position)
|
.bind(default_position)
|
||||||
|
.bind(default_scale)
|
||||||
.bind(is_droppable)
|
.bind(is_droppable)
|
||||||
.bind(is_public)
|
.bind(is_public)
|
||||||
.bind(created_by)
|
.bind(created_by)
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,14 @@ pub enum ClientMessage {
|
||||||
/// Request to refresh identity after registration (guest → user conversion).
|
/// Request to refresh identity after registration (guest → user conversion).
|
||||||
/// Server will fetch updated user data and broadcast to all members.
|
/// Server will fetch updated user data and broadcast to all members.
|
||||||
RefreshIdentity,
|
RefreshIdentity,
|
||||||
|
|
||||||
|
/// Update a loose prop's scale (moderator only).
|
||||||
|
UpdateProp {
|
||||||
|
/// The loose prop ID to update.
|
||||||
|
loose_prop_id: Uuid,
|
||||||
|
/// New scale factor (0.1 - 10.0).
|
||||||
|
scale: f32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Server-to-client WebSocket messages.
|
/// Server-to-client WebSocket messages.
|
||||||
|
|
@ -221,6 +229,12 @@ pub enum ServerMessage {
|
||||||
prop_id: Uuid,
|
prop_id: Uuid,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A prop was updated (scale changed) - clients should update their local copy.
|
||||||
|
PropRefresh {
|
||||||
|
/// The updated prop with all current values.
|
||||||
|
prop: LooseProp,
|
||||||
|
},
|
||||||
|
|
||||||
/// A member updated their avatar appearance.
|
/// A member updated their avatar appearance.
|
||||||
AvatarUpdated {
|
AvatarUpdated {
|
||||||
/// User ID of the member.
|
/// User ID of the member.
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
Message::Close(close_frame) => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
pub mod avatar_canvas;
|
pub mod avatar_canvas;
|
||||||
pub mod avatar_editor;
|
pub mod avatar_editor;
|
||||||
|
pub mod canvas_utils;
|
||||||
pub mod avatar_store;
|
pub mod avatar_store;
|
||||||
pub mod avatar_thumbnail;
|
pub mod avatar_thumbnail;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
|
|
@ -17,6 +18,7 @@ pub mod keybindings;
|
||||||
pub mod keybindings_popup;
|
pub mod keybindings_popup;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod log_popup;
|
pub mod log_popup;
|
||||||
|
pub mod loose_prop_canvas;
|
||||||
pub mod modals;
|
pub mod modals;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod register_modal;
|
pub mod register_modal;
|
||||||
|
|
@ -31,6 +33,7 @@ pub mod ws_client;
|
||||||
pub use avatar_canvas::*;
|
pub use avatar_canvas::*;
|
||||||
pub use avatar_editor::*;
|
pub use avatar_editor::*;
|
||||||
pub use avatar_store::*;
|
pub use avatar_store::*;
|
||||||
|
pub use canvas_utils::*;
|
||||||
pub use avatar_thumbnail::*;
|
pub use avatar_thumbnail::*;
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
pub use chat_types::*;
|
pub use chat_types::*;
|
||||||
|
|
@ -45,6 +48,7 @@ pub use keybindings::*;
|
||||||
pub use keybindings_popup::*;
|
pub use keybindings_popup::*;
|
||||||
pub use layout::*;
|
pub use layout::*;
|
||||||
pub use log_popup::*;
|
pub use log_popup::*;
|
||||||
|
pub use loose_prop_canvas::*;
|
||||||
pub use modals::*;
|
pub use modals::*;
|
||||||
pub use notifications::*;
|
pub use notifications::*;
|
||||||
pub use register_modal::*;
|
pub use register_modal::*;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState};
|
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};
|
use super::chat_types::{ActiveBubble, emotion_bubble_colors};
|
||||||
|
|
||||||
/// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
|
/// 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.
|
/// 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
|
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.
|
/// Draw a rounded rectangle path.
|
||||||
#[cfg(feature = "hydrate")]
|
#[cfg(feature = "hydrate")]
|
||||||
|
|
|
||||||
77
crates/chattyness-user-ui/src/components/canvas_utils.rs
Normal file
77
crates/chattyness-user-ui/src/components/canvas_utils.rs
Normal 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
|
||||||
|
}
|
||||||
188
crates/chattyness-user-ui/src/components/loose_prop_canvas.rs
Normal file
188
crates/chattyness-user-ui/src/components/loose_prop_canvas.rs
Normal 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
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -15,13 +15,15 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
use super::avatar_canvas::hit_test_canvas;
|
|
||||||
use super::avatar_canvas::{AvatarCanvas, member_key};
|
use super::avatar_canvas::{AvatarCanvas, member_key};
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use super::canvas_utils::hit_test_canvas;
|
||||||
use super::chat_types::ActiveBubble;
|
use super::chat_types::ActiveBubble;
|
||||||
use super::context_menu::{ContextMenu, ContextMenuItem};
|
use super::context_menu::{ContextMenu, ContextMenuItem};
|
||||||
|
use super::loose_prop_canvas::LoosePropCanvas;
|
||||||
use super::settings::{
|
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 super::ws_client::FadingMember;
|
||||||
use crate::utils::parse_bounds_dimensions;
|
use crate::utils::parse_bounds_dimensions;
|
||||||
|
|
@ -60,6 +62,12 @@ pub fn RealmSceneViewer(
|
||||||
/// Callback when whisper is requested on a member.
|
/// Callback when whisper is requested on a member.
|
||||||
#[prop(optional, into)]
|
#[prop(optional, into)]
|
||||||
on_whisper_request: Option<Callback<String>>,
|
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 {
|
) -> impl IntoView {
|
||||||
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
let is_guest = is_guest.unwrap_or_else(|| Signal::derive(|| false));
|
||||||
// Use default settings if none provided
|
// Use default settings if none provided
|
||||||
|
|
@ -103,10 +111,9 @@ pub fn RealmSceneViewer(
|
||||||
let has_background_image = scene.background_image_path.is_some();
|
let has_background_image = scene.background_image_path.is_some();
|
||||||
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
||||||
|
|
||||||
// Canvas refs for background and props layers
|
// Canvas ref for background layer
|
||||||
// Avatar layer now uses individual canvas elements per user
|
// Avatar and prop layers use individual canvas elements per user/prop
|
||||||
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
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
|
// Outer container ref for middle-mouse drag scrolling
|
||||||
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
|
let outer_container_ref = NodeRef::<leptos::html::Div>::new();
|
||||||
|
|
@ -121,25 +128,72 @@ pub fn RealmSceneViewer(
|
||||||
// Signal to track when scale factors have been properly calculated
|
// Signal to track when scale factors have been properly calculated
|
||||||
let (scales_ready, set_scales_ready) = signal(false);
|
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_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_position, set_context_menu_position) = signal((0.0_f64, 0.0_f64));
|
||||||
let (context_menu_target, set_context_menu_target) = signal(Option::<String>::None);
|
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
|
// 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")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_overlay_click = {
|
let on_overlay_click = {
|
||||||
let on_move = on_move.clone();
|
let on_move = on_move.clone();
|
||||||
let on_prop_click = on_prop_click.clone();
|
let on_prop_click = on_prop_click.clone();
|
||||||
move |ev: web_sys::MouseEvent| {
|
move |ev: web_sys::MouseEvent| {
|
||||||
// Get click position relative to the target element
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
let client_x = ev.client_x() as f64;
|
||||||
|
let client_y = ev.client_y() as f64;
|
||||||
|
|
||||||
|
// First check for pixel-perfect prop hits
|
||||||
|
let document = web_sys::window().unwrap().document().unwrap();
|
||||||
|
let mut clicked_prop: Option<Uuid> = None;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
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 target = ev.current_target().unwrap();
|
||||||
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
|
let element: web_sys::HtmlElement = target.dyn_into().unwrap();
|
||||||
let rect = element.get_bounding_client_rect();
|
let rect = element.get_bounding_client_rect();
|
||||||
|
|
||||||
let click_x = ev.client_x() as f64 - rect.left();
|
let click_x = client_x - rect.left();
|
||||||
let click_y = ev.client_y() as f64 - rect.top();
|
let click_y = client_y - rect.top();
|
||||||
|
|
||||||
let sx = scale_x.get();
|
let sx = scale_x.get();
|
||||||
let sy = scale_y.get();
|
let sy = scale_y.get();
|
||||||
|
|
@ -153,38 +207,74 @@ pub fn RealmSceneViewer(
|
||||||
let scene_x = scene_x.max(0.0).min(scene_width as f64);
|
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);
|
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 ¤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));
|
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")]
|
#[cfg(feature = "hydrate")]
|
||||||
let on_overlay_contextmenu = {
|
let on_overlay_contextmenu = {
|
||||||
let current_user_id = current_user_id.clone();
|
let current_user_id = current_user_id.clone();
|
||||||
move |ev: web_sys::MouseEvent| {
|
move |ev: web_sys::MouseEvent| {
|
||||||
use wasm_bindgen::JsCast;
|
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() {
|
if is_guest.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -192,10 +282,6 @@ pub fn RealmSceneViewer(
|
||||||
// Get current user identity for filtering
|
// Get current user identity for filtering
|
||||||
let my_user_id = current_user_id.map(|s| s.get()).flatten();
|
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
|
// Query all avatar canvases and check for hit
|
||||||
let document = web_sys::window().unwrap().document().unwrap();
|
let document = web_sys::window().unwrap().document().unwrap();
|
||||||
|
|
||||||
|
|
@ -485,117 +571,9 @@ pub fn RealmSceneViewer(
|
||||||
draw_bg.forget();
|
draw_bg.forget();
|
||||||
});
|
});
|
||||||
|
|
||||||
// =========================================================
|
// Note: Props are now rendered as individual LoosePropCanvas components
|
||||||
// Props Effect - runs when loose_props or settings change
|
// that manage their own positioning and sizing via CSS transforms.
|
||||||
// =========================================================
|
// No shared props canvas or effect needed.
|
||||||
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, ¤t_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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// Middle mouse button drag-to-pan (only in pan mode)
|
// Middle mouse button drag-to-pan (only in pan mode)
|
||||||
|
|
@ -865,7 +843,7 @@ pub fn RealmSceneViewer(
|
||||||
m
|
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 prop_size = Signal::derive(move || {
|
||||||
let current_pan_mode = is_pan_mode.get();
|
let current_pan_mode = is_pan_mode.get();
|
||||||
let current_zoom = zoom_level.get();
|
let current_zoom = zoom_level.get();
|
||||||
|
|
@ -876,16 +854,17 @@ pub fn RealmSceneViewer(
|
||||||
// Reference scale factor for "enlarge props" mode
|
// Reference scale factor for "enlarge props" mode
|
||||||
let ref_scale = (scene_width_f / REFERENCE_WIDTH).max(scene_height_f / REFERENCE_HEIGHT);
|
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_pan_mode {
|
||||||
if current_enlarge {
|
if current_enlarge {
|
||||||
BASE_PROP_SIZE * ref_scale * current_zoom
|
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * current_zoom
|
||||||
} else {
|
} else {
|
||||||
BASE_PROP_SIZE * current_zoom
|
BASE_PROP_SIZE * BASE_AVATAR_SCALE * current_zoom
|
||||||
}
|
}
|
||||||
} else if current_enlarge {
|
} else if current_enlarge {
|
||||||
BASE_PROP_SIZE * ref_scale * sx.min(sy)
|
BASE_PROP_SIZE * BASE_AVATAR_SCALE * ref_scale * sx.min(sy)
|
||||||
} else {
|
} 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)
|
style=move || canvas_style(0)
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
// Props layer - loose props, redrawn on drop/pickup
|
// Props container - individual canvases per prop for pixel-perfect hit detection
|
||||||
<canvas
|
<div
|
||||||
node_ref=props_canvas_ref
|
class="props-container absolute inset-0"
|
||||||
class=canvas_class
|
style="z-index: 1; pointer-events: none;"
|
||||||
style=move || canvas_style(1)
|
>
|
||||||
aria-hidden="true"
|
<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
|
// Avatars container - individual canvases per user
|
||||||
<div
|
<div
|
||||||
class="avatars-container absolute inset-0"
|
class="avatars-container absolute inset-0"
|
||||||
|
|
@ -1095,108 +1102,147 @@ pub fn RealmSceneViewer(
|
||||||
set_context_menu_target.set(None);
|
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>
|
||||||
</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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,17 @@ use crate::utils::LocalStoragePersist;
|
||||||
pub const REFERENCE_WIDTH: f64 = 1920.0;
|
pub const REFERENCE_WIDTH: f64 = 1920.0;
|
||||||
pub const REFERENCE_HEIGHT: f64 = 1080.0;
|
pub const REFERENCE_HEIGHT: f64 = 1080.0;
|
||||||
|
|
||||||
/// Base size for props and avatars in scene space.
|
/// Base size for props/avatars in scene coordinates.
|
||||||
pub const BASE_PROP_SIZE: f64 = 60.0;
|
/// 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%).
|
/// Minimum zoom level (25%).
|
||||||
pub const ZOOM_MIN: f64 = 0.25;
|
pub const ZOOM_MIN: f64 = 0.25;
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,8 @@ pub enum WsEvent {
|
||||||
PropDropped(LooseProp),
|
PropDropped(LooseProp),
|
||||||
/// A prop was picked up (by prop ID).
|
/// A prop was picked up (by prop ID).
|
||||||
PropPickedUp(uuid::Uuid),
|
PropPickedUp(uuid::Uuid),
|
||||||
|
/// A prop was updated (scale changed).
|
||||||
|
PropRefresh(LooseProp),
|
||||||
/// A member started fading out (timeout disconnect).
|
/// A member started fading out (timeout disconnect).
|
||||||
MemberFading(FadingMember),
|
MemberFading(FadingMember),
|
||||||
/// Welcome message received with current user info.
|
/// Welcome message received with current user info.
|
||||||
|
|
@ -521,6 +523,7 @@ fn handle_server_message(
|
||||||
LoosePropsSync(Vec<LooseProp>),
|
LoosePropsSync(Vec<LooseProp>),
|
||||||
PropDropped(LooseProp),
|
PropDropped(LooseProp),
|
||||||
PropPickedUp(uuid::Uuid),
|
PropPickedUp(uuid::Uuid),
|
||||||
|
PropRefresh(LooseProp),
|
||||||
Error(WsError),
|
Error(WsError),
|
||||||
TeleportApproved(TeleportInfo),
|
TeleportApproved(TeleportInfo),
|
||||||
Summoned(SummonInfo),
|
Summoned(SummonInfo),
|
||||||
|
|
@ -659,6 +662,9 @@ fn handle_server_message(
|
||||||
// Treat expired props the same as picked up (remove from display)
|
// Treat expired props the same as picked up (remove from display)
|
||||||
PostAction::PropPickedUp(prop_id)
|
PostAction::PropPickedUp(prop_id)
|
||||||
}
|
}
|
||||||
|
ServerMessage::PropRefresh { prop } => {
|
||||||
|
PostAction::PropRefresh(prop)
|
||||||
|
}
|
||||||
ServerMessage::AvatarUpdated { user_id, avatar } => {
|
ServerMessage::AvatarUpdated { user_id, avatar } => {
|
||||||
// Find member and update their avatar layers
|
// Find member and update their avatar layers
|
||||||
if let Some(m) = state.members
|
if let Some(m) = state.members
|
||||||
|
|
@ -775,6 +781,9 @@ fn handle_server_message(
|
||||||
PostAction::PropPickedUp(prop_id) => {
|
PostAction::PropPickedUp(prop_id) => {
|
||||||
on_event.run(WsEvent::PropPickedUp(prop_id));
|
on_event.run(WsEvent::PropPickedUp(prop_id));
|
||||||
}
|
}
|
||||||
|
PostAction::PropRefresh(prop) => {
|
||||||
|
on_event.run(WsEvent::PropRefresh(prop));
|
||||||
|
}
|
||||||
PostAction::Error(err) => {
|
PostAction::Error(err) => {
|
||||||
on_event.run(WsEvent::Error(err));
|
on_event.run(WsEvent::Error(err));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
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! {
|
view! {
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<RealmSceneViewer
|
<RealmSceneViewer
|
||||||
|
|
@ -1223,6 +1241,8 @@ pub fn RealmPage() -> impl IntoView {
|
||||||
current_user_id=Signal::derive(move || current_user_id.get())
|
current_user_id=Signal::derive(move || current_user_id.get())
|
||||||
is_guest=Signal::derive(move || is_guest.get())
|
is_guest=Signal::derive(move || is_guest.get())
|
||||||
on_whisper_request=on_whisper_request_cb
|
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">
|
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,9 @@ CREATE TABLE server.props (
|
||||||
default_emotion server.emotion_state,
|
default_emotion server.emotion_state,
|
||||||
default_position SMALLINT CHECK (default_position IS NULL OR default_position BETWEEN 0 AND 8),
|
default_position SMALLINT CHECK (default_position IS NULL OR default_position BETWEEN 0 AND 8),
|
||||||
|
|
||||||
|
-- Default scale factor for dropped props (10% - 1000%)
|
||||||
|
default_scale REAL NOT NULL DEFAULT 1.0 CHECK (default_scale >= 0.1 AND default_scale <= 10.0),
|
||||||
|
|
||||||
is_unique BOOLEAN NOT NULL DEFAULT false,
|
is_unique BOOLEAN NOT NULL DEFAULT false,
|
||||||
is_transferable BOOLEAN NOT NULL DEFAULT true,
|
is_transferable BOOLEAN NOT NULL DEFAULT true,
|
||||||
is_portable BOOLEAN NOT NULL DEFAULT true,
|
is_portable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,9 @@ CREATE TABLE realm.props (
|
||||||
default_emotion server.emotion_state,
|
default_emotion server.emotion_state,
|
||||||
default_position SMALLINT CHECK (default_position IS NULL OR default_position BETWEEN 0 AND 8),
|
default_position SMALLINT CHECK (default_position IS NULL OR default_position BETWEEN 0 AND 8),
|
||||||
|
|
||||||
|
-- Default scale factor for dropped props (10% - 1000%)
|
||||||
|
default_scale REAL NOT NULL DEFAULT 1.0 CHECK (default_scale >= 0.1 AND default_scale <= 10.0),
|
||||||
|
|
||||||
is_unique BOOLEAN NOT NULL DEFAULT false,
|
is_unique BOOLEAN NOT NULL DEFAULT false,
|
||||||
is_transferable BOOLEAN NOT NULL DEFAULT true,
|
is_transferable BOOLEAN NOT NULL DEFAULT true,
|
||||||
is_droppable BOOLEAN NOT NULL DEFAULT true,
|
is_droppable BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,9 @@ CREATE TABLE scene.loose_props (
|
||||||
-- Position in scene (PostGIS point, SRID 0)
|
-- Position in scene (PostGIS point, SRID 0)
|
||||||
position public.virtual_point NOT NULL,
|
position public.virtual_point NOT NULL,
|
||||||
|
|
||||||
|
-- Scale factor (10% - 1000%), inherited from prop definition at drop time
|
||||||
|
scale REAL NOT NULL DEFAULT 1.0 CHECK (scale >= 0.1 AND scale <= 10.0),
|
||||||
|
|
||||||
-- Who dropped it (NULL = spawned by system/script)
|
-- Who dropped it (NULL = spawned by system/script)
|
||||||
dropped_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
dropped_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue