feat: recreate Attunements tab with detailed attunement cards
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAttunementStore } from '@/lib/game/stores';
|
||||
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements';
|
||||
import type { AttunementDef, AttunementState } from '@/lib/game/types';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { DebugName } from '@/components/game/debug/debug-context';
|
||||
import { fmt } from '@/lib/game/stores';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function getXpForNextLevel(level: number): number {
|
||||
if (level >= MAX_ATTUNEMENT_LEVEL) return 0;
|
||||
return getAttunementXPForLevel(level + 1);
|
||||
}
|
||||
|
||||
function getXpProgress(state: AttunementState): number {
|
||||
const nextXp = getXpForNextLevel(state.level);
|
||||
if (nextXp <= 0) return 100;
|
||||
return Math.min(100, Math.round((state.experience / nextXp) * 100));
|
||||
}
|
||||
|
||||
function isAttunementUnlocked(id: string, attunements: Record<string, AttunementState>): boolean {
|
||||
return id in attunements;
|
||||
}
|
||||
|
||||
// ─── Attunement Card ─────────────────────────────────────────────────────────
|
||||
|
||||
interface AttunementCardProps {
|
||||
def: AttunementDef;
|
||||
state?: AttunementState;
|
||||
}
|
||||
|
||||
function AttunementCard({ def, state }: AttunementCardProps) {
|
||||
const unlocked = !!state;
|
||||
const xpProgress = state ? getXpProgress(state) : 0;
|
||||
const nextXp = state ? getXpForNextLevel(state.level) : 0;
|
||||
|
||||
return (
|
||||
<Card className={`bg-gray-900/60 ${unlocked ? 'border-gray-700' : 'border-gray-800 opacity-60'}`}>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{def.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-100">{def.name}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{ATTUNEMENT_SLOT_NAMES[def.slot] ?? def.slot}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{unlocked ? (
|
||||
<Badge className="bg-teal-900/50 text-teal-300 text-xs">
|
||||
Lv.{state.level}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-gray-700 text-gray-500 text-xs">
|
||||
Locked
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{def.desc}</p>
|
||||
|
||||
{/* XP Progress (unlocked only) */}
|
||||
{unlocked && state && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">XP Progress</span>
|
||||
<span className="text-gray-400 font-mono">
|
||||
{fmt(state.experience)} / {fmt(nextXp)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={xpProgress} className="h-2" />
|
||||
{state.level >= MAX_ATTUNEMENT_LEVEL && (
|
||||
<p className="text-xs text-amber-400 italic">Maximum level reached</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unlock condition (locked only) */}
|
||||
{!unlocked && def.unlockCondition && (
|
||||
<div className="text-xs text-gray-500 italic border-t border-gray-800 pt-2">
|
||||
🔒 {def.unlockCondition}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs border-t border-gray-800 pt-3">
|
||||
<div>
|
||||
<span className="text-gray-500">Mana Type</span>
|
||||
<p className="text-gray-300 capitalize">
|
||||
{def.primaryManaType ?? 'None (pact-based)'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Raw Regen</span>
|
||||
<p className="text-gray-300">+{def.rawManaRegen}/hr</p>
|
||||
</div>
|
||||
{def.conversionRate > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-500">Conversion</span>
|
||||
<p className="text-gray-300">{def.conversionRate}/hr</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">Status</span>
|
||||
<p className={state?.active ? 'text-green-400' : 'text-gray-500'}>
|
||||
{state?.active ? 'Active' : unlocked ? 'Inactive' : 'Locked'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div className="border-t border-gray-800 pt-3">
|
||||
<span className="text-xs text-gray-500 block mb-1.5">Capabilities</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{def.capabilities.map((cap) => (
|
||||
<Badge
|
||||
key={cap}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-400 text-[10px]"
|
||||
>
|
||||
{cap}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill Categories */}
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block mb-1.5">Skill Categories</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{def.skillCategories.map((cat) => (
|
||||
<Badge
|
||||
key={cat}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-400 text-[10px]"
|
||||
>
|
||||
{cat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function AttunementsTab() {
|
||||
const attunements = useAttunementStore((s) => s.attunements);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const allDefs = Object.values(ATTUNEMENTS_DEF);
|
||||
const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length;
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||
Loading attunements…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DebugName name="AttunementsTab">
|
||||
<div className="space-y-4">
|
||||
{/* Summary header */}
|
||||
<Card className="bg-gray-900/60 border-gray-700">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-100">Attunements</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Class-like abilities tied to body locations
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-teal-400">
|
||||
{unlockedCount}
|
||||
<span className="text-sm text-gray-500 font-normal">
|
||||
/{allDefs.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Unlocked</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Attunement cards */}
|
||||
<ScrollArea className="h-[600px] pr-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{allDefs.map((def) => (
|
||||
<AttunementCard
|
||||
key={def.id}
|
||||
def={def}
|
||||
state={attunements[def.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
AttunementsTab.displayName = 'AttunementsTab';
|
||||
Reference in New Issue
Block a user