93ffa0768b
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m2s
- Fix Fabricator golem-2 capped perk interval from 250 to 500 (spec match) - Update golem-1 description to 'Unlock golem summoning' (spec match)
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useAttunementStore, usePrestigeStore } 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 { Button } from '@/components/ui/button';
|
|
import { DebugName } from '@/components/game/debug/debug-context';
|
|
import { fmt } from '@/lib/game/stores';
|
|
import { Unlock } from 'lucide-react';
|
|
|
|
// ─── 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;
|
|
}
|
|
|
|
/**
|
|
* Check whether an attunement's unlock condition is met.
|
|
* Evaluates the condition based on current game state.
|
|
*/
|
|
function isUnlockConditionMet(id: string, defeatedGuardians: number[]): boolean {
|
|
switch (id) {
|
|
case 'invoker':
|
|
return defeatedGuardians.includes(10);
|
|
case 'fabricator':
|
|
return false; // No specific gating condition implemented
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── Attunement Card ─────────────────────────────────────────────────────────
|
|
|
|
interface AttunementCardProps {
|
|
def: AttunementDef;
|
|
state?: AttunementState;
|
|
canUnlock?: boolean;
|
|
onUnlock?: (id: string) => void;
|
|
}
|
|
|
|
function AttunementCard({ def, state, canUnlock, onUnlock }: AttunementCardProps) {
|
|
const unlocked = !!state;
|
|
const isStarting = def.unlocked === true;
|
|
const xpProgress = state ? getXpProgress(state) : 0;
|
|
const nextXp = state ? getXpForNextLevel(state.level) : 0;
|
|
|
|
// Style tokens derived from def.color
|
|
const color = def.color;
|
|
|
|
return (
|
|
<Card
|
|
className={`relative overflow-hidden ${
|
|
unlocked
|
|
? 'bg-gray-900/60'
|
|
: 'bg-gray-950/80'
|
|
}`}
|
|
style={{
|
|
borderLeft: `3px solid ${unlocked ? color : `${color}33`}`,
|
|
borderColor: unlocked ? `${color}88` : `${color}22`,
|
|
opacity: unlocked ? 1 : 0.55,
|
|
}}
|
|
>
|
|
{/* Starting badge (top-right ribbon) */}
|
|
{isStarting && unlocked && (
|
|
<div
|
|
className="absolute top-3 right-3 text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
|
style={{ backgroundColor: `${color}22`, color }}
|
|
>
|
|
Starting
|
|
</div>
|
|
)}
|
|
|
|
{/* Locked overlay pattern */}
|
|
{!unlocked && (
|
|
<div className="absolute inset-0 pointer-events-none" style={{ background: `repeating-linear-gradient(45deg, transparent, transparent 12px, ${color}08 12px, ${color}08 24px)` }} />
|
|
)}
|
|
|
|
<CardContent className="p-4 space-y-3 relative">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2.5">
|
|
<span
|
|
className="text-xl p-1 rounded"
|
|
style={{ backgroundColor: `${color}18` }}
|
|
>
|
|
{def.icon}
|
|
</span>
|
|
<div>
|
|
<h3
|
|
className="font-semibold"
|
|
style={{ color: unlocked ? color : `${color}99` }}
|
|
>
|
|
{def.name}
|
|
</h3>
|
|
<p className="text-xs text-gray-500">
|
|
{ATTUNEMENT_SLOT_NAMES[def.slot] ?? def.slot}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{unlocked ? (
|
|
<Badge
|
|
className="text-xs font-bold"
|
|
style={{ backgroundColor: `${color}25`, color, border: `1px solid ${color}44` }}
|
|
>
|
|
Lv.{state.level}
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs"
|
|
style={{ borderColor: `${color}44`, color: `${color}88` }}
|
|
>
|
|
🔒 Locked
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<p className={`text-xs leading-relaxed ${unlocked ? 'text-gray-400' : 'text-gray-600'}`}>{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>
|
|
<div className="h-2 rounded-full overflow-hidden bg-gray-800">
|
|
<div
|
|
className="h-full rounded-full transition-all"
|
|
style={{ width: `${xpProgress}%`, backgroundColor: color }}
|
|
/>
|
|
</div>
|
|
{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 italic pt-2"
|
|
style={{ color: `${color}77`, borderTop: `1px solid ${color}15` }}
|
|
>
|
|
🔒 {def.unlockCondition}
|
|
</div>
|
|
)}
|
|
|
|
{/* Unlock button (locked + condition met) */}
|
|
{!unlocked && canUnlock && onUnlock && (
|
|
<div className="pt-2" style={{ borderTop: `1px solid ${color}15` }}>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="w-full text-xs"
|
|
style={{ borderColor: `${color}66`, color }}
|
|
onClick={() => onUnlock(def.id)}
|
|
>
|
|
<Unlock className="w-3 h-3 mr-1" /> Unlock {def.name}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Details grid */}
|
|
<div
|
|
className="grid grid-cols-2 gap-2 text-xs pt-3"
|
|
style={{ borderTop: `1px solid ${unlocked ? `${color}22` : `${color}10`}` }}
|
|
>
|
|
<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 style={{ color: state?.active ? '#4ade80' : unlocked ? `${color}aa` : '#6b7280' }}>
|
|
{state?.active ? '● Active' : unlocked ? '○ Inactive' : 'Locked'}
|
|
</p>
|
|
</div>
|
|
{/* Invoker special: pact-based note */}
|
|
{def.primaryManaType === undefined && (
|
|
<div className="col-span-2">
|
|
<span className="text-gray-500">Special</span>
|
|
<p style={{ color: `${color}cc` }}>
|
|
Gains elemental mana from each guardian pact signed
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Capabilities */}
|
|
<div style={{ borderTop: `1px solid ${unlocked ? `${color}22` : `${color}10`}` }} className="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="text-[10px]"
|
|
style={{
|
|
borderColor: `${color}44`,
|
|
color: unlocked ? `${color}cc` : `${color}66`,
|
|
backgroundColor: `${color}0a`,
|
|
}}
|
|
>
|
|
{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="text-[10px]"
|
|
style={{
|
|
borderColor: `${color}33`,
|
|
color: unlocked ? `${color}aa` : `${color}55`,
|
|
backgroundColor: `${color}08`,
|
|
}}
|
|
>
|
|
{cat}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Main Component ──────────────────────────────────────────────────────────
|
|
|
|
export function AttunementsTab() {
|
|
const attunements = useAttunementStore((s) => s.attunements);
|
|
const unlockAttunement = useAttunementStore((s) => s.unlockAttunement);
|
|
const defeatedGuardians = usePrestigeStore((s) => s.defeatedGuardians);
|
|
|
|
const allDefs = Object.values(ATTUNEMENTS_DEF);
|
|
const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length;
|
|
|
|
const handleUnlock = (id: string) => {
|
|
const prestigeState = usePrestigeStore.getState();
|
|
const success = unlockAttunement(id, prestigeState.defeatedGuardians);
|
|
if (!success) {
|
|
console.warn(`Failed to unlock attunement: ${id}`);
|
|
}
|
|
};
|
|
|
|
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" style={{ color: '#1ABC9C' }}>
|
|
{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 */}
|
|
<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]}
|
|
canUnlock={isUnlockConditionMet(def.id, defeatedGuardians)}
|
|
onUnlock={handleUnlock}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</DebugName>
|
|
);
|
|
}
|
|
|
|
AttunementsTab.displayName = 'AttunementsTab';
|