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 ( +
+ + +
+ + + + + + + + +
+ Style +
+ +
+
+ +
+ Color +
+ +
+
+ +
+ Adjust + +
+
+
+
+
+ ); +} 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 0000000..ad00028 Binary files /dev/null and b/public/character/skin/skin-1.png differ diff --git a/public/character/torso/.gitkeep b/public/character/torso/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/character/torso/torso-1.png b/public/character/torso/torso-1.png new file mode 100644 index 0000000..5a25f7f Binary files /dev/null and b/public/character/torso/torso-1.png differ diff --git a/public/character/torso/torso-2.png b/public/character/torso/torso-2.png new file mode 100644 index 0000000..5a25f7f Binary files /dev/null and b/public/character/torso/torso-2.png differ