Scale landing pet preview for small viewports.

ResizeObserver on the landing LCD drives pet sprite scale while capping LCD height so the hero preview stays proportional on mobile.
This commit is contained in:
2026-05-24 05:15:37 -06:00
parent 0b309fbc24
commit 2b76e7f34c
4 changed files with 62 additions and 6 deletions

View File

@@ -116,6 +116,12 @@
max-width: calc(32rem - 2.5rem - 2rem); max-width: calc(32rem - 2.5rem - 2rem);
} }
/* Landing hero preview: cap LCD height on narrow viewports */
.tamagotchi-lcd-landing {
@apply aspect-[4/3] w-full shrink-0;
max-height: clamp(140px, 42vw, 280px);
}
.pet-viewport-stage { .pet-viewport-stage {
@apply flex min-h-0 w-full flex-1 flex-col items-center justify-center; @apply flex min-h-0 w-full flex-1 flex-col items-center justify-center;
} }

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { PetHabitat } from "@/components/pet/pet-habitat"; import { PetHabitat } from "@/components/pet/pet-habitat";
import { import {
@@ -23,18 +23,49 @@ function LandingPetPreviewSkeleton() {
<div className="h-4 w-24 animate-pulse rounded bg-muted" /> <div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-2 w-16 animate-pulse rounded bg-muted" /> <div className="h-2 w-16 animate-pulse rounded bg-muted" />
</div> </div>
<div className="tamagotchi-lcd aspect-[4/3] w-full animate-pulse bg-muted/40" /> <div className="tamagotchi-lcd tamagotchi-lcd-landing animate-pulse bg-muted/40" />
</div> </div>
); );
} }
function computeLandingPetScale(lcdWidth: number): number {
return Math.min(7, Math.max(4, Math.round(lcdWidth / 36)));
}
export function LandingPetPreview() { export function LandingPetPreview() {
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSignedIn, setIsSignedIn] = useState(false); const [isSignedIn, setIsSignedIn] = useState(false);
const [petScale, setPetScale] = useState(7);
const [customization, setCustomization] = useState<AvatarCustomization>( const [customization, setCustomization] = useState<AvatarCustomization>(
defaultAvatarCustomization, defaultAvatarCustomization,
); );
const lcdRef = useCallback((node: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
if (!node) {
return;
}
const updateScale = (width: number) => {
setPetScale(computeLandingPetScale(width));
};
updateScale(node.getBoundingClientRect().width);
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
updateScale(entry.contentRect.width);
}
});
observer.observe(node);
resizeObserverRef.current = observer;
}, []);
const loadPreview = useCallback(async () => { const loadPreview = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -65,6 +96,12 @@ export function LandingPetPreview() {
void loadPreview(); void loadPreview();
}, [loadPreview]); }, [loadPreview]);
useEffect(() => {
return () => {
resizeObserverRef.current?.disconnect();
};
}, []);
if (isLoading) { if (isLoading) {
return <LandingPetPreviewSkeleton />; return <LandingPetPreviewSkeleton />;
} }
@@ -74,6 +111,9 @@ export function LandingPetPreview() {
customization={customization} customization={customization}
className="w-full max-w-sm" className="w-full max-w-sm"
showStyleLink={false} showStyleLink={false}
petScale={petScale}
lcdClassName="tamagotchi-lcd-landing"
lcdRef={lcdRef}
/> />
); );

View File

@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
type AnimatedPetSpriteProps = { type AnimatedPetSpriteProps = {
customization: AvatarCustomization; customization: AvatarCustomization;
className?: string; className?: string;
petScale?: number;
}; };
type WanderPosition = { type WanderPosition = {
@@ -52,6 +53,7 @@ function randomPosition(current?: WanderPosition): WanderPosition {
export function AnimatedPetSprite({ export function AnimatedPetSprite({
customization, customization,
className, className,
petScale = 7,
}: AnimatedPetSpriteProps) { }: AnimatedPetSpriteProps) {
const [position, setPosition] = useState<WanderPosition>({ const [position, setPosition] = useState<WanderPosition>({
x: PET_WANDER_START.x, x: PET_WANDER_START.x,
@@ -98,7 +100,7 @@ export function AnimatedPetSprite({
colors={customization.colors} colors={customization.colors}
variants={customization.variants} variants={customization.variants}
equippedItems={customization.equippedItems} equippedItems={customization.equippedItems}
scale={7} scale={petScale}
compact compact
className="!h-auto max-h-none" className="!h-auto max-h-none"
/> />

View File

@@ -23,6 +23,9 @@ type PetHabitatProps = {
className?: string; className?: string;
fillViewport?: boolean; fillViewport?: boolean;
showStyleLink?: boolean; showStyleLink?: boolean;
petScale?: number;
lcdClassName?: string;
lcdRef?: React.Ref<HTMLDivElement>;
}; };
const MOOD_LABELS = { const MOOD_LABELS = {
@@ -36,6 +39,9 @@ export function PetHabitat({
className, className,
fillViewport = false, fillViewport = false,
showStyleLink = true, showStyleLink = true,
petScale,
lcdClassName: lcdClassNameOverride,
lcdRef,
}: PetHabitatProps) { }: PetHabitatProps) {
const [condition, setCondition] = useState<AvatarCondition>( const [condition, setCondition] = useState<AvatarCondition>(
defaultAvatarCondition, defaultAvatarCondition,
@@ -60,7 +66,9 @@ export function PetHabitat({
const lcdClassName = cn( const lcdClassName = cn(
"tamagotchi-lcd relative", "tamagotchi-lcd relative",
fillViewport ? "tamagotchi-lcd-pet-fill" : "tamagotchi-lcd-pet-match", fillViewport
? "tamagotchi-lcd-pet-fill"
: (lcdClassNameOverride ?? "tamagotchi-lcd-pet-match"),
); );
if (!isReady) { if (!isReady) {
@@ -82,9 +90,9 @@ export function PetHabitat({
</p> </p>
</div> </div>
<div className={lcdClassName}> <div ref={lcdRef} className={lcdClassName}>
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} /> <ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
<AnimatedPetSprite customization={customization} /> <AnimatedPetSprite customization={customization} petScale={petScale} />
<div className="pointer-events-none absolute left-2 top-2 z-20 grid gap-1.5"> <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="heart" value={condition.health} variant="overlay" />
<PixelGauge type="energy" value={condition.energy} variant="overlay" /> <PixelGauge type="energy" value={condition.energy} variant="overlay" />