generated from nhcarrigan/template
feat: goddess expansion chunks 6–9 — UI panels, tick engine, CSS theme, about page
- Add 11 goddess panels (zones, bosses, quests, disciples, equipment, upgrades, consecration, enlightenment, crafting, exploration, achievements) - Wire all panels into gameLayout via mode/tab routing - Add goddess passive income, disciple tick, quest timers, zone/quest unlock logic, and achievement checking to the tick engine - Add goddess CSS variables, .goddess-mode overrides, 300ms fade transition, and full panel stylesheet coverage - Add 13 Goddess expansion entries to the How to Play guide - Add web-side data files for crafting recipes, exploration areas, materials
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* @file Read-only panel displaying goddess 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 {
|
||||
GoddessQuest,
|
||||
GoddessQuestReward,
|
||||
GoddessZone,
|
||||
} 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 goddess quest reward.
|
||||
* @param reward - The reward to describe.
|
||||
* @param formatNumber - The number formatter function.
|
||||
* @returns The label string, or an empty string for unknown types.
|
||||
*/
|
||||
const getRewardLabel = (
|
||||
reward: GoddessQuestReward,
|
||||
formatNumber: (value: number)=> string,
|
||||
): string => {
|
||||
if (reward.type === "prayers") {
|
||||
return `🙏 ${formatNumber(reward.amount ?? 0)} Prayers`;
|
||||
}
|
||||
if (reward.type === "divinity") {
|
||||
return `✨ ${formatNumber(reward.amount ?? 0)} Divinity`;
|
||||
}
|
||||
if (reward.type === "stardust") {
|
||||
return `⭐ ${formatNumber(reward.amount ?? 0)} Stardust`;
|
||||
}
|
||||
if (reward.type === "upgrade") {
|
||||
return "🔓 Upgrade Unlocked";
|
||||
}
|
||||
if (reward.type === "disciple") {
|
||||
return "👤 New Disciple Tier";
|
||||
}
|
||||
return "🛡️ Equipment Unlocked";
|
||||
};
|
||||
|
||||
interface GoddessQuestCardProperties {
|
||||
readonly quest: GoddessQuest;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly zoneIsOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single goddess quest card (read-only).
|
||||
* @param props - The component properties.
|
||||
* @param props.quest - The goddess 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 GoddessQuestCard = ({
|
||||
quest,
|
||||
unlockHint,
|
||||
zoneIsOpen,
|
||||
}: GoddessQuestCardProperties): 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 goddess quests panel with zone selection and quest list.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessQuestsPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||||
return sessionStorage.getItem("elysium_goddess_quest_zone")
|
||||
?? "goddess_celestial_garden";
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const goddessState = state.goddess;
|
||||
if (goddessState === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Goddess expansion not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { zones, quests } = goddessState;
|
||||
|
||||
const activeZone = zones.find((zone: GoddessZone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
const zoneIsOpen = activeZone?.status === "unlocked";
|
||||
|
||||
const zoneQuests = quests.filter((quest: GoddessQuest) => {
|
||||
return quest.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const questNameById = new Map(
|
||||
quests.map((quest: GoddessQuest) => {
|
||||
return [ quest.id, quest.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
const getUnlockHint = (quest: GoddessQuest): 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_goddess_quest_zone", zoneId);
|
||||
}
|
||||
|
||||
const completedCount = zoneQuests.filter((quest: GoddessQuest) => {
|
||||
return quest.status === "completed";
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<section className="panel goddess-quests-panel">
|
||||
<h2>{"Goddess Quests"}</h2>
|
||||
<div className="zone-filter-buttons">
|
||||
{zones.map((zone: GoddessZone) => {
|
||||
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 goddess 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: GoddessQuest) => {
|
||||
return <GoddessQuestCard
|
||||
key={quest.id}
|
||||
quest={quest}
|
||||
unlockHint={getUnlockHint(quest)}
|
||||
zoneIsOpen={zoneIsOpen}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessQuestsPanel };
|
||||
Reference in New Issue
Block a user