Files
elysium/apps/web/src/components/game/EquipmentPanel.tsx
T
hikari 078ae50e69 feat: add equipment set bonuses and boss bounty runestones
- 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
2026-03-06 23:56:45 -08:00

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>
);
};