fix: delay boss notifications until reveal and animate hp bar colours
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint, Build & Test (push) Successful in 1m5s

- Move bossVictory sound and notification from gameContext into BattleModal,
  fired at the 5.2s reveal timeout so the animation plays before the spoiler
- Replace CSS width transition with a setInterval tick (50ms steps over 5s)
  so bossHpPercent and partyHpPercent update incrementally during the animation
- Both bars now use a shared getHpColour helper: green >50%, yellow 25-50%,
  red <25%, causing colour to shift naturally as the bar visually drains
This commit is contained in:
2026-03-08 19:07:04 -07:00
committed by Naomi Carrigan
parent f9c925b9fc
commit 5a065998b6
2 changed files with 73 additions and 44 deletions
+73 -25
View File
@@ -8,6 +8,8 @@
/* eslint-disable complexity -- Battle result display requires many conditional paths */ /* eslint-disable complexity -- Battle result display requires many conditional paths */
import { type JSX, useEffect, useState } from "react"; import { type JSX, useEffect, useState } from "react";
import { type BattleResult, useGame } from "../../context/gameContext.js"; 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. * Converts HP values to a percentage for display.
@@ -23,6 +25,22 @@ const toHpPercent = (current: number, maximum: number): number => {
return scaled / maximum; 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 { interface BattleModalProperties {
readonly battle: BattleResult; readonly battle: BattleResult;
readonly onDismiss: ()=> void; readonly onDismiss: ()=> void;
@@ -40,12 +58,11 @@ const BattleModal = ({
onDismiss, onDismiss,
}: BattleModalProperties): JSX.Element => { }: BattleModalProperties): JSX.Element => {
const { result, bossName } = battle; const { result, bossName } = battle;
const { formatNumber } = useGame(); const { enableNotifications, enableSounds, formatNumber } = useGame();
const [ phase, setPhase ] = useState<"animating" | "result">("animating"); const [ phase, setPhase ] = useState<"animating" | "result">("animating");
const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp); const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp);
const partyStartPercent = 100;
const bossEndPercent = toHpPercent( const bossEndPercent = toHpPercent(
result.bossHpAtBattleEnd, result.bossHpAtBattleEnd,
@@ -57,37 +74,70 @@ const BattleModal = ({
); );
const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent); const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent);
const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent); const [ partyHpPercent, setPartyHpPercent ] = useState(100);
useEffect(() => { useEffect(() => {
const startAnimation = setTimeout(() => { const animationDurationMs = 5000;
setBossHpPercent(bossEndPercent); const intervalMs = 50;
setPartyHpPercent(partyEndPercent); 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); }, 200);
const revealResult = setTimeout(() => { const revealTimeout = setTimeout(() => {
setPhase("result"); setPhase("result");
if (result.won) {
if (enableSounds) {
playSound("bossVictory");
}
if (enableNotifications) {
sendNotification("⚔️ Boss Defeated!", `You defeated ${bossName}!`);
}
}
}, 5200); }, 5200);
return (): void => { return (): void => {
clearTimeout(startAnimation); clearTimeout(startTimeout);
clearTimeout(revealResult); clearTimeout(revealTimeout);
clearInterval(intervalId);
}; };
}, [ bossEndPercent, partyEndPercent ]); }, [
bossEndPercent,
bossName,
bossStartPercent,
enableNotifications,
enableSounds,
partyEndPercent,
result.won,
]);
let bossHpBarColour = "#c0392b"; const bossHpBarColour = getHpColour(bossHpPercent);
if (bossHpPercent > 50) { const partyHpBarColour = getHpColour(partyHpPercent);
bossHpBarColour = "#e74c3c";
} else if (bossHpPercent > 25) {
bossHpBarColour = "#e67e22";
}
let partyHpBarColour = "#e74c3c";
if (partyHpPercent > 50) {
partyHpBarColour = "#27ae60";
} else if (partyHpPercent > 25) {
partyHpBarColour = "#f39c12";
}
return ( return (
<div className="modal-overlay"> <div className="modal-overlay">
@@ -120,7 +170,6 @@ const BattleModal = ({
className="hp-bar-fill" className="hp-bar-fill"
style={{ style={{
backgroundColor: bossHpBarColour, backgroundColor: bossHpBarColour,
transition: "width 5s ease-in-out",
width: `${bossHpPercent.toFixed(1)}%`, width: `${bossHpPercent.toFixed(1)}%`,
}} }}
/> />
@@ -141,7 +190,6 @@ const BattleModal = ({
className="hp-bar-fill party-hp" className="hp-bar-fill party-hp"
style={{ style={{
backgroundColor: partyHpBarColour, backgroundColor: partyHpBarColour,
transition: "width 5s ease-in-out",
width: `${partyHpPercent.toFixed(1)}%`, width: `${partyHpPercent.toFixed(1)}%`,
}} }}
/> />
-19
View File
@@ -1175,17 +1175,6 @@ export const GameProvider = ({
return applyBossResult(previous, bossId, result); return applyBossResult(previous, bossId, result);
}); });
setBattleResult({ bossName, result }); setBattleResult({ bossName, result });
if (result.won) {
if (enableSoundsReference.current) {
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification(
"⚔️ Boss Defeated!",
`You defeated ${bossName}!`,
);
}
}
}). }).
catch(() => { catch(() => {
@@ -1785,14 +1774,6 @@ export const GameProvider = ({
return applyBossResult(previous, bossId, result); return applyBossResult(previous, bossId, result);
}); });
setBattleResult({ bossName: boss.name, result: result }); setBattleResult({ bossName: boss.name, result: result });
if (result.won) {
if (enableSoundsReference.current) {
playSound("bossVictory");
}
if (enableNotificationsReference.current) {
sendNotification("⚔️ Boss Defeated!", `You defeated ${boss.name}!`);
}
}
} catch { } catch {
// Silently ignore — server errors shouldn't crash the UI // Silently ignore — server errors shouldn't crash the UI
} }