nuked svelte pets system, starting from scratch
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
cursorPositionOnScreen,
|
cursorPositionOnScreen,
|
||||||
friendsCursorPositions,
|
friendsCursorPositions,
|
||||||
friendsActiveDolls,
|
|
||||||
} from "../../events/cursor";
|
} from "../../events/cursor";
|
||||||
import { appData } from "../../events/app-data";
|
import { appData } from "../../events/app-data";
|
||||||
import { sceneInteractive } from "../../events/scene-interactive";
|
import { sceneInteractive } from "../../events/scene-interactive";
|
||||||
@@ -11,168 +10,17 @@
|
|||||||
type UserStatus,
|
type UserStatus,
|
||||||
} 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 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 { AppEvents } from "../../types/bindings/AppEventsConstants";
|
import { AppEvents } from "../../types/bindings/AppEventsConstants";
|
||||||
import { getSpriteSheetUrl } from "$lib/utils/sprite-utils";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { PresenceStatus } from "../../types/bindings/PresenceStatus";
|
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 innerWidth = $state(0);
|
||||||
let innerHeight = $state(0);
|
let innerHeight = $state(0);
|
||||||
|
|
||||||
let isInteractive = $derived($sceneInteractive);
|
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);
|
let presenceStatus: PresenceStatus | null = $state(null);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -203,114 +51,15 @@
|
|||||||
}}> </button
|
}}> </button
|
||||||
>
|
>
|
||||||
<div
|
<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">
|
<DebugBar
|
||||||
<div>
|
{isInteractive}
|
||||||
<span class="py-3 text-xs items-center gap-2 badge">
|
cursorPosition={$cursorPositionOnScreen}
|
||||||
<span
|
{presenceStatus}
|
||||||
class={`size-2 rounded-full ${isInteractive ? "bg-success" : "bg-base-300"}`}
|
friendsCursorPositions={$friendsCursorPositions}
|
||||||
></span>
|
friends={$appData?.friends ?? []}
|
||||||
Intercepting cursor events
|
friendsUserStatuses={$friendsUserStatuses}
|
||||||
</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>
|
||||||
|
|
||||||
<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>
|
</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