Add initial character creation screen
Created the first version of the character creation feature, including the base layout and starting UI elements for customizing a character.
This commit is contained in:
38
components/character/character-color-presets.tsx
Normal file
38
components/character/character-color-presets.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColorPreset } from "@/lib/character/presets";
|
||||
|
||||
type CharacterColorPresetsProps = {
|
||||
presets: ColorPreset[];
|
||||
activeId?: string;
|
||||
onSelect: (preset: ColorPreset) => void;
|
||||
};
|
||||
|
||||
export function CharacterColorPresets({
|
||||
presets,
|
||||
activeId,
|
||||
onSelect,
|
||||
}: CharacterColorPresetsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-9 gap-2">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
title={preset.name}
|
||||
aria-label={preset.name}
|
||||
aria-pressed={activeId === preset.id}
|
||||
onClick={() => onSelect(preset)}
|
||||
className={cn(
|
||||
"aspect-square size-full max-h-9 max-w-9 rounded-md border transition-colors",
|
||||
activeId === preset.id
|
||||
? "border-foreground ring-1 ring-foreground/40 ring-offset-1 ring-offset-background"
|
||||
: "border-border/60 hover:border-border",
|
||||
)}
|
||||
style={{ backgroundColor: preset.hex }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
components/character/character-color-sliders.tsx
Normal file
114
components/character/character-color-sliders.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
LIGHTNESS_MIN,
|
||||
clampHsl,
|
||||
displayToLightness,
|
||||
lightnessToDisplay,
|
||||
type HSL,
|
||||
} from "@/lib/character/color-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ColorSliderProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
displayValue?: number;
|
||||
min: number;
|
||||
max: number;
|
||||
gradient?: string;
|
||||
onChange: (value: number) => void;
|
||||
};
|
||||
|
||||
export function ColorSlider({
|
||||
label,
|
||||
value,
|
||||
displayValue,
|
||||
min,
|
||||
max,
|
||||
gradient,
|
||||
onChange,
|
||||
}: ColorSliderProps) {
|
||||
const shownValue = displayValue ?? value;
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{label}</Label>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{shownValue}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className={cn(
|
||||
"h-2.5 w-full cursor-pointer appearance-none rounded-full border border-border/40",
|
||||
"[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:shadow-sm",
|
||||
"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background [&::-moz-range-thumb]:bg-foreground [&::-moz-range-thumb]:shadow-sm",
|
||||
)}
|
||||
style={
|
||||
gradient
|
||||
? {
|
||||
background: gradient,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CharacterColorSlidersProps = {
|
||||
color: HSL;
|
||||
onChange: (color: HSL) => void;
|
||||
};
|
||||
|
||||
export function CharacterColorSliders({
|
||||
color,
|
||||
onChange,
|
||||
}: CharacterColorSlidersProps) {
|
||||
const hueGradient =
|
||||
"linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%))";
|
||||
|
||||
const saturationGradient = `linear-gradient(to right, hsl(${color.h}, 0%, ${color.l}%), hsl(${color.h}, 100%, ${color.l}%))`;
|
||||
|
||||
const lightnessGradient = `linear-gradient(to right, hsl(${color.h}, ${color.s}%, ${LIGHTNESS_MIN}%), hsl(${color.h}, ${color.s}%, 50%), hsl(${color.h}, ${color.s}%, 100%))`;
|
||||
|
||||
const brightnessDisplay = lightnessToDisplay(color.l);
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<ColorSlider
|
||||
label="Hue"
|
||||
value={color.h}
|
||||
min={0}
|
||||
max={360}
|
||||
gradient={hueGradient}
|
||||
onChange={(h) => onChange({ ...color, h })}
|
||||
/>
|
||||
<ColorSlider
|
||||
label="Saturation"
|
||||
value={color.s}
|
||||
min={0}
|
||||
max={100}
|
||||
gradient={saturationGradient}
|
||||
onChange={(s) => onChange({ ...color, s })}
|
||||
/>
|
||||
<ColorSlider
|
||||
label="Brightness"
|
||||
value={brightnessDisplay}
|
||||
displayValue={brightnessDisplay}
|
||||
min={0}
|
||||
max={100}
|
||||
gradient={lightnessGradient}
|
||||
onChange={(display) =>
|
||||
onChange(clampHsl({ ...color, l: displayToLightness(display) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
components/character/character-creator.tsx
Normal file
138
components/character/character-creator.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { CharacterColorPresets } from "@/components/character/character-color-presets";
|
||||
import { CharacterColorSliders } from "@/components/character/character-color-sliders";
|
||||
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 { clampHsl } from "@/lib/character/color-utils";
|
||||
import {
|
||||
CHARACTER_LAYERS,
|
||||
COLOR_PRESETS,
|
||||
DEFAULT_SKIN_COLOR,
|
||||
LAYER_DEFAULT_COLORS,
|
||||
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">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterCreator() {
|
||||
const [colors, setColors] = useState<LayerColorState>(buildInitialColors);
|
||||
const [variants, setVariants] = useState<LayerVariantState>(
|
||||
buildDefaultVariants,
|
||||
);
|
||||
const [activePresetId, setActivePresetId] = useState<string>(
|
||||
DEFAULT_SKIN_COLOR.id,
|
||||
);
|
||||
const [activeLayerId, setActiveLayerId] = useState<
|
||||
(typeof CHARACTER_LAYERS)[number]["id"]
|
||||
>("skin");
|
||||
|
||||
const activeLayer = getLayerById(activeLayerId);
|
||||
const activeColor = colors[activeLayerId];
|
||||
const pieceVariants = activeLayer.allowVariants ? activeLayer.variants : [];
|
||||
|
||||
const applyPreset = (preset: ColorPreset) => {
|
||||
setActivePresetId(preset.id);
|
||||
setColors((current) => ({
|
||||
...current,
|
||||
[activeLayerId]: { ...preset.hsl },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateActiveColor = (nextColor: typeof activeColor) => {
|
||||
setActivePresetId("");
|
||||
setColors((current) => ({
|
||||
...current,
|
||||
[activeLayerId]: clampHsl(nextColor),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => {
|
||||
setActiveLayerId(layerId);
|
||||
setActivePresetId("");
|
||||
};
|
||||
|
||||
const handleVariantChange = (variantId: string) => {
|
||||
setVariants((current) => ({
|
||||
...current,
|
||||
[activeLayerId]: variantId,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<CharacterLayerTabs
|
||||
activeLayerId={activeLayerId}
|
||||
onLayerChange={handleLayerChange}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<SectionLabel>Style</SectionLabel>
|
||||
<div className="min-h-14">
|
||||
<CharacterPieceSelector
|
||||
variants={pieceVariants}
|
||||
activeId={variants[activeLayerId]}
|
||||
color={activeColor}
|
||||
onSelect={handleVariantChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
components/character/character-layer-preview.tsx
Normal file
101
components/character/character-layer-preview.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
drawTintedSprite,
|
||||
getCachedImage,
|
||||
hslToRgb,
|
||||
} from "@/lib/character/color-utils";
|
||||
import {
|
||||
CHARACTER_LAYERS,
|
||||
getSelectedVariant,
|
||||
} from "@/lib/character/presets";
|
||||
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CharacterLayerPreviewProps = {
|
||||
colors: LayerColorState;
|
||||
variants: LayerVariantState;
|
||||
scale?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CharacterLayerPreview({
|
||||
colors,
|
||||
variants,
|
||||
scale = 10,
|
||||
className,
|
||||
}: CharacterLayerPreviewProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [displaySize, setDisplaySize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function render() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const activeLayers = CHARACTER_LAYERS.flatMap((layer) => {
|
||||
const selected = getSelectedVariant(layer.id, variants[layer.id]);
|
||||
if (!selected) return [];
|
||||
return [{ layer, variant: selected }];
|
||||
});
|
||||
|
||||
const images = await Promise.all(
|
||||
activeLayers.map(async ({ layer, variant }) => ({
|
||||
layer,
|
||||
image: await getCachedImage(variant.src),
|
||||
})),
|
||||
);
|
||||
|
||||
if (cancelled || images.length === 0) return;
|
||||
|
||||
const width = images[0]?.image.width ?? 0;
|
||||
const height = images[0]?.image.height ?? 0;
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
setDisplaySize({ width: width * scale, height: height * scale });
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
for (const { layer, image } of images) {
|
||||
const layerColor = colors[layer.id];
|
||||
drawTintedSprite(
|
||||
ctx,
|
||||
image,
|
||||
hslToRgb(layerColor.h, layerColor.s, layerColor.l),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [colors, variants, scale]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-[320px] items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="block"
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
width: displaySize.width || undefined,
|
||||
height: displaySize.height || undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
components/character/character-layer-tabs.tsx
Normal file
40
components/character/character-layer-tabs.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { CHARACTER_LAYERS, type CharacterLayerId } from "@/lib/character/presets";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CharacterLayerTabsProps = {
|
||||
activeLayerId: CharacterLayerId;
|
||||
onLayerChange: (layerId: CharacterLayerId) => void;
|
||||
};
|
||||
|
||||
export function CharacterLayerTabs({
|
||||
activeLayerId,
|
||||
onLayerChange,
|
||||
}: 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"
|
||||
>
|
||||
{CHARACTER_LAYERS.map((layer) => (
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeLayerId === layer.id}
|
||||
onClick={() => onLayerChange(layer.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",
|
||||
)}
|
||||
>
|
||||
{layer.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
components/character/character-piece-selector.tsx
Normal file
61
components/character/character-piece-selector.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import type { HSL } from "@/lib/character/color-utils";
|
||||
import { PIECE_SLOT_COUNT } from "@/lib/character/presets";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LayerVariant } from "@/lib/character/presets";
|
||||
|
||||
import { TintedSpriteIcon } from "./tinted-sprite-icon";
|
||||
|
||||
type CharacterPieceSelectorProps = {
|
||||
variants: LayerVariant[];
|
||||
activeId: string;
|
||||
color: HSL;
|
||||
onSelect: (variantId: string) => void;
|
||||
};
|
||||
|
||||
export function CharacterPieceSelector({
|
||||
variants,
|
||||
activeId,
|
||||
color,
|
||||
onSelect,
|
||||
}: CharacterPieceSelectorProps) {
|
||||
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
|
||||
variants[index] ?? null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{slots.map((variant, index) => {
|
||||
if (!variant) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${index}`}
|
||||
aria-hidden
|
||||
className="size-14 rounded-lg border border-dashed border-border/50 bg-muted/15"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={variant.id}
|
||||
type="button"
|
||||
title={variant.label}
|
||||
aria-label={variant.label}
|
||||
aria-pressed={activeId === variant.id}
|
||||
onClick={() => onSelect(variant.id)}
|
||||
className={cn(
|
||||
"flex size-14 items-center justify-center rounded-lg border bg-zinc-950/80 p-1.5 transition-colors",
|
||||
activeId === variant.id
|
||||
? "border-foreground bg-zinc-900 shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
|
||||
: "border-border/60 hover:border-border hover:bg-zinc-900/60",
|
||||
)}
|
||||
>
|
||||
<TintedSpriteIcon src={variant.src} color={color} size={36} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
components/character/tinted-sprite-icon.tsx
Normal file
66
components/character/tinted-sprite-icon.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
drawTintedSprite,
|
||||
getCachedImage,
|
||||
hslToRgb,
|
||||
type HSL,
|
||||
} from "@/lib/character/color-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TintedSpriteIconProps = {
|
||||
src: string;
|
||||
color: HSL;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function TintedSpriteIcon({
|
||||
src,
|
||||
color,
|
||||
size = 40,
|
||||
className,
|
||||
}: TintedSpriteIconProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function render() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const image = await getCachedImage(src);
|
||||
if (cancelled) return;
|
||||
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
ctx.clearRect(0, 0, image.width, image.height);
|
||||
drawTintedSprite(ctx, image, hslToRgb(color.h, color.s, color.l));
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src, color]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
className={cn("block max-h-full max-w-full", className)}
|
||||
style={{
|
||||
imageRendering: "pixelated",
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user