From 9d695e704eec3223c0e3cb5d9c061f2442500d21 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Fri, 28 Aug 2026 19:30:47 +0800 Subject: [PATCH] minecraft characters. --- bun.lock | 18 + package.json | 4 +- src-tauri/src/cursor/mod.rs | 11 +- src-tauri/src/lib.rs | 5 + src-tauri/src/puppet/mod.rs | 332 ++++++++++++++++++ src/app.css | 4 + src/lib/bindings.ts | 7 + src/lib/listeners/index.ts | 2 + src/lib/listeners/puppets.ts | 19 + src/routes/scene/+page.svelte | 111 +++--- src/routes/scene/components/renderer.svelte | 99 ++++++ .../components/renderer/puppet/animation.ts | 83 +++++ .../scene/components/renderer/puppet/index.ts | 121 +++++++ .../components/renderer/puppet/manager.ts | 66 ++++ src/routes/scene/components/renderer/types.ts | 7 + src/routes/scene/components/renderer/world.ts | 127 +++++++ 16 files changed, 964 insertions(+), 52 deletions(-) create mode 100644 src-tauri/src/puppet/mod.rs create mode 100644 src/lib/listeners/puppets.ts create mode 100644 src/routes/scene/components/renderer.svelte create mode 100644 src/routes/scene/components/renderer/puppet/animation.ts create mode 100644 src/routes/scene/components/renderer/puppet/index.ts create mode 100644 src/routes/scene/components/renderer/puppet/manager.ts create mode 100644 src/routes/scene/components/renderer/types.ts create mode 100644 src/routes/scene/components/renderer/world.ts diff --git a/bun.lock b/bun.lock index cf73e0e..1703661 100644 --- a/bun.lock +++ b/bun.lock @@ -9,12 +9,14 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "tailwindcss": "^4.3.3", + "three": "^0.185.1", }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2", + "@types/three": "^0.185.4", "daisyui": "^5.7.21", "svelte": "^5.0.0", "svelte-check": "^4.0.0", @@ -24,6 +26,8 @@ }, }, "packages": { + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -210,12 +214,20 @@ "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ=="], + "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], + + "@types/three": ["@types/three@0.185.4", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], @@ -248,6 +260,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -286,6 +300,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -320,6 +336,8 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], diff --git a/package.json b/package.json index 6bde39e..676ad7b 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,15 @@ "@tailwindcss/vite": "^4.3.3", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", - "tailwindcss": "^4.3.3" + "tailwindcss": "^4.3.3", + "three": "^0.185.1" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tauri-apps/cli": "^2", + "@types/three": "^0.185.4", "daisyui": "^5.7.21", "svelte": "^5.0.0", "svelte-check": "^4.0.0", diff --git a/src-tauri/src/cursor/mod.rs b/src-tauri/src/cursor/mod.rs index fe059c2..2d5cfa8 100644 --- a/src-tauri/src/cursor/mod.rs +++ b/src-tauri/src/cursor/mod.rs @@ -11,14 +11,14 @@ use tauri_specta::Event; use tokio::sync::mpsc; use tokio::sync::watch; -#[derive(Debug, Clone, Serialize, Deserialize, Default, Type)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Type)] #[serde(rename_all = "camelCase")] pub struct CursorPosition { pub x: f64, pub y: f64, } -#[derive(Debug, Clone, Serialize, Deserialize, Default, Type)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Type)] #[serde(rename_all = "camelCase")] pub struct CursorPositions { /// Absolute cursor coordinates in physical pixels. @@ -56,6 +56,13 @@ impl CursorState { positions_by_user.retain(|user_id, _| !user_ids.contains(user_id)); Ok((positions_by_user.len() != previous_len).then(|| positions_by_user.clone())) } + + pub(crate) fn snapshot(&self) -> Result, String> { + self.0 + .read() + .map(|positions| positions.clone()) + .map_err(|error| error.to_string()) + } } // Was private, but for some reason LSP diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04b98d2..519ccc8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod keypair; mod live_data; mod network; mod profile; +mod puppet; mod remotes; mod ufa; mod ui; @@ -22,8 +23,10 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box> network::init(handle).await?; app.manage(ufa::ForegroundAppState::default()); app.manage(cursor::CursorState::default()); + app.manage(puppet::PuppetStateStore::default()); ufa::init(handle); cursor::init(handle); + puppet::init(handle)?; ui::init(handle); Ok(()) } @@ -76,6 +79,7 @@ fn specta_builder() -> tauri_specta::Builder { images::pick_and_send_image, images::send_image_bytes, interactions::send_interaction, + puppet::list_puppet_states, ui::control_panel::open_action_window, ui::scene::update_scene_hitboxes, ]) @@ -89,6 +93,7 @@ fn specta_builder() -> tauri_specta::Builder { ufa::ForegroundAppChanged, ufa::FriendForegroundAppChanged, interactions::FriendInteractionReceived, + puppet::PuppetStatesChanged, ]) } diff --git a/src-tauri/src/puppet/mod.rs b/src-tauri/src/puppet/mod.rs new file mode 100644 index 0000000..94dabf0 --- /dev/null +++ b/src-tauri/src/puppet/mod.rs @@ -0,0 +1,332 @@ +use std::{ + collections::HashMap, + sync::RwLock, + time::{Duration, Instant}, +}; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, Manager, State}; +use tauri_specta::Event; +use tokio::time::MissedTickBehavior; + +use crate::cursor::{CursorPosition, CursorPositions, CursorState}; + +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; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] +#[serde(rename_all = "camelCase")] +pub struct PuppetState { + pub id: String, + pub position: CursorPosition, + pub is_moving: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct PuppetStatesChanged { + pub puppets: Vec, +} + +#[derive(Default)] +pub struct PuppetStateStore(RwLock>); + +impl PuppetStateStore { + fn update( + &self, + cursor_positions: &HashMap, + viewport: Viewport, + elapsed: Duration, + ) -> Result>, String> { + let mut puppets = self.0.write().map_err(|error| error.to_string())?; + let changed = advance_puppets(&mut puppets, cursor_positions, viewport, elapsed); + Ok(changed.then(|| sorted_snapshot(&puppets))) + } + + fn snapshot(&self) -> Result, String> { + self.0 + .read() + .map(|puppets| sorted_snapshot(&puppets)) + .map_err(|error| error.to_string()) + } +} + +#[derive(Clone, Copy)] +struct Viewport { + width: f64, + height: f64, +} + +#[tauri::command] +#[specta::specta] +pub fn list_puppet_states(state: State<'_, PuppetStateStore>) -> Result, String> { + state.snapshot() +} + +pub fn init(handle: &AppHandle) -> Result<(), String> { + let monitor = handle + .primary_monitor() + .map_err(|error| error.to_string())? + .ok_or_else(|| "primary monitor is unavailable".to_owned())?; + let scale_factor = monitor.scale_factor(); + let viewport = Viewport { + width: monitor.size().width as f64 / scale_factor, + height: monitor.size().height as f64 / scale_factor, + }; + let handle = handle.clone(); + + tauri::async_runtime::spawn(async move { + let mut ticker = tokio::time::interval(TICK_INTERVAL); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut previous_tick = Instant::now(); + + loop { + ticker.tick().await; + let now = Instant::now(); + let elapsed = now.duration_since(previous_tick); + previous_tick = now; + + let cursor_positions = match handle.state::().snapshot() { + Ok(positions) => positions, + Err(error) => { + eprintln!("failed to read cursor positions for puppet motion: {error}"); + continue; + } + }; + let puppets = match handle.state::().update( + &cursor_positions, + viewport, + elapsed, + ) { + Ok(Some(puppets)) => puppets, + Ok(None) => continue, + Err(error) => { + eprintln!("failed to update puppet motion: {error}"); + continue; + } + }; + + if let Err(error) = emit_changed(&handle, puppets) { + eprintln!("failed to emit puppet states: {error}"); + } + } + }); + + Ok(()) +} + +fn emit_changed(handle: &AppHandle, puppets: Vec) -> Result<(), String> { + (PuppetStatesChanged { puppets }) + .emit(handle) + .map_err(|error| error.to_string()) +} + +fn advance_puppets( + puppets: &mut HashMap, + cursor_positions: &HashMap, + viewport: Viewport, + elapsed: Duration, +) -> 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 Some(puppet) = puppets.get_mut(id) else { + puppets.insert( + id.clone(), + PuppetState { + id: id.clone(), + position: cursor.mapped.clone(), + is_moving: false, + }, + ); + changed = true; + continue; + }; + + changed |= advance_puppet(puppet, &cursor.mapped, viewport, elapsed.as_secs_f64()); + } + + changed +} + +fn advance_puppet( + puppet: &mut PuppetState, + target: &CursorPosition, + viewport: Viewport, + elapsed_seconds: f64, +) -> bool { + let delta_x = (target.x - puppet.position.x) * viewport.width; + let delta_y = (target.y - puppet.position.y) * viewport.height; + let distance = delta_x.hypot(delta_y); + let available_distance = (distance - FOLLOW_RADIUS_LOGICAL_PIXELS).max(0.0); + let step = (SPEED_LOGICAL_PIXELS_PER_SECOND * elapsed_seconds).min(available_distance); + let is_moving = step > 0.0; + + if !is_moving { + if puppet.is_moving { + puppet.is_moving = false; + return true; + } + return false; + } + + puppet.position.x += delta_x / distance * step / viewport.width; + puppet.position.y += delta_y / distance * step / viewport.height; + puppet.is_moving = true; + true +} + +fn sorted_snapshot(puppets: &HashMap) -> Vec { + let mut snapshot = puppets.values().cloned().collect::>(); + snapshot.sort_unstable_by(|left, right| left.id.cmp(&right.id)); + snapshot +} + +#[cfg(test)] +mod tests { + use super::*; + + fn viewport() -> Viewport { + Viewport { + width: 1_000.0, + height: 500.0, + } + } + + fn cursors(entries: &[(&str, f64, f64)]) -> HashMap { + entries + .iter() + .map(|(id, x, y)| { + ( + (*id).to_owned(), + CursorPositions { + raw: CursorPosition::default(), + mapped: CursorPosition { x: *x, y: *y }, + }, + ) + }) + .collect() + } + + #[test] + fn new_puppet_starts_at_first_cursor_target() { + let mut puppets = HashMap::new(); + let changed = advance_puppets( + &mut puppets, + &cursors(&[("friend", 0.75, 0.25)]), + viewport(), + TICK_INTERVAL, + ); + + assert!(changed); + assert_eq!( + puppets["friend"].position, + CursorPosition { x: 0.75, y: 0.25 } + ); + assert!(!puppets["friend"].is_moving); + } + + #[test] + fn movement_is_bounded_by_speed_and_elapsed_time() { + let mut puppet = PuppetState { + id: "friend".to_owned(), + position: CursorPosition { x: 0.0, y: 0.5 }, + is_moving: false, + }; + + assert!(advance_puppet( + &mut puppet, + &CursorPosition { x: 1.0, y: 0.5 }, + viewport(), + 0.125, + )); + assert_eq!(puppet.position.x, 0.01); + assert_eq!(puppet.position.y, 0.5); + assert!(puppet.is_moving); + } + + #[test] + fn puppet_stops_at_follow_radius_without_overshooting() { + let mut puppet = PuppetState { + id: "friend".to_owned(), + position: CursorPosition { x: 0.0, y: 0.5 }, + is_moving: true, + }; + + advance_puppet( + &mut puppet, + &CursorPosition { x: 0.05, y: 0.5 }, + viewport(), + 0.125, + ); + + assert_eq!(puppet.position.x, 0.002); + assert!(advance_puppet( + &mut puppet, + &CursorPosition { x: 0.05, y: 0.5 }, + viewport(), + 0.125, + )); + assert!(!puppet.is_moving); + assert!(!advance_puppet( + &mut puppet, + &CursorPosition { x: 0.05, y: 0.5 }, + viewport(), + 0.125, + )); + } + + #[test] + fn removed_cursor_removes_puppet() { + let mut puppets = HashMap::from([( + "friend".to_owned(), + PuppetState { + id: "friend".to_owned(), + position: CursorPosition::default(), + is_moving: false, + }, + )]); + + assert!(advance_puppets( + &mut puppets, + &HashMap::new(), + viewport(), + TICK_INTERVAL, + )); + assert!(puppets.is_empty()); + } + + #[test] + fn normalized_step_accounts_for_viewport_aspect_ratio() { + let mut horizontal = PuppetState { + id: "horizontal".to_owned(), + position: CursorPosition::default(), + is_moving: false, + }; + let mut vertical = PuppetState { + id: "vertical".to_owned(), + position: CursorPosition::default(), + is_moving: false, + }; + + advance_puppet( + &mut horizontal, + &CursorPosition { x: 1.0, y: 0.0 }, + viewport(), + 0.125, + ); + advance_puppet( + &mut vertical, + &CursorPosition { x: 0.0, y: 1.0 }, + viewport(), + 0.125, + ); + + assert_eq!(horizontal.position.x, 0.01); + assert_eq!(vertical.position.y, 0.02); + } +} diff --git a/src/app.css b/src/app.css index b239fa2..4d0bc3e 100644 --- a/src/app.css +++ b/src/app.css @@ -77,6 +77,10 @@ background-color: transparent; } +.scene-hitbox { + pointer-events: auto; +} + button, input { @apply btn-sm; diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index cf4f842..ae5b959 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -59,6 +59,9 @@ async sendImageBytes(recipientId: string, bytes: number[]) : Promise { async sendInteraction(recipientId: string, content: InteractionContent) : Promise { return await TAURI_INVOKE("send_interaction", { recipientId, content }); }, +async listPuppetStates() : Promise { + return await TAURI_INVOKE("list_puppet_states"); +}, async openActionWindow(name: string, title: string, pageUrl: string) : Promise { return await TAURI_INVOKE("open_action_window", { name, title, pageUrl }); }, @@ -79,6 +82,7 @@ friendStatusesChanged: FriendStatusesChanged, friendsChanged: FriendsChanged, networkStatusChanged: NetworkStatusChanged, profileChanged: ProfileChanged, +puppetStatesChanged: PuppetStatesChanged, remotesChanged: RemotesChanged }>({ cursorPositionChanged: "cursor-position-changed", @@ -89,6 +93,7 @@ friendStatusesChanged: "friend-statuses-changed", friendsChanged: "friends-changed", networkStatusChanged: "network-status-changed", profileChanged: "profile-changed", +puppetStatesChanged: "puppet-states-changed", remotesChanged: "remotes-changed" }) @@ -128,6 +133,8 @@ 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 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[] } diff --git a/src/lib/listeners/index.ts b/src/lib/listeners/index.ts index 7b89e25..aec124b 100644 --- a/src/lib/listeners/index.ts +++ b/src/lib/listeners/index.ts @@ -4,6 +4,7 @@ import { initFriendsListener } from "./friends"; import { initInteractionListener } from "./interactions"; import { initLiveMetadataListeners } from "./live-metadata"; import { initProfileListener } from "./profile"; +import { initPuppetStatesListener } from "./puppets"; import { initRemotesListener } from "./remotes"; type Unlisten = () => void; @@ -20,6 +21,7 @@ export function initAppListeners(): Unlisten { initFriendStatusesListener(), initLiveMetadataListeners(), initInteractionListener(), + initPuppetStatesListener(), ]) .then((results) => { const listeners = results.flatMap((result) => diff --git a/src/lib/listeners/puppets.ts b/src/lib/listeners/puppets.ts new file mode 100644 index 0000000..e4a2fbd --- /dev/null +++ b/src/lib/listeners/puppets.ts @@ -0,0 +1,19 @@ +import { writable } from "svelte/store"; +import { commands, events, type PuppetState } from "$lib/bindings"; + +export const puppetStates = writable([]); +export const puppetStatesListenerError = writable(""); + +export async function initPuppetStatesListener() { + const unlisten = await events.puppetStatesChanged.listen((event) => { + puppetStates.set(event.payload.puppets); + }); + + try { + puppetStates.set(await commands.listPuppetStates()); + } catch (error) { + puppetStatesListenerError.set(String(error)); + } + + return unlisten; +} diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index 52c79bd..41083b4 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -6,15 +6,27 @@ import { incomingInteraction } from "$lib/listeners/interactions"; import { liveMetadata } from "$lib/listeners/live-metadata"; import { profile } from "$lib/listeners/profile"; + import { puppetStates } from "$lib/listeners/puppets"; + import Renderer from "./components/renderer.svelte"; + import type { PuppetScreenBounds } from "./components/renderer/types"; + import { startHitboxSync } from "./hitboxes"; import SceneImageViewer from "./popovers/image-viewer.svelte"; import SceneInteractionBubble from "./popovers/interaction-bubble.svelte"; import SceneUserPopoverContent from "./popovers/user-interaction.svelte"; - import { startHitboxSync } from "./hitboxes"; let selectedUserId = $state(null); - let selectedUserX = $state(null); let lockedPopoverUserId = $state(null); let viewedImage = $state<{ source: string; senderName: string } | null>(null); + let puppetBounds = $state([]); + let puppetBoundsById = $derived( + new Map(puppetBounds.map((bounds) => [bounds.id, bounds])), + ); + let visiblePuppets = $derived( + $puppetStates.filter( + ({ id }) => + id === $liveMetadata.localId || $onlineFriendIds.has(id), + ), + ); onMount(startHitboxSync); @@ -32,12 +44,9 @@ $effect(() => { if ( selectedUserId && - selectedUserId !== $liveMetadata.localId && - !$onlineFriendIds.has(selectedUserId) + !visiblePuppets.some((puppet) => puppet.id === selectedUserId) ) { - selectedUserId = null; - selectedUserX = null; - lockedPopoverUserId = null; + dismissPopover(); } }); @@ -56,36 +65,52 @@ ); } - function setPopoverOpen( - userId: string, - cursorX: number, - open: boolean, - trigger: HTMLButtonElement, - ) { + function setPopoverOpen(userId: string, open: boolean) { if (open && lockedPopoverUserId && lockedPopoverUserId !== userId) return; - selectedUserId = open ? userId : null; - if (!open && lockedPopoverUserId === userId) lockedPopoverUserId = null; - selectedUserX = open - ? window.innerWidth > 0 - ? trigger.getBoundingClientRect().left / window.innerWidth - : cursorX - : null; + if (open) { + selectedUserId = userId; + } else { + dismissPopover(); + } + } + + function dismissPopover() { + selectedUserId = null; + lockedPopoverUserId = null; + } + + function popoverPosition(bounds: PuppetScreenBounds) { + const centerX = bounds.x + bounds.width / 2; + const horizontal = `position: fixed; left: clamp(0.5rem, calc(${centerX}px - 8.5rem), calc(100vw - 17.5rem));`; + + return bounds.y > window.innerHeight / 2 + ? `${horizontal} bottom: calc(100vh - ${bounds.y}px + 0.75rem);` + : `${horizontal} top: ${bounds.y + bounds.height + 12}px;`; } -
-