make emotions named instead, add drop prop
This commit is contained in:
parent
989e20757b
commit
ea3b444d71
19 changed files with 1429 additions and 150 deletions
|
|
@ -343,6 +343,45 @@ impl std::str::FromStr for EmotionState {
|
|||
}
|
||||
}
|
||||
|
||||
impl EmotionState {
|
||||
/// Convert a keybinding index (0-11) to an emotion state.
|
||||
pub fn from_index(i: u8) -> Option<Self> {
|
||||
match i {
|
||||
0 => Some(Self::Neutral),
|
||||
1 => Some(Self::Happy),
|
||||
2 => Some(Self::Sad),
|
||||
3 => Some(Self::Angry),
|
||||
4 => Some(Self::Surprised),
|
||||
5 => Some(Self::Thinking),
|
||||
6 => Some(Self::Laughing),
|
||||
7 => Some(Self::Crying),
|
||||
8 => Some(Self::Love),
|
||||
9 => Some(Self::Confused),
|
||||
10 => Some(Self::Sleeping),
|
||||
11 => Some(Self::Wink),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert emotion state to its index (0-11).
|
||||
pub fn to_index(&self) -> u8 {
|
||||
match self {
|
||||
Self::Neutral => 0,
|
||||
Self::Happy => 1,
|
||||
Self::Sad => 2,
|
||||
Self::Angry => 3,
|
||||
Self::Surprised => 4,
|
||||
Self::Thinking => 5,
|
||||
Self::Laughing => 6,
|
||||
Self::Crying => 7,
|
||||
Self::Love => 8,
|
||||
Self::Confused => 9,
|
||||
Self::Sleeping => 10,
|
||||
Self::Wink => 11,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Models
|
||||
// =============================================================================
|
||||
|
|
@ -485,6 +524,8 @@ pub struct Scene {
|
|||
pub is_hidden: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Default public channel ID for this scene.
|
||||
pub default_channel_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Minimal scene info for listings.
|
||||
|
|
@ -541,6 +582,67 @@ pub struct SpotSummary {
|
|||
// Props Models
|
||||
// =============================================================================
|
||||
|
||||
/// Origin source for a prop in inventory.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "ssr", sqlx(type_name = "prop_origin", rename_all = "snake_case"))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PropOrigin {
|
||||
#[default]
|
||||
ServerLibrary,
|
||||
RealmLibrary,
|
||||
UserUpload,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PropOrigin {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PropOrigin::ServerLibrary => write!(f, "server_library"),
|
||||
PropOrigin::RealmLibrary => write!(f, "realm_library"),
|
||||
PropOrigin::UserUpload => write!(f, "user_upload"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An inventory item (user-owned prop).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
pub struct InventoryItem {
|
||||
pub id: Uuid,
|
||||
pub prop_name: String,
|
||||
pub prop_asset_path: String,
|
||||
pub layer: Option<AvatarLayer>,
|
||||
pub is_transferable: bool,
|
||||
pub is_portable: bool,
|
||||
pub origin: PropOrigin,
|
||||
pub acquired_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Response for inventory list.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InventoryResponse {
|
||||
pub items: Vec<InventoryItem>,
|
||||
}
|
||||
|
||||
/// A prop dropped in a channel, available for pickup.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
pub struct LooseProp {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub server_prop_id: Option<Uuid>,
|
||||
pub realm_prop_id: Option<Uuid>,
|
||||
pub position_x: f64,
|
||||
pub position_y: f64,
|
||||
pub dropped_by: Option<Uuid>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Prop name (JOINed from source prop).
|
||||
pub prop_name: String,
|
||||
/// Asset path for rendering (JOINed from source prop).
|
||||
pub prop_asset_path: String,
|
||||
}
|
||||
|
||||
/// A server-wide prop (global library).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
pub mod avatars;
|
||||
pub mod channel_members;
|
||||
pub mod channels;
|
||||
pub mod guests;
|
||||
pub mod inventory;
|
||||
pub mod loose_props;
|
||||
pub mod memberships;
|
||||
pub mod owner;
|
||||
pub mod props;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::collections::HashMap;
|
|||
use sqlx::{postgres::PgConnection, PgExecutor, PgPool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{ActiveAvatar, AvatarWithPaths, EmotionAvailability};
|
||||
use crate::models::{ActiveAvatar, AvatarWithPaths, EmotionAvailability, EmotionState};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
/// Get the active avatar for a user in a realm.
|
||||
|
|
@ -35,29 +35,27 @@ pub async fn set_emotion<'e>(
|
|||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
realm_id: Uuid,
|
||||
emotion: i16,
|
||||
emotion: EmotionState,
|
||||
) -> Result<[Option<String>; 9], AppError> {
|
||||
if emotion < 0 || emotion > 11 {
|
||||
return Err(AppError::Validation("Emotion must be 0-11".to_string()));
|
||||
}
|
||||
|
||||
// Map emotion index to column prefix
|
||||
// Map emotion to column prefix
|
||||
let emotion_prefix = match emotion {
|
||||
0 => "e_neutral",
|
||||
1 => "e_happy",
|
||||
2 => "e_sad",
|
||||
3 => "e_angry",
|
||||
4 => "e_surprised",
|
||||
5 => "e_thinking",
|
||||
6 => "e_laughing",
|
||||
7 => "e_crying",
|
||||
8 => "e_love",
|
||||
9 => "e_confused",
|
||||
10 => "e_sleeping",
|
||||
11 => "e_wink",
|
||||
_ => return Err(AppError::Validation("Emotion must be 0-11".to_string())),
|
||||
EmotionState::Neutral => "e_neutral",
|
||||
EmotionState::Happy => "e_happy",
|
||||
EmotionState::Sad => "e_sad",
|
||||
EmotionState::Angry => "e_angry",
|
||||
EmotionState::Surprised => "e_surprised",
|
||||
EmotionState::Thinking => "e_thinking",
|
||||
EmotionState::Laughing => "e_laughing",
|
||||
EmotionState::Crying => "e_crying",
|
||||
EmotionState::Love => "e_love",
|
||||
EmotionState::Confused => "e_confused",
|
||||
EmotionState::Sleeping => "e_sleeping",
|
||||
EmotionState::Wink => "e_wink",
|
||||
};
|
||||
|
||||
// Get the numeric index for the database
|
||||
let emotion_index = emotion.to_index() as i16;
|
||||
|
||||
// Build dynamic query for the specific emotion's 9 positions
|
||||
let query = format!(
|
||||
r#"
|
||||
|
|
@ -86,7 +84,7 @@ pub async fn set_emotion<'e>(
|
|||
let result = sqlx::query_as::<_, EmotionLayerRow>(&query)
|
||||
.bind(user_id)
|
||||
.bind(realm_id)
|
||||
.bind(emotion)
|
||||
.bind(emotion_index)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
43
crates/chattyness-db/src/queries/channels.rs
Normal file
43
crates/chattyness-db/src/queries/channels.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! Channel-related database queries.
|
||||
|
||||
use sqlx::PgExecutor;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_error::AppError;
|
||||
|
||||
/// Minimal channel info for WebSocket validation.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
pub struct ChannelInfo {
|
||||
/// Channel ID.
|
||||
pub id: Uuid,
|
||||
/// Scene ID this channel belongs to.
|
||||
pub scene_id: Uuid,
|
||||
/// Realm ID (from the scene).
|
||||
pub realm_id: Uuid,
|
||||
}
|
||||
|
||||
/// Get channel info by ID.
|
||||
///
|
||||
/// Returns the channel with its associated scene and realm IDs.
|
||||
pub async fn get_channel_info<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Option<ChannelInfo>, AppError> {
|
||||
let info = sqlx::query_as::<_, ChannelInfo>(
|
||||
r#"
|
||||
SELECT
|
||||
c.id,
|
||||
c.scene_id,
|
||||
s.realm_id
|
||||
FROM realm.channels c
|
||||
JOIN realm.scenes s ON s.id = c.scene_id
|
||||
WHERE c.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
62
crates/chattyness-db/src/queries/inventory.rs
Normal file
62
crates/chattyness-db/src/queries/inventory.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
//! Inventory-related database queries.
|
||||
|
||||
use sqlx::PgExecutor;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::InventoryItem;
|
||||
use chattyness_error::AppError;
|
||||
|
||||
/// List all inventory items for a user.
|
||||
pub async fn list_user_inventory<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<InventoryItem>, AppError> {
|
||||
let items = sqlx::query_as::<_, InventoryItem>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
prop_name,
|
||||
prop_asset_path,
|
||||
layer,
|
||||
is_transferable,
|
||||
is_portable,
|
||||
origin,
|
||||
acquired_at
|
||||
FROM props.inventory
|
||||
WHERE user_id = $1
|
||||
ORDER BY acquired_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Drop a prop (remove from inventory).
|
||||
pub async fn drop_inventory_item<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
item_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM props.inventory
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(item_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
if result.is_none() {
|
||||
return Err(AppError::NotFound(
|
||||
"Inventory item not found or not owned by user".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
217
crates/chattyness-db/src/queries/loose_props.rs
Normal file
217
crates/chattyness-db/src/queries/loose_props.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
//! Loose props database queries.
|
||||
//!
|
||||
//! Handles props dropped in channels that can be picked up by users.
|
||||
|
||||
use sqlx::PgExecutor;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{InventoryItem, LooseProp};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
/// List all loose props in a channel (excluding expired).
|
||||
pub async fn list_channel_loose_props<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<LooseProp>, AppError> {
|
||||
let props = sqlx::query_as::<_, LooseProp>(
|
||||
r#"
|
||||
SELECT
|
||||
lp.id,
|
||||
lp.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.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 props.loose_props lp
|
||||
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
|
||||
LEFT JOIN props.realm_props rp ON lp.realm_prop_id = rp.id
|
||||
WHERE lp.channel_id = $1
|
||||
AND (lp.expires_at IS NULL OR lp.expires_at > now())
|
||||
ORDER BY lp.created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(props)
|
||||
}
|
||||
|
||||
/// Drop a prop from inventory to the canvas.
|
||||
///
|
||||
/// Deletes from inventory and inserts into loose_props with 30-minute expiry.
|
||||
/// Returns the created loose prop.
|
||||
pub async fn drop_prop_to_canvas<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
inventory_item_id: Uuid,
|
||||
user_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
position_x: f64,
|
||||
position_y: f64,
|
||||
) -> Result<LooseProp, AppError> {
|
||||
// Use a CTE to delete from inventory and insert to loose_props in one query
|
||||
let prop = sqlx::query_as::<_, LooseProp>(
|
||||
r#"
|
||||
WITH deleted_item AS (
|
||||
DELETE FROM props.inventory
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
||||
),
|
||||
inserted_prop AS (
|
||||
INSERT INTO props.loose_props (
|
||||
channel_id,
|
||||
server_prop_id,
|
||||
realm_prop_id,
|
||||
position,
|
||||
dropped_by,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
$3,
|
||||
di.server_prop_id,
|
||||
di.realm_prop_id,
|
||||
public.make_virtual_point($4::real, $5::real),
|
||||
$2,
|
||||
now() + interval '30 minutes'
|
||||
FROM deleted_item di
|
||||
RETURNING
|
||||
id,
|
||||
channel_id,
|
||||
server_prop_id,
|
||||
realm_prop_id,
|
||||
ST_X(position) as position_x,
|
||||
ST_Y(position) as position_y,
|
||||
dropped_by,
|
||||
expires_at,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
ip.id,
|
||||
ip.channel_id,
|
||||
ip.server_prop_id,
|
||||
ip.realm_prop_id,
|
||||
ip.position_x,
|
||||
ip.position_y,
|
||||
ip.dropped_by,
|
||||
ip.expires_at,
|
||||
ip.created_at,
|
||||
di.prop_name,
|
||||
di.prop_asset_path
|
||||
FROM inserted_prop ip
|
||||
CROSS JOIN deleted_item di
|
||||
"#,
|
||||
)
|
||||
.bind(inventory_item_id)
|
||||
.bind(user_id)
|
||||
.bind(channel_id)
|
||||
.bind(position_x as f32)
|
||||
.bind(position_y as f32)
|
||||
.fetch_optional(executor)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound("Inventory item not found or not owned by user".to_string())
|
||||
})?;
|
||||
|
||||
Ok(prop)
|
||||
}
|
||||
|
||||
/// Pick up a loose prop (delete from loose_props, insert to inventory).
|
||||
///
|
||||
/// Returns the created inventory item.
|
||||
pub async fn pick_up_loose_prop<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
loose_prop_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<InventoryItem, AppError> {
|
||||
// Use a CTE to delete from loose_props and insert to inventory
|
||||
let item = sqlx::query_as::<_, InventoryItem>(
|
||||
r#"
|
||||
WITH deleted_prop AS (
|
||||
DELETE FROM props.loose_props
|
||||
WHERE id = $1
|
||||
AND (expires_at IS NULL OR expires_at > now())
|
||||
RETURNING id, server_prop_id, realm_prop_id
|
||||
),
|
||||
source_info AS (
|
||||
SELECT
|
||||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
COALESCE(sp.default_layer, rp.default_layer) as layer,
|
||||
COALESCE(sp.is_transferable, rp.is_transferable) as is_transferable,
|
||||
COALESCE(sp.is_portable, true) as is_portable,
|
||||
dp.server_prop_id,
|
||||
dp.realm_prop_id
|
||||
FROM deleted_prop dp
|
||||
LEFT JOIN server.props sp ON dp.server_prop_id = sp.id
|
||||
LEFT JOIN props.realm_props rp ON dp.realm_prop_id = rp.id
|
||||
),
|
||||
inserted_item AS (
|
||||
INSERT INTO props.inventory (
|
||||
user_id,
|
||||
server_prop_id,
|
||||
realm_prop_id,
|
||||
prop_name,
|
||||
prop_asset_path,
|
||||
layer,
|
||||
origin,
|
||||
is_transferable,
|
||||
is_portable,
|
||||
provenance,
|
||||
acquired_at
|
||||
)
|
||||
SELECT
|
||||
$2,
|
||||
si.server_prop_id,
|
||||
si.realm_prop_id,
|
||||
si.prop_name,
|
||||
si.prop_asset_path,
|
||||
si.layer,
|
||||
'server_library'::props.prop_origin,
|
||||
COALESCE(si.is_transferable, true),
|
||||
COALESCE(si.is_portable, true),
|
||||
'[]'::jsonb,
|
||||
now()
|
||||
FROM source_info si
|
||||
RETURNING id, prop_name, prop_asset_path, layer, is_transferable, is_portable, acquired_at
|
||||
)
|
||||
SELECT
|
||||
ii.id,
|
||||
ii.prop_name,
|
||||
ii.prop_asset_path,
|
||||
ii.layer,
|
||||
ii.is_transferable,
|
||||
ii.is_portable,
|
||||
'server_library'::props.prop_origin as origin,
|
||||
ii.acquired_at
|
||||
FROM inserted_item ii
|
||||
"#,
|
||||
)
|
||||
.bind(loose_prop_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(executor)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?;
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
/// Delete expired loose props.
|
||||
///
|
||||
/// Returns the number of props deleted.
|
||||
pub async fn cleanup_expired_props<'e>(executor: impl PgExecutor<'e>) -> Result<u64, AppError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM props.loose_props
|
||||
WHERE expires_at IS NOT NULL AND expires_at <= now()
|
||||
"#,
|
||||
)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
|
@ -42,24 +42,26 @@ pub async fn get_scene_by_id<'e>(
|
|||
let scene = sqlx::query_as::<_, Scene>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
realm_id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
background_image_path,
|
||||
background_color,
|
||||
ST_AsText(bounds) as bounds_wkt,
|
||||
dimension_mode,
|
||||
ambient_audio_id,
|
||||
ambient_volume,
|
||||
sort_order,
|
||||
is_entry_point,
|
||||
is_hidden,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM realm.scenes
|
||||
WHERE id = $1
|
||||
s.id,
|
||||
s.realm_id,
|
||||
s.name,
|
||||
s.slug,
|
||||
s.description,
|
||||
s.background_image_path,
|
||||
s.background_color,
|
||||
ST_AsText(s.bounds) as bounds_wkt,
|
||||
s.dimension_mode,
|
||||
s.ambient_audio_id,
|
||||
s.ambient_volume,
|
||||
s.sort_order,
|
||||
s.is_entry_point,
|
||||
s.is_hidden,
|
||||
s.created_at,
|
||||
s.updated_at,
|
||||
c.id as default_channel_id
|
||||
FROM realm.scenes s
|
||||
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
|
||||
WHERE s.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(scene_id)
|
||||
|
|
@ -78,24 +80,26 @@ pub async fn get_scene_by_slug<'e>(
|
|||
let scene = sqlx::query_as::<_, Scene>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
realm_id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
background_image_path,
|
||||
background_color,
|
||||
ST_AsText(bounds) as bounds_wkt,
|
||||
dimension_mode,
|
||||
ambient_audio_id,
|
||||
ambient_volume,
|
||||
sort_order,
|
||||
is_entry_point,
|
||||
is_hidden,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM realm.scenes
|
||||
WHERE realm_id = $1 AND slug = $2
|
||||
s.id,
|
||||
s.realm_id,
|
||||
s.name,
|
||||
s.slug,
|
||||
s.description,
|
||||
s.background_image_path,
|
||||
s.background_color,
|
||||
ST_AsText(s.bounds) as bounds_wkt,
|
||||
s.dimension_mode,
|
||||
s.ambient_audio_id,
|
||||
s.ambient_volume,
|
||||
s.sort_order,
|
||||
s.is_entry_point,
|
||||
s.is_hidden,
|
||||
s.created_at,
|
||||
s.updated_at,
|
||||
c.id as default_channel_id
|
||||
FROM realm.scenes s
|
||||
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
|
||||
WHERE s.realm_id = $1 AND s.slug = $2
|
||||
"#,
|
||||
)
|
||||
.bind(realm_id)
|
||||
|
|
@ -167,7 +171,8 @@ pub async fn create_scene<'e>(
|
|||
is_entry_point,
|
||||
is_hidden,
|
||||
created_at,
|
||||
updated_at
|
||||
updated_at,
|
||||
NULL::uuid as default_channel_id
|
||||
"#,
|
||||
)
|
||||
.bind(realm_id)
|
||||
|
|
@ -236,7 +241,8 @@ pub async fn create_scene_with_id<'e>(
|
|||
is_entry_point,
|
||||
is_hidden,
|
||||
created_at,
|
||||
updated_at
|
||||
updated_at,
|
||||
NULL::uuid as default_channel_id
|
||||
"#,
|
||||
)
|
||||
.bind(scene_id)
|
||||
|
|
@ -305,20 +311,27 @@ pub async fn update_scene<'e>(
|
|||
|
||||
// If no updates, just return the current scene
|
||||
let query = if set_clauses.is_empty() {
|
||||
r#"SELECT id, realm_id, name, slug, description, background_image_path,
|
||||
background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode,
|
||||
ambient_audio_id, ambient_volume, sort_order, is_entry_point,
|
||||
is_hidden, created_at, updated_at
|
||||
FROM realm.scenes WHERE id = $1"#.to_string()
|
||||
r#"SELECT s.id, s.realm_id, s.name, s.slug, s.description, s.background_image_path,
|
||||
s.background_color, ST_AsText(s.bounds) as bounds_wkt, s.dimension_mode,
|
||||
s.ambient_audio_id, s.ambient_volume, s.sort_order, s.is_entry_point,
|
||||
s.is_hidden, s.created_at, s.updated_at, c.id as default_channel_id
|
||||
FROM realm.scenes s
|
||||
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
|
||||
WHERE s.id = $1"#.to_string()
|
||||
} else {
|
||||
set_clauses.push("updated_at = now()".to_string());
|
||||
format!(
|
||||
r#"UPDATE realm.scenes SET {}
|
||||
r#"WITH updated AS (
|
||||
UPDATE realm.scenes SET {}
|
||||
WHERE id = $1
|
||||
RETURNING id, realm_id, name, slug, description, background_image_path,
|
||||
background_color, ST_AsText(bounds) as bounds_wkt, dimension_mode,
|
||||
ambient_audio_id, ambient_volume, sort_order, is_entry_point,
|
||||
is_hidden, created_at, updated_at"#,
|
||||
is_hidden, created_at, updated_at
|
||||
)
|
||||
SELECT u.*, c.id as default_channel_id
|
||||
FROM updated u
|
||||
LEFT JOIN realm.channels c ON c.scene_id = u.id AND c.channel_type = 'public'"#,
|
||||
set_clauses.join(", ")
|
||||
)
|
||||
};
|
||||
|
|
@ -399,37 +412,42 @@ pub async fn get_next_sort_order<'e>(
|
|||
/// 1. The scene specified by `default_scene_id` on the realm (if provided and exists)
|
||||
/// 2. The first scene marked as `is_entry_point`
|
||||
/// 3. The first scene by sort_order
|
||||
///
|
||||
/// Also includes the default public channel ID for the scene.
|
||||
pub async fn get_entry_scene_for_realm<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
realm_id: Uuid,
|
||||
default_scene_id: Option<Uuid>,
|
||||
) -> Result<Option<Scene>, AppError> {
|
||||
// Use a single query that handles the priority in SQL
|
||||
// Joins with channels to get the default public channel for the scene
|
||||
let scene = sqlx::query_as::<_, Scene>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
realm_id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
background_image_path,
|
||||
background_color,
|
||||
ST_AsText(bounds) as bounds_wkt,
|
||||
dimension_mode,
|
||||
ambient_audio_id,
|
||||
ambient_volume,
|
||||
sort_order,
|
||||
is_entry_point,
|
||||
is_hidden,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM realm.scenes
|
||||
WHERE realm_id = $1 AND is_hidden = false
|
||||
s.id,
|
||||
s.realm_id,
|
||||
s.name,
|
||||
s.slug,
|
||||
s.description,
|
||||
s.background_image_path,
|
||||
s.background_color,
|
||||
ST_AsText(s.bounds) as bounds_wkt,
|
||||
s.dimension_mode,
|
||||
s.ambient_audio_id,
|
||||
s.ambient_volume,
|
||||
s.sort_order,
|
||||
s.is_entry_point,
|
||||
s.is_hidden,
|
||||
s.created_at,
|
||||
s.updated_at,
|
||||
c.id as default_channel_id
|
||||
FROM realm.scenes s
|
||||
LEFT JOIN realm.channels c ON c.scene_id = s.id AND c.channel_type = 'public'
|
||||
WHERE s.realm_id = $1 AND s.is_hidden = false
|
||||
ORDER BY
|
||||
CASE WHEN id = $2 THEN 0 ELSE 1 END,
|
||||
is_entry_point DESC,
|
||||
sort_order ASC
|
||||
CASE WHEN s.id = $2 THEN 0 ELSE 1 END,
|
||||
s.is_entry_point DESC,
|
||||
s.sort_order ASC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar};
|
||||
use crate::models::{ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
|
||||
|
||||
/// Client-to-server WebSocket messages.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -19,10 +19,10 @@ pub enum ClientMessage {
|
|||
y: f64,
|
||||
},
|
||||
|
||||
/// Update emotion (0-9).
|
||||
/// Update emotion by name.
|
||||
UpdateEmotion {
|
||||
/// Emotion slot (0-9, keyboard: e0-e9).
|
||||
emotion: u8,
|
||||
/// Emotion name (e.g., "happy", "sad", "neutral").
|
||||
emotion: String,
|
||||
},
|
||||
|
||||
/// Ping to keep connection alive.
|
||||
|
|
@ -33,6 +33,18 @@ pub enum ClientMessage {
|
|||
/// Message content (max 500 chars).
|
||||
content: String,
|
||||
},
|
||||
|
||||
/// Drop a prop from inventory to the canvas.
|
||||
DropProp {
|
||||
/// Inventory item ID to drop.
|
||||
inventory_item_id: Uuid,
|
||||
},
|
||||
|
||||
/// Pick up a loose prop from the canvas.
|
||||
PickUpProp {
|
||||
/// Loose prop ID to pick up.
|
||||
loose_prop_id: Uuid,
|
||||
},
|
||||
}
|
||||
|
||||
/// Server-to-client WebSocket messages.
|
||||
|
|
@ -79,8 +91,8 @@ pub enum ServerMessage {
|
|||
user_id: Option<Uuid>,
|
||||
/// Guest session ID (if guest).
|
||||
guest_session_id: Option<Uuid>,
|
||||
/// New emotion slot (0-9).
|
||||
emotion: u8,
|
||||
/// Emotion name (e.g., "happy", "sad", "neutral").
|
||||
emotion: String,
|
||||
/// Asset paths for all 9 positions of the new emotion layer.
|
||||
emotion_layer: [Option<String>; 9],
|
||||
},
|
||||
|
|
@ -108,8 +120,8 @@ pub enum ServerMessage {
|
|||
display_name: String,
|
||||
/// Message content.
|
||||
content: String,
|
||||
/// Current emotion of sender (0-11) for bubble styling.
|
||||
emotion: u8,
|
||||
/// Emotion name for bubble styling (e.g., "happy", "sad", "neutral").
|
||||
emotion: String,
|
||||
/// Sender's X position at time of message.
|
||||
x: f64,
|
||||
/// Sender's Y position at time of message.
|
||||
|
|
@ -117,4 +129,32 @@ pub enum ServerMessage {
|
|||
/// Server timestamp (milliseconds since epoch).
|
||||
timestamp: i64,
|
||||
},
|
||||
|
||||
/// Initial list of loose props when joining channel.
|
||||
LoosePropsSync {
|
||||
/// All current loose props in the channel.
|
||||
props: Vec<LooseProp>,
|
||||
},
|
||||
|
||||
/// A prop was dropped on the canvas.
|
||||
PropDropped {
|
||||
/// The dropped prop.
|
||||
prop: LooseProp,
|
||||
},
|
||||
|
||||
/// A prop was picked up from the canvas.
|
||||
PropPickedUp {
|
||||
/// ID of the prop that was picked up.
|
||||
prop_id: Uuid,
|
||||
/// User ID who picked it up (if authenticated).
|
||||
picked_up_by_user_id: Option<Uuid>,
|
||||
/// Guest session ID who picked it up (if guest).
|
||||
picked_up_by_guest_id: Option<Uuid>,
|
||||
},
|
||||
|
||||
/// A prop expired and was removed.
|
||||
PropExpired {
|
||||
/// ID of the expired prop.
|
||||
prop_id: Uuid,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
pub mod auth;
|
||||
pub mod avatars;
|
||||
pub mod inventory;
|
||||
pub mod realms;
|
||||
pub mod routes;
|
||||
pub mod scenes;
|
||||
|
|
|
|||
41
crates/chattyness-user-ui/src/api/inventory.rs
Normal file
41
crates/chattyness-user-ui/src/api/inventory.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
//! Inventory API handlers for user UI.
|
||||
//!
|
||||
//! Handles inventory listing and item management.
|
||||
|
||||
use axum::extract::Path;
|
||||
use axum::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::{models::InventoryResponse, queries::inventory};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
use crate::auth::{AuthUser, RlsConn};
|
||||
|
||||
/// Get user's full inventory.
|
||||
///
|
||||
/// GET /api/inventory
|
||||
pub async fn get_inventory(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> Result<Json<InventoryResponse>, AppError> {
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
|
||||
let items = inventory::list_user_inventory(&mut *conn, user.id).await?;
|
||||
|
||||
Ok(Json(InventoryResponse { items }))
|
||||
}
|
||||
|
||||
/// Drop an item from inventory.
|
||||
///
|
||||
/// DELETE /api/inventory/{item_id}
|
||||
pub async fn drop_item(
|
||||
rls_conn: RlsConn,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(item_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let mut conn = rls_conn.acquire().await;
|
||||
|
||||
inventory::drop_inventory_item(&mut *conn, user.id, item_id).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "success": true })))
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
use axum::{routing::get, Router};
|
||||
|
||||
use super::{auth, avatars, realms, scenes, websocket};
|
||||
use super::{auth, avatars, inventory, realms, scenes, websocket};
|
||||
use crate::app::AppState;
|
||||
|
||||
/// Build the API router for user UI.
|
||||
|
|
@ -51,4 +51,10 @@ pub fn api_router() -> Router<AppState> {
|
|||
)
|
||||
// Avatar routes (require authentication)
|
||||
.route("/realms/{slug}/avatar", get(avatars::get_avatar))
|
||||
// Inventory routes (require authentication)
|
||||
.route("/inventory", get(inventory::get_inventory))
|
||||
.route(
|
||||
"/inventory/{item_id}",
|
||||
axum::routing::delete(inventory::drop_item),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ use tokio::sync::broadcast;
|
|||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::{
|
||||
models::{AvatarRenderData, ChannelMemberWithAvatar, User},
|
||||
queries::{avatars, channel_members, realms, scenes},
|
||||
models::{AvatarRenderData, ChannelMemberWithAvatar, EmotionState, User},
|
||||
queries::{avatars, channel_members, loose_props, realms, scenes},
|
||||
ws_messages::{ClientMessage, ServerMessage},
|
||||
};
|
||||
use chattyness_error::AppError;
|
||||
|
|
@ -97,14 +97,15 @@ where
|
|||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
|
||||
|
||||
// Verify channel (scene) exists and belongs to this realm
|
||||
// Verify scene exists and belongs to this realm
|
||||
// Note: Using scene_id as channel_id since channel_members uses scenes directly
|
||||
let scene = scenes::get_scene_by_id(&pool, channel_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Channel not found".to_string()))?;
|
||||
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
|
||||
|
||||
if scene.realm_id != realm.id {
|
||||
return Err(AppError::NotFound(
|
||||
"Channel not found in this realm".to_string(),
|
||||
"Scene not found in this realm".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +231,24 @@ async fn handle_socket(
|
|||
}
|
||||
}
|
||||
|
||||
// Send loose props sync
|
||||
match loose_props::list_channel_loose_props(&mut *conn, channel_id).await {
|
||||
Ok(props) => {
|
||||
let props_sync = ServerMessage::LoosePropsSync { props };
|
||||
if let Ok(json) = serde_json::to_string(&props_sync) {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!("[WS->Client] {}", json);
|
||||
if sender.send(Message::Text(json.into())).await.is_err() {
|
||||
let _ = channel_members::leave_channel(&mut *conn, channel_id, user.id).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[WS] Failed to get loose props: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast join to others
|
||||
let avatar = avatars::get_avatar_with_paths_conn(&mut *conn, user.id, realm_id)
|
||||
.await
|
||||
|
|
@ -295,15 +314,20 @@ async fn handle_socket(
|
|||
});
|
||||
}
|
||||
ClientMessage::UpdateEmotion { emotion } => {
|
||||
// We have 12 emotions (0-11)
|
||||
if emotion > 11 {
|
||||
// Parse emotion name to EmotionState
|
||||
let emotion_state = match emotion.parse::<EmotionState>() {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::warn!("[WS] Invalid emotion name: {}", emotion);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let emotion_layer = match avatars::set_emotion(
|
||||
&mut *recv_conn,
|
||||
user_id,
|
||||
realm_id,
|
||||
emotion as i16,
|
||||
emotion_state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -341,13 +365,17 @@ async fn handle_socket(
|
|||
.await;
|
||||
|
||||
if let Ok(Some(member)) = member_info {
|
||||
// Convert emotion index to name
|
||||
let emotion_name = EmotionState::from_index(member.current_emotion as u8)
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "neutral".to_string());
|
||||
let msg = ServerMessage::ChatMessageReceived {
|
||||
message_id: Uuid::new_v4(),
|
||||
user_id: Some(user_id),
|
||||
guest_session_id: None,
|
||||
display_name: member.display_name.clone(),
|
||||
content,
|
||||
emotion: member.current_emotion as u8,
|
||||
emotion: emotion_name,
|
||||
x: member.position_x,
|
||||
y: member.position_y,
|
||||
timestamp: chrono::Utc::now().timestamp_millis(),
|
||||
|
|
@ -355,6 +383,76 @@ async fn handle_socket(
|
|||
let _ = tx.send(msg);
|
||||
}
|
||||
}
|
||||
ClientMessage::DropProp { inventory_item_id } => {
|
||||
// Get user's current position for random offset
|
||||
let member_info = channel_members::get_channel_member(
|
||||
&mut *recv_conn,
|
||||
channel_id,
|
||||
user_id,
|
||||
realm_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(Some(member)) = member_info {
|
||||
// Generate random offset (within ~50 pixels)
|
||||
let offset_x = (rand::random::<f64>() - 0.5) * 100.0;
|
||||
let offset_y = (rand::random::<f64>() - 0.5) * 100.0;
|
||||
let pos_x = member.position_x + offset_x;
|
||||
let pos_y = member.position_y + offset_y;
|
||||
|
||||
match loose_props::drop_prop_to_canvas(
|
||||
&mut *recv_conn,
|
||||
inventory_item_id,
|
||||
user_id,
|
||||
channel_id,
|
||||
pos_x,
|
||||
pos_y,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prop) => {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
"[WS] User {} dropped prop {} at ({}, {})",
|
||||
user_id,
|
||||
prop.id,
|
||||
pos_x,
|
||||
pos_y
|
||||
);
|
||||
let _ = tx.send(ServerMessage::PropDropped { prop });
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Drop prop failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientMessage::PickUpProp { loose_prop_id } => {
|
||||
match loose_props::pick_up_loose_prop(
|
||||
&mut *recv_conn,
|
||||
loose_prop_id,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_inventory_item) => {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
"[WS] User {} picked up prop {}",
|
||||
user_id,
|
||||
loose_prop_id
|
||||
);
|
||||
let _ = tx.send(ServerMessage::PropPickedUp {
|
||||
prop_id: loose_prop_id,
|
||||
picked_up_by_user_id: Some(user_id),
|
||||
picked_up_by_guest_id: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[WS] Pick up prop failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pub mod chat;
|
|||
pub mod chat_types;
|
||||
pub mod editor;
|
||||
pub mod forms;
|
||||
pub mod inventory;
|
||||
pub mod layout;
|
||||
pub mod modals;
|
||||
pub mod scene_viewer;
|
||||
|
|
@ -13,6 +14,7 @@ pub use chat::*;
|
|||
pub use chat_types::*;
|
||||
pub use editor::*;
|
||||
pub use forms::*;
|
||||
pub use inventory::*;
|
||||
pub use layout::*;
|
||||
pub use modals::*;
|
||||
pub use scene_viewer::*;
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@ enum CommandMode {
|
|||
ShowingList,
|
||||
}
|
||||
|
||||
/// Parse an emote command and return the emotion index if valid.
|
||||
/// Parse an emote command and return the emotion name if valid.
|
||||
///
|
||||
/// Supports `:e name`, `:emote name` with partial matching.
|
||||
fn parse_emote_command(cmd: &str) -> Option<u8> {
|
||||
fn parse_emote_command(cmd: &str) -> Option<String> {
|
||||
let cmd = cmd.trim().to_lowercase();
|
||||
|
||||
// Strip the leading colon if present
|
||||
|
|
@ -52,9 +52,8 @@ fn parse_emote_command(cmd: &str) -> Option<u8> {
|
|||
name.and_then(|n| {
|
||||
EMOTIONS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, ename)| ename.starts_with(n) || n.starts_with(**ename))
|
||||
.map(|(idx, _)| idx as u8)
|
||||
.find(|ename| ename.starts_with(n) || n.starts_with(**ename))
|
||||
.map(|ename| (*ename).to_string())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -97,12 +96,10 @@ pub fn ChatInput(
|
|||
|
||||
// Apply emotion via WebSocket
|
||||
let apply_emotion = {
|
||||
move |emotion_idx: u8| {
|
||||
move |emotion: String| {
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::UpdateEmotion {
|
||||
emotion: emotion_idx,
|
||||
});
|
||||
send_fn(ClientMessage::UpdateEmotion { emotion });
|
||||
}
|
||||
});
|
||||
// Clear input and close popup
|
||||
|
|
@ -199,8 +196,8 @@ pub fn ChatInput(
|
|||
};
|
||||
|
||||
// Popup select handler
|
||||
let on_popup_select = Callback::new(move |emotion_idx: u8| {
|
||||
apply_emotion(emotion_idx);
|
||||
let on_popup_select = Callback::new(move |emotion: String| {
|
||||
apply_emotion(emotion);
|
||||
});
|
||||
|
||||
let on_popup_close = Callback::new(move |_: ()| {
|
||||
|
|
@ -265,10 +262,11 @@ pub fn ChatInput(
|
|||
fn EmoteListPopup(
|
||||
emotion_availability: Signal<Option<EmotionAvailability>>,
|
||||
skin_preview_path: Signal<Option<String>>,
|
||||
on_select: Callback<u8>,
|
||||
on_close: Callback<()>,
|
||||
on_select: Callback<String>,
|
||||
#[prop(into)] on_close: Callback<()>,
|
||||
) -> impl IntoView {
|
||||
// Get list of available emotions
|
||||
let _ = on_close; // Suppress unused warning
|
||||
// Get list of available emotions (name, preview_path)
|
||||
let available_emotions = move || {
|
||||
emotion_availability
|
||||
.get()
|
||||
|
|
@ -279,7 +277,7 @@ fn EmoteListPopup(
|
|||
.filter(|(idx, _)| avail.available.get(*idx).copied().unwrap_or(false))
|
||||
.map(|(idx, name)| {
|
||||
let preview = avail.preview_paths.get(idx).cloned().flatten();
|
||||
(idx as u8, *name, preview)
|
||||
((*name).to_string(), preview)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
|
|
@ -296,24 +294,26 @@ fn EmoteListPopup(
|
|||
<div class="grid grid-cols-3 gap-1 max-h-64 overflow-y-auto">
|
||||
<For
|
||||
each=move || available_emotions()
|
||||
key=|(idx, _, _): &(u8, &str, Option<String>)| *idx
|
||||
children=move |(emotion_idx, emotion_name, preview_path): (u8, &str, Option<String>)| {
|
||||
key=|(name, _): &(String, Option<String>)| name.clone()
|
||||
children=move |(emotion_name, preview_path): (String, Option<String>)| {
|
||||
let on_select = on_select.clone();
|
||||
let skin_path = skin_preview_path.get();
|
||||
let emotion_name_for_click = emotion_name.clone();
|
||||
let _skin_path = skin_preview_path.get();
|
||||
let _emotion_path = preview_path.clone();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 p-2 rounded hover:bg-gray-700 transition-colors text-left w-full"
|
||||
on:click=move |_| on_select.run(emotion_idx)
|
||||
on:click=move |_| on_select.run(emotion_name_for_click.clone())
|
||||
role="option"
|
||||
>
|
||||
<EmotionPreview
|
||||
skin_path=skin_path.clone()
|
||||
emotion_path=preview_path.clone()
|
||||
skin_path=_skin_path.clone()
|
||||
emotion_path=_emotion_path.clone()
|
||||
/>
|
||||
<span class="text-white text-sm">
|
||||
":e "
|
||||
{emotion_name}
|
||||
{emotion_name.clone()}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
|
|
|||
101
crates/chattyness-user-ui/src/components/chat_types.rs
Normal file
101
crates/chattyness-user-ui/src/components/chat_types.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
//! Chat message types for client-side state management.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Maximum messages to keep in the local log.
|
||||
pub const MAX_MESSAGE_LOG_SIZE: usize = 2000;
|
||||
|
||||
/// Default speech bubble timeout in milliseconds.
|
||||
pub const DEFAULT_BUBBLE_TIMEOUT_MS: i64 = 60_000;
|
||||
|
||||
/// A chat message for display and logging.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub message_id: Uuid,
|
||||
pub user_id: Option<Uuid>,
|
||||
pub guest_session_id: Option<Uuid>,
|
||||
pub display_name: String,
|
||||
pub content: String,
|
||||
/// Emotion name (e.g., "happy", "sad", "neutral").
|
||||
pub emotion: String,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
/// Timestamp in milliseconds since epoch.
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
/// Message log with bounded capacity for future replay support.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MessageLog {
|
||||
messages: VecDeque<ChatMessage>,
|
||||
}
|
||||
|
||||
impl MessageLog {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: VecDeque::with_capacity(MAX_MESSAGE_LOG_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, msg: ChatMessage) {
|
||||
if self.messages.len() >= MAX_MESSAGE_LOG_SIZE {
|
||||
self.messages.pop_front();
|
||||
}
|
||||
self.messages.push_back(msg);
|
||||
}
|
||||
|
||||
/// Get messages within a time range (for replay).
|
||||
pub fn messages_in_range(&self, start_ms: i64, end_ms: i64) -> Vec<&ChatMessage> {
|
||||
self.messages
|
||||
.iter()
|
||||
.filter(|m| m.timestamp >= start_ms && m.timestamp <= end_ms)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the latest message from a specific user.
|
||||
pub fn latest_from_user(
|
||||
&self,
|
||||
user_id: Option<Uuid>,
|
||||
guest_id: Option<Uuid>,
|
||||
) -> Option<&ChatMessage> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| m.user_id == user_id && m.guest_session_id == guest_id)
|
||||
}
|
||||
|
||||
/// Get all messages.
|
||||
pub fn all_messages(&self) -> &VecDeque<ChatMessage> {
|
||||
&self.messages
|
||||
}
|
||||
}
|
||||
|
||||
/// Active speech bubble state for a user.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActiveBubble {
|
||||
pub message: ChatMessage,
|
||||
/// When the bubble should expire (milliseconds since epoch).
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// Get bubble colors based on emotion name.
|
||||
/// Returns (background_color, border_color, text_color).
|
||||
pub fn emotion_bubble_colors(emotion: &str) -> (&'static str, &'static str, &'static str) {
|
||||
match emotion {
|
||||
"neutral" => ("#374151", "#4B5563", "#F9FAFB"), // gray
|
||||
"happy" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber
|
||||
"sad" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue
|
||||
"angry" => ("#EF4444", "#DC2626", "#F9FAFB"), // red
|
||||
"surprised" => ("#A855F7", "#9333EA", "#F9FAFB"), // purple
|
||||
"thinking" => ("#6366F1", "#4F46E5", "#F9FAFB"), // indigo
|
||||
"laughing" => ("#FBBF24", "#F59E0B", "#1F2937"), // amber
|
||||
"crying" => ("#3B82F6", "#2563EB", "#F9FAFB"), // blue
|
||||
"love" => ("#EC4899", "#DB2777", "#F9FAFB"), // pink
|
||||
"confused" => ("#8B5CF6", "#7C3AED", "#F9FAFB"), // violet
|
||||
"sleeping" => ("#1F2937", "#374151", "#9CA3AF"), // dark gray
|
||||
"wink" => ("#10B981", "#059669", "#F9FAFB"), // emerald
|
||||
_ => ("#374151", "#4B5563", "#F9FAFB"), // default - gray
|
||||
}
|
||||
}
|
||||
295
crates/chattyness-user-ui/src/components/inventory.rs
Normal file
295
crates/chattyness-user-ui/src/components/inventory.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
//! Inventory popup component.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::reactive::owner::LocalStorage;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::InventoryItem;
|
||||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
|
||||
use super::ws_client::WsSender;
|
||||
|
||||
/// Inventory popup component.
|
||||
///
|
||||
/// Shows a grid of user-owned props with drop functionality.
|
||||
///
|
||||
/// Props:
|
||||
/// - `open`: Signal controlling visibility
|
||||
/// - `on_close`: Callback when popup should close
|
||||
/// - `ws_sender`: WebSocket sender for dropping props
|
||||
#[component]
|
||||
pub fn InventoryPopup(
|
||||
#[prop(into)] open: Signal<bool>,
|
||||
on_close: Callback<()>,
|
||||
ws_sender: StoredValue<Option<WsSender>, LocalStorage>,
|
||||
) -> impl IntoView {
|
||||
let (items, set_items) = signal(Vec::<InventoryItem>::new());
|
||||
let (loading, set_loading) = signal(false);
|
||||
let (error, set_error) = signal(Option::<String>::None);
|
||||
let (selected_item, set_selected_item) = signal(Option::<Uuid>::None);
|
||||
let (dropping, set_dropping) = signal(false);
|
||||
|
||||
// Fetch inventory when popup opens
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use gloo_net::http::Request;
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
Effect::new(move |_| {
|
||||
if !open.get() {
|
||||
// Reset state when closing
|
||||
set_selected_item.set(None);
|
||||
return;
|
||||
}
|
||||
|
||||
set_loading.set(true);
|
||||
set_error.set(None);
|
||||
|
||||
spawn_local(async move {
|
||||
let response = Request::get("/api/inventory").send().await;
|
||||
match response {
|
||||
Ok(resp) if resp.ok() => {
|
||||
if let Ok(data) =
|
||||
resp.json::<chattyness_db::models::InventoryResponse>().await
|
||||
{
|
||||
set_items.set(data.items);
|
||||
} else {
|
||||
set_error.set(Some("Failed to parse inventory data".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
set_error.set(Some(format!("Failed to load inventory: {}", resp.status())));
|
||||
}
|
||||
Err(e) => {
|
||||
set_error.set(Some(format!("Network error: {}", e)));
|
||||
}
|
||||
}
|
||||
set_loading.set(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle escape key to close
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
use wasm_bindgen::{closure::Closure, JsCast};
|
||||
|
||||
Effect::new(move |_| {
|
||||
if !open.get() {
|
||||
return;
|
||||
}
|
||||
|
||||
let on_close_clone = on_close.clone();
|
||||
let closure =
|
||||
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
|
||||
if ev.key() == "Escape" {
|
||||
on_close_clone.run(());
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.add_event_listener_with_callback(
|
||||
"keydown",
|
||||
closure.as_ref().unchecked_ref(),
|
||||
);
|
||||
}
|
||||
|
||||
// Intentionally not cleaning up - closure lives for session
|
||||
closure.forget();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle drop action via WebSocket
|
||||
#[cfg(feature = "hydrate")]
|
||||
let handle_drop = {
|
||||
move |item_id: Uuid| {
|
||||
set_dropping.set(true);
|
||||
|
||||
// Send drop command via WebSocket
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::DropProp {
|
||||
inventory_item_id: item_id,
|
||||
});
|
||||
// Optimistically remove from local list
|
||||
set_items.update(|items| {
|
||||
items.retain(|i| i.id != item_id);
|
||||
});
|
||||
set_selected_item.set(None);
|
||||
} else {
|
||||
set_error.set(Some("Not connected to server".to_string()));
|
||||
}
|
||||
});
|
||||
|
||||
set_dropping.set(false);
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let handle_drop = |_item_id: Uuid| {};
|
||||
|
||||
let on_close_backdrop = on_close.clone();
|
||||
let on_close_button = on_close.clone();
|
||||
|
||||
view! {
|
||||
<Show when=move || open.get()>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="inventory-modal-title"
|
||||
>
|
||||
// Backdrop
|
||||
<div
|
||||
class="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
on:click=move |_| on_close_backdrop.run(())
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
// Modal content
|
||||
<div class="relative bg-gray-800 rounded-lg shadow-2xl max-w-2xl w-full mx-4 p-6 border border-gray-700 max-h-[80vh] flex flex-col">
|
||||
// Header
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="inventory-modal-title" class="text-xl font-bold text-white">
|
||||
"Inventory"
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
on:click=move |_| on_close_button.run(())
|
||||
aria-label="Close inventory"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Loading state
|
||||
<Show when=move || loading.get()>
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<p class="text-gray-400">"Loading inventory..."</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// Error state
|
||||
<Show when=move || error.get().is_some()>
|
||||
<div class="bg-red-900/20 border border-red-600 rounded p-4 mb-4">
|
||||
<p class="text-red-400">{move || error.get().unwrap_or_default()}</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// Empty state
|
||||
<Show when=move || !loading.get() && error.get().is_none() && items.get().is_empty()>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-gray-700/50 flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-400">"Your inventory is empty"</p>
|
||||
<p class="text-gray-500 text-sm mt-1">"Collect props to see them here"</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// Grid of items
|
||||
<Show when=move || !loading.get() && !items.get().is_empty()>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
class="grid grid-cols-4 sm:grid-cols-6 gap-2"
|
||||
role="listbox"
|
||||
aria-label="Inventory items"
|
||||
>
|
||||
<For
|
||||
each=move || items.get()
|
||||
key=|item| item.id
|
||||
children=move |item: InventoryItem| {
|
||||
let item_id = item.id;
|
||||
let item_name = item.prop_name.clone();
|
||||
let is_selected = move || selected_item.get() == Some(item_id);
|
||||
let asset_path = if item.prop_asset_path.starts_with('/') {
|
||||
item.prop_asset_path.clone()
|
||||
} else {
|
||||
format!("/static/{}", item.prop_asset_path)
|
||||
};
|
||||
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class=move || format!(
|
||||
"aspect-square rounded-lg border-2 transition-all p-1 focus:outline-none focus:ring-2 focus:ring-blue-500 {}",
|
||||
if is_selected() {
|
||||
"border-blue-500 bg-blue-900/30"
|
||||
} else {
|
||||
"border-gray-600 hover:border-gray-500 bg-gray-700/50"
|
||||
}
|
||||
)
|
||||
on:click=move |_| {
|
||||
set_selected_item.set(Some(item_id));
|
||||
}
|
||||
role="option"
|
||||
aria-selected=is_selected
|
||||
aria-label=item_name
|
||||
>
|
||||
<img
|
||||
src=asset_path
|
||||
alt=""
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
// Selected item details and actions
|
||||
{move || {
|
||||
let item_id = selected_item.get()?;
|
||||
let item = items.get().into_iter().find(|i| i.id == item_id)?;
|
||||
let handle_drop = handle_drop.clone();
|
||||
let is_dropping = dropping.get();
|
||||
|
||||
Some(view! {
|
||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-white font-medium">{item.prop_name.clone()}</h3>
|
||||
<p class="text-gray-400 text-sm">
|
||||
{if item.is_transferable { "Transferable" } else { "Not transferable" }}
|
||||
{if item.is_portable { " \u{2022} Portable" } else { "" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
// Drop button
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
on:click=move |_| handle_drop(item_id)
|
||||
disabled=is_dropping
|
||||
>
|
||||
{if is_dropping { "Dropping..." } else { "Drop" }}
|
||||
</button>
|
||||
// Transfer button (disabled for now)
|
||||
<Show when=move || item.is_transferable>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-gray-600 text-gray-400 rounded-lg cursor-not-allowed"
|
||||
disabled=true
|
||||
title="Transfer functionality coming soon"
|
||||
>
|
||||
"Transfer"
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ use std::collections::HashMap;
|
|||
use leptos::prelude::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
|
||||
|
||||
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
|
||||
|
||||
|
|
@ -53,9 +53,10 @@ fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
|
|||
|
||||
/// Scene viewer component for displaying a realm scene with avatars.
|
||||
///
|
||||
/// Uses two layered canvases:
|
||||
/// Uses three layered canvases:
|
||||
/// - Background canvas (z-index 0): Static background, drawn once
|
||||
/// - Avatar canvas (z-index 1): Transparent, redrawn on member updates
|
||||
/// - Props canvas (z-index 1): Loose props, redrawn on drop/pickup
|
||||
/// - Avatar canvas (z-index 2): Transparent, redrawn on member updates
|
||||
#[component]
|
||||
pub fn RealmSceneViewer(
|
||||
scene: Scene,
|
||||
|
|
@ -66,7 +67,11 @@ pub fn RealmSceneViewer(
|
|||
#[prop(into)]
|
||||
active_bubbles: Signal<HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>>,
|
||||
#[prop(into)]
|
||||
loose_props: Signal<Vec<LooseProp>>,
|
||||
#[prop(into)]
|
||||
on_move: Callback<(f64, f64)>,
|
||||
#[prop(into)]
|
||||
on_prop_click: Callback<Uuid>,
|
||||
) -> impl IntoView {
|
||||
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
|
||||
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
|
||||
|
|
@ -81,8 +86,9 @@ pub fn RealmSceneViewer(
|
|||
#[allow(unused_variables)]
|
||||
let image_path = scene.background_image_path.clone().unwrap_or_default();
|
||||
|
||||
// Two separate canvas refs for layered rendering
|
||||
// Three separate canvas refs for layered rendering
|
||||
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
|
||||
|
||||
// Store scale factors for coordinate conversion (shared between both canvases)
|
||||
|
|
@ -91,10 +97,11 @@ pub fn RealmSceneViewer(
|
|||
let offset_x = StoredValue::new(0.0_f64);
|
||||
let offset_y = StoredValue::new(0.0_f64);
|
||||
|
||||
// Handle canvas click for movement (on avatar canvas - topmost layer)
|
||||
// Handle canvas click for movement or prop pickup (on avatar canvas - topmost layer)
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_canvas_click = {
|
||||
let on_move = on_move.clone();
|
||||
let on_prop_click = on_prop_click.clone();
|
||||
move |ev: web_sys::MouseEvent| {
|
||||
let Some(canvas) = avatar_canvas_ref.get() else {
|
||||
return;
|
||||
|
|
@ -118,9 +125,28 @@ pub fn RealmSceneViewer(
|
|||
let scene_x = scene_x.max(0.0).min(scene_width as f64);
|
||||
let scene_y = scene_y.max(0.0).min(scene_height as f64);
|
||||
|
||||
// Check if click is within 32px of any loose prop
|
||||
let current_props = loose_props.get();
|
||||
let prop_click_radius = 32.0;
|
||||
let mut clicked_prop: Option<Uuid> = None;
|
||||
|
||||
for prop in ¤t_props {
|
||||
let dx = scene_x - prop.position_x;
|
||||
let dy = scene_y - prop.position_y;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
if distance <= prop_click_radius {
|
||||
clicked_prop = Some(prop.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(prop_id) = clicked_prop {
|
||||
on_prop_click.run(prop_id);
|
||||
} else {
|
||||
on_move.run((scene_x, scene_y));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
|
|
@ -157,10 +183,12 @@ pub fn RealmSceneViewer(
|
|||
let image_path = image_path_clone.clone();
|
||||
let bg_drawn_inner = bg_drawn.clone();
|
||||
|
||||
// Use setTimeout to ensure DOM is ready before drawing
|
||||
let draw_bg = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
// If still no dimensions, the canvas likely isn't visible - skip drawing
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
|
@ -226,8 +254,12 @@ pub fn RealmSceneViewer(
|
|||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
// Use setTimeout with small delay to ensure canvas is in DOM and has dimensions
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.request_animation_frame(draw_bg.as_ref().unchecked_ref());
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
draw_bg.as_ref().unchecked_ref(),
|
||||
100, // 100ms delay to allow DOM to settle
|
||||
);
|
||||
draw_bg.forget();
|
||||
});
|
||||
|
||||
|
|
@ -295,6 +327,57 @@ pub fn RealmSceneViewer(
|
|||
let _ = window.request_animation_frame(draw_avatars_closure.as_ref().unchecked_ref());
|
||||
draw_avatars_closure.forget();
|
||||
});
|
||||
|
||||
// =========================================================
|
||||
// Props Effect - runs when loose_props changes
|
||||
// =========================================================
|
||||
Effect::new(move |_| {
|
||||
// Track loose_props signal
|
||||
let current_props = loose_props.get();
|
||||
|
||||
let Some(canvas) = props_canvas_ref.get() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let canvas_el: &web_sys::HtmlCanvasElement = &canvas;
|
||||
let canvas_el = canvas_el.clone();
|
||||
|
||||
let draw_props_closure = Closure::once(Box::new(move || {
|
||||
let display_width = canvas_el.client_width() as u32;
|
||||
let display_height = canvas_el.client_height() as u32;
|
||||
|
||||
if display_width == 0 || display_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize props canvas to match (if needed)
|
||||
if canvas_el.width() != display_width || canvas_el.height() != display_height {
|
||||
canvas_el.set_width(display_width);
|
||||
canvas_el.set_height(display_height);
|
||||
}
|
||||
|
||||
if let Ok(Some(ctx)) = canvas_el.get_context("2d") {
|
||||
let ctx: web_sys::CanvasRenderingContext2d =
|
||||
ctx.dyn_into::<web_sys::CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
// Clear with transparency
|
||||
ctx.clear_rect(0.0, 0.0, display_width as f64, display_height as f64);
|
||||
|
||||
// Get stored scale factors
|
||||
let sx = scale_x.get_value();
|
||||
let sy = scale_y.get_value();
|
||||
let ox = offset_x.get_value();
|
||||
let oy = offset_y.get_value();
|
||||
|
||||
// Draw loose props
|
||||
draw_loose_props(&ctx, ¤t_props, sx, sy, ox, oy);
|
||||
}
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref());
|
||||
draw_props_closure.forget();
|
||||
});
|
||||
}
|
||||
|
||||
let aspect_ratio = scene_width as f64 / scene_height as f64;
|
||||
|
|
@ -315,11 +398,18 @@ pub fn RealmSceneViewer(
|
|||
style="z-index: 0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Props layer - loose props, redrawn on drop/pickup
|
||||
<canvas
|
||||
node_ref=props_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
// Avatar layer - dynamic, transparent background
|
||||
<canvas
|
||||
node_ref=avatar_canvas_ref
|
||||
class="absolute inset-0 w-full h-full"
|
||||
style="z-index: 1"
|
||||
style="z-index: 2"
|
||||
aria-label=format!("Scene: {}", scene.name)
|
||||
role="img"
|
||||
on:click=move |ev| {
|
||||
|
|
@ -477,7 +567,7 @@ fn draw_speech_bubbles(
|
|||
|
||||
// Get emotion colors
|
||||
let (bg_color, border_color, text_color) =
|
||||
emotion_bubble_colors(bubble.message.emotion);
|
||||
emotion_bubble_colors(&bubble.message.emotion);
|
||||
|
||||
// Measure and wrap text
|
||||
ctx.set_font(&format!("{}px sans-serif", font_size));
|
||||
|
|
@ -603,3 +693,57 @@ fn draw_rounded_rect(
|
|||
ctx.arc_to(x, y, x + radius, y, radius).ok();
|
||||
ctx.close_path();
|
||||
}
|
||||
|
||||
/// Draw loose props on the props canvas layer.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn draw_loose_props(
|
||||
ctx: &web_sys::CanvasRenderingContext2d,
|
||||
props: &[LooseProp],
|
||||
scale_x: f64,
|
||||
scale_y: f64,
|
||||
offset_x: f64,
|
||||
offset_y: f64,
|
||||
) {
|
||||
let prop_size = 48.0 * scale_x.min(scale_y);
|
||||
|
||||
for prop in props {
|
||||
let x = prop.position_x * scale_x + offset_x;
|
||||
let y = prop.position_y * scale_y + offset_y;
|
||||
|
||||
// Draw prop sprite if asset path available
|
||||
if !prop.prop_asset_path.is_empty() {
|
||||
let img = web_sys::HtmlImageElement::new().unwrap();
|
||||
let img_clone = img.clone();
|
||||
let ctx_clone = ctx.clone();
|
||||
let draw_x = x - prop_size / 2.0;
|
||||
let draw_y = y - prop_size / 2.0;
|
||||
let size = prop_size;
|
||||
|
||||
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
|
||||
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
|
||||
&img_clone, draw_x, draw_y, size, size,
|
||||
);
|
||||
}) as Box<dyn FnOnce()>);
|
||||
|
||||
img.set_onload(Some(onload.as_ref().unchecked_ref()));
|
||||
onload.forget();
|
||||
img.set_src(&normalize_asset_path(&prop.prop_asset_path));
|
||||
} else {
|
||||
// Fallback: draw a placeholder circle with prop name
|
||||
ctx.begin_path();
|
||||
let _ = ctx.arc(x, y, prop_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
|
||||
ctx.set_fill_style_str("#f59e0b"); // Amber color
|
||||
ctx.fill();
|
||||
ctx.set_stroke_style_str("#d97706");
|
||||
ctx.set_line_width(2.0);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw prop name below
|
||||
ctx.set_fill_style_str("#fff");
|
||||
ctx.set_font(&format!("{}px sans-serif", 10.0 * scale_x.min(scale_y)));
|
||||
ctx.set_text_align("center");
|
||||
ctx.set_text_baseline("top");
|
||||
let _ = ctx.fill_text(&prop.prop_name, x, y + prop_size / 2.0 + 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
use leptos::prelude::*;
|
||||
use leptos::reactive::owner::LocalStorage;
|
||||
|
||||
use chattyness_db::models::ChannelMemberWithAvatar;
|
||||
use chattyness_db::models::{ChannelMemberWithAvatar, EmotionState, LooseProp};
|
||||
use chattyness_db::ws_messages::{ClientMessage, ServerMessage};
|
||||
|
||||
use super::chat_types::ChatMessage;
|
||||
|
|
@ -41,6 +41,9 @@ pub fn use_channel_websocket(
|
|||
channel_id: Signal<Option<uuid::Uuid>>,
|
||||
on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
on_chat_message: Callback<ChatMessage>,
|
||||
on_loose_props_sync: Callback<Vec<LooseProp>>,
|
||||
on_prop_dropped: Callback<LooseProp>,
|
||||
on_prop_picked_up: Callback<uuid::Uuid>,
|
||||
) -> (Signal<WsState>, WsSenderStorage) {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
|
@ -133,6 +136,9 @@ pub fn use_channel_websocket(
|
|||
let members_for_msg = members_clone.clone();
|
||||
let on_members_update_clone = on_members_update.clone();
|
||||
let on_chat_message_clone = on_chat_message.clone();
|
||||
let on_loose_props_sync_clone = on_loose_props_sync.clone();
|
||||
let on_prop_dropped_clone = on_prop_dropped.clone();
|
||||
let on_prop_picked_up_clone = on_prop_picked_up.clone();
|
||||
let onmessage = Closure::wrap(Box::new(move |e: MessageEvent| {
|
||||
if let Ok(text) = e.data().dyn_into::<js_sys::JsString>() {
|
||||
let text: String = text.into();
|
||||
|
|
@ -145,6 +151,9 @@ pub fn use_channel_websocket(
|
|||
&members_for_msg,
|
||||
&on_members_update_clone,
|
||||
&on_chat_message_clone,
|
||||
&on_loose_props_sync_clone,
|
||||
&on_prop_dropped_clone,
|
||||
&on_prop_picked_up_clone,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -187,6 +196,9 @@ fn handle_server_message(
|
|||
members: &std::rc::Rc<std::cell::RefCell<Vec<ChannelMemberWithAvatar>>>,
|
||||
on_update: &Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
on_chat_message: &Callback<ChatMessage>,
|
||||
on_loose_props_sync: &Callback<Vec<LooseProp>>,
|
||||
on_prop_dropped: &Callback<LooseProp>,
|
||||
on_prop_picked_up: &Callback<uuid::Uuid>,
|
||||
) {
|
||||
let mut members_vec = members.borrow_mut();
|
||||
|
||||
|
|
@ -239,7 +251,11 @@ fn handle_server_message(
|
|||
if let Some(m) = members_vec.iter_mut().find(|m| {
|
||||
m.member.user_id == user_id && m.member.guest_session_id == guest_session_id
|
||||
}) {
|
||||
m.member.current_emotion = emotion as i16;
|
||||
// Convert emotion name to index for internal state
|
||||
m.member.current_emotion = emotion
|
||||
.parse::<EmotionState>()
|
||||
.map(|e| e.to_index() as i16)
|
||||
.unwrap_or(0);
|
||||
m.avatar.emotion_layer = emotion_layer;
|
||||
}
|
||||
on_update.run(members_vec.clone());
|
||||
|
|
@ -275,6 +291,19 @@ fn handle_server_message(
|
|||
};
|
||||
on_chat_message.run(chat_msg);
|
||||
}
|
||||
ServerMessage::LoosePropsSync { props } => {
|
||||
on_loose_props_sync.run(props);
|
||||
}
|
||||
ServerMessage::PropDropped { prop } => {
|
||||
on_prop_dropped.run(prop);
|
||||
}
|
||||
ServerMessage::PropPickedUp { prop_id, .. } => {
|
||||
on_prop_picked_up.run(prop_id);
|
||||
}
|
||||
ServerMessage::PropExpired { prop_id } => {
|
||||
// Treat expired props the same as picked up (remove from display)
|
||||
on_prop_picked_up.run(prop_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -285,6 +314,9 @@ pub fn use_channel_websocket(
|
|||
_channel_id: Signal<Option<uuid::Uuid>>,
|
||||
_on_members_update: Callback<Vec<ChannelMemberWithAvatar>>,
|
||||
_on_chat_message: Callback<ChatMessage>,
|
||||
_on_loose_props_sync: Callback<Vec<LooseProp>>,
|
||||
_on_prop_dropped: Callback<LooseProp>,
|
||||
_on_prop_picked_up: Callback<uuid::Uuid>,
|
||||
) -> (Signal<WsState>, WsSenderStorage) {
|
||||
let (ws_state, _) = signal(WsState::Disconnected);
|
||||
let sender: WsSenderStorage = StoredValue::new_local(None);
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ use leptos_router::hooks::use_params_map;
|
|||
use uuid::Uuid;
|
||||
|
||||
use crate::components::{
|
||||
ActiveBubble, Card, ChatInput, ChatMessage, MessageLog, RealmHeader, RealmSceneViewer,
|
||||
DEFAULT_BUBBLE_TIMEOUT_MS,
|
||||
ActiveBubble, Card, ChatInput, ChatMessage, InventoryPopup, MessageLog, RealmHeader,
|
||||
RealmSceneViewer, DEFAULT_BUBBLE_TIMEOUT_MS,
|
||||
};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use crate::components::use_channel_websocket;
|
||||
use chattyness_db::models::{
|
||||
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, RealmRole, RealmWithUserRole,
|
||||
Scene,
|
||||
AvatarWithPaths, ChannelMemberWithAvatar, EmotionAvailability, EmotionState, LooseProp,
|
||||
RealmRole, RealmWithUserRole, Scene,
|
||||
};
|
||||
#[cfg(feature = "hydrate")]
|
||||
use chattyness_db::ws_messages::ClientMessage;
|
||||
|
|
@ -55,6 +55,12 @@ pub fn RealmPage() -> impl IntoView {
|
|||
let (active_bubbles, set_active_bubbles) =
|
||||
signal(HashMap::<(Option<Uuid>, Option<Uuid>), ActiveBubble>::new());
|
||||
|
||||
// Inventory popup state
|
||||
let (inventory_open, set_inventory_open) = signal(false);
|
||||
|
||||
// Loose props state
|
||||
let (loose_props, set_loose_props) = signal(Vec::<LooseProp>::new());
|
||||
|
||||
let realm_data = LocalResource::new(move || {
|
||||
let slug = slug.get();
|
||||
async move {
|
||||
|
|
@ -165,15 +171,40 @@ pub fn RealmPage() -> impl IntoView {
|
|||
});
|
||||
});
|
||||
|
||||
// Loose props callbacks
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_loose_props_sync = Callback::new(move |props: Vec<LooseProp>| {
|
||||
set_loose_props.set(props);
|
||||
});
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_prop_dropped = Callback::new(move |prop: LooseProp| {
|
||||
set_loose_props.update(|props| {
|
||||
props.push(prop);
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_prop_picked_up = Callback::new(move |prop_id: Uuid| {
|
||||
set_loose_props.update(|props| {
|
||||
props.retain(|p| p.id != prop_id);
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
let (_ws_state, ws_sender) = use_channel_websocket(
|
||||
slug,
|
||||
Signal::derive(move || channel_id.get()),
|
||||
on_members_update,
|
||||
on_chat_message,
|
||||
on_loose_props_sync,
|
||||
on_prop_dropped,
|
||||
on_prop_picked_up,
|
||||
);
|
||||
|
||||
// Set channel ID when scene loads (triggers WebSocket connection)
|
||||
// Note: Currently using scene.id as the channel_id since channel_members
|
||||
// uses scenes directly. Proper channel infrastructure can be added later.
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
Effect::new(move |_| {
|
||||
|
|
@ -212,6 +243,21 @@ pub fn RealmPage() -> impl IntoView {
|
|||
#[cfg(not(feature = "hydrate"))]
|
||||
let on_move = Callback::new(move |(_x, _y): (f64, f64)| {});
|
||||
|
||||
// Handle prop click (pickup) via WebSocket
|
||||
#[cfg(feature = "hydrate")]
|
||||
let on_prop_click = Callback::new(move |prop_id: Uuid| {
|
||||
ws_sender.with_value(|sender| {
|
||||
if let Some(send_fn) = sender {
|
||||
send_fn(ClientMessage::PickUpProp {
|
||||
loose_prop_id: prop_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let on_prop_click = Callback::new(move |_prop_id: Uuid| {});
|
||||
|
||||
// Handle global keyboard shortcuts (e+0-9 for emotions, : for chat focus)
|
||||
#[cfg(feature = "hydrate")]
|
||||
{
|
||||
|
|
@ -266,6 +312,13 @@ pub fn RealmPage() -> impl IntoView {
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle 'i' to open inventory
|
||||
if key == "i" || key == "I" {
|
||||
set_inventory_open.set(true);
|
||||
ev.prevent_default();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if 'e' key was pressed
|
||||
if key == "e" || key == "E" {
|
||||
*e_pressed_clone.borrow_mut() = true;
|
||||
|
|
@ -276,8 +329,10 @@ pub fn RealmPage() -> impl IntoView {
|
|||
if *e_pressed_clone.borrow() {
|
||||
*e_pressed_clone.borrow_mut() = false; // Reset regardless of outcome
|
||||
if key.len() == 1 {
|
||||
if let Ok(emotion) = key.parse::<u8>() {
|
||||
if emotion <= 9 {
|
||||
if let Ok(digit) = key.parse::<u8>() {
|
||||
// Convert digit to emotion name using EmotionState
|
||||
if let Some(emotion_state) = EmotionState::from_index(digit) {
|
||||
let emotion = emotion_state.to_string();
|
||||
#[cfg(debug_assertions)]
|
||||
web_sys::console::log_1(
|
||||
&format!("[Emotion] Sending emotion {}", emotion).into(),
|
||||
|
|
@ -394,6 +449,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
}>
|
||||
{move || {
|
||||
let on_move = on_move.clone();
|
||||
let on_prop_click = on_prop_click.clone();
|
||||
let on_chat_focus_change = on_chat_focus_change.clone();
|
||||
let realm_slug_for_viewer = realm_slug_val.clone();
|
||||
#[cfg(feature = "hydrate")]
|
||||
|
|
@ -412,6 +468,7 @@ pub fn RealmPage() -> impl IntoView {
|
|||
#[cfg(not(feature = "hydrate"))]
|
||||
let ws_for_chat: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||||
let active_bubbles_signal = Signal::derive(move || active_bubbles.get());
|
||||
let loose_props_signal = Signal::derive(move || loose_props.get());
|
||||
view! {
|
||||
<div class="relative w-full">
|
||||
<RealmSceneViewer
|
||||
|
|
@ -419,7 +476,9 @@ pub fn RealmPage() -> impl IntoView {
|
|||
realm_slug=realm_slug_for_viewer.clone()
|
||||
members=members_signal
|
||||
active_bubbles=active_bubbles_signal
|
||||
loose_props=loose_props_signal
|
||||
on_move=on_move.clone()
|
||||
on_prop_click=on_prop_click.clone()
|
||||
/>
|
||||
<div class="absolute bottom-0 left-0 right-0 z-10 pb-4 px-4 pointer-events-none">
|
||||
<ChatInput
|
||||
|
|
@ -451,6 +510,23 @@ pub fn RealmPage() -> impl IntoView {
|
|||
}}
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
// Inventory popup
|
||||
{
|
||||
#[cfg(feature = "hydrate")]
|
||||
let ws_sender_for_inv = ws_sender.clone();
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
let ws_sender_for_inv: StoredValue<Option<WsSender>, LocalStorage> = StoredValue::new_local(None);
|
||||
view! {
|
||||
<InventoryPopup
|
||||
open=Signal::derive(move || inventory_open.get())
|
||||
on_close=Callback::new(move |_: ()| {
|
||||
set_inventory_open.set(false);
|
||||
})
|
||||
ws_sender=ws_sender_for_inv
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue