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.
188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
import { AnimatedPetSprite } from "@/components/pet/animated-pet-sprite";
|
|
import { EditablePetName } from "@/components/pet/editable-pet-name";
|
|
import { PetGroundZone } from "@/components/pet/pet-ground-zone";
|
|
import { PetEmotionCaption } from "@/components/pet/pet-emotion-caption";
|
|
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
|
|
import { PixelGauge } from "@/components/pet/pixel-gauge";
|
|
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
|
import {
|
|
defaultAvatarCondition,
|
|
type AvatarCondition,
|
|
} from "@/lib/avatar-state";
|
|
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
|
|
import { getAvatarConditionForToday } from "@/lib/daily-quiz-storage";
|
|
import { PET_EMOTION_DISPLAY_MS } from "@/lib/pet-status";
|
|
import {
|
|
normalizeRoomBackgroundId,
|
|
type RoomBackgroundId,
|
|
} from "@/lib/room-backgrounds";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type PetHabitatProps = {
|
|
customization: AvatarCustomization;
|
|
className?: string;
|
|
fillViewport?: boolean;
|
|
/** Bit name in the LCD top-right (landing preview). */
|
|
nameInLcd?: boolean;
|
|
nameEditable?: boolean;
|
|
onNameChange?: (name: string) => void;
|
|
petScale?: number;
|
|
lcdClassName?: string;
|
|
lcdRef?: React.Ref<HTMLDivElement>;
|
|
};
|
|
|
|
export function PetHabitat({
|
|
customization,
|
|
className,
|
|
fillViewport = false,
|
|
nameInLcd = false,
|
|
nameEditable = false,
|
|
onNameChange,
|
|
petScale,
|
|
lcdClassName: lcdClassNameOverride,
|
|
lcdRef,
|
|
}: PetHabitatProps) {
|
|
const [condition, setCondition] = useState<AvatarCondition>(
|
|
defaultAvatarCondition,
|
|
);
|
|
const [isReady, setIsReady] = useState(false);
|
|
const [emotionVisible, setEmotionVisible] = useState(false);
|
|
const emotionTimeoutRef = useRef<number | null>(null);
|
|
const roomId = normalizeRoomBackgroundId(customization.roomBackground);
|
|
const interactive = fillViewport;
|
|
|
|
const clearEmotionTimeout = useCallback(() => {
|
|
if (emotionTimeoutRef.current !== null) {
|
|
window.clearTimeout(emotionTimeoutRef.current);
|
|
emotionTimeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const showEmotionBriefly = useCallback(() => {
|
|
clearEmotionTimeout();
|
|
setEmotionVisible(true);
|
|
emotionTimeoutRef.current = window.setTimeout(() => {
|
|
setEmotionVisible(false);
|
|
emotionTimeoutRef.current = null;
|
|
}, PET_EMOTION_DISPLAY_MS);
|
|
}, [clearEmotionTimeout]);
|
|
|
|
const loadStatus = useCallback(async () => {
|
|
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
|
|
setIsReady(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadStatus();
|
|
|
|
return () => {
|
|
clearEmotionTimeout();
|
|
};
|
|
}, [loadStatus, clearEmotionTimeout]);
|
|
|
|
useEffect(() => {
|
|
const handleDataUpdated = () => {
|
|
void loadStatus();
|
|
};
|
|
|
|
window.addEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
|
|
|
|
return () => {
|
|
window.removeEventListener(HABIT_PET_DATA_UPDATED_EVENT, handleDataUpdated);
|
|
};
|
|
}, [loadStatus]);
|
|
|
|
const shellClassName = cn(
|
|
"tamagotchi-shell overflow-hidden p-3 sm:p-4",
|
|
fillViewport && "tamagotchi-shell-fill",
|
|
className,
|
|
);
|
|
|
|
const lcdClassName = cn(
|
|
"tamagotchi-lcd relative",
|
|
fillViewport
|
|
? "tamagotchi-lcd-pet-fill"
|
|
: (lcdClassNameOverride ?? "tamagotchi-lcd-pet-match"),
|
|
);
|
|
|
|
const gaugeOverlaySize = fillViewport ? "responsive" : "md";
|
|
const showStaticNameAboveShell = !nameEditable && !fillViewport && !nameInLcd;
|
|
const showFloatingEditableName = nameEditable && (fillViewport || !nameInLcd);
|
|
const showReadOnlyLcdName =
|
|
!nameEditable && (fillViewport || nameInLcd);
|
|
|
|
if (!isReady) {
|
|
return (
|
|
<div className={shellClassName}>
|
|
<p className="text-center text-[10px] text-muted-foreground">
|
|
Waking up your bit...
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={shellClassName}>
|
|
{showStaticNameAboveShell ? (
|
|
<div className="mb-3 shrink-0 border-b-2 border-border/60 pb-3">
|
|
<p className="text-sm text-foreground">{customization.name}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<div ref={lcdRef} className={lcdClassName}>
|
|
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
|
|
<PetGroundZone>
|
|
<AnimatedPetSprite
|
|
customization={customization}
|
|
petScale={petScale}
|
|
interactive={interactive}
|
|
emotionVisible={emotionVisible}
|
|
mood={condition.mood}
|
|
onInteract={showEmotionBriefly}
|
|
/>
|
|
</PetGroundZone>
|
|
<PetEmotionCaption mood={condition.mood} visible={emotionVisible} />
|
|
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1 sm:gap-1.5">
|
|
<PixelGauge
|
|
type="heart"
|
|
value={condition.health}
|
|
variant="overlay"
|
|
overlaySize={gaugeOverlaySize}
|
|
/>
|
|
<PixelGauge
|
|
type="energy"
|
|
value={condition.energy}
|
|
variant="overlay"
|
|
overlaySize={gaugeOverlaySize}
|
|
/>
|
|
</div>
|
|
{showFloatingEditableName ? (
|
|
<EditablePetName
|
|
name={customization.name}
|
|
onNameChange={onNameChange}
|
|
variant="overlay"
|
|
size={fillViewport ? "default" : "landing"}
|
|
/>
|
|
) : null}
|
|
{showReadOnlyLcdName ? (
|
|
<p
|
|
className={cn(
|
|
"pointer-events-none absolute right-2 top-2 z-20 max-w-[52%] truncate text-right lcd-readable-name",
|
|
fillViewport
|
|
? "text-sm sm:text-base md:text-lg"
|
|
: "lcd-readable-name-landing",
|
|
)}
|
|
>
|
|
{customization.name}
|
|
</p>
|
|
) : null}
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[2] h-8 bg-gradient-to-t from-black/35 to-transparent" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|