Improvements to the UI
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
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,
|
||||
@@ -14,6 +16,10 @@ type AnimatedPetSpriteProps = {
|
||||
customization: AvatarCustomization;
|
||||
className?: string;
|
||||
petScale?: number;
|
||||
interactive?: boolean;
|
||||
onInteract?: () => void;
|
||||
emotionVisible?: boolean;
|
||||
mood?: AvatarMood;
|
||||
};
|
||||
|
||||
type WanderPosition = {
|
||||
@@ -54,6 +60,10 @@ 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,
|
||||
@@ -80,16 +90,45 @@ export function AnimatedPetSprite({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleActivate = () => {
|
||||
if (!interactive) {
|
||||
return;
|
||||
}
|
||||
|
||||
onInteract?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("pointer-events-none absolute z-10", className)}
|
||||
className={cn(
|
||||
"absolute z-10",
|
||||
interactive
|
||||
? "pointer-events-auto cursor-pointer"
|
||||
: "pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
left: `${position.x}%`,
|
||||
top: `${position.y}%`,
|
||||
transform: "translate(-50%, -100%)",
|
||||
transition: "left 1.8s steps(10), top 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,
|
||||
|
||||
40
components/pet/pet-emotion-bubble.tsx
Normal file
40
components/pet/pet-emotion-bubble.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { AvatarMood } from "@/lib/avatar-state";
|
||||
import { getPetStatusImagePath } from "@/lib/pet-status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PetEmotionBubbleProps = {
|
||||
mood: AvatarMood;
|
||||
visible: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PetEmotionBubble({
|
||||
mood,
|
||||
visible,
|
||||
className,
|
||||
}: PetEmotionBubbleProps) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-full left-1/2 z-20 mb-1 w-[clamp(3.5rem,18vw,5.5rem)] -translate-x-1/2",
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="animate-emotion-pop">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPetStatusImagePath(mood)}
|
||||
alt=""
|
||||
className="h-auto w-full object-contain image-pixelated"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
components/pet/pet-emotion-caption.tsx
Normal file
35
components/pet/pet-emotion-caption.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { AvatarMood } from "@/lib/avatar-state";
|
||||
import { getPetEmotionMessage } from "@/lib/pet-status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PetEmotionCaptionProps = {
|
||||
mood: AvatarMood;
|
||||
visible: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PetEmotionCaption({
|
||||
mood,
|
||||
visible,
|
||||
className,
|
||||
}: PetEmotionCaptionProps) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-2 bottom-2 z-30 flex justify-center",
|
||||
"animate-emotion-pop",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p className="lcd-readable-label max-w-full">{getPetEmotionMessage(mood)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { AnimatedPetSprite } from "@/components/pet/animated-pet-sprite";
|
||||
import { PetEmotionCaption } from "@/components/pet/pet-emotion-caption";
|
||||
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
|
||||
import { PetStyleLink } from "@/components/pet/pet-style-link";
|
||||
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,
|
||||
@@ -22,23 +24,18 @@ type PetHabitatProps = {
|
||||
customization: AvatarCustomization;
|
||||
className?: string;
|
||||
fillViewport?: boolean;
|
||||
showStyleLink?: boolean;
|
||||
/** Bit name in the LCD top-right (landing preview). */
|
||||
nameInLcd?: boolean;
|
||||
petScale?: number;
|
||||
lcdClassName?: string;
|
||||
lcdRef?: React.Ref<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const MOOD_LABELS = {
|
||||
happy: "HAPPY",
|
||||
neutral: "OKAY",
|
||||
tired: "SLEEPY",
|
||||
} as const;
|
||||
|
||||
export function PetHabitat({
|
||||
customization,
|
||||
className,
|
||||
fillViewport = false,
|
||||
showStyleLink = true,
|
||||
nameInLcd = false,
|
||||
petScale,
|
||||
lcdClassName: lcdClassNameOverride,
|
||||
lcdRef,
|
||||
@@ -47,16 +44,51 @@ export function PetHabitat({
|
||||
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(() => {
|
||||
async function loadStatus() {
|
||||
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -71,11 +103,15 @@ export function PetHabitat({
|
||||
: (lcdClassNameOverride ?? "tamagotchi-lcd-pet-match"),
|
||||
);
|
||||
|
||||
const gaugeOverlaySize = fillViewport ? "responsive" : "md";
|
||||
const showNameAboveShell = !fillViewport && !nameInLcd;
|
||||
const showNameInLcdOverlay = fillViewport || nameInLcd;
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className={shellClassName}>
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
Waking up pet...
|
||||
Waking up your bit...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -83,24 +119,48 @@ export function PetHabitat({
|
||||
|
||||
return (
|
||||
<div className={shellClassName}>
|
||||
<div className="mb-3 shrink-0 border-b-2 border-border/60 pb-3">
|
||||
<p className="text-sm text-foreground">{customization.name}</p>
|
||||
<p className="mt-1 text-[9px] uppercase tracking-[0.25em] text-secondary">
|
||||
{MOOD_LABELS[condition.mood]}
|
||||
</p>
|
||||
</div>
|
||||
{showNameAboveShell ? (
|
||||
<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} />
|
||||
<AnimatedPetSprite customization={customization} petScale={petScale} />
|
||||
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1.5">
|
||||
<PixelGauge type="heart" value={condition.health} variant="overlay" />
|
||||
<PixelGauge type="energy" value={condition.energy} variant="overlay" />
|
||||
<AnimatedPetSprite
|
||||
customization={customization}
|
||||
petScale={petScale}
|
||||
interactive={interactive}
|
||||
emotionVisible={emotionVisible}
|
||||
mood={condition.mood}
|
||||
onInteract={showEmotionBriefly}
|
||||
/>
|
||||
<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>
|
||||
{showStyleLink ? (
|
||||
<div className="absolute right-2 top-2 z-20">
|
||||
<PetStyleLink />
|
||||
</div>
|
||||
{showNameInLcdOverlay ? (
|
||||
<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>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function PetStyleLink({ className }: PetStyleLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
href={routes.customize}
|
||||
aria-label="Customize pet style"
|
||||
aria-label="Customize bit style"
|
||||
title="Style"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center border-2 border-border bg-card/90 p-2 shadow-[var(--retro-shadow-sm)] transition-transform hover:-translate-y-0.5 hover:border-primary/60",
|
||||
|
||||
@@ -11,6 +11,8 @@ type PixelGaugeProps = {
|
||||
max?: number;
|
||||
className?: string;
|
||||
variant?: "default" | "overlay";
|
||||
/** Overlay icon size. Use "responsive" on the bit tab LCD. */
|
||||
overlaySize?: "md" | "lg" | "responsive";
|
||||
};
|
||||
|
||||
export function PixelGauge({
|
||||
@@ -19,6 +21,7 @@ export function PixelGauge({
|
||||
max = WELLNESS_SCALE_MAX,
|
||||
className,
|
||||
variant = "default",
|
||||
overlaySize = "md",
|
||||
}: PixelGaugeProps) {
|
||||
const filled = Math.max(0, Math.min(max, Math.round(value)));
|
||||
const Icon = type === "heart" ? Heart : Zap;
|
||||
@@ -29,6 +32,12 @@ export function PixelGauge({
|
||||
: "fill-amber-300 text-amber-300";
|
||||
const emptyClass = "text-muted-foreground/35";
|
||||
const isOverlay = variant === "overlay";
|
||||
const overlayIconClass =
|
||||
overlaySize === "responsive"
|
||||
? "h-[clamp(1.125rem,3.6vw,3rem)] w-[clamp(1.125rem,3.6vw,3rem)]"
|
||||
: overlaySize === "lg"
|
||||
? "size-12"
|
||||
: "size-6";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -44,13 +53,22 @@ export function PixelGauge({
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={cn("flex items-center", isOverlay ? "gap-1" : "gap-0.5")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
isOverlay
|
||||
? overlaySize === "responsive"
|
||||
? "gap-0.5 sm:gap-1"
|
||||
: "gap-1"
|
||||
: "gap-0.5",
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: max }, (_, index) => (
|
||||
<Icon
|
||||
key={`${type}-${index}`}
|
||||
className={cn(
|
||||
"shrink-0 stroke-[2.5]",
|
||||
isOverlay ? "size-6" : "size-4",
|
||||
isOverlay ? overlayIconClass : "size-4",
|
||||
index < filled ? activeClass : emptyClass,
|
||||
)}
|
||||
aria-hidden
|
||||
|
||||
@@ -32,7 +32,7 @@ export function RoomBackgroundPicker({
|
||||
Room background
|
||||
</h3>
|
||||
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||
Pick the scene behind your pet. Premium rooms unlock in the shop.
|
||||
Pick the scene behind your bit. Premium rooms unlock in the shop.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user