diff --git a/src-tauri/src/services/petpet/mod.rs b/src-tauri/src/services/petpet/mod.rs index a2ea35e..04ead5b 100644 --- a/src-tauri/src/services/petpet/mod.rs +++ b/src-tauri/src/services/petpet/mod.rs @@ -2,24 +2,67 @@ use crate::models::dolls::DollDto; use crate::services::sprite_recolor; use image::{imageops::FilterType, RgbaImage}; use petpet::{encode_gif, generate}; +use rand::prelude::IndexedRandom; + +const SPRITE_SIZE: u32 = 32; +const SPRITE_FRAMES: &[(u32, u32)] = &[ + (160, 125), + (32, 125), + (192, 0), + (224, 0), + (32, 0), + (0, 0), + (0, 127), + (32, 127), + (224, 126), + (224, 96), + (224, 64), + (160, 0), + (160, 127), + (160, 126), + (224, 0), + (224, 127), + (32, 126), + (32, 125), + (0, 126), + (0, 125), + (160, 0), + (160, 127), + (192, 127), + (192, 126), + (224, 125), + (32, 126), + (192, 125), + (224, 127), + (160, 126), + (160, 125), + (224, 0), + (224, 127), +]; + +fn crop_random_sprite(img: &RgbaImage) -> RgbaImage { + let mut rng = rand::rng(); + let &(coord_x, coord_y) = SPRITE_FRAMES + .choose(&mut rng) + .expect("SPRITE_FRAMES must not be empty"); + image::imageops::crop_imm(img, coord_x, coord_y, SPRITE_SIZE, SPRITE_SIZE).to_image() +} 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) + let random_sprite = crop_random_sprite(&img); + + let frames = generate(random_sprite, 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/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index c3623c7..d2e5abf 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -29,6 +29,7 @@ let showFullscreenModal = $state(false); let fullscreenImageSrc = $state(""); let headpatSenderId = $state(null); + let headpatTimer: ReturnType | null = null; // Queue for pending headpats (when modal is already showing) let headpatQueue = $state>([]); @@ -41,12 +42,23 @@ fullscreenImageSrc = `data:image/gif;base64,${next.content}`; headpatSenderId = next.userId; showFullscreenModal = true; + scheduleHeadpatDismiss(); } else { fullscreenImageSrc = ""; headpatSenderId = null; } } + function scheduleHeadpatDismiss() { + if (headpatTimer) { + clearTimeout(headpatTimer); + } + headpatTimer = setTimeout(() => { + showFullscreenModal = false; + headpatTimer = null; + }, 3000); + } + function getHeadpatSenderName(userId: string | null): string { if (!userId) return ""; const friend = getFriendById(userId); @@ -57,32 +69,44 @@ $effect(() => { for (const [userId, interaction] of $receivedInteractions) { if (interaction.type === INTERACTION_TYPE_HEADPAT) { - // Deduplicate: replace existing headpat from same user instead of queueing - const existingIndex = headpatQueue.findIndex((h) => h.userId === userId); - if (existingIndex >= 0) { - headpatQueue[existingIndex] = { userId, content: interaction.content }; - } else if (showFullscreenModal) { - headpatQueue.push({ userId, content: interaction.content }); + if (showFullscreenModal) { + // Queue the headpat for later (deduplicate by replacing existing from same user) + const existingIndex = headpatQueue.findIndex((h) => h.userId === userId); + if (existingIndex >= 0) { + headpatQueue[existingIndex] = { userId, content: interaction.content }; + } else { + headpatQueue.push({ userId, content: interaction.content }); + } + scheduleHeadpatDismiss(); } else { + // Show immediately and clear from store clearInteraction(userId); fullscreenImageSrc = `data:image/gif;base64,${interaction.content}`; headpatSenderId = userId; showFullscreenModal = true; + scheduleHeadpatDismiss(); } } } }); - // Clear headpat interaction when modal closes, then process next in queue + // When modal closes, process next headpat in queue $effect(() => { if (!showFullscreenModal && headpatSenderId) { - clearInteraction(headpatSenderId); headpatSenderId = null; - // Process next headpat in queue processNextHeadpat(); } }); + $effect(() => { + return () => { + if (headpatTimer) { + clearTimeout(headpatTimer); + headpatTimer = null; + } + }; + }); + function getFriendById(userId: string) { const friend = $appData?.friends?.find((f) => f.friend?.id === userId); return friend?.friend; diff --git a/src/routes/scene/components/FullscreenModal.svelte b/src/routes/scene/components/FullscreenModal.svelte index 77bf316..4cb361c 100644 --- a/src/routes/scene/components/FullscreenModal.svelte +++ b/src/routes/scene/components/FullscreenModal.svelte @@ -20,22 +20,6 @@ senderName = "", }: { imageSrc: string; visible: boolean; senderName?: string } = $props(); - let timer: ReturnType | null = null; - - $effect(() => { - if (visible) { - timer = setTimeout(() => { - visible = false; - }, 3000); - } - - return () => { - if (timer) { - clearTimeout(timer); - timer = null; - } - }; - }); {#if visible}