scaffolded pet menu

This commit is contained in:
2026-03-13 19:26:10 +08:00
parent 5eb25bd026
commit 475484abea
3 changed files with 226 additions and 5 deletions

View File

@@ -12,6 +12,48 @@
import { commands } from "$lib/bindings"; import { commands } from "$lib/bindings";
import DebugBar from "./components/debug-bar.svelte"; import DebugBar from "./components/debug-bar.svelte";
import Neko from "./components/neko/neko.svelte"; import Neko from "./components/neko/neko.svelte";
import PetMenu from "./components/pet-menu.svelte";
function createPetActions(name: string) {
// TODO: replace `name` with full user object, onClicks with proper actions
return [
{
icon: "👋",
label: `Wave at ${name}`,
onClick: () => {
console.log(`Wave at ${name}`);
},
},
{
icon: "💬",
label: `Message ${name}`,
onClick: () => {
console.log(`Message ${name}`);
},
},
{
icon: "🔔",
label: `Ping ${name}`,
onClick: () => {
console.log(`Ping ${name}`);
},
},
{
icon: "🔎",
label: `Inspect ${name}`,
onClick: () => {
console.log(`Inspect ${name}`);
},
},
];
}
function getFriendName(friendId: string) {
return (
($appData?.friends ?? []).find((friend) => friend.friend?.id === friendId)
?.friend?.name || friendId
);
}
</script> </script>
<div class="w-svw h-svh p-4 relative overflow-hidden"> <div class="w-svw h-svh p-4 relative overflow-hidden">
@@ -27,18 +69,33 @@
targetX={$cursorPositionOnScreen.raw.x} targetX={$cursorPositionOnScreen.raw.x}
targetY={$cursorPositionOnScreen.raw.y} targetY={$cursorPositionOnScreen.raw.y}
spriteUrl={$activeDollSpriteUrl} spriteUrl={$activeDollSpriteUrl}
>
{#if $sceneInteractive}
<PetMenu
actions={createPetActions("your doll")}
ariaLabel="Open your doll actions"
/> />
{/if} {/if}
</Neko>
{/if}
{#each Object.entries($friendsCursorPositions) as [friendId, position] (friendId)} {#each Object.entries($friendsCursorPositions) as [friendId, position] (friendId)}
{#if $friendActiveDollSpriteUrls[friendId]} {#if $friendActiveDollSpriteUrls[friendId]}
{@const friendName = getFriendName(friendId)}
<Neko <Neko
targetX={position.raw.x} targetX={position.raw.x}
targetY={position.raw.y} targetY={position.raw.y}
spriteUrl={$friendActiveDollSpriteUrls[friendId]} spriteUrl={$friendActiveDollSpriteUrls[friendId]}
initialX={position.raw.x} initialX={position.raw.x}
initialY={position.raw.y} initialY={position.raw.y}
>
{#if $sceneInteractive}
<PetMenu
actions={createPetActions(friendName)}
ariaLabel={`Open ${friendName} actions`}
/> />
{/if} {/if}
</Neko>
{/if}
{/each} {/each}
<div id="debug-bar"> <div id="debug-bar">
<DebugBar <DebugBar

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import type { Snippet } from "svelte";
import { setSprite } from "./sprites"; import { setSprite } from "./sprites";
import { calculateDirection, moveTowards, clampPosition } from "./physics"; import { calculateDirection, moveTowards, clampPosition } from "./physics";
import { updateIdle } from "./idle"; import { updateIdle } from "./idle";
@@ -10,10 +11,17 @@
spriteUrl: string; spriteUrl: string;
initialX?: number; initialX?: number;
initialY?: number; initialY?: number;
children?: Snippet;
} }
let { targetX, targetY, spriteUrl, initialX = 32, initialY = 32 }: Props = let {
$props(); targetX,
targetY,
spriteUrl,
initialX = 32,
initialY = 32,
children,
}: Props = $props();
let nekoEl: HTMLDivElement; let nekoEl: HTMLDivElement;
let animationFrameId: number; let animationFrameId: number;
@@ -103,4 +111,8 @@
bind:this={nekoEl} bind:this={nekoEl}
class="pointer-events-none fixed z-999 size-8 select-none" class="pointer-events-none fixed z-999 size-8 select-none"
style="width: 32px; height: 32px; position: fixed; image-rendering: pixelated;" style="width: 32px; height: 32px; position: fixed; image-rendering: pixelated;"
></div> >
<div class="relative size-full">
{@render children?.()}
</div>
</div>

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { sceneInteractive } from "../../../events/scene-interactive";
interface PetMenuAction {
icon: string;
label: string;
onClick?: () => void;
}
interface Props {
actions?: PetMenuAction[];
ariaLabel?: string;
}
let { actions = [], ariaLabel = "Toggle pet actions" }: Props = $props();
let rootEl = $state<HTMLDivElement | null>(null);
let isOpen = $state(false);
function closeMenu() {
isOpen = false;
}
function toggleMenu() {
if (!$sceneInteractive || actions.length === 0) {
closeMenu();
return;
}
isOpen = !isOpen;
}
function handleActionClick(action: PetMenuAction) {
if (!$sceneInteractive) {
return;
}
action.onClick?.();
closeMenu();
}
function getButtonPosition(index: number, total: number) {
if (total <= 1) {
return { x: 0, y: -48 };
}
const startAngle = -160;
const endAngle = -20;
const angle = startAngle + ((endAngle - startAngle) / (total - 1)) * index;
const angleRad = (angle * Math.PI) / 180;
const radius = 48;
return {
x: Math.cos(angleRad) * radius,
y: Math.sin(angleRad) * radius,
};
}
function handleDocumentPointerDown(event: PointerEvent) {
if (!isOpen || !rootEl) {
return;
}
if (event.target instanceof Node && !rootEl.contains(event.target)) {
closeMenu();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (!isOpen) {
return;
}
if (event.key === "Escape") {
closeMenu();
event.preventDefault();
}
}
$effect(() => {
if (!$sceneInteractive && isOpen) {
closeMenu();
}
});
$effect(() => {
if (isOpen) {
document.addEventListener("pointerdown", handleDocumentPointerDown, true);
document.addEventListener("keydown", handleKeyDown, true);
}
return () => {
document.removeEventListener("pointerdown", handleDocumentPointerDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
};
});
onDestroy(() => {
document.removeEventListener("pointerdown", handleDocumentPointerDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
});
</script>
<div
bind:this={rootEl}
class="pointer-events-auto absolute inset-0 overflow-visible"
>
{#each actions as action, index}
{@const position = getButtonPosition(index, actions.length)}
{@const openDelay = index * 35}
{@const closeDelay = (actions.length - 1 - index) * 25}
<button
type="button"
class={`absolute left-8 top-8 z-20 flex size-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-base-300/80 bg-base-100/95 text-base-content shadow-md backdrop-blur-sm transition-[opacity,transform] duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 ${
isOpen && $sceneInteractive
? "opacity-100 hover:cursor-pointer"
: "pointer-events-none opacity-0"
}`}
style={`transform: translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${isOpen && $sceneInteractive ? 1 : 0.72}); transition-delay: ${isOpen && $sceneInteractive ? openDelay : closeDelay}ms;`}
aria-label={action.label}
title={action.label}
onclick={() => {
handleActionClick(action);
}}
>
<span class="text-[11px] font-semibold leading-none">{action.icon}</span>
</button>
{/each}
<button
type="button"
class={`absolute inset-0 z-30 rounded-full transition-all duration-200 ease-out focus:outline-none ${
$sceneInteractive
? "cursor-pointer"
: "pointer-events-none cursor-default"
} ${isOpen ? "ring-0" : ""}`}
aria-expanded={isOpen}
aria-label={ariaLabel}
tabindex={$sceneInteractive ? 0 : -1}
onclick={toggleMenu}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toggleMenu();
e.preventDefault();
}
}}
>
<span class="sr-only">{ariaLabel}</span>
</button>
</div>