diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a14cfe6..3c32f17 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -53,6 +53,19 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "apng" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a79142fc4e25db11e703bf09431aad1488d65d2e1bb4d0f664a8f8af000054c" +dependencies = [ + "byteorder", + "flate2", + "image", + "png", + "thiserror 1.0.69", +] + [[package]] name = "ashpd" version = "0.11.0" @@ -389,12 +402,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.11.0" @@ -1203,7 +1210,7 @@ dependencies = [ "dotenvy", "enigo", "flate2", - "gif", + "gif 0.14.1", "image", "keyring", "lazy_static", @@ -1212,6 +1219,7 @@ dependencies = [ "objc2-app-kit", "objc2-foundation 0.3.2", "once_cell", + "petpet", "rand 0.9.2", "raw-window-handle", "reqwest", @@ -1495,6 +1503,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.1" @@ -1889,7 +1907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" dependencies = [ "byteorder", - "png 0.17.16", + "png", ] [[package]] @@ -2002,17 +2020,16 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.24.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" dependencies = [ "bytemuck", - "byteorder-lite", + "byteorder", "color_quant", - "gif", - "moxcms", + "gif 0.13.3", "num-traits", - "png 0.18.0", + "png", ] [[package]] @@ -2464,16 +2481,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "moxcms" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" -dependencies = [ - "num-traits", - "pxfm", -] - [[package]] name = "muda" version = "0.17.1" @@ -2489,7 +2496,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", - "png 0.17.16", + "png", "serde", "thiserror 2.0.17", "windows-sys 0.60.2", @@ -3039,6 +3046,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petpet" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9723170e1a50808a2a790e96a36e23762b30c318861c0acf4b4ab51d8944f924" +dependencies = [ + "apng", + "image", +] + [[package]] name = "phf" version = "0.8.0" @@ -3228,19 +3245,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "png" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" -dependencies = [ - "bitflags 2.10.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "polling" version = "3.11.0" @@ -3352,15 +3356,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pxfm" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] - [[package]] name = "quick-xml" version = "0.38.4" @@ -4518,7 +4513,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png 0.17.16", + "png", "proc-macro2", "quote", "semver", @@ -5195,7 +5190,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "once_cell", - "png 0.17.16", + "png", "serde", "thiserror 2.0.17", "windows-sys 0.60.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0faaaf2..c2e5388 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,12 +40,13 @@ flate2 = "1.0.28" rust_socketio = "0.6.0" tracing-appender = "0.2.4" tauri-plugin-dialog = "2.4.2" -image = {version = "0.25.9", default-features = false, features = ["gif", "png"] } +image = {version = "0.24.9", default-features = false, features = ["gif", "png"] } gif = "0.14.1" raw-window-handle = "0.6" enigo = { version = "0.6.1", features = ["wayland"] } lazy_static = "1.5.0" mlua = { version = "0.11", default-features = false, features = ["lua54", "vendored", "serde", "async"] } +petpet = "2.4.3" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-global-shortcut = "2" tauri-plugin-positioner = "2" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index eb86c5f..6cf9b25 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod dolls; pub mod friends; pub mod interaction; pub mod sprite; +pub mod petpet; use crate::lock_r; use crate::state::{init_app_data_scoped, AppDataRefreshScope, FDOLL}; diff --git a/src-tauri/src/commands/petpet.rs b/src-tauri/src/commands/petpet.rs new file mode 100644 index 0000000..602c8e0 --- /dev/null +++ b/src-tauri/src/commands/petpet.rs @@ -0,0 +1,7 @@ +use crate::models::dolls::DollDto; +use crate::services::petpet; + +#[tauri::command] +pub fn encode_pet_doll_gif_base64(doll: DollDto) -> Result { + petpet::encode_pet_doll_gif_base64(doll) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c58ac19..8a3ba4f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ use commands::friends::{ }; use commands::interaction::send_interaction_cmd; use commands::sprite::recolor_gif_base64; +use commands::petpet::encode_pet_doll_gif_base64; use tauri::async_runtime; static APP_HANDLE: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -74,6 +75,7 @@ pub fn run() { set_active_doll, remove_active_doll, recolor_gif_base64, + encode_pet_doll_gif_base64, quit_app, restart_app, retry_connection, diff --git a/src-tauri/src/services/interaction.rs b/src-tauri/src/services/interaction.rs index 2363f4c..76b5858 100644 --- a/src-tauri/src/services/interaction.rs +++ b/src-tauri/src/services/interaction.rs @@ -1,5 +1,5 @@ use serde_json::json; -use tracing::{error, info}; +use tracing::error; use crate::{ lock_r, models::interaction::SendInteractionDto, services::ws::WS_EVENT, state::FDOLL, @@ -25,23 +25,12 @@ pub async fn send_interaction(dto: SendInteractionDto) -> Result<(), String> { // The DTO structure matches what the server expects: // { recipientUserId, content, type } (handled by serde rename_all="camelCase") - // Note: The `type` field in DTO is mapped to `type_` in Rust struct but serialized as `type` - // due to camelCase renaming (if we rely on TS-RS output) or manual renaming. - // Wait, `type` is a reserved keyword in Rust so we used `type_`. - // We need to ensure serialization maps `type_` to `type`. - // However, serde's `rename_all = "camelCase"` handles `recipient_user_id` -> `recipientUserId`. - // It does NOT automatically handle `type_` -> `type`. - // We should add `#[serde(rename = "type")]` to the `type_` field in the model. - // I will fix the model first to ensure correct serialization. - let payload = json!({ "recipientUserId": dto.recipient_user_id, "content": dto.content, "type": dto.type_ }); - info!("Sending interaction via WS: {:?}", payload); - // Blocking emission because rust_socketio::Client::emit is synchronous/blocking // but we are in an async context. Ideally we spawn_blocking. let spawn_result = tauri::async_runtime::spawn_blocking(move || { diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 1ff97fa..600f4e0 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -13,6 +13,7 @@ pub mod interaction; pub mod presence_modules; pub mod scene; pub mod sprite_recolor; +pub mod petpet; pub mod welcome; pub mod ws; diff --git a/src-tauri/src/services/petpet/mod.rs b/src-tauri/src/services/petpet/mod.rs new file mode 100644 index 0000000..a2ea35e --- /dev/null +++ b/src-tauri/src/services/petpet/mod.rs @@ -0,0 +1,25 @@ +use crate::models::dolls::DollDto; +use crate::services::sprite_recolor; +use image::{imageops::FilterType, RgbaImage}; +use petpet::{encode_gif, generate}; + +pub fn encode_pet_doll_gif_base64(doll: DollDto) -> Result { + let body_color = &doll.configuration.color_scheme.body; + let outline_color = &doll.configuration.color_scheme.outline; + + // Get recolored image + let img: RgbaImage = sprite_recolor::get_recolored_image(body_color, outline_color) + .map_err(|e| format!("Failed to recolor image: {}", e))?; + + // Generate petpet frames + let frames = generate(img, FilterType::Lanczos3, None) + .map_err(|e| format!("Failed to generate petpet frames: {}", e))?; + + // Encode to GIF + let mut output = Vec::new(); + encode_gif(frames, &mut output, 10).map_err(|e| format!("Failed to encode GIF: {}", e))?; + + // Base64 + use base64::{engine::general_purpose, Engine as _}; + Ok(general_purpose::STANDARD.encode(&output)) +} diff --git a/src-tauri/src/services/sprite_recolor/mod.rs b/src-tauri/src/services/sprite_recolor/mod.rs index 4258a0c..18940f4 100644 --- a/src-tauri/src/services/sprite_recolor/mod.rs +++ b/src-tauri/src/services/sprite_recolor/mod.rs @@ -226,3 +226,30 @@ fn pick_color_from_palette(palette: &[Rgba], x: u32, y: u32) -> Rgba { // SAFETY: index is guaranteed to be < palette.len() due to modulo operation unsafe { *palette.get_unchecked(index) } } + +pub fn get_recolored_image( + white_color_hex: &str, + black_color_hex: &str, +) -> Result> { + let white_color = parse_hex_color(white_color_hex)?; + let black_color = parse_hex_color(black_color_hex)?; + + // Get pre-decoded GIF data + let decoded_gif = get_decoded_gif(); + + // Take first frame + let frame = &decoded_gif.frames[0]; + let mut img = RgbaImage::from_raw( + decoded_gif.width as u32, + decoded_gif.height as u32, + frame.pixels.clone(), + ) + .ok_or("Failed to create image from frame")?; + + // Recolor the image + let white_palette = generate_color_palette(white_color, 7); + let black_palette = generate_color_palette(black_color, 7); + recolor_image(&mut img, &white_palette, &black_palette, true); + + Ok(img) +} diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index 77dbc74..0983138 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -153,6 +153,7 @@ userStatus={getFriendStatus(userId)} {doll} {isInteractive} + senderDoll={getUserDoll()} /> {/if} {/each} diff --git a/src/routes/scene/components/DesktopPet.svelte b/src/routes/scene/components/DesktopPet.svelte index 0a010a5..7ca6fae 100644 --- a/src/routes/scene/components/DesktopPet.svelte +++ b/src/routes/scene/components/DesktopPet.svelte @@ -14,6 +14,7 @@ import type { UserBasicDto } from "../../../types/bindings/UserBasicDto"; import type { PresenceStatus } from "../../../types/bindings/PresenceStatus"; import type { UserStatus } from "../../../events/user-status"; + import type { InteractionPayloadDto } from "../../../types/bindings/InteractionPayloadDto"; export let id = ""; export let targetX = 0; @@ -22,6 +23,7 @@ export let userStatus: UserStatus | undefined = undefined; export let doll: DollDto | undefined = undefined; export let isInteractive = false; + export let senderDoll: DollDto | undefined = undefined; const { position, currentSprite, updatePosition, setPosition } = usePetState( 32, @@ -33,17 +35,14 @@ let spriteSheetUrl = onekoGif; let isPetMenuOpen = false; - let receivedMessage: string | undefined = undefined; + let receivedInteraction: InteractionPayloadDto | undefined = undefined; let messageTimer: number | undefined = undefined; // Watch for received interactions for this user $: { const interaction = $receivedInteractions.get(user.id); - if (interaction && interaction.content !== receivedMessage) { - console.log( - `Received interaction for ${user.id}: ${interaction.content}`, - ); - receivedMessage = interaction.content; + if (interaction && interaction !== receivedInteraction) { + receivedInteraction = interaction; isPetMenuOpen = true; // Make scene interactive so user can see it @@ -58,7 +57,7 @@ // Auto-close and clear after 8 seconds messageTimer = setTimeout(() => { isPetMenuOpen = false; - receivedMessage = undefined; + receivedInteraction = undefined; clearInteraction(user.id); // We probably shouldn't disable interactivity globally here as other pets might be active, // but 'set_pet_menu_state' in backend handles the window transparency logic per pet/menu. @@ -85,7 +84,7 @@ // Actually, `isInteractive` is a prop passed from +page.svelte probably based on hover state. // If we want the menu to stay open during the message, we should probably ignore this auto-close behavior if a message is present. - $: if (!receivedMessage && !isInteractive) { + $: if (!receivedInteraction && !isInteractive) { isPetMenuOpen = false; } @@ -154,7 +153,13 @@ aria-label="Pet Menu" > {#if doll} - + {/if} {/if} @@ -176,7 +181,7 @@ isPetMenuOpen = !isPetMenuOpen; if (!isPetMenuOpen) { // Clear message when closing menu manually - receivedMessage = undefined; + receivedInteraction = undefined; clearInteraction(user.id); if (messageTimer) clearTimeout(messageTimer); } diff --git a/src/routes/scene/components/PetMenu.svelte b/src/routes/scene/components/PetMenu.svelte index 82060cb..3a10280 100644 --- a/src/routes/scene/components/PetMenu.svelte +++ b/src/routes/scene/components/PetMenu.svelte @@ -4,11 +4,13 @@ import type { UserBasicDto } from "../../../types/bindings/UserBasicDto"; import type { SendInteractionDto } from "../../../types/bindings/SendInteractionDto"; import type { UserStatus } from "../../../events/user-status"; + import type { InteractionPayloadDto } from "../../../types/bindings/InteractionPayloadDto"; export let doll: DollDto; export let user: UserBasicDto; export let userStatus: UserStatus | undefined = undefined; - export let receivedMessage: string | undefined = undefined; + export let receivedInteraction: InteractionPayloadDto | undefined = undefined; + export let senderDoll: DollDto | undefined = undefined; let showMessageInput = false; let messageContent = ""; @@ -32,6 +34,26 @@ } } + async function sendHeadpat() { + if (!senderDoll) return; + + try { + const gifBase64 = await invoke("encode_pet_doll_gif_base64", { doll: senderDoll }) as string; + const dto: SendInteractionDto = { + recipientUserId: user.id, + content: gifBase64, + type: "headpat", + }; + + await invoke("send_interaction_cmd", { dto }); + messageContent = ""; + showMessageInput = false; + } catch (e) { + console.error("Failed to send interaction:", e); + alert("Failed to send headpat: " + e); + } + } + function handleKeydown(event: KeyboardEvent) { if (event.key === "Enter") { sendMessage(); @@ -62,11 +84,19 @@ {/if} - {#if receivedMessage} + {#if receivedInteraction}
-
- {receivedMessage} -
+ {#if receivedInteraction.type === "headpat"} + Headpat GIF + {:else} +
+ {receivedInteraction.content} +
+ {/if}
{:else if showMessageInput}
@@ -89,7 +119,7 @@
{:else}
- +