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.
This commit is contained in:
2026-03-07 01:17:45 -08:00
committed by Naomi Carrigan
parent b0ed976a1d
commit fd286cd29f
11 changed files with 3838 additions and 6 deletions
@@ -51,6 +51,10 @@ const HOW_TO_PLAY = [
title: "📅 Daily Challenges",
body: "Complete daily challenges for bonus rewards including gold, essence, crystals, and runestones. Challenges reset each day and vary in difficulty. Completing all daily challenges gives an extra bonus reward.",
},
{
title: "📖 Codex",
body: "Defeating bosses, completing quests, acquiring equipment, hiring adventurers, purchasing upgrades, unlocking prestige upgrades, and discovering new zones all permanently unlock lore entries in the Codex. A badge appears on the Codex tab and a toast notification pops up each time new lore is discovered. Collect all 364 entries to build a complete picture of the world of Elysium.",
},
{
title: "☁️ Cloud Saves",
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
@@ -0,0 +1,95 @@
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>
);
};
@@ -0,0 +1,45 @@
import { useEffect } from "react";
import { CODEX_ENTRIES } from "../../data/codex.js";
import { useGame } from "../../context/GameContext.js";
interface CodexToastItemProps {
entryId: string;
onDismiss: (id: string) => void;
}
const CodexToastItem = ({ entryId, onDismiss }: CodexToastItemProps): React.JSX.Element | null => {
const entry = CODEX_ENTRIES.find((e) => e.id === entryId);
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(entryId);
}, 4000);
return () => { clearTimeout(timer); };
}, [entryId, onDismiss]);
if (!entry) return null;
return (
<div className="codex-toast" onClick={() => { onDismiss(entryId); }}>
<span className="toast-icon">📖</span>
<div className="toast-content">
<span className="toast-label"> Lore Unlocked!</span>
<span className="toast-name">{entry.title}</span>
</div>
</div>
);
};
export const CodexToast = (): React.JSX.Element | null => {
const { newCodexEntryIds, dismissCodexEntry } = useGame();
if (newCodexEntryIds.length === 0) return null;
return (
<div className="achievement-toast-container">
{newCodexEntryIds.map((id) => (
<CodexToastItem key={id} entryId={id} onDismiss={dismissCodexEntry} />
))}
</div>
);
};
+12 -4
View File
@@ -8,6 +8,8 @@ import { AdventurerPanel } from "./AdventurerPanel.js";
import { BattleModal } from "./BattleModal.js";
import { BossPanel } from "./BossPanel.js";
import { ClickArea } from "./ClickArea.js";
import { CodexPanel } from "./CodexPanel.js";
import { CodexToast } from "./CodexToast.js";
import { EditProfileModal } from "./EditProfileModal.js";
import { EquipmentPanel } from "./EquipmentPanel.js";
import { OfflineModal } from "./OfflineModal.js";
@@ -17,9 +19,9 @@ import { StatisticsPanel } from "./StatisticsPanel.js";
import { UpgradePanel } from "./UpgradePanel.js";
import { DailyChallengePanel } from "./DailyChallengePanel.js";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "statistics" | "daily" | "about";
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "statistics" | "daily" | "codex" | "about";
const TABS: { id: Tab; label: string }[] = [
const BASE_TABS: { id: Tab; label: string }[] = [
{ id: "adventurers", label: "⚔️ Adventurers" },
{ id: "upgrades", label: "🔧 Upgrades" },
{ id: "quests", label: "📜 Quests" },
@@ -29,11 +31,12 @@ const TABS: { id: Tab; label: string }[] = [
{ id: "prestige", label: "⭐ Prestige" },
{ id: "statistics", label: "📊 Statistics" },
{ id: "daily", label: "📅 Daily" },
{ id: "codex", label: "📖 Codex" },
{ id: "about", label: "️ About" },
];
export const GameLayout = (): React.JSX.Element => {
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync } = useGame();
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds } = useGame();
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
const [editingProfile, setEditingProfile] = useState(false);
@@ -71,6 +74,7 @@ export const GameLayout = (): React.JSX.Element => {
/>
<OfflineModal />
<AchievementToast />
<CodexToast />
{battleResult && (
<BattleModal battle={battleResult} onDismiss={dismissBattle} />
)}
@@ -85,7 +89,7 @@ export const GameLayout = (): React.JSX.Element => {
<main className="game-content">
<nav className="tab-bar">
{TABS.map((tab) => (
{BASE_TABS.map((tab) => (
<button
key={tab.id}
className={`tab-button ${activeTab === tab.id ? "active" : ""}`}
@@ -93,6 +97,9 @@ export const GameLayout = (): React.JSX.Element => {
type="button"
>
{tab.label}
{tab.id === "codex" && newCodexEntryIds.length > 0 && (
<span className="tab-badge">{newCodexEntryIds.length}</span>
)}
</button>
))}
</nav>
@@ -107,6 +114,7 @@ export const GameLayout = (): React.JSX.Element => {
{activeTab === "prestige" && <PrestigePanel />}
{activeTab === "statistics" && <StatisticsPanel />}
{activeTab === "daily" && <DailyChallengePanel />}
{activeTab === "codex" && <CodexPanel />}
{activeTab === "about" && <AboutPanel />}
</div>
</main>