Files
HaBit/components/pet/animated-pet-sprite.tsx
Harkamal Randhawa 19b0ece147 Improve pet habitat UX with ground-aligned walk, floating rename, and demo points.
Anchor room floors on resize, constrain wandering to a ground zone, add an editable floating name badge on the avatar screen, and let profile demo tools grant 100 test points.
2026-05-24 09:47:48 -06:00

151 lines
3.7 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
import { PetEmotionBubble } from "@/components/pet/pet-emotion-bubble";
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import type { AvatarMood } from "@/lib/avatar-state";
import {
PET_WANDER_BOUNDS,
PET_WANDER_START,
} from "@/lib/pet-wander-bounds";
import { cn } from "@/lib/utils";
type AnimatedPetSpriteProps = {
customization: AvatarCustomization;
className?: string;
petScale?: number;
interactive?: boolean;
onInteract?: () => void;
emotionVisible?: boolean;
mood?: AvatarMood;
};
type WanderPosition = {
x: number;
y: number;
facing: "left" | "right";
};
const { minX: MIN_X, maxX: MAX_X, minY: MIN_Y, maxY: MAX_Y } =
PET_WANDER_BOUNDS;
function randomPosition(current?: WanderPosition): WanderPosition {
let next: WanderPosition = {
x: MIN_X + Math.random() * (MAX_X - MIN_X),
y: MIN_Y + Math.random() * (MAX_Y - MIN_Y),
facing: Math.random() > 0.5 ? "right" : "left",
};
if (current) {
let attempts = 0;
while (
attempts < 6 &&
Math.hypot(next.x - current.x, next.y - current.y) < 8
) {
next = {
x: MIN_X + Math.random() * (MAX_X - MIN_X),
y: MIN_Y + Math.random() * (MAX_Y - MIN_Y),
facing: next.x >= current.x ? "right" : "left",
};
attempts += 1;
}
}
return next;
}
export function AnimatedPetSprite({
customization,
className,
petScale = 7,
interactive = false,
onInteract,
emotionVisible = false,
mood = "neutral",
}: AnimatedPetSpriteProps) {
const [position, setPosition] = useState<WanderPosition>({
x: PET_WANDER_START.x,
y: PET_WANDER_START.y,
facing: "right",
});
useEffect(() => {
let timeoutId: number | undefined;
const scheduleWander = () => {
const delay = 1800 + Math.random() * 3200;
timeoutId = window.setTimeout(() => {
setPosition((current) => randomPosition(current));
scheduleWander();
}, delay);
};
scheduleWander();
return () => {
if (timeoutId) {
window.clearTimeout(timeoutId);
}
};
}, []);
const handleActivate = () => {
if (!interactive) {
return;
}
onInteract?.();
};
return (
<div
className={cn(
"absolute z-10",
interactive
? "pointer-events-auto cursor-pointer"
: "pointer-events-none",
className,
)}
style={{
left: `${position.x}%`,
bottom: `${position.y}%`,
transform: "translateX(-50%)",
transition: "left 1.8s steps(10), bottom 1.8s steps(10)",
}}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
aria-label={interactive ? "Check how your bit is feeling" : undefined}
onClick={(event) => {
event.stopPropagation();
handleActivate();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
handleActivate();
}
}}
>
<PetEmotionBubble mood={mood} visible={emotionVisible} />
<div
style={{
transform: position.facing === "left" ? "scaleX(-1)" : undefined,
}}
>
<div className="animate-pet-breathe origin-bottom">
<CharacterLayerPreview
colors={customization.colors}
variants={customization.variants}
equippedItems={customization.equippedItems}
scale={petScale}
compact
className="!h-auto max-h-none"
/>
</div>
</div>
</div>
);
}