Files
elysium/apps/web/src/components/game/CodexPanel.tsx
T
hikari fd286cd29f feat: add codex / lore book with 364 entries
Implements a full in-game Codex panel that tracks lore discovery across
seven categories: bosses (72), quests (95), zones (18), equipment (65),
adventurer tiers (32), upgrades (57), and prestige upgrades (25).

Lore entries unlock automatically as players progress — existing
completions are retroactively and silently added on first load. New
discoveries trigger a toast notification and badge counter on the
Codex tab.
2026-03-07 01:17:45 -08:00

96 lines
3.5 KiB
TypeScript

import { useState } from "react";
import type { CodexEntry } from "@elysium/types";
import { CODEX_ENTRIES, ZONE_LABELS } from "../../data/codex.js";
import { useGame } from "../../context/GameContext.js";
const SOURCE_BADGE: Record<CodexEntry["sourceType"], string> = {
boss: "⚔️",
quest: "📜",
equipment: "🛡️",
adventurer: "👥",
upgrade: "🔧",
prestige: "🔮",
zone: "🗺️",
};
export const CodexPanel = (): React.JSX.Element => {
const { state } = useGame();
const [expandedId, setExpandedId] = useState<string | null>(null);
if (!state) 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((e) => unlockedIds.has(e.id)).length;
const progressPercent = totalEntries > 0 ? (unlockedCount / totalEntries) * 100 : 0;
const entriesByZone = Object.entries(ZONE_LABELS).map(([zoneId, zoneName]) => {
const zoneEntries = CODEX_ENTRIES.filter((e) => e.zoneId === zoneId);
const unlockedZoneEntries = zoneEntries.filter((e) => unlockedIds.has(e.id));
return { zoneId, zoneName, entries: zoneEntries, unlockedEntries: unlockedZoneEntries };
}).filter(({ entries }) => 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: `${progressPercent}%` }} />
</div>
</div>
{entriesByZone.map(({ zoneId, zoneName, entries, unlockedEntries }) => (
<div key={zoneId} className="codex-zone">
<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 key={entry.id} className="codex-entry locked">
<div className="codex-entry-header">
<span className="codex-lock">🔒</span>
<span className="codex-entry-title">???</span>
</div>
</div>
);
}
return (
<div
key={entry.id}
className={`codex-entry unlocked ${isExpanded ? "expanded" : ""}`}
onClick={() => { setExpandedId(isExpanded ? null : entry.id); }}
>
<div className="codex-entry-header">
<span className="codex-source-badge">
{SOURCE_BADGE[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>
)}
</div>
);
})}
</div>
</div>
))}
</section>
);
};