diff --git a/src-tauri/migrations/20260829000000_create_scene_configuration.sql b/src-tauri/migrations/20260829000000_create_scene_configuration.sql new file mode 100644 index 0000000..bfc2c0d --- /dev/null +++ b/src-tauri/migrations/20260829000000_create_scene_configuration.sql @@ -0,0 +1,6 @@ +CREATE TABLE scene_configuration ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + puppet_scale REAL NOT NULL DEFAULT 1.0 CHECK (puppet_scale BETWEEN 0.5 AND 2.0) +); + +INSERT INTO scene_configuration (id) VALUES (1); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bd55ddf..c11c912 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod network; mod profile; mod puppet; mod remotes; +mod scene_configuration; mod skins; mod ufa; mod ui; @@ -82,6 +83,8 @@ fn specta_builder() -> tauri_specta::Builder { images::send_image_bytes, interactions::send_interaction, puppet::list_puppet_states, + scene_configuration::get_scene_configuration, + scene_configuration::update_scene_configuration, ui::control_panel::open_action_window, ui::scene::update_scene_hitboxes, ]) @@ -96,6 +99,7 @@ fn specta_builder() -> tauri_specta::Builder { ufa::FriendForegroundAppChanged, interactions::FriendInteractionReceived, puppet::PuppetStatesChanged, + scene_configuration::SceneConfigurationChanged, ]) } diff --git a/src-tauri/src/scene_configuration.rs b/src-tauri/src/scene_configuration.rs new file mode 100644 index 0000000..8499cde --- /dev/null +++ b/src-tauri/src/scene_configuration.rs @@ -0,0 +1,128 @@ +use crate::db::{self, AppDatabase}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, State}; +use tauri_specta::Event; + +const SCENE_CONFIGURATION_ID: i64 = 1; +const MIN_PUPPET_SCALE: f64 = 0.5; +const MAX_PUPPET_SCALE: f64 = 2.0; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] +pub struct SceneConfiguration { + pub puppet_scale: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct SceneConfigurationChanged { + pub configuration: SceneConfiguration, +} + +async fn get(database: &AppDatabase) -> Result { + sqlx::query_as::<_, SceneConfiguration>( + "SELECT puppet_scale FROM scene_configuration WHERE id = ?1", + ) + .bind(SCENE_CONFIGURATION_ID) + .fetch_one(database.pool()) + .await +} + +async fn update( + database: &AppDatabase, + configuration: SceneConfiguration, +) -> Result { + if !configuration.puppet_scale.is_finite() + || !(MIN_PUPPET_SCALE..=MAX_PUPPET_SCALE).contains(&configuration.puppet_scale) + { + return Err(format!( + "Puppet scale must be between {MIN_PUPPET_SCALE} and {MAX_PUPPET_SCALE}." + )); + } + + sqlx::query("UPDATE scene_configuration SET puppet_scale = ?1 WHERE id = ?2") + .bind(configuration.puppet_scale) + .bind(SCENE_CONFIGURATION_ID) + .execute(database.pool()) + .await + .map_err(db::command_error)?; + + get(database).await.map_err(db::command_error) +} + +fn emit_changed(handle: &AppHandle, configuration: SceneConfiguration) -> Result<(), String> { + SceneConfigurationChanged { configuration } + .emit(handle) + .map_err(db::command_error) +} + +#[tauri::command] +#[specta::specta] +pub async fn get_scene_configuration( + handle: AppHandle, + database: State<'_, AppDatabase>, +) -> Result { + let configuration = get(&database).await.map_err(db::command_error)?; + emit_changed(&handle, configuration.clone())?; + Ok(configuration) +} + +#[tauri::command] +#[specta::specta] +pub async fn update_scene_configuration( + handle: AppHandle, + database: State<'_, AppDatabase>, + configuration: SceneConfiguration, +) -> Result { + let configuration = update(&database, configuration).await?; + emit_changed(&handle, configuration.clone())?; + Ok(configuration) +} + +#[cfg(test)] +mod tests { + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + + async fn database() -> AppDatabase { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("connect to in-memory SQLite"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run database migrations"); + AppDatabase::new(pool) + } + + #[tokio::test] + async fn scene_configuration_defaults_to_unit_puppet_scale() { + let database = database().await; + + assert_eq!( + get(&database).await.unwrap(), + SceneConfiguration { puppet_scale: 1.0 } + ); + } + + #[tokio::test] + async fn puppet_scale_is_persisted_and_range_checked() { + let database = database().await; + + let configuration = update(&database, SceneConfiguration { puppet_scale: 1.5 }) + .await + .expect("update scene configuration"); + assert_eq!(configuration.puppet_scale, 1.5); + assert_eq!(get(&database).await.unwrap(), configuration); + + let error = update(&database, SceneConfiguration { puppet_scale: 2.1 }) + .await + .expect_err("reject an out-of-range puppet scale"); + assert_eq!(error, "Puppet scale must be between 0.5 and 2."); + assert_eq!(get(&database).await.unwrap().puppet_scale, 1.5); + } +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index df26fc2..6fb80d2 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -65,6 +65,12 @@ async sendInteraction(recipientId: string, content: InteractionContent) : Promis async listPuppetStates() : Promise { return await TAURI_INVOKE("list_puppet_states"); }, +async getSceneConfiguration() : Promise { + return await TAURI_INVOKE("get_scene_configuration"); +}, +async updateSceneConfiguration(configuration: SceneConfiguration) : Promise { + return await TAURI_INVOKE("update_scene_configuration", { configuration }); +}, async openActionWindow(name: string, title: string, pageUrl: string) : Promise { return await TAURI_INVOKE("open_action_window", { name, title, pageUrl }); }, @@ -86,7 +92,8 @@ friendsChanged: FriendsChanged, networkStatusChanged: NetworkStatusChanged, profileChanged: ProfileChanged, puppetStatesChanged: PuppetStatesChanged, -remotesChanged: RemotesChanged +remotesChanged: RemotesChanged, +sceneConfigurationChanged: SceneConfigurationChanged }>({ cursorPositionChanged: "cursor-position-changed", foregroundAppChanged: "foreground-app-changed", @@ -97,7 +104,8 @@ friendsChanged: "friends-changed", networkStatusChanged: "network-status-changed", profileChanged: "profile-changed", puppetStatesChanged: "puppet-states-changed", -remotesChanged: "remotes-changed" +remotesChanged: "remotes-changed", +sceneConfigurationChanged: "scene-configuration-changed" }) /** user-defined constants **/ @@ -141,6 +149,8 @@ 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 } +export type SceneConfigurationChanged = { configuration: SceneConfiguration } export type SceneHitbox = { x: number; y: number; width: number; height: number } /** * Public user identity exchanged with peers and remote servers. `id` is the diff --git a/src/lib/components/control-panel/scene-panel.svelte b/src/lib/components/control-panel/scene-panel.svelte new file mode 100644 index 0000000..c6ad72c --- /dev/null +++ b/src/lib/components/control-panel/scene-panel.svelte @@ -0,0 +1,86 @@ + + +
+ {#if error || $sceneConfigurationListenerError} + + {error || $sceneConfigurationListenerError} + + {/if} + +
+ Puppets + +
+ + + {puppetScale.toFixed(2)}× + +
+ +
+
diff --git a/src/lib/listeners/index.ts b/src/lib/listeners/index.ts index aec124b..777e619 100644 --- a/src/lib/listeners/index.ts +++ b/src/lib/listeners/index.ts @@ -6,6 +6,7 @@ import { initLiveMetadataListeners } from "./live-metadata"; import { initProfileListener } from "./profile"; import { initPuppetStatesListener } from "./puppets"; import { initRemotesListener } from "./remotes"; +import { initSceneConfigurationListener } from "./scene-configuration"; type Unlisten = () => void; @@ -22,6 +23,7 @@ export function initAppListeners(): Unlisten { initLiveMetadataListeners(), initInteractionListener(), initPuppetStatesListener(), + initSceneConfigurationListener(), ]) .then((results) => { const listeners = results.flatMap((result) => diff --git a/src/lib/listeners/scene-configuration.ts b/src/lib/listeners/scene-configuration.ts new file mode 100644 index 0000000..d256253 --- /dev/null +++ b/src/lib/listeners/scene-configuration.ts @@ -0,0 +1,25 @@ +import { writable } from "svelte/store"; +import { + commands, + events, + type SceneConfiguration, +} from "$lib/bindings"; + +export const sceneConfiguration = writable({ + puppetScale: 1, +}); +export const sceneConfigurationListenerError = writable(""); + +export async function initSceneConfigurationListener() { + const unlisten = await events.sceneConfigurationChanged.listen((event) => { + sceneConfiguration.set(event.payload.configuration); + }); + + try { + sceneConfiguration.set(await commands.getSceneConfiguration()); + } catch (error) { + sceneConfigurationListenerError.set(String(error)); + } + + return unlisten; +} diff --git a/src/routes/control-panel/+page.svelte b/src/routes/control-panel/+page.svelte index c7f2397..bbc59dc 100644 --- a/src/routes/control-panel/+page.svelte +++ b/src/routes/control-panel/+page.svelte @@ -4,6 +4,7 @@ import FriendsPanel from "$lib/components/control-panel/friends-panel.svelte"; import GeneralPanel from "$lib/components/control-panel/general-panel.svelte"; import NetworkPanel from "$lib/components/control-panel/network-panel.svelte"; + import ScenePanel from "$lib/components/control-panel/scene-panel.svelte"; import type { PanelActions, PanelState, @@ -11,6 +12,7 @@ const tabs = [ { id: "general", label: "General" }, + { id: "scene", label: "Scene" }, { id: "friends", label: "Friends" }, { id: "network", label: "Network" }, { id: "activity", label: "Activity" }, @@ -21,6 +23,7 @@ let actions: Partial> = {}; let panelStates = $state>({ general: { dirty: false, busy: false }, + scene: { dirty: false, busy: false }, friends: { dirty: false, busy: false }, network: { dirty: false, busy: false }, activity: { dirty: false, busy: false }, @@ -96,6 +99,8 @@
{#if tab.id === "general"} + {:else if tab.id === "scene"} + {:else if tab.id === "friends"} {:else if tab.id === "network"} diff --git a/src/routes/scene/components/renderer.svelte b/src/routes/scene/components/renderer.svelte index 5b4073f..0d6d6e1 100644 --- a/src/routes/scene/components/renderer.svelte +++ b/src/routes/scene/components/renderer.svelte @@ -1,5 +1,6 @@