petpet headpat init

This commit is contained in:
2026-02-20 02:23:01 +08:00
parent 62a1c1b672
commit ea8f41f852
12 changed files with 162 additions and 78 deletions

93
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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};

View File

@@ -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<String, String> {
petpet::encode_pet_doll_gif_base64(doll)
}

View File

@@ -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<tauri::AppHandle<tauri::Wry>> = 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,

View File

@@ -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 || {

View File

@@ -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;

View File

@@ -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<String, String> {
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))
}

View File

@@ -226,3 +226,30 @@ fn pick_color_from_palette(palette: &[Rgba<u8>], x: u32, y: u32) -> Rgba<u8> {
// 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<RgbaImage, Box<dyn std::error::Error>> {
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)
}

View File

@@ -153,6 +153,7 @@
userStatus={getFriendStatus(userId)}
{doll}
{isInteractive}
senderDoll={getUserDoll()}
/>
{/if}
{/each}

View File

@@ -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}
<PetMenu {doll} {user} {userStatus} {receivedMessage} />
<PetMenu
{doll}
{user}
{userStatus}
{receivedInteraction}
{senderDoll}
/>
{/if}
</div>
{/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);
}

View File

@@ -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 @@
</div>
{/if}
{#if receivedMessage}
{#if receivedInteraction}
<div class="">
<div class="text-sm max-w-[140px]">
{receivedMessage}
</div>
{#if receivedInteraction.type === "headpat"}
<img
src={`data:image/gif;base64,${receivedInteraction.content}`}
alt="Headpat GIF"
class="max-w-[140px] h-auto"
/>
{:else}
<div class="text-sm max-w-[140px]">
{receivedInteraction.content}
</div>
{/if}
</div>
{:else if showMessageInput}
<div class="flex flex-col gap-1">
@@ -89,7 +119,7 @@
</div>
{:else}
<div class="flex flex-row gap-1 w-full *:flex-1 *:btn *:btn-sm">
<button disabled>Headpat</button>
<button onclick={sendHeadpat}>Headpat</button>
<button onclick={() => (showMessageInput = true)}>Message</button>
</div>
<div class="flex flex-row gap-1 w-full *:flex-1 *:btn *:btn-sm">