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.
This commit is contained in:
2026-04-16 10:15:03 -07:00
committed by Naomi Carrigan
parent d9d1228172
commit 3e34701d32
4 changed files with 675 additions and 9 deletions
@@ -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 (
<div className={`achievement-card ${isUnlocked
? "unlocked"
: "locked"}`}>
<div className="achievement-icon">
<span className="achievement-emoji">{achievement.icon}</span>
</div>
<div className="achievement-info">
<h3>{achievement.name}</h3>
<p>{achievement.description}</p>
<p className="achievement-condition">
{conditionDescription(achievement, formatNumber)}
</p>
{!isUnlocked
&& <div className="achievement-progress">
<progress
max={achievement.condition.amount}
value={cappedProgress}
/>
<span className="achievement-progress-label">
{formatNumber(progressValue)}
{" / "}
{formatNumber(achievement.condition.amount)}
</span>
</div>
}
{achievement.reward !== undefined
&& <div className="achievement-reward">
{achievement.reward.ichor !== undefined
&& <p>
{"💧 +"}
{achievement.reward.ichor}
{" Ichor"}
</p>
}
{achievement.reward.soulShards !== undefined
&& <p>
{"💠 +"}
{achievement.reward.soulShards}
{" Soul Shards"}
</p>
}
</div>
}
</div>
<div className="achievement-status">
{isUnlocked
? <>
<span className="achievement-unlocked-badge">{"✓ Unlocked"}</span>
{achievement.unlockedAt !== null
&& <span className="achievement-unlocked-at">
{new Date(achievement.unlockedAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
}
</>
: <span className="achievement-locked-badge">{"🔒"}</span>
}
</div>
</div>
);
};
/**
* 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 (
<section className="panel">
<p>{"Loading..."}</p>
</section>
);
}
const { vampire } = state;
if (vampire === undefined) {
return (
<section className="panel">
<p>{"The Vampire expansion is not yet unlocked."}</p>
</section>
);
}
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 (
<section className="panel achievement-panel vampire-achievements-panel">
<div className="panel-header">
<h2>{"🩸 Vampire Achievements"}</h2>
<LockToggle
lockedCount={locked.length}
onToggle={handleToggle}
showLocked={showLocked}
/>
</div>
<p className="achievement-progress">
{unlocked.length}
{" / "}
{achievementList.length}
{" unlocked"}
</p>
<div className="achievement-list">
{visible.map((achievement) => {
return (
<VampireAchievementCard
achievement={achievement}
formatNumber={formatNumber}
key={achievement.id}
progressValue={getCurrentProgress(achievement, vampire)}
/>
);
})}
</div>
</section>
);
};
export { VampireAchievementsPanel };