From 3e34701d32ebacc2574324ef60a389aa55e32fc6 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 16 Apr 2026 10:15:03 -0700 Subject: [PATCH] feat: vampire zones, quests, and achievements panels Implements the three read-only vampire expansion display panels: - VampireZonesPanel: zone grid with lock state, boss/quest unlock requirements - VampireQuestsPanel: zone-filtered quest list with duration, rewards, progress badge - VampireAchievementsPanel: achievement cards with progress bars and ichor/soul shard rewards Wires all three into GameLayout, replacing the corresponding tab placeholders. --- apps/web/src/components/game/gameLayout.tsx | 15 +- .../game/vampireAchievementsPanel.tsx | 251 +++++++++++++++++ .../components/game/vampireQuestsPanel.tsx | 264 ++++++++++++++++++ .../src/components/game/vampireZonesPanel.tsx | 154 ++++++++++ 4 files changed, 675 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/game/vampireAchievementsPanel.tsx create mode 100644 apps/web/src/components/game/vampireQuestsPanel.tsx create mode 100644 apps/web/src/components/game/vampireZonesPanel.tsx diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 344b701..86b4f86 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -53,6 +53,9 @@ import { StoryPanel } from "./storyPanel.js"; import { StoryToast } from "./storyToast.js"; import { TranscendencePanel } from "./transcendencePanel.js"; import { UpgradePanel } from "./upgradePanel.js"; +import { VampireAchievementsPanel } from "./vampireAchievementsPanel.js"; +import { VampireQuestsPanel } from "./vampireQuestsPanel.js"; +import { VampireZonesPanel } from "./vampireZonesPanel.js"; type Mode = "mortal" | "goddess" | "vampire"; @@ -479,9 +482,7 @@ const GameLayout = (): JSX.Element => { && } {activeMode === "vampire" && activeVampireTab === "vampire-zones" - &&
-

{"πŸ—ΊοΈ Vampire Zones coming soon..."}

-
+ && } {activeMode === "vampire" && activeVampireTab === "vampire-bosses" @@ -491,9 +492,7 @@ const GameLayout = (): JSX.Element => { } {activeMode === "vampire" && activeVampireTab === "vampire-quests" - &&
-

{"πŸ“œ Vampire Quests coming soon..."}

-
+ && } {activeMode === "vampire" && activeVampireTab === "thralls" @@ -539,9 +538,7 @@ const GameLayout = (): JSX.Element => { } {activeMode === "vampire" && activeVampireTab === "vampire-achievements" - &&
-

{"πŸ† Vampire Achievements coming soon..."}

-
+ && } diff --git a/apps/web/src/components/game/vampireAchievementsPanel.tsx b/apps/web/src/components/game/vampireAchievementsPanel.tsx new file mode 100644 index 0000000..d644b8f --- /dev/null +++ b/apps/web/src/components/game/vampireAchievementsPanel.tsx @@ -0,0 +1,251 @@ +/** + * @file Vampire achievements panel component displaying all vampire expansion achievements. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to this panel */ +/* eslint-disable max-lines-per-function -- Achievement panel renders many achievement states */ +import { type JSX, useState } from "react"; +import { useGame } from "../../context/gameContext.js"; +import { LockToggle } from "../ui/lockToggle.js"; +import type { VampireAchievement, VampireState } from "@elysium/types"; + +/** + * Returns the plural form of a word based on a count. + * @param count - The count to check. + * @param word - The base word to pluralise. + * @returns The pluralised word string. + */ +const pluralise = (count: number, word: string): string => { + return count > 1 + ? `${word}s` + : word; +}; + +/** + * Generates a human-readable condition description for a vampire achievement. + * @param achievement - The vampire achievement to describe. + * @param formatNumber - The number formatting utility function. + * @returns A string describing the achievement condition. + */ +const conditionDescription = ( + achievement: VampireAchievement, + formatNumber: (n: number)=> string, +): string => { + const { condition } = achievement; + switch (condition.type) { + case "totalBloodEarned": + return `Earn ${formatNumber(condition.amount)} total blood`; + case "vampireBossesDefeated": + return `Defeat ${String(condition.amount)} vampire ${pluralise(condition.amount, "boss")}`; + case "vampireQuestsCompleted": + return `Complete ${String(condition.amount)} vampire ${pluralise(condition.amount, "quest")}`; + case "thrallTotal": + return `Recruit ${formatNumber(condition.amount)} total ${pluralise(condition.amount, "thrall")}`; + case "siringCount": + return `Sire ${String(condition.amount)} ${pluralise(condition.amount, "time")}`; + case "vampireEquipmentOwned": + return `Own ${String(condition.amount)} vampire equipment ${pluralise(condition.amount, "item")}`; + default: + return "Unknown condition"; + } +}; + +/** + * Returns the player's current progress value toward a vampire achievement's unlock condition. + * @param achievement - The achievement to evaluate progress for. + * @param vampire - The current vampire state. + * @returns The current numeric progress toward the achievement condition. + */ +const getCurrentProgress = ( + achievement: VampireAchievement, + vampire: VampireState, +): number => { + const { condition } = achievement; + switch (condition.type) { + case "totalBloodEarned": + return vampire.lifetimeBloodEarned; + case "vampireBossesDefeated": + return vampire.lifetimeBossesDefeated; + case "vampireQuestsCompleted": + return vampire.lifetimeQuestsCompleted; + case "thrallTotal": + return vampire.thralls.reduce((sum, thrall) => { + return sum + thrall.count; + }, 0); + case "siringCount": + return vampire.siring.count; + case "vampireEquipmentOwned": + return vampire.equipment.filter((item) => { + return item.owned; + }).length; + default: + return 0; + } +}; + +interface VampireAchievementCardProperties { + readonly achievement: VampireAchievement; + readonly formatNumber: (n: number)=> string; + readonly progressValue: number; +} + +/** + * Renders a single vampire achievement card. + * @param props - The achievement card properties. + * @param props.achievement - The achievement to display. + * @param props.formatNumber - The number formatting utility function. + * @param props.progressValue - The player's current progress toward the unlock condition. + * @returns The JSX element. + */ +const VampireAchievementCard = ({ + achievement, + formatNumber, + progressValue, +}: VampireAchievementCardProperties): JSX.Element => { + const isUnlocked = achievement.unlockedAt !== null; + const cappedProgress = Math.min(progressValue, achievement.condition.amount); + + return ( +
+
+ {achievement.icon} +
+
+

{achievement.name}

+

{achievement.description}

+

+ {conditionDescription(achievement, formatNumber)} +

+ {!isUnlocked + &&
+ + + {formatNumber(progressValue)} + {" / "} + {formatNumber(achievement.condition.amount)} + +
+ } + {achievement.reward !== undefined + &&
+ {achievement.reward.ichor !== undefined + &&

+ {"πŸ’§ +"} + {achievement.reward.ichor} + {" Ichor"} +

+ } + {achievement.reward.soulShards !== undefined + &&

+ {"πŸ’  +"} + {achievement.reward.soulShards} + {" Soul Shards"} +

+ } +
+ } +
+
+ {isUnlocked + ? <> + {"βœ“ Unlocked"} + {achievement.unlockedAt !== null + && + {new Date(achievement.unlockedAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + + } + + : {"πŸ”’"} + } +
+
+ ); +}; + +/** + * Renders the vampire achievements panel with all vampire expansion achievements. + * @returns The JSX element. + */ +const VampireAchievementsPanel = (): JSX.Element => { + const { state, formatNumber } = useGame(); + const [ showLocked, setShowLocked ] = useState(true); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const { vampire } = state; + + if (vampire === undefined) { + return ( +
+

{"The Vampire expansion is not yet unlocked."}

+
+ ); + } + + const achievementList = vampire.achievements; + const unlocked = achievementList.filter((achievement) => { + return achievement.unlockedAt !== null; + }); + const locked = achievementList.filter((achievement) => { + return achievement.unlockedAt === null; + }); + const visible = showLocked + ? achievementList + : unlocked; + + function handleToggle(): void { + setShowLocked((current) => { + return !current; + }); + } + + return ( +
+
+

{"🩸 Vampire Achievements"}

+ +
+

+ {unlocked.length} + {" / "} + {achievementList.length} + {" unlocked"} +

+
+ {visible.map((achievement) => { + return ( + + ); + })} +
+
+ ); +}; + +export { VampireAchievementsPanel }; diff --git a/apps/web/src/components/game/vampireQuestsPanel.tsx b/apps/web/src/components/game/vampireQuestsPanel.tsx new file mode 100644 index 0000000..91af4de --- /dev/null +++ b/apps/web/src/components/game/vampireQuestsPanel.tsx @@ -0,0 +1,264 @@ +/** + * @file Read-only panel displaying vampire quests grouped by zone. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex component with many render paths */ +/* eslint-disable react/no-multi-comp -- QuestCard sub-component is tightly coupled */ +import { useState, type JSX } from "react"; +import { useGame } from "../../context/gameContext.js"; +import type { + VampireQuest, + VampireQuestReward, + VampireZone, +} from "@elysium/types"; + +/** + * Formats a duration in seconds to a human-readable string. + * @param seconds - The total number of seconds to format. + * @returns The formatted duration string. + */ +const formatDuration = (seconds: number): string => { + const secondsPerHour = 3600; + const secondsPerMinute = 60; + if (seconds >= secondsPerHour) { + const hours = Math.floor(seconds / secondsPerHour); + const remainderSeconds = seconds % secondsPerHour; + const minutes = Math.floor(remainderSeconds / secondsPerMinute); + return `${String(hours)}h ${String(minutes)}m`; + } + if (seconds >= secondsPerMinute) { + const minutes = Math.floor(seconds / secondsPerMinute); + const secs = seconds % secondsPerMinute; + return `${String(minutes)}m ${String(secs)}s`; + } + return `${String(seconds)}s`; +}; + +/** + * Returns a human-readable label string for a vampire quest reward. + * @param reward - The reward to describe. + * @param formatNumber - The number formatter function. + * @returns The label string for the given reward type. + */ +const getRewardLabel = ( + reward: VampireQuestReward, + formatNumber: (value: number)=> string, +): string => { + if (reward.type === "blood") { + return `🩸 ${formatNumber(reward.amount ?? 0)} Blood`; + } + if (reward.type === "ichor") { + return `πŸ’§ ${formatNumber(reward.amount ?? 0)} Ichor`; + } + if (reward.type === "soulShards") { + return `πŸ’  ${formatNumber(reward.amount ?? 0)} Soul Shards`; + } + if (reward.type === "upgrade") { + return "πŸ”“ Upgrade Unlocked"; + } + if (reward.type === "thrall") { + return "🧟 New Thrall Tier"; + } + return "πŸ¦‡ Equipment Unlocked"; +}; + +interface VampireQuestCardProperties { + readonly quest: VampireQuest; + readonly unlockHint: string | undefined; + readonly zoneIsOpen: boolean; +} + +/** + * Renders a single vampire quest card (read-only). + * @param props - The component properties. + * @param props.quest - The vampire quest to display. + * @param props.unlockHint - The name of the prerequisite quest, if locked. + * @param props.zoneIsOpen - Whether the quest's zone is currently unlocked. + * @returns The JSX element. + */ +const VampireQuestCard = ({ + quest, + unlockHint, + zoneIsOpen, +}: VampireQuestCardProperties): JSX.Element => { + const { formatNumber } = useGame(); + + return ( +
+
+

{quest.name}

+

{quest.description}

+

+ {"⏱ "} + {formatDuration(quest.durationSeconds)} +

+
+ {quest.rewards.map((reward, rewardIndex) => { + return + {getRewardLabel(reward, formatNumber)} + ; + })} +
+
+
+ {quest.status === "locked" && !zoneIsOpen + && {"πŸ”’ Zone Locked"} + } + {quest.status === "locked" && zoneIsOpen + ? <> + {"πŸ”’ Locked"} + {unlockHint !== undefined + &&

+ {"πŸ“œ Complete: "} + {unlockHint} +

+ } + + : null + } + {quest.status === "available" + && {"πŸ“‹ Available"} + } + {quest.status === "active" + && {"⏳ In Progress"} + } + {quest.status === "completed" + && {"βœ… Completed"} + } +
+
+ ); +}; + +/** + * Renders the vampire quests panel with zone selection and quest list. + * @returns The JSX element. + */ +const VampireQuestsPanel = (): JSX.Element => { + const { state } = useGame(); + const [ activeZoneId, setActiveZoneId ] = useState(() => { + return sessionStorage.getItem("elysium_vampire_quest_zone") + ?? "vampire_haunted_catacombs"; + }); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const vampireState = state.vampire; + if (vampireState === undefined) { + return ( +
+

{"Vampire expansion not yet unlocked."}

+
+ ); + } + + const { zones, quests } = vampireState; + + const activeZone = zones.find((zone: VampireZone) => { + return zone.id === activeZoneId; + }); + const zoneIsOpen = activeZone?.status === "unlocked"; + + const zoneQuests = quests.filter((quest: VampireQuest) => { + return quest.zoneId === activeZoneId; + }); + + const questNameById = new Map( + quests.map((quest: VampireQuest) => { + return [ quest.id, quest.name ]; + }), + ); + + const getUnlockHint = (quest: VampireQuest): string | undefined => { + if (quest.status !== "locked" || quest.prerequisiteIds.length === 0) { + return undefined; + } + const [ prereqId ] = quest.prerequisiteIds; + if (prereqId === undefined) { + return undefined; + } + return questNameById.get(prereqId); + }; + + function handleZoneSelect(zoneId: string): void { + setActiveZoneId(zoneId); + sessionStorage.setItem("elysium_vampire_quest_zone", zoneId); + } + + const completedCount = zoneQuests.filter((quest: VampireQuest) => { + return quest.status === "completed"; + }).length; + + return ( +
+

{"Vampire Quests"}

+
+ {zones.map((zone: VampireZone) => { + function handleClick(): void { + handleZoneSelect(zone.id); + } + return ; + })} +
+ {activeZone !== undefined + &&
+

{activeZone.description}

+

+ {String(completedCount)} + {" / "} + {String(zoneQuests.length)} + {" quests completed"} +

+ {activeZone.status === "locked" + &&

+ {"πŸ”’ This zone is locked. Defeat the required vampire boss"} + {" to unlock it."} +

+ } +
+ } +
+ {zoneQuests.length === 0 + ?

{"No quests in this zone."}

+ : zoneQuests.map((quest: VampireQuest) => { + return ; + }) + } +
+
+ ); +}; + +export { VampireQuestsPanel }; diff --git a/apps/web/src/components/game/vampireZonesPanel.tsx b/apps/web/src/components/game/vampireZonesPanel.tsx new file mode 100644 index 0000000..e42826f --- /dev/null +++ b/apps/web/src/components/game/vampireZonesPanel.tsx @@ -0,0 +1,154 @@ +/** + * @file Vampire Zones panel β€” read-only view of all vampire realms. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- Complex panel with zone grid rendering */ +/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ + +import { useGame } from "../../context/gameContext.js"; +import type { VampireZone } from "@elysium/types"; +import type { JSX } from "react"; + +interface ZoneCardProperties { + readonly zone: VampireZone; + readonly isLocked: boolean; + readonly unlockBossName: string | undefined; + readonly unlockQuestName: string | undefined; +} + +/** + * Renders a single vampire zone card. + * @param props - The zone card properties. + * @param props.zone - The zone data. + * @param props.isLocked - Whether this zone is currently locked. + * @param props.unlockBossName - Name of the boss required to unlock, if any. + * @param props.unlockQuestName - Name of the quest required to unlock, if any. + * @returns The JSX element. + */ +const VampireZoneCard = ({ + zone, + isLocked, + unlockBossName, + unlockQuestName, +}: ZoneCardProperties): JSX.Element => { + return ( +
+
+ +

{zone.name}

+ {isLocked + ? {"πŸ”’"} + : null} +
+ +

{zone.description}

+ + {isLocked + && (unlockBossName !== undefined || unlockQuestName !== undefined) + ?
+

{"Unlock requirements:"}

+ {unlockBossName === undefined + ? null + :

+ {"🩸 Defeat: "} + {unlockBossName} +

+ } + {unlockQuestName === undefined + ? null + :

+ {"πŸ“œ Complete: "} + {unlockQuestName} +

+ } +
+ : null} + + {isLocked + ? null + : {"🩸 Unlocked"} + } +
+ ); +}; + +/** + * Renders the Vampire Zones panel showing all 18 vampire realms. + * @returns The JSX element. + */ +const VampireZonesPanel = (): JSX.Element => { + const { state } = useGame(); + + if (state === null) { + return ( +
+

{"Loading..."}

+
+ ); + } + + const { vampire } = state; + + if (vampire === undefined) { + return ( +
+

{"The Vampire expansion is not yet unlocked."}

+
+ ); + } + + const { bosses: vampireBosses, quests: vampireQuests, zones } = vampire; + + const defeatedBossIds = new Set( + vampireBosses. + filter((boss) => { + return boss.status === "defeated"; + }). + map((boss) => { + return boss.id; + }), + ); + + return ( +
+
+

{"πŸ—ΊοΈ Vampire Zones"}

+
+ +
+ {zones.map((zone) => { + const isLocked = zone.unlockBossId !== null + && !defeatedBossIds.has(zone.unlockBossId); + + const unlockBoss = zone.unlockBossId === null + ? undefined + : vampireBosses.find((boss) => { + return boss.id === zone.unlockBossId; + }); + + const unlockQuest = zone.unlockQuestId === null + ? undefined + : vampireQuests.find((quest) => { + return quest.id === zone.unlockQuestId; + }); + + return ( + + ); + })} +
+
+ ); +}; + +export { VampireZonesPanel };