Files
elysium/apps/web/src/components/game/battleModal.tsx
T
hikari 1195b657a0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m10s
CI / Lint, Build & Test (push) Successful in 1m13s
feat: another balance and bug fix pass (#238)
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>
2026-04-06 18:17:00 -07:00

294 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Battle modal component displaying animated battle results.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex battle animation and result display */
/* eslint-disable complexity -- Battle result display requires many conditional paths */
import { type JSX, useEffect, useState } from "react";
import { type BattleResult, useGame } from "../../context/gameContext.js";
import { sendNotification } from "../../utils/notification.js";
import { playSound } from "../../utils/sound.js";
/**
* Converts HP values to a percentage for display.
* @param current - The current HP value.
* @param maximum - The maximum HP value.
* @returns The percentage as a number between 0 and 100.
*/
const toHpPercent = (current: number, maximum: number): number => {
if (maximum === 0) {
return 0;
}
const scaled = current * 100;
return scaled / maximum;
};
/**
* Returns a colour hex string based on the HP percentage.
* Green above 50%, yellow 25โ€“50%, red below 25%.
* @param percent - Current HP as a percentage (0โ€“100).
* @returns A hex colour string.
*/
const getHpColour = (percent: number): string => {
if (percent > 50) {
return "#27ae60";
}
if (percent > 25) {
return "#f39c12";
}
return "#e74c3c";
};
interface BattleModalProperties {
readonly battle: BattleResult;
readonly onDismiss: ()=> void;
}
/**
* Renders the battle modal with HP bars and animated battle results.
* @param props - The battle modal properties.
* @param props.battle - The battle result data to display.
* @param props.onDismiss - Callback to dismiss the modal.
* @returns The JSX element.
*/
const BattleModal = ({
battle,
onDismiss,
}: BattleModalProperties): JSX.Element => {
const { result, bossName } = battle;
const {
enableNotifications,
enableSounds,
flushBossLoreToasts,
formatInteger,
formatNumber,
} = useGame();
const [ phase, setPhase ] = useState<"animating" | "result">("animating");
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
const bossEndPercent = toHpPercent(
result.bossHpAtBattleEnd,
result.bossMaxHp,
);
const partyEndPercent = toHpPercent(
result.partyHpRemaining,
result.partyMaxHp,
);
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
const [ partyHpPercent, setPartyHpPercent ] = useState(100);
useEffect(() => {
const animationDurationMs = 5000;
const intervalMs = 50;
const totalSteps = animationDurationMs / intervalMs;
const bossHpRange = bossEndPercent - bossStartPercent;
const bossDelta = bossHpRange / totalSteps;
const partyHpRange = partyEndPercent - 100;
const partyDelta = partyHpRange / totalSteps;
let currentStep = 0;
// eslint-disable-next-line @typescript-eslint/init-declarations -- assigned inside timeout
let intervalId: ReturnType<typeof setInterval> | undefined;
const tick = (): void => {
currentStep = currentStep + 1;
if (currentStep >= totalSteps) {
setBossHpPercent(bossEndPercent);
setPartyHpPercent(partyEndPercent);
clearInterval(intervalId);
} else {
const bossStep = bossDelta * currentStep;
setBossHpPercent(bossStartPercent + bossStep);
const partyStep = partyDelta * currentStep;
setPartyHpPercent(100 + partyStep);
}
};
const startTimeout = setTimeout(() => {
intervalId = setInterval(tick, intervalMs);
}, 200);
const revealTimeout = setTimeout(() => {
setPhase("result");
flushBossLoreToasts();
if (result.won) {
if (enableSounds) {
playSound("bossVictory");
}
if (enableNotifications) {
sendNotification("โš”๏ธ Boss Defeated!", `You defeated ${bossName}!`);
}
}
}, 5200);
return (): void => {
clearTimeout(startTimeout);
clearTimeout(revealTimeout);
clearInterval(intervalId);
};
}, [
bossEndPercent,
bossName,
bossStartPercent,
enableNotifications,
enableSounds,
flushBossLoreToasts,
partyEndPercent,
result.won,
]);
const bossHpBarColour = getHpColour(bossHpPercent);
const partyHpBarColour = getHpColour(partyHpPercent);
return (
<div className="modal-overlay">
<div className="modal battle-modal">
<h2>
{"โš”๏ธ Battle: "}
{bossName}
</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-divider">{"vs"}</div>
<div className="battle-stat">
<span className="stat-label">{"Boss DPS"}</span>
<span className="stat-value">{formatNumber(result.bossDPS)}</span>
</div>
</div>
<div className="battle-bars">
<div className="battle-bar-row">
<span className="bar-label">
{"๐Ÿ‘น "}
{bossName}
</span>
<div className="hp-bar-container">
<div
className="hp-bar-fill"
style={{
backgroundColor: bossHpBarColour,
width: `${bossHpPercent.toFixed(1)}%`,
}}
/>
</div>
<span className="bar-hp">
{formatNumber(result.bossHpAtBattleEnd)}
{" / "}
{formatNumber(result.bossMaxHp)}
</span>
</div>
<div className="vs-divider">{"โš”๏ธ VS โš”๏ธ"}</div>
<div className="battle-bar-row">
<span className="bar-label">{"๐Ÿ›ก๏ธ Your Party"}</span>
<div className="hp-bar-container">
<div
className="hp-bar-fill party-hp"
style={{
backgroundColor: partyHpBarColour,
width: `${partyHpPercent.toFixed(1)}%`,
}}
/>
</div>
<span className="bar-hp">
{formatNumber(result.partyHpRemaining)}
{" / "}
{formatNumber(result.partyMaxHp)}
</span>
</div>
</div>
{phase === "animating"
&& <p className="battle-in-progress">{"Battlingโ€ฆ"}</p>
}
{phase === "result"
&& <div
className={`battle-outcome ${result.won
? "victory"
: "defeat"}`}
>
{result.won
? <>
<h3>{"๐Ÿ† Victory!"}</h3>
{result.rewards === undefined
? null
: <div className="battle-rewards">
<p>{"Rewards:"}</p>
<span>
{"๐Ÿช™ "}
{formatNumber(result.rewards.gold)}
{" gold"}
</span>
{result.rewards.essence > 0
&& <span>
{"โœจ "}
{formatNumber(result.rewards.essence)}
{" essence"}
</span>
}
{result.rewards.crystals > 0
&& <span>
{"๐Ÿ’Ž "}
{formatInteger(result.rewards.crystals)}
{" crystals"}
</span>
}
{result.rewards.bountyRunestones > 0
&& <span className="battle-bounty">
{"๐Ÿ”ฎ "}
{formatInteger(result.rewards.bountyRunestones)}
{" runestones (first kill!)"}
</span>
}
</div>
}
</>
: <>
<h3>{"๐Ÿ’€ Defeat"}</h3>
<p>{"Your party was defeated. The boss has reset."}</p>
{result.casualties !== undefined
&& result.casualties.length > 0
? <div className="battle-casualties">
<p>{"Casualties:"}</p>
{result.casualties.map((casualty) => {
return (
<span key={casualty.adventurerId}>
{"โ˜ ๏ธ "}
{casualty.killed} {casualty.adventurerId}
{" lost"}
</span>
);
})}
</div>
: null}
</>
}
<button
className="dismiss-button"
onClick={onDismiss}
type="button"
>
{"Continue"}
</button>
</div>
}
</div>
</div>
);
};
export { BattleModal };