generated from nhcarrigan/template
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:
@@ -53,6 +53,9 @@ import { StoryPanel } from "./storyPanel.js";
|
|||||||
import { StoryToast } from "./storyToast.js";
|
import { StoryToast } from "./storyToast.js";
|
||||||
import { TranscendencePanel } from "./transcendencePanel.js";
|
import { TranscendencePanel } from "./transcendencePanel.js";
|
||||||
import { UpgradePanel } from "./upgradePanel.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";
|
type Mode = "mortal" | "goddess" | "vampire";
|
||||||
|
|
||||||
@@ -479,9 +482,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
&& <GoddessAchievementsPanel />}
|
&& <GoddessAchievementsPanel />}
|
||||||
{activeMode === "vampire"
|
{activeMode === "vampire"
|
||||||
&& activeVampireTab === "vampire-zones"
|
&& activeVampireTab === "vampire-zones"
|
||||||
&& <div className="vampire-placeholder">
|
&& <VampireZonesPanel />
|
||||||
<p>{"🗺️ Vampire Zones coming soon..."}</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
{activeMode === "vampire"
|
{activeMode === "vampire"
|
||||||
&& activeVampireTab === "vampire-bosses"
|
&& activeVampireTab === "vampire-bosses"
|
||||||
@@ -491,9 +492,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
{activeMode === "vampire"
|
{activeMode === "vampire"
|
||||||
&& activeVampireTab === "vampire-quests"
|
&& activeVampireTab === "vampire-quests"
|
||||||
&& <div className="vampire-placeholder">
|
&& <VampireQuestsPanel />
|
||||||
<p>{"📜 Vampire Quests coming soon..."}</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
{activeMode === "vampire"
|
{activeMode === "vampire"
|
||||||
&& activeVampireTab === "thralls"
|
&& activeVampireTab === "thralls"
|
||||||
@@ -539,9 +538,7 @@ const GameLayout = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
{activeMode === "vampire"
|
{activeMode === "vampire"
|
||||||
&& activeVampireTab === "vampire-achievements"
|
&& activeVampireTab === "vampire-achievements"
|
||||||
&& <div className="vampire-placeholder">
|
&& <VampireAchievementsPanel />
|
||||||
<p>{"🏆 Vampire Achievements coming soon..."}</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 (
|
||||||
|
<div className={`quest-card quest-${quest.status}`}>
|
||||||
|
<div className="quest-info">
|
||||||
|
<h3>{quest.name}</h3>
|
||||||
|
<p>{quest.description}</p>
|
||||||
|
<p className="quest-duration">
|
||||||
|
{"⏱ "}
|
||||||
|
{formatDuration(quest.durationSeconds)}
|
||||||
|
</p>
|
||||||
|
<div className="quest-rewards">
|
||||||
|
{quest.rewards.map((reward, rewardIndex) => {
|
||||||
|
return <span
|
||||||
|
className="reward-tag"
|
||||||
|
key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}
|
||||||
|
>
|
||||||
|
{getRewardLabel(reward, formatNumber)}
|
||||||
|
</span>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="quest-action">
|
||||||
|
{quest.status === "locked" && !zoneIsOpen
|
||||||
|
&& <span className="quest-badge locked">{"🔒 Zone Locked"}</span>
|
||||||
|
}
|
||||||
|
{quest.status === "locked" && zoneIsOpen
|
||||||
|
? <>
|
||||||
|
<span className="quest-badge locked">{"🔒 Locked"}</span>
|
||||||
|
{unlockHint !== undefined
|
||||||
|
&& <p className="unlock-hint">
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockHint}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
{quest.status === "available"
|
||||||
|
&& <span className="quest-badge available">{"📋 Available"}</span>
|
||||||
|
}
|
||||||
|
{quest.status === "active"
|
||||||
|
&& <span className="quest-badge active">{"⏳ In Progress"}</span>
|
||||||
|
}
|
||||||
|
{quest.status === "completed"
|
||||||
|
&& <span className="quest-badge completed">{"✅ Completed"}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<section className="panel">
|
||||||
|
<p>{"Loading..."}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vampireState = state.vampire;
|
||||||
|
if (vampireState === undefined) {
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<p>{"Vampire expansion not yet unlocked."}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="panel vampire-quests-panel">
|
||||||
|
<h2>{"Vampire Quests"}</h2>
|
||||||
|
<div className="zone-filter-buttons">
|
||||||
|
{zones.map((zone: VampireZone) => {
|
||||||
|
function handleClick(): void {
|
||||||
|
handleZoneSelect(zone.id);
|
||||||
|
}
|
||||||
|
return <button
|
||||||
|
className={`zone-filter-button ${zone.id === activeZoneId
|
||||||
|
? "active"
|
||||||
|
: ""} ${zone.status === "locked"
|
||||||
|
? "zone-locked"
|
||||||
|
: ""}`}
|
||||||
|
key={zone.id}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={zone.status === "locked"
|
||||||
|
? "Zone locked"
|
||||||
|
: zone.name}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{zone.emoji}
|
||||||
|
{" "}
|
||||||
|
{zone.name}
|
||||||
|
</button>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{activeZone !== undefined
|
||||||
|
&& <div className="zone-info">
|
||||||
|
<p className="zone-description">{activeZone.description}</p>
|
||||||
|
<p className="zone-progress">
|
||||||
|
{String(completedCount)}
|
||||||
|
{" / "}
|
||||||
|
{String(zoneQuests.length)}
|
||||||
|
{" quests completed"}
|
||||||
|
</p>
|
||||||
|
{activeZone.status === "locked"
|
||||||
|
&& <p className="zone-locked-notice">
|
||||||
|
{"🔒 This zone is locked. Defeat the required vampire boss"}
|
||||||
|
{" to unlock it."}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div className="quest-list">
|
||||||
|
{zoneQuests.length === 0
|
||||||
|
? <p className="empty-state">{"No quests in this zone."}</p>
|
||||||
|
: zoneQuests.map((quest: VampireQuest) => {
|
||||||
|
return <VampireQuestCard
|
||||||
|
key={quest.id}
|
||||||
|
quest={quest}
|
||||||
|
unlockHint={getUnlockHint(quest)}
|
||||||
|
zoneIsOpen={zoneIsOpen}
|
||||||
|
/>;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { VampireQuestsPanel };
|
||||||
@@ -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 (
|
||||||
|
<div className={`zone-card${isLocked
|
||||||
|
? " locked"
|
||||||
|
: ""}`}>
|
||||||
|
<div className="zone-card-header">
|
||||||
|
<span aria-hidden="true" className="zone-emoji">{zone.emoji}</span>
|
||||||
|
<h3 className="zone-name">{zone.name}</h3>
|
||||||
|
{isLocked
|
||||||
|
? <span aria-label="Locked" className="zone-lock-icon">{"🔒"}</span>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="zone-description">{zone.description}</p>
|
||||||
|
|
||||||
|
{isLocked
|
||||||
|
&& (unlockBossName !== undefined || unlockQuestName !== undefined)
|
||||||
|
? <div className="zone-unlock-requirements">
|
||||||
|
<p className="zone-unlock-label">{"Unlock requirements:"}</p>
|
||||||
|
{unlockBossName === undefined
|
||||||
|
? null
|
||||||
|
: <p className="zone-unlock-item">
|
||||||
|
{"🩸 Defeat: "}
|
||||||
|
{unlockBossName}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
{unlockQuestName === undefined
|
||||||
|
? null
|
||||||
|
: <p className="zone-unlock-item">
|
||||||
|
{"📜 Complete: "}
|
||||||
|
{unlockQuestName}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{isLocked
|
||||||
|
? null
|
||||||
|
: <span className="zone-badge unlocked">{"🩸 Unlocked"}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<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 { bosses: vampireBosses, quests: vampireQuests, zones } = vampire;
|
||||||
|
|
||||||
|
const defeatedBossIds = new Set(
|
||||||
|
vampireBosses.
|
||||||
|
filter((boss) => {
|
||||||
|
return boss.status === "defeated";
|
||||||
|
}).
|
||||||
|
map((boss) => {
|
||||||
|
return boss.id;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel vampire-zones-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2>{"🗺️ Vampire Zones"}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="zone-grid">
|
||||||
|
{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 (
|
||||||
|
<VampireZoneCard
|
||||||
|
isLocked={isLocked}
|
||||||
|
key={zone.id}
|
||||||
|
unlockBossName={unlockBoss?.name}
|
||||||
|
unlockQuestName={unlockQuest?.name}
|
||||||
|
zone={zone}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { VampireZonesPanel };
|
||||||
Reference in New Issue
Block a user