From 5a065998b66b9eef869b508909d513f05b2fae28 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 8 Mar 2026 19:07:04 -0700 Subject: [PATCH] fix: delay boss notifications until reveal and animate hp bar colours - 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 --- apps/web/src/components/game/battleModal.tsx | 98 +++++++++++++++----- apps/web/src/context/gameContext.tsx | 19 ---- 2 files changed, 73 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/game/battleModal.tsx b/apps/web/src/components/game/battleModal.tsx index ab1be4f..e1f177c 100644 --- a/apps/web/src/components/game/battleModal.tsx +++ b/apps/web/src/components/game/battleModal.tsx @@ -8,6 +8,8 @@ /* 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. @@ -23,6 +25,22 @@ const toHpPercent = (current: number, maximum: number): number => { 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; @@ -40,12 +58,11 @@ const BattleModal = ({ onDismiss, }: BattleModalProperties): JSX.Element => { const { result, bossName } = battle; - const { formatNumber } = useGame(); + const { enableNotifications, enableSounds, formatNumber } = useGame(); const [ phase, setPhase ] = useState<"animating" | "result">("animating"); const bossStartPercent = toHpPercent(result.bossHpBefore, result.bossMaxHp); - const partyStartPercent = 100; const bossEndPercent = toHpPercent( result.bossHpAtBattleEnd, @@ -57,37 +74,70 @@ const BattleModal = ({ ); const [ bossHpPercent, setBossHpPercent ] = useState(bossStartPercent); - const [ partyHpPercent, setPartyHpPercent ] = useState(partyStartPercent); + const [ partyHpPercent, setPartyHpPercent ] = useState(100); useEffect(() => { - const startAnimation = setTimeout(() => { - setBossHpPercent(bossEndPercent); - setPartyHpPercent(partyEndPercent); + 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 | 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 revealResult = setTimeout(() => { + const revealTimeout = setTimeout(() => { setPhase("result"); + if (result.won) { + if (enableSounds) { + playSound("bossVictory"); + } + if (enableNotifications) { + sendNotification("⚔️ Boss Defeated!", `You defeated ${bossName}!`); + } + } }, 5200); return (): void => { - clearTimeout(startAnimation); - clearTimeout(revealResult); + clearTimeout(startTimeout); + clearTimeout(revealTimeout); + clearInterval(intervalId); }; - }, [ bossEndPercent, partyEndPercent ]); + }, [ + bossEndPercent, + bossName, + bossStartPercent, + enableNotifications, + enableSounds, + partyEndPercent, + result.won, + ]); - let bossHpBarColour = "#c0392b"; - if (bossHpPercent > 50) { - bossHpBarColour = "#e74c3c"; - } else if (bossHpPercent > 25) { - bossHpBarColour = "#e67e22"; - } - - let partyHpBarColour = "#e74c3c"; - if (partyHpPercent > 50) { - partyHpBarColour = "#27ae60"; - } else if (partyHpPercent > 25) { - partyHpBarColour = "#f39c12"; - } + const bossHpBarColour = getHpColour(bossHpPercent); + const partyHpBarColour = getHpColour(partyHpPercent); return (
@@ -120,7 +170,6 @@ const BattleModal = ({ className="hp-bar-fill" style={{ backgroundColor: bossHpBarColour, - transition: "width 5s ease-in-out", width: `${bossHpPercent.toFixed(1)}%`, }} /> @@ -141,7 +190,6 @@ const BattleModal = ({ className="hp-bar-fill party-hp" style={{ backgroundColor: partyHpBarColour, - transition: "width 5s ease-in-out", width: `${partyHpPercent.toFixed(1)}%`, }} /> diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 56fca68..618669a 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -1175,17 +1175,6 @@ export const GameProvider = ({ return applyBossResult(previous, bossId, result); }); setBattleResult({ bossName, result }); - if (result.won) { - if (enableSoundsReference.current) { - playSound("bossVictory"); - } - if (enableNotificationsReference.current) { - sendNotification( - "⚔️ Boss Defeated!", - `You defeated ${bossName}!`, - ); - } - } }). catch(() => { @@ -1785,14 +1774,6 @@ export const GameProvider = ({ return applyBossResult(previous, bossId, 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 { // Silently ignore — server errors shouldn't crash the UI }