nuked svelte pets system, starting from scratch
This commit is contained in:
@@ -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 @@
|
||||
}}> </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>
|
||||
<DebugBar
|
||||
{isInteractive}
|
||||
cursorPosition={$cursorPositionOnScreen}
|
||||
{presenceStatus}
|
||||
friendsCursorPositions={$friendsCursorPositions}
|
||||
friends={$appData?.friends ?? []}
|
||||
friendsUserStatuses={$friendsUserStatuses}
|
||||
/>
|
||||
</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}
|
||||
{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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
102
src/routes/scene/components/debug-bar.svelte
Normal file
102
src/routes/scene/components/debug-bar.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user