correct sprite cropping

This commit is contained in:
2026-03-03 01:27:07 +08:00
parent 9749c1d97f
commit 1ccc0b69ed
3 changed files with 81 additions and 30 deletions

View File

@@ -2,24 +2,67 @@ use crate::models::dolls::DollDto;
use crate::services::sprite_recolor; use crate::services::sprite_recolor;
use image::{imageops::FilterType, RgbaImage}; use image::{imageops::FilterType, RgbaImage};
use petpet::{encode_gif, generate}; 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<String, String> { pub fn encode_pet_doll_gif_base64(doll: DollDto) -> Result<String, String> {
let body_color = &doll.configuration.color_scheme.body; let body_color = &doll.configuration.color_scheme.body;
let outline_color = &doll.configuration.color_scheme.outline; let outline_color = &doll.configuration.color_scheme.outline;
// Get recolored image
let img: RgbaImage = sprite_recolor::get_recolored_image(body_color, outline_color) let img: RgbaImage = sprite_recolor::get_recolored_image(body_color, outline_color)
.map_err(|e| format!("Failed to recolor image: {}", e))?; .map_err(|e| format!("Failed to recolor image: {}", e))?;
// Generate petpet frames let random_sprite = crop_random_sprite(&img);
let frames = generate(img, FilterType::Lanczos3, None)
let frames = generate(random_sprite, FilterType::Lanczos3, None)
.map_err(|e| format!("Failed to generate petpet frames: {}", e))?; .map_err(|e| format!("Failed to generate petpet frames: {}", e))?;
// Encode to GIF
let mut output = Vec::new(); let mut output = Vec::new();
encode_gif(frames, &mut output, 10).map_err(|e| format!("Failed to encode GIF: {}", e))?; encode_gif(frames, &mut output, 10).map_err(|e| format!("Failed to encode GIF: {}", e))?;
// Base64
use base64::{engine::general_purpose, Engine as _}; use base64::{engine::general_purpose, Engine as _};
Ok(general_purpose::STANDARD.encode(&output)) Ok(general_purpose::STANDARD.encode(&output))
} }

View File

@@ -29,6 +29,7 @@
let showFullscreenModal = $state(false); let showFullscreenModal = $state(false);
let fullscreenImageSrc = $state(""); let fullscreenImageSrc = $state("");
let headpatSenderId = $state<string | null>(null); let headpatSenderId = $state<string | null>(null);
let headpatTimer: ReturnType<typeof setTimeout> | null = null;
// Queue for pending headpats (when modal is already showing) // Queue for pending headpats (when modal is already showing)
let headpatQueue = $state<Array<{ userId: string; content: string }>>([]); let headpatQueue = $state<Array<{ userId: string; content: string }>>([]);
@@ -41,12 +42,23 @@
fullscreenImageSrc = `data:image/gif;base64,${next.content}`; fullscreenImageSrc = `data:image/gif;base64,${next.content}`;
headpatSenderId = next.userId; headpatSenderId = next.userId;
showFullscreenModal = true; showFullscreenModal = true;
scheduleHeadpatDismiss();
} else { } else {
fullscreenImageSrc = ""; fullscreenImageSrc = "";
headpatSenderId = null; headpatSenderId = null;
} }
} }
function scheduleHeadpatDismiss() {
if (headpatTimer) {
clearTimeout(headpatTimer);
}
headpatTimer = setTimeout(() => {
showFullscreenModal = false;
headpatTimer = null;
}, 3000);
}
function getHeadpatSenderName(userId: string | null): string { function getHeadpatSenderName(userId: string | null): string {
if (!userId) return ""; if (!userId) return "";
const friend = getFriendById(userId); const friend = getFriendById(userId);
@@ -57,32 +69,44 @@
$effect(() => { $effect(() => {
for (const [userId, interaction] of $receivedInteractions) { for (const [userId, interaction] of $receivedInteractions) {
if (interaction.type === INTERACTION_TYPE_HEADPAT) { if (interaction.type === INTERACTION_TYPE_HEADPAT) {
// Deduplicate: replace existing headpat from same user instead of queueing if (showFullscreenModal) {
const existingIndex = headpatQueue.findIndex((h) => h.userId === userId); // Queue the headpat for later (deduplicate by replacing existing from same user)
if (existingIndex >= 0) { const existingIndex = headpatQueue.findIndex((h) => h.userId === userId);
headpatQueue[existingIndex] = { userId, content: interaction.content }; if (existingIndex >= 0) {
} else if (showFullscreenModal) { headpatQueue[existingIndex] = { userId, content: interaction.content };
headpatQueue.push({ userId, content: interaction.content }); } else {
headpatQueue.push({ userId, content: interaction.content });
}
scheduleHeadpatDismiss();
} else { } else {
// Show immediately and clear from store
clearInteraction(userId); clearInteraction(userId);
fullscreenImageSrc = `data:image/gif;base64,${interaction.content}`; fullscreenImageSrc = `data:image/gif;base64,${interaction.content}`;
headpatSenderId = userId; headpatSenderId = userId;
showFullscreenModal = true; showFullscreenModal = true;
scheduleHeadpatDismiss();
} }
} }
} }
}); });
// Clear headpat interaction when modal closes, then process next in queue // When modal closes, process next headpat in queue
$effect(() => { $effect(() => {
if (!showFullscreenModal && headpatSenderId) { if (!showFullscreenModal && headpatSenderId) {
clearInteraction(headpatSenderId);
headpatSenderId = null; headpatSenderId = null;
// Process next headpat in queue
processNextHeadpat(); processNextHeadpat();
} }
}); });
$effect(() => {
return () => {
if (headpatTimer) {
clearTimeout(headpatTimer);
headpatTimer = null;
}
};
});
function getFriendById(userId: string) { function getFriendById(userId: string) {
const friend = $appData?.friends?.find((f) => f.friend?.id === userId); const friend = $appData?.friends?.find((f) => f.friend?.id === userId);
return friend?.friend; return friend?.friend;

View File

@@ -20,22 +20,6 @@
senderName = "", senderName = "",
}: { imageSrc: string; visible: boolean; senderName?: string } = $props(); }: { imageSrc: string; visible: boolean; senderName?: string } = $props();
let timer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if (visible) {
timer = setTimeout(() => {
visible = false;
}, 3000);
}
return () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
});
</script> </script>
{#if visible} {#if visible}