From 7c5f2f30f080e1fdb0b693983c8c69b60cc019ef Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 25 Mar 2026 16:35:56 +0000 Subject: [PATCH] pack --- src/app/page.tsx | 153 ++++- src/components/game/AchievementsDisplay.tsx | 175 ++++++ src/components/game/ComboMeter.tsx | 143 +++++ src/components/game/LootInventory.tsx | 117 ++++ src/components/game/tabs/FamiliarTab.tsx | 582 ++++++++++++++++++++ src/lib/game/data/achievements.ts | 272 +++++++++ src/lib/game/data/familiars.ts | 498 +++++++++++++++++ src/lib/game/data/loot-drops.ts | 242 ++++++++ src/lib/game/familiar-slice.ts | 367 ++++++++++++ src/lib/game/store.ts | 228 +++++++- src/lib/game/types.ts | 162 ++++++ 11 files changed, 2929 insertions(+), 10 deletions(-) create mode 100644 src/components/game/AchievementsDisplay.tsx create mode 100644 src/components/game/ComboMeter.tsx create mode 100644 src/components/game/LootInventory.tsx create mode 100644 src/components/game/tabs/FamiliarTab.tsx create mode 100644 src/lib/game/data/achievements.ts create mode 100644 src/lib/game/data/familiars.ts create mode 100644 src/lib/game/data/loot-drops.ts create mode 100644 src/lib/game/familiar-slice.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index 09ec43e..fa0d08f 100755 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,9 +18,14 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; -import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3 } from 'lucide-react'; +import { Sparkles, Swords, BookOpen, FlaskConical, Trophy, RotateCcw, Pause, Play, Zap, Clock, Target, Flame, Droplet, Wind, Mountain, Sun, Moon, Leaf, Skull, Brain, Link, Wind as Force, Droplets, TreeDeciduous, Hourglass, Gem, Star, CircleDot, X, Circle, BarChart3, Award, Package, Heart } from 'lucide-react'; import type { GameAction } from '@/lib/game/types'; import { CraftingTab } from '@/components/game/tabs/CraftingTab'; +import { FamiliarTab } from '@/components/game/tabs/FamiliarTab'; +import { ComboMeter } from '@/components/game/ComboMeter'; +import { LootInventoryDisplay } from '@/components/game/LootInventory'; +import { AchievementsDisplay } from '@/components/game/AchievementsDisplay'; +import { ACHIEVEMENTS } from '@/lib/game/data/achievements'; // Helper to get active spells from equipped caster weapons function getActiveEquipmentSpells( @@ -647,6 +652,9 @@ export default function ManaLoopGame() { + {/* Combo Meter - Shows when climbing or has active combo */} + + {/* Current Study (if any) */} {store.currentStudyTarget && ( @@ -2246,7 +2254,10 @@ export default function ManaLoopGame() { ๐Ÿ“– Spells ๐Ÿงช Lab โœจ Crafting + ๐Ÿ”ฎ Familiars ๐Ÿ“š Skills + ๐Ÿ’Ž Loot + ๐Ÿ† Achievements โญ Grimoire ๐Ÿ“Š Stats @@ -2289,10 +2300,150 @@ export default function ManaLoopGame() { /> + + + + {renderSkillsTab()} + +
+ + + + + + + Loot Information + + + +
+

+ As you climb the Spire, you'll discover valuable loot from defeated floors and guardians. +

+
+
Loot Types:
+
    +
  • Materials - Used for advanced crafting
  • +
  • Essence - Grants elemental mana
  • +
  • Mana Orbs - Direct mana boost
  • +
  • Blueprints - New equipment designs
  • +
+
+
+
Drop Rates:
+
    +
  • Higher floors = better loot
  • +
  • Guardians have 2x drop rate
  • +
  • Rare drops from floor 50+
  • +
  • Legendary items from floor 75+
  • +
+
+
+
+
+ + + + Loot Statistics + + +
+
+
+ {Object.values(store.lootInventory.materials).reduce((a, b) => a + b, 0)} +
+
Materials Found
+
+
+
+ {store.lootInventory.blueprints.length} +
+
Blueprints
+
+
+
+ {store.totalSpellsCast || 0} +
+
Spells Cast
+
+
+
+ {store.combo?.maxCombo || 0} +
+
Max Combo
+
+
+
+
+
+
+ + +
+ + + + + + + Achievement Progress + + + +
+
+
+
+ {store.achievements?.unlocked?.length || 0} +
+
Unlocked
+
+
+
+ {(store.achievements?.unlocked || []) + .reduce((sum: number, id: string) => sum + (ACHIEVEMENTS[id]?.reward?.insight || 0), 0)} +
+
Insight Earned
+
+
+ + + +
+ Achievements provide permanent bonuses and insight rewards. Track your progress and unlock special titles! +
+ +
+
Categories:
+
+ Combat + Progression + Crafting + Magic + Special +
+
+
+
+
+
+
+ {renderGrimoireTab()} diff --git a/src/components/game/AchievementsDisplay.tsx b/src/components/game/AchievementsDisplay.tsx new file mode 100644 index 0000000..44a2746 --- /dev/null +++ b/src/components/game/AchievementsDisplay.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Progress } from '@/components/ui/progress'; +import { Trophy, Lock, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react'; +import type { AchievementState } from '@/lib/game/types'; +import { ACHIEVEMENTS, ACHIEVEMENT_CATEGORY_COLORS, getAchievementsByCategory, isAchievementRevealed } from '@/lib/game/data/achievements'; +import { GameState } from '@/lib/game/types'; + +interface AchievementsProps { + achievements: AchievementState; + gameState: Pick; +} + +export function AchievementsDisplay({ achievements, gameState }: AchievementsProps) { + const [expandedCategory, setExpandedCategory] = useState('combat'); + + const categories = getAchievementsByCategory(); + const unlockedCount = achievements.unlocked.length; + const totalCount = Object.keys(ACHIEVEMENTS).length; + + // Calculate progress for each achievement + const getProgress = (achievementId: string): number => { + const achievement = ACHIEVEMENTS[achievementId]; + if (!achievement) return 0; + if (achievements.unlocked.includes(achievementId)) return achievement.requirement.value; + + const { type, subType } = achievement.requirement; + + switch (type) { + case 'floor': + if (subType === 'noPacts') { + return gameState.maxFloorReached >= achievement.requirement.value && gameState.signedPacts.length === 0 + ? achievement.requirement.value + : gameState.maxFloorReached; + } + return gameState.maxFloorReached; + case 'combo': + return gameState.combo?.maxCombo || 0; + case 'spells': + return gameState.totalSpellsCast || 0; + case 'damage': + return gameState.totalDamageDealt || 0; + case 'mana': + return gameState.totalManaGathered || 0; + case 'pact': + return gameState.signedPacts.length; + case 'craft': + return gameState.totalCraftsCompleted || 0; + default: + return achievements.progress[achievementId] || 0; + } + }; + + return ( + + + + + Achievements + + {unlockedCount} / {totalCount} + + + + + +
+ {Object.entries(categories).map(([category, categoryAchievements]) => ( +
+ + + {expandedCategory === category && ( +
+ {categoryAchievements.map((achievement) => { + const isUnlocked = achievements.unlocked.includes(achievement.id); + const progress = getProgress(achievement.id); + const isRevealed = isAchievementRevealed(achievement, progress); + const progressPercent = Math.min(100, (progress / achievement.requirement.value) * 100); + + if (!isRevealed && !isUnlocked) { + return ( +
+
+ + ??? +
+
+ ); + } + + return ( +
+
+
+ {isUnlocked ? ( + + ) : ( + + )} + + {achievement.name} + +
+ {achievement.reward.title && isUnlocked && ( + + Title + + )} +
+ +
+ {achievement.desc} +
+ + {!isUnlocked && ( +
+ +
+ {progress.toLocaleString()} / {achievement.requirement.value.toLocaleString()} + {progressPercent.toFixed(0)}% +
+
+ )} + + {isUnlocked && achievement.reward && ( +
+ Reward: + {achievement.reward.insight && ` +${achievement.reward.insight} Insight`} + {achievement.reward.manaBonus && ` +${achievement.reward.manaBonus} Max Mana`} + {achievement.reward.damageBonus && ` +${(achievement.reward.damageBonus * 100).toFixed(0)}% Damage`} + {achievement.reward.title && ` "${achievement.reward.title}"`} +
+ )} +
+ ); + })} +
+ )} +
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/game/ComboMeter.tsx b/src/components/game/ComboMeter.tsx new file mode 100644 index 0000000..12e39d6 --- /dev/null +++ b/src/components/game/ComboMeter.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { Progress } from '@/components/ui/progress'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Zap, Flame, Sparkles } from 'lucide-react'; +import type { ComboState } from '@/lib/game/types'; +import { ELEMENTS } from '@/lib/game/constants'; + +interface ComboMeterProps { + combo: ComboState; + isClimbing: boolean; +} + +export function ComboMeter({ combo, isClimbing }: ComboMeterProps) { + const comboPercent = Math.min(100, combo.count); + const multiplierPercent = Math.min(100, ((combo.multiplier - 1) / 2) * 100); // Max 300% = 200% bonus + + // Combo tier names + const getComboTier = (count: number): { name: string; color: string } => { + if (count >= 100) return { name: 'LEGENDARY', color: 'text-amber-400' }; + if (count >= 75) return { name: 'Master', color: 'text-purple-400' }; + if (count >= 50) return { name: 'Expert', color: 'text-blue-400' }; + if (count >= 25) return { name: 'Adept', color: 'text-green-400' }; + if (count >= 10) return { name: 'Novice', color: 'text-cyan-400' }; + return { name: 'Building...', color: 'text-gray-400' }; + }; + + const tier = getComboTier(combo.count); + const hasElementChain = combo.elementChain.length === 3 && new Set(combo.elementChain).size === 3; + + if (!isClimbing && combo.count === 0) { + return null; + } + + return ( + + + + + Combo Meter + {combo.count >= 10 && ( + + {tier.name} + + )} + + + + {/* Combo Count */} +
+
+ Hits + + {combo.count} + {combo.maxCombo > combo.count && ( + max: {combo.maxCombo} + )} + +
+ +
+ + {/* Multiplier */} +
+
+ Multiplier + + {combo.multiplier.toFixed(2)}x + +
+
+
+
+
+ + {/* Element Chain */} + {combo.elementChain.length > 0 && ( +
+
+ Element Chain + {hasElementChain && ( + +25% bonus! + )} +
+
+ {combo.elementChain.map((elem, i) => { + const elemDef = ELEMENTS[elem]; + return ( +
+ {elemDef?.sym || '?'} +
+ ); + })} + {/* Empty slots */} + {Array.from({ length: 3 - combo.elementChain.length }).map((_, i) => ( +
+ ? +
+ ))} +
+
+ )} + + {/* Decay Warning */} + {isClimbing && combo.count > 0 && combo.decayTimer <= 3 && ( +
+ + Combo decaying soon! +
+ )} + + {/* Not climbing warning */} + {!isClimbing && combo.count > 0 && ( +
+ + Resume climbing to maintain combo +
+ )} + + + ); +} diff --git a/src/components/game/LootInventory.tsx b/src/components/game/LootInventory.tsx new file mode 100644 index 0000000..250ea3a --- /dev/null +++ b/src/components/game/LootInventory.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Gem, Sparkles, Scroll, Droplet } from 'lucide-react'; +import type { LootInventory as LootInventoryType } from '@/lib/game/types'; +import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops'; + +interface LootInventoryProps { + inventory: LootInventoryType; +} + +export function LootInventoryDisplay({ inventory }: LootInventoryProps) { + const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0); + const blueprintCount = inventory.blueprints.length; + + if (materialCount === 0 && blueprintCount === 0) { + return ( + + + + + Loot Inventory + + + +
+ No loot collected yet. Defeat floors and guardians to find items! +
+
+
+ ); + } + + return ( + + + + + Loot Inventory + + {materialCount + blueprintCount} items + + + + + +
+ {/* Materials */} + {Object.entries(inventory.materials).length > 0 && ( +
+
+ + Materials +
+
+ {Object.entries(inventory.materials).map(([id, count]) => { + const drop = LOOT_DROPS[id]; + if (!drop || count <= 0) return null; + const rarityStyle = RARITY_COLORS[drop.rarity]; + return ( +
+
+ {drop.name} +
+
+ x{count} +
+
+ ); + })} +
+
+ )} + + {/* Blueprints */} + {inventory.blueprints.length > 0 && ( +
+
+ + Blueprints Discovered +
+
+ {inventory.blueprints.map((id) => { + const drop = LOOT_DROPS[id]; + if (!drop) return null; + const rarityStyle = RARITY_COLORS[drop.rarity]; + return ( + + {drop.name} + + ); + })} +
+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/game/tabs/FamiliarTab.tsx b/src/components/game/tabs/FamiliarTab.tsx new file mode 100644 index 0000000..de18311 --- /dev/null +++ b/src/components/game/tabs/FamiliarTab.tsx @@ -0,0 +1,582 @@ +'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 = { + 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 = { + 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 = { + 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 = { + combat: Sword, + mana: Sparkles, + support: Heart, + guardian: ShieldCheck, +}; + +// Ability type icons +const ABILITY_ICONS: Record = { + 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['getActiveFamiliarBonuses'] extends () => infer R ? R : never; + getAvailableFamiliars: () => string[]; + }; +} + +export function FamiliarTab({ store }: FamiliarTabProps) { + const [selectedFamiliar, setSelectedFamiliar] = useState(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 ( + setSelectedFamiliar(isSelected ? null : index)} + > + +
+
+
+ +
+
+ + {instance.nickname || def.name} + +
+ + Lv.{instance.level} + {instance.active && ( + Active + )} +
+
+
+ + {def.rarity} + +
+
+ + {/* XP Bar */} +
+
+ XP + {formatXp(instance.experience, instance.level)} +
+ +
+ + {/* Bond Bar */} +
+
+ + Bond + + {bondPercent.toFixed(0)}% +
+ +
+ + {/* Abilities Preview */} +
+ {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 ( + + + + + {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)}`} + + + +

{abilityDef.desc}

+

Level {ability.level}/10

+
+
+ ); + })} +
+
+
+ ); + }; + + // 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 ( + + +
+ Familiar Details + +
+
+ + {/* Name and nickname */} +
+
+
+ +
+
+

+ {def.name} +

+ {instance.nickname && ( +

"{instance.nickname}"

+ )} +
+
+ + {/* Nickname input */} +
+ setNicknameInput(e.target.value)} + placeholder="Set nickname..." + className="h-8 text-sm bg-gray-800 border-gray-600" + /> + +
+
+ + {/* Description */} +
+ {def.desc} +
+ + {/* Stats */} +
+
+ Level: + {instance.level}/100 +
+
+ Bond: + {instance.bond.toFixed(0)}% +
+
+ Role: + {def.role} +
+
+ Element: + {def.element} +
+
+ + + + {/* Abilities */} +
+

Abilities

+ {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 ( +
+
+
+ + + {ability.type.replace(/([A-Z])/g, ' $1').trim()} + +
+ Lv.{ability.level}/10 +
+

{abilityDef.desc}

+
+ + 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' ? '%' : ''} + + {ability.level < 10 && ( + + )} +
+
+ ); + })} +
+ + {/* Activate/Deactivate */} + + + {/* Flavor text */} + {def.flavorText && ( +

+ "{def.flavorText}" +

+ )} +
+
+ ); + }; + + // Render summonable familiars + const renderSummonableFamiliars = () => { + if (availableFamiliars.length === 0) return null; + + return ( + + + + + Available to Summon ({availableFamiliars.length}) + + + + +
+ {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 ( +
+
+ +
+
+ {def.name} +
+
+ + {def.role} +
+
+
+ +
+ ); + })} +
+
+
+
+ ); + }; + + // 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 ( + + + + + Active Familiar Bonuses + + + +
+ {familiarBonuses.damageMultiplier > 1 && ( +
+ + +{((familiarBonuses.damageMultiplier - 1) * 100).toFixed(0)}% DMG +
+ )} + {familiarBonuses.manaRegenBonus > 0 && ( +
+ + +{familiarBonuses.manaRegenBonus.toFixed(1)} regen +
+ )} + {familiarBonuses.autoGatherRate > 0 && ( +
+ + +{familiarBonuses.autoGatherRate.toFixed(1)}/hr gather +
+ )} + {familiarBonuses.critChanceBonus > 0 && ( +
+ + +{familiarBonuses.critChanceBonus.toFixed(1)}% crit +
+ )} + {familiarBonuses.castSpeedMultiplier > 1 && ( +
+ + +{((familiarBonuses.castSpeedMultiplier - 1) * 100).toFixed(0)}% speed +
+ )} + {familiarBonuses.elementalDamageMultiplier > 1 && ( +
+ + +{((familiarBonuses.elementalDamageMultiplier - 1) * 100).toFixed(0)}% elem +
+ )} + {familiarBonuses.lifeStealPercent > 0 && ( +
+ + +{familiarBonuses.lifeStealPercent.toFixed(0)}% lifesteal +
+ )} + {familiarBonuses.insightMultiplier > 1 && ( +
+ + +{((familiarBonuses.insightMultiplier - 1) * 100).toFixed(0)}% insight +
+ )} +
+
+
+ ); + }; + + return ( + +
+ {/* Owned Familiars */} + + +
+ + + Your Familiars ({familiars.length}) + +
+ Active Slots: {activeCount}/{activeFamiliarSlots} +
+
+
+ + {familiars.length > 0 ? ( +
+ {familiars.map((instance, index) => renderFamiliarCard(instance, index))} +
+ ) : ( +
+ No familiars yet. Progress through the game to summon companions! +
+ )} +
+
+ + {/* Active Bonuses */} + {renderActiveBonuses()} + + {/* Selected Familiar Details */} + {renderFamiliarDetails()} + + {/* Summonable Familiars */} + {renderSummonableFamiliars()} + + {/* Familiar Guide */} + + + + + Familiar Guide + + + +
+
+

Acquiring Familiars

+

Familiars become available to summon as you progress through floors, gather mana, and sign pacts with guardians. Higher rarity familiars are unlocked later.

+
+
+

Leveling & Bond

+

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.

+
+
+

Roles

+

+ Combat - Damage and crit bonuses
+ Mana - Regeneration and auto-gathering
+ Support - Speed and utility
+ Guardian - Defense and shields +

+
+
+

Active Slots

+

You can have 1 familiar active by default. Upgrade through prestige to unlock more active slots for stacking bonuses.

+
+
+
+
+
+
+ ); +} diff --git a/src/lib/game/data/achievements.ts b/src/lib/game/data/achievements.ts new file mode 100644 index 0000000..1b3fc24 --- /dev/null +++ b/src/lib/game/data/achievements.ts @@ -0,0 +1,272 @@ +// โ”€โ”€โ”€ Achievement Definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +import type { AchievementDef } from '../types'; + +export const ACHIEVEMENTS: Record = { + // โ”€โ”€โ”€ Combat Achievements โ”€โ”€โ”€ + firstBlood: { + id: 'firstBlood', + name: 'First Blood', + desc: 'Clear your first floor', + category: 'combat', + requirement: { type: 'floor', value: 2 }, + reward: { insight: 10 }, + }, + floorClimber: { + id: 'floorClimber', + name: 'Floor Climber', + desc: 'Reach floor 10', + category: 'combat', + requirement: { type: 'floor', value: 10 }, + reward: { insight: 25, manaBonus: 10 }, + }, + spireAssault: { + id: 'spireAssault', + name: 'Spire Assault', + desc: 'Reach floor 25', + category: 'combat', + requirement: { type: 'floor', value: 25 }, + reward: { insight: 50, damageBonus: 0.05 }, + }, + towerConqueror: { + id: 'towerConqueror', + name: 'Tower Conqueror', + desc: 'Reach floor 50', + category: 'combat', + requirement: { type: 'floor', value: 50 }, + reward: { insight: 100, manaBonus: 50, damageBonus: 0.1 }, + }, + spireMaster: { + id: 'spireMaster', + name: 'Spire Master', + desc: 'Reach floor 75', + category: 'combat', + requirement: { type: 'floor', value: 75 }, + reward: { insight: 200, damageBonus: 0.15, title: 'Spire Master' }, + }, + apexReached: { + id: 'apexReached', + name: 'Apex Reached', + desc: 'Reach floor 100', + category: 'combat', + requirement: { type: 'floor', value: 100 }, + reward: { insight: 500, manaBonus: 200, damageBonus: 0.25, title: 'Apex Climber' }, + }, + + // โ”€โ”€โ”€ Combo Achievements โ”€โ”€โ”€ + comboStarter: { + id: 'comboStarter', + name: 'Combo Starter', + desc: 'Reach a 10-hit combo', + category: 'combat', + requirement: { type: 'combo', value: 10 }, + reward: { insight: 15 }, + }, + comboMaster: { + id: 'comboMaster', + name: 'Combo Master', + desc: 'Reach a 50-hit combo', + category: 'combat', + requirement: { type: 'combo', value: 50 }, + reward: { insight: 50, damageBonus: 0.05 }, + }, + comboLegend: { + id: 'comboLegend', + name: 'Combo Legend', + desc: 'Reach a 100-hit combo', + category: 'combat', + requirement: { type: 'combo', value: 100 }, + reward: { insight: 150, damageBonus: 0.1, title: 'Combo Legend' }, + }, + + // โ”€โ”€โ”€ Damage Achievements โ”€โ”€โ”€ + hundredDamage: { + id: 'hundredDamage', + name: 'Heavy Hitter', + desc: 'Deal 100 damage in a single hit', + category: 'combat', + requirement: { type: 'damage', value: 100 }, + reward: { insight: 20 }, + }, + thousandDamage: { + id: 'thousandDamage', + name: 'Devastating Blow', + desc: 'Deal 1,000 damage in a single hit', + category: 'combat', + requirement: { type: 'damage', value: 1000 }, + reward: { insight: 75, damageBonus: 0.03 }, + }, + tenThousandDamage: { + id: 'tenThousandDamage', + name: 'Apocalypse Now', + desc: 'Deal 10,000 damage in a single hit', + category: 'combat', + requirement: { type: 'damage', value: 10000 }, + reward: { insight: 200, damageBonus: 0.05, title: 'Apocalypse Bringer' }, + }, + + // โ”€โ”€โ”€ Pact Achievements โ”€โ”€โ”€ + pactSeeker: { + id: 'pactSeeker', + name: 'Pact Seeker', + desc: 'Sign your first guardian pact', + category: 'progression', + requirement: { type: 'pact', value: 1 }, + reward: { insight: 30 }, + }, + pactCollector: { + id: 'pactCollector', + name: 'Pact Collector', + desc: 'Sign 5 guardian pacts', + category: 'progression', + requirement: { type: 'pact', value: 5 }, + reward: { insight: 100, manaBonus: 25 }, + }, + pactMaster: { + id: 'pactMaster', + name: 'Pact Master', + desc: 'Sign all guardian pacts', + category: 'progression', + requirement: { type: 'pact', value: 12 }, + reward: { insight: 500, damageBonus: 0.2, title: 'Pact Master' }, + }, + + // โ”€โ”€โ”€ Magic Achievements โ”€โ”€โ”€ + spellCaster: { + id: 'spellCaster', + name: 'Spell Caster', + desc: 'Cast 100 spells', + category: 'magic', + requirement: { type: 'spells', value: 100 }, + reward: { insight: 25 }, + }, + spellWeaver: { + id: 'spellWeaver', + name: 'Spell Weaver', + desc: 'Cast 1,000 spells', + category: 'magic', + requirement: { type: 'spells', value: 1000 }, + reward: { insight: 75, regenBonus: 0.5 }, + }, + spellStorm: { + id: 'spellStorm', + name: 'Spell Storm', + desc: 'Cast 10,000 spells', + category: 'magic', + requirement: { type: 'spells', value: 10000 }, + reward: { insight: 200, regenBonus: 1, title: 'Storm Caller' }, + }, + + // โ”€โ”€โ”€ Mana Achievements โ”€โ”€โ”€ + manaPool: { + id: 'manaPool', + name: 'Mana Pool', + desc: 'Accumulate 1,000 total mana', + category: 'magic', + requirement: { type: 'mana', value: 1000 }, + reward: { insight: 20 }, + }, + manaLake: { + id: 'manaLake', + name: 'Mana Lake', + desc: 'Accumulate 100,000 total mana', + category: 'magic', + requirement: { type: 'mana', value: 100000 }, + reward: { insight: 100, manaBonus: 50 }, + }, + manaOcean: { + id: 'manaOcean', + name: 'Mana Ocean', + desc: 'Accumulate 10,000,000 total mana', + category: 'magic', + requirement: { type: 'mana', value: 10000000 }, + reward: { insight: 500, manaBonus: 200, title: 'Mana Ocean' }, + }, + + // โ”€โ”€โ”€ Crafting Achievements โ”€โ”€โ”€ + enchanter: { + id: 'enchanter', + name: 'Enchanter', + desc: 'Complete your first enchantment', + category: 'crafting', + requirement: { type: 'craft', value: 1 }, + reward: { insight: 30 }, + }, + masterEnchanter: { + id: 'masterEnchanter', + name: 'Master Enchanter', + desc: 'Complete 10 enchantments', + category: 'crafting', + requirement: { type: 'craft', value: 10 }, + reward: { insight: 100, unlockEffect: 'efficiencyBoost' }, + }, + legendaryEnchanter: { + id: 'legendaryEnchanter', + name: 'Legendary Enchanter', + desc: 'Complete 50 enchantments', + category: 'crafting', + requirement: { type: 'craft', value: 50 }, + reward: { insight: 300, title: 'Legendary Enchanter' }, + }, + + // โ”€โ”€โ”€ Special Achievements โ”€โ”€โ”€ + speedRunner: { + id: 'speedRunner', + name: 'Speed Runner', + desc: 'Reach floor 50 in under 5 days', + category: 'special', + requirement: { type: 'time', value: 5, subType: 'floor50' }, + reward: { insight: 200, title: 'Speed Demon' }, + hidden: true, + }, + perfectionist: { + id: 'perfectionist', + name: 'Perfectionist', + desc: 'Reach floor 100 without any guardian pacts', + category: 'special', + requirement: { type: 'floor', value: 100, subType: 'noPacts' }, + reward: { insight: 1000, title: 'Perfect Climber' }, + hidden: true, + }, + survivor: { + id: 'survivor', + name: 'Survivor', + desc: 'Complete a loop during full incursion (day 30+)', + category: 'special', + requirement: { type: 'time', value: 30 }, + reward: { insight: 300, manaBonus: 100, title: 'Survivor' }, + }, +}; + +// Category colors for UI +export const ACHIEVEMENT_CATEGORY_COLORS: Record = { + combat: '#EF4444', // Red + progression: '#F59E0B', // Amber + crafting: '#8B5CF6', // Purple + magic: '#3B82F6', // Blue + special: '#EC4899', // Pink +}; + +// Get achievements by category +export function getAchievementsByCategory(): Record { + const result: Record = {}; + + for (const achievement of Object.values(ACHIEVEMENTS)) { + if (!result[achievement.category]) { + result[achievement.category] = []; + } + result[achievement.category].push(achievement); + } + + return result; +} + +// Check if an achievement should be revealed +export function isAchievementRevealed( + achievement: AchievementDef, + progress: number +): boolean { + if (!achievement.hidden) return true; + // Reveal hidden achievements when at 50% progress + return progress >= achievement.requirement.value * 0.5; +} diff --git a/src/lib/game/data/familiars.ts b/src/lib/game/data/familiars.ts new file mode 100644 index 0000000..433b499 --- /dev/null +++ b/src/lib/game/data/familiars.ts @@ -0,0 +1,498 @@ +// โ”€โ”€โ”€ 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 = { + // === 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'; diff --git a/src/lib/game/data/loot-drops.ts b/src/lib/game/data/loot-drops.ts new file mode 100644 index 0000000..288b815 --- /dev/null +++ b/src/lib/game/data/loot-drops.ts @@ -0,0 +1,242 @@ +// โ”€โ”€โ”€ Loot Drop Definitions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +import type { LootDrop } from '../types'; + +export const LOOT_DROPS: Record = { + // โ”€โ”€โ”€ Materials (used for crafting) โ”€โ”€โ”€ + manaCrystalDust: { + id: 'manaCrystalDust', + name: 'Mana Crystal Dust', + rarity: 'common', + type: 'material', + minFloor: 1, + dropChance: 0.15, + }, + arcaneShard: { + id: 'arcaneShard', + name: 'Arcane Shard', + rarity: 'uncommon', + type: 'material', + minFloor: 10, + dropChance: 0.10, + }, + elementalCore: { + id: 'elementalCore', + name: 'Elemental Core', + rarity: 'rare', + type: 'material', + minFloor: 25, + dropChance: 0.08, + }, + voidEssence: { + id: 'voidEssence', + name: 'Void Essence', + rarity: 'epic', + type: 'material', + minFloor: 50, + dropChance: 0.05, + guardianOnly: true, + }, + celestialFragment: { + id: 'celestialFragment', + name: 'Celestial Fragment', + rarity: 'legendary', + type: 'material', + minFloor: 75, + dropChance: 0.02, + guardianOnly: true, + }, + + // โ”€โ”€โ”€ Elemental Essence (grants elemental mana) โ”€โ”€โ”€ + fireEssenceDrop: { + id: 'fireEssenceDrop', + name: 'Fire Essence', + rarity: 'uncommon', + type: 'essence', + minFloor: 5, + dropChance: 0.12, + amount: { min: 5, max: 15 }, + }, + waterEssenceDrop: { + id: 'waterEssenceDrop', + name: 'Water Essence', + rarity: 'uncommon', + type: 'essence', + minFloor: 5, + dropChance: 0.12, + amount: { min: 5, max: 15 }, + }, + airEssenceDrop: { + id: 'airEssenceDrop', + name: 'Air Essence', + rarity: 'uncommon', + type: 'essence', + minFloor: 5, + dropChance: 0.12, + amount: { min: 5, max: 15 }, + }, + earthEssenceDrop: { + id: 'earthEssenceDrop', + name: 'Earth Essence', + rarity: 'uncommon', + type: 'essence', + minFloor: 5, + dropChance: 0.12, + amount: { min: 5, max: 15 }, + }, + lightEssenceDrop: { + id: 'lightEssenceDrop', + name: 'Light Essence', + rarity: 'rare', + type: 'essence', + minFloor: 20, + dropChance: 0.08, + amount: { min: 3, max: 10 }, + }, + darkEssenceDrop: { + id: 'darkEssenceDrop', + name: 'Dark Essence', + rarity: 'rare', + type: 'essence', + minFloor: 20, + dropChance: 0.08, + amount: { min: 3, max: 10 }, + }, + lifeEssenceDrop: { + id: 'lifeEssenceDrop', + name: 'Life Essence', + rarity: 'epic', + type: 'essence', + minFloor: 40, + dropChance: 0.05, + amount: { min: 2, max: 8 }, + }, + deathEssenceDrop: { + id: 'deathEssenceDrop', + name: 'Death Essence', + rarity: 'epic', + type: 'essence', + minFloor: 40, + dropChance: 0.05, + amount: { min: 2, max: 8 }, + }, + + // โ”€โ”€โ”€ Raw Mana Drops โ”€โ”€โ”€ + manaOrb: { + id: 'manaOrb', + name: 'Mana Orb', + rarity: 'common', + type: 'gold', // Uses gold type but gives raw mana + minFloor: 1, + dropChance: 0.20, + amount: { min: 10, max: 50 }, + }, + greaterManaOrb: { + id: 'greaterManaOrb', + name: 'Greater Mana Orb', + rarity: 'uncommon', + type: 'gold', + minFloor: 15, + dropChance: 0.10, + amount: { min: 50, max: 150 }, + }, + supremeManaOrb: { + id: 'supremeManaOrb', + name: 'Supreme Mana Orb', + rarity: 'rare', + type: 'gold', + minFloor: 35, + dropChance: 0.05, + amount: { min: 100, max: 500 }, + }, + + // โ”€โ”€โ”€ Equipment Blueprints โ”€โ”€โ”€ + staffBlueprint: { + id: 'staffBlueprint', + name: 'Staff Blueprint', + rarity: 'uncommon', + type: 'blueprint', + minFloor: 10, + dropChance: 0.03, + }, + wandBlueprint: { + id: 'wandBlueprint', + name: 'Wand Blueprint', + rarity: 'rare', + type: 'blueprint', + minFloor: 20, + dropChance: 0.02, + }, + robeBlueprint: { + id: 'robeBlueprint', + name: 'Mage Robe Blueprint', + rarity: 'rare', + type: 'blueprint', + minFloor: 25, + dropChance: 0.02, + }, + artifactBlueprint: { + id: 'artifactBlueprint', + name: 'Artifact Blueprint', + rarity: 'legendary', + type: 'blueprint', + minFloor: 60, + dropChance: 0.01, + guardianOnly: true, + }, +}; + +// Rarity colors for UI +export const RARITY_COLORS: Record = { + common: { color: '#9CA3AF', glow: '#9CA3AF40' }, + uncommon: { color: '#22C55E', glow: '#22C55E40' }, + rare: { color: '#3B82F6', glow: '#3B82F640' }, + epic: { color: '#A855F7', glow: '#A855F740' }, + legendary: { color: '#F59E0B', glow: '#F59E0B60' }, +}; + +// Get loot drops available at a given floor +export function getAvailableDrops(floor: number, isGuardian: boolean): LootDrop[] { + return Object.values(LOOT_DROPS).filter(drop => { + if (drop.minFloor > floor) return false; + if (drop.guardianOnly && !isGuardian) return false; + return true; + }); +} + +// Roll for loot drops +export function rollLootDrops( + floor: number, + isGuardian: boolean, + luckBonus: number = 0 +): Array<{ drop: LootDrop; amount: number }> { + const available = getAvailableDrops(floor, isGuardian); + const drops: Array<{ drop: LootDrop; amount: number }> = []; + + for (const drop of available) { + // Calculate adjusted drop chance + let chance = drop.dropChance; + chance *= (1 + luckBonus); // Apply luck bonus + + // Guardian floors have 2x drop rate + if (isGuardian) chance *= 2; + + // Cap at 50% for any single drop + chance = Math.min(0.5, chance); + + if (Math.random() < chance) { + let amount = 1; + + // For gold/essence types, roll amount + if (drop.amount) { + amount = Math.floor( + Math.random() * (drop.amount.max - drop.amount.min + 1) + drop.amount.min + ); + } + + drops.push({ drop, amount }); + } + } + + return drops; +} diff --git a/src/lib/game/familiar-slice.ts b/src/lib/game/familiar-slice.ts new file mode 100644 index 0000000..8f06c69 --- /dev/null +++ b/src/lib/game/familiar-slice.ts @@ -0,0 +1,367 @@ +// โ”€โ”€โ”€ 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) => 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, + 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 + }]; +} diff --git a/src/lib/game/store.ts b/src/lib/game/store.ts index 436659e..0c0acb3 100755 --- a/src/lib/game/store.ts +++ b/src/lib/game/store.ts @@ -39,6 +39,15 @@ import { } from './crafting-slice'; import { EQUIPMENT_TYPES } from './data/equipment'; import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects'; +import { + createFamiliarSlice, + processFamiliarTick, + grantStartingFamiliar, + type FamiliarActions, + type FamiliarBonuses, + DEFAULT_FAMILIAR_BONUSES, +} from './familiar-slice'; +import { rollLootDrops } from './data/loot-drops'; // Default empty effects for when effects aren't provided const DEFAULT_EFFECTS: ComputedEffects = { @@ -528,14 +537,48 @@ function makeInitial(overrides: Partial = {}): GameState { incursionStrength: 0, containmentWards: 0, - log: ['โœจ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. Gather your strength, mage.'], + // Combo System + combo: { + count: 0, + multiplier: 1, + lastCastTime: 0, + decayTimer: 0, + maxCombo: 0, + elementChain: [], + }, + totalTicks: 0, + + // Loot System + lootInventory: { + materials: {}, + essence: {}, + blueprints: [], + }, + lootDropsToday: 0, + + // Achievements + achievements: { + unlocked: [], + progress: {}, + }, + totalDamageDealt: 0, + totalSpellsCast: 0, + totalCraftsCompleted: 0, + + // Familiars + 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, }; } // โ”€โ”€โ”€ Game Store โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -interface GameStore extends GameState, CraftingActions { +interface GameStore extends GameState, CraftingActions, FamiliarActions { // Actions tick: () => void; gatherMana: () => void; @@ -573,6 +616,7 @@ export const useGameStore = create()( persist( (set, get) => ({ ...makeInitial(), + ...createFamiliarSlice(set, get), getMaxMana: () => computeMaxMana(get()), getRegen: () => computeRegen(get()), @@ -600,8 +644,16 @@ export const useGameStore = create()( // Compute unified effects (includes skill upgrades AND equipment enchantments) 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 baseRegen = computeRegen(state, effects); + const baseRegen = computeRegen(state, effects) + familiarBonuses.manaRegenBonus; // Time progression let hour = state.hour + HOURS_PER_TICK; @@ -653,18 +705,29 @@ export const useGameStore = create()( // Calculate effective regen with incursion and meditation const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier; - + // Mana regeneration let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana); let totalManaGathered = state.totalManaGathered; + // Familiar auto-gather and auto-convert + 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 let currentStudyTarget = state.currentStudyTarget; let skills = state.skills; let skillProgress = state.skillProgress; let spells = state.spells; let log = state.log; - let elements = state.elements; let unlockedEffects = state.unlockedEffects; if (state.currentAction === 'study' && currentStudyTarget) { @@ -738,8 +801,27 @@ export const useGameStore = create()( } // Combat - MULTI-SPELL casting from all equipped weapons - let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates } = state; + let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates, combo, totalTicks, lootInventory, achievements, totalDamageDealt, totalSpellsCast } = state; const floorElement = getFloorElement(currentFloor); + + // Increment total ticks + const newTotalTicks = totalTicks + 1; + + // Combo decay - decay combo when not climbing or when decay timer expires + let newCombo = { ...combo }; + if (state.currentAction !== 'climb') { + // Rapidly decay combo when not climbing + newCombo.count = Math.max(0, newCombo.count - 5); + newCombo.multiplier = 1 + newCombo.count * 0.02; + } else if (newCombo.count > 0) { + // Slow decay while climbing but not casting + newCombo.decayTimer--; + if (newCombo.decayTimer <= 0) { + newCombo.count = Math.max(0, newCombo.count - 2); + newCombo.multiplier = 1 + newCombo.count * 0.02; + newCombo.decayTimer = 10; + } + } if (state.currentAction === 'climb') { // Get all spells from equipped caster weapons @@ -768,7 +850,7 @@ export const useGameStore = create()( // Compute attack speed from quickCast skill and upgrades const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05; - const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier; + const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier * familiarBonuses.castSpeedMultiplier; // Process each active spell for (const { spellId, equipmentId } of activeSpells) { @@ -796,9 +878,41 @@ export const useGameStore = create()( elements = afterCost.elements; totalManaGathered += spellDef.cost.amount; + // Increment spell cast counter + totalSpellsCast++; + + // โ”€โ”€โ”€ Combo System โ”€โ”€โ”€ + // Build combo on each cast + newCombo.count = Math.min(100, newCombo.count + 1); + newCombo.lastCastTime = newTotalTicks; + newCombo.decayTimer = 10; // Reset decay timer + newCombo.maxCombo = Math.max(newCombo.maxCombo, newCombo.count); + + // Track element chain + const spellElement = spellDef.elem; + newCombo.elementChain = [...newCombo.elementChain.slice(-2), spellElement]; + + // Calculate combo multiplier + let comboMult = 1 + newCombo.count * 0.02; // +2% per combo + + // Element chain bonus: +25% if last 3 spells were different elements + const uniqueElements = new Set(newCombo.elementChain); + if (newCombo.elementChain.length === 3 && uniqueElements.size === 3) { + comboMult += 0.25; + // Log elemental chain occasionally + if (newCombo.count % 10 === 0) { + log = [`๐ŸŒˆ Elemental Chain! (${newCombo.elementChain.join(' โ†’ ')})`, ...log.slice(0, 49)]; + } + } + + newCombo.multiplier = Math.min(3.0, comboMult); + // Calculate damage let dmg = calcDamage(state, spellId, floorElement); + // Apply combo multiplier FIRST + dmg *= newCombo.multiplier; + // Apply upgrade damage multipliers and bonuses dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus; @@ -811,7 +925,16 @@ export const useGameStore = create()( if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { dmg *= 1.5; } - + + // 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 const echoChance = (skills.spellEcho || 0) * 0.1; if (Math.random() < echoChance) { @@ -825,6 +948,15 @@ export const useGameStore = create()( const healAmount = dmg * lifestealEffect.value; 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 + totalDamageDealt += dmg; // Apply damage floorHP = Math.max(0, floorHP - dmg); @@ -835,6 +967,33 @@ export const useGameStore = create()( if (floorHP <= 0) { // Floor cleared const wasGuardian = GUARDIANS[currentFloor]; + + // โ”€โ”€โ”€ Loot Drop System โ”€โ”€โ”€ + const lootDrops = rollLootDrops(currentFloor, !!wasGuardian, 0); + + for (const { drop, amount } of lootDrops) { + if (drop.type === 'material') { + lootInventory.materials[drop.id] = (lootInventory.materials[drop.id] || 0) + amount; + log = [`๐Ÿ’Ž Found: ${drop.name}!`, ...log.slice(0, 49)]; + } else if (drop.type === 'essence' && drop.id) { + // Extract element from essence drop id (e.g., 'fireEssenceDrop' -> 'fire') + const element = drop.id.replace('EssenceDrop', ''); + if (elements[element]) { + const gain = Math.min(amount, elements[element].max - elements[element].current); + elements[element] = { ...elements[element], current: elements[element].current + gain }; + log = [`โœจ Gained ${gain} ${element} essence!`, ...log.slice(0, 49)]; + } + } else if (drop.type === 'gold') { + rawMana += amount; + log = [`๐Ÿ’ซ Gained ${amount} mana from ${drop.name}!`, ...log.slice(0, 49)]; + } else if (drop.type === 'blueprint') { + if (!lootInventory.blueprints.includes(drop.id)) { + lootInventory.blueprints.push(drop.id); + log = [`๐Ÿ“œ Discovered: ${drop.name}!`, ...log.slice(0, 49)]; + } + } + } + if (wasGuardian && !signedPacts.includes(currentFloor)) { signedPacts = [...signedPacts, currentFloor]; log = [`โš”๏ธ ${wasGuardian.name} defeated! Pact signed! (${wasGuardian.pact}x)`, ...log.slice(0, 49)]; @@ -852,6 +1011,11 @@ export const useGameStore = create()( floorHP = floorMaxHP; maxFloorReached = Math.max(maxFloorReached, currentFloor); + // Reset combo on floor change (partial reset - keep 50%) + newCombo.count = Math.floor(newCombo.count * 0.5); + newCombo.multiplier = 1 + newCombo.count * 0.02; + newCombo.elementChain = []; + // Reset ALL spell progress on floor change equipmentSpellStates = equipmentSpellStates.map(s => ({ ...s, castProgress: 0 })); spellState = { ...spellState, castProgress: 0 }; @@ -865,6 +1029,9 @@ export const useGameStore = create()( ); } } + + // Update combo state + combo = newCombo; // Process crafting actions (design, prepare, enchant) const craftingUpdates = processCraftingTick( @@ -912,11 +1079,47 @@ export const useGameStore = create()( spells, elements, log, - equipmentSpellStates, + equipmentSpellStates, + combo, + totalTicks: newTotalTicks, + lootInventory, + achievements, + totalDamageDealt, + totalSpellsCast, }); 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({ day, hour, @@ -937,6 +1140,13 @@ export const useGameStore = create()( unlockedEffects, log, equipmentSpellStates, + combo, + totalTicks: newTotalTicks, + lootInventory, + achievements, + totalDamageDealt, + totalSpellsCast, + familiars, ...craftingUpdates, }); }, diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts index e8d0883..fee633e 100755 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -213,6 +213,85 @@ export interface EquipmentSpellState { castProgress: number; // 0-1 progress toward next cast } +// โ”€โ”€โ”€ Combo System โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface ComboState { + count: number; // Number of consecutive spell casts + multiplier: number; // Current damage multiplier (1.0 = base) + lastCastTime: number; // Game tick when last spell was cast + decayTimer: number; // Ticks until combo decays + maxCombo: number; // Highest combo achieved this loop + elementChain: string[]; // Last 3 elements cast (for elemental chain bonus) +} + +export const COMBO_CONFIG = { + maxCombo: 100, // Maximum combo count + baseDecayTicks: 10, // Ticks before combo starts decaying + decayRate: 2, // Combo lost per decay tick + baseMultiplier: 0.02, // +2% damage per combo + maxMultiplier: 3.0, // 300% max multiplier + elementalChainBonus: 0.25, // +25% for 3 different elements + perfectChainBonus: 0.5, // +50% for perfect element wheel +} as const; + +// โ”€โ”€โ”€ Loot Drop System โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface LootDrop { + id: string; + name: string; + rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + type: 'equipment' | 'material' | 'gold' | 'essence' | 'blueprint'; + minFloor: number; // Minimum floor for this drop + dropChance: number; // Base drop chance (0-1) + guardianOnly?: boolean; // Only drops from guardians + effect?: LootEffect; + amount?: { min: number; max: number }; // For gold/essence +} + +export interface LootEffect { + type: 'manaBonus' | 'damageBonus' | 'regenBonus' | 'castSpeed' | 'critChance' | 'special'; + value: number; + desc: string; +} + +export interface LootInventory { + materials: Record; // materialId -> count + essence: Record; // element -> count + blueprints: string[]; // unlocked blueprint IDs +} + +// โ”€โ”€โ”€ Achievement System โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface AchievementDef { + id: string; + name: string; + desc: string; + category: 'combat' | 'progression' | 'crafting' | 'magic' | 'special'; + requirement: AchievementRequirement; + reward: AchievementReward; + hidden?: boolean; // Hidden until unlocked +} + +export interface AchievementRequirement { + type: 'floor' | 'combo' | 'spells' | 'damage' | 'mana' | 'craft' | 'pact' | 'time'; + value: number; + subType?: string; // e.g., specific element, spell type +} + +export interface AchievementReward { + insight?: number; + manaBonus?: number; + regenBonus?: number; + damageBonus?: number; + unlockEffect?: string; // Unlocks an enchantment effect + title?: string; // Display title +} + +export interface AchievementState { + unlocked: string[]; // Achievement IDs + progress: Record; // achievementId -> progress +} + export interface BlueprintDef { id: string; name: string; @@ -226,6 +305,69 @@ export interface BlueprintDef { 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 interface ScheduleBlock { @@ -326,6 +468,26 @@ export interface GameState { incursionStrength: number; containmentWards: number; + // Combo System + combo: ComboState; + totalTicks: number; // Total ticks this loop (for combo timing) + + // Loot System + lootInventory: LootInventory; + lootDropsToday: number; // Track drops for diminishing returns + + // Achievements + achievements: AchievementState; + totalDamageDealt: number; // For damage achievements + totalSpellsCast: number; // For spell 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: string[];