feat: redesign Elemental subtab in DisciplinesTab to group by mana type
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s

This commit is contained in:
2026-05-28 21:24:06 +02:00
parent adeb106428
commit e20216bda5
6 changed files with 385 additions and 183 deletions
+47 -181
View File
@@ -1,8 +1,6 @@
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback } from 'react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
import type { ManaType } from '@/lib/game/types/elements';
import { ELEMENTS } from '@/lib/game/constants/elements';
import { baseDisciplines } from '@/lib/game/data/disciplines/base';
import { elementalAttunementDisciplines } from '@/lib/game/data/disciplines/elemental';
import { elementalRegenDisciplines } from '@/lib/game/data/disciplines/elemental-regen';
@@ -13,14 +11,14 @@ import { enchanterSpellDisciplines } from '@/lib/game/data/disciplines/enchanter
import { enchanterSpecialDisciplines } from '@/lib/game/data/disciplines/enchanter-special';
import { fabricatorDisciplines } from '@/lib/game/data/disciplines/fabricator';
import { invokerDisciplines } from '@/lib/game/data/disciplines/invoker';
import { calculateStatBonus, calculateManaDrain, checkDisciplinePrerequisites } from '@/lib/game/utils/discipline-math';
import { checkDisciplinePrerequisites } from '@/lib/game/utils/discipline-math';
import { ALL_DISCIPLINES } from '@/lib/game/data/disciplines';
import { useManaStore } from '@/lib/game/stores/manaStore';
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
import clsx from 'clsx';
import { DebugName } from '@/components/game/debug/debug-context';
import { TICKS_PER_SECOND, computePerkCurrentEffect, computeTotalPerkBonusForStat, isRateStat } from './disciplines-utils';
import type { ComputedPerkEffect } from './disciplines-utils';
import { DisciplineCard } from './DisciplineCard';
import { ElementalSubtab } from './ElementalSubtab';
// ─── Attunement Tabs ─────────────────────────────────────────────────────────
@@ -38,168 +36,37 @@ const ATTUNEMENT_TABS: AttunementTab[] = [
{ key: 'invoker', label: 'Invoker', items: invokerDisciplines },
];
// ─── Discipline Card Props ───────────────────────────────────────────────────
// ─── Discipline Card Wrapper (for flat grid) ─────────────────────────────────
interface DisciplineCardProps {
definition: DisciplineDefinition;
xp: number;
paused: boolean;
interface CardWrapperProps {
disc: DisciplineDefinition;
disciplines: Record<string, { xp: number; paused: boolean }>;
concurrentLimit: number;
isLocked: boolean;
missingPrereqs: string[];
missingSourceMana: string[];
elements: ReturnType<typeof useManaStore.getState>['elements'];
signedPacts: ReturnType<typeof usePrestigeStore.getState>['signedPacts'];
onToggle: (id: string, paused: boolean) => void;
}
// ─── Discipline Card Component ───────────────────────────────────────────────
const DisciplineCard: React.FC<DisciplineCardProps> = ({
definition, xp, paused: isPaused, concurrentLimit,
isLocked, missingPrereqs, missingSourceMana, onToggle,
const CardWrapper: React.FC<CardWrapperProps> = ({
disc, disciplines, concurrentLimit, elements, onToggle,
}) => {
const {
id, name, description, manaType, perks,
statBonus, drainBase, difficultyFactor, scalingFactor,
conversionRate, sourceManaTypes,
} = definition;
const displayXp = xp;
const progressPercent = Math.min(displayXp / Math.max(1, concurrentLimit * 100), 100);
// statBonus.baseValue is the correct field — not a top-level baseValue
const activeStatBonus = calculateStatBonus(statBonus.baseValue, displayXp, scalingFactor);
const drainPerSecond = calculateManaDrain(drainBase, displayXp, difficultyFactor) * TICKS_PER_SECOND;
const elementDef = ELEMENTS[manaType];
const manaColor = elementDef?.color ?? '#888888';
const manaIcon = elementDef?.sym ?? '✦';
const manaName = elementDef?.name ?? manaType;
const effectiveIsLocked = isLocked || missingSourceMana.length > 0;
const statBonusLabel = statBonus.label;
// Compute perk effects with current values
const computedPerks = useMemo((): ComputedPerkEffect[] => {
if (!perks) return [];
return perks.map((perk) => ({
description: perk.description,
currentEffect: computePerkCurrentEffect(perk, displayXp),
}));
}, [perks, displayXp]);
// Perk-augmented total for the stat bonus
const perkBonusTotal = useMemo(() => {
if (!perks || perks.length === 0) return 0;
return computeTotalPerkBonusForStat(perks, displayXp, statBonus.stat);
}, [perks, displayXp, statBonus.stat]);
const statBonusTotal = activeStatBonus + perkBonusTotal;
const discState = disciplines[disc.id] ?? { xp: 0, paused: true };
const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements);
return (
<div className={clsx('border rounded-lg p-4 shadow-sm space-y-3', effectiveIsLocked && 'opacity-60 border-gray-600')}>
<div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-medium">{name}</h3>
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${manaColor}20`,
borderColor: `${manaColor}60`,
borderWidth: 1,
color: manaColor,
}}
>
<span>{manaIcon}</span>
<span>{manaName}</span>
</span>
</div>
<p className="text-sm text-gray-400">{description}</p>
{/* XP Progress */}
<div className="flex items-center gap-2">
<span className="text-xs font-mono whitespace-nowrap">{Math.round(progressPercent)}%</span>
<div className="flex-1 bg-gray-200 rounded-full overflow-hidden h-3">
<div
className={`transition-all duration-300 ${activeStatBonus > 0 ? 'bg-green-500' : 'bg-red-500'}`}
style={{ width: `${Math.round(progressPercent)}%` }}
/>
</div>
</div>
{/* Stats Row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-400">
<span><strong>Drain:</strong> {drainPerSecond.toFixed(1)}/sec</span>
<span><strong>XP:</strong> {displayXp}</span>
</div>
{/* Conversion Info */}
{conversionRate != null && sourceManaTypes && (
<div className="mt-2 text-xs text-gray-400">
<strong>Converts:</strong> {sourceManaTypes.map(s => s === 'raw' ? 'raw' : ELEMENTS[s]?.name ?? s).join(' + ')} {manaName}
</div>
)}
{/* Stat Bonus with Perk Total */}
{(() => {
// NaN guard — if the calculation produced NaN, show a safe fallback
const safeActive = Number.isFinite(activeStatBonus) ? activeStatBonus : 0;
const safePerk = Number.isFinite(perkBonusTotal) ? perkBonusTotal : 0;
const safeTotal = Number.isFinite(statBonusTotal) ? statBonusTotal : 0;
const rateSuffix = isRateStat(statBonus.stat) ? '/sec' : '';
return (
<div className="mt-2 text-sm">
<strong>Stat Bonus:</strong> {safeActive.toFixed(2)}{rateSuffix} on {statBonusLabel}
{safePerk > 0 && (
<span className="text-green-400 ml-1">
({safeTotal.toFixed(2)}{rateSuffix} with perks)
</span>
)}
</div>
);
})()}
{/* Perks */}
<div className="mt-2">
<strong>Perks:</strong>
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
{computedPerks.length > 0 ? (
computedPerks.map((p) => (
<li key={p.description} className={clsx(
p.currentEffect.startsWith('at ') ? 'text-gray-400' : 'text-green-500',
)}>
{p.description}
<span className="text-gray-300 ml-1"> {p.currentEffect}</span>
</li>
))
) : (
<li className="text-gray-400"> none </li>
)}
</ul>
</div>
{/* Lock Reasons */}
{effectiveIsLocked && (missingPrereqs.length > 0 || missingSourceMana.length > 0) && (
<div className="mt-2 text-xs text-red-400">
<strong>Requires:</strong> {[...missingPrereqs, ...missingSourceMana].join(', ')}
</div>
)}
{/* Action Button */}
<div className="mt-4 flex justify-end">
<button
onClick={() => onToggle(id, isPaused)}
disabled={effectiveIsLocked}
className={clsx(
'rounded px-3 py-1 text-sm font-medium',
effectiveIsLocked
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
: isPaused
? 'bg-yellow-600 text-white hover:bg-yellow-500'
: 'bg-blue-600 text-white hover:bg-blue-500',
)}
>
{effectiveIsLocked ? 'Locked' : isPaused ? 'Start Practicing' : 'Stop Practicing'}
</button>
</div>
</div>
<DisciplineCard
definition={disc}
xp={discState.xp}
paused={discState.paused}
concurrentLimit={concurrentLimit}
isLocked={!prereqCheck.canProceed}
missingPrereqs={prereqCheck.missingPrereqs}
missingSourceMana={disc.sourceManaTypes
? disc.sourceManaTypes
.filter((src) => src !== 'raw' && (!elements[src] || !elements[src].unlocked))
.map((src) => `${src} mana`)
: []}
onToggle={onToggle}
/>
);
};
@@ -247,30 +114,29 @@ export const DisciplinesTab: React.FC = () => {
))}
</div>
{/* Discipline cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{activeTab?.items.map((disc) => {
const discState = disciplines[disc.id] ?? { xp: 0, paused: true };
const prereqCheck = checkDisciplinePrerequisites(disc, disciplines, ALL_DISCIPLINES, elements);
return (
<DisciplineCard
{/* Grouped layout for elemental tab, grid for others */}
{activeAttunement === 'elemental' ? (
<ElementalSubtab
disciplines={disciplines}
concurrentLimit={concurrentLimit}
elements={elements}
onToggle={handleToggle}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{activeTab?.items.map((disc) => (
<CardWrapper
key={disc.id}
definition={disc}
xp={discState.xp}
paused={discState.paused}
disc={disc}
disciplines={disciplines}
concurrentLimit={concurrentLimit}
isLocked={!prereqCheck.canProceed}
missingPrereqs={prereqCheck.missingPrereqs}
missingSourceMana={disc.sourceManaTypes
? disc.sourceManaTypes
.filter((src) => src !== 'raw' && (!elements[src] || !elements[src].unlocked))
.map((src) => `${src} mana`)
: []}
elements={elements}
signedPacts={signedPacts}
onToggle={handleToggle}
/>
);
})}
</div>
))}
</div>
)}
{/* Summary */}
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-500">