shoo the puppets to the ground (ugly ahh graphics lmao)

This commit is contained in:
2026-08-30 01:07:32 +08:00
parent fbb7388028
commit 64e5c363f2
9 changed files with 259 additions and 9 deletions
@@ -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'));
+1
View File
@@ -20,6 +20,7 @@ use tauri::Manager;
async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
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?;
+76 -3
View File
@@ -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<String, CursorPositions>,
viewport: Viewport,
elapsed: Duration,
movement_mode: PuppetMovementMode,
) -> Result<Option<Vec<PuppetState>>, 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::<SceneConfigurationState>()
.puppet_movement_mode()
{
Ok(mode) => mode,
Err(error) => {
eprintln!("failed to read Puppet movement mode: {error}");
continue;
}
};
let puppets = match handle.state::<PuppetStateStore>().update(
&cursor_positions,
viewport,
elapsed,
movement_mode,
) {
Ok(Some(puppets)) => puppets,
Ok(None) => continue,
@@ -128,18 +148,20 @@ fn advance_puppets(
cursor_positions: &HashMap<String, CursorPositions>,
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 {
+53 -3
View File
@@ -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<SceneConfiguration>);
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<PuppetMovementMode, String> {
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<SceneConfiguration, sqlx::Error> {
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::<AppDatabase>()).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<SceneConfiguration, String> {
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<SceneConfiguration, String> {
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
+2 -1
View File
@@ -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 }
/**
@@ -1,5 +1,5 @@
<script lang="ts">
import { commands } from "$lib/bindings";
import { commands, type PuppetMovementMode } from "$lib/bindings";
import {
sceneConfiguration,
sceneConfigurationListenerError,
@@ -12,6 +12,7 @@
let puppetScale = $state(1);
let puppetOpacity = $state(1);
let puppetMovementMode = $state<PuppetMovementMode>("free");
let dirty = $state(false);
let busy = $state(false);
let error = $state("");
@@ -20,6 +21,7 @@
if (!dirty) {
puppetScale = $sceneConfiguration.puppetScale;
puppetOpacity = $sceneConfiguration.puppetOpacity;
puppetMovementMode = $sceneConfiguration.puppetMovementMode;
}
register("scene", { apply, reset }, { dirty, busy });
});
@@ -29,13 +31,15 @@
function updateDirty() {
dirty =
puppetScale !== $sceneConfiguration.puppetScale ||
puppetOpacity !== $sceneConfiguration.puppetOpacity;
puppetOpacity !== $sceneConfiguration.puppetOpacity ||
puppetMovementMode !== $sceneConfiguration.puppetMovementMode;
error = "";
}
function reset() {
puppetScale = $sceneConfiguration.puppetScale;
puppetOpacity = $sceneConfiguration.puppetOpacity;
puppetMovementMode = $sceneConfiguration.puppetMovementMode;
dirty = false;
error = "";
}
@@ -49,9 +53,11 @@
const configuration = await commands.updateSceneConfiguration({
puppetScale,
puppetOpacity,
puppetMovementMode,
});
puppetScale = configuration.puppetScale;
puppetOpacity = configuration.puppetOpacity;
puppetMovementMode = configuration.puppetMovementMode;
dirty = false;
return true;
} catch (cause) {
@@ -110,5 +116,72 @@
disabled={busy}
aria-describedby="puppet-opacity-help"
/>
<fieldset class="mt-4">
<legend class="fieldset-label mb-2">Movement</legend>
<div class="grid grid-cols-2 gap-2">
<label
class:border-primary={puppetMovementMode === "free"}
class="card cursor-pointer overflow-hidden border border-base-300 bg-base-200"
>
<img
src="/puppet-movement-free.svg"
alt="Puppets moving freely across the full viewport"
class="w-full object-cover bg-linear-to-b from-base-100 to-base-300"
class:from-primary-content={puppetMovementMode === "free"}
/>
<span
class="card-body items-center gap-2 p-2 text-center"
class:bg-primary-content={puppetMovementMode === "free"}
class:text-primary={puppetMovementMode === "free"}
>
<span class="text-xs font-medium">Freeroam around</span>
<input
class="hidden"
type="radio"
name="puppet-movement-mode"
value="free"
checked={puppetMovementMode === "free"}
onchange={() => {
puppetMovementMode = "free";
updateDirty();
}}
disabled={busy}
/>
</span>
</label>
<label
class:border-primary={puppetMovementMode === "bottom"}
class:text-primary={puppetMovementMode === "bottom"}
class="card cursor-pointer overflow-hidden border border-base-300 bg-base-200"
>
<img
src="/puppet-movement-bottom.svg"
alt="Puppets moving along the bottom of the viewport"
class="w-full object-cover bg-linear-to-b from-base-100 to-base-300"
class:from-primary-content={puppetMovementMode === "bottom"}
/>
<span
class="card-body items-center gap-2 p-2 text-center"
class:bg-primary-content={puppetMovementMode === "bottom"}
>
<span class="text-xs font-medium">Stay on the ground</span>
<input
class="hidden"
type="radio"
name="puppet-movement-mode"
value="bottom"
checked={puppetMovementMode === "bottom"}
onchange={() => {
puppetMovementMode = "bottom";
updateDirty();
}}
disabled={busy}
/>
</span>
</label>
</div>
</fieldset>
</fieldset>
</div>
+1
View File
@@ -8,6 +8,7 @@ import {
export const sceneConfiguration = writable<SceneConfiguration>({
puppetScale: 1,
puppetOpacity: 1,
puppetMovementMode: "free",
});
export const sceneConfigurationListenerError = writable("");
+24
View File
@@ -0,0 +1,24 @@
<svg width="200" height="150" viewBox="0 0 200 150" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="60.5" y="10.5" width="115" height="80" fill="#AAAAAA" stroke="#BBBBBB"/>
<rect opacity="0.5" x="64" y="30" width="108" height="57" fill="#DDDDDD"/>
<rect x="67" y="17" width="7" height="7" fill="#DDDDDD"/>
<rect x="77" y="17" width="7" height="7" fill="#DDDDDD"/>
<rect x="87" y="17" width="7" height="7" fill="#DDDDDD"/>
<rect x="23.5" y="35.5" width="115" height="80" fill="#AAAAAA" stroke="#BBBBBB"/>
<rect opacity="0.5" x="27" y="55" width="108" height="57" fill="#DDDDDD"/>
<rect x="30" y="42" width="7" height="7" fill="#DDDDDD"/>
<rect x="40" y="42" width="7" height="7" fill="#DDDDDD"/>
<rect x="50" y="42" width="7" height="7" fill="#DDDDDD"/>
<rect x="52.6701" y="137" width="5" height="12" transform="rotate(8 52.6701 137)" fill="white"/>
<rect x="60" y="137.696" width="5" height="12" transform="rotate(-8 60 137.696)" fill="white"/>
<rect x="48.1042" y="125.507" width="5" height="12" transform="rotate(20 48.1042 125.507)" fill="white"/>
<rect x="65" y="127.217" width="5" height="12" transform="rotate(-20 65 127.217)" fill="white"/>
<rect x="51" y="124" width="16" height="16" fill="#EEEEEE"/>
<rect x="45" y="96" width="28" height="28" fill="white"/>
<rect x="89.6701" y="137" width="5" height="12" transform="rotate(8 89.6701 137)" fill="white"/>
<rect x="97" y="137.696" width="5" height="12" transform="rotate(-8 97 137.696)" fill="white"/>
<rect x="85.1042" y="125.507" width="5" height="12" transform="rotate(20 85.1042 125.507)" fill="white"/>
<rect x="102" y="127.217" width="5" height="12" transform="rotate(-20 102 127.217)" fill="white"/>
<rect x="88" y="124" width="16" height="16" fill="#EEEEEE"/>
<rect x="82" y="96" width="28" height="28" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg width="200" height="150" viewBox="0 0 200 150" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="60.5" y="10.5" width="115" height="80" fill="#AAAAAA" stroke="#BBBBBB"/>
<rect opacity="0.5" x="64" y="30" width="108" height="57" fill="#DDDDDD"/>
<rect x="67" y="17" width="7" height="7" fill="#DDDDDD"/>
<rect x="77" y="17" width="7" height="7" fill="#DDDDDD"/>
<rect x="87" y="17" width="7" height="7" fill="#DDDDDD"/>
<rect x="23.5" y="35.5" width="115" height="80" fill="#AAAAAA" stroke="#BBBBBB"/>
<rect opacity="0.5" x="27" y="55" width="108" height="57" fill="#DDDDDD"/>
<rect x="30" y="42" width="7" height="7" fill="#DDDDDD"/>
<rect x="40" y="42" width="7" height="7" fill="#DDDDDD"/>
<rect x="50" y="42" width="7" height="7" fill="#DDDDDD"/>
<rect x="134.67" y="115" width="5" height="12" transform="rotate(8 134.67 115)" fill="white"/>
<rect x="142" y="115.696" width="5" height="12" transform="rotate(-8 142 115.696)" fill="white"/>
<rect x="130.104" y="103.507" width="5" height="12" transform="rotate(20 130.104 103.507)" fill="white"/>
<rect x="147" y="105.217" width="5" height="12" transform="rotate(-20 147 105.217)" fill="white"/>
<rect x="133" y="102" width="16" height="16" fill="#EEEEEE"/>
<rect x="127" y="74" width="28" height="28" fill="white"/>
<rect x="52.6701" y="63" width="5" height="12" transform="rotate(8 52.6701 63)" fill="white"/>
<rect x="60" y="63.6959" width="5" height="12" transform="rotate(-8 60 63.6959)" fill="white"/>
<rect x="48.1042" y="51.5068" width="5" height="12" transform="rotate(20 48.1042 51.5068)" fill="white"/>
<rect x="65" y="53.2169" width="5" height="12" transform="rotate(-20 65 53.2169)" fill="white"/>
<rect x="51" y="50" width="16" height="16" fill="#EEEEEE"/>
<rect x="45" y="22" width="28" height="28" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB