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,213 @@
|
||||
/**
|
||||
* @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 };
|
||||
Reference in New Issue
Block a user