make emotions named instead, add drop prop

This commit is contained in:
Evan Carroll 2026-01-13 16:49:07 -06:00
parent 989e20757b
commit ea3b444d71
19 changed files with 1429 additions and 150 deletions

View file

@ -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))]

View file

@ -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;

View file

@ -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?;

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

View 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(())
}

View 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())
}

View file

@ -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
"#,
)

View file

@ -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,
},
}

View file

@ -2,6 +2,7 @@
pub mod auth;
pub mod avatars;
pub mod inventory;
pub mod realms;
pub mod routes;
pub mod scenes;

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

View file

@ -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),
)
}

View file

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

View file

@ -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::*;

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@ use std::collections::HashMap;
use leptos::prelude::*;
use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, Scene};
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene};
use super::chat_types::{emotion_bubble_colors, ActiveBubble};
@ -53,9 +53,10 @@ fn parse_bounds_dimensions(bounds_wkt: &str) -> Option<(u32, u32)> {
/// Scene viewer component for displaying a realm scene with avatars.
///
/// Uses two layered canvases:
/// Uses three layered canvases:
/// - Background canvas (z-index 0): Static background, drawn once
/// - Avatar canvas (z-index 1): Transparent, redrawn on member updates
/// - Props canvas (z-index 1): Loose props, redrawn on drop/pickup
/// - Avatar canvas (z-index 2): Transparent, redrawn on member updates
#[component]
pub fn RealmSceneViewer(
scene: Scene,
@ -66,7 +67,11 @@ pub fn RealmSceneViewer(
#[prop(into)]
active_bubbles: Signal<HashMap<(Option<Uuid>, Option<Uuid>), ActiveBubble>>,
#[prop(into)]
loose_props: Signal<Vec<LooseProp>>,
#[prop(into)]
on_move: Callback<(f64, f64)>,
#[prop(into)]
on_prop_click: Callback<Uuid>,
) -> impl IntoView {
let dimensions = parse_bounds_dimensions(&scene.bounds_wkt);
let (scene_width, scene_height) = dimensions.unwrap_or((800, 600));
@ -81,8 +86,9 @@ pub fn RealmSceneViewer(
#[allow(unused_variables)]
let image_path = scene.background_image_path.clone().unwrap_or_default();
// Two separate canvas refs for layered rendering
// Three separate canvas refs for layered rendering
let bg_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
let props_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
let avatar_canvas_ref = NodeRef::<leptos::html::Canvas>::new();
// Store scale factors for coordinate conversion (shared between both canvases)
@ -91,10 +97,11 @@ pub fn RealmSceneViewer(
let offset_x = StoredValue::new(0.0_f64);
let offset_y = StoredValue::new(0.0_f64);
// Handle canvas click for movement (on avatar canvas - topmost layer)
// Handle canvas click for movement or prop pickup (on avatar canvas - topmost layer)
#[cfg(feature = "hydrate")]
let on_canvas_click = {
let on_move = on_move.clone();
let on_prop_click = on_prop_click.clone();
move |ev: web_sys::MouseEvent| {
let Some(canvas) = avatar_canvas_ref.get() else {
return;
@ -118,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 &current_props {
let dx = scene_x - prop.position_x;
let dy = scene_y - prop.position_y;
let distance = (dx * dx + dy * dy).sqrt();
if distance <= prop_click_radius {
clicked_prop = Some(prop.id);
break;
}
}
if let Some(prop_id) = clicked_prop {
on_prop_click.run(prop_id);
} else {
on_move.run((scene_x, scene_y));
}
}
}
};
#[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, &current_props, sx, sy, ox, oy);
}
}) as Box<dyn FnOnce()>);
let window = web_sys::window().unwrap();
let _ = window.request_animation_frame(draw_props_closure.as_ref().unchecked_ref());
draw_props_closure.forget();
});
}
let aspect_ratio = scene_width as f64 / scene_height as f64;
@ -315,11 +398,18 @@ pub fn RealmSceneViewer(
style="z-index: 0"
aria-hidden="true"
/>
// Props layer - loose props, redrawn on drop/pickup
<canvas
node_ref=props_canvas_ref
class="absolute inset-0 w-full h-full"
style="z-index: 1"
aria-hidden="true"
/>
// Avatar layer - dynamic, transparent background
<canvas
node_ref=avatar_canvas_ref
class="absolute inset-0 w-full h-full"
style="z-index: 1"
style="z-index: 2"
aria-label=format!("Scene: {}", scene.name)
role="img"
on:click=move |ev| {
@ -477,7 +567,7 @@ fn draw_speech_bubbles(
// Get emotion colors
let (bg_color, border_color, text_color) =
emotion_bubble_colors(bubble.message.emotion);
emotion_bubble_colors(&bubble.message.emotion);
// Measure and wrap text
ctx.set_font(&format!("{}px sans-serif", font_size));
@ -603,3 +693,57 @@ fn draw_rounded_rect(
ctx.arc_to(x, y, x + radius, y, radius).ok();
ctx.close_path();
}
/// Draw loose props on the props canvas layer.
#[cfg(feature = "hydrate")]
fn draw_loose_props(
ctx: &web_sys::CanvasRenderingContext2d,
props: &[LooseProp],
scale_x: f64,
scale_y: f64,
offset_x: f64,
offset_y: f64,
) {
let prop_size = 48.0 * scale_x.min(scale_y);
for prop in props {
let x = prop.position_x * scale_x + offset_x;
let y = prop.position_y * scale_y + offset_y;
// Draw prop sprite if asset path available
if !prop.prop_asset_path.is_empty() {
let img = web_sys::HtmlImageElement::new().unwrap();
let img_clone = img.clone();
let ctx_clone = ctx.clone();
let draw_x = x - prop_size / 2.0;
let draw_y = y - prop_size / 2.0;
let size = prop_size;
let onload = wasm_bindgen::closure::Closure::once(Box::new(move || {
let _ = ctx_clone.draw_image_with_html_image_element_and_dw_and_dh(
&img_clone, draw_x, draw_y, size, size,
);
}) as Box<dyn FnOnce()>);
img.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
img.set_src(&normalize_asset_path(&prop.prop_asset_path));
} else {
// Fallback: draw a placeholder circle with prop name
ctx.begin_path();
let _ = ctx.arc(x, y, prop_size / 2.0, 0.0, std::f64::consts::PI * 2.0);
ctx.set_fill_style_str("#f59e0b"); // Amber color
ctx.fill();
ctx.set_stroke_style_str("#d97706");
ctx.set_line_width(2.0);
ctx.stroke();
// Draw prop name below
ctx.set_fill_style_str("#fff");
ctx.set_font(&format!("{}px sans-serif", 10.0 * scale_x.min(scale_y)));
ctx.set_text_align("center");
ctx.set_text_baseline("top");
let _ = ctx.fill_text(&prop.prop_name, x, y + prop_size / 2.0 + 2.0);
}
}
}

View file

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

View file

@ -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()
}