diff --git a/src-tauri/migrations/20260829020000_add_puppet_movement_mode.sql b/src-tauri/migrations/20260829020000_add_puppet_movement_mode.sql new file mode 100644 index 0000000..05c3ed7 --- /dev/null +++ b/src-tauri/migrations/20260829020000_add_puppet_movement_mode.sql @@ -0,0 +1,3 @@ +ALTER TABLE scene_configuration +ADD COLUMN puppet_movement_mode TEXT NOT NULL DEFAULT 'free' +CHECK (puppet_movement_mode IN ('free', 'bottom')); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c11c912..c5dd31c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ use tauri::Manager; async fn launch_app(app: &tauri::App) -> Result<(), Box> { let handle = app.handle(); db::init(handle).await?; + scene_configuration::init(handle).await?; keypair::init(handle).await?; app.manage(interactions::InteractionState::default()); network::init(handle).await?; diff --git a/src-tauri/src/puppet/mod.rs b/src-tauri/src/puppet/mod.rs index 94dabf0..e6d3b22 100644 --- a/src-tauri/src/puppet/mod.rs +++ b/src-tauri/src/puppet/mod.rs @@ -11,10 +11,12 @@ use tauri_specta::Event; use tokio::time::MissedTickBehavior; use crate::cursor::{CursorPosition, CursorPositions, CursorState}; +use crate::scene_configuration::{PuppetMovementMode, SceneConfigurationState}; const TICK_INTERVAL: Duration = Duration::from_millis(125); const SPEED_LOGICAL_PIXELS_PER_SECOND: f64 = 80.0; const FOLLOW_RADIUS_LOGICAL_PIXELS: f64 = 48.0; +const BOTTOM_MOVEMENT_Y: f64 = 1.0; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] #[serde(rename_all = "camelCase")] @@ -39,9 +41,16 @@ impl PuppetStateStore { cursor_positions: &HashMap, viewport: Viewport, elapsed: Duration, + movement_mode: PuppetMovementMode, ) -> Result>, String> { let mut puppets = self.0.write().map_err(|error| error.to_string())?; - let changed = advance_puppets(&mut puppets, cursor_positions, viewport, elapsed); + let changed = advance_puppets( + &mut puppets, + cursor_positions, + viewport, + elapsed, + movement_mode, + ); Ok(changed.then(|| sorted_snapshot(&puppets))) } @@ -95,10 +104,21 @@ pub fn init(handle: &AppHandle) -> Result<(), String> { continue; } }; + let movement_mode = match handle + .state::() + .puppet_movement_mode() + { + Ok(mode) => mode, + Err(error) => { + eprintln!("failed to read Puppet movement mode: {error}"); + continue; + } + }; let puppets = match handle.state::().update( &cursor_positions, viewport, elapsed, + movement_mode, ) { Ok(Some(puppets)) => puppets, Ok(None) => continue, @@ -128,18 +148,20 @@ fn advance_puppets( cursor_positions: &HashMap, viewport: Viewport, elapsed: Duration, + movement_mode: PuppetMovementMode, ) -> bool { let previous_len = puppets.len(); puppets.retain(|id, _| cursor_positions.contains_key(id)); let mut changed = puppets.len() != previous_len; for (id, cursor) in cursor_positions { + let target = movement_target(&cursor.mapped, movement_mode); let Some(puppet) = puppets.get_mut(id) else { puppets.insert( id.clone(), PuppetState { id: id.clone(), - position: cursor.mapped.clone(), + position: target, is_moving: false, }, ); @@ -147,12 +169,26 @@ fn advance_puppets( continue; }; - changed |= advance_puppet(puppet, &cursor.mapped, viewport, elapsed.as_secs_f64()); + if movement_mode == PuppetMovementMode::Bottom && puppet.position.y != BOTTOM_MOVEMENT_Y { + puppet.position.y = BOTTOM_MOVEMENT_Y; + changed = true; + } + changed |= advance_puppet(puppet, &target, viewport, elapsed.as_secs_f64()); } changed } +fn movement_target(cursor: &CursorPosition, mode: PuppetMovementMode) -> CursorPosition { + CursorPosition { + x: cursor.x, + y: match mode { + PuppetMovementMode::Free => cursor.y, + PuppetMovementMode::Bottom => BOTTOM_MOVEMENT_Y, + }, + } +} + fn advance_puppet( puppet: &mut PuppetState, target: &CursorPosition, @@ -220,6 +256,7 @@ mod tests { &cursors(&[("friend", 0.75, 0.25)]), viewport(), TICK_INTERVAL, + PuppetMovementMode::Free, ); assert!(changed); @@ -296,10 +333,46 @@ mod tests { &HashMap::new(), viewport(), TICK_INTERVAL, + PuppetMovementMode::Free, )); assert!(puppets.is_empty()); } + #[test] + fn bottom_mode_preserves_horizontal_target_and_insets_vertical_target() { + let cursor = CursorPosition { x: 0.25, y: 0.1 }; + + assert_eq!( + movement_target(&cursor, PuppetMovementMode::Bottom), + CursorPosition { + x: 0.25, + y: BOTTOM_MOVEMENT_Y, + } + ); + assert_eq!(movement_target(&cursor, PuppetMovementMode::Free), cursor); + } + + #[test] + fn bottom_mode_emits_a_fixed_inset_y_for_existing_puppets() { + let mut puppets = HashMap::from([( + "friend".to_owned(), + PuppetState { + id: "friend".to_owned(), + position: CursorPosition { x: 0.25, y: 0.1 }, + is_moving: false, + }, + )]); + + assert!(advance_puppets( + &mut puppets, + &cursors(&[("friend", 0.25, 0.1)]), + viewport(), + TICK_INTERVAL, + PuppetMovementMode::Bottom, + )); + assert_eq!(puppets["friend"].position.y, BOTTOM_MOVEMENT_Y); + } + #[test] fn normalized_step_accounts_for_viewport_aspect_ratio() { let mut horizontal = PuppetState { diff --git a/src-tauri/src/scene_configuration.rs b/src-tauri/src/scene_configuration.rs index e74c5e3..203bcb5 100644 --- a/src-tauri/src/scene_configuration.rs +++ b/src-tauri/src/scene_configuration.rs @@ -1,7 +1,8 @@ use crate::db::{self, AppDatabase}; use serde::{Deserialize, Serialize}; use specta::Type; -use tauri::{AppHandle, State}; +use std::sync::RwLock; +use tauri::{AppHandle, Manager, State}; use tauri_specta::Event; const SCENE_CONFIGURATION_ID: i64 = 1; @@ -10,11 +11,41 @@ const MAX_PUPPET_SCALE: f64 = 2.0; const MIN_PUPPET_OPACITY: f64 = 0.1; const MAX_PUPPET_OPACITY: f64 = 1.0; +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, Type, sqlx::Type)] +#[serde(rename_all = "camelCase")] +#[sqlx(type_name = "TEXT", rename_all = "lowercase")] +pub enum PuppetMovementMode { + #[default] + Free, + Bottom, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type, sqlx::FromRow)] #[serde(rename_all = "camelCase")] pub struct SceneConfiguration { pub puppet_scale: f64, pub puppet_opacity: f64, + pub puppet_movement_mode: PuppetMovementMode, +} + +pub struct SceneConfigurationState(RwLock); + +impl SceneConfigurationState { + fn new(configuration: SceneConfiguration) -> Self { + Self(RwLock::new(configuration)) + } + + fn replace(&self, configuration: SceneConfiguration) -> Result<(), String> { + *self.0.write().map_err(|error| error.to_string())? = configuration; + Ok(()) + } + + pub(crate) fn puppet_movement_mode(&self) -> Result { + self.0 + .read() + .map(|configuration| configuration.puppet_movement_mode) + .map_err(|error| error.to_string()) + } } #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] @@ -25,7 +56,7 @@ pub struct SceneConfigurationChanged { async fn get(database: &AppDatabase) -> Result { sqlx::query_as::<_, SceneConfiguration>( - "SELECT puppet_scale, puppet_opacity FROM scene_configuration WHERE id = ?1", + "SELECT puppet_scale, puppet_opacity, puppet_movement_mode FROM scene_configuration WHERE id = ?1", ) .bind(SCENE_CONFIGURATION_ID) .fetch_one(database.pool()) @@ -52,10 +83,11 @@ async fn update( } sqlx::query( - "UPDATE scene_configuration SET puppet_scale = ?1, puppet_opacity = ?2 WHERE id = ?3", + "UPDATE scene_configuration SET puppet_scale = ?1, puppet_opacity = ?2, puppet_movement_mode = ?3 WHERE id = ?4", ) .bind(configuration.puppet_scale) .bind(configuration.puppet_opacity) + .bind(configuration.puppet_movement_mode) .bind(SCENE_CONFIGURATION_ID) .execute(database.pool()) .await @@ -64,6 +96,12 @@ async fn update( get(database).await.map_err(db::command_error) } +pub async fn init(handle: &AppHandle) -> Result<(), sqlx::Error> { + let configuration = get(&handle.state::()).await?; + handle.manage(SceneConfigurationState::new(configuration)); + Ok(()) +} + fn emit_changed(handle: &AppHandle, configuration: SceneConfiguration) -> Result<(), String> { SceneConfigurationChanged { configuration } .emit(handle) @@ -75,8 +113,10 @@ fn emit_changed(handle: &AppHandle, configuration: SceneConfiguration) -> Result pub async fn get_scene_configuration( handle: AppHandle, database: State<'_, AppDatabase>, + state: State<'_, SceneConfigurationState>, ) -> Result { let configuration = get(&database).await.map_err(db::command_error)?; + state.replace(configuration.clone())?; emit_changed(&handle, configuration.clone())?; Ok(configuration) } @@ -86,9 +126,11 @@ pub async fn get_scene_configuration( pub async fn update_scene_configuration( handle: AppHandle, database: State<'_, AppDatabase>, + state: State<'_, SceneConfigurationState>, configuration: SceneConfiguration, ) -> Result { let configuration = update(&database, configuration).await?; + state.replace(configuration.clone())?; emit_changed(&handle, configuration.clone())?; Ok(configuration) } @@ -121,6 +163,7 @@ mod tests { SceneConfiguration { puppet_scale: 1.0, puppet_opacity: 1.0, + puppet_movement_mode: PuppetMovementMode::Free, } ); } @@ -134,12 +177,17 @@ mod tests { SceneConfiguration { puppet_scale: 1.5, puppet_opacity: 0.4, + puppet_movement_mode: PuppetMovementMode::Bottom, }, ) .await .expect("update scene configuration"); assert_eq!(configuration.puppet_scale, 1.5); assert_eq!(configuration.puppet_opacity, 0.4); + assert_eq!( + configuration.puppet_movement_mode, + PuppetMovementMode::Bottom + ); assert_eq!(get(&database).await.unwrap(), configuration); let error = update( @@ -147,6 +195,7 @@ mod tests { SceneConfiguration { puppet_scale: 2.1, puppet_opacity: 0.4, + puppet_movement_mode: PuppetMovementMode::Bottom, }, ) .await @@ -159,6 +208,7 @@ mod tests { SceneConfiguration { puppet_scale: 1.5, puppet_opacity: 0.05, + puppet_movement_mode: PuppetMovementMode::Bottom, }, ) .await diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index f0a5da9..105a1ae 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -144,12 +144,13 @@ export type FriendsChanged = { friends: Friend[] } export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string } export type NetworkStatusChanged = { statuses: ConnectionStatus[] } export type ProfileChanged = { profile: User } +export type PuppetMovementMode = "free" | "bottom" export type PuppetState = { id: string; position: CursorPosition; isMoving: boolean } export type PuppetStatesChanged = { puppets: PuppetState[] } export type Remote = { id: string; address: string; name: string | null; port: number | null } export type RemoteInput = { address: string; name: string | null; port: number | null } export type RemotesChanged = { remotes: Remote[] } -export type SceneConfiguration = { puppetScale: number; puppetOpacity: number } +export type SceneConfiguration = { puppetScale: number; puppetOpacity: number; puppetMovementMode: PuppetMovementMode } export type SceneConfigurationChanged = { configuration: SceneConfiguration } export type SceneHitbox = { x: number; y: number; width: number; height: number } /** diff --git a/src/lib/components/control-panel/scene-panel.svelte b/src/lib/components/control-panel/scene-panel.svelte index a2303be..5743994 100644 --- a/src/lib/components/control-panel/scene-panel.svelte +++ b/src/lib/components/control-panel/scene-panel.svelte @@ -1,5 +1,5 @@