Stable version with additional styling
This commit is contained in:
107
components/pet/animated-pet-sprite.tsx
Normal file
107
components/pet/animated-pet-sprite.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
|
||||
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AnimatedPetSpriteProps = {
|
||||
customization: AvatarCustomization;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type WanderPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
facing: "left" | "right";
|
||||
};
|
||||
|
||||
const MIN_X = 24;
|
||||
const MAX_X = 72;
|
||||
const MIN_Y = 34;
|
||||
const MAX_Y = 62;
|
||||
|
||||
function randomPosition(current?: WanderPosition): WanderPosition {
|
||||
let next: WanderPosition = {
|
||||
x: MIN_X + Math.random() * (MAX_X - MIN_X),
|
||||
y: MIN_Y + Math.random() * (MAX_Y - MIN_Y),
|
||||
facing: Math.random() > 0.5 ? "right" : "left",
|
||||
};
|
||||
|
||||
if (current) {
|
||||
let attempts = 0;
|
||||
while (
|
||||
attempts < 6 &&
|
||||
Math.hypot(next.x - current.x, next.y - current.y) < 8
|
||||
) {
|
||||
next = {
|
||||
x: MIN_X + Math.random() * (MAX_X - MIN_X),
|
||||
y: MIN_Y + Math.random() * (MAX_Y - MIN_Y),
|
||||
facing: next.x >= current.x ? "right" : "left",
|
||||
};
|
||||
attempts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function AnimatedPetSprite({
|
||||
customization,
|
||||
className,
|
||||
}: AnimatedPetSpriteProps) {
|
||||
const [position, setPosition] = useState<WanderPosition>({
|
||||
x: 48,
|
||||
y: 48,
|
||||
facing: "right",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const scheduleWander = () => {
|
||||
const delay = 1800 + Math.random() * 3200;
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setPosition((current) => randomPosition(current));
|
||||
scheduleWander();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
scheduleWander();
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("pointer-events-none absolute z-10", className)}
|
||||
style={{
|
||||
left: `${position.x}%`,
|
||||
top: `${position.y}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
transition: "left 1.8s steps(10), top 1.8s steps(10)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: position.facing === "left" ? "scaleX(-1)" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="animate-pet-breathe origin-bottom">
|
||||
<CharacterLayerPreview
|
||||
colors={customization.colors}
|
||||
variants={customization.variants}
|
||||
equippedItems={customization.equippedItems}
|
||||
scale={7}
|
||||
compact
|
||||
className="!h-auto max-h-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/pet/parallax-room-background.tsx
Normal file
68
components/pet/parallax-room-background.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
getRoomBackground,
|
||||
type RoomBackgroundId,
|
||||
} from "@/lib/room-backgrounds";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ParallaxRoomBackgroundProps = {
|
||||
roomId: RoomBackgroundId;
|
||||
className?: string;
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
export function ParallaxRoomBackground({
|
||||
roomId,
|
||||
className,
|
||||
interactive = true,
|
||||
}: ParallaxRoomBackgroundProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const room = getRoomBackground(roomId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
|
||||
const y = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
|
||||
setOffset({ x, y });
|
||||
};
|
||||
|
||||
container.addEventListener("pointermove", handlePointerMove);
|
||||
return () => {
|
||||
container.removeEventListener("pointermove", handlePointerMove);
|
||||
};
|
||||
}, [interactive]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("absolute inset-0 overflow-hidden", className)}>
|
||||
{room.layers.map((layer, index) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute inset-[-12%]",
|
||||
layer.className,
|
||||
index % 2 === 0 ? "animate-parallax-drift" : "animate-star-twinkle",
|
||||
)}
|
||||
style={{
|
||||
transform: `translate3d(${offset.x * layer.speed * 10}px, ${offset.y * layer.speed * 8}px, 0)`,
|
||||
transition: "transform 0.15s steps(4)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
components/pet/pet-habitat.tsx
Normal file
77
components/pet/pet-habitat.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { AnimatedPetSprite } from "@/components/pet/animated-pet-sprite";
|
||||
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 { getAvatarConditionForToday } from "@/lib/daily-quiz-storage";
|
||||
import {
|
||||
normalizeRoomBackgroundId,
|
||||
type RoomBackgroundId,
|
||||
} from "@/lib/room-backgrounds";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PetHabitatProps = {
|
||||
customization: AvatarCustomization;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const MOOD_LABELS = {
|
||||
happy: "HAPPY",
|
||||
neutral: "OKAY",
|
||||
tired: "SLEEPY",
|
||||
} as const;
|
||||
|
||||
export function PetHabitat({ customization, className }: PetHabitatProps) {
|
||||
const [condition, setCondition] = useState<AvatarCondition>(
|
||||
defaultAvatarCondition,
|
||||
);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const roomId = normalizeRoomBackgroundId(customization.roomBackground);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadStatus() {
|
||||
setCondition((await getAvatarConditionForToday()) ?? defaultAvatarCondition);
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
void loadStatus();
|
||||
}, []);
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className={cn("tamagotchi-shell p-4", className)}>
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
Waking up pet...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("tamagotchi-shell overflow-hidden p-3 sm:p-4", className)}>
|
||||
<div className="mb-3 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>
|
||||
|
||||
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
|
||||
<ParallaxRoomBackground roomId={roomId as RoomBackgroundId} />
|
||||
<AnimatedPetSprite customization={customization} />
|
||||
<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" />
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
62
components/pet/pixel-gauge.tsx
Normal file
62
components/pet/pixel-gauge.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { Heart, Zap } from "lucide-react";
|
||||
|
||||
import { WELLNESS_SCALE_MAX } from "@/lib/avatar-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PixelGaugeProps = {
|
||||
type: "heart" | "energy";
|
||||
value: number;
|
||||
max?: number;
|
||||
className?: string;
|
||||
variant?: "default" | "overlay";
|
||||
};
|
||||
|
||||
export function PixelGauge({
|
||||
type,
|
||||
value,
|
||||
max = WELLNESS_SCALE_MAX,
|
||||
className,
|
||||
variant = "default",
|
||||
}: PixelGaugeProps) {
|
||||
const filled = Math.max(0, Math.min(max, Math.round(value)));
|
||||
const Icon = type === "heart" ? Heart : Zap;
|
||||
const label = type === "heart" ? "HP" : "NRG";
|
||||
const activeClass =
|
||||
type === "heart"
|
||||
? "fill-rose-400 text-rose-400"
|
||||
: "fill-amber-300 text-amber-300";
|
||||
const emptyClass = "text-muted-foreground/35";
|
||||
const isOverlay = variant === "overlay";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
isOverlay ? "gap-1" : "gap-1.5",
|
||||
className,
|
||||
)}
|
||||
aria-label={`${label}: ${filled} of ${max}`}
|
||||
>
|
||||
{!isOverlay ? (
|
||||
<span className="w-7 shrink-0 text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={cn("flex items-center", isOverlay ? "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",
|
||||
index < filled ? activeClass : emptyClass,
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
components/pet/room-background-picker.tsx
Normal file
83
components/pet/room-background-picker.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Lock } from "lucide-react";
|
||||
|
||||
import {
|
||||
ROOM_BACKGROUNDS,
|
||||
isRoomBackgroundUnlocked,
|
||||
type RoomBackgroundId,
|
||||
} from "@/lib/room-backgrounds";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type RoomBackgroundPickerProps = {
|
||||
value: RoomBackgroundId;
|
||||
ownedItemIds: string[];
|
||||
onChange: (roomId: RoomBackgroundId) => void;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function RoomBackgroundPicker({
|
||||
value,
|
||||
ownedItemIds,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: RoomBackgroundPickerProps) {
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
{!compact ? (
|
||||
<div>
|
||||
<h3 className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Room background
|
||||
</h3>
|
||||
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||
Pick the scene behind your pet. Premium rooms unlock in the shop.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
Premium rooms unlock in the shop.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROOM_BACKGROUNDS.map((room) => {
|
||||
const unlocked = isRoomBackgroundUnlocked(room.id, ownedItemIds);
|
||||
const selected = value === room.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
disabled={!unlocked}
|
||||
aria-pressed={selected}
|
||||
title={
|
||||
unlocked
|
||||
? room.name
|
||||
: `${room.name} — unlock in the shop`
|
||||
}
|
||||
onClick={() => onChange(room.id)}
|
||||
className={cn(
|
||||
"relative aspect-[4/3] overflow-hidden border-2 transition-transform",
|
||||
selected
|
||||
? "border-secondary shadow-[0_0_0_2px_hsl(var(--secondary)/0.35)]"
|
||||
: "border-border/70",
|
||||
unlocked
|
||||
? "hover:-translate-y-0.5"
|
||||
: "cursor-not-allowed opacity-80",
|
||||
)}
|
||||
>
|
||||
<div className={cn("absolute inset-0", room.previewClassName)} />
|
||||
<p className="absolute inset-x-0 bottom-0 bg-black/55 px-1.5 py-1 text-left text-[8px] uppercase tracking-wider text-foreground">
|
||||
{room.name}
|
||||
</p>
|
||||
{!unlocked ? (
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-background/55">
|
||||
<Lock className="size-4 text-muted-foreground" strokeWidth={2.25} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
components/pet/room-style-selector.tsx
Normal file
80
components/pet/room-style-selector.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { Lock } from "lucide-react";
|
||||
|
||||
import { PIECE_SLOT_COUNT } from "@/lib/character/presets";
|
||||
import {
|
||||
ROOM_BACKGROUNDS,
|
||||
isRoomBackgroundUnlocked,
|
||||
type RoomBackgroundId,
|
||||
} from "@/lib/room-backgrounds";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type RoomStyleSelectorProps = {
|
||||
activeId: RoomBackgroundId;
|
||||
ownedItemIds: readonly string[];
|
||||
onSelect: (roomId: RoomBackgroundId) => void;
|
||||
};
|
||||
|
||||
export function RoomStyleSelector({
|
||||
activeId,
|
||||
ownedItemIds,
|
||||
onSelect,
|
||||
}: RoomStyleSelectorProps) {
|
||||
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
|
||||
ROOM_BACKGROUNDS[index] ?? null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{slots.map((room, index) => {
|
||||
if (!room) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${index}`}
|
||||
aria-hidden
|
||||
className="size-14 rounded-lg border border-dashed border-border/50 bg-muted/15"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const locked = !isRoomBackgroundUnlocked(room.id, ownedItemIds);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
disabled={locked}
|
||||
aria-disabled={locked}
|
||||
title={
|
||||
locked ? `${room.name} — unlock in the shop` : room.name
|
||||
}
|
||||
aria-label={
|
||||
locked ? `${room.name}, locked — unlock in the shop` : room.name
|
||||
}
|
||||
aria-pressed={activeId === room.id}
|
||||
onClick={() => onSelect(room.id)}
|
||||
className={cn(
|
||||
"relative size-14 overflow-hidden rounded-lg border transition-colors",
|
||||
locked
|
||||
? "cursor-not-allowed border-border/40 opacity-80"
|
||||
: activeId === room.id
|
||||
? "border-foreground shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
|
||||
: "border-border/60 hover:border-border",
|
||||
)}
|
||||
>
|
||||
<div className={cn("absolute inset-0", room.previewClassName)} />
|
||||
{locked ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 flex items-center justify-center bg-background/75"
|
||||
>
|
||||
<Lock className="size-4 text-muted-foreground" strokeWidth={2.25} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user