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:
14
app/character-creator/page.tsx
Normal file
14
app/character-creator/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { CharacterCreator } from "@/components/character/character-creator";
|
||||||
|
|
||||||
|
export default function CharacterCreatorPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-10">
|
||||||
|
<h1 className="mb-8 text-3xl font-bold tracking-tight">
|
||||||
|
Character Creation
|
||||||
|
</h1>
|
||||||
|
<CharacterCreator />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
lib/character/color-utils.ts
Normal file
177
lib/character/color-utils.ts
Normal file
@@ -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<HTMLImageElement> {
|
||||||
|
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<string, HTMLImageElement>();
|
||||||
|
|
||||||
|
export async function getCachedImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
141
lib/character/presets.ts
Normal file
141
lib/character/presets.ts
Normal file
@@ -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<CharacterLayerId, HSL> = {
|
||||||
|
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;
|
||||||
5
lib/character/types.ts
Normal file
5
lib/character/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { HSL } from "./color-utils";
|
||||||
|
import type { CharacterLayerId } from "./presets";
|
||||||
|
|
||||||
|
export type LayerColorState = Record<CharacterLayerId, HSL>;
|
||||||
|
export type LayerVariantState = Record<CharacterLayerId, string>;
|
||||||
0
public/character/eyes/.gitkeep
Normal file
0
public/character/eyes/.gitkeep
Normal file
0
public/character/head/.gitkeep
Normal file
0
public/character/head/.gitkeep
Normal file
0
public/character/pants/.gitkeep
Normal file
0
public/character/pants/.gitkeep
Normal file
BIN
public/character/skin/skin-1.png
Normal file
BIN
public/character/skin/skin-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 580 B |
0
public/character/torso/.gitkeep
Normal file
0
public/character/torso/.gitkeep
Normal file
BIN
public/character/torso/torso-1.png
Normal file
BIN
public/character/torso/torso-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 261 B |
BIN
public/character/torso/torso-2.png
Normal file
BIN
public/character/torso/torso-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 261 B |
Reference in New Issue
Block a user