scaffolded pet menu
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
152
src/routes/scene/components/pet-menu.svelte
Normal file
152
src/routes/scene/components/pet-menu.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user