Files
elysium/apps/web/src/components/game/vampireQuestsPanel.tsx
T
hikari 3e34701d32 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.
2026-04-16 10:15:03 -07:00

265 lines
7.8 KiB
TypeScript

/**
* @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 };