avatar to main merge
I have completed the character creation screen on the avatar branch and am preparing to merge it into main.
This commit is contained in:
@@ -7,26 +7,24 @@ 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 {
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
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 ColorPreset,
|
||||
} from "@/lib/character/presets";
|
||||
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
|
||||
|
||||
function buildInitialColors(): LayerColorState {
|
||||
return { ...LAYER_DEFAULT_COLORS };
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
@@ -35,10 +33,45 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterCreator() {
|
||||
const [colors, setColors] = useState<LayerColorState>(buildInitialColors);
|
||||
type CharacterCreatorProps = {
|
||||
initialCustomization?: AvatarCustomization;
|
||||
name?: string;
|
||||
onNameChange?: (name: string) => void;
|
||||
showNameField?: boolean;
|
||||
saveLabel?: string;
|
||||
isSaving?: boolean;
|
||||
ownedVariantIds?: string[];
|
||||
onSave?: (customization: AvatarCustomization) => Promise<void>;
|
||||
};
|
||||
|
||||
export function CharacterCreator({
|
||||
initialCustomization,
|
||||
name: controlledName,
|
||||
onNameChange,
|
||||
ownedVariantIds = [],
|
||||
showNameField = false,
|
||||
saveLabel = "Save avatar",
|
||||
isSaving = false,
|
||||
onSave,
|
||||
}: CharacterCreatorProps) {
|
||||
const [internalName, setInternalName] = useState(
|
||||
initialCustomization?.name ?? "Pixel Me",
|
||||
);
|
||||
const name = controlledName ?? internalName;
|
||||
|
||||
const setName = (nextName: string) => {
|
||||
if (onNameChange) {
|
||||
onNameChange(nextName);
|
||||
return;
|
||||
}
|
||||
|
||||
setInternalName(nextName);
|
||||
};
|
||||
const [colors, setColors] = useState<LayerColorState>(
|
||||
initialCustomization?.colors ?? { ...LAYER_DEFAULT_COLORS },
|
||||
);
|
||||
const [variants, setVariants] = useState<LayerVariantState>(
|
||||
buildDefaultVariants,
|
||||
initialCustomization?.variants ?? buildDefaultVariants(),
|
||||
);
|
||||
const [activePresetId, setActivePresetId] = useState<string>(
|
||||
DEFAULT_GRAY_COLOR.id,
|
||||
@@ -46,10 +79,17 @@ export function CharacterCreator() {
|
||||
const [activeLayerId, setActiveLayerId] = useState<
|
||||
(typeof CHARACTER_LAYERS)[number]["id"]
|
||||
>("skin");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const activeLayer = getLayerById(activeLayerId);
|
||||
const activeColor = colors[activeLayerId];
|
||||
const pieceVariants = activeLayer.allowVariants ? activeLayer.variants : [];
|
||||
const ownedSet = new Set(ownedVariantIds);
|
||||
const pieceVariants = activeLayer.allowVariants
|
||||
? activeLayer.variants.filter(
|
||||
(variant) =>
|
||||
variant.id === NONE_VARIANT_ID || ownedSet.has(variant.id),
|
||||
)
|
||||
: [];
|
||||
|
||||
const applyPreset = (preset: ColorPreset) => {
|
||||
setActivePresetId(preset.id);
|
||||
@@ -86,8 +126,45 @@ export function CharacterCreator() {
|
||||
}));
|
||||
};
|
||||
|
||||
const buildCustomization = (): AvatarCustomization => ({
|
||||
name,
|
||||
colors,
|
||||
variants,
|
||||
customized: true,
|
||||
equippedItems: initialCustomization?.equippedItems ?? [],
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
await onSave(buildCustomization());
|
||||
} catch (error) {
|
||||
setSaveError(
|
||||
error instanceof Error ? error.message : "Could not save avatar.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
{showNameField ? (
|
||||
<div className="grid max-w-md gap-2">
|
||||
<Label htmlFor="avatar-name">Pet name</Label>
|
||||
<Input
|
||||
id="avatar-name"
|
||||
value={name}
|
||||
maxLength={32}
|
||||
placeholder="Name your pet"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CharacterLayerTabs
|
||||
activeLayerId={activeLayerId}
|
||||
onLayerChange={handleLayerChange}
|
||||
@@ -138,6 +215,22 @@ export function CharacterCreator() {
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { applyEquippedItemsToVariants } from "@/lib/avatar-display";
|
||||
import {
|
||||
drawTintedSprite,
|
||||
getCachedImage,
|
||||
@@ -17,15 +18,19 @@ import { cn } from "@/lib/utils";
|
||||
type CharacterLayerPreviewProps = {
|
||||
colors: LayerColorState;
|
||||
variants: LayerVariantState;
|
||||
equippedItems?: string[];
|
||||
scale?: number;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function CharacterLayerPreview({
|
||||
colors,
|
||||
variants,
|
||||
equippedItems = [],
|
||||
scale = 10,
|
||||
className,
|
||||
compact = false,
|
||||
}: CharacterLayerPreviewProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
|
||||
@@ -40,8 +45,13 @@ export function CharacterLayerPreview({
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const displayVariants = applyEquippedItemsToVariants(
|
||||
variants,
|
||||
equippedItems,
|
||||
);
|
||||
|
||||
const activeLayers = CHARACTER_LAYERS.flatMap((layer) => {
|
||||
const selected = getSelectedVariant(layer.id, variants[layer.id]);
|
||||
const selected = getSelectedVariant(layer.id, displayVariants[layer.id]);
|
||||
if (!selected) return [];
|
||||
return [{ layer, variant: selected }];
|
||||
});
|
||||
@@ -90,12 +100,13 @@ export function CharacterLayerPreview({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [colors, variants, scale]);
|
||||
}, [colors, variants, equippedItems, scale]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-[320px] items-center justify-center",
|
||||
"relative flex items-center justify-center",
|
||||
compact ? "h-[180px]" : "h-[320px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user