nuked svelte pets system, starting from scratch

This commit is contained in:
2026-03-07 01:56:41 +08:00
parent 7b355804f0
commit c3e39e7d9a
5 changed files with 112 additions and 655 deletions

View File

@@ -2,7 +2,6 @@
import {
cursorPositionOnScreen,
friendsCursorPositions,
friendsActiveDolls,
} from "../../events/cursor";
import { appData } from "../../events/app-data";
import { sceneInteractive } from "../../events/scene-interactive";
@@ -11,168 +10,17 @@
type UserStatus,
} from "../../events/user-status";
import { invoke } from "@tauri-apps/api/core";
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 { AppEvents } from "../../types/bindings/AppEventsConstants";
import { getSpriteSheetUrl } from "$lib/utils/sprite-utils";
import { onMount } from "svelte";
import type { PresenceStatus } from "../../types/bindings/PresenceStatus";
import type { DollDto } from "../../types/bindings/DollDto";
import DebugBar from "./components/debug-bar.svelte";
let innerWidth = $state(0);
let innerHeight = $state(0);
let isInteractive = $derived($sceneInteractive);
// Fullscreen modal state for headpats
let showFullscreenModal = $state(false);
let fullscreenImageSrc = $state("");
let headpatSenderSpriteUrl = $state("");
let headpatSenderId = $state<string | null>(null);
let headpatTimer: ReturnType<typeof setTimeout> | null = null;
// Queue for pending headpats (when modal is already showing)
let headpatQueue = $state<Array<{ userId: string; content: string }>>([]);
let headpatSpriteToken = 0;
// Process next headpat in queue
function processNextHeadpat() {
if (headpatQueue.length > 0) {
const next = headpatQueue.shift()!;
clearInteraction(next.userId);
headpatSenderId = next.userId;
void loadHeadpatSprites(next.userId);
showFullscreenModal = true;
scheduleHeadpatDismiss();
} else {
fullscreenImageSrc = "";
headpatSenderSpriteUrl = "";
headpatSenderId = null;
}
}
async function loadHeadpatSprites(senderId: string) {
const token = ++headpatSpriteToken;
const senderDoll = getFriendDoll(senderId);
const userDoll = getUserDoll();
let userPetpetGif = "";
if (userDoll) {
try {
const gifBase64 = await invoke<string>("encode_pet_doll_gif_base64", {
doll: userDoll,
});
userPetpetGif = `data:image/gif;base64,${gifBase64}`;
} catch (e) {
console.error("Failed to generate user petpet:", e);
}
}
const senderSpriteUrl = senderDoll
? await getSpriteSheetUrl({
bodyColor: senderDoll.configuration.colorScheme.body,
outlineColor: senderDoll.configuration.colorScheme.outline,
})
: await getSpriteSheetUrl();
if (token !== headpatSpriteToken) return;
fullscreenImageSrc = userPetpetGif;
headpatSenderSpriteUrl = senderSpriteUrl;
}
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);
return friend?.name ?? "";
}
// Watch for headpat interactions and show fullscreen modal
$effect(() => {
for (const [userId, interaction] of $receivedInteractions) {
if (interaction.type === INTERACTION_TYPE_HEADPAT) {
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);
headpatSenderId = userId;
void loadHeadpatSprites(userId);
showFullscreenModal = true;
scheduleHeadpatDismiss();
}
}
}
});
// When modal closes, process next headpat in queue
$effect(() => {
if (!showFullscreenModal && headpatSenderId) {
headpatSenderId = null;
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;
}
function getFriendDoll(userId: string) {
if (userId in $friendsActiveDolls) {
return $friendsActiveDolls[userId];
}
const friend = $appData?.friends?.find((f) => f.friend?.id === userId);
return friend?.friend?.activeDoll;
}
function getFriendStatus(userId: string) {
return $friendsUserStatuses[userId];
}
function getUserDoll(): DollDto | undefined {
const user = $appData?.user;
if (!user || !user.activeDollId) return undefined;
return $appData?.dolls?.find((d) => d.id === user.activeDollId);
}
let presenceStatus: PresenceStatus | null = $state(null);
onMount(() => {
@@ -203,114 +51,15 @@
}}>&nbsp;</button
>
<div
class="size-max mx-auto bg-base-100 border-base-200 border p-1 rounded-lg shadow-md"
id="debug-bar"
>
<div class="flex flex-row gap-1 items-center text-center">
<div>
<span class="py-3 text-xs items-center gap-2 badge">
<span
class={`size-2 rounded-full ${isInteractive ? "bg-success" : "bg-base-300"}`}
></span>
Intercepting cursor events
</span>
</div>
<span class="font-mono text-xs badge py-3">
({$cursorPositionOnScreen.mapped.x.toFixed(3)}, {$cursorPositionOnScreen.mapped.y.toFixed(
3,
)})
</span>
<span class="font-mono text-xs badge py-3 flex items-center gap-2">
{#if presenceStatus?.graphicsB64}
<img
src={`data:image/png;base64,${presenceStatus.graphicsB64}`}
alt="Active app icon"
class="size-4"
/>
{/if}
{presenceStatus?.title}
</span>
{#if Object.keys($friendsCursorPositions).length > 0}
<div class="flex flex-col gap-2">
<div>
{#each Object.entries($friendsCursorPositions) as [userId, position]}
{@const status = getFriendStatus(userId)}
<div class="badge py-3 text-xs text-left flex flex-row gap-2">
<span class="font-bold">{getFriendById(userId)?.name}</span>
<div class="flex flex-row font-mono gap-2">
<span>
({position.mapped.x.toFixed(3)}, {position.mapped.y.toFixed(
3,
)})
</span>
{#if status}
<span class="flex items-center gap-1">
{status.state} in
{#if status.presenceStatus.graphicsB64}
<img
src={`data:image/png;base64,${status.presenceStatus.graphicsB64}`}
alt="Friend's active app icon"
class="size-4"
/>
{/if}
{status.presenceStatus.title ||
status.presenceStatus.subtitle}
</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<div class="absolute inset-0 size-full">
{#if Object.keys($friendsCursorPositions).length > 0}
{#each Object.entries($friendsCursorPositions) as [userId, position]}
{@const doll = getFriendDoll(userId)}
{@const friend = getFriendById(userId)}
{#if doll && friend}
<DesktopPet
id={userId}
targetX={position.mapped.x * innerWidth}
targetY={position.mapped.y * innerHeight}
user={friend}
userStatus={getFriendStatus(userId)}
{doll}
<DebugBar
{isInteractive}
senderDoll={getUserDoll()}
/>
{/if}
{/each}
{/if}
{#if $appData?.user && getUserDoll()}
<DesktopPet
id={$appData.user.id}
targetX={$cursorPositionOnScreen.mapped.x * innerWidth}
targetY={$cursorPositionOnScreen.mapped.y * innerHeight}
user={{
id: $appData.user.id,
name: $appData.user.name,
username: $appData.user.username,
activeDoll: getUserDoll() ?? null,
}}
userStatus={presenceStatus
? { presenceStatus: presenceStatus, state: "idle" }
: undefined}
doll={getUserDoll()}
isInteractive={false}
/>
{/if}
</div>
<FullscreenModal
bind:visible={showFullscreenModal}
imageSrc={fullscreenImageSrc}
senderSpriteUrl={headpatSenderSpriteUrl}
senderName={getHeadpatSenderName(headpatSenderId)}
cursorPosition={$cursorPositionOnScreen}
{presenceStatus}
friendsCursorPositions={$friendsCursorPositions}
friends={$appData?.friends ?? []}
friendsUserStatuses={$friendsUserStatuses}
/>
</div>
</div>

View File

@@ -1,204 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { usePetState } from "$lib/composables/usePetState";
import { getSpriteSheetUrl } from "$lib/utils/sprite-utils";
import PetSprite from "$lib/components/PetSprite.svelte";
import onekoGif from "../../../assets/oneko/oneko.gif";
import {
receivedInteractions,
clearInteraction,
} from "$lib/stores/interaction-store";
import { INTERACTION_TYPE_HEADPAT } from "$lib/constants/interaction";
import PetMenu from "./PetMenu.svelte";
import type { DollDto } from "../../../types/bindings/DollDto";
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;
export let targetY = 0;
export let user: UserBasicDto;
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,
32,
);
let animationFrameId: number;
let lastFrameTimestamp: number;
let spriteSheetUrl = onekoGif;
let isPetMenuOpen = false;
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 !== receivedInteraction) {
receivedInteraction = interaction;
if (interaction.type !== INTERACTION_TYPE_HEADPAT) {
isPetMenuOpen = true;
// Make scene interactive so user can see it
invoke("set_scene_interactive", {
interactive: true,
shouldClick: false,
});
// Clear existing timer if any
if (messageTimer) clearTimeout(messageTimer);
// Auto-close and clear after 8 seconds
messageTimer = setTimeout(() => {
isPetMenuOpen = false;
receivedInteraction = undefined;
clearInteraction(user.id);
}, 8000);
}
}
}
// Watch for color changes to regenerate sprite
$: updateSprite(
doll?.configuration.colorScheme.body,
doll?.configuration.colorScheme.outline,
);
$: if (!receivedInteraction && !isInteractive) {
isPetMenuOpen = false;
}
$: {
if (id) {
console.log(`Setting pet menu state for ${id}: ${isPetMenuOpen}`);
invoke("set_pet_menu_state", { id, open: isPetMenuOpen });
}
}
async function updateSprite(
body: string | undefined,
outline: string | undefined,
) {
if (body && outline) {
spriteSheetUrl = await getSpriteSheetUrl({
bodyColor: body,
outlineColor: outline,
});
} else {
spriteSheetUrl = onekoGif;
}
}
function frame(timestamp: number) {
if (!lastFrameTimestamp) {
lastFrameTimestamp = timestamp;
}
// 100ms per frame for the animation loop
if (timestamp - lastFrameTimestamp > 100) {
lastFrameTimestamp = timestamp;
if (!isPetMenuOpen) {
updatePosition(targetX, targetY, window.innerWidth, window.innerHeight);
}
}
animationFrameId = requestAnimationFrame(frame);
}
onMount(() => {
// Initialize position to target so it doesn't fly in from 32,32 every time
setPosition(targetX, targetY);
animationFrameId = requestAnimationFrame(frame);
});
onDestroy(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
if (messageTimer) {
clearTimeout(messageTimer);
}
});
</script>
<div
class="desktop-pet flex flex-col items-center relative"
style="
transform: translate({$position.x - 16}px, {$position.y - 16}px);
z-index: 50;
"
>
{#if isPetMenuOpen}
<div
class="z-10 absolute -translate-y-30 w-50 h-28 *:size-full shadow-md rounded"
role="menu"
tabindex="0"
aria-label="Pet Menu"
>
{#if doll}
<PetMenu
{doll}
{user}
{userStatus}
{receivedInteraction}
{senderDoll}
/>
{/if}
</div>
{/if}
{#if userStatus}
<div class="absolute -top-5 left-0 right-0 w-max mx-auto">
{#if userStatus.presenceStatus.graphicsB64}
<img
src={`data:image/png;base64,${userStatus.presenceStatus.graphicsB64}`}
alt="Friend's active app icon"
class="size-4"
/>
{/if}
</div>
{/if}
<button
disabled={!isInteractive}
onclick={() => {
if (!isInteractive) return;
isPetMenuOpen = !isPetMenuOpen;
if (!isPetMenuOpen) {
// Clear message when closing menu manually
receivedInteraction = undefined;
clearInteraction(user.id);
if (messageTimer) clearTimeout(messageTimer);
}
}}
>
<PetSprite
{spriteSheetUrl}
spriteX={$currentSprite.x}
spriteY={$currentSprite.y}
/>
</button>
<span
class="absolute -bottom-5 width-full text-[10px] bg-black/50 text-white px-1 rounded backdrop-blur-sm mt-1 whitespace-nowrap opacity-0 transition-opacity"
class:opacity-100={isInteractive && !isPetMenuOpen}
>
{doll?.name}
</span>
</div>
<style>
.desktop-pet {
position: fixed; /* Fixed relative to the viewport/container */
top: 0;
left: 0;
will-change: transform;
}
</style>

View File

@@ -1,66 +0,0 @@
<script lang="ts">
import { cubicOut } from "svelte/easing";
import { type TransitionConfig } from "svelte/transition";
import PetSprite from "$lib/components/PetSprite.svelte";
import { SPRITE_SETS, SPRITE_SIZE } from "$lib/constants/pet-sprites";
function fadeSlide(
node: HTMLElement,
params: { duration: number }
): TransitionConfig {
const opacity = parseFloat(getComputedStyle(node).opacity);
return {
duration: params.duration,
easing: cubicOut,
css: (t) => `opacity: ${t * opacity}; transform: translateY(${(1 - t) * 20}px);`,
};
}
const idleSprite = {
x: SPRITE_SETS.idle[0][0] * SPRITE_SIZE,
y: SPRITE_SETS.idle[0][1] * SPRITE_SIZE,
};
let {
imageSrc,
senderSpriteUrl = "",
visible = $bindable(false),
senderName = "",
}: {
imageSrc: string;
senderSpriteUrl?: string;
visible: boolean;
senderName?: string;
} = $props();
</script>
{#if visible}
<div
class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-gradient-to-b from-transparent to-black/80"
transition:fadeSlide={{ duration: 300 }}
>
{#if senderName}
<div class="mb-4 text-white text-lg font-medium">{senderName} gave you a headpat!</div>
{/if}
<div class="flex items-center justify-center gap-6">
{#if senderSpriteUrl}
<div class="flex items-center justify-center size-32">
<div style="transform: scale(4); transform-origin: center;">
<PetSprite
spriteSheetUrl={senderSpriteUrl}
spriteX={idleSprite.x}
spriteY={idleSprite.y}
size={32}
/>
</div>
</div>
{/if}
<img
src={imageSrc}
alt="Headpat"
class="max-w-full max-h-full object-contain"
/>
</div>
</div>
{/if}

View File

@@ -1,124 +0,0 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { type DollDto } from "../../../types/bindings/DollDto";
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";
import { INTERACTION_TYPE_HEADPAT } from "$lib/constants/interaction";
export let doll: DollDto;
export let user: UserBasicDto;
export let userStatus: UserStatus | undefined = undefined;
export let receivedInteraction: InteractionPayloadDto | undefined = undefined;
export let senderDoll: DollDto | undefined = undefined;
let showMessageInput = false;
let messageContent = "";
async function sendMessage() {
if (!messageContent.trim()) return;
const dto: SendInteractionDto = {
recipientUserId: user.id,
content: messageContent,
type: "text",
};
try {
await invoke("send_interaction_cmd", { dto });
messageContent = "";
showMessageInput = false;
} catch (e) {
console.error("Failed to send interaction:", e);
alert("Failed to send message: " + e);
}
}
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();
} else if (event.key === "Escape") {
showMessageInput = false;
}
}
</script>
<div class="bg-base-100 border border-base-200 card p-1 min-w-[150px]">
<div class="flex flex-col gap-1">
<div class="flex flex-row w-full items-end gap-1">
<p class="text-sm font-semibold">{doll.name}</p>
<p class="text-[0.6rem] opacity-50">From {user.name}</p>
</div>
{#if userStatus}
<div class="card bg-base-200 px-2 py-1 flex flex-row gap-2 items-center">
{#if userStatus.presenceStatus.graphicsB64}
<img
src={`data:image/png;base64,${userStatus.presenceStatus.graphicsB64}`}
alt="Friend's active app icon"
class="size-3"
/>
{/if}
<p class="text-[0.6rem] font-mono text-ellipsis line-clamp-1">
{userStatus.presenceStatus.title}
</p>
</div>
{/if}
{#if receivedInteraction && receivedInteraction.type !== INTERACTION_TYPE_HEADPAT}
<div class="">
<div class="text-sm max-w-[140px]">
{receivedInteraction.content}
</div>
</div>
{:else if showMessageInput}
<div class="flex flex-col gap-1">
<input
type="text"
bind:value={messageContent}
onkeydown={handleKeydown}
placeholder="Type message..."
class="input input-xs input-bordered w-full"
/>
<div class="flex gap-1">
<button class="btn btn-xs btn-primary flex-1" onclick={sendMessage}
>Send</button
>
<button
class="btn btn-xs flex-1"
onclick={() => (showMessageInput = false)}>Cancel</button
>
</div>
</div>
{:else}
<div class="flex flex-row gap-1 w-full *:flex-1 *:btn *:btn-sm">
<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">
<button disabled>Postcard</button>
<button disabled>Wormhole</button>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import type { PresenceStatus } from "../../../types/bindings/PresenceStatus";
import type { UserStatus } from "../../../events/user-status";
interface Friend {
friend?: {
id: string;
name: string;
} | null;
}
interface Props {
isInteractive: boolean;
cursorPosition: { mapped: { x: number; y: number } };
presenceStatus: PresenceStatus | null;
friendsCursorPositions: Record<string, { mapped: { x: number; y: number } }>;
friends: Friend[];
friendsUserStatuses: Record<string, UserStatus>;
}
let {
isInteractive,
cursorPosition,
presenceStatus,
friendsCursorPositions,
friends,
friendsUserStatuses,
}: Props = $props();
function getFriendById(userId: string) {
const friend = friends.find((f) => f.friend?.id === userId);
return friend?.friend;
}
function getFriendStatus(userId: string) {
return friendsUserStatuses[userId];
}
</script>
<div
class="size-max mx-auto bg-base-100 border-base-200 border p-1 rounded-lg shadow-md"
>
<div class="flex flex-row gap-1 items-center text-center">
<div>
<span class="py-3 text-xs items-center gap-2 badge">
<span
class={`size-2 rounded-full ${isInteractive ? "bg-success" : "bg-base-300"}`}
></span>
Interactive
</span>
</div>
<span class="font-mono text-xs badge py-3">
{cursorPosition.mapped.x.toFixed(3)}, {cursorPosition.mapped.y.toFixed(3)}
</span>
{#if presenceStatus}
<span class="font-mono text-xs badge py-3 flex items-center gap-2">
{#if presenceStatus.graphicsB64}
<img
src={`data:image/png;base64,${presenceStatus.graphicsB64}`}
alt="Active app icon"
class="size-4"
/>
{/if}
{presenceStatus.title}
</span>
{/if}
{#if Object.keys(friendsCursorPositions).length > 0}
<div class="flex flex-col gap-2">
<div>
{#each Object.entries(friendsCursorPositions) as [userId, position]}
{@const status = getFriendStatus(userId)}
<div class="badge py-3 text-xs text-left flex flex-row gap-2">
<span class="font-bold">{getFriendById(userId)?.name}</span>
<div class="flex flex-row font-mono gap-2">
<span>
{position.mapped.x.toFixed(3)}, {position.mapped.y.toFixed(3)}
</span>
{#if status}
<span class="flex items-center gap-1">
{status.state} in
{#if status.presenceStatus.graphicsB64}
<img
src={`data:image/png;base64,${status.presenceStatus.graphicsB64}`}
alt="Friend's active app icon"
class="size-4"
/>
{/if}
{status.presenceStatus.title ||
status.presenceStatus.subtitle}
</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>