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>> { async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let handle = app.handle(); let handle = app.handle();
db::init(handle).await?; db::init(handle).await?;
scene_configuration::init(handle).await?;
keypair::init(handle).await?; keypair::init(handle).await?;
app.manage(interactions::InteractionState::default()); app.manage(interactions::InteractionState::default());
network::init(handle).await?; network::init(handle).await?;
+76 -3
View File
@@ -11,10 +11,12 @@ use tauri_specta::Event;
use tokio::time::MissedTickBehavior; use tokio::time::MissedTickBehavior;
use crate::cursor::{CursorPosition, CursorPositions, CursorState}; use crate::cursor::{CursorPosition, CursorPositions, CursorState};
use crate::scene_configuration::{PuppetMovementMode, SceneConfigurationState};
const TICK_INTERVAL: Duration = Duration::from_millis(125); const TICK_INTERVAL: Duration = Duration::from_millis(125);
const SPEED_LOGICAL_PIXELS_PER_SECOND: f64 = 80.0; const SPEED_LOGICAL_PIXELS_PER_SECOND: f64 = 80.0;
const FOLLOW_RADIUS_LOGICAL_PIXELS: f64 = 48.0; const FOLLOW_RADIUS_LOGICAL_PIXELS: f64 = 48.0;
const BOTTOM_MOVEMENT_Y: f64 = 1.0;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Type)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -39,9 +41,16 @@ impl PuppetStateStore {
cursor_positions: &HashMap<String, CursorPositions>, cursor_positions: &HashMap<String, CursorPositions>,
viewport: Viewport, viewport: Viewport,
elapsed: Duration, elapsed: Duration,
movement_mode: PuppetMovementMode,
) -> Result<Option<Vec<PuppetState>>, String> { ) -> Result<Option<Vec<PuppetState>>, String> {
let mut puppets = self.0.write().map_err(|error| error.to_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))) Ok(changed.then(|| sorted_snapshot(&puppets)))
} }
@@ -95,10 +104,21 @@ pub fn init(handle: &AppHandle) -> Result<(), String> {
continue; 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( let puppets = match handle.state::<PuppetStateStore>().update(
&cursor_positions, &cursor_positions,
viewport, viewport,
elapsed, elapsed,
movement_mode,
) { ) {
Ok(Some(puppets)) => puppets, Ok(Some(puppets)) => puppets,
Ok(None) => continue, Ok(None) => continue,
@@ -128,18 +148,20 @@ fn advance_puppets(
cursor_positions: &HashMap<String, CursorPositions>, cursor_positions: &HashMap<String, CursorPositions>,
viewport: Viewport, viewport: Viewport,
elapsed: Duration, elapsed: Duration,
movement_mode: PuppetMovementMode,
) -> bool { ) -> bool {
let previous_len = puppets.len(); let previous_len = puppets.len();
puppets.retain(|id, _| cursor_positions.contains_key(id)); puppets.retain(|id, _| cursor_positions.contains_key(id));
let mut changed = puppets.len() != previous_len; let mut changed = puppets.len() != previous_len;
for (id, cursor) in cursor_positions { for (id, cursor) in cursor_positions {
let target = movement_target(&cursor.mapped, movement_mode);
let Some(puppet) = puppets.get_mut(id) else { let Some(puppet) = puppets.get_mut(id) else {
puppets.insert( puppets.insert(
id.clone(), id.clone(),
PuppetState { PuppetState {
id: id.clone(), id: id.clone(),
position: cursor.mapped.clone(), position: target,
is_moving: false, is_moving: false,
}, },
); );
@@ -147,12 +169,26 @@ fn advance_puppets(
continue; 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 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( fn advance_puppet(
puppet: &mut PuppetState, puppet: &mut PuppetState,
target: &CursorPosition, target: &CursorPosition,
@@ -220,6 +256,7 @@ mod tests {
&cursors(&[("friend", 0.75, 0.25)]), &cursors(&[("friend", 0.75, 0.25)]),
viewport(), viewport(),
TICK_INTERVAL, TICK_INTERVAL,
PuppetMovementMode::Free,
); );
assert!(changed); assert!(changed);
@@ -296,10 +333,46 @@ mod tests {
&HashMap::new(), &HashMap::new(),
viewport(), viewport(),
TICK_INTERVAL, TICK_INTERVAL,
PuppetMovementMode::Free,
)); ));
assert!(puppets.is_empty()); 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] #[test]
fn normalized_step_accounts_for_viewport_aspect_ratio() { fn normalized_step_accounts_for_viewport_aspect_ratio() {
let mut horizontal = PuppetState { let mut horizontal = PuppetState {
+53 -3
View File
@@ -1,7 +1,8 @@
use crate::db::{self, AppDatabase}; use crate::db::{self, AppDatabase};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use specta::Type; use specta::Type;
use tauri::{AppHandle, State}; use std::sync::RwLock;
use tauri::{AppHandle, Manager, State};
use tauri_specta::Event; use tauri_specta::Event;
const SCENE_CONFIGURATION_ID: i64 = 1; 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 MIN_PUPPET_OPACITY: f64 = 0.1;
const MAX_PUPPET_OPACITY: f64 = 1.0; 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type, sqlx::FromRow)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SceneConfiguration { pub struct SceneConfiguration {
pub puppet_scale: f64, pub puppet_scale: f64,
pub puppet_opacity: 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)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
@@ -25,7 +56,7 @@ pub struct SceneConfigurationChanged {
async fn get(database: &AppDatabase) -> Result<SceneConfiguration, sqlx::Error> { async fn get(database: &AppDatabase) -> Result<SceneConfiguration, sqlx::Error> {
sqlx::query_as::<_, SceneConfiguration>( 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) .bind(SCENE_CONFIGURATION_ID)
.fetch_one(database.pool()) .fetch_one(database.pool())
@@ -52,10 +83,11 @@ async fn update(
} }
sqlx::query( 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_scale)
.bind(configuration.puppet_opacity) .bind(configuration.puppet_opacity)
.bind(configuration.puppet_movement_mode)
.bind(SCENE_CONFIGURATION_ID) .bind(SCENE_CONFIGURATION_ID)
.execute(database.pool()) .execute(database.pool())
.await .await
@@ -64,6 +96,12 @@ async fn update(
get(database).await.map_err(db::command_error) 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> { fn emit_changed(handle: &AppHandle, configuration: SceneConfiguration) -> Result<(), String> {
SceneConfigurationChanged { configuration } SceneConfigurationChanged { configuration }
.emit(handle) .emit(handle)
@@ -75,8 +113,10 @@ fn emit_changed(handle: &AppHandle, configuration: SceneConfiguration) -> Result
pub async fn get_scene_configuration( pub async fn get_scene_configuration(
handle: AppHandle, handle: AppHandle,
database: State<'_, AppDatabase>, database: State<'_, AppDatabase>,
state: State<'_, SceneConfigurationState>,
) -> Result<SceneConfiguration, String> { ) -> Result<SceneConfiguration, String> {
let configuration = get(&database).await.map_err(db::command_error)?; let configuration = get(&database).await.map_err(db::command_error)?;
state.replace(configuration.clone())?;
emit_changed(&handle, configuration.clone())?; emit_changed(&handle, configuration.clone())?;
Ok(configuration) Ok(configuration)
} }
@@ -86,9 +126,11 @@ pub async fn get_scene_configuration(
pub async fn update_scene_configuration( pub async fn update_scene_configuration(
handle: AppHandle, handle: AppHandle,
database: State<'_, AppDatabase>, database: State<'_, AppDatabase>,
state: State<'_, SceneConfigurationState>,
configuration: SceneConfiguration, configuration: SceneConfiguration,
) -> Result<SceneConfiguration, String> { ) -> Result<SceneConfiguration, String> {
let configuration = update(&database, configuration).await?; let configuration = update(&database, configuration).await?;
state.replace(configuration.clone())?;
emit_changed(&handle, configuration.clone())?; emit_changed(&handle, configuration.clone())?;
Ok(configuration) Ok(configuration)
} }
@@ -121,6 +163,7 @@ mod tests {
SceneConfiguration { SceneConfiguration {
puppet_scale: 1.0, puppet_scale: 1.0,
puppet_opacity: 1.0, puppet_opacity: 1.0,
puppet_movement_mode: PuppetMovementMode::Free,
} }
); );
} }
@@ -134,12 +177,17 @@ mod tests {
SceneConfiguration { SceneConfiguration {
puppet_scale: 1.5, puppet_scale: 1.5,
puppet_opacity: 0.4, puppet_opacity: 0.4,
puppet_movement_mode: PuppetMovementMode::Bottom,
}, },
) )
.await .await
.expect("update scene configuration"); .expect("update scene configuration");
assert_eq!(configuration.puppet_scale, 1.5); assert_eq!(configuration.puppet_scale, 1.5);
assert_eq!(configuration.puppet_opacity, 0.4); assert_eq!(configuration.puppet_opacity, 0.4);
assert_eq!(
configuration.puppet_movement_mode,
PuppetMovementMode::Bottom
);
assert_eq!(get(&database).await.unwrap(), configuration); assert_eq!(get(&database).await.unwrap(), configuration);
let error = update( let error = update(
@@ -147,6 +195,7 @@ mod tests {
SceneConfiguration { SceneConfiguration {
puppet_scale: 2.1, puppet_scale: 2.1,
puppet_opacity: 0.4, puppet_opacity: 0.4,
puppet_movement_mode: PuppetMovementMode::Bottom,
}, },
) )
.await .await
@@ -159,6 +208,7 @@ mod tests {
SceneConfiguration { SceneConfiguration {
puppet_scale: 1.5, puppet_scale: 1.5,
puppet_opacity: 0.05, puppet_opacity: 0.05,
puppet_movement_mode: PuppetMovementMode::Bottom,
}, },
) )
.await .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 InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
export type NetworkStatusChanged = { statuses: ConnectionStatus[] } export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
export type ProfileChanged = { profile: User } export type ProfileChanged = { profile: User }
export type PuppetMovementMode = "free" | "bottom"
export type PuppetState = { id: string; position: CursorPosition; isMoving: boolean } export type PuppetState = { id: string; position: CursorPosition; isMoving: boolean }
export type PuppetStatesChanged = { puppets: PuppetState[] } export type PuppetStatesChanged = { puppets: PuppetState[] }
export type Remote = { id: string; address: string; name: string | null; port: number | null } 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 RemoteInput = { address: string; name: string | null; port: number | null }
export type RemotesChanged = { remotes: Remote[] } 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 SceneConfigurationChanged = { configuration: SceneConfiguration }
export type SceneHitbox = { x: number; y: number; width: number; height: number } export type SceneHitbox = { x: number; y: number; width: number; height: number }
/** /**
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { commands } from "$lib/bindings"; import { commands, type PuppetMovementMode } from "$lib/bindings";
import { import {
sceneConfiguration, sceneConfiguration,
sceneConfigurationListenerError, sceneConfigurationListenerError,
@@ -12,6 +12,7 @@
let puppetScale = $state(1); let puppetScale = $state(1);
let puppetOpacity = $state(1); let puppetOpacity = $state(1);
let puppetMovementMode = $state<PuppetMovementMode>("free");
let dirty = $state(false); let dirty = $state(false);
let busy = $state(false); let busy = $state(false);
let error = $state(""); let error = $state("");
@@ -20,6 +21,7 @@
if (!dirty) { if (!dirty) {
puppetScale = $sceneConfiguration.puppetScale; puppetScale = $sceneConfiguration.puppetScale;
puppetOpacity = $sceneConfiguration.puppetOpacity; puppetOpacity = $sceneConfiguration.puppetOpacity;
puppetMovementMode = $sceneConfiguration.puppetMovementMode;
} }
register("scene", { apply, reset }, { dirty, busy }); register("scene", { apply, reset }, { dirty, busy });
}); });
@@ -29,13 +31,15 @@
function updateDirty() { function updateDirty() {
dirty = dirty =
puppetScale !== $sceneConfiguration.puppetScale || puppetScale !== $sceneConfiguration.puppetScale ||
puppetOpacity !== $sceneConfiguration.puppetOpacity; puppetOpacity !== $sceneConfiguration.puppetOpacity ||
puppetMovementMode !== $sceneConfiguration.puppetMovementMode;
error = ""; error = "";
} }
function reset() { function reset() {
puppetScale = $sceneConfiguration.puppetScale; puppetScale = $sceneConfiguration.puppetScale;
puppetOpacity = $sceneConfiguration.puppetOpacity; puppetOpacity = $sceneConfiguration.puppetOpacity;
puppetMovementMode = $sceneConfiguration.puppetMovementMode;
dirty = false; dirty = false;
error = ""; error = "";
} }
@@ -49,9 +53,11 @@
const configuration = await commands.updateSceneConfiguration({ const configuration = await commands.updateSceneConfiguration({
puppetScale, puppetScale,
puppetOpacity, puppetOpacity,
puppetMovementMode,
}); });
puppetScale = configuration.puppetScale; puppetScale = configuration.puppetScale;
puppetOpacity = configuration.puppetOpacity; puppetOpacity = configuration.puppetOpacity;
puppetMovementMode = configuration.puppetMovementMode;
dirty = false; dirty = false;
return true; return true;
} catch (cause) { } catch (cause) {
@@ -110,5 +116,72 @@
disabled={busy} disabled={busy}
aria-describedby="puppet-opacity-help" 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> </fieldset>
</div> </div>
+1
View File
@@ -8,6 +8,7 @@ import {
export const sceneConfiguration = writable<SceneConfiguration>({ export const sceneConfiguration = writable<SceneConfiguration>({
puppetScale: 1, puppetScale: 1,
puppetOpacity: 1, puppetOpacity: 1,
puppetMovementMode: "free",
}); });
export const sceneConfigurationListenerError = writable(""); 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