feat: vampire UI infrastructure - mode bar, tab row, and blood-red theme

- Added vampire API client functions (boss challenge, siring, awakening, upgrades, craft, explore)
- Fixed goddess API client URL bugs (/goddess/* → /goddess-boss/challenge, /goddess-upgrade/buy, /goddess-craft, /goddess-explore/*)
- Added VampireTab type and vampireTabs array (11 tabs) to GameLayout
- Fixed mode unlock chain: vampire after apotheosis, goddess after eternalSovereignty
- Added body.vampire-mode CSS class toggle alongside existing goddess-mode toggle
- Added vampire tab bar nav with blood-red theme
- Replaced single vampire placeholder with per-tab placeholders
- Added body.vampire-mode CSS variables (blood-red palette) and .vampire-tab-bar styles
- Added Blood/Ichor/Soul Shards resource display to ResourceBar (gated on apotheosis)
This commit is contained in:
2026-04-16 10:03:14 -07:00
committed by Naomi Carrigan
parent 8fa5d12f05
commit d9d1228172
4 changed files with 392 additions and 39 deletions
+179 -15
View File
@@ -10,8 +10,12 @@ import type {
ApotheosisRequest, ApotheosisRequest,
ApotheosisResponse, ApotheosisResponse,
AuthResponse, AuthResponse,
AwakeningRequest,
AwakeningResponse,
BossChallengeRequest, BossChallengeRequest,
BossChallengeResponse, BossChallengeResponse,
BuyAwakeningUpgradeRequest,
BuyAwakeningUpgradeResponse,
BuyConsecrationUpgradeRequest, BuyConsecrationUpgradeRequest,
BuyConsecrationUpgradeResponse, BuyConsecrationUpgradeResponse,
BuyEchoUpgradeRequest, BuyEchoUpgradeRequest,
@@ -22,6 +26,10 @@ import type {
BuyGoddessUpgradeResponse, BuyGoddessUpgradeResponse,
BuyPrestigeUpgradeRequest, BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse, BuyPrestigeUpgradeResponse,
BuySiringUpgradeRequest,
BuySiringUpgradeResponse,
BuyVampireUpgradeRequest,
BuyVampireUpgradeResponse,
ConsecrationRequest, ConsecrationRequest,
ConsecrationResponse, ConsecrationResponse,
CraftRecipeRequest, CraftRecipeRequest,
@@ -49,11 +57,22 @@ import type {
PublicProfileResponse, PublicProfileResponse,
SaveRequest, SaveRequest,
SaveResponse, SaveResponse,
SiringRequest,
SiringResponse,
SyncNewContentResponse, SyncNewContentResponse,
TranscendenceRequest, TranscendenceRequest,
TranscendenceResponse, TranscendenceResponse,
UpdateProfileRequest, UpdateProfileRequest,
UpdateProfileResponse, UpdateProfileResponse,
VampireBossChallengeRequest,
VampireBossChallengeResponse,
VampireCraftRequest,
VampireCraftResponse,
VampireExploreClaimableResponse,
VampireExploreCollectRequest,
VampireExploreCollectResponse,
VampireExploreStartRequest,
VampireExploreStartResponse,
} from "@elysium/types"; } from "@elysium/types";
const baseUrl = "/api"; const baseUrl = "/api";
@@ -356,10 +375,10 @@ const debugHardReset = async(): Promise<LoadResponse> => {
const challengeGoddessBoss = async( const challengeGoddessBoss = async(
body: GoddessBossChallengeRequest, body: GoddessBossChallengeRequest,
): Promise<GoddessBossChallengeResponse> => { ): Promise<GoddessBossChallengeResponse> => {
return await fetchJson<GoddessBossChallengeResponse>("/goddess/boss", { return await fetchJson<GoddessBossChallengeResponse>(
body: JSON.stringify(body), "/goddess-boss/challenge",
method: "POST", { body: JSON.stringify(body), method: "POST" },
}); );
}; };
/** /**
@@ -426,7 +445,7 @@ const buyEnlightenmentUpgrade = async(
const buyGoddessUpgrade = async( const buyGoddessUpgrade = async(
body: BuyGoddessUpgradeRequest, body: BuyGoddessUpgradeRequest,
): Promise<BuyGoddessUpgradeResponse> => { ): Promise<BuyGoddessUpgradeResponse> => {
return await fetchJson<BuyGoddessUpgradeResponse>("/goddess/upgrade", { return await fetchJson<BuyGoddessUpgradeResponse>("/goddess-upgrade/buy", {
body: JSON.stringify(body), body: JSON.stringify(body),
method: "POST", method: "POST",
}); });
@@ -440,7 +459,7 @@ const buyGoddessUpgrade = async(
const craftGoddessRecipe = async( const craftGoddessRecipe = async(
body: GoddessCraftRequest, body: GoddessCraftRequest,
): Promise<GoddessCraftResponse> => { ): Promise<GoddessCraftResponse> => {
return await fetchJson<GoddessCraftResponse>("/goddess/craft", { return await fetchJson<GoddessCraftResponse>("/goddess-craft", {
body: JSON.stringify(body), body: JSON.stringify(body),
method: "POST", method: "POST",
}); });
@@ -454,10 +473,10 @@ const craftGoddessRecipe = async(
const startGoddessExploration = async( const startGoddessExploration = async(
body: GoddessExploreStartRequest, body: GoddessExploreStartRequest,
): Promise<GoddessExploreStartResponse> => { ): Promise<GoddessExploreStartResponse> => {
return await fetchJson<GoddessExploreStartResponse>("/goddess/explore", { return await fetchJson<GoddessExploreStartResponse>(
body: JSON.stringify(body), "/goddess-explore/start",
method: "POST", { body: JSON.stringify(body), method: "POST" },
}); );
}; };
/** /**
@@ -468,10 +487,10 @@ const startGoddessExploration = async(
const collectGoddessExploration = async( const collectGoddessExploration = async(
body: GoddessExploreCollectRequest, body: GoddessExploreCollectRequest,
): Promise<GoddessExploreCollectResponse> => { ): Promise<GoddessExploreCollectResponse> => {
return await fetchJson<GoddessExploreCollectResponse>("/goddess/explore", { return await fetchJson<GoddessExploreCollectResponse>(
body: JSON.stringify(body), "/goddess-explore/collect",
method: "PUT", { body: JSON.stringify(body), method: "PUT" },
}); );
}; };
/** /**
@@ -483,7 +502,142 @@ const checkGoddessExplorationClaimable = async(
areaId: string, areaId: string,
): Promise<GoddessExploreClaimableResponse> => { ): Promise<GoddessExploreClaimableResponse> => {
return await fetchJson<GoddessExploreClaimableResponse>( return await fetchJson<GoddessExploreClaimableResponse>(
`/goddess/explore/claimable?areaId=${encodeURIComponent(areaId)}`, `/goddess-explore/claimable?areaId=${encodeURIComponent(areaId)}`,
);
};
/**
* Challenges a vampire boss.
* @param body - The vampire boss challenge request payload.
* @returns The vampire boss challenge response data.
*/
const challengeVampireBoss = async(
body: VampireBossChallengeRequest,
): Promise<VampireBossChallengeResponse> => {
return await fetchJson<VampireBossChallengeResponse>(
"/vampire-boss/challenge",
{ body: JSON.stringify(body), method: "POST" },
);
};
/**
* Triggers a siring reset on the server.
* @param body - The siring request payload.
* @returns The siring response data.
*/
const sire = async(body: SiringRequest): Promise<SiringResponse> => {
return await fetchJson<SiringResponse>("/siring", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Purchases a siring upgrade on the server.
* @param body - The buy siring upgrade request payload.
* @returns The buy siring upgrade response data.
*/
const buySiringUpgrade = async(
body: BuySiringUpgradeRequest,
): Promise<BuySiringUpgradeResponse> => {
return await fetchJson<BuySiringUpgradeResponse>("/siring/buy-upgrade", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Triggers a vampire awakening reset on the server.
* @param body - The awakening request payload.
* @returns The awakening response data.
*/
const awaken = async(body: AwakeningRequest): Promise<AwakeningResponse> => {
return await fetchJson<AwakeningResponse>("/vampire-awakening", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Purchases a vampire awakening upgrade on the server.
* @param body - The buy awakening upgrade request payload.
* @returns The buy awakening upgrade response data.
*/
const buyAwakeningUpgrade = async(
body: BuyAwakeningUpgradeRequest,
): Promise<BuyAwakeningUpgradeResponse> => {
return await fetchJson<BuyAwakeningUpgradeResponse>(
"/vampire-awakening/buy-upgrade",
{ body: JSON.stringify(body), method: "POST" },
);
};
/**
* Purchases a vampire upgrade on the server.
* @param body - The buy vampire upgrade request payload.
* @returns The buy vampire upgrade response data.
*/
const buyVampireUpgrade = async(
body: BuyVampireUpgradeRequest,
): Promise<BuyVampireUpgradeResponse> => {
return await fetchJson<BuyVampireUpgradeResponse>("/vampire-upgrade/buy", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Crafts a vampire recipe on the server.
* @param body - The vampire craft request payload.
* @returns The vampire craft response data.
*/
const craftVampireRecipe = async(
body: VampireCraftRequest,
): Promise<VampireCraftResponse> => {
return await fetchJson<VampireCraftResponse>("/vampire-craft", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Starts a vampire exploration in a given area.
* @param body - The vampire exploration start request payload.
* @returns The vampire exploration start response data.
*/
const startVampireExploration = async(
body: VampireExploreStartRequest,
): Promise<VampireExploreStartResponse> => {
return await fetchJson<VampireExploreStartResponse>(
"/vampire-explore/start",
{ body: JSON.stringify(body), method: "POST" },
);
};
/**
* Collects the rewards from a completed vampire exploration.
* @param body - The vampire exploration collect request payload.
* @returns The vampire exploration collect response data.
*/
const collectVampireExploration = async(
body: VampireExploreCollectRequest,
): Promise<VampireExploreCollectResponse> => {
return await fetchJson<VampireExploreCollectResponse>(
"/vampire-explore/collect",
{ body: JSON.stringify(body), method: "PUT" },
);
};
/**
* Checks whether a given vampire exploration area is ready to claim on the server.
* @param areaId - The area ID to check.
* @returns Whether the vampire exploration is claimable.
*/
const checkVampireExplorationClaimable = async(
areaId: string,
): Promise<VampireExploreClaimableResponse> => {
return await fetchJson<VampireExploreClaimableResponse>(
`/vampire-explore/claimable?areaId=${encodeURIComponent(areaId)}`,
); );
}; };
@@ -515,20 +669,28 @@ const updateProfile = async(
export { export {
ValidationError, ValidationError,
achieveApotheosis, achieveApotheosis,
awaken,
buyAwakeningUpgrade,
buyConsecrationUpgrade, buyConsecrationUpgrade,
buyEchoUpgrade, buyEchoUpgrade,
buyEnlightenmentUpgrade, buyEnlightenmentUpgrade,
buyGoddessUpgrade, buyGoddessUpgrade,
buyPrestigeUpgrade, buyPrestigeUpgrade,
buySiringUpgrade,
buyVampireUpgrade,
challengeBoss, challengeBoss,
challengeGoddessBoss, challengeGoddessBoss,
challengeVampireBoss,
checkExplorationClaimable, checkExplorationClaimable,
checkGoddessExplorationClaimable, checkGoddessExplorationClaimable,
checkVampireExplorationClaimable,
collectExploration, collectExploration,
collectGoddessExploration, collectGoddessExploration,
collectVampireExploration,
consecrate, consecrate,
craftGoddessRecipe, craftGoddessRecipe,
craftRecipe, craftRecipe,
craftVampireRecipe,
debugHardReset, debugHardReset,
enlighten, enlighten,
forceUnlocks, forceUnlocks,
@@ -541,8 +703,10 @@ export {
prestige, prestige,
resetProgress, resetProgress,
saveGame, saveGame,
sire,
startExploration, startExploration,
startGoddessExploration, startGoddessExploration,
startVampireExploration,
transcend, transcend,
updateProfile, updateProfile,
}; };
+143 -24
View File
@@ -7,6 +7,7 @@
/* eslint-disable max-lines -- Complex layout with many conditional renders */ /* eslint-disable max-lines -- Complex layout with many conditional renders */
/* eslint-disable max-lines-per-function -- Complex layout with many conditional renders */ /* eslint-disable max-lines-per-function -- Complex layout with many conditional renders */
/* eslint-disable complexity -- Many tab render paths */ /* eslint-disable complexity -- Many tab render paths */
/* eslint-disable max-statements -- Many state variables for multi-mode tab routing */
import { type JSX, useEffect, useState } from "react"; import { type JSX, useEffect, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { ResourceBar } from "../ui/resourceBar.js"; import { ResourceBar } from "../ui/resourceBar.js";
@@ -89,6 +90,19 @@ type GoddessTab =
| "goddess-exploration" | "goddess-exploration"
| "goddess-achievements"; | "goddess-achievements";
type VampireTab =
| "vampire-zones"
| "vampire-bosses"
| "vampire-quests"
| "thralls"
| "vampire-equipment"
| "vampire-upgrades"
| "siring"
| "vampire-awakening"
| "vampire-crafting"
| "vampire-exploration"
| "vampire-achievements";
const baseTabs: Array<{ id: Tab; label: string }> = [ const baseTabs: Array<{ id: Tab; label: string }> = [
{ id: "adventurers", label: "⚔️ Adventurers" }, { id: "adventurers", label: "⚔️ Adventurers" },
{ id: "upgrades", label: "🔧 Upgrades" }, { id: "upgrades", label: "🔧 Upgrades" },
@@ -111,6 +125,20 @@ const baseTabs: Array<{ id: Tab; label: string }> = [
{ id: "debug", label: "🔧 Debug" }, { id: "debug", label: "🔧 Debug" },
]; ];
const vampireTabs: Array<{ id: VampireTab; label: string }> = [
{ id: "vampire-zones", label: "🗺️ Zones" },
{ id: "vampire-bosses", label: "🩸 Bosses" },
{ id: "vampire-quests", label: "📜 Quests" },
{ id: "thralls", label: "🧟 Thralls" },
{ id: "vampire-equipment", label: "🦇 Equipment" },
{ id: "vampire-upgrades", label: "⚔️ Upgrades" },
{ id: "siring", label: "🩸 Siring" },
{ id: "vampire-awakening", label: "💀 Awakening" },
{ id: "vampire-crafting", label: "⚗️ Crafting" },
{ id: "vampire-exploration", label: "🌑 Exploration" },
{ id: "vampire-achievements", label: "🏆 Achievements" },
];
const goddessTabs: Array<{ id: GoddessTab; label: string }> = [ const goddessTabs: Array<{ id: GoddessTab; label: string }> = [
{ id: "goddess-zones", label: "🌟 Zones" }, { id: "goddess-zones", label: "🌟 Zones" },
{ id: "goddess-bosses", label: "👁️ Bosses" }, { id: "goddess-bosses", label: "👁️ Bosses" },
@@ -169,12 +197,15 @@ const GameLayout = (): JSX.Element => {
const [ activeTab, setActiveTab ] = useState<Tab>("adventurers"); const [ activeTab, setActiveTab ] = useState<Tab>("adventurers");
const [ activeGoddessTab, setActiveGoddessTab ] const [ activeGoddessTab, setActiveGoddessTab ]
= useState<GoddessTab>("goddess-zones"); = useState<GoddessTab>("goddess-zones");
const [ activeVampireTab, setActiveVampireTab ]
= useState<VampireTab>("vampire-zones");
const [ editingProfile, setEditingProfile ] = useState(false); const [ editingProfile, setEditingProfile ] = useState(false);
const [ dismissedOutdatedWarning, setDismissedOutdatedWarning ] const [ dismissedOutdatedWarning, setDismissedOutdatedWarning ]
= useState(false); = useState(false);
useEffect(() => { useEffect(() => {
document.body.classList.toggle("goddess-mode", activeMode === "goddess"); document.body.classList.toggle("goddess-mode", activeMode === "goddess");
document.body.classList.toggle("vampire-mode", activeMode === "vampire");
}, [ activeMode ]); }, [ activeMode ]);
if (isLoading) { if (isLoading) {
@@ -273,8 +304,13 @@ const GameLayout = (): JSX.Element => {
<nav className="mode-bar"> <nav className="mode-bar">
{modes.map((mode) => { {modes.map((mode) => {
const apotheosisCount = state.apotheosis?.count ?? 0; const apotheosisCount = state.apotheosis?.count ?? 0;
const goddessLocked = mode === "goddess" && apotheosisCount === 0; const eternalSovereigntyCount
const isLocked = goddessLocked || mode === "vampire"; = state.vampire?.eternalSovereignty.count ?? 0;
const vampireLocked
= mode === "vampire" && apotheosisCount === 0;
const goddessLocked
= mode === "goddess" && eternalSovereigntyCount === 0;
const isLocked = vampireLocked || goddessLocked;
function handleModeClick(): void { function handleModeClick(): void {
if (!isLocked) { if (!isLocked) {
handleSetMode(mode); handleSetMode(mode);
@@ -304,6 +340,7 @@ const GameLayout = (): JSX.Element => {
})} })}
</nav> </nav>
{/* eslint-disable-next-line no-nested-ternary -- Three-way mode switch for tab bar */}
{activeMode === "mortal" {activeMode === "mortal"
? <nav className="tab-bar"> ? <nav className="tab-bar">
{baseTabs.map((tab) => { {baseTabs.map((tab) => {
@@ -331,26 +368,47 @@ const GameLayout = (): JSX.Element => {
); );
})} })}
</nav> </nav>
: <nav className="tab-bar goddess-tab-bar"> : activeMode === "goddess"
{goddessTabs.map((tab) => { ? <nav className="tab-bar goddess-tab-bar">
const { id: tabId, label } = tab; {goddessTabs.map((tab) => {
function handleGoddessTabClick(): void { const { id: tabId, label } = tab;
setActiveGoddessTab(tabId); function handleGoddessTabClick(): void {
} setActiveGoddessTab(tabId);
return ( }
<button return (
className={`tab-button${activeGoddessTab === tabId <button
? " active" className={`tab-button${activeGoddessTab === tabId
: ""}`} ? " active"
key={tabId} : ""}`}
onClick={handleGoddessTabClick} key={tabId}
type="button" onClick={handleGoddessTabClick}
> type="button"
{label} >
</button> {label}
); </button>
})} );
</nav> })}
</nav>
: <nav className="tab-bar vampire-tab-bar">
{vampireTabs.map((tab) => {
const { id: tabId, label } = tab;
function handleVampireTabClick(): void {
setActiveVampireTab(tabId);
}
return (
<button
className={`tab-button${activeVampireTab === tabId
? " active"
: ""}`}
key={tabId}
onClick={handleVampireTabClick}
type="button"
>
{label}
</button>
);
})}
</nav>
} }
<div className="tab-content"> <div className="tab-content">
@@ -420,8 +478,69 @@ const GameLayout = (): JSX.Element => {
&& activeGoddessTab === "goddess-achievements" && activeGoddessTab === "goddess-achievements"
&& <GoddessAchievementsPanel />} && <GoddessAchievementsPanel />}
{activeMode === "vampire" {activeMode === "vampire"
&& <div className="goddess-placeholder"> && activeVampireTab === "vampire-zones"
<p>{"🧛 Vampire panels coming soon..."}</p> && <div className="vampire-placeholder">
<p>{"🗺️ Vampire Zones coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-bosses"
&& <div className="vampire-placeholder">
<p>{"🩸 Vampire Bosses coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-quests"
&& <div className="vampire-placeholder">
<p>{"📜 Vampire Quests coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "thralls"
&& <div className="vampire-placeholder">
<p>{"🧟 Thralls coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-equipment"
&& <div className="vampire-placeholder">
<p>{"🦇 Vampire Equipment coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-upgrades"
&& <div className="vampire-placeholder">
<p>{"⚔️ Vampire Upgrades coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "siring"
&& <div className="vampire-placeholder">
<p>{"🩸 Siring coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-awakening"
&& <div className="vampire-placeholder">
<p>{"💀 Vampire Awakening coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-crafting"
&& <div className="vampire-placeholder">
<p>{"⚗️ Vampire Crafting coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-exploration"
&& <div className="vampire-placeholder">
<p>{"🌑 Vampire Exploration coming soon..."}</p>
</div>
}
{activeMode === "vampire"
&& activeVampireTab === "vampire-achievements"
&& <div className="vampire-placeholder">
<p>{"🏆 Vampire Achievements coming soon..."}</p>
</div> </div>
} }
</div> </div>
@@ -88,6 +88,9 @@ const ResourceBar = ({
const { gold, essence, crystals, prayers, divinity, stardust } = resources; const { gold, essence, crystals, prayers, divinity, stardust } = resources;
const hasApotheosis = apotheosisCount > 0; const hasApotheosis = apotheosisCount > 0;
const blood = resources.blood ?? 0;
const ichor = state?.vampire?.siring.ichor ?? 0;
const soulShards = state?.vampire?.awakening.soulShards ?? 0;
let partyCombatPower = 0; let partyCombatPower = 0;
let goldPerSecond = 0; let goldPerSecond = 0;
let essencePerSecond = 0; let essencePerSecond = 0;
@@ -286,6 +289,40 @@ const ResourceBar = ({
</span> </span>
<span className="resource-label">{"Stardust"}</span> <span className="resource-label">{"Stardust"}</span>
</div> </div>
<hr className="resources-divider" />
<div className={`resource${hasApotheosis
? ""
: " resource-locked"}`}>
<span className="resource-icon">{"🩸"}</span>
<span className="resource-value">
{hasApotheosis
? formatNumber(blood)
: "🔒"}
</span>
<span className="resource-label">{"Blood"}</span>
</div>
<div className={`resource${hasApotheosis
? ""
: " resource-locked"}`}>
<span className="resource-icon">{"💧"}</span>
<span className="resource-value">
{hasApotheosis
? formatNumber(ichor)
: "🔒"}
</span>
<span className="resource-label">{"Ichor"}</span>
</div>
<div className={`resource${hasApotheosis
? ""
: " resource-locked"}`}>
<span className="resource-icon">{"💠"}</span>
<span className="resource-value">
{hasApotheosis
? formatNumber(soulShards)
: "🔒"}
</span>
<span className="resource-label">{"Soul Shards"}</span>
</div>
</div> </div>
: null} : null}
</div> </div>
+33
View File
@@ -5382,3 +5382,36 @@ body,
color: var(--colour-text-muted); color: var(--colour-text-muted);
font-size: 0.85rem; font-size: 0.85rem;
} }
/* ===================== VAMPIRE THEME ===================== */
body.vampire-mode {
--colour-bg: #1a0a0a;
--colour-surface: #2d1515;
--colour-surface-2: #3d2020;
--colour-border: #5c3d3d;
--colour-accent: #c41e3a;
--colour-accent-light: #e84c3d;
--colour-gold: #d4a574;
--colour-text: #f5e6e6;
--colour-text-muted: #b8a8a8;
}
/* ===================== VAMPIRE TAB BAR ===================== */
.vampire-tab-bar .tab-button.active {
background: linear-gradient(
135deg,
var(--colour-accent),
var(--colour-accent-light)
);
border-color: var(--colour-accent-light);
}
/* ===================== VAMPIRE PLACEHOLDER ===================== */
.vampire-placeholder {
align-items: center;
color: var(--colour-text-muted);
display: flex;
font-size: 1.1rem;
justify-content: center;
min-height: 200px;
}