Improvements to the UI

This commit is contained in:
pricewowen
2026-05-24 08:49:37 -06:00
parent d35b4e57d9
commit ec1bfa3fbe
60 changed files with 669 additions and 391 deletions

View File

@@ -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,

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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

View File

@@ -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>
) : (