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,359 @@
|
||||
/**
|
||||
* @file Equipment panel component for managing owned and available equipment.
|
||||
* @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 conditional render paths */
|
||||
import { type JSX, useState } from "react";
|
||||
import { useGame } from "../../context/gameContext.js";
|
||||
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
||||
import { LockToggle } from "../ui/lockToggle.js";
|
||||
import type { Equipment, EquipmentType } from "@elysium/types";
|
||||
|
||||
const rarityLabel: Record<string, string> = {
|
||||
common: "Common",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
rare: "Rare",
|
||||
};
|
||||
|
||||
const typeIcon: Record<EquipmentType, string> = {
|
||||
armour: "🛡️",
|
||||
trinket: "💍",
|
||||
weapon: "⚔️",
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes a human-readable bonus description for a piece of equipment.
|
||||
* @param item - The equipment item.
|
||||
* @returns The formatted bonus description.
|
||||
*/
|
||||
const bonusDescription = (item: Equipment): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (item.bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat`);
|
||||
}
|
||||
if (item.bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold/s`);
|
||||
}
|
||||
if (item.bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((item.bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click`);
|
||||
}
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats an equipment cost as a readable string.
|
||||
* @param cost - The cost object with gold, essence, and crystals.
|
||||
* @param cost.gold - The gold component of the cost.
|
||||
* @param cost.essence - The essence component of the cost.
|
||||
* @param cost.crystals - The crystals component of the cost.
|
||||
* @returns The formatted cost string.
|
||||
*/
|
||||
const costLabel = (cost: {
|
||||
gold: number;
|
||||
essence: number;
|
||||
crystals: number;
|
||||
}): string => {
|
||||
const parts: Array<string> = [];
|
||||
if (cost.gold > 0) {
|
||||
parts.push(`🪙 ${cost.gold.toLocaleString()}`);
|
||||
}
|
||||
if (cost.essence > 0) {
|
||||
parts.push(`✨ ${cost.essence.toLocaleString()}`);
|
||||
}
|
||||
if (cost.crystals > 0) {
|
||||
parts.push(`💎 ${cost.crystals.toLocaleString()}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
};
|
||||
|
||||
interface EquipmentCardProperties {
|
||||
readonly item: Equipment;
|
||||
readonly gold: number;
|
||||
readonly essence: number;
|
||||
readonly crystals: number;
|
||||
readonly dropBossName: string | undefined;
|
||||
readonly setName: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single equipment card.
|
||||
* @param props - The equipment card properties.
|
||||
* @param props.item - The equipment item data.
|
||||
* @param props.gold - The current gold amount.
|
||||
* @param props.essence - The current essence amount.
|
||||
* @param props.crystals - The current crystals amount.
|
||||
* @param props.dropBossName - The name of the boss that drops this item.
|
||||
* @param props.setName - The name of the set this item belongs to.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EquipmentCard = ({
|
||||
item,
|
||||
gold,
|
||||
essence,
|
||||
crystals,
|
||||
dropBossName,
|
||||
setName,
|
||||
}: EquipmentCardProperties): JSX.Element => {
|
||||
const { equipItem, buyEquipment } = useGame();
|
||||
|
||||
const canAfford
|
||||
= item.cost !== undefined
|
||||
&& gold >= item.cost.gold
|
||||
&& essence >= item.cost.essence
|
||||
&& crystals >= item.cost.crystals;
|
||||
|
||||
function handleBuy(): void {
|
||||
buyEquipment(item.id);
|
||||
}
|
||||
function handleEquip(): void {
|
||||
equipItem(item.id);
|
||||
}
|
||||
|
||||
const ownedClass = item.owned
|
||||
? ""
|
||||
: "not-owned";
|
||||
const equippedClass = item.equipped
|
||||
? "equipped"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`equipment-card rarity-${item.rarity} ${equippedClass} ${ownedClass}`}
|
||||
>
|
||||
<div className="equipment-icon">{typeIcon[item.type]}</div>
|
||||
<div className="equipment-info">
|
||||
<div className="equipment-name-row">
|
||||
<h3>{item.name}</h3>
|
||||
<span className={`rarity-badge rarity-${item.rarity}`}>
|
||||
{rarityLabel[item.rarity]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="equipment-description">{item.description}</p>
|
||||
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
||||
{setName === undefined
|
||||
? null
|
||||
: <span className="equipment-set-badge">
|
||||
{"🔗 "}
|
||||
{setName}
|
||||
</span>
|
||||
}
|
||||
{item.owned || item.cost === undefined
|
||||
? null
|
||||
: <p className="equipment-cost">{costLabel(item.cost)}</p>
|
||||
}
|
||||
</div>
|
||||
<div className="equipment-action">
|
||||
{!item.owned && item.cost === undefined
|
||||
&& <span className="equipment-locked">
|
||||
{dropBossName === undefined
|
||||
? "🔒 Boss drop"
|
||||
: `⚔️ Drop: ${dropBossName}`}
|
||||
</span>
|
||||
}
|
||||
{item.owned || item.cost === undefined
|
||||
? null
|
||||
: <button
|
||||
className="equip-button"
|
||||
disabled={!canAfford}
|
||||
onClick={handleBuy}
|
||||
type="button"
|
||||
>
|
||||
{canAfford
|
||||
? "Purchase"
|
||||
: "Can't afford"}
|
||||
</button>
|
||||
}
|
||||
{item.owned && item.equipped
|
||||
? <span className="equipment-equipped-badge">{"✓ Equipped"}</span>
|
||||
: null}
|
||||
{item.owned && !item.equipped
|
||||
? <button
|
||||
className="equip-button"
|
||||
onClick={handleEquip}
|
||||
type="button"
|
||||
>
|
||||
{"Equip"}
|
||||
</button>
|
||||
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const slotOrder: Array<EquipmentType> = [ "weapon", "armour", "trinket" ];
|
||||
const slotLabel: Record<EquipmentType, string> = {
|
||||
armour: "🛡️ Armour",
|
||||
trinket: "💍 Trinkets",
|
||||
weapon: "⚔️ Weapons",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the equipment panel with all owned and available equipment.
|
||||
* @returns The JSX element.
|
||||
*/
|
||||
const EquipmentPanel = (): JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const [ showLocked, setShowLocked ] = useState(true);
|
||||
|
||||
if (state === null) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<p>{"Loading..."}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const { bosses, equipment, resources } = state;
|
||||
const unownedCount = equipment.filter((item) => {
|
||||
return !item.owned;
|
||||
}).length;
|
||||
|
||||
const equipmentDropSources = new Map<string, string>();
|
||||
for (const { equipmentRewards, name: bossName } of bosses) {
|
||||
for (const equipmentId of equipmentRewards) {
|
||||
equipmentDropSources.set(equipmentId, bossName);
|
||||
}
|
||||
}
|
||||
|
||||
const setNameById = new Map<string, string>(
|
||||
EQUIPMENT_SETS.map((equipSet) => {
|
||||
return [ equipSet.id, equipSet.name ];
|
||||
}),
|
||||
);
|
||||
|
||||
const equippedItemIds = new Set(
|
||||
equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
}),
|
||||
);
|
||||
const activeSets = EQUIPMENT_SETS.map((set) => {
|
||||
const count = set.pieces.filter((id) => {
|
||||
return equippedItemIds.has(id);
|
||||
}).length;
|
||||
return { count, set };
|
||||
}).filter(({ count }) => {
|
||||
return count >= 2;
|
||||
});
|
||||
|
||||
function setBonusDescription(
|
||||
equipSet: (typeof EQUIPMENT_SETS)[number],
|
||||
count: number,
|
||||
): string {
|
||||
const parts: Array<string> = [];
|
||||
for (const threshold of [ 2, 3 ] as const) {
|
||||
if (count >= threshold) {
|
||||
const bonus = equipSet.bonuses[threshold];
|
||||
if (bonus.goldMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.goldMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Gold/s (${String(threshold)}pc)`);
|
||||
}
|
||||
if (bonus.combatMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.combatMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Combat (${String(threshold)}pc)`);
|
||||
}
|
||||
if (bonus.clickMultiplier !== undefined) {
|
||||
const pct = Math.round((bonus.clickMultiplier - 1) * 100);
|
||||
parts.push(`+${String(pct)}% Click (${String(threshold)}pc)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function handleToggle(): void {
|
||||
setShowLocked((current) => {
|
||||
return !current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel equipment-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{"Equipment"}</h2>
|
||||
<LockToggle
|
||||
lockedCount={unownedCount}
|
||||
onToggle={handleToggle}
|
||||
showLocked={showLocked}
|
||||
/>
|
||||
</div>
|
||||
<p className="equipment-intro">
|
||||
{"Equipment drops from bosses and grants passive bonuses."
|
||||
+ " Only one item per slot can be equipped at a time."
|
||||
+ " Equip matching set pieces for bonus effects!"}
|
||||
</p>
|
||||
|
||||
{activeSets.length > 0
|
||||
&& <div className="active-sets">
|
||||
<h3 className="active-sets-heading">{"✨ Active Set Bonuses"}</h3>
|
||||
{activeSets.map(({ set, count }) => {
|
||||
return (
|
||||
<div className="active-set-row" key={set.id}>
|
||||
<span className="active-set-name">
|
||||
{set.name}
|
||||
{" ("}
|
||||
{count}
|
||||
{"/"}
|
||||
{set.pieces.length}
|
||||
{")"}
|
||||
</span>
|
||||
<span className="active-set-bonus">
|
||||
{setBonusDescription(set, count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
{slotOrder.map((slotType) => {
|
||||
const items = equipment.filter((item) => {
|
||||
return item.type === slotType && (showLocked || item.owned);
|
||||
});
|
||||
return (
|
||||
<div className="equipment-slot-section" key={slotType}>
|
||||
<h3 className="slot-heading">{slotLabel[slotType]}</h3>
|
||||
<div className="equipment-list">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<EquipmentCard
|
||||
crystals={resources.crystals}
|
||||
dropBossName={equipmentDropSources.get(item.id)}
|
||||
essence={resources.essence}
|
||||
gold={resources.gold}
|
||||
item={item}
|
||||
key={item.id}
|
||||
setName={
|
||||
item.setId === undefined
|
||||
? undefined
|
||||
: setNameById.get(item.setId)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{items.length === 0
|
||||
&& <p className="empty-zone">
|
||||
{"No items to show in this slot."}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { EquipmentPanel };
|
||||
Reference in New Issue
Block a user