370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { ELEMENTS } from '@/lib/game/constants';
|
|
import type { GuardianDef, GuardianBoon } from '@/lib/game/types';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Shield, Swords, Clock, Sparkles, Check, Lock, ChevronRight, Heart, Hexagon } from 'lucide-react';
|
|
import clsx from 'clsx';
|
|
import { DebugName } from '@/components/game/debug/debug-context';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
export type GuardianStatus = 'undefeated' | 'defeated' | 'signed';
|
|
|
|
export interface FloorTier {
|
|
label: string;
|
|
floors: number[];
|
|
}
|
|
|
|
// ─── Element Display Helper ──────────────────────────────────────────────────
|
|
|
|
interface ElementDisplay {
|
|
sym: string;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
function getElementDisplays(element: string | string[]): ElementDisplay[] {
|
|
const parts = Array.isArray(element) ? element : element.split('+');
|
|
return parts.map((el) => {
|
|
const def = ELEMENTS[el];
|
|
return {
|
|
sym: def?.sym ?? '?',
|
|
name: def?.name ?? el,
|
|
color: def?.color ?? '#888',
|
|
};
|
|
});
|
|
}
|
|
|
|
// ─── Guardian Card ───────────────────────────────────────────────────────────
|
|
|
|
interface GuardianCardProps {
|
|
floor: number;
|
|
guardian: GuardianDef;
|
|
status: GuardianStatus;
|
|
canAfford: boolean;
|
|
hasSlot: boolean;
|
|
isRitualActive: boolean;
|
|
ritualProgress: number;
|
|
onStartRitual: (floor: number) => void;
|
|
}
|
|
|
|
export const GuardianCard: React.FC<GuardianCardProps> = React.memo(({
|
|
floor,
|
|
guardian,
|
|
status,
|
|
canAfford,
|
|
hasSlot,
|
|
isRitualActive,
|
|
ritualProgress,
|
|
onStartRitual,
|
|
}) => {
|
|
const elemDisplays = getElementDisplays(guardian.element);
|
|
const primaryColor = elemDisplays[0]?.color ?? '#888';
|
|
const isCombo = elemDisplays.length > 1;
|
|
|
|
const statusConfig: Record<GuardianStatus, { label: string; color: string; bg: string }> = {
|
|
undefeated: { label: 'Undefeated', color: 'text-gray-400', bg: 'bg-gray-800/50' },
|
|
defeated: { label: 'Pact Available', color: 'text-amber-400', bg: 'bg-amber-900/20' },
|
|
signed: { label: 'Pact Signed', color: 'text-green-400', bg: 'bg-green-900/20' },
|
|
};
|
|
|
|
const sc = statusConfig[status];
|
|
const ritualTime = guardian.pactTime;
|
|
const ritualComplete = ritualProgress >= ritualTime;
|
|
|
|
// Build element label: single element name, or "Fire + Water" for combos
|
|
const elementLabel = isCombo
|
|
? elemDisplays.map(e => e.name).join(' + ')
|
|
: elemDisplays[0]?.name ?? guardian.element.join(' + ');
|
|
|
|
return (
|
|
<DebugName name="GuardianPactsComponents">
|
|
<Card
|
|
className={clsx(
|
|
'border transition-colors',
|
|
status === 'signed' && 'border-green-600/40',
|
|
status === 'defeated' && 'border-amber-600/40',
|
|
status === 'undefeated' && 'border-gray-700/60',
|
|
)}
|
|
>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<CardTitle className="text-sm flex items-center gap-2" style={{ color: primaryColor }}>
|
|
<span className="flex items-center gap-0.5">
|
|
{elemDisplays.map((e, i) => (
|
|
<span key={i}>{e.sym}</span>
|
|
))}
|
|
</span>
|
|
<span className="truncate">{guardian.name}</span>
|
|
</CardTitle>
|
|
<div className="text-xs text-gray-500 mt-0.5">
|
|
Floor {floor} · {elementLabel}
|
|
{isCombo && <span className="ml-1 text-purple-400">✦ Combo</span>}
|
|
</div>
|
|
</div>
|
|
<Badge className={clsx('text-[10px] px-1.5 py-0 shrink-0', sc.bg, sc.color)}>
|
|
{sc.label}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-3">
|
|
<GuardianStats guardian={guardian} />
|
|
<GuardianBoons guardian={guardian} />
|
|
|
|
<div className="text-xs text-gray-400">
|
|
<span className="text-gray-500">Perk:</span> {guardian.uniquePerk}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 text-xs text-gray-400">
|
|
<div className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
<span>{ritualTime}h</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-gray-500">Cost:</span> {guardian.pactCost.toLocaleString()} mana
|
|
</div>
|
|
</div>
|
|
|
|
{isRitualActive && (
|
|
<RitualProgress ritualProgress={ritualProgress} ritualTime={ritualTime} ritualComplete={ritualComplete} />
|
|
)}
|
|
|
|
{status === 'defeated' && !isRitualActive && (
|
|
<PactActionButton
|
|
canAfford={canAfford}
|
|
hasSlot={hasSlot}
|
|
onStartRitual={() => onStartRitual(floor)}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</DebugName>
|
|
);
|
|
});
|
|
|
|
GuardianCard.displayName = 'GuardianCard';
|
|
|
|
// ─── Guardian Stats ──────────────────────────────────────────────────────────
|
|
|
|
function GuardianStats({ guardian }: { guardian: GuardianDef }) {
|
|
const hasShield = !!(guardian.shield && guardian.shield > 0);
|
|
const hasBarrier = !!(guardian.barrier && guardian.barrier > 0);
|
|
const hasHealthRegen = !!(guardian.healthRegen && guardian.healthRegen > 0);
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
<div className="flex items-center gap-1 text-gray-400">
|
|
<Shield className="w-3 h-3" />
|
|
<span>Health: {guardian.hp.toLocaleString()}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 text-gray-400">
|
|
<Swords className="w-3 h-3" />
|
|
<span>Power: {guardian.power.toLocaleString()}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 text-gray-400">
|
|
<Shield className="w-3 h-3" />
|
|
<span>Armor: {Math.round((guardian.armor ?? 0) * 100)}%</span>
|
|
</div>
|
|
</div>
|
|
{(hasShield || hasBarrier || hasHealthRegen) && (
|
|
<div className="grid grid-cols-3 gap-2 text-xs border-t border-gray-700/40 pt-1.5">
|
|
{hasShield && (
|
|
<div className="flex items-center gap-1 text-cyan-400">
|
|
<Hexagon className="w-3 h-3" />
|
|
<span>Shield: {guardian.shield!.toLocaleString()}</span>
|
|
</div>
|
|
)}
|
|
{hasBarrier && (
|
|
<div className="flex items-center gap-1 text-blue-400">
|
|
<Shield className="w-3 h-3" />
|
|
<span>Barrier: {Math.round(guardian.barrier! * 100)}%</span>
|
|
</div>
|
|
)}
|
|
{hasHealthRegen && (
|
|
<div className="flex items-center gap-1 text-green-400">
|
|
<Heart className="w-3 h-3" />
|
|
<span>Regen: {guardian.healthRegenIsPercent ? guardian.healthRegen + '%/tick' : guardian.healthRegen + '/tick'}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Guardian Boons ──────────────────────────────────────────────────────────
|
|
|
|
function GuardianBoons({ guardian }: { guardian: GuardianDef }) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="text-xs font-medium text-gray-300 flex items-center gap-1">
|
|
<Sparkles className="w-3 h-3" /> Boons
|
|
</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{guardian.boons.map((boon: GuardianBoon, i: number) => (
|
|
<span
|
|
key={i}
|
|
className="px-1.5 py-0.5 text-[10px] rounded border border-gray-600/50 text-gray-300"
|
|
>
|
|
{boon.desc}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Ritual Progress ─────────────────────────────────────────────────────────
|
|
|
|
function RitualProgress({ ritualProgress, ritualTime, ritualComplete }: { ritualProgress: number; ritualTime: number; ritualComplete: boolean }) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between text-xs">
|
|
<span className="text-amber-400">Ritual in progress…</span>
|
|
<span className="text-gray-400">{ritualProgress}/{ritualTime}h</span>
|
|
</div>
|
|
<div className="w-full bg-gray-700 rounded-full h-1.5">
|
|
<div
|
|
className="bg-amber-500 h-1.5 rounded-full transition-all"
|
|
style={{ width: `${Math.min(100, (ritualProgress / ritualTime) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
{ritualComplete && (
|
|
<div className="text-xs text-green-400 flex items-center gap-1">
|
|
<Check className="w-3 h-3" /> Ritual complete — pact will be signed on next tick
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Pact Action Button ──────────────────────────────────────────────────────
|
|
|
|
function PactActionButton({ canAfford, hasSlot, onStartRitual }: { canAfford: boolean; hasSlot: boolean; onStartRitual: () => void }) {
|
|
const disabled = !canAfford || !hasSlot;
|
|
return (
|
|
<button
|
|
onClick={onStartRitual}
|
|
disabled={disabled}
|
|
className={clsx(
|
|
'w-full rounded px-3 py-1.5 text-xs font-medium transition-colors flex items-center justify-center gap-1',
|
|
!disabled
|
|
? 'bg-amber-600/80 text-white hover:bg-amber-500'
|
|
: 'bg-gray-700 text-gray-500 cursor-not-allowed',
|
|
)}
|
|
>
|
|
{!canAfford ? (
|
|
<><Lock className="w-3 h-3" /> Not enough mana</>
|
|
) : !hasSlot ? (
|
|
<><Lock className="w-3 h-3" /> No pact slots</>
|
|
) : (
|
|
<><ChevronRight className="w-3 h-3" /> Begin Pact Ritual</>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ─── Pact Header Summary ────────────────────────────────────────────────────
|
|
|
|
export function PactHeaderSummary({
|
|
signedCount,
|
|
pactSlots,
|
|
defeatedCount,
|
|
cumulativeBoons,
|
|
}: {
|
|
signedCount: number;
|
|
pactSlots: number;
|
|
defeatedCount: number;
|
|
cumulativeBoons: Record<string, number>;
|
|
}) {
|
|
return (
|
|
<Card className="bg-[var(--bg-panel)] border-[var(--border-subtle)]">
|
|
<CardContent className="py-3">
|
|
<div className="flex flex-wrap items-center gap-4 text-xs">
|
|
<div className="flex items-center gap-1.5">
|
|
<Shield className="w-3.5 h-3.5 text-amber-400" />
|
|
<span className="text-gray-400">Pact Slots:</span>
|
|
<span className="text-gray-200">{signedCount} / {pactSlots}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Check className="w-3.5 h-3.5 text-green-400" />
|
|
<span className="text-gray-400">Signed:</span>
|
|
<span className="text-green-400">{signedCount}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Swords className="w-3.5 h-3.5 text-red-400" />
|
|
<span className="text-gray-400">Defeated:</span>
|
|
<span className="text-red-400">{defeatedCount}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{signedCount > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-gray-700/50">
|
|
<div className="text-xs text-gray-400 mb-1">Active Boon Effects:</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{Object.entries(cumulativeBoons).map(([type, value]) => (
|
|
<span
|
|
key={type}
|
|
className="px-1.5 py-0.5 text-[10px] rounded border border-green-600/30 text-green-300 bg-green-900/20"
|
|
>
|
|
{type}: +{value}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Tier Filter ─────────────────────────────────────────────────────────────
|
|
|
|
export function TierFilter({
|
|
tiers,
|
|
activeTier,
|
|
guardianFloors,
|
|
onSelectTier,
|
|
}: {
|
|
tiers: FloorTier[];
|
|
activeTier: string;
|
|
guardianFloors: number[];
|
|
onSelectTier: (tier: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex gap-2 flex-wrap">
|
|
<button
|
|
onClick={() => onSelectTier('all')}
|
|
className={clsx(
|
|
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
|
activeTier === 'all'
|
|
? 'bg-amber-600 text-white'
|
|
: 'text-gray-400 hover:text-gray-200',
|
|
)}
|
|
>
|
|
All ({guardianFloors.length})
|
|
</button>
|
|
{tiers.map((tier) => (
|
|
<button
|
|
key={tier.label}
|
|
onClick={() => onSelectTier(tier.label)}
|
|
className={clsx(
|
|
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
|
activeTier === tier.label
|
|
? 'bg-amber-600 text-white'
|
|
: 'text-gray-400 hover:text-gray-200',
|
|
)}
|
|
>
|
|
{tier.label} ({tier.floors.length})
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|