Files
elysium/apps/web/src/components/game/battleModal.tsx
T
hikari 6e2cb45553
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m6s
fix: delay boss lore toasts until battle animation reveals result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:18:46 -07:00

293 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 2550%, red below 25%.
* @param percent - Current HP as a percentage (0100).
* @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,
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>
{"💎 "}
{formatNumber(result.rewards.crystals)}
{" crystals"}
</span>
}
{result.rewards.bountyRunestones > 0
&& <span className="battle-bounty">
{"🔮 "}
{formatNumber(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 };