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,503 @@
|
||||
/**
|
||||
* @file Goddess Boss panel — challenge divine realm bosses.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines -- Panel with sub-component, modal, and zone filter */
|
||||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||||
/* eslint-disable complexity -- Boss card requires many conditional render paths */
|
||||
/* eslint-disable max-statements -- Panel requires many variable declarations */
|
||||
/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to this panel */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import type {
|
||||
GoddessBoss,
|
||||
GoddessBossChallengeResponse,
|
||||
GoddessZone,
|
||||
} from "@elysium/types";
|
||||
|
||||
interface GoddessBossCardProperties {
|
||||
readonly boss: GoddessBoss;
|
||||
readonly onChallenge: (bossId: string)=> void;
|
||||
readonly isChallenging: boolean;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly formatInteger: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single goddess boss card.
|
||||
* @param props - The boss card properties.
|
||||
* @param props.boss - The boss data.
|
||||
* @param props.onChallenge - Callback to challenge this boss.
|
||||
* @param props.isChallenging - Whether this boss is currently being challenged.
|
||||
* @param props.unlockHint - Optional hint for how to unlock this boss.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.formatInteger - The integer formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessBossCard = ({
|
||||
boss,
|
||||
onChallenge,
|
||||
isChallenging,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
}: GoddessBossCardProperties): JSX.Element => {
|
||||
const canChallenge
|
||||
= (boss.status === "available" || boss.status === "in_progress")
|
||||
&& !isChallenging;
|
||||
|
||||
const hpRatio = boss.currentHp / boss.maxHp;
|
||||
const hpPercent = hpRatio * 100;
|
||||
|
||||
function handleChallenge(): void {
|
||||
onChallenge(boss.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`boss-card boss-${boss.status}`}>
|
||||
<div className="boss-info">
|
||||
<h3>{boss.name}</h3>
|
||||
<p>{boss.description}</p>
|
||||
{boss.status === "locked" && unlockHint !== undefined
|
||||
? <p className="unlock-hint">{unlockHint}</p>
|
||||
: null}
|
||||
{boss.consecrationRequirement > 0
|
||||
? <p className="consecration-requirement">
|
||||
{"🕊️ Requires Consecration "}
|
||||
{boss.consecrationRequirement}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status !== "locked" && boss.status !== "defeated"
|
||||
? <div className="boss-hp">
|
||||
<div className="hp-bar">
|
||||
<div
|
||||
className="hp-fill"
|
||||
style={{ width: `${hpPercent.toFixed(1)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hp-text">
|
||||
{formatNumber(boss.currentHp)}
|
||||
{" / "}
|
||||
{formatNumber(boss.maxHp)}
|
||||
{" HP"}
|
||||
</span>
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className="boss-meta">
|
||||
<span className="boss-dps">
|
||||
{"💢 Boss DPS: "}
|
||||
{formatNumber(boss.damagePerSecond)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="boss-rewards">
|
||||
{boss.prayersReward > 0
|
||||
&& <span>
|
||||
{"🙏 "}
|
||||
{formatNumber(boss.prayersReward)}
|
||||
</span>
|
||||
}
|
||||
{boss.divinityReward > 0
|
||||
&& <span>
|
||||
{"✨ "}
|
||||
{formatInteger(boss.divinityReward)}
|
||||
{" Divinity"}
|
||||
</span>
|
||||
}
|
||||
{boss.stardustReward > 0
|
||||
&& <span>
|
||||
{"⭐ "}
|
||||
{formatInteger(boss.stardustReward)}
|
||||
{" Stardust"}
|
||||
</span>
|
||||
}
|
||||
{boss.equipmentRewards.length > 0
|
||||
&& <span>
|
||||
{"🗡️ "}
|
||||
{boss.equipmentRewards.length}
|
||||
{" Equipment"}
|
||||
</span>
|
||||
}
|
||||
{boss.status !== "defeated"
|
||||
&& boss.bountyDivinity > 0
|
||||
&& boss.bountyDivinityClaimed !== true
|
||||
? <span className="boss-bounty">
|
||||
{"✨ "}
|
||||
{boss.bountyDivinity}
|
||||
{" Divinity (first kill)"}
|
||||
</span>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{boss.status === "available" || boss.status === "in_progress"
|
||||
? <button
|
||||
className="attack-button"
|
||||
disabled={!canChallenge}
|
||||
onClick={handleChallenge}
|
||||
type="button"
|
||||
>
|
||||
{isChallenging
|
||||
? "⚔️ Battling…"
|
||||
: "⚔️ Challenge"}
|
||||
</button>
|
||||
: null}
|
||||
|
||||
{boss.status === "defeated"
|
||||
? <span className="boss-badge defeated">{"☠️ Defeated"}</span>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GoddessBattleModalProperties {
|
||||
readonly result: GoddessBossChallengeResponse;
|
||||
readonly onDismiss: ()=> void;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
readonly formatInteger: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the goddess battle result modal overlay.
|
||||
* @param props - The modal properties.
|
||||
* @param props.result - The battle result data.
|
||||
* @param props.onDismiss - Callback to dismiss the modal.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @param props.formatInteger - The integer formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessBattleModal = ({
|
||||
result,
|
||||
onDismiss,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
}: GoddessBattleModalProperties): JSX.Element => {
|
||||
return (
|
||||
<div aria-modal="true" className="battle-modal-overlay" role="dialog">
|
||||
<div className="battle-modal">
|
||||
<h2 className="battle-modal-title">
|
||||
{result.won
|
||||
? "⚔️ Victory!"
|
||||
: "💀 Defeated!"}
|
||||
</h2>
|
||||
|
||||
<div className="battle-stats">
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"⚔️ Your Party DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.partyDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"💢 Boss DPS"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"❤️ Boss HP Before"}</span>
|
||||
<span className="stat-value">
|
||||
{formatNumber(result.bossHpBefore)}
|
||||
{" / "}
|
||||
{formatNumber(result.bossMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"❤️ Boss HP After"}</span>
|
||||
<span className="stat-value">{formatNumber(result.bossNewHp)}</span>
|
||||
</div>
|
||||
<div className="battle-stat">
|
||||
<span className="stat-label">{"🛡️ Party HP Remaining"}</span>
|
||||
<span className="stat-value">
|
||||
{formatNumber(result.partyHpRemaining)}
|
||||
{" / "}
|
||||
{formatNumber(result.partyMaxHp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.won && result.rewards !== undefined
|
||||
? <div className="battle-rewards">
|
||||
<h3>{"Rewards"}</h3>
|
||||
{result.rewards.prayers > 0
|
||||
? <p>
|
||||
{"🙏 "}
|
||||
{formatNumber(result.rewards.prayers)}
|
||||
{" Prayers"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.divinity > 0
|
||||
? <p>
|
||||
{"✨ "}
|
||||
{formatInteger(result.rewards.divinity)}
|
||||
{" Divinity"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.stardust > 0
|
||||
? <p>
|
||||
{"⭐ "}
|
||||
{formatInteger(result.rewards.stardust)}
|
||||
{" Stardust"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.bountyDivinity > 0
|
||||
? <p className="bounty-reward">
|
||||
{"✨ "}
|
||||
{formatInteger(result.rewards.bountyDivinity)}
|
||||
{" Divinity (first kill bonus!)"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.upgradeIds.length > 0
|
||||
? <p>
|
||||
{"🔓 "}
|
||||
{result.rewards.upgradeIds.length}
|
||||
{" Upgrade(s) unlocked"}
|
||||
</p>
|
||||
: null}
|
||||
{result.rewards.equipmentIds.length > 0
|
||||
? <p>
|
||||
{"🗡️ "}
|
||||
{result.rewards.equipmentIds.length}
|
||||
{" Equipment item(s) gained"}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
{result.casualties !== undefined && result.casualties.length > 0
|
||||
? <div className="battle-casualties">
|
||||
<h3>{"Casualties"}</h3>
|
||||
{result.casualties.map((casualty) => {
|
||||
return (
|
||||
<p key={casualty.discipleId}>
|
||||
{casualty.killed}
|
||||
{" "}
|
||||
{casualty.discipleId}
|
||||
{" lost"}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<button
|
||||
className="dismiss-button"
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
>
|
||||
{"Dismiss"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the Goddess Boss panel with zone filtering and battle result modal.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const GoddessBossPanel = (): JSX.Element => {
|
||||
const {
|
||||
state,
|
||||
challengeGoddessBoss,
|
||||
goddessBattleResult,
|
||||
dismissGoddessBattle,
|
||||
formatNumber,
|
||||
formatInteger,
|
||||
} = useGame();
|
||||
|
||||
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [ activeZoneId, setActiveZoneId ] = useState<string | null>(() => {
|
||||
return sessionStorage.getItem("elysium_goddess_boss_zone");
|
||||
});
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { goddess } = state;
|
||||
|
||||
if (goddess === undefined) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"The Goddess expansion is not yet unlocked."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, quests, zones } = goddess;
|
||||
|
||||
async function handleChallenge(bossId: string): Promise<void> {
|
||||
setChallengingBossId(bossId);
|
||||
try {
|
||||
await challengeGoddessBoss(bossId);
|
||||
} finally {
|
||||
setChallengingBossId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChallengeClick(bossId: string): void {
|
||||
void handleChallenge(bossId);
|
||||
}
|
||||
|
||||
function handleZoneSelect(zoneId: string): void {
|
||||
setActiveZoneId(zoneId);
|
||||
sessionStorage.setItem("elysium_goddess_boss_zone", zoneId);
|
||||
}
|
||||
|
||||
function handleShowAll(): void {
|
||||
setActiveZoneId(null);
|
||||
sessionStorage.removeItem("elysium_goddess_boss_zone");
|
||||
}
|
||||
|
||||
const filteredBosses = activeZoneId === null
|
||||
? bosses
|
||||
: bosses.filter((boss) => {
|
||||
return boss.zoneId === activeZoneId;
|
||||
});
|
||||
|
||||
const bossUnlockHints = new Map<string, string>();
|
||||
for (const zone of zones) {
|
||||
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
|
||||
const zoneBosses = bosses.filter((boss) => {
|
||||
return boss.zoneId === zoneId;
|
||||
});
|
||||
for (let index = 0; index < zoneBosses.length; index = index + 1) {
|
||||
const boss = zoneBosses[index];
|
||||
if (boss === undefined || boss.status !== "locked") {
|
||||
continue;
|
||||
}
|
||||
if (index === 0) {
|
||||
const parts: Array<string> = [];
|
||||
if (unlockBossId !== null) {
|
||||
const gateBoss = bosses.find((candidate) => {
|
||||
return candidate.id === unlockBossId;
|
||||
});
|
||||
if (gateBoss !== undefined) {
|
||||
parts.push(`⚔️ Defeat: ${gateBoss.name}`);
|
||||
}
|
||||
}
|
||||
if (unlockQuestId !== null) {
|
||||
const gateQuest = quests.find((candidate) => {
|
||||
return candidate.id === unlockQuestId;
|
||||
});
|
||||
if (gateQuest !== undefined) {
|
||||
parts.push(`📜 Complete: ${gateQuest.name}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
bossUnlockHints.set(boss.id, parts.join(" & "));
|
||||
}
|
||||
} else {
|
||||
const previousBoss = zoneBosses[index - 1];
|
||||
if (previousBoss !== undefined) {
|
||||
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeZoneData: GoddessZone | undefined = activeZoneId === null
|
||||
? undefined
|
||||
: zones.find((zone) => {
|
||||
return zone.id === activeZoneId;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel goddess-boss-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"⚔️ Goddess Bosses"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="zone-selector">
|
||||
<button
|
||||
className={`zone-tab${activeZoneId === null
|
||||
? " zone-tab-active"
|
||||
: ""}`}
|
||||
onClick={handleShowAll}
|
||||
type="button"
|
||||
>
|
||||
{"All Zones"}
|
||||
</button>
|
||||
{zones.map((zone) => {
|
||||
function handleSelect(): void {
|
||||
handleZoneSelect(zone.id);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`zone-tab${zone.id === activeZoneId
|
||||
? " zone-tab-active"
|
||||
: ""}`}
|
||||
key={zone.id}
|
||||
onClick={handleSelect}
|
||||
title={zone.description}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">{zone.emoji}</span>
|
||||
<span className="zone-name">{zone.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeZoneData?.status === "locked"
|
||||
? <div className="exploration-zone-locked-hint">
|
||||
<p>{"🔒 This zone is locked."}</p>
|
||||
{activeZoneData.unlockBossId === null
|
||||
? null
|
||||
: <p>
|
||||
{"⚔️ Defeat: "}
|
||||
{bosses.find((boss) => {
|
||||
return boss.id === activeZoneData.unlockBossId;
|
||||
})?.name ?? activeZoneData.unlockBossId}
|
||||
</p>}
|
||||
{activeZoneData.unlockQuestId === null
|
||||
? null
|
||||
: <p>
|
||||
{"📜 Complete: "}
|
||||
{quests.find((quest) => {
|
||||
return quest.id === activeZoneData.unlockQuestId;
|
||||
})?.name ?? activeZoneData.unlockQuestId}
|
||||
</p>}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className="boss-list">
|
||||
{filteredBosses.map((boss) => {
|
||||
return (
|
||||
<GoddessBossCard
|
||||
boss={boss}
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
isChallenging={challengingBossId === boss.id}
|
||||
key={boss.id}
|
||||
onChallenge={handleChallengeClick}
|
||||
unlockHint={bossUnlockHints.get(boss.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filteredBosses.length === 0
|
||||
? <p className="empty-zone">{"No bosses to show in this zone."}</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{goddessBattleResult === null
|
||||
? null
|
||||
: <GoddessBattleModal
|
||||
formatInteger={formatInteger}
|
||||
formatNumber={formatNumber}
|
||||
onDismiss={dismissGoddessBattle}
|
||||
result={goddessBattleResult}
|
||||
/>}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { GoddessBossPanel };
|
||||
Reference in New Issue
Block a user