From 19b0ece147ddbed3b685b28d0b3e0ff65af27b2e Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Sun, 24 May 2026 09:33:08 -0600 Subject: [PATCH] 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. --- components/avatar/avatar-page-content.tsx | 11 +- components/pet/animated-pet-sprite.tsx | 6 +- components/pet/editable-pet-name.tsx | 239 ++++++++++++++++++ components/pet/parallax-room-background.tsx | 2 +- components/pet/pet-ground-zone.tsx | 20 ++ components/pet/pet-habitat.tsx | 42 ++- .../profile/profile-preferences-form.tsx | 45 +++- lib/coins.ts | 3 + lib/pet-wander-bounds.ts | 13 +- 9 files changed, 357 insertions(+), 24 deletions(-) create mode 100644 components/pet/editable-pet-name.tsx create mode 100644 components/pet/pet-ground-zone.tsx diff --git a/components/avatar/avatar-page-content.tsx b/components/avatar/avatar-page-content.tsx index a61afa8..64c24d7 100644 --- a/components/avatar/avatar-page-content.tsx +++ b/components/avatar/avatar-page-content.tsx @@ -50,7 +50,16 @@ export function AvatarPageContent() { return (
- + + setCustomization((current) => + current ? { ...current, name } : current, + ) + } + />
diff --git a/components/pet/animated-pet-sprite.tsx b/components/pet/animated-pet-sprite.tsx index c31c551..01eb24b 100644 --- a/components/pet/animated-pet-sprite.tsx +++ b/components/pet/animated-pet-sprite.tsx @@ -109,9 +109,9 @@ export function AnimatedPetSprite({ )} style={{ left: `${position.x}%`, - top: `${position.y}%`, - transform: "translate(-50%, -100%)", - transition: "left 1.8s steps(10), top 1.8s steps(10)", + 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} diff --git a/components/pet/editable-pet-name.tsx b/components/pet/editable-pet-name.tsx new file mode 100644 index 0000000..daf3ba7 --- /dev/null +++ b/components/pet/editable-pet-name.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { Pencil } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { Input } from "@/components/ui/input"; +import { + DEFAULT_AVATAR_NAME, + saveAvatarName, +} from "@/lib/avatar-customization-storage"; +import { cn } from "@/lib/utils"; + +type EditablePetNameProps = { + name: string; + moodLabel?: string; + editable?: boolean; + onNameChange?: (name: string) => void; + /** Frame header above LCD, or floating badge inside the LCD viewport. */ + variant?: "frame" | "overlay"; + size?: "default" | "landing"; + className?: string; +}; + +export function EditablePetName({ + name, + moodLabel, + editable = true, + onNameChange, + variant = "frame", + size = "default", + className, +}: EditablePetNameProps) { + const [displayName, setDisplayName] = useState(name); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(name); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const isOverlay = variant === "overlay"; + + useEffect(() => { + setDisplayName(name); + if (!isEditing) { + setDraft(name); + } + }, [name, isEditing]); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [isEditing]); + + const beginEdit = () => { + if (!editable || isSaving) { + return; + } + + setDraft(displayName); + setError(null); + setIsEditing(true); + }; + + const cancelEdit = () => { + setDraft(displayName); + setError(null); + setIsEditing(false); + }; + + const saveName = async () => { + const trimmed = draft.trim() || DEFAULT_AVATAR_NAME; + + if (trimmed === displayName) { + setIsEditing(false); + return; + } + + setIsSaving(true); + setError(null); + + try { + const saved = await saveAvatarName(trimmed); + setDisplayName(saved); + onNameChange?.(saved); + setIsEditing(false); + } catch (saveError) { + setError( + saveError instanceof Error + ? saveError.message + : "Could not save bit name.", + ); + setDraft(displayName); + } finally { + setIsSaving(false); + } + }; + + const overlayNameClass = + size === "landing" + ? "lcd-readable-name lcd-readable-name-landing" + : cn( + "lcd-readable-name text-sm sm:text-base md:text-lg", + ); + + const editInput = ( +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void saveName(); + } + + if (event.key === "Escape") { + event.preventDefault(); + cancelEdit(); + } + }} + onBlur={() => { + void saveName(); + }} + onClick={(event) => event.stopPropagation()} + /> + {error ? ( +

+ {error} +

+ ) : null} +
+ ); + + const displayRow = editable ? ( +
+ {isOverlay ? ( + + {displayName} + + ) : ( + + )} + {isOverlay ? ( + + ) : null} +
+ ) : ( +

+ {displayName} +

+ ); + + return ( +
{ + if (isEditing) { + event.stopPropagation(); + } + }} + > + {isEditing ? editInput : displayRow} + + {!isOverlay && moodLabel ? ( +

+ {moodLabel} +

+ ) : null} +
+ ); +} diff --git a/components/pet/parallax-room-background.tsx b/components/pet/parallax-room-background.tsx index 0e6bf67..a38c540 100644 --- a/components/pet/parallax-room-background.tsx +++ b/components/pet/parallax-room-background.tsx @@ -26,7 +26,7 @@ export function ParallaxRoomBackground({ src={room.sceneImage} alt="" aria-hidden - className="absolute inset-0 size-full object-cover image-pixelated" + className="absolute inset-0 size-full object-cover object-bottom image-pixelated" />
); diff --git a/components/pet/pet-ground-zone.tsx b/components/pet/pet-ground-zone.tsx new file mode 100644 index 0000000..f349f8a --- /dev/null +++ b/components/pet/pet-ground-zone.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { PET_GROUND_ZONE_HEIGHT_RATIO } from "@/lib/pet-wander-bounds"; +import { cn } from "@/lib/utils"; + +type PetGroundZoneProps = { + children: React.ReactNode; + className?: string; +}; + +export function PetGroundZone({ children, className }: PetGroundZoneProps) { + return ( +
+ {children} +
+ ); +} diff --git a/components/pet/pet-habitat.tsx b/components/pet/pet-habitat.tsx index b8ed7c4..ff565d6 100644 --- a/components/pet/pet-habitat.tsx +++ b/components/pet/pet-habitat.tsx @@ -3,6 +3,8 @@ 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"; @@ -26,6 +28,8 @@ type PetHabitatProps = { 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; @@ -36,6 +40,8 @@ export function PetHabitat({ className, fillViewport = false, nameInLcd = false, + nameEditable = false, + onNameChange, petScale, lcdClassName: lcdClassNameOverride, lcdRef, @@ -104,8 +110,10 @@ export function PetHabitat({ ); const gaugeOverlaySize = fillViewport ? "responsive" : "md"; - const showNameAboveShell = !fillViewport && !nameInLcd; - const showNameInLcdOverlay = fillViewport || nameInLcd; + const showStaticNameAboveShell = !nameEditable && !fillViewport && !nameInLcd; + const showFloatingEditableName = nameEditable && (fillViewport || !nameInLcd); + const showReadOnlyLcdName = + !nameEditable && (fillViewport || nameInLcd); if (!isReady) { return ( @@ -119,7 +127,7 @@ export function PetHabitat({ return (
- {showNameAboveShell ? ( + {showStaticNameAboveShell ? (

{customization.name}

@@ -127,14 +135,16 @@ export function PetHabitat({
- + + +
- {showNameInLcdOverlay ? ( + {showFloatingEditableName ? ( + + ) : null} + {showReadOnlyLcdName ? (

(null); const [isResettingQuiz, setIsResettingQuiz] = useState(false); + const [coinsGrantMessage, setCoinsGrantMessage] = useState(null); + const [isGrantingCoins, setIsGrantingCoins] = useState(false); useEffect(() => { setNotificationPermission(getNotificationPermissionState()); @@ -212,6 +217,28 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) { ); }; + const handleGrantDemoCoins = async () => { + setCoinsGrantMessage(null); + setError(null); + setIsGrantingCoins(true); + + try { + const nextBalance = await adjustCoins(DEMO_COINS_GRANT); + notifyHabitPetDataUpdated(); + setCoinsGrantMessage( + `Added ${DEMO_COINS_GRANT} points. Your balance is now ${nextBalance}.`, + ); + } catch (grantError) { + setError( + grantError instanceof Error + ? grantError.message + : "Could not add demo points.", + ); + } finally { + setIsGrantingCoins(false); + } + }; + const handleResetDailyQuiz = async () => { setQuizResetMessage(null); setError(null); @@ -399,8 +426,22 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {

Reset clears today's wellness quiz entry so you can complete it - again immediately. + again immediately. Demo points are for testing the shop only.

+ + {coinsGrantMessage ? ( +

{coinsGrantMessage}

+ ) : null}