generated from nhcarrigan/template
49bfc6a109
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.
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
/**
|
||
* @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 };
|