generated from nhcarrigan/template
29c817230d
## 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>
214 lines
5.9 KiB
TypeScript
214 lines
5.9 KiB
TypeScript
/**
|
|
* @file Companion panel component for managing active companions.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
|
|
/* eslint-disable max-lines-per-function -- Complex companion card with conditional renders */
|
|
import { COMPANIONS, type Companion } from "@elysium/types";
|
|
import { useGame } from "../../context/gameContext.js";
|
|
import type { JSX } from "react";
|
|
|
|
const bonusLabels: Record<string, string> = {
|
|
bossDamage: "Boss Damage",
|
|
clickGold: "Click Gold",
|
|
essenceIncome: "Essence Income",
|
|
passiveGold: "Passive Gold",
|
|
questTime: "Quest Time",
|
|
};
|
|
|
|
const unlockLabels: Record<string, string> = {
|
|
apotheosis: "apotheosis",
|
|
lifetimeBosses: "lifetime bosses defeated",
|
|
lifetimeGold: "lifetime gold earned",
|
|
lifetimeQuests: "lifetime quests completed",
|
|
prestige: "prestige(s)",
|
|
transcendence: "transcendence(s)",
|
|
};
|
|
|
|
/**
|
|
* Formats a companion unlock threshold for display.
|
|
* @param type - The unlock condition type.
|
|
* @param threshold - The threshold value.
|
|
* @returns The formatted threshold string.
|
|
*/
|
|
const formatThreshold = (type: string, threshold: number): string => {
|
|
if (type === "lifetimeGold") {
|
|
if (threshold >= 1e18) {
|
|
return `${(threshold / 1e18).toFixed(0)}Qt`;
|
|
}
|
|
if (threshold >= 1e15) {
|
|
return `${(threshold / 1e15).toFixed(0)}Q`;
|
|
}
|
|
if (threshold >= 1e12) {
|
|
return `${(threshold / 1e12).toFixed(0)}T`;
|
|
}
|
|
if (threshold >= 1e9) {
|
|
return `${(threshold / 1e9).toFixed(0)}B`;
|
|
}
|
|
if (threshold >= 1e6) {
|
|
return `${(threshold / 1e6).toFixed(0)}M`;
|
|
}
|
|
if (threshold >= 1e3) {
|
|
return `${(threshold / 1e3).toFixed(0)}K`;
|
|
}
|
|
}
|
|
return threshold.toString();
|
|
};
|
|
|
|
interface CompanionCardProperties {
|
|
readonly companion: Companion;
|
|
readonly isUnlocked: boolean;
|
|
readonly isActive: boolean;
|
|
readonly onSelect: ()=> void;
|
|
}
|
|
|
|
/**
|
|
* Renders a single companion card.
|
|
* @param props - The companion card properties.
|
|
* @param props.companion - The companion data.
|
|
* @param props.isUnlocked - Whether this companion is unlocked.
|
|
* @param props.isActive - Whether this companion is currently active.
|
|
* @param props.onSelect - Callback when the companion is selected/deselected.
|
|
* @returns The JSX element.
|
|
*/
|
|
const CompanionCard = ({
|
|
companion,
|
|
isUnlocked,
|
|
isActive,
|
|
onSelect,
|
|
}: CompanionCardProperties): JSX.Element => {
|
|
const bonusSign = companion.bonus.type === "questTime"
|
|
? "-"
|
|
: "+";
|
|
const bonusPercent = Math.round(companion.bonus.value * 100);
|
|
const bonusLabel = bonusLabels[companion.bonus.type] ?? companion.bonus.type;
|
|
|
|
return (
|
|
<div
|
|
className={`companion-card ${
|
|
isUnlocked
|
|
? "companion-unlocked"
|
|
: "companion-locked"
|
|
} ${isActive
|
|
? "companion-active"
|
|
: ""}`}
|
|
>
|
|
<div className="companion-header">
|
|
<div className="companion-name-block">
|
|
<span className="companion-name">{companion.name}</span>
|
|
<span className="companion-title">{companion.title}</span>
|
|
</div>
|
|
{isActive
|
|
? <span className="companion-active-badge">{"Active"}</span>
|
|
: null}
|
|
</div>
|
|
|
|
<p className="companion-description">{companion.description}</p>
|
|
|
|
<div className="companion-bonus">
|
|
<span className="companion-bonus-label">{bonusLabel}</span>
|
|
<span className="companion-bonus-value">
|
|
{bonusSign}
|
|
{bonusPercent}
|
|
{"%"}
|
|
</span>
|
|
</div>
|
|
|
|
{isUnlocked
|
|
? <button
|
|
className={`companion-select-btn ${
|
|
isActive
|
|
? "companion-select-active"
|
|
: ""
|
|
}`}
|
|
onClick={onSelect}
|
|
type="button"
|
|
>
|
|
{isActive
|
|
? "Deactivate"
|
|
: "Activate"}
|
|
</button>
|
|
: <div className="companion-unlock-requirement">
|
|
{"🔒 Unlock: "}
|
|
{formatThreshold(
|
|
companion.unlock.type,
|
|
companion.unlock.threshold,
|
|
)}{" "}
|
|
{unlockLabels[companion.unlock.type] ?? companion.unlock.type}
|
|
</div>
|
|
}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Renders the companion panel with all companions.
|
|
* @returns The JSX element.
|
|
*/
|
|
const CompanionPanel = (): JSX.Element => {
|
|
const { state, setActiveCompanion } = useGame();
|
|
|
|
if (state === null) {
|
|
return (
|
|
<section className="panel">
|
|
<p>{"Loading..."}</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const unlockedIds = state.companions?.unlockedCompanionIds ?? [];
|
|
const activeId = state.companions?.activeCompanionId ?? null;
|
|
|
|
function handleSelect(companionId: string): void {
|
|
setActiveCompanion(activeId === companionId
|
|
? null
|
|
: companionId);
|
|
}
|
|
|
|
const activeCompanion
|
|
= activeId === null
|
|
? undefined
|
|
: COMPANIONS.find((companion) => {
|
|
return companion.id === activeId;
|
|
});
|
|
|
|
return (
|
|
<div className="companion-panel">
|
|
<h2>{"👥 Companions"}</h2>
|
|
<p className="companion-intro">
|
|
{"Companions provide powerful bonuses while active."
|
|
+ " You can only have one companion active at a time."}
|
|
{activeId === null
|
|
? null
|
|
: <>
|
|
{" Currently active: "}
|
|
<strong>{activeCompanion?.name ?? activeId}</strong>
|
|
{"."}
|
|
</>
|
|
}
|
|
</p>
|
|
|
|
<div className="companion-grid">
|
|
{COMPANIONS.map((companion) => {
|
|
function handleCompanionSelect(): void {
|
|
handleSelect(companion.id);
|
|
}
|
|
return (
|
|
<CompanionCard
|
|
companion={companion}
|
|
isActive={activeId === companion.id}
|
|
isUnlocked={unlockedIds.includes(companion.id)}
|
|
key={companion.id}
|
|
onSelect={handleCompanionSelect}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export { CompanionPanel };
|