generated from nhcarrigan/template
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:
@@ -22,7 +22,7 @@ A running list of planned features and content additions. Strike through items a
|
|||||||
|
|
||||||
- [x] **Equipment set bonuses** — Group existing equipment into named sets (e.g. "Shadow Infiltrator"). Wearing 2/3/4 pieces of a set grants escalating bonuses. Adds strategic depth without requiring lots of new items.
|
- [x] **Equipment set bonuses** — Group existing equipment into named sets (e.g. "Shadow Infiltrator"). Wearing 2/3/4 pieces of a set grants escalating bonuses. Adds strategic depth without requiring lots of new items.
|
||||||
|
|
||||||
- [ ] **The Codex / Lore Book** — Defeating bosses and completing quests unlocks lore entries about the world. Pure flavour, but gives the world depth and a collection mechanic. Show a ✨ notification when new lore unlocks.
|
- [x] **The Codex / Lore Book** — Defeating bosses and completing quests unlocks lore entries about the world. Pure flavour, but gives the world depth and a collection mechanic. Show a ✨ notification when new lore unlocks.
|
||||||
|
|
||||||
- [x] **Milestone prestige bonuses** — Every 5th prestige, earn a free prestige upgrade or a large runestone windfall. Gives players mini-goals within the prestige loop.
|
- [x] **Milestone prestige bonuses** — Every 5th prestige, earn a free prestige upgrade or a large runestone windfall. Gives players mini-goals within the prestige loop.
|
||||||
|
|
||||||
@@ -45,5 +45,5 @@ A running list of planned features and content additions. Strike through items a
|
|||||||
5. ~~Milestone prestige bonuses~~ ✅
|
5. ~~Milestone prestige bonuses~~ ✅
|
||||||
6. ~~Equipment set bonuses~~ ✅
|
6. ~~Equipment set bonuses~~ ✅
|
||||||
7. ~~Auto-prestige toggle~~ ✅
|
7. ~~Auto-prestige toggle~~ ✅
|
||||||
8. The Codex / Lore Book (flavour, lower priority)
|
8. ~~The Codex / Lore Book~~ ✅
|
||||||
9. Second prestige layer / Transcendence (big feature, save for later)
|
9. Second prestige layer / Transcendence (big feature, save for later)
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const HOW_TO_PLAY = [
|
|||||||
title: "📅 Daily Challenges",
|
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.",
|
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",
|
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.",
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,6 +8,8 @@ import { AdventurerPanel } from "./AdventurerPanel.js";
|
|||||||
import { BattleModal } from "./BattleModal.js";
|
import { BattleModal } from "./BattleModal.js";
|
||||||
import { BossPanel } from "./BossPanel.js";
|
import { BossPanel } from "./BossPanel.js";
|
||||||
import { ClickArea } from "./ClickArea.js";
|
import { ClickArea } from "./ClickArea.js";
|
||||||
|
import { CodexPanel } from "./CodexPanel.js";
|
||||||
|
import { CodexToast } from "./CodexToast.js";
|
||||||
import { EditProfileModal } from "./EditProfileModal.js";
|
import { EditProfileModal } from "./EditProfileModal.js";
|
||||||
import { EquipmentPanel } from "./EquipmentPanel.js";
|
import { EquipmentPanel } from "./EquipmentPanel.js";
|
||||||
import { OfflineModal } from "./OfflineModal.js";
|
import { OfflineModal } from "./OfflineModal.js";
|
||||||
@@ -17,9 +19,9 @@ import { StatisticsPanel } from "./StatisticsPanel.js";
|
|||||||
import { UpgradePanel } from "./UpgradePanel.js";
|
import { UpgradePanel } from "./UpgradePanel.js";
|
||||||
import { DailyChallengePanel } from "./DailyChallengePanel.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: "adventurers", label: "⚔️ Adventurers" },
|
||||||
{ id: "upgrades", label: "🔧 Upgrades" },
|
{ id: "upgrades", label: "🔧 Upgrades" },
|
||||||
{ id: "quests", label: "📜 Quests" },
|
{ id: "quests", label: "📜 Quests" },
|
||||||
@@ -29,11 +31,12 @@ const TABS: { id: Tab; label: string }[] = [
|
|||||||
{ id: "prestige", label: "⭐ Prestige" },
|
{ id: "prestige", label: "⭐ Prestige" },
|
||||||
{ id: "statistics", label: "📊 Statistics" },
|
{ id: "statistics", label: "📊 Statistics" },
|
||||||
{ id: "daily", label: "📅 Daily" },
|
{ id: "daily", label: "📅 Daily" },
|
||||||
|
{ id: "codex", label: "📖 Codex" },
|
||||||
{ id: "about", label: "ℹ️ About" },
|
{ id: "about", label: "ℹ️ About" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GameLayout = (): React.JSX.Element => {
|
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 [activeTab, setActiveTab] = useState<Tab>("adventurers");
|
||||||
const [editingProfile, setEditingProfile] = useState(false);
|
const [editingProfile, setEditingProfile] = useState(false);
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ export const GameLayout = (): React.JSX.Element => {
|
|||||||
/>
|
/>
|
||||||
<OfflineModal />
|
<OfflineModal />
|
||||||
<AchievementToast />
|
<AchievementToast />
|
||||||
|
<CodexToast />
|
||||||
{battleResult && (
|
{battleResult && (
|
||||||
<BattleModal battle={battleResult} onDismiss={dismissBattle} />
|
<BattleModal battle={battleResult} onDismiss={dismissBattle} />
|
||||||
)}
|
)}
|
||||||
@@ -85,7 +89,7 @@ export const GameLayout = (): React.JSX.Element => {
|
|||||||
|
|
||||||
<main className="game-content">
|
<main className="game-content">
|
||||||
<nav className="tab-bar">
|
<nav className="tab-bar">
|
||||||
{TABS.map((tab) => (
|
{BASE_TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`tab-button ${activeTab === tab.id ? "active" : ""}`}
|
className={`tab-button ${activeTab === tab.id ? "active" : ""}`}
|
||||||
@@ -93,6 +97,9 @@ export const GameLayout = (): React.JSX.Element => {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{tab.id === "codex" && newCodexEntryIds.length > 0 && (
|
||||||
|
<span className="tab-badge">{newCodexEntryIds.length}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -107,6 +114,7 @@ export const GameLayout = (): React.JSX.Element => {
|
|||||||
{activeTab === "prestige" && <PrestigePanel />}
|
{activeTab === "prestige" && <PrestigePanel />}
|
||||||
{activeTab === "statistics" && <StatisticsPanel />}
|
{activeTab === "statistics" && <StatisticsPanel />}
|
||||||
{activeTab === "daily" && <DailyChallengePanel />}
|
{activeTab === "daily" && <DailyChallengePanel />}
|
||||||
|
{activeTab === "codex" && <CodexPanel />}
|
||||||
{activeTab === "about" && <AboutPanel />}
|
{activeTab === "about" && <AboutPanel />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
prestige as prestigeApi,
|
prestige as prestigeApi,
|
||||||
saveGame,
|
saveGame,
|
||||||
} from "../api/client.js";
|
} from "../api/client.js";
|
||||||
|
import { CODEX_ENTRIES } from "../data/codex.js";
|
||||||
import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js";
|
import { RESOURCE_CAP, applyTick, calculateClickPower } from "../engine/tick.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
import { formatNumber as formatNumberUtil } from "../utils/format.js";
|
||||||
@@ -76,6 +77,10 @@ interface GameContextValue {
|
|||||||
buyPrestigeUpgrade: (upgradeId: string) => Promise<void>;
|
buyPrestigeUpgrade: (upgradeId: string) => Promise<void>;
|
||||||
/** Toggle the auto-prestige setting on/off (requires auto_prestige upgrade) */
|
/** Toggle the auto-prestige setting on/off (requires auto_prestige upgrade) */
|
||||||
toggleAutoPrestige: () => void;
|
toggleAutoPrestige: () => void;
|
||||||
|
/** Queue of newly unlocked codex entry IDs (for toast notifications) */
|
||||||
|
newCodexEntryIds: string[];
|
||||||
|
/** Remove a codex entry ID from the notification queue */
|
||||||
|
dismissCodexEntry: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameContext = createContext<GameContextValue | null>(null);
|
const GameContext = createContext<GameContextValue | null>(null);
|
||||||
@@ -105,6 +110,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
const signatureRef = useRef<string | null>(localStorage.getItem("elysium_save_signature"));
|
const signatureRef = useRef<string | null>(localStorage.getItem("elysium_save_signature"));
|
||||||
const isAutoPrestigingRef = useRef(false);
|
const isAutoPrestigingRef = useRef(false);
|
||||||
const reloadRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
const reloadRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||||
|
const [newCodexEntryIds, setNewCodexEntryIds] = useState<string[]>([]);
|
||||||
|
const codexProcessedRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
|
|
||||||
@@ -149,6 +156,100 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
void reload();
|
void reload();
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
|
// Detect newly defeated bosses and completed quests to unlock Codex entries
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
const existingUnlocked = state.codex?.unlockedEntryIds ?? [];
|
||||||
|
// On first run (empty processed set), silently unlock existing completions
|
||||||
|
const isFirstRun = codexProcessedRef.current.size === 0;
|
||||||
|
const newIds: string[] = [];
|
||||||
|
|
||||||
|
for (const boss of state.bosses) {
|
||||||
|
const codexId = `boss_${boss.id}`;
|
||||||
|
if (boss.status === "defeated" && !codexProcessedRef.current.has(codexId)) {
|
||||||
|
codexProcessedRef.current.add(codexId);
|
||||||
|
if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) {
|
||||||
|
newIds.push(codexId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const quest of state.quests) {
|
||||||
|
const codexId = `quest_${quest.id}`;
|
||||||
|
if (quest.status === "completed" && !codexProcessedRef.current.has(codexId)) {
|
||||||
|
codexProcessedRef.current.add(codexId);
|
||||||
|
if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) {
|
||||||
|
newIds.push(codexId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const zone of state.zones) {
|
||||||
|
const codexId = `zone_${zone.id}`;
|
||||||
|
if (zone.status === "unlocked" && !codexProcessedRef.current.has(codexId)) {
|
||||||
|
codexProcessedRef.current.add(codexId);
|
||||||
|
if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) {
|
||||||
|
newIds.push(codexId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const equip of state.equipment) {
|
||||||
|
const codexId = `equipment_${equip.id}`;
|
||||||
|
if (equip.owned && !codexProcessedRef.current.has(codexId)) {
|
||||||
|
codexProcessedRef.current.add(codexId);
|
||||||
|
if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) {
|
||||||
|
newIds.push(codexId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const adventurer of state.adventurers) {
|
||||||
|
const codexId = `adventurer_${adventurer.id}`;
|
||||||
|
if (adventurer.count > 0 && !codexProcessedRef.current.has(codexId)) {
|
||||||
|
codexProcessedRef.current.add(codexId);
|
||||||
|
if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) {
|
||||||
|
newIds.push(codexId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const upgrade of state.upgrades) {
|
||||||
|
const codexId = `upgrade_${upgrade.id}`;
|
||||||
|
if (upgrade.purchased && !codexProcessedRef.current.has(codexId)) {
|
||||||
|
codexProcessedRef.current.add(codexId);
|
||||||
|
if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) {
|
||||||
|
newIds.push(codexId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prestigeId of state.prestige.purchasedUpgradeIds) {
|
||||||
|
const codexId = `prestige_${prestigeId}`;
|
||||||
|
if (!codexProcessedRef.current.has(codexId)) {
|
||||||
|
codexProcessedRef.current.add(codexId);
|
||||||
|
if (!existingUnlocked.includes(codexId) && CODEX_ENTRIES.some((e) => e.id === codexId)) {
|
||||||
|
newIds.push(codexId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const existing = prev.codex?.unlockedEntryIds ?? [];
|
||||||
|
const toAdd = newIds.filter((id) => !existing.includes(id));
|
||||||
|
if (toAdd.length === 0) return prev;
|
||||||
|
return { ...prev, codex: { unlockedEntryIds: [...existing, ...toAdd] } };
|
||||||
|
});
|
||||||
|
if (!isFirstRun) {
|
||||||
|
setNewCodexEntryIds((prev) => [...prev, ...newIds]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally re-runs on state change to detect completions
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
// Game loop via requestAnimationFrame
|
// Game loop via requestAnimationFrame
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
@@ -571,6 +672,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
setNewAchievements((prev) => prev.filter((a) => a.id !== id));
|
setNewAchievements((prev) => prev.filter((a) => a.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const dismissCodexEntry = useCallback((id: string) => {
|
||||||
|
setNewCodexEntryIds((prev) => prev.filter((e) => e !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const boundFormatNumber = useCallback(
|
const boundFormatNumber = useCallback(
|
||||||
(value: number) => formatNumberUtil(value, numberFormat),
|
(value: number) => formatNumberUtil(value, numberFormat),
|
||||||
[numberFormat],
|
[numberFormat],
|
||||||
@@ -606,6 +711,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
|||||||
formatNumber: boundFormatNumber,
|
formatNumber: boundFormatNumber,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
|
newCodexEntryIds,
|
||||||
|
dismissCodexEntry,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2151,3 +2151,172 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== CODEX PANEL ===================== */
|
||||||
|
.codex-progress {
|
||||||
|
background: var(--colour-surface-2);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-progress-text {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-progress-bar {
|
||||||
|
background: var(--colour-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-progress-fill {
|
||||||
|
background: linear-gradient(90deg, var(--colour-essence), var(--colour-accent-light));
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-zone {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-zone-header {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--colour-accent-light);
|
||||||
|
display: flex;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-zone-count {
|
||||||
|
background: var(--colour-surface-2);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entries {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry {
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry.locked {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry.unlocked {
|
||||||
|
border-color: var(--colour-essence);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry-header {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--colour-surface-2);
|
||||||
|
border: none;
|
||||||
|
color: var(--colour-text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry.unlocked .codex-entry-header:hover {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry.locked .codex-entry-header {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-lock {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry.locked .codex-entry-title {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-source-badge {
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-chevron {
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codex-entry-content {
|
||||||
|
background: var(--colour-bg);
|
||||||
|
border-top: 1px solid var(--colour-essence);
|
||||||
|
color: var(--colour-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Codex toast — uses a different accent from achievement toast */
|
||||||
|
.codex-toast {
|
||||||
|
align-items: center;
|
||||||
|
animation: slide-in-right 0.35s ease-out;
|
||||||
|
background: var(--colour-surface);
|
||||||
|
border: 1px solid var(--colour-essence);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab notification badge */
|
||||||
|
.tab-badge {
|
||||||
|
background: var(--colour-essence);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
min-width: 16px;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export type { CodexEntry, CodexState } from "./interfaces/Codex.js";
|
||||||
export type {
|
export type {
|
||||||
Achievement,
|
Achievement,
|
||||||
AchievementCondition,
|
AchievementCondition,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface CodexEntry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
sourceType: "boss" | "quest" | "equipment" | "adventurer" | "upgrade" | "prestige" | "zone";
|
||||||
|
sourceId: string;
|
||||||
|
zoneId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexState {
|
||||||
|
unlockedEntryIds: string[];
|
||||||
|
}
|
||||||
@@ -1,6 +1,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 { Boss } from "./Boss.js";
|
import type { Boss } from "./Boss.js";
|
||||||
|
import type { CodexState } from "./Codex.js";
|
||||||
import type { DailyChallengeState } from "./DailyChallenge.js";
|
import type { DailyChallengeState } from "./DailyChallenge.js";
|
||||||
import type { Equipment } from "./Equipment.js";
|
import type { Equipment } from "./Equipment.js";
|
||||||
import type { Player } from "./Player.js";
|
import type { Player } from "./Player.js";
|
||||||
@@ -27,4 +28,6 @@ export interface GameState {
|
|||||||
lastTickAt: number;
|
lastTickAt: number;
|
||||||
/** Daily challenge progress — optional for backwards compatibility with old saves */
|
/** Daily challenge progress — optional for backwards compatibility with old saves */
|
||||||
dailyChallenges?: DailyChallengeState;
|
dailyChallenges?: DailyChallengeState;
|
||||||
|
/** Lore codex unlock state — optional for backwards compatibility with old saves */
|
||||||
|
codex?: CodexState;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user