From 4705b1a0ec2d0db9a8c2ba1113437963fd535261 Mon Sep 17 00:00:00 2001
From: pricewowen <231476659+pricewowen@users.noreply.github.com>
Date: Sat, 23 May 2026 19:32:20 -0600
Subject: [PATCH] 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.
---
app/character-creator/page.tsx | 14 ++
.../character/character-color-presets.tsx | 38 ++++
.../character/character-color-sliders.tsx | 114 +++++++++++
components/character/character-creator.tsx | 138 ++++++++++++++
.../character/character-layer-preview.tsx | 101 ++++++++++
components/character/character-layer-tabs.tsx | 40 ++++
.../character/character-piece-selector.tsx | 61 ++++++
components/character/tinted-sprite-icon.tsx | 66 +++++++
lib/character/color-utils.ts | 177 ++++++++++++++++++
lib/character/presets.ts | 141 ++++++++++++++
lib/character/types.ts | 5 +
public/character/eyes/.gitkeep | 0
public/character/head/.gitkeep | 0
public/character/pants/.gitkeep | 0
public/character/skin/skin-1.png | Bin 0 -> 580 bytes
public/character/torso/.gitkeep | 0
public/character/torso/torso-1.png | Bin 0 -> 261 bytes
public/character/torso/torso-2.png | Bin 0 -> 261 bytes
18 files changed, 895 insertions(+)
create mode 100644 app/character-creator/page.tsx
create mode 100644 components/character/character-color-presets.tsx
create mode 100644 components/character/character-color-sliders.tsx
create mode 100644 components/character/character-creator.tsx
create mode 100644 components/character/character-layer-preview.tsx
create mode 100644 components/character/character-layer-tabs.tsx
create mode 100644 components/character/character-piece-selector.tsx
create mode 100644 components/character/tinted-sprite-icon.tsx
create mode 100644 lib/character/color-utils.ts
create mode 100644 lib/character/presets.ts
create mode 100644 lib/character/types.ts
create mode 100644 public/character/eyes/.gitkeep
create mode 100644 public/character/head/.gitkeep
create mode 100644 public/character/pants/.gitkeep
create mode 100644 public/character/skin/skin-1.png
create mode 100644 public/character/torso/.gitkeep
create mode 100644 public/character/torso/torso-1.png
create mode 100644 public/character/torso/torso-2.png
diff --git a/app/character-creator/page.tsx b/app/character-creator/page.tsx
new file mode 100644
index 0000000..cfe3e30
--- /dev/null
+++ b/app/character-creator/page.tsx
@@ -0,0 +1,14 @@
+import { CharacterCreator } from "@/components/character/character-creator";
+
+export default function CharacterCreatorPage() {
+ return (
+
+
+
+ Character Creation
+
+
+
+
+ );
+}
diff --git a/components/character/character-color-presets.tsx b/components/character/character-color-presets.tsx
new file mode 100644
index 0000000..31772dc
--- /dev/null
+++ b/components/character/character-color-presets.tsx
@@ -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 (
+
+ {presets.map((preset) => (
+
+ );
+}
diff --git a/components/character/character-color-sliders.tsx b/components/character/character-color-sliders.tsx
new file mode 100644
index 0000000..6d51e6e
--- /dev/null
+++ b/components/character/character-color-sliders.tsx
@@ -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 (
+
+
+
+
+ {shownValue}
+
+
+
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
+ }
+ />
+
+ );
+}
+
+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 (
+
+ onChange({ ...color, h })}
+ />
+ onChange({ ...color, s })}
+ />
+
+ onChange(clampHsl({ ...color, l: displayToLightness(display) }))
+ }
+ />
+
+ );
+}
diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx
new file mode 100644
index 0000000..f817438
--- /dev/null
+++ b/components/character/character-creator.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export function CharacterCreator() {
+ const [colors, setColors] = useState(buildInitialColors);
+ const [variants, setVariants] = useState(
+ buildDefaultVariants,
+ );
+ const [activePresetId, setActivePresetId] = useState(
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/character/character-layer-preview.tsx b/components/character/character-layer-preview.tsx
new file mode 100644
index 0000000..2dbfd57
--- /dev/null
+++ b/components/character/character-layer-preview.tsx
@@ -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(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 (
+
+
+
+ );
+}
diff --git a/components/character/character-layer-tabs.tsx b/components/character/character-layer-tabs.tsx
new file mode 100644
index 0000000..9333a27
--- /dev/null
+++ b/components/character/character-layer-tabs.tsx
@@ -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 (
+
+ {CHARACTER_LAYERS.map((layer) => (
+
+ ))}
+
+ );
+}
diff --git a/components/character/character-piece-selector.tsx b/components/character/character-piece-selector.tsx
new file mode 100644
index 0000000..1c8c372
--- /dev/null
+++ b/components/character/character-piece-selector.tsx
@@ -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 (
+
+ {slots.map((variant, index) => {
+ if (!variant) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/components/character/tinted-sprite-icon.tsx b/components/character/tinted-sprite-icon.tsx
new file mode 100644
index 0000000..dbc2407
--- /dev/null
+++ b/components/character/tinted-sprite-icon.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/lib/character/color-utils.ts b/lib/character/color-utils.ts
new file mode 100644
index 0000000..0100279
--- /dev/null
+++ b/lib/character/color-utils.ts
@@ -0,0 +1,177 @@
+export type RGB = { r: number; g: number; b: number };
+export type HSL = { h: number; s: number; l: number };
+
+export const LIGHTNESS_MIN = 5;
+
+export function lightnessToDisplay(lightness: number): number {
+ return Math.round(
+ ((lightness - LIGHTNESS_MIN) / (100 - LIGHTNESS_MIN)) * 100,
+ );
+}
+
+export function displayToLightness(display: number): number {
+ return Math.round(
+ LIGHTNESS_MIN + (display / 100) * (100 - LIGHTNESS_MIN),
+ );
+}
+
+export function clampHsl(hsl: HSL): HSL {
+ return {
+ h: hsl.h,
+ s: Math.max(0, Math.min(100, hsl.s)),
+ l: Math.max(LIGHTNESS_MIN, Math.min(100, hsl.l)),
+ };
+}
+
+export function hslToRgb(h: number, s: number, l: number): RGB {
+ const saturation = s / 100;
+ const lightness = l / 100;
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
+ const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
+ const m = lightness - chroma / 2;
+
+ let r = 0;
+ let g = 0;
+ let b = 0;
+
+ if (h < 60) {
+ r = chroma;
+ g = x;
+ } else if (h < 120) {
+ r = x;
+ g = chroma;
+ } else if (h < 180) {
+ g = chroma;
+ b = x;
+ } else if (h < 240) {
+ g = x;
+ b = chroma;
+ } else if (h < 300) {
+ r = x;
+ b = chroma;
+ } else {
+ r = chroma;
+ b = x;
+ }
+
+ return {
+ r: Math.round((r + m) * 255),
+ g: Math.round((g + m) * 255),
+ b: Math.round((b + m) * 255),
+ };
+}
+
+export function rgbToHsl(r: number, g: number, b: number): HSL {
+ const red = r / 255;
+ const green = g / 255;
+ const blue = b / 255;
+ const max = Math.max(red, green, blue);
+ const min = Math.min(red, green, blue);
+ const lightness = (max + min) / 2;
+
+ let hue = 0;
+ let saturation = 0;
+
+ if (max !== min) {
+ const delta = max - min;
+ saturation =
+ lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
+
+ switch (max) {
+ case red:
+ hue = ((green - blue) / delta + (green < blue ? 6 : 0)) * 60;
+ break;
+ case green:
+ hue = ((blue - red) / delta + 2) * 60;
+ break;
+ default:
+ hue = ((red - green) / delta + 4) * 60;
+ break;
+ }
+ }
+
+ return {
+ h: Math.round(hue),
+ s: Math.round(saturation * 100),
+ l: Math.round(lightness * 100),
+ };
+}
+
+export function hexToRgb(hex: string): RGB {
+ const normalized = hex.replace("#", "");
+ const value =
+ normalized.length === 3
+ ? normalized
+ .split("")
+ .map((char) => char + char)
+ .join("")
+ : normalized;
+
+ return {
+ r: parseInt(value.slice(0, 2), 16),
+ g: parseInt(value.slice(2, 4), 16),
+ b: parseInt(value.slice(4, 6), 16),
+ };
+}
+
+export function rgbToHex({ r, g, b }: RGB): string {
+ return `#${[r, g, b]
+ .map((channel) => channel.toString(16).padStart(2, "0"))
+ .join("")}`;
+}
+
+/** Tint a monochrome sprite by mapping pixel brightness to the target color. */
+export function tintImageData(imageData: ImageData, color: RGB): void {
+ const { data } = imageData;
+
+ for (let i = 0; i < data.length; i += 4) {
+ const alpha = data[i + 3];
+ if (alpha === 0) continue;
+
+ const brightness =
+ 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);
+ }
+}
+
+export function loadImage(src: string): Promise {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => resolve(image);
+ image.onerror = () => reject(new Error(`Failed to load image: ${src}`));
+ image.src = src;
+ });
+}
+
+const imageCache = new Map();
+
+export async function getCachedImage(src: string): Promise {
+ if (!imageCache.has(src)) {
+ imageCache.set(src, await loadImage(src));
+ }
+ return imageCache.get(src)!;
+}
+
+export function drawTintedSprite(
+ ctx: CanvasRenderingContext2D,
+ image: HTMLImageElement,
+ color: RGB,
+): void {
+ const width = image.width;
+ const height = image.height;
+ const offscreen = document.createElement("canvas");
+ offscreen.width = width;
+ offscreen.height = height;
+
+ const offscreenCtx = offscreen.getContext("2d", { willReadFrequently: true });
+ if (!offscreenCtx) return;
+
+ offscreenCtx.drawImage(image, 0, 0);
+ const imageData = offscreenCtx.getImageData(0, 0, width, height);
+ tintImageData(imageData, color);
+ offscreenCtx.putImageData(imageData, 0, 0);
+ ctx.drawImage(offscreen, 0, 0);
+}
diff --git a/lib/character/presets.ts b/lib/character/presets.ts
new file mode 100644
index 0000000..161d980
--- /dev/null
+++ b/lib/character/presets.ts
@@ -0,0 +1,141 @@
+import {
+ clampHsl,
+ hexToRgb,
+ hslToRgb,
+ rgbToHex,
+ rgbToHsl,
+ type HSL,
+} from "./color-utils";
+import type { LayerVariantState } from "./types";
+
+export type ColorPreset = {
+ id: string;
+ name: string;
+ hex: string;
+ hsl: HSL;
+};
+
+export type LayerVariant = {
+ id: string;
+ label: string;
+ src: string;
+};
+
+export type CharacterLayerId = "skin" | "pants" | "torso" | "head" | "eyes";
+
+export type CharacterLayer = {
+ id: CharacterLayerId;
+ label: string;
+ allowVariants: boolean;
+ variants: LayerVariant[];
+};
+
+function presetFromHsl(id: string, name: string, hsl: HSL): ColorPreset {
+ const clamped = clampHsl(hsl);
+ return {
+ id,
+ name,
+ hsl: clamped,
+ hex: rgbToHex(hslToRgb(clamped.h, clamped.s, clamped.l)),
+ };
+}
+
+function presetFromHex(id: string, name: string, hex: string): ColorPreset {
+ const rgb = hexToRgb(hex);
+ return presetFromHsl(id, name, rgbToHsl(rgb.r, rgb.g, rgb.b));
+}
+
+export const COLOR_PRESETS: ColorPreset[] = [
+ presetFromHsl("black", "Black", { h: 0, s: 0, l: 5 }),
+ presetFromHsl("gray", "Gray", { h: 0, s: 0, l: 50 }),
+ presetFromHsl("white", "White", { h: 0, s: 0, l: 100 }),
+ presetFromHex("red", "Red", "#FF2020"),
+ presetFromHex("orange", "Orange", "#FF8800"),
+ presetFromHex("yellow", "Yellow", "#FFD700"),
+ presetFromHex("green", "Green", "#22AA44"),
+ presetFromHex("blue", "Blue", "#2266FF"),
+ presetFromHex("indigo", "Indigo", "#4B0082"),
+ presetFromHex("porcelain", "Porcelain", "#FFDFC4"),
+ presetFromHex("fair", "Fair", "#F0C9A5"),
+ presetFromHex("light", "Light", "#E0AC69"),
+ presetFromHex("medium", "Medium", "#C68642"),
+ presetFromHex("tan", "Tan", "#A87149"),
+ presetFromHex("brown", "Brown", "#8D5524"),
+ presetFromHex("dark", "Dark", "#5C3317"),
+ presetFromHex("deep", "Deep", "#3B2212"),
+ presetFromHex("onyx", "Onyx", "#140C08"),
+];
+
+export const DEFAULT_SKIN_COLOR =
+ COLOR_PRESETS.find((preset) => preset.id === "light")!;
+
+export const PIECE_SLOT_COUNT = 8;
+
+export const CHARACTER_LAYERS: CharacterLayer[] = [
+ {
+ id: "skin",
+ label: "Skin",
+ allowVariants: false,
+ variants: [
+ { id: "skin-1", label: "Skin 1", src: "/character/skin/skin-1.png" },
+ ],
+ },
+ {
+ id: "pants",
+ label: "Pants",
+ allowVariants: true,
+ variants: [],
+ },
+ {
+ id: "torso",
+ label: "Torso",
+ allowVariants: true,
+ variants: [
+ { 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",
+ label: "Eyes",
+ allowVariants: true,
+ variants: [],
+ },
+];
+
+export const LAYER_DEFAULT_COLORS: Record = {
+ skin: DEFAULT_SKIN_COLOR.hsl,
+ pants: COLOR_PRESETS.find((preset) => preset.id === "blue")!.hsl,
+ torso: COLOR_PRESETS.find((preset) => preset.id === "red")!.hsl,
+ head: COLOR_PRESETS.find((preset) => preset.id === "yellow")!.hsl,
+ eyes: COLOR_PRESETS.find((preset) => preset.id === "indigo")!.hsl,
+};
+
+export function buildDefaultVariants(): LayerVariantState {
+ return CHARACTER_LAYERS.reduce((state, layer) => {
+ state[layer.id] = layer.variants[0]?.id ?? "";
+ return state;
+ }, {} as LayerVariantState);
+}
+
+export function getLayerById(layerId: CharacterLayerId): CharacterLayer {
+ return CHARACTER_LAYERS.find((layer) => layer.id === layerId)!;
+}
+
+export function getSelectedVariant(
+ layerId: CharacterLayerId,
+ variantId: string,
+): LayerVariant | undefined {
+ return getLayerById(layerId).variants.find(
+ (variant) => variant.id === variantId,
+ );
+}
+
+/** @deprecated Use DEFAULT_SKIN_COLOR */
+export const DEFAULT_BASE_COLOR = DEFAULT_SKIN_COLOR;
diff --git a/lib/character/types.ts b/lib/character/types.ts
new file mode 100644
index 0000000..4eaee31
--- /dev/null
+++ b/lib/character/types.ts
@@ -0,0 +1,5 @@
+import type { HSL } from "./color-utils";
+import type { CharacterLayerId } from "./presets";
+
+export type LayerColorState = Record;
+export type LayerVariantState = Record;
diff --git a/public/character/eyes/.gitkeep b/public/character/eyes/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/public/character/head/.gitkeep b/public/character/head/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/public/character/pants/.gitkeep b/public/character/pants/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/public/character/skin/skin-1.png b/public/character/skin/skin-1.png
new file mode 100644
index 0000000000000000000000000000000000000000..ad00028f45fa139c1da4cefc37705efecd6f6fcf
GIT binary patch
literal 580
zcmV-K0=xZ*P)ID}TAt|V%AYT9g02y>e
zSad^gZEa<4bO1wgWnpw>WFU8GbZ8()Nlj2!fese{00FE?L_t(Y$IVsEYQjJe{xT*+=mhFMWU>b1HoWl_lU+Dy2P$MGz^a&~6X9>t?h0
zp*^$%IXFA>eY4-rjKE(Tk$()V7M$7%!rczdW-}HTMG*$4r=Txy2p6*?fShbn%3OtM
z3gFXmhze4YgLrZPKnLJc%0kB>7chk1kb^k0tGO%po0KxyZ>WJ@hG6_m)<6JUTIYf(
z&2(o1;C{U>{B(}vXwlgOU@YgG0L%qgss=x{fz^UrpaPjaR>4*O8a1m$+8HG6eLs+P
z?`Mgdl2ZlznBK*N)^mS!_#VRf?VkluF&;b;ZEOCt}an8@pP0cG|a4t$sEJ;mK
zD9lFz@i_i=LLgUz0gvljyp=z7CRc7?!LEpZd#4!Cug(nc!Oql&MS#Piy1s!{an^LB{Ts56pUC2
literal 0
HcmV?d00001
diff --git a/public/character/torso/torso-2.png b/public/character/torso/torso-2.png
new file mode 100644
index 0000000000000000000000000000000000000000..5a25f7f725a5329b4443a3bcc7d9bdeceb30b996
GIT binary patch
literal 261
zcmeAS@N?(olHy`uVBq!ia0vp^0zj<5!3HFyJAa%3Qfx`y?k)@<7Q@wL`<4PJ&H|6f
zVg?31We{epSZZGe6l5>)^mS!_#VRf?VkluF&;b;ZEOCt}an8@pP0cG|a4t$sEJ;mK
zD9lFz@i_i=LLgUz0gvljyp=z7CRc7?!LEpZd#4!Cug(nc!Oql&MS#Piy1s!{an^LB{Ts56pUC2
literal 0
HcmV?d00001