Files
Mana-Loop/src/components/game/tabs/GolemancyTab.tsx
T
n8n-gitea badd233c63
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m31s
feat: expand golem stat abbreviations and add special abilities display
2026-05-27 12:20:44 +02:00

339 lines
12 KiB
TypeScript

'use client';
import React, { useState, useCallback, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore } from '@/lib/game/stores/combatStore';
import { useAttunementStore } from '@/lib/game/stores/attunementStore';
import { useManaStore } from '@/lib/game/stores/manaStore';
import { GOLEMS_DEF, isGolemUnlocked, canAffordGolemSummon, getGolemSlots } from '@/lib/game/data/golems';
import type { GolemDef } from '@/lib/game/data/golems';
import { ELEMENTS } from '@/lib/game/constants/elements';
import { DebugName } from '@/components/game/debug/debug-context';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import clsx from 'clsx';
// ─── Tier configuration ──────────────────────────────────────────────────────
interface TierConfig {
key: string;
label: string;
tier: number;
}
const TIERS: TierConfig[] = [
{ key: 'base', label: 'Base', tier: 1 },
{ key: 'elemental', label: 'Elemental', tier: 2 },
{ key: 'hybrid', label: 'Hybrid', tier: 3 },
];
function getTierLabel(tier: number): string {
if (tier <= 1) return 'Base';
if (tier <= 2) return 'Elemental';
return 'Hybrid';
}
function getTierColor(tier: number): string {
if (tier <= 1) return 'bg-gray-600';
if (tier <= 2) return 'bg-blue-600';
if (tier <= 3) return 'bg-purple-600';
return 'bg-amber-500';
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatCost(cost: GolemDef['summonCost'][number]): string {
if (cost.type === 'raw') return `${cost.amount} raw`;
const elem = cost.element ? ELEMENTS[cost.element] : null;
return `${cost.amount} ${elem?.sym ?? ''} ${cost.element ?? ''}`.trim();
}
function formatUnlockCondition(golem: GolemDef): string {
const cond = golem.unlockCondition;
switch (cond.type) {
case 'attunement_level':
return `${cond.attunement} level ${cond.level}`;
case 'mana_unlocked': {
const elem = cond.manaType ? ELEMENTS[cond.manaType] : null;
return `Unlock ${elem?.sym ?? ''} ${cond.manaType ?? ''}`.trim();
}
case 'dual_attunement':
return `${cond.attunements?.join(' + ')} level ${cond.levels?.join('/')}`;
default:
return 'Unknown';
}
}
// ─── Golem Card ──────────────────────────────────────────────────────────────
interface GolemCardProps {
golem: GolemDef;
unlocked: boolean;
enabled: boolean;
summoned: boolean;
canAfford: boolean;
onToggle: (id: string) => void;
}
const GolemCard: React.FC<GolemCardProps> = React.memo(({
golem,
unlocked,
enabled,
summoned,
canAfford,
onToggle,
}) => {
const elemColor = ELEMENTS[golem.baseManaType]?.color ?? '#888';
const elemSym = ELEMENTS[golem.baseManaType]?.sym ?? '';
return (
<div
className={clsx(
'rounded-lg border p-4 space-y-3 transition-colors',
unlocked
? 'bg-gray-800/50 border-gray-600'
: 'bg-gray-900/50 border-gray-800 opacity-60',
summoned && 'ring-1 ring-green-500/50',
)}
>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold text-gray-100 truncate">{golem.name}</h3>
<p className="text-xs text-gray-400 mt-0.5">{golem.description}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span
className="text-xs"
style={{ color: elemColor }}
title={golem.baseManaType}
>
{elemSym}
</span>
<Badge className={clsx('text-[10px] px-1.5 py-0', getTierColor(golem.tier))}>
T{golem.tier}
</Badge>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="text-gray-400">
<span className="text-gray-500">Damage:</span> {golem.damage}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Attack Speed:</span> {golem.attackSpeed}/h
</div>
<div className="text-gray-400">
<span className="text-gray-500">HP:</span> {golem.hp}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Armor Pierce:</span> {Math.round(golem.armorPierce * 100)}%
</div>
{golem.isAoe && (
<div className="col-span-2 text-gray-400">
<span className="text-gray-500">AoE:</span> {golem.aoeTargets} targets
</div>
)}
</div>
{/* Special Abilities */}
{golem.specialAbilities && golem.specialAbilities.length > 0 && (
<div className="text-xs space-y-0.5">
<span className="text-gray-500">Special:</span>
{golem.specialAbilities.map((ability, i) => (
<div key={i} className="text-gray-400 pl-2">
{ability.name}: {ability.description}
</div>
))}
</div>
)}
{/* Costs */}
<div className="text-xs space-y-0.5">
<div className="text-gray-400">
<span className="text-gray-500">Summon:</span>{' '}
{golem.summonCost.map((c, i) => (
<span key={i}>{formatCost(c)}{i < golem.summonCost.length - 1 ? ' + ' : ''}</span>
))}
</div>
<div className="text-gray-400">
<span className="text-gray-500">Upkeep:</span>{' '}
{golem.maintenanceCost.map((c, i) => (
<span key={i}>{formatCost(c)}{i < golem.maintenanceCost.length - 1 ? ' + ' : ''}</span>
))}/tick
</div>
</div>
{/* Unlock requirement */}
{!unlocked && (
<div className="text-xs text-red-400">
🔒 Requires: {formatUnlockCondition(golem)}
</div>
)}
{/* Status + toggle */}
<div className="flex items-center justify-between pt-1">
<div className="text-xs">
{summoned ? (
<span className="text-green-400"> Summoned</span>
) : enabled ? (
<span className="text-yellow-400"> Queued</span>
) : (
<span className="text-gray-500"> Idle</span>
)}
</div>
<button
onClick={() => onToggle(golem.id)}
disabled={!unlocked}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
!unlocked
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
: enabled
? 'bg-red-600/80 text-white hover:bg-red-500'
: canAfford
? 'bg-green-600/80 text-white hover:bg-green-500'
: 'bg-blue-600/80 text-white hover:bg-blue-500',
)}
>
{!unlocked ? 'Locked' : enabled ? 'Disable' : 'Enable'}
</button>
</div>
</div>
);
});
GolemCard.displayName = 'GolemCard';
// ─── Main Tab ────────────────────────────────────────────────────────────────
export const GolemancyTab: React.FC = () => {
const [activeTier, setActiveTier] = useState<string>('base');
const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({
golemancy: s.golemancy,
toggleGolem: s.toggleGolem,
})));
const attunements = useAttunementStore(s => s.attunements);
const { rawMana, elements } = useManaStore(useShallow(s => ({
rawMana: s.rawMana,
elements: s.elements,
})));
// Build attunement lookup for isGolemUnlocked
const attunementLookup = useMemo(() => {
const lookup: Record<string, { active: boolean; level: number }> = {};
for (const [id, att] of Object.entries(attunements)) {
lookup[id] = { active: att.active, level: att.level };
}
return lookup;
}, [attunements]);
const unlockedElements = useMemo(
() => Object.entries(elements).filter(([, e]) => e.unlocked).map(([k]) => k),
[elements],
);
// Group golems by tier
const golemsByTier = useMemo(() => {
const groups: Record<string, GolemDef[]> = { base: [], elemental: [], hybrid: [] };
for (const golem of Object.values(GOLEMS_DEF)) {
const label = getTierLabel(golem.tier);
const key = label.toLowerCase();
if (groups[key]) {
groups[key].push(golem);
} else {
// tier 4 golems go into hybrid
groups.hybrid.push(golem);
}
}
return groups;
}, []);
const handleToggle = useCallback((id: string) => {
toggleGolem(id);
}, [toggleGolem]);
// Golem slot info
const fabricatorLevel = attunements.fabricator?.level ?? 0;
const golemSlots = getGolemSlots(fabricatorLevel);
const enabledCount = golemancy.enabledGolems.length;
const activeTierGolems = golemsByTier[activeTier] ?? [];
return (
<DebugName name="GolemancyTab">
<div className="space-y-4">
{/* Header info */}
<div className="text-sm text-gray-400 space-y-1">
<p>
Configure your golem loadout. Enabled golems are automatically summoned
when entering the spire if you can afford the cost.
</p>
<div className="flex gap-4 text-xs">
<span>
Slots: {enabledCount}/{golemSlots > 0 ? golemSlots : '—'}
</span>
<span>
Summoned: {golemancy.summonedGolems.length}
</span>
</div>
</div>
{/* Tier tabs */}
<div className="flex gap-2">
{TIERS.map((tier) => {
const count = golemsByTier[tier.key]?.length ?? 0;
return (
<button
key={tier.key}
onClick={() => setActiveTier(tier.key)}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
activeTier === tier.key
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:text-gray-200',
)}
>
{tier.label} ({count})
</button>
);
})}
</div>
{/* Golem cards */}
<ScrollArea className="h-[500px] rounded border border-gray-700 p-3">
{activeTierGolems.length === 0 ? (
<div className="text-center text-gray-500 py-8">
No golems in this tier.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{activeTierGolems.map((golem) => {
const unlocked = isGolemUnlocked(golem.id, attunementLookup, unlockedElements);
const enabled = golemancy.enabledGolems.includes(golem.id);
const summoned = golemancy.summonedGolems.some(g => g.golemId === golem.id);
const canAfford = canAffordGolemSummon(golem.id, rawMana, elements);
return (
<GolemCard
key={golem.id}
golem={golem}
unlocked={unlocked}
enabled={enabled}
summoned={summoned}
canAfford={canAfford}
onToggle={handleToggle}
/>
);
})}
</div>
)}
</ScrollArea>
</div>
</DebugName>
);
};
GolemancyTab.displayName = 'GolemancyTab';