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>
294 lines
8.9 KiB
TypeScript
294 lines
8.9 KiB
TypeScript
/**
|
||
* @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 };
|