generated from nhcarrigan/template
feat: initial prototype — core game systems (#30)
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @file Transcendence panel component for the second prestige layer.
|
||||
* @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-statements -- Transcendence panel manages many local state variables */
|
||||
import { useState, type JSX } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import {
|
||||
TRANSCENDENCE_UPGRADES,
|
||||
TRANSCENDENCE_UPGRADE_CATEGORY_LABELS,
|
||||
} from "../../data/transcendenceUpgrades.js";
|
||||
import type { TranscendenceUpgradeCategory } from "@elysium/types";
|
||||
|
||||
const echoFormulaConstant = 853;
|
||||
const finalBossId = "the_absolute_one";
|
||||
|
||||
/**
|
||||
* Calculates the echo preview for a transcendence.
|
||||
* @param prestigeCount - The current prestige count.
|
||||
* @param echoMetaMultiplier - The echo meta multiplier from upgrades.
|
||||
* @returns The predicted echo reward.
|
||||
*/
|
||||
const calculateEchoPreview = (
|
||||
prestigeCount: number,
|
||||
echoMetaMultiplier: number,
|
||||
): number => {
|
||||
const safeCount = Math.max(prestigeCount, 1);
|
||||
return Math.floor(
|
||||
// eslint-disable-next-line stylistic/no-extra-parens -- Required by no-mixed-operators rule
|
||||
(echoFormulaConstant / Math.sqrt(safeCount)) * echoMetaMultiplier,
|
||||
);
|
||||
};
|
||||
|
||||
const categoryOrder: Array<TranscendenceUpgradeCategory> = [
|
||||
"income",
|
||||
"combat",
|
||||
"prestige_threshold",
|
||||
"prestige_runestones",
|
||||
"echo_meta",
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the transcendence panel with transcendence and echo shop tabs.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const TranscendencePanel = (): JSX.Element => {
|
||||
const { state, formatNumber, transcend, buyEchoUpgrade } = useGame();
|
||||
const [ isPending, setIsPending ] = useState(false);
|
||||
const [ result, setResult ] = useState<{
|
||||
echoes: number;
|
||||
count: number;
|
||||
} | null>(null);
|
||||
const [ error, setError ] = useState<string | null>(null);
|
||||
const [ buyingId, setBuyingId ] = useState<string | null>(null);
|
||||
type TranscendTab = "transcend" | "shop";
|
||||
const [ activeTab, setActiveTab ] = useState<TranscendTab>("transcend");
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, prestige: prestigeData, transcendence } = state;
|
||||
const hasDefeatedFinalBoss = bosses.some((boss) => {
|
||||
return boss.id === finalBossId && boss.status === "defeated";
|
||||
});
|
||||
const echoMetaMultiplier = transcendence?.echoMetaMultiplier ?? 1;
|
||||
const echoPreview = calculateEchoPreview(
|
||||
prestigeData.count,
|
||||
echoMetaMultiplier,
|
||||
);
|
||||
const currentEchoes = transcendence?.echoes ?? 0;
|
||||
const transcendenceCount = transcendence?.count ?? 0;
|
||||
|
||||
async function handleTranscend(): Promise<void> {
|
||||
setIsPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await transcend();
|
||||
setResult({ count: data.newTranscendenceCount, echoes: data.echoes });
|
||||
} catch (error_: unknown) {
|
||||
setError(
|
||||
error_ instanceof Error
|
||||
? error_.message
|
||||
: "Transcendence failed",
|
||||
);
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBuyUpgrade(upgradeId: string): Promise<void> {
|
||||
setBuyingId(upgradeId);
|
||||
try {
|
||||
await buyEchoUpgrade(upgradeId);
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const upgradesByCategory = categoryOrder.map((catId) => {
|
||||
const categoryLabels = TRANSCENDENCE_UPGRADE_CATEGORY_LABELS;
|
||||
const label = categoryLabels[catId] ?? catId;
|
||||
const upgrades = TRANSCENDENCE_UPGRADES.filter((upgrade) => {
|
||||
return upgrade.category === catId;
|
||||
});
|
||||
return { catId, label, upgrades };
|
||||
});
|
||||
|
||||
function handleTranscendClick(): void {
|
||||
void handleTranscend();
|
||||
}
|
||||
|
||||
function handleTranscendTabClick(): void {
|
||||
setActiveTab("transcend");
|
||||
}
|
||||
|
||||
function handleShopTabClick(): void {
|
||||
setActiveTab("shop");
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel transcendence-panel">
|
||||
<h2>{"🌌 Transcendence"}</h2>
|
||||
|
||||
<div className="prestige-tabs">
|
||||
<button
|
||||
className={`prestige-tab ${
|
||||
activeTab === "transcend"
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={handleTranscendTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"Transcend"}
|
||||
</button>
|
||||
<button
|
||||
className={`prestige-tab ${activeTab === "shop"
|
||||
? "active"
|
||||
: ""}`}
|
||||
onClick={handleShopTabClick}
|
||||
type="button"
|
||||
>
|
||||
{"✨ Echo Shop ("}
|
||||
{formatNumber(currentEchoes)}
|
||||
{" echoes)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "transcend"
|
||||
&& <>
|
||||
<p className="transcendence-intro">
|
||||
{"Transcendence is the ultimate reset. It wipes "}
|
||||
<strong>{"everything"}</strong>
|
||||
{" — resources, prestige, runestones, upgrades, and equipment"
|
||||
+ " — but grants "}
|
||||
<strong>{"Echoes"}</strong>
|
||||
{", a permanent currency that survives all future resets."}
|
||||
{" Echoes power upgrades that permanently amplify every run."}
|
||||
</p>
|
||||
<p className="transcendence-intro">
|
||||
<em>
|
||||
{"Fewer prestiges = more Echoes."}
|
||||
{" Optimise your run for maximum yield!"}
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div className="transcendence-status">
|
||||
{transcendenceCount > 0
|
||||
&& <p>
|
||||
{"Transcendence count: "}
|
||||
<strong>{transcendenceCount}</strong>
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
{"Current Echoes: "}
|
||||
<strong>{formatNumber(currentEchoes)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
{"Current prestige count: "}
|
||||
<strong>{prestigeData.count}</strong>
|
||||
</p>
|
||||
{hasDefeatedFinalBoss
|
||||
? <p className="echo-preview">
|
||||
{"Echoes on transcendence: "}
|
||||
<strong>
|
||||
{"+"}
|
||||
{formatNumber(echoPreview)}
|
||||
</strong>
|
||||
{echoMetaMultiplier > 1
|
||||
&& <span className="echo-meta-bonus">
|
||||
{" (×"}
|
||||
{echoMetaMultiplier.toFixed(2)}
|
||||
{" meta bonus applied)"}
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? null
|
||||
: <div className="transcendence-locked">
|
||||
<p>
|
||||
{"🔒 "}
|
||||
<strong>{"Defeat The Absolute One"}</strong>
|
||||
{" to unlock transcendence."}
|
||||
</p>
|
||||
<p className="transcendence-hint">
|
||||
{"The Absolute One is the final boss of The Absolute zone,"
|
||||
+ " requiring Prestige 90 to challenge."}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
{hasDefeatedFinalBoss
|
||||
? <div className="prestige-form">
|
||||
<p>
|
||||
{"You are ready to transcend. This action is "}
|
||||
<strong>{"irreversible"}</strong>
|
||||
{"."}
|
||||
</p>
|
||||
<button
|
||||
className="transcendence-button"
|
||||
disabled={isPending}
|
||||
onClick={handleTranscendClick}
|
||||
type="button"
|
||||
>
|
||||
{isPending
|
||||
? "Transcending..."
|
||||
: `🌌 Transcend (+${formatNumber(echoPreview)} Echoes)`}
|
||||
</button>
|
||||
{error === null
|
||||
? null
|
||||
: <p className="error">{error}</p>}
|
||||
{result === null
|
||||
? null
|
||||
: <p className="success">
|
||||
{"Transcended! Earned "}
|
||||
<strong>
|
||||
{formatNumber(result.echoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
{". This is Transcendence "}
|
||||
{result.count}
|
||||
{". A new cycle begins."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
|
||||
{activeTab === "shop"
|
||||
&& <div className="echo-shop">
|
||||
<p className="shop-balance">
|
||||
{"Balance: "}
|
||||
<strong>
|
||||
{formatNumber(currentEchoes)}
|
||||
{" Echoes"}
|
||||
</strong>
|
||||
</p>
|
||||
<p className="echo-shop-description">
|
||||
{"Echo upgrades are "}
|
||||
<strong>{"permanent"}</strong>
|
||||
{" — they survive all future prestiges and transcendences."}
|
||||
</p>
|
||||
|
||||
{upgradesByCategory.map(({ catId, label, upgrades }) => {
|
||||
return (
|
||||
<div className="shop-category" key={catId}>
|
||||
<h3>{label}</h3>
|
||||
<div className="shop-upgrades">
|
||||
{upgrades.map((upgrade) => {
|
||||
const purchased = (
|
||||
transcendence?.purchasedUpgradeIds ?? []
|
||||
).includes(upgrade.id);
|
||||
const canAfford = currentEchoes >= upgrade.cost;
|
||||
const isLoading = buyingId === upgrade.id;
|
||||
|
||||
function handleBuyClick(): void {
|
||||
void handleBuyUpgrade(upgrade.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`shop-upgrade-card echo-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.cost)} Echoes`}
|
||||
</p>
|
||||
</div>
|
||||
{purchased
|
||||
? null
|
||||
: <button
|
||||
className="buy-upgrade-button echo-buy-button"
|
||||
disabled={
|
||||
!canAfford || isLoading || buyingId !== null
|
||||
}
|
||||
onClick={handleBuyClick}
|
||||
type="button"
|
||||
>
|
||||
{isLoading
|
||||
? "Buying..."
|
||||
: "Buy"}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { TranscendencePanel };
|
||||
Reference in New Issue
Block a user