feat: implement Active Disciplines system
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 31s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 31s
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
|
||||
import type { DisciplineDefinition } from '@/types/disciplines';
|
||||
import baseDisciplines from '../data/disciplines/base';
|
||||
import enchanterDisciplines from '../data/disciplines/enchanter';
|
||||
import fabricatorDisciplines from '../data/disciplines/fabricator';
|
||||
import invokerDisciplines from '../data/disciplines/invoker';
|
||||
import { calculateStatBonus, calculateManaDrain } from '@/lib/game/utils/discipline-math';
|
||||
import { useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export const DisciplinesTab: React.FC = () => {
|
||||
const store = useDisciplineStore();
|
||||
const { disciplines, activeIds, concurrentLimit } = store;
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const allDisciplines: DisciplineDefinition[] = [
|
||||
...baseDisciplines,
|
||||
...enchanterDisciplines,
|
||||
...fabricatorDisciplines,
|
||||
...invokerDisciplines,
|
||||
];
|
||||
|
||||
// Group disciplines by attunement for tab rendering
|
||||
const attunementTabs: {
|
||||
label: string;
|
||||
items: DisciplineDefinition[];
|
||||
}[] = [
|
||||
{ label: 'Base', items: baseDisciplines },
|
||||
{ label: 'Enchanter', items: enchanterDisciplines },
|
||||
{ label: 'Fabricator', items: fabricatorDisciplines },
|
||||
{ label: 'Invoker', items: invokerDisciplines },
|
||||
];
|
||||
|
||||
// Helper to render a single discipline card
|
||||
const DisciplineCard: React.FC<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
xp: number;
|
||||
perkThresholds?: number[];
|
||||
perkValues?: number[];
|
||||
perkTypes?: string[];
|
||||
statBonus: string;
|
||||
baseValue: number;
|
||||
drainBase: number;
|
||||
difficultyFactor: number;
|
||||
scalingFactor: number;
|
||||
}> = ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
xp,
|
||||
perkThresholds,
|
||||
perkValues,
|
||||
perkTypes,
|
||||
statBonus,
|
||||
baseValue,
|
||||
drainBase,
|
||||
difficultyFactor,
|
||||
scalingFactor,
|
||||
}) => {
|
||||
if (!mounted) return null;
|
||||
|
||||
const state = useDisciplineStore().getState();
|
||||
const currentDisc = state.disciplines[id] ?? { xp: 0, paused: true };
|
||||
const isActive = activeIds.includes(id);
|
||||
const canActivate = concurrentLimit > activeIds.filter(a => state.disciplines[a]?.paused !== true).length;
|
||||
|
||||
// Calculate displayed stats
|
||||
const displayXp = currentDisc.xp;
|
||||
const progressPercent = Math.min(displayXp / Math.max(1, (concurrentLimit * 100) ?? 1), 100);
|
||||
const isPaused = currentDisc.paused;
|
||||
const hasPendingPerk = perkThresholds?.some((t, i) => displayXp >= t && perkTypes?.[i] !== 'capped');
|
||||
|
||||
const activeStatBonus = calculateStatBonus(
|
||||
parseInt(baseValue) || 0,
|
||||
displayXp,
|
||||
scalingFactor
|
||||
);
|
||||
|
||||
// Simple visual for drain per tick
|
||||
const estimatedDrain = calculateManaDrain(
|
||||
drainBase,
|
||||
displayXp,
|
||||
difficultyFactor
|
||||
);
|
||||
|
||||
// Determine unlocked perks
|
||||
const unlockedPerks = perkTypes?.reduce<string[]>((acc, typ, idx) => {
|
||||
if (typ === 'once' || typ === 'infinite') {
|
||||
if (displayXp >= perkThresholds?.[idx] ?? 0) {
|
||||
acc.push(`${typ}-${idx}`);
|
||||
}
|
||||
} else if (typ === 'capped') {
|
||||
const tier = Math.max(0, Math.floor((displayXp - perkThresholds?.[idx] ?? 0) / perkValues?.[idx] ?? 1) + 1);
|
||||
if (tier > 0) acc.push(`${typ}-${idx}`);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Helper to decide button action
|
||||
const toggleAction = () => {
|
||||
if (isPaused) {
|
||||
// Resume – activate
|
||||
const storeDispatch = useDisciplineStore().getState().activate as any;
|
||||
storeDispatch(id);
|
||||
} else {
|
||||
// Pause – deactivate
|
||||
const storeDispatch = useDisciplineStore().getState().deactivate as any;
|
||||
storeDispatch(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={id} className="border rounded-lg p-4 shadow-sm space-y-3">
|
||||
<h3 className="text-lg font-medium">{name}</h3>
|
||||
<p className="text-sm text-gray-400">{description}</p>
|
||||
|
||||
<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={`bg-blue-500 transition-all duration-300 ${
|
||||
activeStatBonus > 0 ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
<strong>Drain:</strong> {estimatedDrain.toFixed(1)} ✦{' '}
|
||||
<strong>XP:</strong> {displayXp}
|
||||
</div>
|
||||
|
||||
{/* Bonus display */}
|
||||
<div className="mt-2 text-sm">
|
||||
<strong>Stat Bonus:</strong> {activeStatBonus.toFixed(2)} on {statBonus}
|
||||
</div>
|
||||
|
||||
{/* Perks */}
|
||||
<div className="mt-2">
|
||||
<strong>Perks:</strong>
|
||||
<ul className="mt-1 list-disc list-inside space-y-1 text-xs">
|
||||
{unlockedPerks?.map((p) => (
|
||||
<li key={p} className="text-green-500">{p.replace(/-([0-9]+)$/, ' $1')}</li>
|
||||
)) : (
|
||||
<li className="text-gray-400">—locked—</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={toggleAction}
|
||||
className={clsx(
|
||||
'rounded px-3 py-1 text-sm font-medium',
|
||||
isPaused
|
||||
? 'bg-yellow-600 text-white hover:bg-yellow-500'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-500',
|
||||
)}
|
||||
>
|
||||
{isPaused ? 'Activate' : 'Pause'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading disciplines…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{attunementTabs.map((tab) => {
|
||||
const isActiveTab = store
|
||||
.getState()
|
||||
.activeAttunement === tab.label.toLowerCase();
|
||||
return (
|
||||
<button
|
||||
key={tab.label}
|
||||
onClick={() =>
|
||||
// Here you could dispatch an action to switch tabs if needed
|
||||
// For simplicity, we just render the tabs
|
||||
console.log(`Switch to ${tab.label}`);
|
||||
}
|
||||
className={clsx('rounded px-3 py-1', {
|
||||
'bg-blue-600 text-white': tab.label === 'Base', // highlight first for demo
|
||||
'text-gray-600': tab.label !== 'Base',
|
||||
})}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Discipline cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{attunementTabs
|
||||
.map((tab) =>
|
||||
tab.items.map((disc) => {
|
||||
const state = useDisciplineStore().getState();
|
||||
const discState = state.disciplines[disc.id] ?? { xp: 0, paused: true };
|
||||
const isActive = activeIds.includes(disc.id);
|
||||
const isVisible = attunementTabs.find((t) => t.label === tab.label)?.items === tab.items;
|
||||
return isVisible && (
|
||||
<DisciplineCard
|
||||
key={disc.id}
|
||||
id={disc.id}
|
||||
name={disc.name}
|
||||
description={disc.description}
|
||||
xp={discState.xp}
|
||||
perkThresholds={disc.perks?.map((p) => p.threshold)}
|
||||
perkValues={disc.perks?.map((p) => p.value)}
|
||||
perkTypes={disc.perks?.map((p) => p.type)}
|
||||
statBonus={disc.statBonus}
|
||||
baseValue={disc.statBonus.baseValue?.toString() ?? '0'}
|
||||
drainBase={disc.drainBase}
|
||||
difficultyFactor={disc.difficultyFactor}
|
||||
scalingFactor={disc.scalingFactor}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)
|
||||
.flat()
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Summary info */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row gap-2 text-sm text-gray-500">
|
||||
<div>Active Disciple{activeIds.length}{activeIds.length === 1 ? '' : 's'} / {concurrentLimit}</div>
|
||||
<div>Concurrent Limit: {concurrentLimit}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user