diff --git a/components/character/character-creator.tsx b/components/character/character-creator.tsx index f817438..b8c37fb 100644 --- a/components/character/character-creator.tsx +++ b/components/character/character-creator.tsx @@ -15,7 +15,7 @@ import { clampHsl } from "@/lib/character/color-utils"; import { CHARACTER_LAYERS, COLOR_PRESETS, - DEFAULT_SKIN_COLOR, + DEFAULT_GRAY_COLOR, LAYER_DEFAULT_COLORS, buildDefaultVariants, getLayerById, @@ -41,7 +41,7 @@ export function CharacterCreator() { buildDefaultVariants, ); const [activePresetId, setActivePresetId] = useState( - DEFAULT_SKIN_COLOR.id, + DEFAULT_GRAY_COLOR.id, ); const [activeLayerId, setActiveLayerId] = useState< (typeof CHARACTER_LAYERS)[number]["id"] @@ -69,7 +69,14 @@ export function CharacterCreator() { const handleLayerChange = (layerId: (typeof CHARACTER_LAYERS)[number]["id"]) => { 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) => { @@ -107,6 +114,7 @@ export function CharacterCreator() { variants={pieceVariants} activeId={variants[activeLayerId]} color={activeColor} + skinColor={colors.skin} onSelect={handleVariantChange} /> diff --git a/components/character/character-layer-preview.tsx b/components/character/character-layer-preview.tsx index 2dbfd57..069ff34 100644 --- a/components/character/character-layer-preview.tsx +++ b/components/character/character-layer-preview.tsx @@ -65,10 +65,22 @@ export function CharacterLayerPreview({ for (const { layer, image } of images) { const layerColor = colors[layer.id]; + const rgb = hslToRgb(layerColor.h, layerColor.s, layerColor.l); + drawTintedSprite( ctx, 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, ); } } diff --git a/components/character/character-piece-selector.tsx b/components/character/character-piece-selector.tsx index 1c8c372..9b05be1 100644 --- a/components/character/character-piece-selector.tsx +++ b/components/character/character-piece-selector.tsx @@ -1,7 +1,9 @@ "use client"; +import { CircleSlash } from "lucide-react"; + 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 type { LayerVariant } from "@/lib/character/presets"; @@ -11,6 +13,7 @@ type CharacterPieceSelectorProps = { variants: LayerVariant[]; activeId: string; color: HSL; + skinColor?: HSL; onSelect: (variantId: string) => void; }; @@ -18,6 +21,7 @@ export function CharacterPieceSelector({ variants, activeId, color, + skinColor, onSelect, }: CharacterPieceSelectorProps) { const slots = Array.from({ length: PIECE_SLOT_COUNT }, (_, index) => @@ -37,6 +41,31 @@ export function CharacterPieceSelector({ ); } + if (variant.id === NONE_VARIANT_ID) { + return ( + + ); + } + return ( ); })} diff --git a/components/character/tinted-sprite-icon.tsx b/components/character/tinted-sprite-icon.tsx index dbc2407..f391348 100644 --- a/components/character/tinted-sprite-icon.tsx +++ b/components/character/tinted-sprite-icon.tsx @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"; type TintedSpriteIconProps = { src: string; color: HSL; + skinColor?: HSL; size?: number; className?: string; }; @@ -20,6 +21,7 @@ type TintedSpriteIconProps = { export function TintedSpriteIcon({ src, color, + skinColor, size = 40, className, }: TintedSpriteIconProps) { @@ -41,7 +43,21 @@ export function TintedSpriteIcon({ 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)); + + 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(); @@ -49,7 +65,7 @@ export function TintedSpriteIcon({ return () => { cancelled = true; }; - }, [src, color]); + }, [src, color, skinColor]); return ( = 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 { return new Promise((resolve, reject) => { const image = new Image(); @@ -159,6 +223,7 @@ export function drawTintedSprite( ctx: CanvasRenderingContext2D, image: HTMLImageElement, color: RGB, + options?: TintOptions, ): void { const width = image.width; const height = image.height; @@ -171,7 +236,13 @@ export function drawTintedSprite( offscreenCtx.drawImage(image, 0, 0); const imageData = offscreenCtx.getImageData(0, 0, width, height); - tintImageData(imageData, color); + + if (options?.mode === "eyes" && options.skinColor) { + tintEyesImageData(imageData, color, options.skinColor); + } else { + 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 index 161d980..0889070 100644 --- a/lib/character/presets.ts +++ b/lib/character/presets.ts @@ -21,7 +21,21 @@ export type LayerVariant = { 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 = { 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)); } +function layerVariants( + layerId: Exclude, + 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, + label: string, + count: number, +): LayerVariant[] { + return [NONE_VARIANT, ...layerVariants(layerId, label, count)]; +} + 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 }), + presetFromHsl("black", "Black", { h: 0, s: 0, l: 5 }), presetFromHex("red", "Red", "#FF2020"), presetFromHex("orange", "Orange", "#FF8800"), presetFromHex("yellow", "Yellow", "#FFD700"), @@ -66,11 +103,14 @@ export const COLOR_PRESETS: ColorPreset[] = [ presetFromHex("onyx", "Onyx", "#140C08"), ]; -export const DEFAULT_SKIN_COLOR = - COLOR_PRESETS.find((preset) => preset.id === "light")!; +export const DEFAULT_GRAY_COLOR = + COLOR_PRESETS.find((preset) => preset.id === "gray")!; + +export const DEFAULT_SKIN_COLOR = DEFAULT_GRAY_COLOR; export const PIECE_SLOT_COUNT = 8; +/** Stack order: bottom → top */ export const CHARACTER_LAYERS: CharacterLayer[] = [ { id: "skin", @@ -84,42 +124,48 @@ export const CHARACTER_LAYERS: CharacterLayer[] = [ id: "pants", label: "Pants", allowVariants: true, - variants: [], + variants: layerVariantsWithNone("pants", "Pants", 3), + }, + { + id: "shoes", + label: "Shoes", + allowVariants: true, + variants: layerVariantsWithNone("shoes", "Shoes", 3), }, { 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: [], + variants: layerVariantsWithNone("torso", "Torso", 4), }, { id: "eyes", label: "Eyes", 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 = { - 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, + skin: DEFAULT_GRAY_COLOR.hsl, + pants: DEFAULT_GRAY_COLOR.hsl, + shoes: DEFAULT_GRAY_COLOR.hsl, + torso: DEFAULT_GRAY_COLOR.hsl, + eyes: DEFAULT_GRAY_COLOR.hsl, + head: DEFAULT_GRAY_COLOR.hsl, }; export function buildDefaultVariants(): LayerVariantState { 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; }, {} as LayerVariantState); } @@ -132,9 +178,15 @@ export function getSelectedVariant( layerId: CharacterLayerId, variantId: string, ): LayerVariant | undefined { - return getLayerById(layerId).variants.find( - (variant) => variant.id === variantId, + if (variantId === NONE_VARIANT_ID) return undefined; + + const variant = getLayerById(layerId).variants.find( + (entry) => entry.id === variantId, ); + + if (!variant?.src) return undefined; + + return variant; } /** @deprecated Use DEFAULT_SKIN_COLOR */ diff --git a/public/character/eyes/eyes-1.png b/public/character/eyes/eyes-1.png new file mode 100644 index 0000000..1b2a17a Binary files /dev/null and b/public/character/eyes/eyes-1.png differ diff --git a/public/character/eyes/eyes-2.png b/public/character/eyes/eyes-2.png new file mode 100644 index 0000000..c26920c Binary files /dev/null and b/public/character/eyes/eyes-2.png differ diff --git a/public/character/head/head-1.png b/public/character/head/head-1.png new file mode 100644 index 0000000..19a7a71 Binary files /dev/null and b/public/character/head/head-1.png differ diff --git a/public/character/head/head-2.png b/public/character/head/head-2.png new file mode 100644 index 0000000..f3c8cda Binary files /dev/null and b/public/character/head/head-2.png differ diff --git a/public/character/head/head-3.png b/public/character/head/head-3.png new file mode 100644 index 0000000..32ee7f9 Binary files /dev/null and b/public/character/head/head-3.png differ diff --git a/public/character/head/head-4.png b/public/character/head/head-4.png new file mode 100644 index 0000000..a0b47d3 Binary files /dev/null and b/public/character/head/head-4.png differ diff --git a/public/character/head/head-5.png b/public/character/head/head-5.png new file mode 100644 index 0000000..a024c0c Binary files /dev/null and b/public/character/head/head-5.png differ diff --git a/public/character/head/head-6.png b/public/character/head/head-6.png new file mode 100644 index 0000000..0ff3b9d Binary files /dev/null and b/public/character/head/head-6.png differ diff --git a/public/character/head/head-7.png b/public/character/head/head-7.png new file mode 100644 index 0000000..721749b Binary files /dev/null and b/public/character/head/head-7.png differ diff --git a/public/character/pants/pants-1.png b/public/character/pants/pants-1.png new file mode 100644 index 0000000..0d8d741 Binary files /dev/null and b/public/character/pants/pants-1.png differ diff --git a/public/character/pants/pants-2.png b/public/character/pants/pants-2.png new file mode 100644 index 0000000..82fca3c Binary files /dev/null and b/public/character/pants/pants-2.png differ diff --git a/public/character/pants/pants-3.png b/public/character/pants/pants-3.png new file mode 100644 index 0000000..0df9b4a Binary files /dev/null and b/public/character/pants/pants-3.png differ diff --git a/public/character/shoes/shoes-1.png b/public/character/shoes/shoes-1.png new file mode 100644 index 0000000..6bed3ef Binary files /dev/null and b/public/character/shoes/shoes-1.png differ diff --git a/public/character/shoes/shoes-2.png b/public/character/shoes/shoes-2.png new file mode 100644 index 0000000..eefb405 Binary files /dev/null and b/public/character/shoes/shoes-2.png differ diff --git a/public/character/shoes/shoes-3.png b/public/character/shoes/shoes-3.png new file mode 100644 index 0000000..735378e Binary files /dev/null and b/public/character/shoes/shoes-3.png differ diff --git a/public/character/skin/skin-1.png b/public/character/skin/skin-1.png index ad00028..b597929 100644 Binary files a/public/character/skin/skin-1.png and b/public/character/skin/skin-1.png differ diff --git a/public/character/torso/torso-1.png b/public/character/torso/torso-1.png index 5a25f7f..d383293 100644 Binary files a/public/character/torso/torso-1.png 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 index 5a25f7f..694c60b 100644 Binary files a/public/character/torso/torso-2.png and b/public/character/torso/torso-2.png differ diff --git a/public/character/torso/torso-3.png b/public/character/torso/torso-3.png new file mode 100644 index 0000000..98273ee Binary files /dev/null and b/public/character/torso/torso-3.png differ diff --git a/public/character/torso/torso-4.png b/public/character/torso/torso-4.png new file mode 100644 index 0000000..9ebdc7b Binary files /dev/null and b/public/character/torso/torso-4.png differ