From 66c2f7e8e9cf42c1048107291eb99f73c4bbf940 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 19 Mar 2026 21:06:13 -0700 Subject: [PATCH] wip: paperdoll --- apps/web/src/components/game/aboutPanel.tsx | 9 ++ .../components/game/characterSheetPanel.tsx | 143 +++++++++++++++++- apps/web/src/components/game/gameLayout.tsx | 2 + apps/web/src/components/game/paperDoll.tsx | 89 +++++++++++ apps/web/src/context/gameContext.tsx | 18 +++ packages/types/src/index.ts | 9 ++ packages/types/src/interfaces/appearance.ts | 50 ++++++ packages/types/src/interfaces/gameState.ts | 7 + 8 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/game/paperDoll.tsx create mode 100644 packages/types/src/interfaces/appearance.ts diff --git a/apps/web/src/components/game/aboutPanel.tsx b/apps/web/src/components/game/aboutPanel.tsx index 804110e..76d6217 100644 --- a/apps/web/src/components/game/aboutPanel.tsx +++ b/apps/web/src/components/game/aboutPanel.tsx @@ -158,6 +158,15 @@ const howToPlay = [ + " is visible on your public profile page.", title: "📋 Character Sheet", }, + { + body: + "Customise your adventurer's appearance from the Character tab. Choose" + + " your skin tone, hair style, hair colour, outfit, and accessory." + + " Your paper doll is displayed in the sidebar for you to see at all" + + " times. Appearance settings are purely cosmetic and persist through" + + " prestige and transcendence resets.", + title: "🎨 Paper Doll", + }, { body: "Earn Titles by reaching milestones — defeating bosses, completing" diff --git a/apps/web/src/components/game/characterSheetPanel.tsx b/apps/web/src/components/game/characterSheetPanel.tsx index b716d7e..d9f1844 100644 --- a/apps/web/src/components/game/characterSheetPanel.tsx +++ b/apps/web/src/components/game/characterSheetPanel.tsx @@ -9,8 +9,10 @@ /* eslint-disable max-statements -- Component requires many state declarations */ /* eslint-disable max-lines -- Large component with editing and view modes */ import { + defaultAppearance, DEFAULT_PROFILE_SETTINGS, STORY_CHAPTERS, + type Appearance, type EquipmentBonus, type EquipmentRarity, type EquipmentType, @@ -87,7 +89,7 @@ const formatBonus = (bonus: EquipmentBonus): string => { * @returns The JSX element. */ const CharacterSheetPanel = (): JSX.Element => { - const { state, loginStreak } = useGame(); + const { state, loginStreak, updateAppearance } = useGame(); const player = state?.player; const [ sheet, setSheet ] = useState(emptySheet); @@ -276,6 +278,35 @@ const CharacterSheetPanel = (): JSX.Element => { }); } + const currentAppearance = state?.appearance ?? defaultAppearance; + + function handleAppearanceChange( + field: keyof Appearance, + value: string, + ): void { + updateAppearance({ ...currentAppearance, [field]: value }); + } + + function handleSkinToneChange(event: ChangeEvent): void { + handleAppearanceChange("skinTone", event.target.value); + } + + function handleHairStyleChange(event: ChangeEvent): void { + handleAppearanceChange("hairStyle", event.target.value); + } + + function handleHairColourChange(event: ChangeEvent): void { + handleAppearanceChange("hairColour", event.target.value); + } + + function handleOutfitChange(event: ChangeEvent): void { + handleAppearanceChange("outfit", event.target.value); + } + + function handleAccessoryChange(event: ChangeEvent): void { + handleAppearanceChange("accessory", event.target.value); + } + if (loading) { return (
@@ -573,6 +604,116 @@ const CharacterSheetPanel = (): JSX.Element => { } +
+

+ {"🎨 Appearance"} +

+

+ {"Customise your adventurer's look. Changes save automatically."} +

+
+ + + + + + + + + + + + + + +
+
+

{"🗡️ Equipment"}

{sheet.equippedItems.length > 0 diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 23819e6..5fb7d3b 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -31,6 +31,7 @@ import { LoginBonusModal } from "./loginBonusModal.js"; import { MilestoneToast } from "./milestoneToast.js"; import { OfflineModal } from "./offlineModal.js"; import { OutdatedSchemaModal } from "./outdatedSchemaModal.js"; +import { PaperDoll } from "./paperDoll.js"; import { PrestigePanel } from "./prestigePanel.js"; import { QuestPanel } from "./questPanel.js"; import { QuestCompleteToast, QuestFailedToast } from "./questToast.js"; @@ -193,6 +194,7 @@ const GameLayout = (): JSX.Element => { diff --git a/apps/web/src/components/game/paperDoll.tsx b/apps/web/src/components/game/paperDoll.tsx new file mode 100644 index 0000000..721e69a --- /dev/null +++ b/apps/web/src/components/game/paperDoll.tsx @@ -0,0 +1,89 @@ +/** + * @file Paper doll component for displaying layered adventurer appearance. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Hikari + */ +import { + defaultAppearance, + type HairColour, + type SkinTone, +} from "@elysium/types"; +import { useGame } from "../../context/gameContext.js"; +import { cdnImage } from "../../utils/cdn.js"; +import type { JSX } from "react"; + +/** + * CSS filter strings for each skin tone, applied to the base body layer. + * Uses brightness + sepia + saturation to shift the neutral base skin. + */ +const skinToneFilters: Record = { + dark: "brightness(0.55) saturate(0.55) sepia(0.5) contrast(1.1)", + light: "brightness(0.98) saturate(0.4) sepia(0.12)", + medium: "brightness(0.74) saturate(0.75) sepia(0.42)", + pale: "brightness(1.05) saturate(0.2) sepia(0.08)", + tan: "brightness(0.88) saturate(0.65) sepia(0.28)", +}; + +/** + * CSS filter strings for each hair colour. + * Applied to the greyscale hair layer via sepia + hue-rotate tinting. + */ +const hairColourFilters: Record = { + auburn: "sepia(1) saturate(3) hue-rotate(350deg)", + black: "brightness(0.15)", + blonde: "sepia(1) saturate(3) hue-rotate(5deg) brightness(1.6)", + blue: "sepia(1) saturate(5) hue-rotate(190deg)", + brown: "sepia(1) saturate(2) hue-rotate(0deg)", + pink: "sepia(1) saturate(5) hue-rotate(305deg)", + purple: "sepia(1) saturate(5) hue-rotate(245deg)", + red: "sepia(1) saturate(4) hue-rotate(345deg)", + silver: "grayscale(1) brightness(1.9) contrast(0.8)", +}; + +/** + * Renders the paper doll — a layered composite of body, outfit, hair, and + * accessory images that together represent the player's adventurer appearance. + * All layers use mix-blend-mode: multiply so white backgrounds become + * transparent, allowing the layers to composite cleanly. + * @returns The JSX element. + */ +const PaperDoll = (): JSX.Element => { + const { state } = useGame(); + const appearance = state?.appearance ?? defaultAppearance; + const { skinTone, hairStyle, hairColour, outfit, accessory } = appearance; + + return ( +
+ {/* Base body — skin-toneable */} + + {/* Outfit layer */} + + {/* Hair layer — colour-tintable greyscale */} + + {accessory === "none" + ? null + : } +
+ ); +}; + +export { PaperDoll }; diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index e3275b7..fc98eaf 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -14,6 +14,7 @@ import { STORY_CHAPTERS, type Achievement, + type Appearance, type ApotheosisResponse, type BossChallengeResponse, type ExploreCollectResponse, @@ -452,6 +453,12 @@ interface GameContextValue { */ toggleAutoAdventurer: ()=> void; + /** + * Update the player's paper doll appearance customisation. + * @param appearance - The new appearance settings. + */ + updateAppearance: (appearance: Appearance)=> void; + /** * Queue of newly unlocked codex entry IDs (for toast notifications). */ @@ -1910,6 +1917,15 @@ export const GameProvider = ({ }); }, []); + const updateAppearance = useCallback((appearance: Appearance) => { + setState((previous) => { + if (previous === null) { + return previous; + } + return { ...previous, appearance }; + }); + }, []); + const setActiveCompanion = useCallback((companionId: string | null) => { setState((previous) => { if (previous === null) { @@ -2239,6 +2255,7 @@ export const GameProvider = ({ unlockedAchievements, unlockedCodexEntryIds, unlockedStoryChapterIds, + updateAppearance, }; }, [ apotheosis, @@ -2311,6 +2328,7 @@ export const GameProvider = ({ unlockedAchievements, unlockedCodexEntryIds, unlockedStoryChapterIds, + updateAppearance, ]); return ( diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index ed06475..e8f8f4e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -5,6 +5,15 @@ * @author Naomi Carrigan */ export type { ApotheosisData } from "./interfaces/apotheosis.js"; +export type { + Accessory, + Appearance, + HairColour, + HairStyle, + Outfit, + SkinTone, +} from "./interfaces/appearance.js"; +export { defaultAppearance } from "./interfaces/appearance.js"; export type { Companion, CompanionBonus, diff --git a/packages/types/src/interfaces/appearance.ts b/packages/types/src/interfaces/appearance.ts new file mode 100644 index 0000000..ac9fa40 --- /dev/null +++ b/packages/types/src/interfaces/appearance.ts @@ -0,0 +1,50 @@ +/** + * @file Appearance type for the paper doll customisation system. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Hikari + */ + +type SkinTone = "pale" | "light" | "tan" | "medium" | "dark"; + +type HairStyle = + | "short" + | "shoulder" + | "long" + | "ponytail" + | "twintails" + | "bun"; + +type HairColour = + | "brown" + | "black" + | "blonde" + | "red" + | "auburn" + | "silver" + | "blue" + | "purple" + | "pink"; + +type Outfit = "warrior" | "mage" | "rogue" | "archer" | "bard" | "ranger"; + +type Accessory = "none" | "glasses" | "hat" | "cape"; + +interface Appearance { + skinTone: SkinTone; + hairStyle: HairStyle; + hairColour: HairColour; + outfit: Outfit; + accessory: Accessory; +} + +const defaultAppearance: Appearance = { + accessory: "none", + hairColour: "brown", + hairStyle: "short", + outfit: "warrior", + skinTone: "pale", +}; + +export type { Accessory, Appearance, HairColour, HairStyle, Outfit, SkinTone }; +export { defaultAppearance }; diff --git a/packages/types/src/interfaces/gameState.ts b/packages/types/src/interfaces/gameState.ts index 4cf3dcb..6bd1c52 100644 --- a/packages/types/src/interfaces/gameState.ts +++ b/packages/types/src/interfaces/gameState.ts @@ -7,6 +7,7 @@ import type { Achievement } from "./achievement.js"; import type { Adventurer } from "./adventurer.js"; import type { ApotheosisData } from "./apotheosis.js"; +import type { Appearance } from "./appearance.js"; import type { Boss } from "./boss.js"; import type { CodexState } from "./codex.js"; import type { CompanionState } from "./companion.js"; @@ -98,6 +99,12 @@ interface GameState { * Schema version — used to detect saves from older game versions. */ schemaVersion?: number; + + /** + * Paper doll appearance customisation — optional for backwards compatibility. + * Persists across prestige and transcendence resets. + */ + appearance?: Appearance; } export type { GameState };