Stable version with additional styling

This commit is contained in:
pricewowen
2026-05-24 01:43:59 -06:00
parent cd5feb3a98
commit 9fdec200b5
40 changed files with 1682 additions and 467 deletions

View File

@@ -7,24 +7,30 @@ import { CharacterColorSliders } from "@/components/character/character-color-sl
import { CharacterLayerPreview } from "@/components/character/character-layer-preview";
import { CharacterLayerTabs } from "@/components/character/character-layer-tabs";
import { CharacterPieceSelector } from "@/components/character/character-piece-selector";
import { ParallaxRoomBackground } from "@/components/pet/parallax-room-background";
import { RoomStyleSelector } from "@/components/pet/room-style-selector";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { AvatarCustomization } from "@/lib/avatar-customization-storage";
import { clampHsl } from "@/lib/character/color-utils";
import {
CHARACTER_LAYERS,
COLOR_PRESETS,
DEFAULT_GRAY_COLOR,
LAYER_DEFAULT_COLORS,
NONE_VARIANT_ID,
buildDefaultVariants,
getLayerById,
type CharacterCreatorTabId,
type ColorPreset,
} from "@/lib/character/presets";
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
import { isVariantUnlocked } from "@/lib/character/variant-access";
import {
isRoomBackgroundUnlocked,
normalizeRoomBackgroundId,
type RoomBackgroundId,
} from "@/lib/room-backgrounds";
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
@@ -74,14 +80,20 @@ export function CharacterCreator({
const [variants, setVariants] = useState<LayerVariantState>(
initialCustomization?.variants ?? buildDefaultVariants(),
);
const [roomBackground, setRoomBackground] = useState<RoomBackgroundId>(
normalizeRoomBackgroundId(initialCustomization?.roomBackground),
);
const [activePresetId, setActivePresetId] = useState<string>(
DEFAULT_GRAY_COLOR.id,
);
const [activeLayerId, setActiveLayerId] = useState<
(typeof CHARACTER_LAYERS)[number]["id"]
>("skin");
const [activeTabId, setActiveTabId] = useState<CharacterCreatorTabId>("skin");
const [saveError, setSaveError] = useState<string | null>(null);
const isRoomTab = activeTabId === "room";
const isSkinTab = activeTabId === "skin";
const showStyleSection = !isSkinTab;
const showColorAdjust = !isRoomTab;
const activeLayerId = isRoomTab ? "skin" : activeTabId;
const activeLayer = getLayerById(activeLayerId);
const activeColor = colors[activeLayerId];
const ownedSet = new Set(ownedVariantIds);
@@ -112,9 +124,14 @@ export function CharacterCreator({
}));
};
const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => {
setActiveLayerId(layerId);
const layerColor = colors[layerId];
const handleTabChange = (tabId: CharacterCreatorTabId) => {
setActiveTabId(tabId);
if (tabId === "room") {
return;
}
const layerColor = colors[tabId];
const matchingPreset = COLOR_PRESETS.find(
(preset) =>
preset.hsl.h === layerColor.h &&
@@ -135,12 +152,21 @@ export function CharacterCreator({
}));
};
const handleRoomChange = (roomId: RoomBackgroundId) => {
if (!isRoomBackgroundUnlocked(roomId, ownedVariantIds)) {
return;
}
setRoomBackground(roomId);
};
const buildCustomization = (): AvatarCustomization => ({
name,
colors,
variants,
customized: true,
equippedItems: initialCustomization?.equippedItems ?? [],
roomBackground,
});
const handleSave = async () => {
@@ -160,9 +186,9 @@ export function CharacterCreator({
};
return (
<div className="grid gap-5">
<div className="tamagotchi-shell overflow-hidden p-3 sm:p-4">
{showNameField ? (
<div className="grid max-w-md gap-2">
<div className="mb-4 grid max-w-md gap-2 border-b-2 border-border/60 pb-4">
<Label htmlFor="avatar-name">Pet name</Label>
<Input
id="avatar-name"
@@ -174,75 +200,89 @@ export function CharacterCreator({
</div>
) : null}
<CharacterLayerTabs
activeLayerId={activeLayerId}
onLayerChange={handleLayerChange}
/>
<CharacterLayerTabs activeTabId={activeTabId} onTabChange={handleTabChange} />
<div className="grid items-stretch gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<Card className="flex flex-col overflow-hidden border-border/60 shadow-sm">
<CardContent className="flex flex-1 items-center justify-center p-6">
<CharacterLayerPreview
colors={colors}
variants={variants}
scale={10}
className="w-full"
/>
</CardContent>
</Card>
<div className="customize-body mt-4 grid items-stretch gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:gap-5">
<div className="flex min-h-full min-w-0 flex-col">
<div className="tamagotchi-lcd tamagotchi-lcd-pet-match relative">
<ParallaxRoomBackground roomId={roomBackground} interactive={false} />
<div className="relative z-10 flex h-full items-center justify-center">
<CharacterLayerPreview
colors={colors}
variants={variants}
scale={8}
compact
className="!h-auto max-h-none"
/>
</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>
<Card className="flex flex-col border-border/60 shadow-sm">
<CardContent className="flex flex-1 flex-col gap-5 p-6">
<section className="grid h-[5.75rem] shrink-0 gap-3">
<div className="flex min-h-full min-w-0 flex-col gap-5 border-t-2 border-border/60 pt-4 lg:border-l-2 lg:border-t-0 lg:pl-5 lg:pt-0">
{showStyleSection ? (
<section className="grid shrink-0 gap-3">
<SectionLabel>Style</SectionLabel>
<div className="min-h-14">
<CharacterPieceSelector
variants={pieceVariants}
lockedVariantIds={lockedVariantIds}
activeId={variants[activeLayerId]}
{isRoomTab ? (
<RoomStyleSelector
activeId={roomBackground}
ownedItemIds={ownedVariantIds}
onSelect={handleRoomChange}
/>
) : (
<CharacterPieceSelector
variants={pieceVariants}
lockedVariantIds={lockedVariantIds}
activeId={variants[activeLayerId]}
color={activeColor}
skinColor={colors.skin}
onSelect={handleVariantChange}
/>
)}
</div>
</section>
) : null}
{showColorAdjust ? (
<>
<section className="grid shrink-0 gap-3">
<SectionLabel>Color</SectionLabel>
<div className="min-h-[4.75rem]">
<CharacterColorPresets
presets={COLOR_PRESETS}
activeId={activePresetId}
onSelect={applyPreset}
/>
</div>
</section>
<section className="grid shrink-0 gap-4">
<SectionLabel>Adjust</SectionLabel>
<CharacterColorSliders
color={activeColor}
skinColor={colors.skin}
onSelect={handleVariantChange}
onChange={updateActiveColor}
/>
</div>
</section>
</section>
</>
) : null}
<section className="grid shrink-0 gap-3">
<SectionLabel>Color</SectionLabel>
<div className="min-h-[4.75rem]">
<CharacterColorPresets
presets={COLOR_PRESETS}
activeId={activePresetId}
onSelect={applyPreset}
/>
</div>
</section>
<section className="grid shrink-0 gap-4">
<SectionLabel>Adjust</SectionLabel>
<CharacterColorSliders
color={activeColor}
onChange={updateActiveColor}
/>
</section>
{onSave ? (
<div className="grid gap-2 pt-1">
<Button
id="avatar-customization-save"
type="button"
disabled={isSaving}
onClick={() => void handleSave()}
>
{isSaving ? "Saving..." : saveLabel}
</Button>
{saveError ? (
<p className="text-sm text-red-500">{saveError}</p>
) : null}
</div>
) : null}
</CardContent>
</Card>
{onSave ? (
<div className="mt-auto grid gap-2 pt-1">
<Button
id="avatar-customization-save"
type="button"
disabled={isSaving}
onClick={() => void handleSave()}
>
{isSaving ? "Saving..." : saveLabel}
</Button>
{saveError ? (
<p className="text-sm text-red-500">{saveError}</p>
) : null}
</div>
) : null}
</div>
</div>
</div>
);

View File

@@ -1,38 +1,41 @@
"use client";
import { CHARACTER_LAYERS, type CharacterLayerId } from "@/lib/character/presets";
import {
CHARACTER_CUSTOMIZATION_TABS,
type CharacterCreatorTabId,
} from "@/lib/character/presets";
import { cn } from "@/lib/utils";
type CharacterLayerTabsProps = {
activeLayerId: CharacterLayerId;
onLayerChange: (layerId: CharacterLayerId) => void;
activeTabId: CharacterCreatorTabId;
onTabChange: (tabId: CharacterCreatorTabId) => void;
};
export function CharacterLayerTabs({
activeLayerId,
onLayerChange,
activeTabId,
onTabChange,
}: CharacterLayerTabsProps) {
return (
<div
role="tablist"
aria-label="Character layers"
className="flex gap-0.5 overflow-x-auto rounded-lg border bg-muted/30 p-1"
aria-label="Character customization"
className="grid grid-cols-7 gap-0.5 border-2 border-border bg-muted/30 p-0.5"
>
{CHARACTER_LAYERS.map((layer) => (
{CHARACTER_CUSTOMIZATION_TABS.map((tab) => (
<button
key={layer.id}
key={tab.id}
type="button"
role="tab"
aria-selected={activeLayerId === layer.id}
onClick={() => onLayerChange(layer.id)}
aria-selected={activeTabId === tab.id}
onClick={() => onTabChange(tab.id)}
className={cn(
"min-w-[4.5rem] flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors",
activeLayerId === layer.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
"min-w-0 truncate border-2 px-0.5 py-1.5 text-[7px] font-medium uppercase leading-tight tracking-wide transition-colors sm:px-1 sm:text-[8px]",
activeTabId === tab.id
? "border-secondary bg-background text-foreground shadow-[var(--retro-shadow-sm)]"
: "border-transparent text-muted-foreground hover:border-border/60 hover:text-foreground",
)}
>
{layer.label}
{tab.label}
</button>
))}
</div>