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:
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>;
|
||||
Reference in New Issue
Block a user