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,241 @@
|
||||
/**
|
||||
* @file Adventurer panel component for hiring and managing adventurers.
|
||||
* @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 component with many render paths */
|
||||
/* eslint-disable complexity -- Complex component with many render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Adventurer } from "@elysium/types";
|
||||
|
||||
const iconByClass: Record<string, string> = {
|
||||
cleric: "✝️",
|
||||
mage: "🔮",
|
||||
paladin: "🛡️",
|
||||
ranger: "🏹",
|
||||
rogue: "🗝️",
|
||||
warrior: "🗡️",
|
||||
};
|
||||
|
||||
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
|
||||
const batchOptions: Array<BatchSize> = [ 1, 5, 10, 25, 100, "max" ];
|
||||
|
||||
/**
|
||||
* Computes the total cost to buy a batch of adventurers.
|
||||
* @param adventurer - The adventurer to buy.
|
||||
* @param quantity - The number to buy.
|
||||
* @returns The total gold cost.
|
||||
*/
|
||||
const computeBatchCost = (adventurer: Adventurer, quantity: number): number => {
|
||||
let total = 0;
|
||||
for (let index = 0; index < quantity; index = index + 1) {
|
||||
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
|
||||
total = total + cost;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the maximum number of adventurers affordable with given gold.
|
||||
* @param adventurer - The adventurer type.
|
||||
* @param gold - The available gold.
|
||||
* @returns The maximum affordable quantity.
|
||||
*/
|
||||
const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
|
||||
let total = 0;
|
||||
let quantity = 0;
|
||||
for (let index = 0; index < 100_000; index = index + 1) {
|
||||
const cost = adventurer.baseCost * Math.pow(1.15, adventurer.count + index);
|
||||
if (total + cost > gold) {
|
||||
break;
|
||||
}
|
||||
total = total + cost;
|
||||
quantity = quantity + 1;
|
||||
}
|
||||
return quantity;
|
||||
};
|
||||
|
||||
interface AdventurerCardProperties {
|
||||
readonly adventurer: Adventurer;
|
||||
readonly currentGold: number;
|
||||
readonly batchSize: BatchSize;
|
||||
readonly unlockHint: string | undefined;
|
||||
readonly formatNumber: (n: number)=> string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single adventurer card with buy controls.
|
||||
* @param props - The adventurer card properties.
|
||||
* @param props.adventurer - The adventurer data.
|
||||
* @param props.currentGold - The current gold available.
|
||||
* @param props.batchSize - The selected batch size.
|
||||
* @param props.unlockHint - Optional quest name that unlocks this adventurer.
|
||||
* @param props.formatNumber - The number formatting utility function.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerCard = ({
|
||||
adventurer,
|
||||
currentGold,
|
||||
batchSize,
|
||||
unlockHint,
|
||||
formatNumber,
|
||||
}: AdventurerCardProperties): JSX.Element => {
|
||||
const { buyAdventurer } = useGame();
|
||||
|
||||
const resolvedQuantity
|
||||
= batchSize === "max"
|
||||
? computeMaxAffordable(adventurer, currentGold)
|
||||
: batchSize;
|
||||
const cost = computeBatchCost(adventurer, resolvedQuantity);
|
||||
const canAfford = resolvedQuantity > 0 && currentGold >= cost;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyAdventurer(adventurer.id, resolvedQuantity);
|
||||
}
|
||||
|
||||
const maxSuffix
|
||||
= batchSize === "max" && resolvedQuantity > 0
|
||||
? ` (×${String(resolvedQuantity)})`
|
||||
: "";
|
||||
const buttonLabel = adventurer.unlocked
|
||||
? `🪙 ${formatNumber(Math.ceil(cost))}${maxSuffix}`
|
||||
: "🔒 Locked";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/dot-notation -- "class" is a reserved word
|
||||
const adventurerIcon = iconByClass[adventurer["class"]] ?? "⚔️";
|
||||
|
||||
return (
|
||||
<div className={`adventurer-card ${adventurer.unlocked
|
||||
? ""
|
||||
: "locked"}`}>
|
||||
<div className="adventurer-icon">{adventurerIcon}</div>
|
||||
<div className="adventurer-info">
|
||||
<h3>{adventurer.name}</h3>
|
||||
<p>
|
||||
{formatNumber(adventurer.goldPerSecond)}
|
||||
{" gold/s each"}
|
||||
</p>
|
||||
{adventurer.essencePerSecond > 0
|
||||
&& <p>
|
||||
{formatNumber(adventurer.essencePerSecond)}
|
||||
{" essence/s each"}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div className="adventurer-count">
|
||||
{"×"}
|
||||
{adventurer.count}
|
||||
</div>
|
||||
<button
|
||||
className="buy-button"
|
||||
disabled={!canAfford || !adventurer.unlocked}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
{!adventurer.unlocked && unlockHint !== undefined
|
||||
? <p className="unlock-hint">
|
||||
{"📜 Complete: "}
|
||||
{unlockHint}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the adventurer panel with all available adventurers.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const AdventurerPanel = (): JSX.Element => {
|
||||
const { state, formatNumber } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
const [ batchSize, setBatchSize ] = useState<BatchSize>(1);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const locked = state.adventurers.filter((adventurer) => {
|
||||
return !adventurer.unlocked;
|
||||
});
|
||||
const visible = showLocked
|
||||
? state.adventurers
|
||||
: state.adventurers.filter((adventurer) => {
|
||||
return adventurer.unlocked;
|
||||
});
|
||||
|
||||
const adventurerUnlockHints = new Map<string, string>();
|
||||
for (const quest of state.quests) {
|
||||
for (const reward of quest.rewards) {
|
||||
if (reward.type === "adventurer" && reward.targetId !== undefined) {
|
||||
adventurerUnlockHints.set(reward.targetId, quest.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel adventurer-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Adventurers"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={locked.length}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="batch-selector">
|
||||
{batchOptions.map((option) => {
|
||||
function handleBatchSelect(): void {
|
||||
setBatchSize(option);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`batch-button ${batchSize === option
|
||||
? "active"
|
||||
: ""}`}
|
||||
key={option}
|
||||
onClick={handleBatchSelect}
|
||||
type="button"
|
||||
>
|
||||
{option === "max"
|
||||
? "xMax"
|
||||
: `x${String(option)}`}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="adventurer-list">
|
||||
{visible.map((adventurer) => {
|
||||
return (
|
||||
<AdventurerCard
|
||||
adventurer={adventurer}
|
||||
batchSize={batchSize}
|
||||
currentGold={state.resources.gold}
|
||||
formatNumber={formatNumber}
|
||||
key={adventurer.id}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { AdventurerPanel };
|
||||
Reference in New Issue
Block a user