Files
elysium/apps/web/src/components/game/prestigePanel.tsx
T
hikari 49bfc6a109
CI / Lint, Build & Test (pull_request) Failing after 5s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Failing after 4s
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.
2026-03-08 15:06:28 -07:00

420 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Prestige panel component for ascending and purchasing runestone upgrades.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- Many conditional render paths */
/* eslint-disable max-lines -- Large panel with prestige and shop tabs */
/* eslint-disable max-statements -- Prestige panel manages many local state variables */
import { useState, type JSX } from "react";
import { prestige } from "../../api/client.js";
import { useGame } from "../../context/gameContext.js";
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;
const thresholdScale = 5;
const runestonesPerLevel = 10;
/**
* Calculates the prestige threshold for a given prestige count.
* @param prestigeCount - The current prestige count.
* @returns The required gold to prestige.
*/
const calculateThreshold = (prestigeCount: number): number => {
return baseThreshold * Math.pow(thresholdScale, prestigeCount);
};
/**
* Calculates the production multiplier for a given prestige count.
* @param prestigeCount - The number of times the player has prestiged.
* @returns The compounding multiplier applied to all income sources.
*/
const calculateProductionMultiplier = (prestigeCount: number): number => {
return Math.pow(1.15, prestigeCount);
};
/**
* Calculates the runestone preview for a prestige.
* @param totalGoldEarned - Total gold earned this run.
* @param prestigeCount - The current prestige count.
* @param purchasedUpgradeIds - IDs of purchased prestige upgrades.
* @returns The predicted runestone reward.
*/
const calculateRunestonePreview = (
totalGoldEarned: number,
prestigeCount: number,
purchasedUpgradeIds: Array<string>,
): number => {
const threshold = calculateThreshold(prestigeCount);
const base
= Math.floor(Math.sqrt(totalGoldEarned / threshold)) * runestonesPerLevel;
const runestoneMult = PRESTIGE_UPGRADES.filter((upgrade) => {
return (
upgrade.category === "runestones"
&& purchasedUpgradeIds.includes(upgrade.id)
);
}).reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
return Math.floor(base * runestoneMult);
};
const categoryOrder: Array<PrestigeUpgradeCategory> = [
"income",
"click",
"essence",
"crystals",
"runestones",
"utility",
];
/**
* Renders the prestige panel with ascension and runestone shop tabs.
* @returns The JSX element.
*/
const PrestigePanel = (): JSX.Element => {
const {
state,
reload,
formatNumber,
buyPrestigeUpgrade,
enableNotifications,
enableSounds,
toggleAutoPrestige,
} = useGame();
const [ isPending, setIsPending ] = useState(false);
const [ result, setResult ] = useState<{
runestones: number;
count: number;
milestoneRunestones: number;
} | null>(null);
const [ prestigeError, setPrestigeError ] = useState<string | null>(null);
const [ buyingId, setBuyingId ] = useState<string | null>(null);
const [ activeTab, setActiveTab ] = useState<"prestige" | "shop">("prestige");
if (state === null) {
return (
<section className="panel">
<p>{"Loading..."}</p>
</section>
);
}
const { prestige: prestigeData, player } = state;
const threshold = calculateThreshold(prestigeData.count);
const isEligible = player.totalGoldEarned >= threshold;
const runestonePreview = calculateRunestonePreview(
player.totalGoldEarned,
prestigeData.count,
prestigeData.purchasedUpgradeIds,
);
const nextMultiplier = calculateProductionMultiplier(prestigeData.count + 1);
async function handlePrestige(): Promise<void> {
setIsPending(true);
setPrestigeError(null);
try {
const data = await prestige({});
setResult({
count: data.newPrestigeCount,
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(
error_ instanceof Error
? error_.message
: "Prestige failed",
);
} finally {
setIsPending(false);
}
}
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
setBuyingId(upgradeId);
try {
await buyPrestigeUpgrade(upgradeId);
} finally {
setBuyingId(null);
}
}
const upgradesByCategory = categoryOrder.map((categoryId) => {
const label = PRESTIGE_UPGRADE_CATEGORY_LABELS[categoryId] ?? categoryId;
const upgrades = PRESTIGE_UPGRADES.filter((upgrade) => {
return upgrade.category === categoryId;
});
return { categoryId, label, upgrades };
});
function handlePrestigeClick(): void {
void handlePrestige();
}
function handleAutoPrestigeToggle(): void {
toggleAutoPrestige();
}
function handlePrestigeTabClick(): void {
setActiveTab("prestige");
}
function handleShopTabClick(): void {
setActiveTab("shop");
}
const progressRatio = player.totalGoldEarned / threshold;
const progressPct = (progressRatio * 100).toFixed(1);
return (
<section className="panel prestige-panel">
<h2>{"⭐ Prestige"}</h2>
<div className="prestige-tabs">
<button
className={`prestige-tab ${activeTab === "prestige"
? "active"
: ""}`}
onClick={handlePrestigeTabClick}
type="button"
>
{"Ascend"}
</button>
<button
className={`prestige-tab ${activeTab === "shop"
? "active"
: ""}`}
onClick={handleShopTabClick}
type="button"
>
{"🔮 Runestone Shop ("}
{formatNumber(prestigeData.runestones)}
{" stones)"}
</button>
</div>
{activeTab === "prestige"
&& <>
<p>
{"Prestige resets your progress but grants "}
<strong>{"Runestones"}</strong>
{"— permanent currency used for powerful upgrades."}
{" Each prestige multiplies your global production by ×1.15"}
{" (compounding each run)."}
</p>
<div className="prestige-status">
<p>
{"Total gold this run: "}
<strong>{formatNumber(player.totalGoldEarned)}</strong>
</p>
<p>
{"Required to prestige: "}
<strong>{formatNumber(threshold)}</strong>
</p>
<p>
{"Prestige count: "}
<strong>{prestigeData.count}</strong>
</p>
<p>
{"Current production multiplier: "}
<strong>
{"×"}
{prestigeData.productionMultiplier.toFixed(2)}
</strong>
</p>
<p>
{"After next prestige: "}
<strong>
{"×"}
{nextMultiplier.toFixed(2)}
</strong>
</p>
<p>
{"Runestones: "}
<strong>{formatNumber(prestigeData.runestones)}</strong>
</p>
{isEligible
? <p className="runestone-preview">
{"Runestones on prestige: "}
<strong>
{"+"}
{formatNumber(runestonePreview)}
</strong>
</p>
: null}
{isEligible
? null
: <p className="prestige-progress">
{"Progress: "}
{formatNumber(player.totalGoldEarned)}
{" / "}
{formatNumber(threshold)}
{" ("}
{progressPct}
{"%"}
{")"}
</p>
}
</div>
{isEligible
? <div className="prestige-form">
<p>{"You are ready to prestige!"}</p>
<button
className="prestige-button"
disabled={isPending}
onClick={handlePrestigeClick}
type="button"
>
{isPending
? "Ascending..."
: `✨ Ascend (+${formatNumber(runestonePreview)} Runestones)`}
</button>
{prestigeError === null
? null
: <p className="error">{prestigeError}</p>
}
{result === null
? null
: <p className="success">
{"Ascended to Prestige "}
{result.count}
{"! Earned "}
{formatNumber(result.runestones)}
{" Runestones."}
{result.milestoneRunestones > 0
&& <>
{" 🎉 Milestone bonus: +"}
{formatNumber(result.milestoneRunestones)}
{" Runestones!"}
</>
}
</p>
}
</div>
: <p className="prestige-locked">
{"Earn "}
{formatNumber(threshold - player.totalGoldEarned)}
{" more gold to unlock prestige."}
</p>
}
</>
}
{activeTab === "shop"
&& <div className="runestone-shop">
<p className="shop-balance">
{"Balance: "}
<strong>
{formatNumber(prestigeData.runestones)}
{" Runestones"}
</strong>
</p>
{upgradesByCategory.map(({ categoryId, label, upgrades }) => {
return (
<div className="shop-category" key={categoryId}>
<h3>{label}</h3>
<div className="shop-upgrades">
{upgrades.map((upgrade) => {
const purchased = prestigeData.purchasedUpgradeIds.includes(
upgrade.id,
);
const canAfford
= prestigeData.runestones >= upgrade.runestonesCost;
const isLoading = buyingId === upgrade.id;
const isAutoPrestigeToggle
= upgrade.id === "auto_prestige" && purchased;
const autoPrestigeEnabled
= prestigeData.autoPrestigeEnabled ?? false;
function handleBuyClick(): void {
void handleBuyUpgrade(upgrade.id);
}
return (
<div
className={`shop-upgrade-card ${
purchased
? "purchased"
: ""
} ${!canAfford && !purchased
? "unaffordable"
: ""}`}
key={upgrade.id}
>
<div className="shop-upgrade-info">
<h4>{upgrade.name}</h4>
<p>{upgrade.description}</p>
<p className="upgrade-cost">
{purchased
? "✅ Purchased"
: `🔮 ${formatNumber(upgrade.runestonesCost)} Runestones`}
</p>
</div>
{isAutoPrestigeToggle
? <button
className={`auto-prestige-toggle ${
autoPrestigeEnabled
? "enabled"
: "disabled"
}`}
onClick={handleAutoPrestigeToggle}
type="button"
>
{autoPrestigeEnabled
? "⚡ Auto ON"
: "⏸ Auto OFF"}
</button>
: null}
{purchased
? null
: <button
className="buy-upgrade-button"
disabled={
!canAfford || isLoading || buyingId !== null
}
onClick={handleBuyClick}
type="button"
>
{isLoading
? "Buying..."
: "Buy"}
</button>
}
</div>
);
})}
</div>
</div>
);
})}
</div>
}
</section>
);
};
export { PrestigePanel };