generated from nhcarrigan/template
feat: v1 prototype — core game systems #30
@@ -44,6 +44,8 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => {
|
|||||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||||
: "suffix";
|
: "suffix";
|
||||||
return {
|
return {
|
||||||
|
enableNotifications: rawObject.enableNotifications === true,
|
||||||
|
enableSounds: rawObject.enableSounds === true,
|
||||||
numberFormat: numberFormat,
|
numberFormat: numberFormat,
|
||||||
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
||||||
showAdventurersRecruited: rawObject.showAdventurersRecruited !== false,
|
showAdventurersRecruited: rawObject.showAdventurersRecruited !== false,
|
||||||
@@ -203,6 +205,8 @@ profileRouter.put("/", authMiddleware, async(context) => {
|
|||||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||||
: "suffix";
|
: "suffix";
|
||||||
const profileSettings: ProfileSettings = {
|
const profileSettings: ProfileSettings = {
|
||||||
|
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||||
|
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||||
numberFormat: numberFormat,
|
numberFormat: numberFormat,
|
||||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||||
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
||||||
|
|||||||
@@ -245,6 +245,17 @@ const howToPlay = [
|
|||||||
+ " progress is permanent and survives all resets.",
|
+ " progress is permanent and survives all resets.",
|
||||||
title: "📖 Story",
|
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 => {
|
const formatDate = (dateString: string): string => {
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
import { type ChangeEvent, type JSX, useEffect, useState } from "react";
|
import { type ChangeEvent, type JSX, useEffect, useState } from "react";
|
||||||
import { updateProfile } from "../../api/client.js";
|
import { updateProfile } from "../../api/client.js";
|
||||||
import { useGame } from "../../context/gameContext.js";
|
import { useGame } from "../../context/gameContext.js";
|
||||||
|
import {
|
||||||
|
requestNotificationPermission,
|
||||||
|
} from "../../utils/notification.js";
|
||||||
|
|
||||||
interface EditProfileModalProperties {
|
interface EditProfileModalProperties {
|
||||||
readonly onClose: ()=> void;
|
readonly onClose: ()=> void;
|
||||||
@@ -96,6 +99,8 @@ const EditProfileModal = ({
|
|||||||
state,
|
state,
|
||||||
numberFormat: currentNumberFormat,
|
numberFormat: currentNumberFormat,
|
||||||
setNumberFormat,
|
setNumberFormat,
|
||||||
|
setEnableSounds,
|
||||||
|
setEnableNotifications,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
const player = state?.player;
|
const player = state?.player;
|
||||||
|
|
||||||
@@ -157,6 +162,8 @@ const EditProfileModal = ({
|
|||||||
profileSettings,
|
profileSettings,
|
||||||
});
|
});
|
||||||
setNumberFormat(profileSettings.numberFormat);
|
setNumberFormat(profileSettings.numberFormat);
|
||||||
|
setEnableSounds(profileSettings.enableSounds);
|
||||||
|
setEnableNotifications(profileSettings.enableNotifications);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(onClose, 900);
|
setTimeout(onClose, 900);
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
@@ -194,6 +201,30 @@ const EditProfileModal = ({
|
|||||||
setBio(event.target.value);
|
setBio(event.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSoundsToggle(): void {
|
||||||
|
toggleSetting("enableSounds");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNotificationsEnable(): Promise<void> {
|
||||||
|
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() === "";
|
const isSaveDisabled = saving || characterName.trim() === "";
|
||||||
|
|
||||||
let saveLabel = "Save Profile";
|
let saveLabel = "Save Profile";
|
||||||
@@ -348,6 +379,46 @@ const EditProfileModal = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="edit-profile-section">
|
||||||
|
<p className="edit-profile-label">{"Sounds & Notifications"}</p>
|
||||||
|
<p className="edit-profile-sublabel">
|
||||||
|
{"Control in-game sound effects and browser notifications."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className={`stat-toggle-btn ${
|
||||||
|
profileSettings.enableSounds
|
||||||
|
? "stat-toggle-on"
|
||||||
|
: "stat-toggle-off"
|
||||||
|
}`}
|
||||||
|
onClick={handleSoundsToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{"🔊 Sound Effects"}</span>
|
||||||
|
<span className="stat-toggle-indicator">
|
||||||
|
{profileSettings.enableSounds
|
||||||
|
? "✓ On"
|
||||||
|
: "Off"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`stat-toggle-btn ${
|
||||||
|
profileSettings.enableNotifications
|
||||||
|
? "stat-toggle-on"
|
||||||
|
: "stat-toggle-off"
|
||||||
|
}`}
|
||||||
|
onClick={handleNotificationsToggle}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{"🔔 Browser Notifications"}</span>
|
||||||
|
<span className="stat-toggle-indicator">
|
||||||
|
{profileSettings.enableNotifications
|
||||||
|
? "✓ On"
|
||||||
|
: "Off"
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="edit-profile-section">
|
<div className="edit-profile-section">
|
||||||
<p className="edit-profile-label">{"Number Format"}</p>
|
<p className="edit-profile-label">{"Number Format"}</p>
|
||||||
<p className="edit-profile-sublabel">
|
<p className="edit-profile-sublabel">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
PRESTIGE_UPGRADES,
|
PRESTIGE_UPGRADES,
|
||||||
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
PRESTIGE_UPGRADE_CATEGORY_LABELS,
|
||||||
} from "../../data/prestigeUpgrades.js";
|
} from "../../data/prestigeUpgrades.js";
|
||||||
|
import { sendNotification } from "../../utils/notification.js";
|
||||||
|
import { playSound } from "../../utils/sound.js";
|
||||||
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
import type { PrestigeUpgradeCategory } from "@elysium/types";
|
||||||
|
|
||||||
const baseThreshold = 1_000_000;
|
const baseThreshold = 1_000_000;
|
||||||
@@ -84,6 +86,8 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
reload,
|
reload,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
|
enableNotifications,
|
||||||
|
enableSounds,
|
||||||
toggleAutoPrestige,
|
toggleAutoPrestige,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
const [ isPending, setIsPending ] = useState(false);
|
const [ isPending, setIsPending ] = useState(false);
|
||||||
@@ -124,6 +128,15 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
milestoneRunestones: data.milestoneRunestones,
|
milestoneRunestones: data.milestoneRunestones,
|
||||||
runestones: data.runestones,
|
runestones: data.runestones,
|
||||||
});
|
});
|
||||||
|
if (enableSounds) {
|
||||||
|
playSound("prestige");
|
||||||
|
}
|
||||||
|
if (enableNotifications) {
|
||||||
|
sendNotification(
|
||||||
|
"⭐ Prestige!",
|
||||||
|
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
await reload();
|
await reload();
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
setPrestigeError(
|
setPrestigeError(
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import {
|
|||||||
} from "../engine/tick.js";
|
} from "../engine/tick.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
import { formatNumber as formatNumberUtil } from "../utils/format.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 autoSaveIntervalMs = 30_000;
|
||||||
const autoPrestigeThresholdBase = 1_000_000;
|
const autoPrestigeThresholdBase = 1_000_000;
|
||||||
@@ -342,6 +344,26 @@ interface GameContextValue {
|
|||||||
*/
|
*/
|
||||||
setNumberFormat: (format: NumberFormat)=> void;
|
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.
|
* Format a number using the player's chosen notation style.
|
||||||
*/
|
*/
|
||||||
@@ -499,11 +521,17 @@ export const GameProvider = ({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [ numberFormat, setNumberFormat ] = useState<NumberFormat>("suffix");
|
const [ numberFormat, setNumberFormat ] = useState<NumberFormat>("suffix");
|
||||||
|
const [ enableSounds, setEnableSounds ] = useState(false);
|
||||||
|
const [ enableNotifications, setEnableNotifications ] = useState(false);
|
||||||
|
const enableSoundsReference = useRef(false);
|
||||||
|
const enableNotificationsReference = useRef(false);
|
||||||
const stateReference = useRef<GameState | null>(null);
|
const stateReference = useRef<GameState | null>(null);
|
||||||
const lastSaveReference = useRef<number>(Date.now());
|
const lastSaveReference = useRef<number>(Date.now());
|
||||||
const isSyncingReference = useRef(false);
|
const isSyncingReference = useRef(false);
|
||||||
const rafReference = useRef<number | null>(null);
|
const rafReference = useRef<number | null>(null);
|
||||||
const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
|
const unlockedAchievementsReference = useRef<Array<Achievement>>([]);
|
||||||
|
const newlyCompletedQuestsCountReference = useRef(0);
|
||||||
|
const newlyFailedQuestsCountReference = useRef(0);
|
||||||
const signatureReference = useRef<string | null>(
|
const signatureReference = useRef<string | null>(
|
||||||
localStorage.getItem("elysium_save_signature"),
|
localStorage.getItem("elysium_save_signature"),
|
||||||
);
|
);
|
||||||
@@ -561,7 +589,11 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response cast
|
||||||
const profile = (await response.json()) as {
|
const profile = (await response.json()) as {
|
||||||
profileSettings?: { numberFormat?: NumberFormat };
|
profileSettings?: {
|
||||||
|
enableNotifications?: boolean;
|
||||||
|
enableSounds?: boolean;
|
||||||
|
numberFormat?: NumberFormat;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
const fmt = profile.profileSettings?.numberFormat;
|
const fmt = profile.profileSettings?.numberFormat;
|
||||||
if (
|
if (
|
||||||
@@ -571,10 +603,14 @@ export const GameProvider = ({
|
|||||||
) {
|
) {
|
||||||
setNumberFormat(fmt);
|
setNumberFormat(fmt);
|
||||||
}
|
}
|
||||||
|
setEnableSounds(profile.profileSettings?.enableSounds === true);
|
||||||
|
setEnableNotifications(
|
||||||
|
profile.profileSettings?.enableNotifications === true,
|
||||||
|
);
|
||||||
}).
|
}).
|
||||||
catch(() => {
|
catch(() => {
|
||||||
|
|
||||||
/* Fall back to default "suffix" */
|
/* Fall back to defaults */
|
||||||
});
|
});
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
setError(
|
setError(
|
||||||
@@ -589,6 +625,14 @@ export const GameProvider = ({
|
|||||||
|
|
||||||
reloadReference.current = reload;
|
reloadReference.current = reload;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
enableSoundsReference.current = enableSounds;
|
||||||
|
}, [ enableSounds ]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
enableNotificationsReference.current = enableNotifications;
|
||||||
|
}, [ enableNotifications ]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
}, [ 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;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -911,9 +976,37 @@ export const GameProvider = ({
|
|||||||
setUnlockedAchievements((previous) => {
|
setUnlockedAchievements((previous) => {
|
||||||
return [ ...previous, ...unlockedAchievementsReference.current ];
|
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 = [];
|
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)
|
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions)
|
||||||
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
|
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
|
||||||
lastSaveReference.current = Date.now();
|
lastSaveReference.current = Date.now();
|
||||||
@@ -961,6 +1054,12 @@ export const GameProvider = ({
|
|||||||
isAutoPrestigingReference.current = true;
|
isAutoPrestigingReference.current = true;
|
||||||
void prestigeApi({}).
|
void prestigeApi({}).
|
||||||
then(async() => {
|
then(async() => {
|
||||||
|
if (enableSoundsReference.current) {
|
||||||
|
playSound("prestige");
|
||||||
|
}
|
||||||
|
if (enableNotificationsReference.current) {
|
||||||
|
sendNotification("⭐ Prestige!", "You have ascended!");
|
||||||
|
}
|
||||||
await reloadReference.current();
|
await reloadReference.current();
|
||||||
}).
|
}).
|
||||||
catch(() => {
|
catch(() => {
|
||||||
@@ -1004,6 +1103,17 @@ 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(() => {
|
||||||
|
|
||||||
@@ -1333,12 +1443,24 @@ export const GameProvider = ({
|
|||||||
|
|
||||||
const transcend = useCallback(async() => {
|
const transcend = useCallback(async() => {
|
||||||
const result = await transcendApi({});
|
const result = await transcendApi({});
|
||||||
|
if (enableSoundsReference.current) {
|
||||||
|
playSound("transcendence");
|
||||||
|
}
|
||||||
|
if (enableNotificationsReference.current) {
|
||||||
|
sendNotification("🌌 Transcendence!", "You have transcended reality!");
|
||||||
|
}
|
||||||
await reload();
|
await reload();
|
||||||
return result;
|
return result;
|
||||||
}, [ reload ]);
|
}, [ reload ]);
|
||||||
|
|
||||||
const apotheosis = useCallback(async() => {
|
const apotheosis = useCallback(async() => {
|
||||||
const result = await achieveApotheosisApi({});
|
const result = await achieveApotheosisApi({});
|
||||||
|
if (enableSoundsReference.current) {
|
||||||
|
playSound("apotheosis");
|
||||||
|
}
|
||||||
|
if (enableNotificationsReference.current) {
|
||||||
|
sendNotification("✨ Apotheosis!", "You have achieved godhood!");
|
||||||
|
}
|
||||||
await reload();
|
await reload();
|
||||||
return result;
|
return result;
|
||||||
}, [ reload ]);
|
}, [ reload ]);
|
||||||
@@ -1589,6 +1711,14 @@ 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
|
||||||
}
|
}
|
||||||
@@ -1707,6 +1837,8 @@ export const GameProvider = ({
|
|||||||
dismissLoginBonus,
|
dismissLoginBonus,
|
||||||
dismissOfflineGold,
|
dismissOfflineGold,
|
||||||
dismissStoryChapter,
|
dismissStoryChapter,
|
||||||
|
enableNotifications,
|
||||||
|
enableSounds,
|
||||||
equipItem,
|
equipItem,
|
||||||
error,
|
error,
|
||||||
forceSync,
|
forceSync,
|
||||||
@@ -1725,6 +1857,8 @@ export const GameProvider = ({
|
|||||||
saveSchemaVersion,
|
saveSchemaVersion,
|
||||||
schemaOutdated,
|
schemaOutdated,
|
||||||
setActiveCompanion,
|
setActiveCompanion,
|
||||||
|
setEnableNotifications,
|
||||||
|
setEnableSounds,
|
||||||
setNumberFormat,
|
setNumberFormat,
|
||||||
startExploration,
|
startExploration,
|
||||||
startQuest,
|
startQuest,
|
||||||
@@ -1758,6 +1892,8 @@ export const GameProvider = ({
|
|||||||
dismissLoginBonus,
|
dismissLoginBonus,
|
||||||
dismissOfflineGold,
|
dismissOfflineGold,
|
||||||
dismissStoryChapter,
|
dismissStoryChapter,
|
||||||
|
enableNotifications,
|
||||||
|
enableSounds,
|
||||||
equipItem,
|
equipItem,
|
||||||
error,
|
error,
|
||||||
forceSync,
|
forceSync,
|
||||||
@@ -1775,6 +1911,8 @@ export const GameProvider = ({
|
|||||||
saveSchemaVersion,
|
saveSchemaVersion,
|
||||||
schemaOutdated,
|
schemaOutdated,
|
||||||
setActiveCompanion,
|
setActiveCompanion,
|
||||||
|
setEnableNotifications,
|
||||||
|
setEnableSounds,
|
||||||
setNumberFormat,
|
setNumberFormat,
|
||||||
startExploration,
|
startExploration,
|
||||||
startQuest,
|
startQuest,
|
||||||
|
|||||||
@@ -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<boolean> => {
|
||||||
|
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 };
|
||||||
@@ -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<number>;
|
||||||
|
gain: number;
|
||||||
|
noteDuration: number;
|
||||||
|
type: OscillatorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
|
||||||
|
const SOUND_PATTERNS: Record<SoundEvent, SoundPattern> = {
|
||||||
|
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 };
|
||||||
@@ -38,10 +38,22 @@ interface ProfileSettings {
|
|||||||
* Whether this player appears on the public leaderboards.
|
* Whether this player appears on the public leaderboards.
|
||||||
*/
|
*/
|
||||||
showOnLeaderboards: boolean;
|
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
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
|
||||||
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
|
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
|
||||||
|
enableNotifications: false,
|
||||||
|
enableSounds: false,
|
||||||
numberFormat: "suffix",
|
numberFormat: "suffix",
|
||||||
showAchievementsUnlocked: true,
|
showAchievementsUnlocked: true,
|
||||||
showAdventurersRecruited: true,
|
showAdventurersRecruited: true,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ describe("DEFAULT_PROFILE_SETTINGS", () => {
|
|||||||
expect(DEFAULT_PROFILE_SETTINGS.showAdventurersRecruited).toBe(true);
|
expect(DEFAULT_PROFILE_SETTINGS.showAdventurersRecruited).toBe(true);
|
||||||
expect(DEFAULT_PROFILE_SETTINGS.showAchievementsUnlocked).toBe(true);
|
expect(DEFAULT_PROFILE_SETTINGS.showAchievementsUnlocked).toBe(true);
|
||||||
expect(DEFAULT_PROFILE_SETTINGS.showOnLeaderboards).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", () => {
|
it("defaults numberFormat to suffix", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user