generated from nhcarrigan/template
11e97325cb
## Summary - Adds `apps/web/src/utils/cdn.ts` with a `cdnImage(folder, id)` helper that builds URLs from `https://cdn.nhcarrigan.com/elysium/` - Wires CDN art into all 13 game panels (bosses, quests, adventurers, companions, equipment, upgrades, prestige, transcendence, achievements, explorations, crafting, story, codex) - Zone selector tabs now display 16:9 zone art thumbnails in place of emoji icons - Adds a fixed background image at 15% opacity via `body::before` - Documents the art generation and CDN upload process in `CLAUDE.md` for future expansions Resolves #15 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #43 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
195 lines
5.8 KiB
TypeScript
195 lines
5.8 KiB
TypeScript
/**
|
|
* @file Codex panel component displaying discovered lore entries.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* eslint-disable max-lines-per-function -- Complex component with zone and entry rendering */
|
|
import { type JSX, useState } from "react";
|
|
import { useGame } from "../../context/gameContext.js";
|
|
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
|
|
import { cdnImage } from "../../utils/cdn.js";
|
|
import type { CodexEntry } from "@elysium/types";
|
|
|
|
/**
|
|
* Converts a fraction to a percentage value.
|
|
* @param numerator - The numerator value.
|
|
* @param denominator - The denominator value.
|
|
* @returns The percentage as a number between 0 and 100.
|
|
*/
|
|
const toPercent = (numerator: number, denominator: number): number => {
|
|
if (denominator === 0) {
|
|
return 0;
|
|
}
|
|
const scaled = numerator * 100;
|
|
return scaled / denominator;
|
|
};
|
|
|
|
const sourceBadge: Record<CodexEntry["sourceType"], string> = {
|
|
adventurer: "👥",
|
|
boss: "⚔️",
|
|
equipment: "🛡️",
|
|
exploration: "🧭",
|
|
prestige: "🔮",
|
|
quest: "📜",
|
|
recipe: "⚗️",
|
|
upgrade: "🔧",
|
|
zone: "🗺️",
|
|
};
|
|
|
|
const sourceTypeFolder: Record<CodexEntry["sourceType"], string> = {
|
|
adventurer: "adventurers",
|
|
boss: "bosses",
|
|
equipment: "equipment",
|
|
exploration: "explorations",
|
|
prestige: "prestige-upgrades",
|
|
quest: "quests",
|
|
recipe: "recipes",
|
|
upgrade: "upgrades",
|
|
zone: "zones",
|
|
};
|
|
|
|
/**
|
|
* Renders the codex panel with lore entries grouped by zone.
|
|
* @returns The JSX element.
|
|
*/
|
|
const CodexPanel = (): JSX.Element => {
|
|
const { state } = useGame();
|
|
const [ expandedId, setExpandedId ] = useState<string | null>(null);
|
|
|
|
if (state === null) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading..."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const unlockedIds = new Set(state.codex?.unlockedEntryIds ?? []);
|
|
const totalEntries = CODEX_ENTRIES.length;
|
|
const unlockedCount = CODEX_ENTRIES.filter((entry) => {
|
|
return unlockedIds.has(entry.id);
|
|
}).length;
|
|
const progressPercent = toPercent(unlockedCount, totalEntries);
|
|
|
|
const entriesByZone = Object.entries(ZONE_LABELS).
|
|
map(([ zoneId, zoneName ]) => {
|
|
const entries = CODEX_ENTRIES.filter((entry) => {
|
|
return entry.zoneId === zoneId;
|
|
});
|
|
const unlockedEntries = entries.filter((entry) => {
|
|
return unlockedIds.has(entry.id);
|
|
});
|
|
return {
|
|
entries,
|
|
unlockedEntries,
|
|
zoneId,
|
|
zoneName,
|
|
};
|
|
}).
|
|
filter(({ entries }) => {
|
|
return entries.length > 0;
|
|
});
|
|
|
|
return (
|
|
<section className="panel codex-panel">
|
|
<h2>{"📖 Codex"}</h2>
|
|
|
|
<div className="codex-progress">
|
|
<p className="codex-progress-text">
|
|
{"Lore discovered: "}
|
|
<strong>
|
|
{unlockedCount}
|
|
{" / "}
|
|
{totalEntries}
|
|
</strong>
|
|
</p>
|
|
<div className="codex-progress-bar">
|
|
<div
|
|
className="codex-progress-fill"
|
|
style={{ width: `${String(Math.round(progressPercent))}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{entriesByZone.map(({ zoneId, zoneName, entries, unlockedEntries }) => {
|
|
return (
|
|
<div className="codex-zone" key={zoneId}>
|
|
<h3 className="codex-zone-header">
|
|
{zoneName}
|
|
<span className="codex-zone-count">
|
|
{unlockedEntries.length}
|
|
{"/"}
|
|
{entries.length}
|
|
</span>
|
|
</h3>
|
|
|
|
<div className="codex-entries">
|
|
{entries.map((entry) => {
|
|
const isUnlocked = unlockedIds.has(entry.id);
|
|
const isExpanded = expandedId === entry.id;
|
|
|
|
if (!isUnlocked) {
|
|
return (
|
|
<div className="codex-entry locked" key={entry.id}>
|
|
<div className="codex-entry-header">
|
|
<span className="codex-lock">{"🔒"}</span>
|
|
<span className="codex-entry-title">{"???"}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function handleExpand(): void {
|
|
setExpandedId(isExpanded
|
|
? null
|
|
: entry.id);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`codex-entry unlocked ${
|
|
isExpanded
|
|
? "expanded"
|
|
: ""
|
|
}`}
|
|
key={entry.id}
|
|
onClick={handleExpand}
|
|
>
|
|
<div className="codex-entry-header">
|
|
<span className="codex-source-badge">
|
|
{sourceBadge[entry.sourceType]}
|
|
</span>
|
|
<span className="codex-entry-title">{entry.title}</span>
|
|
<span className="codex-chevron">
|
|
{isExpanded
|
|
? "▲"
|
|
: "▼"}
|
|
</span>
|
|
</div>
|
|
{isExpanded
|
|
? <>
|
|
<img
|
|
alt={entry.title}
|
|
className="codex-entry-image"
|
|
src={cdnImage(
|
|
sourceTypeFolder[entry.sourceType],
|
|
entry.sourceId,
|
|
)}
|
|
/>
|
|
<p className="codex-entry-content">{entry.content}</p>
|
|
</>
|
|
: null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { CodexPanel };
|