generated from nhcarrigan/template
078ae50e69
- Define EquipmentSet type + computeSetBonuses utility in packages/types - Add setId field to Equipment type and assign sets to 27 equipment items - Create 9 named equipment sets (Iron Vanguard → Eternal Throne) with 2pc/3pc bonuses - Apply set combat multiplier in boss route - Apply set gold/click multipliers in tick engine and click handler - Include set bonuses in anti-cheat delta validation - Show active set bonus strip + set badge per card in EquipmentPanel - Add boss first-kill bounty runestones (scaling 1–10 per boss tier) - Update AboutPanel and IDEAS.md
207 lines
7.4 KiB
TypeScript
207 lines
7.4 KiB
TypeScript
import type { Equipment, EquipmentType } from "@elysium/types";
|
|
import { useState } from "react";
|
|
import { useGame } from "../../context/GameContext.js";
|
|
import { EQUIPMENT_SETS } from "../../data/equipmentSets.js";
|
|
import { LockToggle } from "../ui/LockToggle.js";
|
|
|
|
const RARITY_LABEL: Record<string, string> = {
|
|
common: "Common",
|
|
rare: "Rare",
|
|
epic: "Epic",
|
|
legendary: "Legendary",
|
|
};
|
|
|
|
const TYPE_ICON: Record<EquipmentType, string> = {
|
|
weapon: "⚔️",
|
|
armour: "🛡️",
|
|
trinket: "💍",
|
|
};
|
|
|
|
const bonusDescription = (item: Equipment): string => {
|
|
const parts: string[] = [];
|
|
if (item.bonus.combatMultiplier != null) {
|
|
parts.push(`+${Math.round((item.bonus.combatMultiplier - 1) * 100)}% Combat`);
|
|
}
|
|
if (item.bonus.goldMultiplier != null) {
|
|
parts.push(`+${Math.round((item.bonus.goldMultiplier - 1) * 100)}% Gold/s`);
|
|
}
|
|
if (item.bonus.clickMultiplier != null) {
|
|
parts.push(`+${Math.round((item.bonus.clickMultiplier - 1) * 100)}% Click`);
|
|
}
|
|
return parts.join(", ");
|
|
};
|
|
|
|
interface EquipmentCardProps {
|
|
item: Equipment;
|
|
gold: number;
|
|
essence: number;
|
|
crystals: number;
|
|
dropBossName?: string | undefined;
|
|
setName?: string | undefined;
|
|
}
|
|
|
|
const costLabel = (cost: { gold: number; essence: number; crystals: number }): string => {
|
|
const parts: 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(" ");
|
|
};
|
|
|
|
const EquipmentCard = ({ item, gold, essence, crystals, dropBossName, setName }: EquipmentCardProps): React.JSX.Element => {
|
|
const { equipItem, buyEquipment } = useGame();
|
|
|
|
const canAfford = item.cost
|
|
? gold >= item.cost.gold && essence >= item.cost.essence && crystals >= item.cost.crystals
|
|
: false;
|
|
|
|
return (
|
|
<div className={`equipment-card rarity-${item.rarity} ${item.equipped ? "equipped" : ""} ${!item.owned ? "not-owned" : ""}`}>
|
|
<div className="equipment-icon">{TYPE_ICON[item.type]}</div>
|
|
<div className="equipment-info">
|
|
<div className="equipment-name-row">
|
|
<h3>{item.name}</h3>
|
|
<span className={`rarity-badge rarity-${item.rarity}`}>{RARITY_LABEL[item.rarity]}</span>
|
|
</div>
|
|
<p className="equipment-description">{item.description}</p>
|
|
<p className="equipment-bonus">{bonusDescription(item)}</p>
|
|
{setName && <span className="equipment-set-badge">🔗 {setName}</span>}
|
|
{!item.owned && item.cost && (
|
|
<p className="equipment-cost">{costLabel(item.cost)}</p>
|
|
)}
|
|
</div>
|
|
<div className="equipment-action">
|
|
{!item.owned && !item.cost && (
|
|
<span className="equipment-locked">
|
|
{dropBossName ? `⚔️ Drop: ${dropBossName}` : "🔒 Boss drop"}
|
|
</span>
|
|
)}
|
|
{!item.owned && item.cost && (
|
|
<button
|
|
className="equip-button"
|
|
disabled={!canAfford}
|
|
onClick={() => { buyEquipment(item.id); }}
|
|
type="button"
|
|
>
|
|
{canAfford ? "Purchase" : "Can't afford"}
|
|
</button>
|
|
)}
|
|
{item.owned && item.equipped && <span className="equipment-equipped-badge">✓ Equipped</span>}
|
|
{item.owned && !item.equipped && (
|
|
<button
|
|
className="equip-button"
|
|
onClick={() => { equipItem(item.id); }}
|
|
type="button"
|
|
>
|
|
Equip
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const SLOT_ORDER: EquipmentType[] = ["weapon", "armour", "trinket"];
|
|
const SLOT_LABEL: Record<EquipmentType, string> = {
|
|
weapon: "⚔️ Weapons",
|
|
armour: "🛡️ Armour",
|
|
trinket: "💍 Trinkets",
|
|
};
|
|
|
|
export const EquipmentPanel = (): React.JSX.Element => {
|
|
const { state } = useGame();
|
|
const [showLocked, setShowLocked] = useState(true);
|
|
|
|
if (!state) return <section className="panel"><p>Loading...</p></section>;
|
|
|
|
const equipment = state.equipment ?? [];
|
|
const unownedCount = equipment.filter((e) => !e.owned).length;
|
|
|
|
const equipmentDropSources = new Map<string, string>();
|
|
for (const boss of state.bosses) {
|
|
for (const equipmentId of (boss.equipmentRewards ?? [])) {
|
|
equipmentDropSources.set(equipmentId, boss.name);
|
|
}
|
|
}
|
|
|
|
// Build set name lookup for card badges
|
|
const setNameById = new Map<string, string>(
|
|
EQUIPMENT_SETS.map((s) => [s.id, s.name]),
|
|
);
|
|
|
|
// Compute active set bonuses for the summary strip
|
|
const equippedItemIds = equipment.filter((e) => e.equipped).map((e) => e.id);
|
|
const activeSets = EQUIPMENT_SETS.map((set) => {
|
|
const count = set.pieces.filter((id) => equippedItemIds.includes(id)).length;
|
|
return { set, count };
|
|
}).filter(({ count }) => count >= 2);
|
|
|
|
const setBonusDescription = (set: typeof EQUIPMENT_SETS[number], count: number): string => {
|
|
const parts: string[] = [];
|
|
for (const threshold of [2, 3] as const) {
|
|
if (count >= threshold) {
|
|
const bonus = set.bonuses[threshold];
|
|
if (bonus.goldMultiplier) parts.push(`+${Math.round((bonus.goldMultiplier - 1) * 100)}% Gold/s (${threshold}pc)`);
|
|
if (bonus.combatMultiplier) parts.push(`+${Math.round((bonus.combatMultiplier - 1) * 100)}% Combat (${threshold}pc)`);
|
|
if (bonus.clickMultiplier) parts.push(`+${Math.round((bonus.clickMultiplier - 1) * 100)}% Click (${threshold}pc)`);
|
|
}
|
|
}
|
|
return parts.join(", ");
|
|
};
|
|
|
|
return (
|
|
<section className="panel equipment-panel">
|
|
<div className="panel-header">
|
|
<h2>Equipment</h2>
|
|
<LockToggle
|
|
lockedCount={unownedCount}
|
|
showLocked={showLocked}
|
|
onToggle={() => { setShowLocked((v) => !v); }}
|
|
/>
|
|
</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 }) => (
|
|
<div key={set.id} className="active-set-row">
|
|
<span className="active-set-name">{set.name} ({count}/{set.pieces.length})</span>
|
|
<span className="active-set-bonus">{setBonusDescription(set, count)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{SLOT_ORDER.map((slotType) => {
|
|
const items = equipment.filter(
|
|
(e) => e.type === slotType && (showLocked || e.owned),
|
|
);
|
|
return (
|
|
<div key={slotType} className="equipment-slot-section">
|
|
<h3 className="slot-heading">{SLOT_LABEL[slotType]}</h3>
|
|
<div className="equipment-list">
|
|
{items.map((item) => (
|
|
<EquipmentCard
|
|
key={item.id}
|
|
item={item}
|
|
gold={state.resources.gold}
|
|
essence={state.resources.essence}
|
|
crystals={state.resources.crystals}
|
|
dropBossName={equipmentDropSources.get(item.id)}
|
|
setName={item.setId ? setNameById.get(item.setId) : undefined}
|
|
/>
|
|
))}
|
|
{items.length === 0 && (
|
|
<p className="empty-zone">No items to show in this slot.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</section>
|
|
);
|
|
};
|