generated from nhcarrigan/template
81ae1f18e1
## Summary Resolves player confusion about equipment combat bonuses not affecting quest combat power. The behaviour is by design — combat multipliers only apply to boss DPS — but this was never communicated anywhere. - **Equipment bonus labels** now read `+X% Boss Combat` instead of `+X% Combat` (both individual items and set bonuses) - **About panel** — both equipment entries updated to explicitly state that combat bonuses only affect boss fights, and that quest combat power is determined solely by adventurers No game logic changed. Closes #81 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #83 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
/**
|
|
* @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 */
|
|
/* eslint-disable max-lines -- Equipment panel with set bonus display and sort logic */
|
|
import { type JSX, useState } from "react";
|
|
import { useGame } from "../../context/gameContext.js";
|
|
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
|
import { cdnImage } from "../../utils/cdn.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",
|
|
};
|
|
|
|
/**
|
|
* 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)}% Boss 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}`}
|
|
>
|
|
<img
|
|
alt={item.name}
|
|
className="card-thumbnail"
|
|
src={cdnImage("equipment", item.id)}
|
|
/>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Computes a combined power score for sorting — sum of all bonus multipliers.
|
|
* Using the sum (rather than a single stat) keeps hybrid items in sensible order.
|
|
* @param item - The equipment piece whose bonus multipliers are summed.
|
|
* @returns The combined bonus value.
|
|
*/
|
|
const equipmentPower = (item: Equipment): number => {
|
|
return (
|
|
(item.bonus.combatMultiplier ?? 1)
|
|
+ (item.bonus.goldMultiplier ?? 1)
|
|
+ (item.bonus.clickMultiplier ?? 1)
|
|
);
|
|
};
|
|
|
|
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)}% Boss 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);
|
|
}).sort((a, b) => {
|
|
return equipmentPower(a) - equipmentPower(b);
|
|
});
|
|
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 };
|