Complete character creation screen

Finished the character creation screen with the final layout, UI elements, and customization flow ready for integration.
This commit is contained in:
pricewowen
2026-05-23 21:35:59 -06:00
parent 4705b1a0ec
commit 1023b3edc6
26 changed files with 232 additions and 39 deletions

View File

@@ -15,7 +15,7 @@ import { clampHsl } from "@/lib/character/color-utils";
import { import {
CHARACTER_LAYERS, CHARACTER_LAYERS,
COLOR_PRESETS, COLOR_PRESETS,
DEFAULT_SKIN_COLOR, DEFAULT_GRAY_COLOR,
LAYER_DEFAULT_COLORS, LAYER_DEFAULT_COLORS,
buildDefaultVariants, buildDefaultVariants,
getLayerById, getLayerById,
@@ -41,7 +41,7 @@ export function CharacterCreator() {
buildDefaultVariants, buildDefaultVariants,
); );
const [activePresetId, setActivePresetId] = useState<string>( const [activePresetId, setActivePresetId] = useState<string>(
DEFAULT_SKIN_COLOR.id, DEFAULT_GRAY_COLOR.id,
); );
const [activeLayerId, setActiveLayerId] = useState< const [activeLayerId, setActiveLayerId] = useState<
(typeof CHARACTER_LAYERS)[number]["id"] (typeof CHARACTER_LAYERS)[number]["id"]
@@ -69,7 +69,14 @@ export function CharacterCreator() {
const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => { const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => {
setActiveLayerId(layerId); setActiveLayerId(layerId);
setActivePresetId(""); const layerColor = colors[layerId];
const matchingPreset = COLOR_PRESETS.find(
(preset) =>
preset.hsl.h === layerColor.h &&
preset.hsl.s === layerColor.s &&
preset.hsl.l === layerColor.l,
);
setActivePresetId(matchingPreset?.id ?? "");
}; };
const handleVariantChange = (variantId: string) => { const handleVariantChange = (variantId: string) => {
@@ -107,6 +114,7 @@ export function CharacterCreator() {
variants={pieceVariants} variants={pieceVariants}
activeId={variants[activeLayerId]} activeId={variants[activeLayerId]}
color={activeColor} color={activeColor}
skinColor={colors.skin}
onSelect={handleVariantChange} onSelect={handleVariantChange}
/> />
</div> </div>

View File

@@ -65,10 +65,22 @@ export function CharacterLayerPreview({
for (const { layer, image } of images) { for (const { layer, image } of images) {
const layerColor = colors[layer.id]; const layerColor = colors[layer.id];
const rgb = hslToRgb(layerColor.h, layerColor.s, layerColor.l);
drawTintedSprite( drawTintedSprite(
ctx, ctx,
image, image,
hslToRgb(layerColor.h, layerColor.s, layerColor.l), rgb,
layer.id === "eyes"
? {
mode: "eyes",
skinColor: hslToRgb(
colors.skin.h,
colors.skin.s,
colors.skin.l,
),
}
: undefined,
); );
} }
} }

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { CircleSlash } from "lucide-react";
import type { HSL } from "@/lib/character/color-utils"; import type { HSL } from "@/lib/character/color-utils";
import { PIECE_SLOT_COUNT } from "@/lib/character/presets"; import { NONE_VARIANT_ID, PIECE_SLOT_COUNT } from "@/lib/character/presets";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { LayerVariant } from "@/lib/character/presets"; import type { LayerVariant } from "@/lib/character/presets";
@@ -11,6 +13,7 @@ type CharacterPieceSelectorProps = {
variants: LayerVariant[]; variants: LayerVariant[];
activeId: string; activeId: string;
color: HSL; color: HSL;
skinColor?: HSL;
onSelect: (variantId: string) => void; onSelect: (variantId: string) => void;
}; };
@@ -18,6 +21,7 @@ export function CharacterPieceSelector({
variants, variants,
activeId, activeId,
color, color,
skinColor,
onSelect, onSelect,
}: CharacterPieceSelectorProps) { }: CharacterPieceSelectorProps) {
const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) => const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) =>
@@ -37,6 +41,31 @@ export function CharacterPieceSelector({
); );
} }
if (variant.id === NONE_VARIANT_ID) {
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-muted/20 transition-colors",
activeId === variant.id
? "border-foreground bg-muted/35 shadow-[inset_0_0_0_1px_hsl(var(--foreground)/0.15)]"
: "border-border/60 hover:border-border hover:bg-muted/30",
)}
>
<CircleSlash
className="size-6 text-muted-foreground"
strokeWidth={1.75}
aria-hidden
/>
</button>
);
}
return ( return (
<button <button
key={variant.id} key={variant.id}
@@ -52,7 +81,12 @@ export function CharacterPieceSelector({
: "border-border/60 hover:border-border hover:bg-zinc-900/60", : "border-border/60 hover:border-border hover:bg-zinc-900/60",
)} )}
> >
<TintedSpriteIcon src={variant.src} color={color} size={36} /> <TintedSpriteIcon
src={variant.src}
color={color}
skinColor={skinColor}
size={36}
/>
</button> </button>
); );
})} })}

View File

@@ -13,6 +13,7 @@ import { cn } from "@/lib/utils";
type TintedSpriteIconProps = { type TintedSpriteIconProps = {
src: string; src: string;
color: HSL; color: HSL;
skinColor?: HSL;
size?: number; size?: number;
className?: string; className?: string;
}; };
@@ -20,6 +21,7 @@ type TintedSpriteIconProps = {
export function TintedSpriteIcon({ export function TintedSpriteIcon({
src, src,
color, color,
skinColor,
size = 40, size = 40,
className, className,
}: TintedSpriteIconProps) { }: TintedSpriteIconProps) {
@@ -41,7 +43,21 @@ export function TintedSpriteIcon({
canvas.width = image.width; canvas.width = image.width;
canvas.height = image.height; canvas.height = image.height;
ctx.clearRect(0, 0, image.width, image.height); ctx.clearRect(0, 0, image.width, image.height);
drawTintedSprite(ctx, image, hslToRgb(color.h, color.s, color.l));
const rgb = hslToRgb(color.h, color.s, color.l);
const isEyesLayer = src.includes("/character/eyes/");
drawTintedSprite(
ctx,
image,
rgb,
isEyesLayer && skinColor
? {
mode: "eyes",
skinColor: hslToRgb(skinColor.h, skinColor.s, skinColor.l),
}
: undefined,
);
} }
render(); render();
@@ -49,7 +65,7 @@ export function TintedSpriteIcon({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [src, color]); }, [src, color, skinColor]);
return ( return (
<canvas <canvas

View File

@@ -128,15 +128,79 @@ export function tintImageData(imageData: ImageData, color: RGB): void {
const alpha = data[i + 3]; const alpha = data[i + 3];
if (alpha === 0) continue; if (alpha === 0) continue;
const brightness = applyMonochromeTint(data, i, color);
Math.max(data[i], data[i + 1], data[i + 2]) / 255;
data[i] = Math.round(color.r * brightness);
data[i + 1] = Math.round(color.g * brightness);
data[i + 2] = Math.round(color.b * brightness);
} }
} }
function isWhitePixel(r: number, g: number, b: number): boolean {
return r >= 240 && g >= 240 && b >= 240;
}
function isMagentaPixel(r: number, g: number, b: number): boolean {
return r >= 200 && g <= 80 && b >= 200;
}
function applyMonochromeTint(
data: Uint8ClampedArray,
index: number,
color: RGB,
brightness?: number,
): void {
const pixelBrightness =
brightness ??
Math.max(data[index], data[index + 1], data[index + 2]) / 255;
data[index] = Math.round(color.r * pixelBrightness);
data[index + 1] = Math.round(color.g * pixelBrightness);
data[index + 2] = Math.round(color.b * pixelBrightness);
}
/** Darkest shadow in the skin template — magenta maps here, not bright red. */
const SKIN_TEMPLATE_SHADOW = hexToRgb("#330000");
const SKIN_SHADOW_BRIGHTNESS =
Math.max(
SKIN_TEMPLATE_SHADOW.r,
SKIN_TEMPLATE_SHADOW.g,
SKIN_TEMPLATE_SHADOW.b,
) / 255;
/** Eyes: white stays white, magenta uses skin color, other pixels use eye color. */
export function tintEyesImageData(
imageData: ImageData,
eyeColor: RGB,
skinColor: RGB,
): void {
const { data } = imageData;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha === 0) continue;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if (isWhitePixel(r, g, b)) {
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
continue;
}
if (isMagentaPixel(r, g, b)) {
applyMonochromeTint(data, i, skinColor, SKIN_SHADOW_BRIGHTNESS);
continue;
}
applyMonochromeTint(data, i, eyeColor);
}
}
export type TintOptions = {
mode?: "monochrome" | "eyes";
skinColor?: RGB;
};
export function loadImage(src: string): Promise<HTMLImageElement> { export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const image = new Image(); const image = new Image();
@@ -159,6 +223,7 @@ export function drawTintedSprite(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
image: HTMLImageElement, image: HTMLImageElement,
color: RGB, color: RGB,
options?: TintOptions,
): void { ): void {
const width = image.width; const width = image.width;
const height = image.height; const height = image.height;
@@ -171,7 +236,13 @@ export function drawTintedSprite(
offscreenCtx.drawImage(image, 0, 0); offscreenCtx.drawImage(image, 0, 0);
const imageData = offscreenCtx.getImageData(0, 0, width, height); const imageData = offscreenCtx.getImageData(0, 0, width, height);
if (options?.mode === "eyes" && options.skinColor) {
tintEyesImageData(imageData, color, options.skinColor);
} else {
tintImageData(imageData, color); tintImageData(imageData, color);
}
offscreenCtx.putImageData(imageData, 0, 0); offscreenCtx.putImageData(imageData, 0, 0);
ctx.drawImage(offscreen, 0, 0); ctx.drawImage(offscreen, 0, 0);
} }

View File

@@ -21,7 +21,21 @@ export type LayerVariant = {
src: string; src: string;
}; };
export type CharacterLayerId = "skin" | "pants" | "torso" | "head" | "eyes"; export const NONE_VARIANT_ID = "none";
export const NONE_VARIANT: LayerVariant = {
id: NONE_VARIANT_ID,
label: "None",
src: "",
};
export type CharacterLayerId =
| "skin"
| "pants"
| "shoes"
| "torso"
| "eyes"
| "head";
export type CharacterLayer = { export type CharacterLayer = {
id: CharacterLayerId; id: CharacterLayerId;
@@ -45,10 +59,33 @@ function presetFromHex(id: string, name: string, hex: string): ColorPreset {
return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b)); return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b));
} }
function layerVariants(
layerId: Exclude<CharacterLayerId, "skin">,
label: string,
count: number,
): LayerVariant[] {
return Array.from({ length: count }, (_, index) => {
const number = index + 1;
return {
id: `${layerId}-${number}`,
label: `${label} ${number}`,
src: `/character/${layerId}/${layerId}-${number}.png`,
};
});
}
function layerVariantsWithNone(
layerId: Exclude<CharacterLayerId, "skin">,
label: string,
count: number,
): LayerVariant[] {
return [NONE_VARIANT, ...layerVariants(layerId, label, count)];
}
export const COLOR_PRESETS: ColorPreset[] = [ export const COLOR_PRESETS: ColorPreset[] = [
presetFromHsl("black", "Black", { h: 0, s: 0, l: 5 }),
presetFromHsl("gray", "Gray", { h: 0, s: 0, l: 50 }), presetFromHsl("gray", "Gray", { h: 0, s: 0, l: 50 }),
presetFromHsl("white", "White", { h: 0, s: 0, l: 100 }), presetFromHsl("white", "White", { h: 0, s: 0, l: 100 }),
presetFromHsl("black", "Black", { h: 0, s: 0, l: 5 }),
presetFromHex("red", "Red", "#FF2020"), presetFromHex("red", "Red", "#FF2020"),
presetFromHex("orange", "Orange", "#FF8800"), presetFromHex("orange", "Orange", "#FF8800"),
presetFromHex("yellow", "Yellow", "#FFD700"), presetFromHex("yellow", "Yellow", "#FFD700"),
@@ -66,11 +103,14 @@ export const COLOR_PRESETS: ColorPreset[] = [
presetFromHex("onyx", "Onyx", "#140C08"), presetFromHex("onyx", "Onyx", "#140C08"),
]; ];
export const DEFAULT_SKIN_COLOR = export const DEFAULT_GRAY_COLOR =
COLOR_PRESETS.find((preset) => preset.id === "light")!; COLOR_PRESETS.find((preset) => preset.id === "gray")!;
export const DEFAULT_SKIN_COLOR = DEFAULT_GRAY_COLOR;
export const PIECE_SLOT_COUNT = 8; export const PIECE_SLOT_COUNT = 8;
/** Stack order: bottom → top */
export const CHARACTER_LAYERS: CharacterLayer[] = [ export const CHARACTER_LAYERS: CharacterLayer[] = [
{ {
id: "skin", id: "skin",
@@ -84,42 +124,48 @@ export const CHARACTER_LAYERS: CharacterLayer[] = [
id: "pants", id: "pants",
label: "Pants", label: "Pants",
allowVariants: true, allowVariants: true,
variants: [], variants: layerVariantsWithNone("pants", "Pants", 3),
},
{
id: "shoes",
label: "Shoes",
allowVariants: true,
variants: layerVariantsWithNone("shoes", "Shoes", 3),
}, },
{ {
id: "torso", id: "torso",
label: "Torso", label: "Torso",
allowVariants: true, allowVariants: true,
variants: [ variants: layerVariantsWithNone("torso", "Torso", 4),
{ id: "torso-1", label: "Torso 1", src: "/character/torso/torso-1.png" },
{ id: "torso-2", label: "Torso 2", src: "/character/torso/torso-2.png" },
],
},
{
id: "head",
label: "Head",
allowVariants: true,
variants: [],
}, },
{ {
id: "eyes", id: "eyes",
label: "Eyes", label: "Eyes",
allowVariants: true, allowVariants: true,
variants: [], variants: layerVariantsWithNone("eyes", "Eyes", 2),
},
{
id: "head",
label: "Head",
allowVariants: true,
variants: layerVariantsWithNone("head", "Head", 7),
}, },
]; ];
export const LAYER_DEFAULT_COLORS: Record<CharacterLayerId, HSL> = { export const LAYER_DEFAULT_COLORS: Record<CharacterLayerId, HSL> = {
skin: DEFAULT_SKIN_COLOR.hsl, skin: DEFAULT_GRAY_COLOR.hsl,
pants: COLOR_PRESETS.find((preset) => preset.id === "blue")!.hsl, pants: DEFAULT_GRAY_COLOR.hsl,
torso: COLOR_PRESETS.find((preset) => preset.id === "red")!.hsl, shoes: DEFAULT_GRAY_COLOR.hsl,
head: COLOR_PRESETS.find((preset) => preset.id === "yellow")!.hsl, torso: DEFAULT_GRAY_COLOR.hsl,
eyes: COLOR_PRESETS.find((preset) => preset.id === "indigo")!.hsl, eyes: DEFAULT_GRAY_COLOR.hsl,
head: DEFAULT_GRAY_COLOR.hsl,
}; };
export function buildDefaultVariants(): LayerVariantState { export function buildDefaultVariants(): LayerVariantState {
return CHARACTER_LAYERS.reduce((state, layer) => { return CHARACTER_LAYERS.reduce((state, layer) => {
state[layer.id] = layer.variants[0]?.id ?? ""; state[layer.id] = layer.allowVariants
? NONE_VARIANT_ID
: (layer.variants[0]?.id ?? "");
return state; return state;
}, {} as LayerVariantState); }, {} as LayerVariantState);
} }
@@ -132,9 +178,15 @@ export function getSelectedVariant(
layerId: CharacterLayerId, layerId: CharacterLayerId,
variantId: string, variantId: string,
): LayerVariant | undefined { ): LayerVariant | undefined {
return getLayerById(layerId).variants.find( if (variantId === NONE_VARIANT_ID) return undefined;
(variant) => variant.id === variantId,
const variant = getLayerById(layerId).variants.find(
(entry) => entry.id === variantId,
); );
if (!variant?.src) return undefined;
return variant;
} }
/** @deprecated Use DEFAULT_SKIN_COLOR */ /** @deprecated Use DEFAULT_SKIN_COLOR */

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

After

Width:  |  Height:  |  Size: 583 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 B

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 B

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B