interaction system

This commit is contained in:
2026-01-13 12:55:25 +08:00
parent efb2a2e4d1
commit 354e362ac3
15 changed files with 381 additions and 12 deletions

33
src/events/interaction.ts Normal file
View File

@@ -0,0 +1,33 @@
import { listen } from "@tauri-apps/api/event";
import { addInteraction } from "$lib/stores/interaction-store";
import type { InteractionPayloadDto } from "../types/bindings/InteractionPayloadDto";
import type { InteractionDeliveryFailedDto } from "../types/bindings/InteractionDeliveryFailedDto";
let unlistenReceived: (() => void) | undefined;
let unlistenFailed: (() => void) | undefined;
export async function initInteractionListeners() {
unlistenReceived = await listen<InteractionPayloadDto>(
"interaction-received",
(event) => {
console.log("Received interaction:", event.payload);
addInteraction(event.payload);
},
);
unlistenFailed = await listen<InteractionDeliveryFailedDto>(
"interaction-delivery-failed",
(event) => {
console.error("Interaction delivery failed:", event.payload);
// You might want to show a toast or alert here
alert(
`Failed to send message to user ${event.payload.recipientUserId}: ${event.payload.reason}`,
);
},
);
}
export function stopInteractionListeners() {
if (unlistenReceived) unlistenReceived();
if (unlistenFailed) unlistenFailed();
}

View File

@@ -0,0 +1,23 @@
import { writable } from "svelte/store";
import type { InteractionPayloadDto } from "../../types/bindings/InteractionPayloadDto";
// Map senderUserId -> InteractionPayloadDto
export const receivedInteractions = writable<Map<string, InteractionPayloadDto>>(new Map());
export function addInteraction(interaction: InteractionPayloadDto) {
receivedInteractions.update((map) => {
// For now, we only store the latest message per user.
// In the future, we could store an array if we want a history.
const newMap = new Map(map);
newMap.set(interaction.senderUserId, interaction);
return newMap;
});
}
export function clearInteraction(userId: string) {
receivedInteractions.update((map) => {
const newMap = new Map(map);
newMap.delete(userId);
return newMap;
});
}

View File

@@ -3,6 +3,7 @@
import { onMount, onDestroy } from "svelte";
import { initCursorTracking, stopCursorTracking } from "../events/cursor";
import { initAppDataListener } from "../events/app-data";
import { initInteractionListeners, stopInteractionListeners } from "../events/interaction";
import {
initSceneInteractiveListener,
stopSceneInteractiveListener,
@@ -15,6 +16,7 @@
await initCursorTracking();
await initAppDataListener();
await initSceneInteractiveListener();
await initInteractionListeners();
} catch (err) {
console.error("Failed to initialize event listeners:", err);
}
@@ -23,6 +25,7 @@
onDestroy(() => {
stopCursorTracking();
stopSceneInteractiveListener();
stopInteractionListeners();
});
}
</script>

View File

@@ -5,6 +5,10 @@
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 PetMenu from "./PetMenu.svelte";
import type { DollDto } from "../../../types/bindings/DollDto";
import type { UserBasicDto } from "../../../types/bindings/UserBasicDto";
@@ -26,6 +30,40 @@
let spriteSheetUrl = onekoGif;
let isPetMenuOpen = false;
let receivedMessage: string | undefined = undefined;
let messageTimer: number | undefined = undefined;
// Watch for received interactions for this user
$: {
const interaction = $receivedInteractions.get(user.id);
if (interaction && interaction.content !== receivedMessage) {
console.log(`Received interaction for ${user.id}: ${interaction.content}`);
receivedMessage = interaction.content;
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;
receivedMessage = undefined;
clearInteraction(user.id);
// We probably shouldn't disable interactivity globally here as other pets might be active,
// but 'set_pet_menu_state' in backend handles the window transparency logic per pet/menu.
// However, we did explicitly call set_scene_interactive(true).
// 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;
}
}
// Watch for color changes to regenerate sprite
$: updateSprite(
@@ -33,10 +71,22 @@
doll?.configuration.colorScheme.outline,
);
$: (isInteractive, (isPetMenuOpen = false));
// This reactive statement forces the menu closed whenever `isInteractive` changes.
// This conflicts with our message logic because we explicitly set interactive=true when opening the menu for a message.
// We should remove this or condition it.
// The original intent was likely to close the menu if the user moves the mouse away (interactive becomes false),
// but `isInteractive` is driven by mouse hover usually.
// When we force it via invoke("set_scene_interactive", { interactive: true }), it might not reflect back into `isInteractive` prop immediately or correctly depending on how the parent passes it.
// Actually, `isInteractive` is a prop passed from +page.svelte probably based on hover state.
// If we want the menu to stay open during the message, we should probably ignore this auto-close behavior if a message is present.
$: if (!receivedMessage && !isInteractive) {
isPetMenuOpen = false;
}
$: {
if (id) {
console.log(`Setting pet menu state for ${id}: ${isPetMenuOpen}`);
invoke("set_pet_menu_state", { id, open: isPetMenuOpen });
}
}
@@ -99,13 +149,19 @@
aria-label="Pet Menu"
>
{#if doll}
<PetMenu {doll} {user} />
<PetMenu {doll} {user} {receivedMessage} />
{/if}
</div>
{/if}
<button
onclick={() => {
isPetMenuOpen = !isPetMenuOpen;
if (!isPetMenuOpen) {
// Clear message when closing menu manually
receivedMessage = undefined;
clearInteraction(user.id);
if (messageTimer) clearTimeout(messageTimer);
}
}}
>
<PetSprite

View File

@@ -1,23 +1,86 @@
<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";
export let doll: DollDto;
export let user: UserBasicDto;
export let receivedMessage: string | 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);
}
}
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">
<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>
<div class="flex flex-row gap-1 w-full *:flex-1 *:btn *:btn-sm">
<button>Headpat</button>
<button>Message</button>
</div>
<div class="flex flex-row gap-1 w-full *:flex-1 *:btn *:btn-sm">
<button>Postcard</button>
<button>Wormhole</button>
</div>
{#if receivedMessage}
<div class="">
<div class="text-sm max-w-[140px]">
{receivedMessage}
</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"
autofocus
/>
<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 disabled>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,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HealthResponseDto = { status: string, version: string, uptimeSecs: bigint, db: string, };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InteractionDeliveryFailedDto = { recipientUserId: string, reason: string, };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type InteractionPayloadDto = { senderUserId: string, senderName: string, content: string, type: string, timestamp: string, };

View File

@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SendInteractionDto = { recipientUserId: string, content: string, type: string, };