1 Commits

Author SHA1 Message Date
naomi 66c2f7e8e9 wip: paperdoll 2026-03-19 21:06:13 -07:00
15 changed files with 333 additions and 92 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/api", "name": "@elysium/api",
"version": "0.2.0", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/web", "name": "@elysium/web",
"version": "0.2.0", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -158,6 +158,15 @@ const howToPlay = [
+ " is visible on your public profile page.", + " is visible on your public profile page.",
title: "📋 Character Sheet", 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: body:
"Earn Titles by reaching milestones — defeating bosses, completing" "Earn Titles by reaching milestones — defeating bosses, completing"
@@ -143,10 +143,6 @@ const AdventurerCard = ({
{" essence/s each"} {" essence/s each"}
</p> </p>
} }
<p>
{formatNumber(adventurer.combatPower)}
{" combat power each"}
</p>
</div> </div>
<div className="adventurer-count"> <div className="adventurer-count">
{"×"} {"×"}
@@ -267,23 +267,6 @@ const BossPanel = (): JSX.Element => {
} }
const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state; const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
const zoneBosses = bosses.filter((boss) => { const zoneBosses = bosses.filter((boss) => {
return boss.zoneId === activeZoneId; return boss.zoneId === activeZoneId;
}); });
@@ -410,27 +393,6 @@ const BossPanel = (): JSX.Element => {
zones={zones} zones={zones}
/> />
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<div className="party-combat-stats"> <div className="party-combat-stats">
<div className="combat-stat"> <div className="combat-stat">
<span className="stat-label">{"⚔️ Party DPS"}</span> <span className="stat-label">{"⚔️ Party DPS"}</span>
@@ -9,8 +9,10 @@
/* eslint-disable max-statements -- Component requires many state declarations */ /* eslint-disable max-statements -- Component requires many state declarations */
/* eslint-disable max-lines -- Large component with editing and view modes */ /* eslint-disable max-lines -- Large component with editing and view modes */
import { import {
defaultAppearance,
DEFAULT_PROFILE_SETTINGS, DEFAULT_PROFILE_SETTINGS,
STORY_CHAPTERS, STORY_CHAPTERS,
type Appearance,
type EquipmentBonus, type EquipmentBonus,
type EquipmentRarity, type EquipmentRarity,
type EquipmentType, type EquipmentType,
@@ -87,7 +89,7 @@ const formatBonus = (bonus: EquipmentBonus): string => {
* @returns The JSX element. * @returns The JSX element.
*/ */
const CharacterSheetPanel = (): JSX.Element => { const CharacterSheetPanel = (): JSX.Element => {
const { state, loginStreak } = useGame(); const { state, loginStreak, updateAppearance } = useGame();
const player = state?.player; const player = state?.player;
const [ sheet, setSheet ] = useState<CharacterSheetData>(emptySheet); const [ sheet, setSheet ] = useState<CharacterSheetData>(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<HTMLSelectElement>): void {
handleAppearanceChange("skinTone", event.target.value);
}
function handleHairStyleChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("hairStyle", event.target.value);
}
function handleHairColourChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("hairColour", event.target.value);
}
function handleOutfitChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("outfit", event.target.value);
}
function handleAccessoryChange(event: ChangeEvent<HTMLSelectElement>): void {
handleAppearanceChange("accessory", event.target.value);
}
if (loading) { if (loading) {
return ( return (
<section className="panel"> <section className="panel">
@@ -573,6 +604,116 @@ const CharacterSheetPanel = (): JSX.Element => {
} }
</div> </div>
<div className="character-sheet-section">
<h3 className="character-sheet-section-title">
{"🎨 Appearance"}
</h3>
<p className="character-sheet-hint">
{"Customise your adventurer's look. Changes save automatically."}
</p>
<div className="appearance-editor">
<label
className="character-sheet-label"
htmlFor="appearance-skin-tone"
>
{"Skin Tone"}
</label>
<select
className="character-sheet-select"
id="appearance-skin-tone"
onChange={handleSkinToneChange}
value={currentAppearance.skinTone}
>
<option value="pale">{"Pale"}</option>
<option value="light">{"Light"}</option>
<option value="tan">{"Tan"}</option>
<option value="medium">{"Medium"}</option>
<option value="dark">{"Dark"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-hair-style"
>
{"Hair Style"}
</label>
<select
className="character-sheet-select"
id="appearance-hair-style"
onChange={handleHairStyleChange}
value={currentAppearance.hairStyle}
>
<option value="short">{"Short"}</option>
<option value="shoulder">{"Shoulder-length"}</option>
<option value="long">{"Long"}</option>
<option value="ponytail">{"Ponytail"}</option>
<option value="twintails">{"Twin Tails"}</option>
<option value="bun">{"Bun"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-hair-colour"
>
{"Hair Colour"}
</label>
<select
className="character-sheet-select"
id="appearance-hair-colour"
onChange={handleHairColourChange}
value={currentAppearance.hairColour}
>
<option value="brown">{"Brown"}</option>
<option value="black">{"Black"}</option>
<option value="blonde">{"Blonde"}</option>
<option value="red">{"Red"}</option>
<option value="auburn">{"Auburn"}</option>
<option value="silver">{"Silver"}</option>
<option value="blue">{"Blue"}</option>
<option value="purple">{"Purple"}</option>
<option value="pink">{"Pink"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-outfit"
>
{"Outfit"}
</label>
<select
className="character-sheet-select"
id="appearance-outfit"
onChange={handleOutfitChange}
value={currentAppearance.outfit}
>
<option value="warrior">{"Warrior"}</option>
<option value="mage">{"Mage"}</option>
<option value="rogue">{"Rogue"}</option>
<option value="archer">{"Archer"}</option>
<option value="bard">{"Bard"}</option>
<option value="ranger">{"Ranger"}</option>
</select>
<label
className="character-sheet-label"
htmlFor="appearance-accessory"
>
{"Accessory"}
</label>
<select
className="character-sheet-select"
id="appearance-accessory"
onChange={handleAccessoryChange}
value={currentAppearance.accessory}
>
<option value="none">{"None"}</option>
<option value="glasses">{"Glasses"}</option>
<option value="hat">{"Hat"}</option>
<option value="cape">{"Cape"}</option>
</select>
</div>
</div>
<div className="character-sheet-section"> <div className="character-sheet-section">
<h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3> <h3 className="character-sheet-section-title">{"🗡️ Equipment"}</h3>
{sheet.equippedItems.length > 0 {sheet.equippedItems.length > 0
@@ -31,6 +31,7 @@ import { LoginBonusModal } from "./loginBonusModal.js";
import { MilestoneToast } from "./milestoneToast.js"; import { MilestoneToast } from "./milestoneToast.js";
import { OfflineModal } from "./offlineModal.js"; import { OfflineModal } from "./offlineModal.js";
import { OutdatedSchemaModal } from "./outdatedSchemaModal.js"; import { OutdatedSchemaModal } from "./outdatedSchemaModal.js";
import { PaperDoll } from "./paperDoll.js";
import { PrestigePanel } from "./prestigePanel.js"; import { PrestigePanel } from "./prestigePanel.js";
import { QuestPanel } from "./questPanel.js"; import { QuestPanel } from "./questPanel.js";
import { QuestCompleteToast, QuestFailedToast } from "./questToast.js"; import { QuestCompleteToast, QuestFailedToast } from "./questToast.js";
@@ -193,6 +194,7 @@ const GameLayout = (): JSX.Element => {
<aside className="game-sidebar"> <aside className="game-sidebar">
<ClickArea /> <ClickArea />
<div id="tree-nation-offset-website" /> <div id="tree-nation-offset-website" />
<PaperDoll />
<p className="game-copyright">{"© NHCarrigan"}</p> <p className="game-copyright">{"© NHCarrigan"}</p>
</aside> </aside>
@@ -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<SkinTone, string> = {
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<HairColour, string> = {
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 (
<div className="paper-doll">
{/* Base body — skin-toneable */}
<img
alt=""
className="paper-doll-layer paper-doll-body"
src={cdnImage("paper-doll", "body")}
style={{ filter: skinToneFilters[skinTone] }}
/>
{/* Outfit layer */}
<img
alt=""
className="paper-doll-layer paper-doll-outfit"
src={cdnImage("paper-doll", `outfit-${outfit}`)}
/>
{/* Hair layer — colour-tintable greyscale */}
<img
alt=""
className="paper-doll-layer paper-doll-hair"
src={cdnImage("paper-doll", `hair-${hairStyle}`)}
style={{ filter: hairColourFilters[hairColour] }}
/>
{accessory === "none"
? null
: <img
alt=""
className="paper-doll-layer paper-doll-accessory"
src={cdnImage("paper-doll", `accessory-${accessory}`)}
/>}
</div>
);
};
export { PaperDoll };
+3 -45
View File
@@ -4,7 +4,6 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
/* eslint-disable max-lines -- QuestPanel with sub-component and helper functions */
/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */ /* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */ /* eslint-disable complexity -- Many conditional render paths */
@@ -149,7 +148,8 @@ const QuestCard = ({
&& <p className="quest-failure-chance"> && <p className="quest-failure-chance">
{"🎲 "} {"🎲 "}
{String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))} {String(Math.round((zoneFailureChance[quest.zoneId] ?? 0) * 100))}
{"% failure chance"} {"% failure chance — if failed, the quest resets"}
{" and must be retried."}
</p> </p>
} }
{quest.status === "available" && quest.lastFailedAt !== undefined {quest.status === "available" && quest.lastFailedAt !== undefined
@@ -208,24 +208,7 @@ const QuestPanel = (): JSX.Element => {
); );
} }
const { adventurers, autoQuest, bosses, quests, zones } = state; const { adventurers, autoQuest, quests, zones } = state;
const activeZone = zones.find((zone) => {
return zone.id === activeZoneId;
});
const zoneIsLocked = activeZone?.status === "locked";
const unlockBoss = activeZone?.unlockBossId === null
|| activeZone?.unlockBossId === undefined
? undefined
: bosses.find((boss) => {
return boss.id === activeZone.unlockBossId;
});
const unlockQuest = activeZone?.unlockQuestId === null
|| activeZone?.unlockQuestId === undefined
? undefined
: quests.find((quest) => {
return quest.id === activeZone.unlockQuestId;
});
let partyCombatPower = 0; let partyCombatPower = 0;
for (const adventurer of adventurers) { for (const adventurer of adventurers) {
const contribution = adventurer.combatPower * adventurer.count; const contribution = adventurer.combatPower * adventurer.count;
@@ -324,31 +307,6 @@ const QuestPanel = (): JSX.Element => {
zones={zones} zones={zones}
/> />
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
? <div className="exploration-zone-locked-hint">
<p>{"🔒 This zone is locked. Unlock quests by:"}</p>
{unlockBoss === undefined
? null
: <p>
{"⚔️ Defeat: "}
{unlockBoss.name}
</p>
}
{unlockQuest === undefined
? null
: <p>
{"📜 Complete: "}
{unlockQuest.name}
</p>
}
</div>
: null
}
<p className="quest-failure-note">
{"⚠️ If a quest fails, it resets with no rewards — you must retry."}
</p>
<div className="quest-list"> <div className="quest-list">
{visibleQuests.map((quest) => { {visibleQuests.map((quest) => {
return ( return (
+18
View File
@@ -14,6 +14,7 @@
import { import {
STORY_CHAPTERS, STORY_CHAPTERS,
type Achievement, type Achievement,
type Appearance,
type ApotheosisResponse, type ApotheosisResponse,
type BossChallengeResponse, type BossChallengeResponse,
type ExploreCollectResponse, type ExploreCollectResponse,
@@ -452,6 +453,12 @@ interface GameContextValue {
*/ */
toggleAutoAdventurer: ()=> void; 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). * 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) => { const setActiveCompanion = useCallback((companionId: string | null) => {
setState((previous) => { setState((previous) => {
if (previous === null) { if (previous === null) {
@@ -2239,6 +2255,7 @@ export const GameProvider = ({
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
updateAppearance,
}; };
}, [ }, [
apotheosis, apotheosis,
@@ -2311,6 +2328,7 @@ export const GameProvider = ({
unlockedAchievements, unlockedAchievements,
unlockedCodexEntryIds, unlockedCodexEntryIds,
unlockedStoryChapterIds, unlockedStoryChapterIds,
updateAppearance,
]); ]);
return ( return (
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "elysium", "name": "elysium",
"version": "0.2.0", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@elysium/types", "name": "@elysium/types",
"version": "0.2.0", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "./prod/src/index.js", "main": "./prod/src/index.js",
+9
View File
@@ -5,6 +5,15 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
export type { ApotheosisData } from "./interfaces/apotheosis.js"; 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 { export type {
Companion, Companion,
CompanionBonus, CompanionBonus,
@@ -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 };
@@ -7,6 +7,7 @@
import type { Achievement } from "./achievement.js"; import type { Achievement } from "./achievement.js";
import type { Adventurer } from "./adventurer.js"; import type { Adventurer } from "./adventurer.js";
import type { ApotheosisData } from "./apotheosis.js"; import type { ApotheosisData } from "./apotheosis.js";
import type { Appearance } from "./appearance.js";
import type { Boss } from "./boss.js"; import type { Boss } from "./boss.js";
import type { CodexState } from "./codex.js"; import type { CodexState } from "./codex.js";
import type { CompanionState } from "./companion.js"; import type { CompanionState } from "./companion.js";
@@ -98,6 +99,12 @@ interface GameState {
* Schema version — used to detect saves from older game versions. * Schema version — used to detect saves from older game versions.
*/ */
schemaVersion?: number; schemaVersion?: number;
/**
* Paper doll appearance customisation — optional for backwards compatibility.
* Persists across prestige and transcendence resets.
*/
appearance?: Appearance;
} }
export type { GameState }; export type { GameState };