puppet scale

This commit is contained in:
2026-08-29 17:13:40 +08:00
parent 9615fe870f
commit 55ed1f9d32
11 changed files with 307 additions and 31 deletions
@@ -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);
+4
View File
@@ -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<tauri::Wry> {
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<tauri::Wry> {
ufa::FriendForegroundAppChanged,
interactions::FriendInteractionReceived,
puppet::PuppetStatesChanged,
scene_configuration::SceneConfigurationChanged,
])
}
+128
View File
@@ -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<SceneConfiguration, sqlx::Error> {
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<SceneConfiguration, String> {
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<SceneConfiguration, String> {
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<SceneConfiguration, String> {
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);
}
}
+12 -2
View File
@@ -65,6 +65,12 @@ async sendInteraction(recipientId: string, content: InteractionContent) : Promis
async listPuppetStates() : Promise<PuppetState[]> {
return await TAURI_INVOKE("list_puppet_states");
},
async getSceneConfiguration() : Promise<SceneConfiguration> {
return await TAURI_INVOKE("get_scene_configuration");
},
async updateSceneConfiguration(configuration: SceneConfiguration) : Promise<SceneConfiguration> {
return await TAURI_INVOKE("update_scene_configuration", { configuration });
},
async openActionWindow(name: string, title: string, pageUrl: string) : Promise<null> {
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
@@ -0,0 +1,86 @@
<script lang="ts">
import { commands } from "$lib/bindings";
import {
sceneConfiguration,
sceneConfigurationListenerError,
} from "$lib/listeners/scene-configuration";
import { onMount } from "svelte";
import PanelMessage from "./panel-message.svelte";
import type { RegisterPanel } from "./types";
let { register }: { register: RegisterPanel } = $props();
let puppetScale = $state(1);
let dirty = $state(false);
let busy = $state(false);
let error = $state("");
$effect(() => {
if (!dirty) puppetScale = $sceneConfiguration.puppetScale;
register("scene", { apply, reset }, { dirty, busy });
});
onMount(() => register("scene", { apply, reset }, { dirty, busy }));
function updateDirty() {
dirty = puppetScale !== $sceneConfiguration.puppetScale;
error = "";
}
function reset() {
puppetScale = $sceneConfiguration.puppetScale;
dirty = false;
error = "";
}
async function apply() {
if (!dirty) return true;
busy = true;
error = "";
try {
const configuration = await commands.updateSceneConfiguration({
puppetScale,
});
puppetScale = configuration.puppetScale;
dirty = false;
return true;
} catch (cause) {
error = String(cause);
return false;
} finally {
busy = false;
}
}
</script>
<div class="space-y-3">
{#if error || $sceneConfigurationListenerError}
<PanelMessage kind="error">
{error || $sceneConfigurationListenerError}
</PanelMessage>
{/if}
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Puppets</legend>
<div class="flex items-center justify-between gap-3">
<label class="fieldset-label" for="puppet-scale">Scale</label>
<output class="badge badge-outline tabular-nums" for="puppet-scale">
{puppetScale.toFixed(2)}×
</output>
</div>
<input
id="puppet-scale"
class="range range-sm"
type="range"
min="0.5"
max="2"
step="0.05"
bind:value={puppetScale}
oninput={updateDirty}
disabled={busy}
aria-describedby="puppet-scale-help"
/>
</fieldset>
</div>
+2
View File
@@ -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) =>
+25
View File
@@ -0,0 +1,25 @@
import { writable } from "svelte/store";
import {
commands,
events,
type SceneConfiguration,
} from "$lib/bindings";
export const sceneConfiguration = writable<SceneConfiguration>({
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;
}
+5
View File
@@ -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<Record<TabId, PanelActions>> = {};
let panelStates = $state<Record<TabId, PanelState>>({
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 @@
<div class="h-full">
{#if tab.id === "general"}
<GeneralPanel {register} />
{:else if tab.id === "scene"}
<ScenePanel {register} />
{:else if tab.id === "friends"}
<FriendsPanel {register} />
{:else if tab.id === "network"}
@@ -1,5 +1,6 @@
<script lang="ts">
import type { PuppetState } from "$lib/bindings";
import { sceneConfiguration } from "$lib/listeners/scene-configuration";
import { onDestroy, onMount } from "svelte";
import * as THREE from "three";
import { PuppetManager } from "./renderer/puppet/manager";
@@ -36,6 +37,7 @@
clock.update();
puppetManager.update(
puppets,
$sceneConfiguration.puppetScale,
frozenPuppetId,
clock.getDelta(),
clock.getElapsed(),
@@ -32,15 +32,21 @@ export class Puppet {
private readonly skin: PuppetSkin;
private readonly targetGroundPosition = new THREE.Vector3();
constructor(readonly id: string) {
constructor(readonly id: string, scale: number) {
this.rig = this.createRig();
this.root = this.rig.root;
this.animation = new PuppetAnimation(this.rig);
const placeholderMaterial = this.rig.body.material;
this.skin = new PuppetSkin(id, this.rig);
this.setScale(scale);
if (!Array.isArray(placeholderMaterial)) placeholderMaterial.dispose();
}
setScale(scale: number) {
if (this.root.scale.x === scale) return;
this.root.scale.setScalar(scale);
}
update(
state: PuppetState,
world: World,
@@ -81,50 +87,49 @@ export class Puppet {
private createRig(): PuppetRig {
const root = new THREE.Group();
const material = new THREE.MeshStandardMaterial();
const scale = 1;
const body = new THREE.Mesh(
new THREE.BoxGeometry(16 * scale, 24 * scale, 8 * scale),
new THREE.BoxGeometry(16, 24, 8),
material,
);
body.position.y = 36 * scale;
body.position.y = 36;
const head = new THREE.Mesh(
new THREE.BoxGeometry(16 * scale, 16 * scale, 16 * scale),
new THREE.BoxGeometry(16, 16, 16),
material,
);
head.position.y = 56 * scale;
head.position.y = 56;
const { pivot: leftArm, mesh: leftArmMesh } = this.createLimbPivot(
-12 * scale,
48 * scale,
8 * scale,
24 * scale,
8 * scale,
-12,
48,
8,
24,
8,
material,
);
const { pivot: rightArm, mesh: rightArmMesh } = this.createLimbPivot(
12 * scale,
48 * scale,
8 * scale,
24 * scale,
8 * scale,
12,
48,
8,
24,
8,
material,
);
const { pivot: leftLeg, mesh: leftLegMesh } = this.createLimbPivot(
-4 * scale,
24 * scale,
8 * scale,
24 * scale,
8 * scale,
-4,
24,
8,
24,
8,
material,
);
const { pivot: rightLeg, mesh: rightLegMesh } = this.createLimbPivot(
4 * scale,
24 * scale,
8 * scale,
24 * scale,
8 * scale,
4,
24,
8,
24,
8,
material,
);
@@ -10,12 +10,13 @@ export class PuppetManager {
update(
states: readonly PuppetState[],
scale: number,
frozenPuppetId: string | null,
deltaSeconds: number,
elapsedSeconds: number,
skinHashes: ReadonlyMap<string, string | null>,
) {
this.syncPuppets(states);
this.syncPuppets(states, scale);
for (const state of states) {
this.puppets
@@ -46,17 +47,19 @@ export class PuppetManager {
this.puppets.clear();
}
private syncPuppets(states: readonly PuppetState[]) {
private syncPuppets(states: readonly PuppetState[], scale: number) {
const nextPuppetIds = new Set(states.map((state) => state.id));
for (const state of states) {
if (this.puppets.has(state.id)) continue;
const puppet = new Puppet(state.id);
const puppet = new Puppet(state.id, scale);
this.puppets.set(puppet.id, puppet);
this.world.addObject(puppet.root);
}
for (const puppet of this.puppets.values()) puppet.setScale(scale);
for (const [puppetId, puppet] of this.puppets) {
if (nextPuppetIds.has(puppetId)) continue;