diff --git a/apps/web/src/components/game/achievementToast.tsx b/apps/web/src/components/game/achievementToast.tsx index 5cece1e..fb14bd6 100644 --- a/apps/web/src/components/game/achievementToast.tsx +++ b/apps/web/src/components/game/achievementToast.tsx @@ -41,7 +41,7 @@ const ToastItem = ({ const crystals = achievement.reward?.crystals; return ( -
+
{achievement.icon}
{"Achievement Unlocked!"} @@ -70,7 +70,7 @@ const AchievementToast = (): JSX.Element | null => { } return ( -
+ <> {pendingAchievements.map((achievement) => { return ( { /> ); })} -
+ ); }; diff --git a/apps/web/src/components/game/codexToast.tsx b/apps/web/src/components/game/codexToast.tsx index 5ed089f..2f9450a 100644 --- a/apps/web/src/components/game/codexToast.tsx +++ b/apps/web/src/components/game/codexToast.tsx @@ -47,7 +47,7 @@ const CodexToastItem = ({ } return ( -
+
{"📖"}
{"✨ Lore Unlocked!"} @@ -70,13 +70,13 @@ const CodexToast = (): JSX.Element | null => { } return ( -
+ <> {pendingEntryIds.map((id) => { return ( ); })} -
+ ); }; diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 33fd363..8e2a4d9 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -27,10 +27,12 @@ import { EditProfileModal } from "./editProfileModal.js"; import { EquipmentPanel } from "./equipmentPanel.js"; import { ExplorationPanel } from "./explorationPanel.js"; import { LoginBonusModal } from "./loginBonusModal.js"; +import { MilestoneToast } from "./milestoneToast.js"; import { OfflineModal } from "./offlineModal.js"; import { OutdatedSchemaModal } from "./outdatedSchemaModal.js"; import { PrestigePanel } from "./prestigePanel.js"; import { QuestPanel } from "./questPanel.js"; +import { QuestCompleteToast, QuestFailedToast } from "./questToast.js"; import { StatisticsPanel } from "./statisticsPanel.js"; import { StoryPanel } from "./storyPanel.js"; import { StoryToast } from "./storyToast.js"; @@ -164,9 +166,14 @@ const GameLayout = (): JSX.Element => { {schemaOutdated && !dismissedOutdatedWarning ? : null} - - - +
+ + + + + + +
{loginBonus === null ? null : diff --git a/apps/web/src/components/game/milestoneToast.tsx b/apps/web/src/components/game/milestoneToast.tsx new file mode 100644 index 0000000..821bfd0 --- /dev/null +++ b/apps/web/src/components/game/milestoneToast.tsx @@ -0,0 +1,96 @@ +/** + * @file Milestone toast notification component for prestige, transcendence, and apotheosis. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to their containers */ +import { type JSX, useEffect } from "react"; +import { useGame } from "../../context/gameContext.js"; + +interface MilestoneToastItemProperties { + readonly icon: string; + readonly label: string; + readonly onDismiss: ()=> void; +} + +/** + * Renders a single milestone toast notification. + * @param props - The toast item properties. + * @param props.icon - The emoji icon. + * @param props.label - The label text. + * @param props.onDismiss - Callback to dismiss the toast. + * @returns The JSX element. + */ +const MilestoneToastItem = ({ + icon, + label, + onDismiss, +}: MilestoneToastItemProperties): JSX.Element => { + useEffect(() => { + const timer = setTimeout(() => { + onDismiss(); + }, 4000); + return (): void => { + clearTimeout(timer); + }; + }, [ onDismiss ]); + + return ( +
+ {icon} +
+ {label} +
+
+ ); +}; + +/** + * Renders all milestone toasts (prestige, transcendence, apotheosis). + * @returns The JSX element or null if no milestone toasts are pending. + */ +const MilestoneToast = (): JSX.Element | null => { + const { + showPrestigeToast, + showTranscendenceToast, + showApotheosisToast, + dismissPrestigeToast, + dismissTranscendenceToast, + dismissApotheosisToast, + } = useGame(); + + const hasAny + = showPrestigeToast || showTranscendenceToast || showApotheosisToast; + if (!hasAny) { + return null; + } + + return ( + <> + {showPrestigeToast + ? + : null} + {showTranscendenceToast + ? + : null} + {showApotheosisToast + ? + : null} + + ); +}; + +export { MilestoneToast }; diff --git a/apps/web/src/components/game/prestigePanel.tsx b/apps/web/src/components/game/prestigePanel.tsx index 7e57bd8..e834518 100644 --- a/apps/web/src/components/game/prestigePanel.tsx +++ b/apps/web/src/components/game/prestigePanel.tsx @@ -89,6 +89,7 @@ const PrestigePanel = (): JSX.Element => { enableNotifications, enableSounds, toggleAutoPrestige, + triggerPrestigeToast, } = useGame(); const [ isPending, setIsPending ] = useState(false); const [ result, setResult ] = useState<{ @@ -128,6 +129,7 @@ const PrestigePanel = (): JSX.Element => { milestoneRunestones: data.milestoneRunestones, runestones: data.runestones, }); + triggerPrestigeToast(); if (enableSounds) { playSound("prestige"); } diff --git a/apps/web/src/components/game/questPanel.tsx b/apps/web/src/components/game/questPanel.tsx index eccfd44..442122e 100644 --- a/apps/web/src/components/game/questPanel.tsx +++ b/apps/web/src/components/game/questPanel.tsx @@ -190,10 +190,11 @@ const QuestPanel = (): JSX.Element => { } const { adventurers, autoQuest, quests, zones } = state; - // eslint-disable-next-line unicorn/no-array-reduce -- Need the total! - const partyCombatPower = adventurers.reduce((total, adventurer) => { - return total + adventurer.combatPower * adventurer.count; - }, 0); + let partyCombatPower = 0; + for (const adventurer of adventurers) { + const contribution = adventurer.combatPower * adventurer.count; + partyCombatPower = partyCombatPower + contribution; + } const zoneQuests = quests.filter(({ zoneId }) => { return zoneId === activeZoneId; }); diff --git a/apps/web/src/components/game/questToast.tsx b/apps/web/src/components/game/questToast.tsx new file mode 100644 index 0000000..cdaa3b8 --- /dev/null +++ b/apps/web/src/components/game/questToast.tsx @@ -0,0 +1,113 @@ +/** + * @file Quest toast notification component for completed and failed quests. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable react/no-multi-comp -- Sub-components are tightly coupled to their containers */ +import { type JSX, useEffect } from "react"; +import { useGame } from "../../context/gameContext.js"; +import type { Quest } from "@elysium/types"; + +interface QuestToastItemProperties { + readonly quest: Quest; + readonly onDismiss: (id: string)=> void; + // eslint-disable-next-line react/require-default-props -- Default value set in destructuring + readonly isFailure?: boolean; +} + +/** + * Renders a single quest toast notification. + * @param props - The toast item properties. + * @param props.quest - The quest to display. + * @param props.onDismiss - Callback to dismiss the toast. + * @param props.isFailure - Whether this is a failure toast. + * @returns The JSX element. + */ +const QuestToastItem = ({ + quest, + onDismiss, + isFailure = false, +}: QuestToastItemProperties): JSX.Element => { + useEffect(() => { + const timer = setTimeout(() => { + onDismiss(quest.id); + }, 4000); + return (): void => { + clearTimeout(timer); + }; + }, [ quest.id, onDismiss ]); + + function handleClick(): void { + onDismiss(quest.id); + } + + return ( +
+ {isFailure + ? "💀" + : "📜"} +
+ {isFailure + ? "Quest Failed!" + : "✨ Quest Complete!"} + {quest.name} +
+
+ ); +}; + +/** + * Renders the quest complete toast container. + * @returns The JSX element or null if there are no pending quest toasts. + */ +const QuestCompleteToast = (): JSX.Element | null => { + const { completedQuestToasts, dismissCompletedQuest } = useGame(); + + if (completedQuestToasts.length === 0) { + return null; + } + + return ( + <> + {completedQuestToasts.map((quest) => { + return ( + + ); + })} + + ); +}; + +/** + * Renders the quest failed toast container. + * @returns The JSX element or null if there are no pending failure toasts. + */ +const QuestFailedToast = (): JSX.Element | null => { + const { failedQuestToasts, dismissFailedQuest } = useGame(); + + if (failedQuestToasts.length === 0) { + return null; + } + + return ( + <> + {failedQuestToasts.map((quest) => { + return ( + + ); + })} + + ); +}; + +export { QuestCompleteToast, QuestFailedToast }; diff --git a/apps/web/src/components/game/storyToast.tsx b/apps/web/src/components/game/storyToast.tsx index e33750b..61c83ac 100644 --- a/apps/web/src/components/game/storyToast.tsx +++ b/apps/web/src/components/game/storyToast.tsx @@ -45,13 +45,13 @@ const StoryToastItem = ({ } return ( - +
); }; @@ -65,11 +65,11 @@ const StoryToast = (): JSX.Element | null => { return null; } return ( -
+ <> {pendingChapterIds.map((id) => { return ; })} -
+ ); }; diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 4ec7ce4..56fca68 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -20,6 +20,7 @@ import { type GameState, type LoginBonusResult, type NumberFormat, + type Quest, type TranscendenceResponse, isStoryChapterUnlocked, } from "@elysium/types"; @@ -334,6 +335,61 @@ interface GameContextValue { */ dismissAchievement: (id: string)=> void; + /** + * Queue of newly completed quests (for toast notifications). + */ + completedQuestToasts: Array; + + /** + * Remove a quest from the completed toast queue. + */ + dismissCompletedQuest: (id: string)=> void; + + /** + * Queue of newly failed quests (for toast notifications). + */ + failedQuestToasts: Array; + + /** + * Remove a quest from the failed toast queue. + */ + dismissFailedQuest: (id: string)=> void; + + /** + * Whether the prestige milestone toast is currently showing. + */ + showPrestigeToast: boolean; + + /** + * Trigger the prestige milestone toast (called from prestigePanel on manual prestige). + */ + triggerPrestigeToast: ()=> void; + + /** + * Dismiss the prestige milestone toast. + */ + dismissPrestigeToast: ()=> void; + + /** + * Whether the transcendence milestone toast is currently showing. + */ + showTranscendenceToast: boolean; + + /** + * Dismiss the transcendence milestone toast. + */ + dismissTranscendenceToast: ()=> void; + + /** + * Whether the apotheosis milestone toast is currently showing. + */ + showApotheosisToast: boolean; + + /** + * Dismiss the apotheosis milestone toast. + */ + dismissApotheosisToast: ()=> void; + /** * The player's chosen number display format. */ @@ -514,6 +570,15 @@ export const GameProvider = ({ const [ unlockedAchievements, setUnlockedAchievements ] = useState< Array >([]); + const [ completedQuestToasts, setCompletedQuestToasts ] = useState< + Array + >([]); + const [ failedQuestToasts, setFailedQuestToasts ] = useState>( + [], + ); + const [ showPrestigeToast, setShowPrestigeToast ] = useState(false); + const [ showTranscendenceToast, setShowTranscendenceToast ] = useState(false); + const [ showApotheosisToast, setShowApotheosisToast ] = useState(false); const [ lastSavedAt, setLastSavedAt ] = useState(null); const [ isSyncing, setIsSyncing ] = useState(false); const [ syncError, setSyncError ] = useState(null); @@ -530,8 +595,8 @@ export const GameProvider = ({ const isSyncingReference = useRef(false); const rafReference = useRef(null); const unlockedAchievementsReference = useRef>([]); - const newlyCompletedQuestsCountReference = useRef(0); - const newlyFailedQuestsCountReference = useRef(0); + const newlyCompletedQuestsReference = useRef>([]); + const newlyFailedQuestsReference = useRef>([]); const signatureReference = useRef( localStorage.getItem("elysium_save_signature"), ); @@ -949,17 +1014,17 @@ export const GameProvider = ({ ); // Detect newly completed quests - newlyCompletedQuestsCountReference.current = next.quests.filter( + newlyCompletedQuestsReference.current = next.quests.filter( (q, index) => { return ( previous.quests[index]?.status === "active" && q.status === "completed" ); }, - ).length; + ); // Detect newly failed quests - newlyFailedQuestsCountReference.current = next.quests.filter( + newlyFailedQuestsReference.current = next.quests.filter( (q, index) => { const previousFailedAt = previous.quests[index]?.lastFailedAt; return ( @@ -967,7 +1032,7 @@ export const GameProvider = ({ && q.lastFailedAt !== previousFailedAt ); }, - ).length; + ); return next; }); @@ -987,24 +1052,30 @@ export const GameProvider = ({ unlockedAchievementsReference.current = []; } - if (newlyCompletedQuestsCountReference.current > 0) { + if (newlyCompletedQuestsReference.current.length > 0) { + setCompletedQuestToasts((previous) => { + return [ ...previous, ...newlyCompletedQuestsReference.current ]; + }); if (enableSoundsReference.current) { playSound("questCompleted"); } if (enableNotificationsReference.current) { sendNotification("📜 Quest Complete!", "A quest has been completed."); } - newlyCompletedQuestsCountReference.current = 0; + newlyCompletedQuestsReference.current = []; } - if (newlyFailedQuestsCountReference.current > 0) { + if (newlyFailedQuestsReference.current.length > 0) { + setFailedQuestToasts((previous) => { + return [ ...previous, ...newlyFailedQuestsReference.current ]; + }); if (enableSoundsReference.current) { playSound("questFailed"); } if (enableNotificationsReference.current) { sendNotification("💀 Quest Failed!", "A quest has failed."); } - newlyFailedQuestsCountReference.current = 0; + newlyFailedQuestsReference.current = []; } // Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions) @@ -1054,6 +1125,7 @@ export const GameProvider = ({ isAutoPrestigingReference.current = true; void prestigeApi({}). then(async() => { + setShowPrestigeToast(true); if (enableSoundsReference.current) { playSound("prestige"); } @@ -1443,6 +1515,7 @@ export const GameProvider = ({ const transcend = useCallback(async() => { const result = await transcendApi({}); + setShowTranscendenceToast(true); if (enableSoundsReference.current) { playSound("transcendence"); } @@ -1455,6 +1528,7 @@ export const GameProvider = ({ const apotheosis = useCallback(async() => { const result = await achieveApotheosisApi({}); + setShowApotheosisToast(true); if (enableSoundsReference.current) { playSound("apotheosis"); } @@ -1733,6 +1807,38 @@ export const GameProvider = ({ setBattleResult(null); }, []); + const dismissCompletedQuest = useCallback((id: string) => { + setCompletedQuestToasts((previous) => { + return previous.filter((q) => { + return q.id !== id; + }); + }); + }, []); + + const dismissFailedQuest = useCallback((id: string) => { + setFailedQuestToasts((previous) => { + return previous.filter((q) => { + return q.id !== id; + }); + }); + }, []); + + const triggerPrestigeToast = useCallback(() => { + setShowPrestigeToast(true); + }, []); + + const dismissPrestigeToast = useCallback(() => { + setShowPrestigeToast(false); + }, []); + + const dismissTranscendenceToast = useCallback(() => { + setShowTranscendenceToast(false); + }, []); + + const dismissApotheosisToast = useCallback(() => { + setShowApotheosisToast(false); + }, []); + const dismissAchievement = useCallback((id: string) => { setUnlockedAchievements((previous) => { return previous.filter((a) => { @@ -1829,18 +1935,25 @@ export const GameProvider = ({ challengeBoss, collectExploration, completeChapter, + completedQuestToasts, craftRecipe, currentSchemaVersion, dismissAchievement, + dismissApotheosisToast, dismissBattle, dismissCodexEntry, + dismissCompletedQuest, + dismissFailedQuest, dismissLoginBonus, dismissOfflineGold, + dismissPrestigeToast, dismissStoryChapter, + dismissTranscendenceToast, enableNotifications, enableSounds, equipItem, error, + failedQuestToasts, forceSync, formatNumber, handleClick, @@ -1860,6 +1973,9 @@ export const GameProvider = ({ setEnableNotifications, setEnableSounds, setNumberFormat, + showApotheosisToast, + showPrestigeToast, + showTranscendenceToast, startExploration, startQuest, state, @@ -1868,6 +1984,7 @@ export const GameProvider = ({ toggleAutoPrestige, toggleAutoQuest, transcend, + triggerPrestigeToast, unlockedAchievements, unlockedCodexEntryIds, unlockedStoryChapterIds, @@ -1875,6 +1992,8 @@ export const GameProvider = ({ }, [ apotheosis, battleResult, + completedQuestToasts, + failedQuestToasts, formatNumber, buyAdventurer, buyEchoUpgrade, @@ -1887,11 +2006,16 @@ export const GameProvider = ({ craftRecipe, currentSchemaVersion, dismissAchievement, + dismissApotheosisToast, dismissBattle, dismissCodexEntry, + dismissCompletedQuest, + dismissFailedQuest, dismissLoginBonus, dismissOfflineGold, + dismissPrestigeToast, dismissStoryChapter, + dismissTranscendenceToast, enableNotifications, enableSounds, equipItem, @@ -1914,6 +2038,9 @@ export const GameProvider = ({ setEnableNotifications, setEnableSounds, setNumberFormat, + showApotheosisToast, + showPrestigeToast, + showTranscendenceToast, startExploration, startQuest, state, @@ -1922,6 +2049,7 @@ export const GameProvider = ({ toggleAutoPrestige, toggleAutoQuest, transcend, + triggerPrestigeToast, unlockedAchievements, unlockedCodexEntryIds, unlockedStoryChapterIds, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 72343e5..125c885 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1432,20 +1432,6 @@ body { z-index: 200; } -.achievement-toast { - align-items: center; - animation: slide-in-right 0.35s ease-out; - background: var(--colour-surface); - border: 1px solid var(--colour-gold); - border-radius: var(--radius); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); - cursor: pointer; - display: flex; - gap: 0.75rem; - max-width: 280px; - padding: 0.75rem 1rem; -} - .toast-icon { font-size: 1.5rem; flex-shrink: 0; @@ -2481,8 +2467,8 @@ body { padding: 0.6rem 0.75rem; } -/* Codex toast — uses a different accent from achievement toast */ -.codex-toast { +/* Unified game toast — essence-coloured border used by all in-game notifications */ +.game-toast { align-items: center; animation: slide-in-right 0.35s ease-out; background: var(--colour-surface);