generated from nhcarrigan/template
1195b657a0
Working through open issues — fixes, balance changes, and features. ## Closed - Closes #161 - Closes #181 - Closes #191 - Closes #199 - Closes #201 - Closes #202 - Closes #203 - Closes #204 - Closes #205 - Closes #206 - Closes #208 - Closes #211 - Closes #212 - Closes #213 - Closes #214 - Closes #216 - Closes #219 - Closes #220 - Closes #221 - Closes #222 - Closes #224 - Closes #225 - Closes #226 - Closes #228 - Closes #229 - Closes #230 - Closes #231 - Closes #232 - Closes #233 - Closes #234 - Closes #235 - Closes #236 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #238 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
/**
|
|
* @file Boss panel component for viewing and challenging zone bosses.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
|
/* 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 -- Boss panel requires many variable declarations */
|
|
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */
|
|
import { type JSX, useState } from "react";
|
|
import { useGame } from "../../context/gameContext.js";
|
|
import { computePartyCombatPower } from "../../engine/tick.js";
|
|
import { cdnImage } from "../../utils/cdn.js";
|
|
import { LockToggle } from "../ui/lockToggle.js";
|
|
import { ZoneSelector } from "./zoneSelector.js";
|
|
import type { Boss } from "@elysium/types";
|
|
|
|
interface BossCardProperties {
|
|
readonly boss: Boss;
|
|
readonly prestigeCount: number;
|
|
readonly onChallenge: (bossId: string)=> void;
|
|
readonly isChallenging: boolean;
|
|
readonly unlockHint: string | undefined;
|
|
readonly formatInteger: (n: number)=> string;
|
|
readonly formatNumber: (n: number)=> string;
|
|
}
|
|
|
|
/**
|
|
* Renders a single boss card.
|
|
* @param props - The boss card properties.
|
|
* @param props.boss - The boss data.
|
|
* @param props.prestigeCount - The current prestige count for lock checking.
|
|
* @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.formatInteger - The integer formatting utility function.
|
|
* @param props.formatNumber - The number formatting utility function.
|
|
* @returns The JSX element.
|
|
*/
|
|
const BossCard = ({
|
|
boss,
|
|
prestigeCount,
|
|
onChallenge,
|
|
isChallenging,
|
|
unlockHint,
|
|
formatInteger,
|
|
formatNumber,
|
|
}: BossCardProperties): JSX.Element => {
|
|
const scaled = boss.currentHp * 100;
|
|
const hpPercent = scaled / boss.maxHp;
|
|
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
|
|
const canChallenge
|
|
= (boss.status === "available" || boss.status === "in_progress")
|
|
&& !isChallenging;
|
|
|
|
function handleChallenge(): void {
|
|
onChallenge(boss.id);
|
|
}
|
|
|
|
return (
|
|
<div className={`boss-card boss-${boss.status}`}>
|
|
<img
|
|
alt={boss.name}
|
|
className="card-thumbnail"
|
|
src={cdnImage("bosses", boss.id)}
|
|
/>
|
|
<div className="boss-info">
|
|
<h3>{boss.name}</h3>
|
|
<p>{boss.description}</p>
|
|
{isPrestigeLocked && boss.status === "locked"
|
|
? <p className="prestige-lock">
|
|
{"🔒 Requires Prestige "}
|
|
{boss.prestigeRequirement}
|
|
</p>
|
|
: null}
|
|
{!isPrestigeLocked
|
|
&& boss.status === "locked"
|
|
&& unlockHint !== undefined
|
|
? <p className="unlock-hint">{unlockHint}</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>
|
|
}
|
|
|
|
<div className="boss-meta">
|
|
<span className="boss-dps">
|
|
{"💢 Boss DPS: "}
|
|
{formatNumber(boss.damagePerSecond)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="boss-rewards">
|
|
<span>
|
|
{"🪙 "}
|
|
{formatNumber(boss.goldReward)}
|
|
</span>
|
|
{boss.essenceReward > 0
|
|
&& <span>
|
|
{"✨ "}
|
|
{formatNumber(boss.essenceReward)}
|
|
</span>
|
|
}
|
|
{boss.crystalReward > 0
|
|
&& <span>
|
|
{"💎 "}
|
|
{formatInteger(boss.crystalReward)}
|
|
</span>
|
|
}
|
|
{boss.equipmentRewards.length > 0
|
|
&& <span>
|
|
{"🗡️ "}
|
|
{boss.equipmentRewards.length}
|
|
{" Equipment"}
|
|
</span>
|
|
}
|
|
{boss.status !== "defeated"
|
|
&& boss.bountyRunestones > 0
|
|
&& boss.bountyRunestonesClaimed !== true
|
|
&& <span className="boss-bounty">
|
|
{"🔮 "}
|
|
{boss.bountyRunestones}
|
|
{" (first kill)"}
|
|
</span>
|
|
}
|
|
</div>
|
|
|
|
{(boss.status === "available" || boss.status === "in_progress")
|
|
&& <button
|
|
className="attack-button"
|
|
disabled={!canChallenge}
|
|
onClick={handleChallenge}
|
|
type="button"
|
|
>
|
|
{isChallenging
|
|
? "⚔️ Battling…"
|
|
: "⚔️ Challenge"}
|
|
</button>
|
|
}
|
|
|
|
{boss.status === "defeated"
|
|
&& <span className="boss-badge defeated">{"☠️ Defeated"}</span>
|
|
}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Renders the boss panel with zone selection and boss list.
|
|
* @returns The JSX element.
|
|
*/
|
|
const BossPanel = (): JSX.Element => {
|
|
const {
|
|
state,
|
|
challengeBoss,
|
|
formatInteger,
|
|
formatNumber,
|
|
toggleAutoBoss,
|
|
autoBossLastResult,
|
|
autoBossError,
|
|
bossError,
|
|
} = useGame();
|
|
const [ challengingBossId, setChallengingBossId ] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
|
return sessionStorage.getItem("elysium_boss_zone") ?? "verdant_vale";
|
|
});
|
|
const [ showLocked, setShowLocked ] = useState(true);
|
|
|
|
if (state === null) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading..."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
async function handleChallenge(bossId: string): Promise<void> {
|
|
setChallengingBossId(bossId);
|
|
try {
|
|
await challengeBoss(bossId);
|
|
} finally {
|
|
setChallengingBossId(null);
|
|
}
|
|
}
|
|
|
|
function handleChallengeClick(bossId: string): void {
|
|
void handleChallenge(bossId);
|
|
}
|
|
|
|
const {
|
|
adventurers,
|
|
autoBoss,
|
|
bosses,
|
|
prestige: playerPrestige,
|
|
quests,
|
|
zones,
|
|
} = state;
|
|
|
|
const activeZone = zones.find((zone) => {
|
|
return zone.id === activeZoneId;
|
|
});
|
|
const zoneIsLocked = activeZone?.status === "locked";
|
|
const unlockBoss = activeZone?.unlockBossId === null
|
|
|| activeZone?.unlockBossId === undefined
|
|
? undefined
|
|
: bosses.find((boss) => {
|
|
return boss.id === activeZone.unlockBossId;
|
|
});
|
|
const unlockQuest = activeZone?.unlockQuestId === null
|
|
|| activeZone?.unlockQuestId === undefined
|
|
? undefined
|
|
: quests.find((quest) => {
|
|
return quest.id === activeZone.unlockQuestId;
|
|
});
|
|
const zoneBosses = bosses.filter((boss) => {
|
|
return boss.zoneId === activeZoneId;
|
|
});
|
|
const lockedCount = zoneBosses.filter((boss) => {
|
|
return boss.status === "locked";
|
|
}).length;
|
|
const visibleBosses = showLocked
|
|
? zoneBosses
|
|
: zoneBosses.filter((boss) => {
|
|
return boss.status !== "locked";
|
|
});
|
|
|
|
const bossUnlockHints = new Map<string, string>();
|
|
for (const zone of zones) {
|
|
const { id: zoneId, unlockBossId, unlockQuestId } = zone;
|
|
const allZoneBosses = bosses.filter((boss) => {
|
|
return boss.zoneId === zoneId;
|
|
});
|
|
for (let index = 0; index < allZoneBosses.length; index = index + 1) {
|
|
const boss = allZoneBosses[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 = allZoneBosses[index - 1];
|
|
if (previousBoss !== undefined) {
|
|
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${previousBoss.name} first`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleZoneSelect(zoneId: string): void {
|
|
setActiveZoneId(zoneId);
|
|
sessionStorage.setItem("elysium_boss_zone", zoneId);
|
|
}
|
|
|
|
function handleToggle(): void {
|
|
setShowLocked((current) => {
|
|
return !current;
|
|
});
|
|
}
|
|
|
|
const autoBossOn = autoBoss === true;
|
|
const partyDps = computePartyCombatPower(state);
|
|
let partyHp = 0;
|
|
for (const { level, count } of adventurers) {
|
|
// eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear
|
|
partyHp = partyHp + level * 50 * count;
|
|
}
|
|
const { count: prestigeCount } = playerPrestige;
|
|
|
|
return (
|
|
<section className="panel boss-panel">
|
|
<div className="panel-header">
|
|
<h2>{"Boss Encounters"}</h2>
|
|
<div className="panel-header-controls">
|
|
<button
|
|
className={`auto-toggle-btn ${
|
|
autoBossOn
|
|
? "auto-toggle-on"
|
|
: "auto-toggle-off"
|
|
}`}
|
|
onClick={toggleAutoBoss}
|
|
title="Automatically challenge the highest available boss"
|
|
type="button"
|
|
>
|
|
{"🤖 Auto: "}
|
|
{autoBossOn
|
|
? "ON"
|
|
: "OFF"}
|
|
</button>
|
|
<LockToggle
|
|
lockedCount={lockedCount}
|
|
onToggle={handleToggle}
|
|
showLocked={showLocked}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{bossError === null
|
|
? null
|
|
: <p className="auto-boss-error">
|
|
{"⚠️ "}
|
|
{bossError}
|
|
</p>
|
|
}
|
|
{autoBossError === null
|
|
? null
|
|
: <p className="auto-boss-error">
|
|
{"⚠️ Auto-boss stopped: "}
|
|
{autoBossError}
|
|
</p>
|
|
}
|
|
{autoBossLastResult !== null && autoBossError === null
|
|
? <p className="auto-boss-status">
|
|
{"🤖 Last fight: "}
|
|
{autoBossLastResult.bossName}
|
|
{autoBossLastResult.won
|
|
? " — ✅ Won"
|
|
: " — ❌ Lost"}
|
|
</p>
|
|
: null}
|
|
|
|
<ZoneSelector
|
|
activeZoneId={activeZoneId}
|
|
onSelectZone={handleZoneSelect}
|
|
zones={zones}
|
|
/>
|
|
|
|
{zoneIsLocked && (unlockBoss !== undefined || unlockQuest !== undefined)
|
|
? <div className="exploration-zone-locked-hint">
|
|
<p>{"🔒 This zone is locked. Unlock bosses by:"}</p>
|
|
{unlockBoss === undefined
|
|
? null
|
|
: <p>
|
|
{"⚔️ Defeat: "}
|
|
{unlockBoss.name}
|
|
</p>
|
|
}
|
|
{unlockQuest === undefined
|
|
? null
|
|
: <p>
|
|
{"📜 Complete: "}
|
|
{unlockQuest.name}
|
|
</p>
|
|
}
|
|
</div>
|
|
: null
|
|
}
|
|
|
|
<div className="party-combat-stats">
|
|
<div className="combat-stat">
|
|
<span className="stat-label">{"⚔️ Party DPS"}</span>
|
|
<span className="stat-value">{formatNumber(partyDps)}</span>
|
|
</div>
|
|
<div className="combat-stat">
|
|
<span className="stat-label">{"❤️ Party HP"}</span>
|
|
<span className="stat-value">{formatNumber(partyHp)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="boss-list">
|
|
{visibleBosses.map((boss) => {
|
|
const { id: bossId } = boss;
|
|
return (
|
|
<BossCard
|
|
boss={boss}
|
|
formatInteger={formatInteger}
|
|
formatNumber={formatNumber}
|
|
isChallenging={challengingBossId === bossId}
|
|
key={bossId}
|
|
onChallenge={handleChallengeClick}
|
|
prestigeCount={prestigeCount}
|
|
unlockHint={bossUnlockHints.get(bossId)}
|
|
/>
|
|
);
|
|
})}
|
|
{visibleBosses.length === 0
|
|
&& <p className="empty-zone">{"No bosses to show in this zone."}</p>
|
|
}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { BossPanel };
|