From 49bfc6a109dda7b06b03c309af61bd698c460d05 Mon Sep 17 00:00:00 2001 From: Hikari Date: Sun, 8 Mar 2026 15:06:28 -0700 Subject: [PATCH] feat: add in-game sound effects and browser notifications (#27) Adds Web Audio API sound effects and browser notifications for key game events: achievement unlocked, quest completed, quest failed, boss defeated, prestige, transcendence, and apotheosis. Both features are toggled via profile settings, with notification permission requested on first enable. --- apps/api/src/routes/profile.ts | 4 + apps/web/src/components/game/aboutPanel.tsx | 11 ++ .../src/components/game/editProfileModal.tsx | 71 +++++++++ .../web/src/components/game/prestigePanel.tsx | 13 ++ apps/web/src/context/gameContext.tsx | 142 +++++++++++++++++- apps/web/src/utils/notification.ts | 46 ++++++ apps/web/src/utils/sound.ts | 110 ++++++++++++++ .../types/src/interfaces/profileSettings.ts | 12 ++ packages/types/test/profileSettings.spec.ts | 2 + 9 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/utils/notification.ts create mode 100644 apps/web/src/utils/sound.ts diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index e1ac314..2fec074 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -44,6 +44,8 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => { ? (parsedNumberFormat as ProfileSettings["numberFormat"]) : "suffix"; return { + enableNotifications: rawObject.enableNotifications === true, + enableSounds: rawObject.enableSounds === true, numberFormat: numberFormat, showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false, showAdventurersRecruited: rawObject.showAdventurersRecruited !== false, @@ -203,6 +205,8 @@ profileRouter.put("/", authMiddleware, async(context) => { ? (parsedNumberFormat as ProfileSettings["numberFormat"]) : "suffix"; const profileSettings: ProfileSettings = { + enableNotifications: body.profileSettings.enableNotifications ?? false, + enableSounds: body.profileSettings.enableSounds ?? false, numberFormat: numberFormat, showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true, showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true, diff --git a/apps/web/src/components/game/aboutPanel.tsx b/apps/web/src/components/game/aboutPanel.tsx index 7bc2dd3..c4d466e 100644 --- a/apps/web/src/components/game/aboutPanel.tsx +++ b/apps/web/src/components/game/aboutPanel.tsx @@ -245,6 +245,17 @@ const howToPlay = [ + " progress is permanent and survives all resets.", title: "📖 Story", }, + { + body: + "Enable sound effects and browser notifications in your profile settings" + + " (click your character name in the top bar). Sound effects play when" + + " you defeat a boss, complete or fail a quest, unlock an achievement," + + " prestige, transcend, or achieve apotheosis. Browser notifications" + + " alert you to the same events even when the game tab is in the" + + " background. You will be prompted to grant notification permission" + + " when you first enable them.", + title: "🔔 Sounds & Notifications", + }, ]; const formatDate = (dateString: string): string => { diff --git a/apps/web/src/components/game/editProfileModal.tsx b/apps/web/src/components/game/editProfileModal.tsx index a4454cc..3be4e74 100644 --- a/apps/web/src/components/game/editProfileModal.tsx +++ b/apps/web/src/components/game/editProfileModal.tsx @@ -16,6 +16,9 @@ import { import { type ChangeEvent, type JSX, useEffect, useState } from "react"; import { updateProfile } from "../../api/client.js"; import { useGame } from "../../context/gameContext.js"; +import { + requestNotificationPermission, +} from "../../utils/notification.js"; interface EditProfileModalProperties { readonly onClose: ()=> void; @@ -96,6 +99,8 @@ const EditProfileModal = ({ state, numberFormat: currentNumberFormat, setNumberFormat, + setEnableSounds, + setEnableNotifications, } = useGame(); const player = state?.player; @@ -157,6 +162,8 @@ const EditProfileModal = ({ profileSettings, }); setNumberFormat(profileSettings.numberFormat); + setEnableSounds(profileSettings.enableSounds); + setEnableNotifications(profileSettings.enableNotifications); setSaved(true); setTimeout(onClose, 900); } catch (error_: unknown) { @@ -194,6 +201,30 @@ const EditProfileModal = ({ setBio(event.target.value); } + function handleSoundsToggle(): void { + toggleSetting("enableSounds"); + } + + async function handleNotificationsEnable(): Promise { + if (profileSettings.enableNotifications) { + toggleSetting("enableNotifications"); + return; + } + const granted = await requestNotificationPermission(); + if (granted) { + toggleSetting("enableNotifications"); + } else { + setError( + "Browser notification permission was denied." + + " Please enable it in your browser settings.", + ); + } + } + + function handleNotificationsToggle(): void { + void handleNotificationsEnable(); + } + const isSaveDisabled = saving || characterName.trim() === ""; let saveLabel = "Save Profile"; @@ -348,6 +379,46 @@ const EditProfileModal = ({ +
+

{"Sounds & Notifications"}

+

+ {"Control in-game sound effects and browser notifications."} +

+ + +
+

{"Number Format"}

diff --git a/apps/web/src/components/game/prestigePanel.tsx b/apps/web/src/components/game/prestigePanel.tsx index 1c9d63d..7e57bd8 100644 --- a/apps/web/src/components/game/prestigePanel.tsx +++ b/apps/web/src/components/game/prestigePanel.tsx @@ -15,6 +15,8 @@ import { PRESTIGE_UPGRADES, PRESTIGE_UPGRADE_CATEGORY_LABELS, } from "../../data/prestigeUpgrades.js"; +import { sendNotification } from "../../utils/notification.js"; +import { playSound } from "../../utils/sound.js"; import type { PrestigeUpgradeCategory } from "@elysium/types"; const baseThreshold = 1_000_000; @@ -84,6 +86,8 @@ const PrestigePanel = (): JSX.Element => { reload, formatNumber, buyPrestigeUpgrade, + enableNotifications, + enableSounds, toggleAutoPrestige, } = useGame(); const [ isPending, setIsPending ] = useState(false); @@ -124,6 +128,15 @@ const PrestigePanel = (): JSX.Element => { milestoneRunestones: data.milestoneRunestones, runestones: data.runestones, }); + if (enableSounds) { + playSound("prestige"); + } + if (enableNotifications) { + sendNotification( + "⭐ Prestige!", + `You've reached prestige level ${data.newPrestigeCount.toString()}!`, + ); + } await reload(); } catch (error_: unknown) { setPrestigeError( diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index c6a2aa0..4ec7ce4 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -58,6 +58,8 @@ import { } from "../engine/tick.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js"; +import { sendNotification } from "../utils/notification.js"; +import { playSound } from "../utils/sound.js"; const autoSaveIntervalMs = 30_000; const autoPrestigeThresholdBase = 1_000_000; @@ -342,6 +344,26 @@ interface GameContextValue { */ setNumberFormat: (format: NumberFormat)=> void; + /** + * Whether in-game sound effects are enabled. + */ + enableSounds: boolean; + + /** + * Whether browser system notifications are enabled. + */ + enableNotifications: boolean; + + /** + * Update the enable-sounds preference. + */ + setEnableSounds: (enabled: boolean)=> void; + + /** + * Update the enable-notifications preference. + */ + setEnableNotifications: (enabled: boolean)=> void; + /** * Format a number using the player's chosen notation style. */ @@ -499,11 +521,17 @@ export const GameProvider = ({ null, ); const [ numberFormat, setNumberFormat ] = useState("suffix"); + const [ enableSounds, setEnableSounds ] = useState(false); + const [ enableNotifications, setEnableNotifications ] = useState(false); + const enableSoundsReference = useRef(false); + const enableNotificationsReference = useRef(false); const stateReference = useRef(null); const lastSaveReference = useRef(Date.now()); const isSyncingReference = useRef(false); const rafReference = useRef(null); const unlockedAchievementsReference = useRef>([]); + const newlyCompletedQuestsCountReference = useRef(0); + const newlyFailedQuestsCountReference = useRef(0); const signatureReference = useRef( localStorage.getItem("elysium_save_signature"), ); @@ -561,7 +589,11 @@ export const GameProvider = ({ } // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast const profile = (await response.json()) as { - profileSettings?: { numberFormat?: NumberFormat }; + profileSettings?: { + enableNotifications?: boolean; + enableSounds?: boolean; + numberFormat?: NumberFormat; + }; }; const fmt = profile.profileSettings?.numberFormat; if ( @@ -571,10 +603,14 @@ export const GameProvider = ({ ) { setNumberFormat(fmt); } + setEnableSounds(profile.profileSettings?.enableSounds === true); + setEnableNotifications( + profile.profileSettings?.enableNotifications === true, + ); }). catch(() => { - /* Fall back to default "suffix" */ + /* Fall back to defaults */ }); } catch (error_: unknown) { setError( @@ -589,6 +625,14 @@ export const GameProvider = ({ reloadReference.current = reload; + useEffect(() => { + enableSoundsReference.current = enableSounds; + }, [ enableSounds ]); + + useEffect(() => { + enableNotificationsReference.current = enableNotifications; + }, [ enableNotifications ]); + useEffect(() => { void reload(); }, [ reload ]); @@ -904,6 +948,27 @@ export const GameProvider = ({ }, ); + // Detect newly completed quests + newlyCompletedQuestsCountReference.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( + (q, index) => { + const previousFailedAt = previous.quests[index]?.lastFailedAt; + return ( + q.lastFailedAt !== undefined + && q.lastFailedAt !== previousFailedAt + ); + }, + ).length; + return next; }); @@ -911,9 +976,37 @@ export const GameProvider = ({ setUnlockedAchievements((previous) => { return [ ...previous, ...unlockedAchievementsReference.current ]; }); + if (enableSoundsReference.current) { + playSound("achievement"); + } + if (enableNotificationsReference.current) { + for (const achievement of unlockedAchievementsReference.current) { + sendNotification("🏆 Achievement Unlocked!", achievement.name); + } + } unlockedAchievementsReference.current = []; } + if (newlyCompletedQuestsCountReference.current > 0) { + if (enableSoundsReference.current) { + playSound("questCompleted"); + } + if (enableNotificationsReference.current) { + sendNotification("📜 Quest Complete!", "A quest has been completed."); + } + newlyCompletedQuestsCountReference.current = 0; + } + + if (newlyFailedQuestsCountReference.current > 0) { + if (enableSoundsReference.current) { + playSound("questFailed"); + } + if (enableNotificationsReference.current) { + sendNotification("💀 Quest Failed!", "A quest has failed."); + } + newlyFailedQuestsCountReference.current = 0; + } + // Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions) if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) { lastSaveReference.current = Date.now(); @@ -961,6 +1054,12 @@ export const GameProvider = ({ isAutoPrestigingReference.current = true; void prestigeApi({}). then(async() => { + if (enableSoundsReference.current) { + playSound("prestige"); + } + if (enableNotificationsReference.current) { + sendNotification("⭐ Prestige!", "You have ascended!"); + } await reloadReference.current(); }). catch(() => { @@ -1004,6 +1103,17 @@ 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(() => { @@ -1333,12 +1443,24 @@ export const GameProvider = ({ const transcend = useCallback(async() => { const result = await transcendApi({}); + if (enableSoundsReference.current) { + playSound("transcendence"); + } + if (enableNotificationsReference.current) { + sendNotification("🌌 Transcendence!", "You have transcended reality!"); + } await reload(); return result; }, [ reload ]); const apotheosis = useCallback(async() => { const result = await achieveApotheosisApi({}); + if (enableSoundsReference.current) { + playSound("apotheosis"); + } + if (enableNotificationsReference.current) { + sendNotification("✨ Apotheosis!", "You have achieved godhood!"); + } await reload(); return result; }, [ reload ]); @@ -1589,6 +1711,14 @@ 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 } @@ -1707,6 +1837,8 @@ export const GameProvider = ({ dismissLoginBonus, dismissOfflineGold, dismissStoryChapter, + enableNotifications, + enableSounds, equipItem, error, forceSync, @@ -1725,6 +1857,8 @@ export const GameProvider = ({ saveSchemaVersion, schemaOutdated, setActiveCompanion, + setEnableNotifications, + setEnableSounds, setNumberFormat, startExploration, startQuest, @@ -1758,6 +1892,8 @@ export const GameProvider = ({ dismissLoginBonus, dismissOfflineGold, dismissStoryChapter, + enableNotifications, + enableSounds, equipItem, error, forceSync, @@ -1775,6 +1911,8 @@ export const GameProvider = ({ saveSchemaVersion, schemaOutdated, setActiveCompanion, + setEnableNotifications, + setEnableSounds, setNumberFormat, startExploration, startQuest, diff --git a/apps/web/src/utils/notification.ts b/apps/web/src/utils/notification.ts new file mode 100644 index 0000000..da32cbb --- /dev/null +++ b/apps/web/src/utils/notification.ts @@ -0,0 +1,46 @@ +/** + * @file Browser notification utilities. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/** + * Requests browser notification permission from the user. + * @returns Whether permission was granted. + */ +const requestNotificationPermission = async(): Promise => { + if (typeof Notification === "undefined") { + return false; + } + if (Notification.permission === "granted") { + return true; + } + if (Notification.permission === "denied") { + return false; + } + const permission = await Notification.requestPermission(); + return permission === "granted"; +}; + +/** + * Sends a browser notification if permission has been granted. + * @param title - The notification title text. + * @param body - The notification message displayed below the title. + */ +const sendNotification = (title: string, body: string): void => { + if ( + typeof Notification === "undefined" + || Notification.permission !== "granted" + ) { + return; + } + try { + // eslint-disable-next-line no-new -- Notification constructor has side effects + new Notification(title, { body: body, icon: "/favicon.ico" }); + } catch { + // Silently ignore — notifications may fail silently + } +}; + +export { requestNotificationPermission, sendNotification }; diff --git a/apps/web/src/utils/sound.ts b/apps/web/src/utils/sound.ts new file mode 100644 index 0000000..29dbe95 --- /dev/null +++ b/apps/web/src/utils/sound.ts @@ -0,0 +1,110 @@ +/** + * @file Sound effect utilities using the Web Audio API. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +type SoundEvent = + | "achievement" + | "apotheosis" + | "bossVictory" + | "prestige" + | "questCompleted" + | "questFailed" + | "transcendence"; + +interface SoundPattern { + frequencies: Array; + gain: number; + noteDuration: number; + type: OscillatorType; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name +const SOUND_PATTERNS: Record = { + achievement: { + frequencies: [ 523, 659, 784, 1047 ], + gain: 0.3, + noteDuration: 0.12, + type: "triangle", + }, + apotheosis: { + frequencies: [ 1047, 880, 784, 659, 523 ], + gain: 0.35, + noteDuration: 0.25, + type: "sine", + }, + bossVictory: { + frequencies: [ 523, 784, 1047 ], + gain: 0.4, + noteDuration: 0.18, + type: "square", + }, + prestige: { + frequencies: [ 392, 523, 659, 784 ], + gain: 0.35, + noteDuration: 0.15, + type: "sawtooth", + }, + questCompleted: { + frequencies: [ 523, 659 ], + gain: 0.25, + noteDuration: 0.15, + type: "sine", + }, + questFailed: { + frequencies: [ 392, 330, 261 ], + gain: 0.25, + noteDuration: 0.18, + type: "triangle", + }, + transcendence: { + frequencies: [ 261, 329, 392, 523 ], + gain: 0.3, + noteDuration: 0.3, + type: "sine", + }, +}; + +// eslint-disable-next-line @typescript-eslint/init-declarations -- lazily initialised on first use +let audioContext: AudioContext | undefined; + +const getAudioContext = (): AudioContext => { + if (audioContext === undefined) { + audioContext = new AudioContext(); + } + return audioContext; +}; + +/** + * Plays a sound effect for a given game event using the Web Audio API. + * @param event - The game event to play a sound for. + */ +const playSound = (event: SoundEvent): void => { + try { + const context = getAudioContext(); + const pattern = SOUND_PATTERNS[event]; + for (const [ index, frequency ] of pattern.frequencies.entries()) { + const oscillator = context.createOscillator(); + const gainNode = context.createGain(); + oscillator.connect(gainNode); + gainNode.connect(context.destination); + oscillator.type = pattern.type; + oscillator.frequency.value = frequency; + const noteOffset = index * pattern.noteDuration; + const startTime = context.currentTime + noteOffset; + const endTime = startTime + pattern.noteDuration; + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime(pattern.gain, startTime + 0.01); + gainNode.gain.linearRampToValueAtTime(0, endTime); + oscillator.start(startTime); + oscillator.stop(endTime); + } + } catch { + // Silently ignore — audio may not be available in all environments + } +}; + +export type { SoundEvent }; +export { playSound }; diff --git a/packages/types/src/interfaces/profileSettings.ts b/packages/types/src/interfaces/profileSettings.ts index c294b9b..9b4dbc0 100644 --- a/packages/types/src/interfaces/profileSettings.ts +++ b/packages/types/src/interfaces/profileSettings.ts @@ -38,10 +38,22 @@ interface ProfileSettings { * Whether this player appears on the public leaderboards. */ showOnLeaderboards: boolean; + + /** + * Whether in-game sound effects are enabled. + */ + enableSounds: boolean; + + /** + * Whether browser system notifications are enabled. + */ + enableNotifications: boolean; } // eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { + enableNotifications: false, + enableSounds: false, numberFormat: "suffix", showAchievementsUnlocked: true, showAdventurersRecruited: true, diff --git a/packages/types/test/profileSettings.spec.ts b/packages/types/test/profileSettings.spec.ts index 3b4448d..1352708 100644 --- a/packages/types/test/profileSettings.spec.ts +++ b/packages/types/test/profileSettings.spec.ts @@ -20,6 +20,8 @@ describe("DEFAULT_PROFILE_SETTINGS", () => { expect(DEFAULT_PROFILE_SETTINGS.showAdventurersRecruited).toBe(true); expect(DEFAULT_PROFILE_SETTINGS.showAchievementsUnlocked).toBe(true); expect(DEFAULT_PROFILE_SETTINGS.showOnLeaderboards).toBe(true); + expect(DEFAULT_PROFILE_SETTINGS.enableSounds).toBe(false); + expect(DEFAULT_PROFILE_SETTINGS.enableNotifications).toBe(false); }); it("defaults numberFormat to suffix", () => {