Remove familiar system to focus on guardian pacts
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m11s
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m11s
- Remove familiar-slice.ts, familiars.ts data file, and FamiliarTab.tsx - Remove all familiar types from types.ts (FamiliarRole, FamiliarAbilityType, etc.) - Remove familiar state from GameState interface - Remove familiar bonuses from combat calculations - Remove familiar XP tracking from tick function - Remove familiar tab from page.tsx UI This simplifies the game to focus on guardian pacts as the primary progression mechanic, avoiding the parallel familiar system that diluted the importance of pacts.
This commit is contained in:
@@ -13,7 +13,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { RotateCcw } from 'lucide-react';
|
import { RotateCcw } from 'lucide-react';
|
||||||
import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab } from '@/components/game/tabs';
|
import { CraftingTab, SpireTab, SpellsTab, LabTab, SkillsTab, StatsTab } from '@/components/game/tabs';
|
||||||
import { FamiliarTab } from '@/components/game/tabs/FamiliarTab';
|
|
||||||
import { ComboMeter, ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
|
import { ComboMeter, ActionButtons, CalendarDisplay, ManaDisplay, TimeDisplay } from '@/components/game';
|
||||||
import { LootInventoryDisplay } from '@/components/game/LootInventory';
|
import { LootInventoryDisplay } from '@/components/game/LootInventory';
|
||||||
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
|
import { AchievementsDisplay } from '@/components/game/AchievementsDisplay';
|
||||||
@@ -227,13 +226,12 @@ export default function ManaLoopGame() {
|
|||||||
{/* Right Panel - Tabs */}
|
{/* Right Panel - Tabs */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid grid-cols-8 w-full mb-4">
|
<TabsList className="grid grid-cols-7 w-full mb-4">
|
||||||
<TabsTrigger value="spire">⚔️ Spire</TabsTrigger>
|
<TabsTrigger value="spire">⚔️ Spire</TabsTrigger>
|
||||||
<TabsTrigger value="skills">📚 Skills</TabsTrigger>
|
<TabsTrigger value="skills">📚 Skills</TabsTrigger>
|
||||||
<TabsTrigger value="spells">✨ Spells</TabsTrigger>
|
<TabsTrigger value="spells">✨ Spells</TabsTrigger>
|
||||||
<TabsTrigger value="crafting">🔧 Craft</TabsTrigger>
|
<TabsTrigger value="crafting">🔧 Craft</TabsTrigger>
|
||||||
<TabsTrigger value="lab">🔬 Lab</TabsTrigger>
|
<TabsTrigger value="lab">🔬 Lab</TabsTrigger>
|
||||||
<TabsTrigger value="familiar">🐾 Familiar</TabsTrigger>
|
|
||||||
<TabsTrigger value="stats">📊 Stats</TabsTrigger>
|
<TabsTrigger value="stats">📊 Stats</TabsTrigger>
|
||||||
<TabsTrigger value="grimoire">📖 Grimoire</TabsTrigger>
|
<TabsTrigger value="grimoire">📖 Grimoire</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -266,10 +264,6 @@ export default function ManaLoopGame() {
|
|||||||
<LabTab store={store} />
|
<LabTab store={store} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="familiar">
|
|
||||||
<FamiliarTab store={store} />
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="stats">
|
<TabsContent value="stats">
|
||||||
<StatsTab
|
<StatsTab
|
||||||
store={store}
|
store={store}
|
||||||
|
|||||||
@@ -1,582 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
Sparkles, Heart, Zap, Star, Shield, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull,
|
|
||||||
Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, CircleDot, Circle,
|
|
||||||
Sword, Wand2, ShieldCheck, TrendingUp, Clock, Crown
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { GameState, FamiliarInstance, FamiliarDef, FamiliarAbilityType } from '@/lib/game/types';
|
|
||||||
import { FAMILIARS_DEF, getFamiliarXpRequired, getFamiliarAbilityValue } from '@/lib/game/data/familiars';
|
|
||||||
import { ELEMENTS } from '@/lib/game/constants';
|
|
||||||
|
|
||||||
// Element icon mapping
|
|
||||||
const ELEMENT_ICONS: Record<string, typeof Flame> = {
|
|
||||||
fire: Flame,
|
|
||||||
water: Droplet,
|
|
||||||
air: Wind,
|
|
||||||
earth: Mountain,
|
|
||||||
light: Sun,
|
|
||||||
dark: Moon,
|
|
||||||
life: Leaf,
|
|
||||||
death: Skull,
|
|
||||||
mental: Brain,
|
|
||||||
transference: Link,
|
|
||||||
force: Force,
|
|
||||||
blood: Droplets,
|
|
||||||
metal: Shield,
|
|
||||||
wood: TreeDeciduous,
|
|
||||||
sand: Hourglass,
|
|
||||||
crystal: Gem,
|
|
||||||
stellar: Star,
|
|
||||||
void: CircleDot,
|
|
||||||
raw: Circle,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rarity colors
|
|
||||||
const RARITY_COLORS: Record<string, string> = {
|
|
||||||
common: 'text-gray-400 border-gray-600',
|
|
||||||
uncommon: 'text-green-400 border-green-600',
|
|
||||||
rare: 'text-blue-400 border-blue-600',
|
|
||||||
epic: 'text-purple-400 border-purple-600',
|
|
||||||
legendary: 'text-amber-400 border-amber-600',
|
|
||||||
};
|
|
||||||
|
|
||||||
const RARITY_BG: Record<string, string> = {
|
|
||||||
common: 'bg-gray-900/50',
|
|
||||||
uncommon: 'bg-green-900/20',
|
|
||||||
rare: 'bg-blue-900/20',
|
|
||||||
epic: 'bg-purple-900/20',
|
|
||||||
legendary: 'bg-amber-900/20',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Role icons
|
|
||||||
const ROLE_ICONS: Record<string, typeof Sword> = {
|
|
||||||
combat: Sword,
|
|
||||||
mana: Sparkles,
|
|
||||||
support: Heart,
|
|
||||||
guardian: ShieldCheck,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ability type icons
|
|
||||||
const ABILITY_ICONS: Record<FamiliarAbilityType, typeof Zap> = {
|
|
||||||
damageBonus: Sword,
|
|
||||||
manaRegen: Sparkles,
|
|
||||||
autoGather: Zap,
|
|
||||||
critChance: Star,
|
|
||||||
castSpeed: Clock,
|
|
||||||
manaShield: Shield,
|
|
||||||
elementalBonus: Flame,
|
|
||||||
lifeSteal: Heart,
|
|
||||||
bonusGold: TrendingUp,
|
|
||||||
autoConvert: Wand2,
|
|
||||||
thorns: ShieldCheck,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FamiliarTabProps {
|
|
||||||
store: GameState & {
|
|
||||||
setActiveFamiliar: (index: number, active: boolean) => void;
|
|
||||||
setFamiliarNickname: (index: number, nickname: string) => void;
|
|
||||||
summonFamiliar: (familiarId: string) => void;
|
|
||||||
upgradeFamiliarAbility: (index: number, abilityType: FamiliarAbilityType) => void;
|
|
||||||
getActiveFamiliarBonuses: () => ReturnType<typeof import('@/lib/game/familiar-slice').createFamiliarSlice>['getActiveFamiliarBonuses'] extends () => infer R ? R : never;
|
|
||||||
getAvailableFamiliars: () => string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FamiliarTab({ store }: FamiliarTabProps) {
|
|
||||||
const [selectedFamiliar, setSelectedFamiliar] = useState<number | null>(null);
|
|
||||||
const [nicknameInput, setNicknameInput] = useState('');
|
|
||||||
|
|
||||||
const familiars = store.familiars;
|
|
||||||
const activeFamiliarSlots = store.activeFamiliarSlots;
|
|
||||||
const activeCount = familiars.filter(f => f.active).length;
|
|
||||||
const availableFamiliars = store.getAvailableFamiliars();
|
|
||||||
const familiarBonuses = store.getActiveFamiliarBonuses();
|
|
||||||
|
|
||||||
// Format XP display
|
|
||||||
const formatXp = (current: number, level: number) => {
|
|
||||||
const required = getFamiliarXpRequired(level);
|
|
||||||
return `${current}/${required}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get familiar definition
|
|
||||||
const getFamiliarDef = (instance: FamiliarInstance): FamiliarDef | undefined => {
|
|
||||||
return FAMILIARS_DEF[instance.familiarId];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render a single familiar card
|
|
||||||
const renderFamiliarCard = (instance: FamiliarInstance, index: number) => {
|
|
||||||
const def = getFamiliarDef(instance);
|
|
||||||
if (!def) return null;
|
|
||||||
|
|
||||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
|
||||||
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
|
||||||
const xpRequired = getFamiliarXpRequired(instance.level);
|
|
||||||
const xpPercent = Math.min(100, (instance.experience / xpRequired) * 100);
|
|
||||||
const bondPercent = instance.bond;
|
|
||||||
const isSelected = selectedFamiliar === index;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={`${instance.familiarId}-${index}`}
|
|
||||||
className={`cursor-pointer transition-all ${RARITY_BG[def.rarity]} ${
|
|
||||||
isSelected ? 'ring-2 ring-amber-500' : ''
|
|
||||||
} ${instance.active ? 'ring-1 ring-green-500/50' : ''} border ${RARITY_COLORS[def.rarity]}`}
|
|
||||||
onClick={() => setSelectedFamiliar(isSelected ? null : index)}
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-black/30 flex items-center justify-center">
|
|
||||||
<ElementIcon className="w-6 h-6" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle className={`text-sm ${RARITY_COLORS[def.rarity]}`}>
|
|
||||||
{instance.nickname || def.name}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-1 text-xs text-gray-400">
|
|
||||||
<RoleIcon className="w-3 h-3" />
|
|
||||||
<span>Lv.{instance.level}</span>
|
|
||||||
{instance.active && (
|
|
||||||
<Badge className="ml-1 bg-green-900/50 text-green-300 text-xs py-0">Active</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className={`${RARITY_COLORS[def.rarity]} text-xs`}>
|
|
||||||
{def.rarity}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{/* XP Bar */}
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="flex justify-between text-xs text-gray-400">
|
|
||||||
<span>XP</span>
|
|
||||||
<span>{formatXp(instance.experience, instance.level)}</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={xpPercent} className="h-1.5 bg-gray-800" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bond Bar */}
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="flex justify-between text-xs text-gray-400">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Heart className="w-3 h-3" /> Bond
|
|
||||||
</span>
|
|
||||||
<span>{bondPercent.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={bondPercent} className="h-1.5 bg-gray-800" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Abilities Preview */}
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
{instance.abilities.slice(0, 3).map(ability => {
|
|
||||||
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
|
||||||
if (!abilityDef) return null;
|
|
||||||
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
|
||||||
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={ability.type}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge variant="outline" className="text-xs py-0 flex items-center gap-1">
|
|
||||||
<AbilityIcon className="w-3 h-3" />
|
|
||||||
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
|
||||||
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
|
||||||
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
|
||||||
ability.type === 'bonusGold'
|
|
||||||
? `+${value.toFixed(1)}%`
|
|
||||||
: `+${value.toFixed(1)}`}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="text-sm">{abilityDef.desc}</p>
|
|
||||||
<p className="text-xs text-gray-400">Level {ability.level}/10</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render selected familiar details
|
|
||||||
const renderFamiliarDetails = () => {
|
|
||||||
if (selectedFamiliar === null || selectedFamiliar >= familiars.length) return null;
|
|
||||||
|
|
||||||
const instance = familiars[selectedFamiliar];
|
|
||||||
const def = getFamiliarDef(instance);
|
|
||||||
if (!def) return null;
|
|
||||||
|
|
||||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-amber-400 text-sm">Familiar Details</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={() => setSelectedFamiliar(null)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Name and nickname */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-black/30 flex items-center justify-center">
|
|
||||||
<ElementIcon className="w-8 h-8" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-lg font-bold ${RARITY_COLORS[def.rarity]}`}>
|
|
||||||
{def.name}
|
|
||||||
</h3>
|
|
||||||
{instance.nickname && (
|
|
||||||
<p className="text-sm text-gray-400">"{instance.nickname}"</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nickname input */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={nicknameInput}
|
|
||||||
onChange={(e) => setNicknameInput(e.target.value)}
|
|
||||||
placeholder="Set nickname..."
|
|
||||||
className="h-8 text-sm bg-gray-800 border-gray-600"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
store.setFamiliarNickname(selectedFamiliar, nicknameInput);
|
|
||||||
setNicknameInput('');
|
|
||||||
}}
|
|
||||||
disabled={!nicknameInput.trim()}
|
|
||||||
>
|
|
||||||
Set
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="text-sm text-gray-300 italic">
|
|
||||||
{def.desc}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-400">Level:</span>
|
|
||||||
<span className="text-white">{instance.level}/100</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-400">Bond:</span>
|
|
||||||
<span className="text-white">{instance.bond.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-400">Role:</span>
|
|
||||||
<span className="text-white capitalize">{def.role}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-400">Element:</span>
|
|
||||||
<span style={{ color: ELEMENTS[def.element]?.color }}>{def.element}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="bg-gray-700" />
|
|
||||||
|
|
||||||
{/* Abilities */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold text-gray-300">Abilities</h4>
|
|
||||||
{instance.abilities.map(ability => {
|
|
||||||
const abilityDef = def.abilities.find(a => a.type === ability.type);
|
|
||||||
if (!abilityDef) return null;
|
|
||||||
const AbilityIcon = ABILITY_ICONS[ability.type] || Zap;
|
|
||||||
const value = getFamiliarAbilityValue(abilityDef, instance.level, ability.level);
|
|
||||||
const upgradeCost = ability.level * 100;
|
|
||||||
const canUpgrade = instance.experience >= upgradeCost && ability.level < 10;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={ability.type} className="p-2 rounded bg-gray-800/50 border border-gray-700">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AbilityIcon className="w-4 h-4 text-amber-400" />
|
|
||||||
<span className="text-sm font-medium capitalize">
|
|
||||||
{ability.type.replace(/([A-Z])/g, ' $1').trim()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-xs">Lv.{ability.level}/10</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 mb-2">{abilityDef.desc}</p>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-green-400">
|
|
||||||
Current: +{value.toFixed(2)}
|
|
||||||
{ability.type === 'damageBonus' || ability.type === 'elementalBonus' ||
|
|
||||||
ability.type === 'castSpeed' || ability.type === 'critChance' ||
|
|
||||||
ability.type === 'lifeSteal' || ability.type === 'thorns' ||
|
|
||||||
ability.type === 'bonusGold' ? '%' : ''}
|
|
||||||
</span>
|
|
||||||
{ability.level < 10 && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-6 text-xs"
|
|
||||||
disabled={!canUpgrade}
|
|
||||||
onClick={() => store.upgradeFamiliarAbility(selectedFamiliar, ability.type)}
|
|
||||||
>
|
|
||||||
Upgrade ({upgradeCost} XP)
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Activate/Deactivate */}
|
|
||||||
<Button
|
|
||||||
className={`w-full ${instance.active ? 'bg-red-900/50 hover:bg-red-800/50' : 'bg-green-900/50 hover:bg-green-800/50'}`}
|
|
||||||
onClick={() => store.setActiveFamiliar(selectedFamiliar, !instance.active)}
|
|
||||||
disabled={!instance.active && activeCount >= activeFamiliarSlots}
|
|
||||||
>
|
|
||||||
{instance.active ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Flavor text */}
|
|
||||||
{def.flavorText && (
|
|
||||||
<p className="text-xs text-gray-500 italic text-center">
|
|
||||||
"{def.flavorText}"
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render summonable familiars
|
|
||||||
const renderSummonableFamiliars = () => {
|
|
||||||
if (availableFamiliars.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-cyan-400 text-sm flex items-center gap-2">
|
|
||||||
<Sparkles className="w-4 h-4" />
|
|
||||||
Available to Summon ({availableFamiliars.length})
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ScrollArea className="h-48">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{availableFamiliars.map(familiarId => {
|
|
||||||
const def = FAMILIARS_DEF[familiarId];
|
|
||||||
if (!def) return null;
|
|
||||||
|
|
||||||
const ElementIcon = ELEMENT_ICONS[def.element] || Circle;
|
|
||||||
const RoleIcon = ROLE_ICONS[def.role] || Sparkles;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={familiarId}
|
|
||||||
className={`p-2 rounded border ${RARITY_COLORS[def.rarity]} ${RARITY_BG[def.rarity]} flex items-center justify-between`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ElementIcon className="w-5 h-5" style={{ color: ELEMENTS[def.element]?.color || '#fff' }} />
|
|
||||||
<div>
|
|
||||||
<div className={`text-sm font-medium ${RARITY_COLORS[def.rarity]}`}>
|
|
||||||
{def.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 flex items-center gap-1">
|
|
||||||
<RoleIcon className="w-3 h-3" />
|
|
||||||
{def.role}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="h-7"
|
|
||||||
onClick={() => store.summonFamiliar(familiarId)}
|
|
||||||
>
|
|
||||||
Summon
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render active bonuses
|
|
||||||
const renderActiveBonuses = () => {
|
|
||||||
const hasBonuses = Object.entries(familiarBonuses).some(([key, value]) => {
|
|
||||||
if (key === 'damageMultiplier' || key === 'castSpeedMultiplier' ||
|
|
||||||
key === 'elementalDamageMultiplier' || key === 'insightMultiplier') {
|
|
||||||
return value > 1;
|
|
||||||
}
|
|
||||||
return value > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasBonuses) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-green-400 text-sm flex items-center gap-2">
|
|
||||||
<TrendingUp className="w-4 h-4" />
|
|
||||||
Active Familiar Bonuses
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 text-sm">
|
|
||||||
{familiarBonuses.damageMultiplier > 1 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Sword className="w-4 h-4 text-red-400" />
|
|
||||||
<span>+{((familiarBonuses.damageMultiplier - 1) * 100).toFixed(0)}% DMG</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{familiarBonuses.manaRegenBonus > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Sparkles className="w-4 h-4 text-blue-400" />
|
|
||||||
<span>+{familiarBonuses.manaRegenBonus.toFixed(1)} regen</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{familiarBonuses.autoGatherRate > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Zap className="w-4 h-4 text-yellow-400" />
|
|
||||||
<span>+{familiarBonuses.autoGatherRate.toFixed(1)}/hr gather</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{familiarBonuses.critChanceBonus > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Star className="w-4 h-4 text-amber-400" />
|
|
||||||
<span>+{familiarBonuses.critChanceBonus.toFixed(1)}% crit</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{familiarBonuses.castSpeedMultiplier > 1 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4 text-cyan-400" />
|
|
||||||
<span>+{((familiarBonuses.castSpeedMultiplier - 1) * 100).toFixed(0)}% speed</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{familiarBonuses.elementalDamageMultiplier > 1 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Flame className="w-4 h-4 text-orange-400" />
|
|
||||||
<span>+{((familiarBonuses.elementalDamageMultiplier - 1) * 100).toFixed(0)}% elem</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{familiarBonuses.lifeStealPercent > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Heart className="w-4 h-4 text-red-400" />
|
|
||||||
<span>+{familiarBonuses.lifeStealPercent.toFixed(0)}% lifesteal</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{familiarBonuses.insightMultiplier > 1 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TrendingUp className="w-4 h-4 text-purple-400" />
|
|
||||||
<span>+{((familiarBonuses.insightMultiplier - 1) * 100).toFixed(0)}% insight</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
{/* Owned Familiars */}
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-amber-400 text-sm flex items-center gap-2">
|
|
||||||
<Heart className="w-4 h-4" />
|
|
||||||
Your Familiars ({familiars.length})
|
|
||||||
</CardTitle>
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
Active Slots: {activeCount}/{activeFamiliarSlots}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{familiars.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{familiars.map((instance, index) => renderFamiliarCard(instance, index))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center p-8 text-gray-500">
|
|
||||||
No familiars yet. Progress through the game to summon companions!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Active Bonuses */}
|
|
||||||
{renderActiveBonuses()}
|
|
||||||
|
|
||||||
{/* Selected Familiar Details */}
|
|
||||||
{renderFamiliarDetails()}
|
|
||||||
|
|
||||||
{/* Summonable Familiars */}
|
|
||||||
{renderSummonableFamiliars()}
|
|
||||||
|
|
||||||
{/* Familiar Guide */}
|
|
||||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-purple-400 text-sm flex items-center gap-2">
|
|
||||||
<Crown className="w-4 h-4" />
|
|
||||||
Familiar Guide
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm text-gray-300">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-amber-400 mb-1">Acquiring Familiars</h4>
|
|
||||||
<p>Familiars become available to summon as you progress through floors, gather mana, and sign pacts with guardians. Higher rarity familiars are unlocked later.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-amber-400 mb-1">Leveling & Bond</h4>
|
|
||||||
<p>Active familiars gain XP from combat, gathering, and time. Higher bond increases their power and XP gain. Upgrade abilities using XP to boost their effects.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-amber-400 mb-1">Roles</h4>
|
|
||||||
<p>
|
|
||||||
<span className="text-red-400">Combat</span> - Damage and crit bonuses<br/>
|
|
||||||
<span className="text-blue-400">Mana</span> - Regeneration and auto-gathering<br/>
|
|
||||||
<span className="text-green-400">Support</span> - Speed and utility<br/>
|
|
||||||
<span className="text-amber-400">Guardian</span> - Defense and shields
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-amber-400 mb-1">Active Slots</h4>
|
|
||||||
<p>You can have 1 familiar active by default. Upgrade through prestige to unlock more active slots for stacking bonuses.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
// ─── Familiar Definitions ───────────────────────────────────────────────────────
|
|
||||||
// Magical companions that provide passive bonuses and active assistance
|
|
||||||
|
|
||||||
import type { FamiliarDef, FamiliarAbility } from '../types';
|
|
||||||
|
|
||||||
// ─── Familiar Abilities ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ABILITIES = {
|
|
||||||
// Combat abilities
|
|
||||||
damageBonus: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'damageBonus',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `+${base}% damage (+${scaling}% per level)`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
critChance: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'critChance',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `+${base}% crit chance (+${scaling}% per level)`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
castSpeed: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'castSpeed',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `+${base}% cast speed (+${scaling}% per level)`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
elementalBonus: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'elementalBonus',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `+${base}% elemental damage (+${scaling}% per level)`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
lifeSteal: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'lifeSteal',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `+${base}% life steal (+${scaling}% per level)`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
thorns: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'thorns',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `Reflect ${base}% damage taken (+${scaling}% per level)`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Mana abilities
|
|
||||||
manaRegen: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'manaRegen',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `+${base} mana regen (+${scaling} per level)`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
autoGather: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'autoGather',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `Auto-gather ${base} mana/hour (+${scaling} per level)`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
autoConvert: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'autoConvert',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `Auto-convert ${base} mana/hour (+${scaling} per level)`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
manaShield: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'manaShield',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `Shield absorbs ${base} damage, costs 1 mana per ${base} damage`,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// Support abilities
|
|
||||||
bonusGold: (base: number, scaling: number): FamiliarAbility => ({
|
|
||||||
type: 'bonusGold',
|
|
||||||
baseValue: base,
|
|
||||||
scalingPerLevel: scaling,
|
|
||||||
desc: `+${base}% insight gain (+${scaling}% per level)`,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Familiar Definitions ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const FAMILIARS_DEF: Record<string, FamiliarDef> = {
|
|
||||||
// === COMMON FAMILIARS (Tier 1) ===
|
|
||||||
|
|
||||||
// Mana Wisps - Basic mana helpers
|
|
||||||
manaWisp: {
|
|
||||||
id: 'manaWisp',
|
|
||||||
name: 'Mana Wisp',
|
|
||||||
desc: 'A gentle spirit of pure mana that drifts lazily through the air.',
|
|
||||||
role: 'mana',
|
|
||||||
element: 'raw',
|
|
||||||
rarity: 'common',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.manaRegen(0.5, 0.1),
|
|
||||||
],
|
|
||||||
baseStats: { power: 10, bond: 15 },
|
|
||||||
unlockCondition: { type: 'mana', value: 100 },
|
|
||||||
flavorText: 'It hums with quiet contentment, barely visible in dim light.',
|
|
||||||
},
|
|
||||||
|
|
||||||
fireSpark: {
|
|
||||||
id: 'fireSpark',
|
|
||||||
name: 'Fire Spark',
|
|
||||||
desc: 'A tiny ember given life, crackling with barely contained energy.',
|
|
||||||
role: 'combat',
|
|
||||||
element: 'fire',
|
|
||||||
rarity: 'common',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.damageBonus(2, 0.5),
|
|
||||||
],
|
|
||||||
baseStats: { power: 12, bond: 10 },
|
|
||||||
unlockCondition: { type: 'floor', value: 5 },
|
|
||||||
flavorText: 'It bounces excitedly, leaving scorch marks on everything it touches.',
|
|
||||||
},
|
|
||||||
|
|
||||||
waterDroplet: {
|
|
||||||
id: 'waterDroplet',
|
|
||||||
name: 'Water Droplet',
|
|
||||||
desc: 'A perfect sphere of living water that never seems to evaporate.',
|
|
||||||
role: 'support',
|
|
||||||
element: 'water',
|
|
||||||
rarity: 'common',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.manaRegen(0.3, 0.1),
|
|
||||||
ABILITIES.lifeSteal(1, 0.2),
|
|
||||||
],
|
|
||||||
baseStats: { power: 8, bond: 12 },
|
|
||||||
unlockCondition: { type: 'floor', value: 3 },
|
|
||||||
flavorText: 'Ripples spread across its surface with each spell you cast.',
|
|
||||||
},
|
|
||||||
|
|
||||||
earthPebble: {
|
|
||||||
id: 'earthPebble',
|
|
||||||
name: 'Earth Pebble',
|
|
||||||
desc: 'A small stone with a surprisingly friendly personality.',
|
|
||||||
role: 'guardian',
|
|
||||||
element: 'earth',
|
|
||||||
rarity: 'common',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.thorns(2, 0.5),
|
|
||||||
],
|
|
||||||
baseStats: { power: 15, bond: 8 },
|
|
||||||
unlockCondition: { type: 'floor', value: 8 },
|
|
||||||
flavorText: 'It occasionally rolls itself to a new position when bored.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// === UNCOMMON FAMILIARS (Tier 2) ===
|
|
||||||
|
|
||||||
flameImp: {
|
|
||||||
id: 'flameImp',
|
|
||||||
name: 'Flame Imp',
|
|
||||||
desc: 'A mischievous fire spirit that delights in destruction.',
|
|
||||||
role: 'combat',
|
|
||||||
element: 'fire',
|
|
||||||
rarity: 'uncommon',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.damageBonus(4, 0.8),
|
|
||||||
ABILITIES.elementalBonus(3, 0.6),
|
|
||||||
],
|
|
||||||
baseStats: { power: 25, bond: 12 },
|
|
||||||
unlockCondition: { type: 'floor', value: 15 },
|
|
||||||
flavorText: 'It cackles with glee whenever you defeat an enemy.',
|
|
||||||
},
|
|
||||||
|
|
||||||
windSylph: {
|
|
||||||
id: 'windSylph',
|
|
||||||
name: 'Wind Sylph',
|
|
||||||
desc: 'An airy spirit that moves like a gentle breeze.',
|
|
||||||
role: 'support',
|
|
||||||
element: 'air',
|
|
||||||
rarity: 'uncommon',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.castSpeed(3, 0.6),
|
|
||||||
],
|
|
||||||
baseStats: { power: 20, bond: 15 },
|
|
||||||
unlockCondition: { type: 'floor', value: 12 },
|
|
||||||
flavorText: 'Its laughter sounds like wind chimes in a storm.',
|
|
||||||
},
|
|
||||||
|
|
||||||
manaSprite: {
|
|
||||||
id: 'manaSprite',
|
|
||||||
name: 'Mana Sprite',
|
|
||||||
desc: 'A more evolved mana spirit with a playful nature.',
|
|
||||||
role: 'mana',
|
|
||||||
element: 'raw',
|
|
||||||
rarity: 'uncommon',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.manaRegen(1, 0.2),
|
|
||||||
ABILITIES.autoGather(2, 0.5),
|
|
||||||
],
|
|
||||||
baseStats: { power: 18, bond: 18 },
|
|
||||||
unlockCondition: { type: 'mana', value: 1000 },
|
|
||||||
flavorText: 'It sometimes tickles your ear with invisible hands.',
|
|
||||||
},
|
|
||||||
|
|
||||||
crystalGolem: {
|
|
||||||
id: 'crystalGolem',
|
|
||||||
name: 'Crystal Golem',
|
|
||||||
desc: 'A small construct made of crystallized mana.',
|
|
||||||
role: 'guardian',
|
|
||||||
element: 'crystal',
|
|
||||||
rarity: 'uncommon',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.thorns(5, 1),
|
|
||||||
ABILITIES.manaShield(10, 2),
|
|
||||||
],
|
|
||||||
baseStats: { power: 30, bond: 10 },
|
|
||||||
unlockCondition: { type: 'floor', value: 20 },
|
|
||||||
flavorText: 'Light refracts through its body in mesmerizing patterns.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// === RARE FAMILIARS (Tier 3) ===
|
|
||||||
|
|
||||||
phoenixHatchling: {
|
|
||||||
id: 'phoenixHatchling',
|
|
||||||
name: 'Phoenix Hatchling',
|
|
||||||
desc: 'A young phoenix, still learning to control its flames.',
|
|
||||||
role: 'combat',
|
|
||||||
element: 'fire',
|
|
||||||
rarity: 'rare',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.damageBonus(6, 1.2),
|
|
||||||
ABILITIES.lifeSteal(3, 0.5),
|
|
||||||
],
|
|
||||||
baseStats: { power: 40, bond: 15 },
|
|
||||||
unlockCondition: { type: 'floor', value: 30 },
|
|
||||||
flavorText: 'Tiny flames dance around its feathers as it practices flying.',
|
|
||||||
},
|
|
||||||
|
|
||||||
frostWisp: {
|
|
||||||
id: 'frostWisp',
|
|
||||||
name: 'Frost Wisp',
|
|
||||||
desc: 'A spirit of eternal winter, beautiful and deadly.',
|
|
||||||
role: 'combat',
|
|
||||||
element: 'water',
|
|
||||||
rarity: 'rare',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.elementalBonus(8, 1.5),
|
|
||||||
ABILITIES.castSpeed(4, 0.8),
|
|
||||||
],
|
|
||||||
baseStats: { power: 35, bond: 12 },
|
|
||||||
unlockCondition: { type: 'floor', value: 25 },
|
|
||||||
flavorText: 'Frost patterns appear on surfaces wherever it lingers.',
|
|
||||||
},
|
|
||||||
|
|
||||||
manaElemental: {
|
|
||||||
id: 'manaElemental',
|
|
||||||
name: 'Mana Elemental',
|
|
||||||
desc: 'A concentrated form of pure magical energy.',
|
|
||||||
role: 'mana',
|
|
||||||
element: 'raw',
|
|
||||||
rarity: 'rare',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.manaRegen(2, 0.4),
|
|
||||||
ABILITIES.autoGather(5, 1),
|
|
||||||
ABILITIES.autoConvert(2, 0.5),
|
|
||||||
],
|
|
||||||
baseStats: { power: 30, bond: 20 },
|
|
||||||
unlockCondition: { type: 'mana', value: 5000 },
|
|
||||||
flavorText: 'Reality seems to bend slightly around its fluctuating form.',
|
|
||||||
},
|
|
||||||
|
|
||||||
shieldGuardian: {
|
|
||||||
id: 'shieldGuardian',
|
|
||||||
name: 'Shield Guardian',
|
|
||||||
desc: 'A loyal protector carved from enchanted stone.',
|
|
||||||
role: 'guardian',
|
|
||||||
element: 'earth',
|
|
||||||
rarity: 'rare',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.thorns(8, 1.5),
|
|
||||||
ABILITIES.manaShield(20, 4),
|
|
||||||
],
|
|
||||||
baseStats: { power: 50, bond: 8 },
|
|
||||||
unlockCondition: { type: 'floor', value: 35 },
|
|
||||||
flavorText: 'It stands motionless for hours, then suddenly moves to block danger.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// === EPIC FAMILIARS (Tier 4) ===
|
|
||||||
|
|
||||||
infernoDrake: {
|
|
||||||
id: 'infernoDrake',
|
|
||||||
name: 'Inferno Drake',
|
|
||||||
desc: 'A small dragon wreathed in eternal flames.',
|
|
||||||
role: 'combat',
|
|
||||||
element: 'fire',
|
|
||||||
rarity: 'epic',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.damageBonus(10, 2),
|
|
||||||
ABILITIES.elementalBonus(12, 2),
|
|
||||||
ABILITIES.critChance(3, 0.6),
|
|
||||||
],
|
|
||||||
baseStats: { power: 60, bond: 12 },
|
|
||||||
unlockCondition: { type: 'floor', value: 50 },
|
|
||||||
flavorText: 'Smoke occasionally drifts from its nostrils as it dreams of conquest.',
|
|
||||||
},
|
|
||||||
|
|
||||||
starlightSerpent: {
|
|
||||||
id: 'starlightSerpent',
|
|
||||||
name: 'Starlight Serpent',
|
|
||||||
desc: 'A serpentine creature formed from captured starlight.',
|
|
||||||
role: 'support',
|
|
||||||
element: 'stellar',
|
|
||||||
rarity: 'epic',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.castSpeed(8, 1.5),
|
|
||||||
ABILITIES.bonusGold(5, 1),
|
|
||||||
ABILITIES.manaRegen(1.5, 0.3),
|
|
||||||
],
|
|
||||||
baseStats: { power: 45, bond: 25 },
|
|
||||||
unlockCondition: { type: 'floor', value: 45 },
|
|
||||||
flavorText: 'It traces constellations in the air with its glowing body.',
|
|
||||||
},
|
|
||||||
|
|
||||||
voidWalker: {
|
|
||||||
id: 'voidWalker',
|
|
||||||
name: 'Void Walker',
|
|
||||||
desc: 'A being that exists partially outside normal reality.',
|
|
||||||
role: 'mana',
|
|
||||||
element: 'void',
|
|
||||||
rarity: 'epic',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.manaRegen(3, 0.6),
|
|
||||||
ABILITIES.autoGather(10, 2),
|
|
||||||
ABILITIES.manaShield(15, 3),
|
|
||||||
],
|
|
||||||
baseStats: { power: 55, bond: 15 },
|
|
||||||
unlockCondition: { type: 'floor', value: 55 },
|
|
||||||
flavorText: 'It sometimes disappears entirely, only to reappear moments later.',
|
|
||||||
},
|
|
||||||
|
|
||||||
ancientGolem: {
|
|
||||||
id: 'ancientGolem',
|
|
||||||
name: 'Ancient Golem',
|
|
||||||
desc: 'A construct from a forgotten age, still following its prime directive.',
|
|
||||||
role: 'guardian',
|
|
||||||
element: 'earth',
|
|
||||||
rarity: 'epic',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.thorns(15, 3),
|
|
||||||
ABILITIES.manaShield(30, 5),
|
|
||||||
ABILITIES.damageBonus(5, 1),
|
|
||||||
],
|
|
||||||
baseStats: { power: 80, bond: 6 },
|
|
||||||
unlockCondition: { type: 'floor', value: 60 },
|
|
||||||
flavorText: 'Ancient runes glow faintly across its weathered surface.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// === LEGENDARY FAMILIARS (Tier 5) ===
|
|
||||||
|
|
||||||
primordialPhoenix: {
|
|
||||||
id: 'primordialPhoenix',
|
|
||||||
name: 'Primordial Phoenix',
|
|
||||||
desc: 'An ancient fire bird, reborn countless times through the ages.',
|
|
||||||
role: 'combat',
|
|
||||||
element: 'fire',
|
|
||||||
rarity: 'legendary',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.damageBonus(15, 3),
|
|
||||||
ABILITIES.elementalBonus(20, 4),
|
|
||||||
ABILITIES.lifeSteal(8, 1.5),
|
|
||||||
ABILITIES.critChance(5, 1),
|
|
||||||
],
|
|
||||||
baseStats: { power: 100, bond: 20 },
|
|
||||||
unlockCondition: { type: 'pact', value: 25 }, // Guardian floor 25
|
|
||||||
flavorText: 'Its eyes hold the wisdom of a thousand lifetimes.',
|
|
||||||
},
|
|
||||||
|
|
||||||
leviathanSpawn: {
|
|
||||||
id: 'leviathanSpawn',
|
|
||||||
name: 'Leviathan Spawn',
|
|
||||||
desc: 'The offspring of an ancient sea god, still growing into its power.',
|
|
||||||
role: 'mana',
|
|
||||||
element: 'water',
|
|
||||||
rarity: 'legendary',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.manaRegen(5, 1),
|
|
||||||
ABILITIES.autoGather(20, 4),
|
|
||||||
ABILITIES.autoConvert(8, 1.5),
|
|
||||||
ABILITIES.manaShield(25, 5),
|
|
||||||
],
|
|
||||||
baseStats: { power: 90, bond: 18 },
|
|
||||||
unlockCondition: { type: 'pact', value: 50 },
|
|
||||||
flavorText: 'The air around it always smells of salt and deep ocean.',
|
|
||||||
},
|
|
||||||
|
|
||||||
celestialGuardian: {
|
|
||||||
id: 'celestialGuardian',
|
|
||||||
name: 'Celestial Guardian',
|
|
||||||
desc: 'A divine protector sent by powers beyond mortal comprehension.',
|
|
||||||
role: 'guardian',
|
|
||||||
element: 'light',
|
|
||||||
rarity: 'legendary',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.thorns(25, 5),
|
|
||||||
ABILITIES.manaShield(50, 10),
|
|
||||||
ABILITIES.damageBonus(10, 2),
|
|
||||||
ABILITIES.lifeSteal(5, 1),
|
|
||||||
],
|
|
||||||
baseStats: { power: 120, bond: 12 },
|
|
||||||
unlockCondition: { type: 'pact', value: 75 },
|
|
||||||
flavorText: 'It radiates an aura of absolute protection and quiet judgment.',
|
|
||||||
},
|
|
||||||
|
|
||||||
voidEmperor: {
|
|
||||||
id: 'voidEmperor',
|
|
||||||
name: 'Void Emperor',
|
|
||||||
desc: 'A ruler from the spaces between dimensions, bound to your service.',
|
|
||||||
role: 'support',
|
|
||||||
element: 'void',
|
|
||||||
rarity: 'legendary',
|
|
||||||
abilities: [
|
|
||||||
ABILITIES.castSpeed(15, 3),
|
|
||||||
ABILITIES.bonusGold(15, 3),
|
|
||||||
ABILITIES.manaRegen(4, 0.8),
|
|
||||||
ABILITIES.critChance(8, 1.5),
|
|
||||||
],
|
|
||||||
baseStats: { power: 85, bond: 25 },
|
|
||||||
unlockCondition: { type: 'floor', value: 90 },
|
|
||||||
flavorText: 'It regards reality with the detached interest of a god.',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Helper Functions ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Get XP required for next familiar level
|
|
||||||
export function getFamiliarXpRequired(level: number): number {
|
|
||||||
// Exponential scaling: 100 * 1.5^(level-1)
|
|
||||||
return Math.floor(100 * Math.pow(1.5, level - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get bond required for next bond level (1-100)
|
|
||||||
export function getBondRequired(currentBond: number): number {
|
|
||||||
// Linear scaling, every 10 bond requires more time
|
|
||||||
const bondTier = Math.floor(currentBond / 10);
|
|
||||||
return 100 + bondTier * 50; // Base 100, +50 per tier
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate familiar's ability value at given level and ability level
|
|
||||||
export function getFamiliarAbilityValue(
|
|
||||||
ability: FamiliarAbility,
|
|
||||||
familiarLevel: number,
|
|
||||||
abilityLevel: number
|
|
||||||
): number {
|
|
||||||
// Base value + (familiar level bonus) + (ability level bonus)
|
|
||||||
const familiarBonus = Math.floor(familiarLevel / 10) * ability.scalingPerLevel;
|
|
||||||
const abilityBonus = (abilityLevel - 1) * ability.scalingPerLevel * 2;
|
|
||||||
return ability.baseValue + familiarBonus + abilityBonus;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all familiars of a specific rarity
|
|
||||||
export function getFamiliarsByRarity(rarity: FamiliarDef['rarity']): FamiliarDef[] {
|
|
||||||
return Object.values(FAMILIARS_DEF).filter(f => f.rarity === rarity);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all familiars of a specific role
|
|
||||||
export function getFamiliarsByRole(role: FamiliarRole): FamiliarDef[] {
|
|
||||||
return Object.values(FAMILIARS_DEF).filter(f => f.role === role);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if player meets unlock condition for a familiar
|
|
||||||
export function canUnlockFamiliar(
|
|
||||||
familiar: FamiliarDef,
|
|
||||||
maxFloor: number,
|
|
||||||
signedPacts: number[],
|
|
||||||
totalManaGathered: number,
|
|
||||||
skillsLearned: number
|
|
||||||
): boolean {
|
|
||||||
if (!familiar.unlockCondition) return true;
|
|
||||||
|
|
||||||
const { type, value } = familiar.unlockCondition;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'floor':
|
|
||||||
return maxFloor >= value;
|
|
||||||
case 'pact':
|
|
||||||
return signedPacts.includes(value);
|
|
||||||
case 'mana':
|
|
||||||
return totalManaGathered >= value;
|
|
||||||
case 'study':
|
|
||||||
return skillsLearned >= value;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starting familiar (given to new players)
|
|
||||||
export const STARTING_FAMILIAR = 'manaWisp';
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
// ─── Familiar Slice ─────────────────────────────────────────────────────────────
|
|
||||||
// Actions and computations for the familiar system
|
|
||||||
|
|
||||||
import type { GameState, FamiliarInstance, FamiliarAbilityType } from './types';
|
|
||||||
import { FAMILIARS_DEF, getFamiliarXpRequired, getFamiliarAbilityValue, canUnlockFamiliar, STARTING_FAMILIAR } from './data/familiars';
|
|
||||||
import { HOURS_PER_TICK } from './constants';
|
|
||||||
|
|
||||||
// ─── Familiar Actions Interface ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface FamiliarActions {
|
|
||||||
// Summoning and management
|
|
||||||
summonFamiliar: (familiarId: string) => void;
|
|
||||||
setActiveFamiliar: (instanceIndex: number, active: boolean) => void;
|
|
||||||
setFamiliarNickname: (instanceIndex: number, nickname: string) => void;
|
|
||||||
|
|
||||||
// Progression
|
|
||||||
gainFamiliarXp: (amount: number, source: 'combat' | 'gather' | 'meditate' | 'study' | 'time') => void;
|
|
||||||
upgradeFamiliarAbility: (instanceIndex: number, abilityType: FamiliarAbilityType) => void;
|
|
||||||
|
|
||||||
// Computation
|
|
||||||
getActiveFamiliarBonuses: () => FamiliarBonuses;
|
|
||||||
getAvailableFamiliars: () => string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Computed Bonuses ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface FamiliarBonuses {
|
|
||||||
damageMultiplier: number;
|
|
||||||
manaRegenBonus: number;
|
|
||||||
autoGatherRate: number;
|
|
||||||
autoConvertRate: number;
|
|
||||||
critChanceBonus: number;
|
|
||||||
castSpeedMultiplier: number;
|
|
||||||
elementalDamageMultiplier: number;
|
|
||||||
lifeStealPercent: number;
|
|
||||||
thornsPercent: number;
|
|
||||||
insightMultiplier: number;
|
|
||||||
manaShieldAmount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_FAMILIAR_BONUSES: FamiliarBonuses = {
|
|
||||||
damageMultiplier: 1,
|
|
||||||
manaRegenBonus: 0,
|
|
||||||
autoGatherRate: 0,
|
|
||||||
autoConvertRate: 0,
|
|
||||||
critChanceBonus: 0,
|
|
||||||
castSpeedMultiplier: 1,
|
|
||||||
elementalDamageMultiplier: 1,
|
|
||||||
lifeStealPercent: 0,
|
|
||||||
thornsPercent: 0,
|
|
||||||
insightMultiplier: 1,
|
|
||||||
manaShieldAmount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Familiar Slice Factory ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function createFamiliarSlice(
|
|
||||||
set: (fn: (state: GameState) => Partial<GameState>) => void,
|
|
||||||
get: () => GameState
|
|
||||||
): FamiliarActions {
|
|
||||||
return {
|
|
||||||
// Summon a new familiar
|
|
||||||
summonFamiliar: (familiarId: string) => {
|
|
||||||
const state = get();
|
|
||||||
const familiarDef = FAMILIARS_DEF[familiarId];
|
|
||||||
if (!familiarDef) return;
|
|
||||||
|
|
||||||
// Check if already owned
|
|
||||||
if (state.familiars.some(f => f.familiarId === familiarId)) return;
|
|
||||||
|
|
||||||
// Check unlock condition
|
|
||||||
if (!canUnlockFamiliar(
|
|
||||||
familiarDef,
|
|
||||||
state.maxFloorReached,
|
|
||||||
state.signedPacts,
|
|
||||||
state.totalManaGathered,
|
|
||||||
Object.keys(state.skills).length
|
|
||||||
)) return;
|
|
||||||
|
|
||||||
// Create new familiar instance
|
|
||||||
const newInstance: FamiliarInstance = {
|
|
||||||
familiarId,
|
|
||||||
level: 1,
|
|
||||||
bond: 0,
|
|
||||||
experience: 0,
|
|
||||||
abilities: familiarDef.abilities.map(a => ({
|
|
||||||
type: a.type,
|
|
||||||
level: 1,
|
|
||||||
})),
|
|
||||||
active: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add to familiars list
|
|
||||||
set((s) => ({
|
|
||||||
familiars: [...s.familiars, newInstance],
|
|
||||||
log: [`🌟 ${familiarDef.name} has answered your call!`, ...s.log.slice(0, 49)],
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Set a familiar as active/inactive
|
|
||||||
setActiveFamiliar: (instanceIndex: number, active: boolean) => {
|
|
||||||
const state = get();
|
|
||||||
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
|
|
||||||
|
|
||||||
const activeCount = state.familiars.filter(f => f.active).length;
|
|
||||||
|
|
||||||
// Check if we have slots available
|
|
||||||
if (active && activeCount >= state.activeFamiliarSlots) {
|
|
||||||
// Deactivate another familiar first
|
|
||||||
const newFamiliars = [...state.familiars];
|
|
||||||
const activeIndex = newFamiliars.findIndex(f => f.active);
|
|
||||||
if (activeIndex >= 0) {
|
|
||||||
newFamiliars[activeIndex] = { ...newFamiliars[activeIndex], active: false };
|
|
||||||
}
|
|
||||||
newFamiliars[instanceIndex] = { ...newFamiliars[instanceIndex], active };
|
|
||||||
set({ familiars: newFamiliars });
|
|
||||||
} else {
|
|
||||||
// Just toggle the familiar
|
|
||||||
const newFamiliars = [...state.familiars];
|
|
||||||
newFamiliars[instanceIndex] = { ...newFamiliars[instanceIndex], active };
|
|
||||||
set({ familiars: newFamiliars });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Set a familiar's nickname
|
|
||||||
setFamiliarNickname: (instanceIndex: number, nickname: string) => {
|
|
||||||
const state = get();
|
|
||||||
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
|
|
||||||
|
|
||||||
const newFamiliars = [...state.familiars];
|
|
||||||
newFamiliars[instanceIndex] = {
|
|
||||||
...newFamiliars[instanceIndex],
|
|
||||||
nickname: nickname || undefined
|
|
||||||
};
|
|
||||||
set({ familiars: newFamiliars });
|
|
||||||
},
|
|
||||||
|
|
||||||
// Grant XP to all active familiars
|
|
||||||
gainFamiliarXp: (amount: number, _source: 'combat' | 'gather' | 'meditate' | 'study' | 'time') => {
|
|
||||||
const state = get();
|
|
||||||
if (state.familiars.length === 0) return;
|
|
||||||
|
|
||||||
const newFamiliars = [...state.familiars];
|
|
||||||
let leveled = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < newFamiliars.length; i++) {
|
|
||||||
const familiar = newFamiliars[i];
|
|
||||||
if (!familiar.active) continue;
|
|
||||||
|
|
||||||
const def = FAMILIARS_DEF[familiar.familiarId];
|
|
||||||
if (!def) continue;
|
|
||||||
|
|
||||||
// Apply bond multiplier to XP gain
|
|
||||||
const bondMultiplier = 1 + (familiar.bond / 100);
|
|
||||||
const xpGain = Math.floor(amount * bondMultiplier);
|
|
||||||
|
|
||||||
let newExp = familiar.experience + xpGain;
|
|
||||||
let newLevel = familiar.level;
|
|
||||||
|
|
||||||
// Check for level ups
|
|
||||||
while (newLevel < 100 && newExp >= getFamiliarXpRequired(newLevel)) {
|
|
||||||
newExp -= getFamiliarXpRequired(newLevel);
|
|
||||||
newLevel++;
|
|
||||||
leveled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gain bond passively
|
|
||||||
const newBond = Math.min(100, familiar.bond + 0.01);
|
|
||||||
|
|
||||||
newFamiliars[i] = {
|
|
||||||
...familiar,
|
|
||||||
level: newLevel,
|
|
||||||
experience: newExp,
|
|
||||||
bond: newBond,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
|
||||||
familiars: newFamiliars,
|
|
||||||
totalFamiliarXpEarned: state.totalFamiliarXpEarned + amount,
|
|
||||||
...(leveled ? { log: ['📈 Your familiar has grown stronger!', ...state.log.slice(0, 49)] } : {}),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Upgrade a familiar's ability
|
|
||||||
upgradeFamiliarAbility: (instanceIndex: number, abilityType: FamiliarAbilityType) => {
|
|
||||||
const state = get();
|
|
||||||
if (instanceIndex < 0 || instanceIndex >= state.familiars.length) return;
|
|
||||||
|
|
||||||
const familiar = state.familiars[instanceIndex];
|
|
||||||
const def = FAMILIARS_DEF[familiar.familiarId];
|
|
||||||
if (!def) return;
|
|
||||||
|
|
||||||
// Find the ability
|
|
||||||
const abilityIndex = familiar.abilities.findIndex(a => a.type === abilityType);
|
|
||||||
if (abilityIndex < 0) return;
|
|
||||||
|
|
||||||
const ability = familiar.abilities[abilityIndex];
|
|
||||||
if (ability.level >= 10) return; // Max level
|
|
||||||
|
|
||||||
// Cost: level * 100 XP
|
|
||||||
const cost = ability.level * 100;
|
|
||||||
if (familiar.experience < cost) return;
|
|
||||||
|
|
||||||
// Upgrade
|
|
||||||
const newAbilities = [...familiar.abilities];
|
|
||||||
newAbilities[abilityIndex] = { ...ability, level: ability.level + 1 };
|
|
||||||
|
|
||||||
const newFamiliars = [...state.familiars];
|
|
||||||
newFamiliars[instanceIndex] = {
|
|
||||||
...familiar,
|
|
||||||
abilities: newAbilities,
|
|
||||||
experience: familiar.experience - cost,
|
|
||||||
};
|
|
||||||
|
|
||||||
set({ familiars: newFamiliars });
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get total bonuses from active familiars
|
|
||||||
getActiveFamiliarBonuses: (): FamiliarBonuses => {
|
|
||||||
const state = get();
|
|
||||||
const bonuses = { ...DEFAULT_FAMILIAR_BONUSES };
|
|
||||||
|
|
||||||
for (const familiar of state.familiars) {
|
|
||||||
if (!familiar.active) continue;
|
|
||||||
|
|
||||||
const def = FAMILIARS_DEF[familiar.familiarId];
|
|
||||||
if (!def) continue;
|
|
||||||
|
|
||||||
// Bond multiplier: up to 50% bonus at max bond
|
|
||||||
const bondMultiplier = 1 + (familiar.bond / 200);
|
|
||||||
|
|
||||||
for (const abilityInst of familiar.abilities) {
|
|
||||||
const abilityDef = def.abilities.find(a => a.type === abilityInst.type);
|
|
||||||
if (!abilityDef) continue;
|
|
||||||
|
|
||||||
const value = getFamiliarAbilityValue(abilityDef, familiar.level, abilityInst.level) * bondMultiplier;
|
|
||||||
|
|
||||||
switch (abilityInst.type) {
|
|
||||||
case 'damageBonus':
|
|
||||||
bonuses.damageMultiplier += value / 100;
|
|
||||||
break;
|
|
||||||
case 'manaRegen':
|
|
||||||
bonuses.manaRegenBonus += value;
|
|
||||||
break;
|
|
||||||
case 'autoGather':
|
|
||||||
bonuses.autoGatherRate += value;
|
|
||||||
break;
|
|
||||||
case 'autoConvert':
|
|
||||||
bonuses.autoConvertRate += value;
|
|
||||||
break;
|
|
||||||
case 'critChance':
|
|
||||||
bonuses.critChanceBonus += value;
|
|
||||||
break;
|
|
||||||
case 'castSpeed':
|
|
||||||
bonuses.castSpeedMultiplier += value / 100;
|
|
||||||
break;
|
|
||||||
case 'elementalBonus':
|
|
||||||
bonuses.elementalDamageMultiplier += value / 100;
|
|
||||||
break;
|
|
||||||
case 'lifeSteal':
|
|
||||||
bonuses.lifeStealPercent += value;
|
|
||||||
break;
|
|
||||||
case 'thorns':
|
|
||||||
bonuses.thornsPercent += value;
|
|
||||||
break;
|
|
||||||
case 'bonusGold':
|
|
||||||
bonuses.insightMultiplier += value / 100;
|
|
||||||
break;
|
|
||||||
case 'manaShield':
|
|
||||||
bonuses.manaShieldAmount += value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bonuses;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Get list of available (unlocked but not owned) familiars
|
|
||||||
getAvailableFamiliars: (): string[] => {
|
|
||||||
const state = get();
|
|
||||||
const owned = new Set(state.familiars.map(f => f.familiarId));
|
|
||||||
|
|
||||||
return Object.values(FAMILIARS_DEF)
|
|
||||||
.filter(f =>
|
|
||||||
!owned.has(f.id) &&
|
|
||||||
canUnlockFamiliar(
|
|
||||||
f,
|
|
||||||
state.maxFloorReached,
|
|
||||||
state.signedPacts,
|
|
||||||
state.totalManaGathered,
|
|
||||||
Object.keys(state.skills).length
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.map(f => f.id);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Familiar Tick Processing ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Process familiar-related tick effects (called from main tick)
|
|
||||||
export function processFamiliarTick(
|
|
||||||
state: Pick<GameState, 'familiars' | 'rawMana' | 'elements' | 'totalManaGathered' | 'activeFamiliarSlots'>,
|
|
||||||
familiarBonuses: FamiliarBonuses
|
|
||||||
): { rawMana: number; elements: GameState['elements']; totalManaGathered: number; gatherLog?: string } {
|
|
||||||
let rawMana = state.rawMana;
|
|
||||||
let elements = state.elements;
|
|
||||||
let totalManaGathered = state.totalManaGathered;
|
|
||||||
let gatherLog: string | undefined;
|
|
||||||
|
|
||||||
// Auto-gather from familiars
|
|
||||||
if (familiarBonuses.autoGatherRate > 0) {
|
|
||||||
const gathered = familiarBonuses.autoGatherRate * HOURS_PER_TICK;
|
|
||||||
rawMana += gathered;
|
|
||||||
totalManaGathered += gathered;
|
|
||||||
if (gathered >= 1) {
|
|
||||||
gatherLog = `✨ Familiars gathered ${Math.floor(gathered)} mana`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-convert from familiars
|
|
||||||
if (familiarBonuses.autoConvertRate > 0) {
|
|
||||||
const convertAmount = Math.min(
|
|
||||||
familiarBonuses.autoConvertRate * HOURS_PER_TICK,
|
|
||||||
Math.floor(rawMana / 5) // 5 raw mana per element
|
|
||||||
);
|
|
||||||
|
|
||||||
if (convertAmount > 0) {
|
|
||||||
// Find unlocked elements with space
|
|
||||||
const unlockedElements = Object.entries(elements)
|
|
||||||
.filter(([, e]) => e.unlocked && e.current < e.max)
|
|
||||||
.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
|
|
||||||
|
|
||||||
if (unlockedElements.length > 0) {
|
|
||||||
const [targetId, targetState] = unlockedElements[0];
|
|
||||||
const canConvert = Math.min(convertAmount, targetState.max - targetState.current);
|
|
||||||
rawMana -= canConvert * 5;
|
|
||||||
elements = {
|
|
||||||
...elements,
|
|
||||||
[targetId]: { ...targetState, current: targetState.current + canConvert },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { rawMana, elements, totalManaGathered, gatherLog };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grant starting familiar to new players
|
|
||||||
export function grantStartingFamiliar(): FamiliarInstance[] {
|
|
||||||
const starterDef = FAMILIARS_DEF[STARTING_FAMILIAR];
|
|
||||||
if (!starterDef) return [];
|
|
||||||
|
|
||||||
return [{
|
|
||||||
familiarId: STARTING_FAMILIAR,
|
|
||||||
level: 1,
|
|
||||||
bond: 0,
|
|
||||||
experience: 0,
|
|
||||||
abilities: starterDef.abilities.map(a => ({
|
|
||||||
type: a.type,
|
|
||||||
level: 1,
|
|
||||||
})),
|
|
||||||
active: true, // Start with familiar active
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
@@ -32,14 +32,6 @@ import {
|
|||||||
type CraftingActions
|
type CraftingActions
|
||||||
} from './crafting-slice';
|
} from './crafting-slice';
|
||||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
||||||
import {
|
|
||||||
createFamiliarSlice,
|
|
||||||
processFamiliarTick,
|
|
||||||
grantStartingFamiliar,
|
|
||||||
type FamiliarActions,
|
|
||||||
type FamiliarBonuses,
|
|
||||||
DEFAULT_FAMILIAR_BONUSES,
|
|
||||||
} from './familiar-slice';
|
|
||||||
import {
|
import {
|
||||||
createNavigationSlice,
|
createNavigationSlice,
|
||||||
type NavigationActions,
|
type NavigationActions,
|
||||||
@@ -280,20 +272,14 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
|||||||
totalSpellsCast: 0,
|
totalSpellsCast: 0,
|
||||||
totalCraftsCompleted: 0,
|
totalCraftsCompleted: 0,
|
||||||
|
|
||||||
// Familiars
|
log: ['✨ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. Gather your strength, mage.'],
|
||||||
familiars: grantStartingFamiliar(),
|
|
||||||
activeFamiliarSlots: 1,
|
|
||||||
familiarSummonProgress: 0,
|
|
||||||
totalFamiliarXpEarned: 0,
|
|
||||||
|
|
||||||
log: ['✨ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. A friendly Mana Wisp floats nearby. Gather your strength, mage.'],
|
|
||||||
loopInsight: 0,
|
loopInsight: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Game Store ───────────────────────────────────────────────────────────────
|
// ─── Game Store ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface GameStore extends GameState, CraftingActions, FamiliarActions, NavigationActions, StudyActions {
|
interface GameStore extends GameState, CraftingActions, NavigationActions, StudyActions {
|
||||||
// Actions
|
// Actions
|
||||||
tick: () => void;
|
tick: () => void;
|
||||||
gatherMana: () => void;
|
gatherMana: () => void;
|
||||||
@@ -329,7 +315,6 @@ export const useGameStore = create<GameStore>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
...makeInitial(),
|
...makeInitial(),
|
||||||
...createFamiliarSlice(set, get),
|
|
||||||
...createNavigationSlice(set, get),
|
...createNavigationSlice(set, get),
|
||||||
...createStudySlice(set, get),
|
...createStudySlice(set, get),
|
||||||
|
|
||||||
@@ -359,16 +344,8 @@ export const useGameStore = create<GameStore>()(
|
|||||||
// Compute unified effects (includes skill upgrades AND equipment enchantments)
|
// Compute unified effects (includes skill upgrades AND equipment enchantments)
|
||||||
const effects = getUnifiedEffects(state);
|
const effects = getUnifiedEffects(state);
|
||||||
|
|
||||||
// Compute familiar bonuses
|
|
||||||
const familiarBonuses = state.familiars.length > 0
|
|
||||||
? (() => {
|
|
||||||
const slice = createFamiliarSlice(set, get);
|
|
||||||
return slice.getActiveFamiliarBonuses();
|
|
||||||
})()
|
|
||||||
: DEFAULT_FAMILIAR_BONUSES;
|
|
||||||
|
|
||||||
const maxMana = computeMaxMana(state, effects);
|
const maxMana = computeMaxMana(state, effects);
|
||||||
const baseRegen = computeRegen(state, effects) + familiarBonuses.manaRegenBonus;
|
const baseRegen = computeRegen(state, effects);
|
||||||
|
|
||||||
// Time progression
|
// Time progression
|
||||||
let hour = state.hour + HOURS_PER_TICK;
|
let hour = state.hour + HOURS_PER_TICK;
|
||||||
@@ -431,18 +408,7 @@ export const useGameStore = create<GameStore>()(
|
|||||||
// Mana regeneration
|
// Mana regeneration
|
||||||
let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||||
let totalManaGathered = state.totalManaGathered;
|
let totalManaGathered = state.totalManaGathered;
|
||||||
|
|
||||||
// Familiar auto-gather and auto-convert
|
|
||||||
let elements = state.elements;
|
let elements = state.elements;
|
||||||
if (familiarBonuses.autoGatherRate > 0 || familiarBonuses.autoConvertRate > 0) {
|
|
||||||
const familiarUpdates = processFamiliarTick(
|
|
||||||
{ rawMana, elements, totalManaGathered, familiars: state.familiars, activeFamiliarSlots: state.activeFamiliarSlots },
|
|
||||||
familiarBonuses
|
|
||||||
);
|
|
||||||
rawMana = Math.min(familiarUpdates.rawMana, maxMana);
|
|
||||||
elements = familiarUpdates.elements;
|
|
||||||
totalManaGathered = familiarUpdates.totalManaGathered;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Study progress
|
// Study progress
|
||||||
let currentStudyTarget = state.currentStudyTarget;
|
let currentStudyTarget = state.currentStudyTarget;
|
||||||
@@ -623,7 +589,7 @@ export const useGameStore = create<GameStore>()(
|
|||||||
|
|
||||||
// Compute attack speed from quickCast skill and upgrades
|
// Compute attack speed from quickCast skill and upgrades
|
||||||
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
|
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
|
||||||
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier * familiarBonuses.castSpeedMultiplier;
|
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
|
||||||
|
|
||||||
// Process each active spell
|
// Process each active spell
|
||||||
for (const { spellId, equipmentId } of activeSpells) {
|
for (const { spellId, equipmentId } of activeSpells) {
|
||||||
@@ -707,15 +673,6 @@ export const useGameStore = create<GameStore>()(
|
|||||||
log = [`💥 Combo Master! Triple damage!`, ...log.slice(0, 49)];
|
log = [`💥 Combo Master! Triple damage!`, ...log.slice(0, 49)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Familiar bonuses
|
|
||||||
dmg *= familiarBonuses.damageMultiplier;
|
|
||||||
dmg *= familiarBonuses.elementalDamageMultiplier;
|
|
||||||
|
|
||||||
// Familiar crit chance bonus
|
|
||||||
if (Math.random() < familiarBonuses.critChanceBonus / 100) {
|
|
||||||
dmg *= 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spell echo - chance to cast again
|
// Spell echo - chance to cast again
|
||||||
const echoChance = (skills.spellEcho || 0) * 0.1;
|
const echoChance = (skills.spellEcho || 0) * 0.1;
|
||||||
if (Math.random() < echoChance) {
|
if (Math.random() < echoChance) {
|
||||||
@@ -730,12 +687,6 @@ export const useGameStore = create<GameStore>()(
|
|||||||
rawMana = Math.min(rawMana + healAmount, maxMana);
|
rawMana = Math.min(rawMana + healAmount, maxMana);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Familiar lifesteal
|
|
||||||
if (familiarBonuses.lifeStealPercent > 0) {
|
|
||||||
const healAmount = dmg * (familiarBonuses.lifeStealPercent / 100);
|
|
||||||
rawMana = Math.min(rawMana + healAmount, maxMana);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track total damage for achievements
|
// Track total damage for achievements
|
||||||
totalDamageDealt += dmg;
|
totalDamageDealt += dmg;
|
||||||
|
|
||||||
@@ -899,36 +850,6 @@ export const useGameStore = create<GameStore>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant XP to active familiars based on activity
|
|
||||||
let familiars = state.familiars;
|
|
||||||
if (familiars.some(f => f.active)) {
|
|
||||||
let xpGain = 0;
|
|
||||||
let xpSource: 'combat' | 'gather' | 'meditate' | 'study' | 'time' = 'time';
|
|
||||||
|
|
||||||
if (state.currentAction === 'climb') {
|
|
||||||
xpGain = 2 * HOURS_PER_TICK; // 2 XP per hour in combat
|
|
||||||
xpSource = 'combat';
|
|
||||||
} else if (state.currentAction === 'meditate') {
|
|
||||||
xpGain = 1 * HOURS_PER_TICK;
|
|
||||||
xpSource = 'meditate';
|
|
||||||
} else if (state.currentAction === 'study') {
|
|
||||||
xpGain = 1.5 * HOURS_PER_TICK;
|
|
||||||
xpSource = 'study';
|
|
||||||
} else {
|
|
||||||
xpGain = 0.5 * HOURS_PER_TICK; // Passive XP
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update familiar XP and bond
|
|
||||||
familiars = familiars.map(f => {
|
|
||||||
if (!f.active) return f;
|
|
||||||
const bondMultiplier = 1 + (f.bond / 100);
|
|
||||||
const xpGained = Math.floor(xpGain * bondMultiplier);
|
|
||||||
const newXp = f.experience + xpGained;
|
|
||||||
const newBond = Math.min(100, f.bond + 0.02); // Slow bond gain
|
|
||||||
return { ...f, experience: newXp, bond: newBond };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
day,
|
day,
|
||||||
hour,
|
hour,
|
||||||
@@ -955,7 +876,6 @@ export const useGameStore = create<GameStore>()(
|
|||||||
achievements,
|
achievements,
|
||||||
totalDamageDealt,
|
totalDamageDealt,
|
||||||
totalSpellsCast,
|
totalSpellsCast,
|
||||||
familiars,
|
|
||||||
consecutiveStudyHours,
|
consecutiveStudyHours,
|
||||||
studyStartedAt,
|
studyStartedAt,
|
||||||
...craftingUpdates,
|
...craftingUpdates,
|
||||||
@@ -1738,11 +1658,6 @@ export const useGameStore = create<GameStore>()(
|
|||||||
totalDamageDealt: state.totalDamageDealt,
|
totalDamageDealt: state.totalDamageDealt,
|
||||||
totalSpellsCast: state.totalSpellsCast,
|
totalSpellsCast: state.totalSpellsCast,
|
||||||
totalCraftsCompleted: state.totalCraftsCompleted,
|
totalCraftsCompleted: state.totalCraftsCompleted,
|
||||||
// Familiars
|
|
||||||
familiars: state.familiars,
|
|
||||||
activeFamiliarSlots: state.activeFamiliarSlots,
|
|
||||||
familiarSummonProgress: state.familiarSummonProgress,
|
|
||||||
totalFamiliarXpEarned: state.totalFamiliarXpEarned,
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -314,69 +314,6 @@ export interface BlueprintDef {
|
|||||||
learned: boolean;
|
learned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Familiar System ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Familiar role determines their primary function
|
|
||||||
export type FamiliarRole = 'combat' | 'mana' | 'support' | 'guardian';
|
|
||||||
|
|
||||||
// Familiar ability types
|
|
||||||
export type FamiliarAbilityType =
|
|
||||||
| 'damageBonus' // +X% damage
|
|
||||||
| 'manaRegen' // +X mana regen
|
|
||||||
| 'autoGather' // Gathers mana automatically
|
|
||||||
| 'critChance' // +X% crit chance
|
|
||||||
| 'castSpeed' // +X% cast speed
|
|
||||||
| 'manaShield' // Absorbs damage, costs mana
|
|
||||||
| 'elementalBonus' // +X% elemental damage
|
|
||||||
| 'lifeSteal' // Heal on hit
|
|
||||||
| 'bonusGold' // +X% insight gain
|
|
||||||
| 'autoConvert' // Auto-converts mana to elements
|
|
||||||
| 'thorns'; // Reflects damage
|
|
||||||
|
|
||||||
export interface FamiliarAbility {
|
|
||||||
type: FamiliarAbilityType;
|
|
||||||
baseValue: number; // Base effect value
|
|
||||||
scalingPerLevel: number; // How much it increases per familiar level
|
|
||||||
desc: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Familiar definition (static data)
|
|
||||||
export interface FamiliarDef {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
desc: string;
|
|
||||||
role: FamiliarRole;
|
|
||||||
element: string; // Associated element
|
|
||||||
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
|
||||||
abilities: FamiliarAbility[];
|
|
||||||
baseStats: {
|
|
||||||
power: number; // Affects ability strength
|
|
||||||
bond: number; // How fast bond grows
|
|
||||||
};
|
|
||||||
unlockCondition?: {
|
|
||||||
type: 'floor' | 'pact' | 'mana' | 'study';
|
|
||||||
value: number;
|
|
||||||
};
|
|
||||||
flavorText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Familiar instance (player's owned familiar)
|
|
||||||
export interface FamiliarInstance {
|
|
||||||
familiarId: string; // Reference to FamiliarDef
|
|
||||||
level: number; // 1-100
|
|
||||||
bond: number; // 0-100, affects power multiplier
|
|
||||||
experience: number; // XP towards next level
|
|
||||||
abilities: Array<{
|
|
||||||
type: FamiliarAbilityType;
|
|
||||||
level: number; // Ability level (1-10)
|
|
||||||
}>;
|
|
||||||
active: boolean; // Is this familiar currently summoned?
|
|
||||||
nickname?: string; // Optional custom name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Familiar experience gain sources
|
|
||||||
export type FamiliarXpSource = 'combat' | 'gather' | 'meditate' | 'study' | 'time';
|
|
||||||
|
|
||||||
export type GameAction = 'meditate' | 'climb' | 'study' | 'craft' | 'repair' | 'convert' | 'design' | 'prepare' | 'enchant';
|
export type GameAction = 'meditate' | 'climb' | 'study' | 'craft' | 'repair' | 'convert' | 'design' | 'prepare' | 'enchant';
|
||||||
|
|
||||||
export interface ScheduleBlock {
|
export interface ScheduleBlock {
|
||||||
@@ -502,12 +439,6 @@ export interface GameState {
|
|||||||
totalSpellsCast: number; // For spell achievements
|
totalSpellsCast: number; // For spell achievements
|
||||||
totalCraftsCompleted: number; // For craft achievements
|
totalCraftsCompleted: number; // For craft achievements
|
||||||
|
|
||||||
// Familiars
|
|
||||||
familiars: FamiliarInstance[]; // Owned familiars
|
|
||||||
activeFamiliarSlots: number; // How many familiars can be active (default 1)
|
|
||||||
familiarSummonProgress: number; // Progress toward summoning new familiar (0-100)
|
|
||||||
totalFamiliarXpEarned: number; // Lifetime XP earned for familiars
|
|
||||||
|
|
||||||
// Log
|
// Log
|
||||||
log: string[];
|
log: string[];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user