generated from nhcarrigan/template
29c817230d
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
172 lines
5.1 KiB
TypeScript
172 lines
5.1 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 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: "🗺️",
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
? <p className="codex-entry-content">{entry.content}</p>
|
|
: null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { CodexPanel };
|