fullscreen headpat

This commit is contained in:
2026-03-03 00:07:18 +08:00
parent 9c6c447205
commit e4fc97d8ce
5 changed files with 137 additions and 32 deletions

View File

@@ -0,0 +1,2 @@
export const INTERACTION_TYPE_HEADPAT = "headpat";
export const INTERACTION_TYPE_TEXT = "text";

View File

@@ -12,6 +12,9 @@
} from "../../events/user-status"; } from "../../events/user-status";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import DesktopPet from "./components/DesktopPet.svelte"; import DesktopPet from "./components/DesktopPet.svelte";
import FullscreenModal from "./components/FullscreenModal.svelte";
import { receivedInteractions, clearInteraction } from "$lib/stores/interaction-store";
import { INTERACTION_TYPE_HEADPAT } from "$lib/constants/interaction";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { PresenceStatus } from "../../types/bindings/PresenceStatus"; import type { PresenceStatus } from "../../types/bindings/PresenceStatus";
@@ -22,6 +25,64 @@
let isInteractive = $derived($sceneInteractive); let isInteractive = $derived($sceneInteractive);
// Fullscreen modal state for headpats
let showFullscreenModal = $state(false);
let fullscreenImageSrc = $state("");
let headpatSenderId = $state<string | null>(null);
// Queue for pending headpats (when modal is already showing)
let headpatQueue = $state<Array<{ userId: string; content: string }>>([]);
// Process next headpat in queue
function processNextHeadpat() {
if (headpatQueue.length > 0) {
const next = headpatQueue.shift()!;
clearInteraction(next.userId);
fullscreenImageSrc = `data:image/gif;base64,${next.content}`;
headpatSenderId = next.userId;
showFullscreenModal = true;
} else {
fullscreenImageSrc = "";
headpatSenderId = null;
}
}
function getHeadpatSenderName(userId: string | null): string {
if (!userId) return "";
const friend = getFriendById(userId);
return friend?.name ?? "";
}
// Watch for headpat interactions and show fullscreen modal
$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 });
} else {
clearInteraction(userId);
fullscreenImageSrc = `data:image/gif;base64,${interaction.content}`;
headpatSenderId = userId;
showFullscreenModal = true;
}
}
}
});
// Clear headpat interaction when modal closes, then process next in queue
$effect(() => {
if (!showFullscreenModal && headpatSenderId) {
clearInteraction(headpatSenderId);
headpatSenderId = null;
// Process next headpat in queue
processNextHeadpat();
}
});
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;
@@ -177,4 +238,10 @@
/> />
{/if} {/if}
</div> </div>
<FullscreenModal
bind:visible={showFullscreenModal}
imageSrc={fullscreenImageSrc}
senderName={getHeadpatSenderName(headpatSenderId)}
/>
</div> </div>

View File

@@ -9,6 +9,7 @@
receivedInteractions, receivedInteractions,
clearInteraction, clearInteraction,
} from "$lib/stores/interaction-store"; } from "$lib/stores/interaction-store";
import { INTERACTION_TYPE_HEADPAT } from "$lib/constants/interaction";
import PetMenu from "./PetMenu.svelte"; import PetMenu from "./PetMenu.svelte";
import type { DollDto } from "../../../types/bindings/DollDto"; import type { DollDto } from "../../../types/bindings/DollDto";
import type { UserBasicDto } from "../../../types/bindings/UserBasicDto"; import type { UserBasicDto } from "../../../types/bindings/UserBasicDto";
@@ -43,29 +44,35 @@
const interaction = $receivedInteractions.get(user.id); const interaction = $receivedInteractions.get(user.id);
if (interaction && interaction !== receivedInteraction) { if (interaction && interaction !== receivedInteraction) {
receivedInteraction = interaction; receivedInteraction = interaction;
isPetMenuOpen = true;
// Make scene interactive so user can see it // Headpats are handled at the page level via FullscreenModal instead of the pet menu.
invoke("set_scene_interactive", { // This provides a more prominent/fullscreen experience for headpat animations,
interactive: true, // while regular messages/bubbles are shown in the pet menu near the desktop pet.
shouldClick: false, if (interaction.type !== INTERACTION_TYPE_HEADPAT) {
}); isPetMenuOpen = true;
// Clear existing timer if any // Make scene interactive so user can see it
if (messageTimer) clearTimeout(messageTimer); invoke("set_scene_interactive", {
interactive: true,
shouldClick: false,
});
// Auto-close and clear after 8 seconds // Clear existing timer if any
messageTimer = setTimeout(() => { if (messageTimer) clearTimeout(messageTimer);
isPetMenuOpen = false;
receivedInteraction = undefined; // Auto-close and clear after 8 seconds
clearInteraction(user.id); messageTimer = setTimeout(() => {
// We probably shouldn't disable interactivity globally here as other pets might be active, isPetMenuOpen = false;
// but 'set_pet_menu_state' in backend handles the window transparency logic per pet/menu. receivedInteraction = undefined;
// However, we did explicitly call set_scene_interactive(true). clearInteraction(user.id);
// It might be safer to let the mouse-leave or other logic handle setting it back to false, // We probably shouldn't disable interactivity globally here as other pets might be active,
// or just leave it as is since the user might want to interact. // but 'set_pet_menu_state' in backend handles the window transparency logic per pet/menu.
// For now, focusing on the message lifecycle. // However, we did explicitly call set_scene_interactive(true).
}, 8000) as unknown as number; // It might be safer to let the mouse-leave or other logic handle setting it back to false,
// or just leave it as is since the user might want to interact.
// For now, focusing on the message lifecycle.
}, 8000) as unknown as number;
}
} }
} }
@@ -135,6 +142,9 @@
if (animationFrameId) { if (animationFrameId) {
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
} }
if (messageTimer) {
clearTimeout(messageTimer);
}
}); });
</script> </script>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
let {
imageSrc,
visible = $bindable(false),
senderName = "",
}: { 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>
{#if visible}
<div class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/80">
{#if senderName}
<div class="mb-4 text-white text-lg font-medium">{senderName} gave you a headpat!</div>
{/if}
<img src={imageSrc} alt="Headpat" class="max-w-full max-h-full object-contain" />
</div>
{/if}

View File

@@ -5,6 +5,7 @@
import type { SendInteractionDto } from "../../../types/bindings/SendInteractionDto"; import type { SendInteractionDto } from "../../../types/bindings/SendInteractionDto";
import type { UserStatus } from "../../../events/user-status"; import type { UserStatus } from "../../../events/user-status";
import type { InteractionPayloadDto } from "../../../types/bindings/InteractionPayloadDto"; import type { InteractionPayloadDto } from "../../../types/bindings/InteractionPayloadDto";
import { INTERACTION_TYPE_HEADPAT } from "$lib/constants/interaction";
export let doll: DollDto; export let doll: DollDto;
export let user: UserBasicDto; export let user: UserBasicDto;
@@ -84,19 +85,11 @@
</div> </div>
{/if} {/if}
{#if receivedInteraction} {#if receivedInteraction && receivedInteraction.type !== INTERACTION_TYPE_HEADPAT}
<div class=""> <div class="">
{#if receivedInteraction.type === "headpat"} <div class="text-sm max-w-[140px]">
<img {receivedInteraction.content}
src={`data:image/gif;base64,${receivedInteraction.content}`} </div>
alt="Headpat GIF"
class="max-w-[140px] h-auto"
/>
{:else}
<div class="text-sm max-w-[140px]">
{receivedInteraction.content}
</div>
{/if}
</div> </div>
{:else if showMessageInput} {:else if showMessageInput}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">