All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m45s
1901 lines
64 KiB
TypeScript
Executable File
1901 lines
64 KiB
TypeScript
Executable File
// ─── Game Store ───────────────────────────────────────────────────────────────
|
|
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import type { GameState, GameAction, StudyTarget, SpellCost, SkillUpgradeChoice, EquipmentSlot, EquipmentInstance, EnchantmentDesign, DesignEffect, AttunementState } from './types';
|
|
import {
|
|
ELEMENTS,
|
|
GUARDIANS,
|
|
SPELLS_DEF,
|
|
SKILLS_DEF,
|
|
PRESTIGE_DEF,
|
|
FLOOR_ELEM_CYCLE,
|
|
BASE_UNLOCKED_ELEMENTS,
|
|
TICK_MS,
|
|
HOURS_PER_TICK,
|
|
MAX_DAY,
|
|
INCURSION_START_DAY,
|
|
MANA_PER_ELEMENT,
|
|
getStudySpeedMultiplier,
|
|
getStudyCostMultiplier,
|
|
ELEMENT_OPPOSITES,
|
|
EFFECT_RESEARCH_MAPPING,
|
|
BASE_UNLOCKED_EFFECTS,
|
|
ENCHANTING_UNLOCK_EFFECTS,
|
|
} from './constants';
|
|
import { computeEffects, hasSpecial, SPECIAL_EFFECTS, type ComputedEffects } from './upgrade-effects';
|
|
import {
|
|
computeAllEffects,
|
|
getUnifiedEffects,
|
|
computeEquipmentEffects,
|
|
type UnifiedEffects
|
|
} from './effects';
|
|
import { SKILL_EVOLUTION_PATHS } from './skill-evolution';
|
|
import {
|
|
createStartingEquipment,
|
|
processCraftingTick,
|
|
getSpellsFromEquipment,
|
|
type CraftingActions
|
|
} from './crafting-slice';
|
|
import { EQUIPMENT_TYPES } from './data/equipment';
|
|
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
|
import { ATTUNEMENTS_DEF, getTotalAttunementRegen, getAttunementConversionRate, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from './data/attunements';
|
|
|
|
// Default empty effects for when effects aren't provided
|
|
const DEFAULT_EFFECTS: ComputedEffects = {
|
|
maxManaMultiplier: 1,
|
|
maxManaBonus: 0,
|
|
regenMultiplier: 1,
|
|
regenBonus: 0,
|
|
clickManaMultiplier: 1,
|
|
clickManaBonus: 0,
|
|
meditationEfficiency: 1,
|
|
spellCostMultiplier: 1,
|
|
conversionEfficiency: 1,
|
|
baseDamageMultiplier: 1,
|
|
baseDamageBonus: 0,
|
|
attackSpeedMultiplier: 1,
|
|
critChanceBonus: 0,
|
|
critDamageMultiplier: 1.5,
|
|
elementalDamageMultiplier: 1,
|
|
studySpeedMultiplier: 1,
|
|
studyCostMultiplier: 1,
|
|
progressRetention: 0,
|
|
instantStudyChance: 0,
|
|
freeStudyChance: 0,
|
|
elementCapMultiplier: 1,
|
|
elementCapBonus: 0,
|
|
conversionCostMultiplier: 1,
|
|
doubleCraftChance: 0,
|
|
permanentRegenBonus: 0,
|
|
specials: new Set(),
|
|
activeUpgrades: [],
|
|
};
|
|
|
|
// ─── Helper Functions ─────────────────────────────────────────────────────────
|
|
|
|
export function fmt(n: number): string {
|
|
if (!isFinite(n) || isNaN(n)) return '0';
|
|
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
|
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
|
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
return Math.floor(n).toString();
|
|
}
|
|
|
|
export function fmtDec(n: number, d: number = 1): string {
|
|
return isFinite(n) ? n.toFixed(d) : '0';
|
|
}
|
|
|
|
export function getFloorMaxHP(floor: number): number {
|
|
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
|
|
// Improved scaling: slower early game, faster late game
|
|
const baseHP = 100;
|
|
const floorScaling = floor * 50;
|
|
const exponentialScaling = Math.pow(floor, 1.7);
|
|
return Math.floor(baseHP + floorScaling + exponentialScaling);
|
|
}
|
|
|
|
export function getFloorElement(floor: number): string {
|
|
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
|
|
}
|
|
|
|
// ─── Computed Stats Functions ─────────────────────────────────────────────────
|
|
|
|
// Helper to get effective skill level accounting for tiers
|
|
function getEffectiveSkillLevel(
|
|
skills: Record<string, number>,
|
|
baseSkillId: string,
|
|
skillTiers: Record<string, number> = {}
|
|
): { level: number; tier: number; tierMultiplier: number } {
|
|
// Find the highest tier the player has for this base skill
|
|
const currentTier = skillTiers[baseSkillId] || 1;
|
|
|
|
// Look for the tiered skill ID (e.g., manaFlow_t2)
|
|
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
|
|
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
|
|
|
|
// Tier multiplier: each tier is 10x more powerful
|
|
const tierMultiplier = Math.pow(10, currentTier - 1);
|
|
|
|
return { level, tier: currentTier, tierMultiplier };
|
|
}
|
|
|
|
export function computeMaxMana(
|
|
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
|
effects?: ComputedEffects | UnifiedEffects
|
|
): number {
|
|
const pu = state.prestigeUpgrades;
|
|
const base =
|
|
100 +
|
|
(state.skills.manaWell || 0) * 100 +
|
|
(pu.manaWell || 0) * 500;
|
|
|
|
// If effects not provided, compute unified effects (includes equipment)
|
|
if (!effects && state.equipmentInstances && state.equippedInstances) {
|
|
effects = getUnifiedEffects(state as any);
|
|
}
|
|
|
|
// Apply effects if available (now includes equipment bonuses)
|
|
if (effects) {
|
|
return Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
|
|
}
|
|
return base;
|
|
}
|
|
|
|
export function computeElementMax(
|
|
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
|
|
effects?: ComputedEffects
|
|
): number {
|
|
const pu = state.prestigeUpgrades;
|
|
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
|
|
|
|
// Apply upgrade effects if provided
|
|
if (effects) {
|
|
return Math.floor((base + effects.elementCapBonus) * effects.elementCapMultiplier);
|
|
}
|
|
return base;
|
|
}
|
|
|
|
export function computeRegen(
|
|
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances' | 'attunements'>,
|
|
effects?: ComputedEffects | UnifiedEffects
|
|
): number {
|
|
const pu = state.prestigeUpgrades;
|
|
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
|
|
const base =
|
|
2 +
|
|
(state.skills.manaFlow || 0) * 1 +
|
|
(state.skills.manaSpring || 0) * 2 +
|
|
(pu.manaFlow || 0) * 0.5;
|
|
|
|
let regen = base * temporalBonus;
|
|
|
|
// Add attunement raw mana regen
|
|
const attunementRegen = getTotalAttunementRegen(state.attunements || {});
|
|
regen += attunementRegen;
|
|
|
|
// If effects not provided, compute unified effects (includes equipment)
|
|
if (!effects && state.equipmentInstances && state.equippedInstances) {
|
|
effects = getUnifiedEffects(state as any);
|
|
}
|
|
|
|
// Apply effects if available (now includes equipment bonuses)
|
|
if (effects) {
|
|
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
|
|
}
|
|
|
|
return regen;
|
|
}
|
|
|
|
/**
|
|
* Compute regen with dynamic special effects (needs current mana, max mana, incursion)
|
|
*/
|
|
export function computeEffectiveRegen(
|
|
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'rawMana' | 'incursionStrength' | 'skillUpgrades' | 'skillTiers'>,
|
|
effects?: ComputedEffects
|
|
): number {
|
|
// Base regen from existing function
|
|
let regen = computeRegen(state, effects);
|
|
|
|
const maxMana = computeMaxMana(state, effects);
|
|
const currentMana = state.rawMana;
|
|
const incursionStrength = state.incursionStrength || 0;
|
|
|
|
// Apply incursion penalty
|
|
regen *= (1 - incursionStrength);
|
|
|
|
return regen;
|
|
}
|
|
|
|
export function computeClickMana(
|
|
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers' | 'equipmentInstances' | 'equippedInstances'>,
|
|
effects?: ComputedEffects | UnifiedEffects
|
|
): number {
|
|
const base =
|
|
1 +
|
|
(state.skills.manaTap || 0) * 1 +
|
|
(state.skills.manaSurge || 0) * 3;
|
|
|
|
// If effects not provided, compute unified effects (includes equipment)
|
|
if (!effects && state.equipmentInstances && state.equippedInstances) {
|
|
effects = getUnifiedEffects(state as any);
|
|
}
|
|
|
|
// Apply effects if available (now includes equipment bonuses)
|
|
if (effects) {
|
|
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
|
|
}
|
|
return base;
|
|
}
|
|
|
|
// Elemental damage bonus: +50% if spell element opposes floor element (super effective)
|
|
// -25% if spell element matches its own opposite (weak)
|
|
function getElementalBonus(spellElem: string, floorElem: string): number {
|
|
if (spellElem === 'raw') return 1.0; // Raw mana has no elemental bonus
|
|
|
|
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
|
|
|
|
// Check for super effective first: spell is the opposite of floor
|
|
// e.g., casting water (opposite of fire) at fire floor = super effective
|
|
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective: +50% damage
|
|
|
|
// Check for weak: spell's opposite matches floor
|
|
// e.g., casting fire (whose opposite is water) at water floor = weak
|
|
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak: -25% damage
|
|
|
|
return 1.0; // Neutral
|
|
}
|
|
|
|
export function calcDamage(
|
|
state: Pick<GameState, 'skills' | 'signedPacts'>,
|
|
spellId: string,
|
|
floorElem?: string
|
|
): number {
|
|
const sp = SPELLS_DEF[spellId];
|
|
if (!sp) return 5;
|
|
const skills = state.skills;
|
|
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5;
|
|
const pct = 1 + (skills.arcaneFury || 0) * 0.1;
|
|
|
|
// Elemental mastery bonus
|
|
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15;
|
|
|
|
// Guardian bane bonus
|
|
const guardianBonus = floorElem && GUARDIANS[Object.values(GUARDIANS).find(g => g.element === floorElem)?.hp ? 0 : 0]
|
|
? 1 + (skills.guardianBane || 0) * 0.2
|
|
: 1;
|
|
|
|
const critChance = (skills.precision || 0) * 0.05;
|
|
const pactMult = state.signedPacts.reduce(
|
|
(m, f) => m * (GUARDIANS[f]?.pact || 1),
|
|
1
|
|
);
|
|
|
|
let damage = baseDmg * pct * pactMult * elemMasteryBonus;
|
|
|
|
// Apply elemental bonus if floor element provided
|
|
if (floorElem) {
|
|
damage *= getElementalBonus(sp.elem, floorElem);
|
|
}
|
|
|
|
// Apply crit
|
|
if (Math.random() < critChance) {
|
|
damage *= 1.5;
|
|
}
|
|
|
|
return damage;
|
|
}
|
|
|
|
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
|
|
const pu = state.prestigeUpgrades;
|
|
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
|
|
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
|
|
return Math.floor(
|
|
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
|
|
);
|
|
}
|
|
|
|
// Meditation bonus now affects regen rate directly
|
|
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
|
|
const hasMeditation = skills.meditation === 1;
|
|
const hasDeepTrance = skills.deepTrance === 1;
|
|
const hasVoidMeditation = skills.voidMeditation === 1;
|
|
|
|
const hours = meditateTicks * HOURS_PER_TICK;
|
|
|
|
// Base meditation: ramps up over 4 hours to 1.5x
|
|
let bonus = 1 + Math.min(hours / 4, 0.5);
|
|
|
|
// With Meditation Focus: up to 2.5x after 4 hours
|
|
if (hasMeditation && hours >= 4) {
|
|
bonus = 2.5;
|
|
}
|
|
|
|
// With Deep Trance: up to 3.0x after 6 hours
|
|
if (hasDeepTrance && hours >= 6) {
|
|
bonus = 3.0;
|
|
}
|
|
|
|
// With Void Meditation: up to 5.0x after 8 hours
|
|
if (hasVoidMeditation && hours >= 8) {
|
|
bonus = 5.0;
|
|
}
|
|
|
|
// Apply meditation efficiency from upgrades (Deep Wellspring, etc.)
|
|
bonus *= meditationEfficiency;
|
|
|
|
return bonus;
|
|
}
|
|
|
|
export function getIncursionStrength(day: number, hour: number): number {
|
|
if (day < INCURSION_START_DAY) return 0;
|
|
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
|
|
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
|
|
return Math.min(0.95, (totalHours / maxHours) * 0.95);
|
|
}
|
|
|
|
// Check if player can afford spell cost
|
|
export function canAffordSpellCost(
|
|
cost: SpellCost,
|
|
rawMana: number,
|
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
|
): boolean {
|
|
if (cost.type === 'raw') {
|
|
return rawMana >= cost.amount;
|
|
} else {
|
|
const elem = elements[cost.element || ''];
|
|
return elem && elem.unlocked && elem.current >= cost.amount;
|
|
}
|
|
}
|
|
|
|
// Deduct spell cost from appropriate mana pool
|
|
function deductSpellCost(
|
|
cost: SpellCost,
|
|
rawMana: number,
|
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
|
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
|
|
const newElements = { ...elements };
|
|
|
|
if (cost.type === 'raw') {
|
|
return { rawMana: rawMana - cost.amount, elements: newElements };
|
|
} else if (cost.element && newElements[cost.element]) {
|
|
newElements[cost.element] = {
|
|
...newElements[cost.element],
|
|
current: newElements[cost.element].current - cost.amount
|
|
};
|
|
return { rawMana, elements: newElements };
|
|
}
|
|
|
|
return { rawMana, elements: newElements };
|
|
}
|
|
|
|
// ─── Initial State Factory ────────────────────────────────────────────────────
|
|
|
|
function makeInitial(overrides: Partial<GameState> = {}): GameState {
|
|
const pu = overrides.prestigeUpgrades || {};
|
|
const startFloor = 1 + (pu.spireKey || 0) * 2;
|
|
const elemMax = computeElementMax({ skills: overrides.skills || {}, prestigeUpgrades: pu });
|
|
|
|
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
|
Object.keys(ELEMENTS).forEach((k) => {
|
|
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
|
|
let startAmount = 0;
|
|
|
|
// Start with some elemental mana if elemStart upgrade
|
|
if (isUnlocked && pu.elemStart) {
|
|
startAmount = pu.elemStart * 5;
|
|
}
|
|
|
|
elements[k] = {
|
|
current: overrides.elements?.[k]?.current ?? startAmount,
|
|
max: elemMax,
|
|
unlocked: isUnlocked,
|
|
};
|
|
});
|
|
|
|
// Starting raw mana
|
|
const startRawMana = 10 + (pu.manaWell || 0) * 500 + (pu.quickStart || 0) * 100;
|
|
|
|
// Create starting equipment (staff with mana bolt, clothes)
|
|
const startingEquipment = createStartingEquipment();
|
|
|
|
// Get spells from starting equipment
|
|
const equipmentSpells = getSpellsFromEquipment(
|
|
startingEquipment.equipmentInstances,
|
|
Object.values(startingEquipment.equippedInstances)
|
|
);
|
|
|
|
// Starting spells - now come from equipment instead of being learned directly
|
|
const startSpells: Record<string, { learned: boolean; level: number; studyProgress: number }> = {};
|
|
|
|
// Add spells from equipment
|
|
for (const spellId of equipmentSpells) {
|
|
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
|
|
}
|
|
|
|
// Add random starting spells from spell memory upgrade (pact spells)
|
|
if (pu.spellMemory) {
|
|
const availableSpells = Object.keys(SPELLS_DEF).filter(s => !startSpells[s]);
|
|
const shuffled = availableSpells.sort(() => Math.random() - 0.5);
|
|
for (let i = 0; i < Math.min(pu.spellMemory, shuffled.length); i++) {
|
|
startSpells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 };
|
|
}
|
|
}
|
|
|
|
// Starting attunements - player begins with Enchanter
|
|
const startingAttunements: Record<string, AttunementState> = {
|
|
enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 },
|
|
};
|
|
|
|
// Add any attunements from previous loops (for persistence)
|
|
if (overrides.attunements) {
|
|
Object.entries(overrides.attunements).forEach(([id, state]) => {
|
|
if (id !== 'enchanter') {
|
|
startingAttunements[id] = state;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Unlock transference element for Enchanter attunement
|
|
if (elements['transference']) {
|
|
elements['transference'] = { ...elements['transference'], unlocked: true };
|
|
}
|
|
|
|
return {
|
|
day: 1,
|
|
hour: 0,
|
|
loopCount: overrides.loopCount || 0,
|
|
gameOver: false,
|
|
victory: false,
|
|
paused: false,
|
|
|
|
rawMana: startRawMana,
|
|
meditateTicks: 0,
|
|
totalManaGathered: overrides.totalManaGathered || 0,
|
|
|
|
// Attunements (class-like system)
|
|
attunements: startingAttunements,
|
|
|
|
elements: elements as Record<string, { current: number; max: number; unlocked: boolean }>,
|
|
|
|
currentFloor: startFloor,
|
|
floorHP: getFloorMaxHP(startFloor),
|
|
floorMaxHP: getFloorMaxHP(startFloor),
|
|
maxFloorReached: startFloor,
|
|
signedPacts: [],
|
|
activeSpell: 'manaBolt',
|
|
currentAction: 'meditate',
|
|
castProgress: 0,
|
|
combo: {
|
|
count: 0,
|
|
maxCombo: 0,
|
|
multiplier: 1,
|
|
elementChain: [],
|
|
decayTimer: 0,
|
|
},
|
|
|
|
spells: startSpells,
|
|
skills: overrides.skills || {},
|
|
skillProgress: {},
|
|
skillUpgrades: overrides.skillUpgrades || {},
|
|
skillTiers: overrides.skillTiers || {},
|
|
parallelStudyTarget: null,
|
|
|
|
// Achievements
|
|
achievements: {
|
|
unlocked: [],
|
|
progress: {},
|
|
},
|
|
|
|
// Stats tracking
|
|
totalSpellsCast: 0,
|
|
totalDamageDealt: 0,
|
|
totalCraftsCompleted: 0,
|
|
|
|
// New equipment system
|
|
equippedInstances: startingEquipment.equippedInstances,
|
|
equipmentInstances: startingEquipment.equipmentInstances,
|
|
enchantmentDesigns: [],
|
|
designProgress: null,
|
|
preparationProgress: null,
|
|
applicationProgress: null,
|
|
equipmentCraftingProgress: null,
|
|
unlockedEffects: [...BASE_UNLOCKED_EFFECTS], // Start with mana bolt only
|
|
equipmentSpellStates: [],
|
|
|
|
// Legacy equipment (for backward compatibility)
|
|
equipment: {
|
|
mainHand: null,
|
|
offHand: null,
|
|
head: null,
|
|
body: null,
|
|
hands: null,
|
|
accessory: null,
|
|
},
|
|
inventory: [],
|
|
|
|
blueprints: {},
|
|
|
|
// Loot inventory
|
|
lootInventory: {
|
|
materials: {},
|
|
blueprints: [],
|
|
},
|
|
|
|
schedule: [],
|
|
autoSchedule: false,
|
|
studyQueue: [],
|
|
craftQueue: [],
|
|
|
|
currentStudyTarget: null,
|
|
|
|
insight: overrides.insight || 0,
|
|
totalInsight: overrides.totalInsight || 0,
|
|
prestigeUpgrades: pu,
|
|
memorySlots: 3 + (pu.deepMemory || 0),
|
|
memories: overrides.memories || [],
|
|
|
|
incursionStrength: 0,
|
|
containmentWards: 0,
|
|
|
|
log: ['✨ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. Gather your strength, mage.'],
|
|
loopInsight: 0,
|
|
};
|
|
}
|
|
|
|
// ─── Game Store ───────────────────────────────────────────────────────────────
|
|
|
|
interface GameStore extends GameState, CraftingActions {
|
|
// Actions
|
|
tick: () => void;
|
|
gatherMana: () => void;
|
|
setAction: (action: GameAction) => void;
|
|
setSpell: (spellId: string) => void;
|
|
startStudyingSkill: (skillId: string) => void;
|
|
startStudyingSpell: (spellId: string) => void;
|
|
startParallelStudySkill: (skillId: string) => void;
|
|
cancelStudy: () => void;
|
|
cancelParallelStudy: () => void;
|
|
convertMana: (element: string, amount: number) => void;
|
|
unlockElement: (element: string) => void;
|
|
craftComposite: (target: string) => void;
|
|
doPrestige: (id: string) => void;
|
|
startNewLoop: () => void;
|
|
togglePause: () => void;
|
|
resetGame: () => void;
|
|
addLog: (message: string) => void;
|
|
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
|
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
|
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => void;
|
|
tierUpSkill: (skillId: string) => void;
|
|
|
|
// Attunement XP and leveling
|
|
addAttunementXP: (attunementId: string, amount: number) => void;
|
|
|
|
// Debug functions
|
|
debugUnlockAttunement: (attunementId: string) => void;
|
|
debugAddElementalMana: (element: string, amount: number) => void;
|
|
debugSetTime: (day: number, hour: number) => void;
|
|
debugAddAttunementXP: (attunementId: string, amount: number) => void;
|
|
debugSetFloor: (floor: number) => void;
|
|
|
|
// Computed getters
|
|
getMaxMana: () => number;
|
|
getRegen: () => number;
|
|
getClickMana: () => number;
|
|
getDamage: (spellId: string) => number;
|
|
getMeditationMultiplier: () => number;
|
|
canCastSpell: (spellId: string) => boolean;
|
|
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] };
|
|
}
|
|
|
|
export const useGameStore = create<GameStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
...makeInitial(),
|
|
|
|
getMaxMana: () => computeMaxMana(get()),
|
|
getRegen: () => computeRegen(get()),
|
|
getClickMana: () => computeClickMana(get()),
|
|
getDamage: (spellId: string) => calcDamage(get(), spellId),
|
|
getMeditationMultiplier: () => getMeditationBonus(get().meditateTicks, get().skills),
|
|
|
|
canCastSpell: (spellId: string) => {
|
|
const state = get();
|
|
const spell = SPELLS_DEF[spellId];
|
|
if (!spell || !state.spells[spellId]?.learned) return false;
|
|
return canAffordSpellCost(spell.cost, state.rawMana, state.elements);
|
|
},
|
|
|
|
addLog: (message: string) => {
|
|
set((state) => ({
|
|
log: [message, ...state.log.slice(0, 49)],
|
|
}));
|
|
},
|
|
|
|
tick: () => {
|
|
const state = get();
|
|
if (state.gameOver || state.paused) return;
|
|
|
|
// Compute unified effects (includes skill upgrades AND equipment enchantments)
|
|
const effects = getUnifiedEffects(state);
|
|
|
|
const maxMana = computeMaxMana(state, effects);
|
|
const baseRegen = computeRegen(state, effects);
|
|
|
|
// Time progression
|
|
let hour = state.hour + HOURS_PER_TICK;
|
|
let day = state.day;
|
|
if (hour >= 24) {
|
|
hour -= 24;
|
|
day += 1;
|
|
}
|
|
|
|
// Check for loop end
|
|
if (day > MAX_DAY) {
|
|
const insightGained = calcInsight(state);
|
|
set({
|
|
day,
|
|
hour,
|
|
gameOver: true,
|
|
victory: false,
|
|
loopInsight: insightGained,
|
|
log: [`⏰ The loop ends. Gained ${insightGained} Insight.`, ...state.log.slice(0, 49)],
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check for victory
|
|
if (state.maxFloorReached >= 100 && state.signedPacts.includes(100)) {
|
|
const insightGained = calcInsight(state) * 3;
|
|
set({
|
|
gameOver: true,
|
|
victory: true,
|
|
loopInsight: insightGained,
|
|
log: [`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`, ...state.log.slice(0, 49)],
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Incursion
|
|
const incursionStrength = getIncursionStrength(day, hour);
|
|
|
|
// Meditation bonus tracking and regen calculation
|
|
let meditateTicks = state.meditateTicks;
|
|
let meditationMultiplier = 1;
|
|
|
|
if (state.currentAction === 'meditate') {
|
|
meditateTicks++;
|
|
meditationMultiplier = getMeditationBonus(meditateTicks, state.skills);
|
|
} else {
|
|
meditateTicks = 0;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Attunement mana conversion - convert raw mana to attunement's primary mana type
|
|
let elements = state.elements;
|
|
if (state.attunements) {
|
|
Object.entries(state.attunements).forEach(([attId, attState]) => {
|
|
if (!attState.active) return;
|
|
|
|
const attDef = ATTUNEMENTS_DEF[attId];
|
|
if (!attDef || !attDef.primaryManaType || attDef.conversionRate <= 0) return;
|
|
|
|
const elem = elements[attDef.primaryManaType];
|
|
if (!elem || !elem.unlocked) return;
|
|
|
|
// Get level-scaled conversion rate
|
|
const scaledConversionRate = getAttunementConversionRate(attId, attState.level || 1);
|
|
|
|
// Convert raw mana to primary type
|
|
const conversionAmount = scaledConversionRate * HOURS_PER_TICK;
|
|
const actualConversion = Math.min(conversionAmount, rawMana, elem.max - elem.current);
|
|
|
|
if (actualConversion > 0) {
|
|
rawMana -= actualConversion;
|
|
elements = {
|
|
...elements,
|
|
[attDef.primaryManaType]: {
|
|
...elem,
|
|
current: elem.current + actualConversion,
|
|
},
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// Study progress
|
|
let currentStudyTarget = state.currentStudyTarget;
|
|
let skills = state.skills;
|
|
let skillProgress = state.skillProgress;
|
|
let spells = state.spells;
|
|
let log = state.log;
|
|
let unlockedEffects = state.unlockedEffects;
|
|
|
|
if (state.currentAction === 'study' && currentStudyTarget) {
|
|
const studySpeedMult = getStudySpeedMultiplier(skills);
|
|
const progressGain = HOURS_PER_TICK * studySpeedMult;
|
|
currentStudyTarget = {
|
|
...currentStudyTarget,
|
|
progress: currentStudyTarget.progress + progressGain,
|
|
};
|
|
|
|
// Check if study is complete
|
|
if (currentStudyTarget.progress >= currentStudyTarget.required) {
|
|
if (currentStudyTarget.type === 'skill') {
|
|
const skillId = currentStudyTarget.id;
|
|
const currentLevel = skills[skillId] || 0;
|
|
const newLevel = currentLevel + 1;
|
|
skills = { ...skills, [skillId]: newLevel };
|
|
skillProgress = { ...skillProgress, [skillId]: 0 };
|
|
log = [`✅ ${SKILLS_DEF[skillId]?.name} Lv.${newLevel} mastered!`, ...log.slice(0, 49)];
|
|
|
|
// Check if this skill unlocks effects (research skills)
|
|
const effectsToUnlock = EFFECT_RESEARCH_MAPPING[skillId];
|
|
if (effectsToUnlock && newLevel >= (SKILLS_DEF[skillId]?.max || 1)) {
|
|
const newEffects = effectsToUnlock.filter(e => !unlockedEffects.includes(e));
|
|
if (newEffects.length > 0) {
|
|
unlockedEffects = [...unlockedEffects, ...newEffects];
|
|
log = [`🔬 Unlocked ${newEffects.length} new enchantment effect(s)!`, ...log.slice(0, 49)];
|
|
}
|
|
}
|
|
|
|
// Special case: When enchanting skill reaches level 1, unlock mana bolt
|
|
if (skillId === 'enchanting' && newLevel >= 1) {
|
|
const enchantingEffects = ENCHANTING_UNLOCK_EFFECTS.filter(e => !unlockedEffects.includes(e));
|
|
if (enchantingEffects.length > 0) {
|
|
unlockedEffects = [...unlockedEffects, ...enchantingEffects];
|
|
log = [`✨ Enchantment design unlocked! Mana Bolt effect available.`, ...log.slice(0, 49)];
|
|
}
|
|
}
|
|
} else if (currentStudyTarget.type === 'spell') {
|
|
// Spells can no longer be studied directly - they come from equipment
|
|
// This branch is kept for backward compatibility but should not be used
|
|
const spellId = currentStudyTarget.id;
|
|
spells = { ...spells, [spellId]: { learned: true, level: 1, studyProgress: 0 } };
|
|
log = [`📖 ${SPELLS_DEF[spellId]?.name} learned!`, ...log.slice(0, 49)];
|
|
}
|
|
currentStudyTarget = null;
|
|
}
|
|
}
|
|
|
|
// Convert action - auto convert mana
|
|
if (state.currentAction === 'convert') {
|
|
const unlockedElements = Object.entries(elements)
|
|
.filter(([, e]) => e.unlocked && e.current < e.max);
|
|
|
|
if (unlockedElements.length > 0 && rawMana >= MANA_PER_ELEMENT) {
|
|
// Sort by space available (descending)
|
|
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
|
|
const [targetId, targetState] = unlockedElements[0];
|
|
const canConvert = Math.min(
|
|
Math.floor(rawMana / MANA_PER_ELEMENT),
|
|
targetState.max - targetState.current
|
|
);
|
|
if (canConvert > 0) {
|
|
rawMana -= canConvert * MANA_PER_ELEMENT;
|
|
elements = {
|
|
...elements,
|
|
[targetId]: { ...targetState, current: targetState.current + canConvert }
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Combat - uses cast speed and spell casting
|
|
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, castProgress } = state;
|
|
const floorElement = getFloorElement(currentFloor);
|
|
|
|
if (state.currentAction === 'climb') {
|
|
const spellId = state.activeSpell;
|
|
const spellDef = SPELLS_DEF[spellId];
|
|
|
|
if (spellDef) {
|
|
// Compute attack speed from quickCast skill and upgrades
|
|
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
|
|
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
|
|
|
|
// Get spell cast speed (casts per hour, default 1)
|
|
const spellCastSpeed = spellDef.castSpeed || 1;
|
|
|
|
// Effective casts per tick = spellCastSpeed * totalAttackSpeed
|
|
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
|
|
|
// Accumulate cast progress
|
|
castProgress = (castProgress || 0) + progressPerTick;
|
|
|
|
// Process complete casts
|
|
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) {
|
|
// Deduct cost
|
|
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
|
rawMana = afterCost.rawMana;
|
|
elements = afterCost.elements;
|
|
totalManaGathered += spellDef.cost.amount;
|
|
|
|
// Calculate damage
|
|
let dmg = calcDamage(state, spellId, floorElement);
|
|
|
|
// Apply upgrade damage multipliers and bonuses
|
|
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
|
|
|
|
// Executioner: +100% damage to enemies below 25% HP
|
|
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && floorHP / floorMaxHP < 0.25) {
|
|
dmg *= 2;
|
|
}
|
|
|
|
// Berserker: +50% damage when below 50% mana
|
|
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
|
dmg *= 1.5;
|
|
}
|
|
|
|
// Spell echo - chance to cast again
|
|
const echoChance = (skills.spellEcho || 0) * 0.1;
|
|
if (Math.random() < echoChance) {
|
|
dmg *= 2;
|
|
log = [`✨ Spell Echo! Double damage!`, ...log.slice(0, 49)];
|
|
}
|
|
|
|
// Lifesteal effect
|
|
const lifestealEffect = spellDef.effects?.find(e => e.type === 'lifesteal');
|
|
if (lifestealEffect) {
|
|
const healAmount = dmg * lifestealEffect.value;
|
|
rawMana = Math.min(rawMana + healAmount, maxMana);
|
|
}
|
|
|
|
// Apply damage
|
|
floorHP = Math.max(0, floorHP - dmg);
|
|
|
|
// Reduce cast progress by 1 (one cast completed)
|
|
castProgress -= 1;
|
|
|
|
if (floorHP <= 0) {
|
|
// Floor cleared
|
|
const wasGuardian = GUARDIANS[currentFloor];
|
|
if (wasGuardian && !signedPacts.includes(currentFloor)) {
|
|
signedPacts = [...signedPacts, currentFloor];
|
|
log = [`⚔️ ${wasGuardian.name} defeated! Pact signed! (${wasGuardian.pact}x)`, ...log.slice(0, 49)];
|
|
} else if (!wasGuardian) {
|
|
if (currentFloor % 5 === 0) {
|
|
log = [`🏰 Floor ${currentFloor} cleared!`, ...log.slice(0, 49)];
|
|
}
|
|
}
|
|
|
|
currentFloor = currentFloor + 1;
|
|
if (currentFloor > 100) {
|
|
currentFloor = 100;
|
|
}
|
|
floorMaxHP = getFloorMaxHP(currentFloor);
|
|
floorHP = floorMaxHP;
|
|
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
|
|
|
// Reset cast progress on floor change
|
|
castProgress = 0;
|
|
}
|
|
}
|
|
} else {
|
|
// Not enough mana - pause casting (keep progress)
|
|
castProgress = castProgress || 0;
|
|
}
|
|
}
|
|
|
|
// Process crafting actions (design, prepare, enchant)
|
|
const craftingUpdates = processCraftingTick(
|
|
{
|
|
...state,
|
|
rawMana,
|
|
log,
|
|
currentFloor,
|
|
floorHP,
|
|
floorMaxHP,
|
|
maxFloorReached,
|
|
signedPacts,
|
|
castProgress,
|
|
incursionStrength,
|
|
currentStudyTarget,
|
|
skills,
|
|
skillProgress,
|
|
spells,
|
|
elements,
|
|
meditateTicks,
|
|
},
|
|
{ rawMana, log }
|
|
);
|
|
|
|
// Apply crafting updates
|
|
if (craftingUpdates.rawMana !== undefined) rawMana = craftingUpdates.rawMana;
|
|
if (craftingUpdates.log !== undefined) log = craftingUpdates.log;
|
|
if (craftingUpdates.currentAction !== undefined) {
|
|
set({
|
|
...craftingUpdates,
|
|
day,
|
|
hour,
|
|
rawMana,
|
|
meditateTicks,
|
|
totalManaGathered,
|
|
currentFloor,
|
|
floorHP,
|
|
floorMaxHP,
|
|
maxFloorReached,
|
|
signedPacts,
|
|
incursionStrength,
|
|
currentStudyTarget,
|
|
skills,
|
|
skillProgress,
|
|
spells,
|
|
elements,
|
|
log,
|
|
castProgress,
|
|
});
|
|
return;
|
|
}
|
|
|
|
set({
|
|
day,
|
|
hour,
|
|
rawMana,
|
|
meditateTicks,
|
|
totalManaGathered,
|
|
currentFloor,
|
|
floorHP,
|
|
floorMaxHP,
|
|
maxFloorReached,
|
|
signedPacts,
|
|
incursionStrength,
|
|
currentStudyTarget,
|
|
skills,
|
|
skillProgress,
|
|
spells,
|
|
elements,
|
|
unlockedEffects,
|
|
log,
|
|
castProgress,
|
|
...craftingUpdates,
|
|
});
|
|
},
|
|
|
|
gatherMana: () => {
|
|
const state = get();
|
|
|
|
// Compute unified effects for click mana
|
|
const effects = getUnifiedEffects(state);
|
|
let cm = computeClickMana(state, effects);
|
|
|
|
// Mana overflow bonus
|
|
const overflowBonus = 1 + (state.skills.manaOverflow || 0) * 0.25;
|
|
cm = Math.floor(cm * overflowBonus);
|
|
|
|
const max = computeMaxMana(state, effects);
|
|
set({
|
|
rawMana: Math.min(state.rawMana + cm, max),
|
|
totalManaGathered: state.totalManaGathered + cm,
|
|
});
|
|
},
|
|
|
|
setAction: (action: GameAction) => {
|
|
set((state) => ({
|
|
currentAction: action,
|
|
meditateTicks: action === 'meditate' ? state.meditateTicks : 0,
|
|
}));
|
|
},
|
|
|
|
setSpell: (spellId: string) => {
|
|
const state = get();
|
|
// Can only set learned spells
|
|
if (state.spells[spellId]?.learned) {
|
|
set({ activeSpell: spellId });
|
|
}
|
|
},
|
|
|
|
startStudyingSkill: (skillId: string) => {
|
|
const state = get();
|
|
const sk = SKILLS_DEF[skillId];
|
|
if (!sk) return;
|
|
|
|
const currentLevel = state.skills[skillId] || 0;
|
|
if (currentLevel >= sk.max) return;
|
|
|
|
// Check prerequisites
|
|
if (sk.req) {
|
|
for (const [r, rl] of Object.entries(sk.req)) {
|
|
if ((state.skills[r] || 0) < rl) return;
|
|
}
|
|
}
|
|
|
|
// Check mana cost (with focused mind reduction)
|
|
const costMult = getStudyCostMultiplier(state.skills);
|
|
const cost = Math.floor(sk.base * (currentLevel + 1) * costMult);
|
|
if (state.rawMana < cost) return;
|
|
|
|
// Start studying
|
|
set({
|
|
rawMana: state.rawMana - cost,
|
|
currentAction: 'study',
|
|
currentStudyTarget: {
|
|
type: 'skill',
|
|
id: skillId,
|
|
progress: state.skillProgress[skillId] || 0,
|
|
required: sk.studyTime,
|
|
},
|
|
log: [`📚 Started studying ${sk.name}...`, ...state.log.slice(0, 49)],
|
|
});
|
|
},
|
|
|
|
startStudyingSpell: (spellId: string) => {
|
|
const state = get();
|
|
const sp = SPELLS_DEF[spellId];
|
|
if (!sp || state.spells[spellId]?.learned) return;
|
|
|
|
// Check mana cost (with focused mind reduction)
|
|
const costMult = getStudyCostMultiplier(state.skills);
|
|
const cost = Math.floor(sp.unlock * costMult);
|
|
if (state.rawMana < cost) return;
|
|
|
|
const studyTime = sp.studyTime || (sp.tier * 4); // Default study time based on tier
|
|
|
|
// Start studying
|
|
set({
|
|
rawMana: state.rawMana - cost,
|
|
currentAction: 'study',
|
|
currentStudyTarget: {
|
|
type: 'spell',
|
|
id: spellId,
|
|
progress: state.spells[spellId]?.studyProgress || 0,
|
|
required: studyTime,
|
|
},
|
|
spells: {
|
|
...state.spells,
|
|
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 },
|
|
},
|
|
log: [`📚 Started studying ${sp.name}...`, ...state.log.slice(0, 49)],
|
|
});
|
|
},
|
|
|
|
cancelStudy: () => {
|
|
const state = get();
|
|
if (!state.currentStudyTarget) return;
|
|
|
|
// Knowledge retention bonus
|
|
const retentionBonus = 1 + (state.skills.knowledgeRetention || 0) * 0.2;
|
|
const savedProgress = Math.min(
|
|
state.currentStudyTarget.progress,
|
|
state.currentStudyTarget.required * retentionBonus
|
|
);
|
|
|
|
// Save progress
|
|
if (state.currentStudyTarget.type === 'skill') {
|
|
set({
|
|
currentStudyTarget: null,
|
|
currentAction: 'meditate',
|
|
skillProgress: {
|
|
...state.skillProgress,
|
|
[state.currentStudyTarget.id]: savedProgress,
|
|
},
|
|
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
|
|
});
|
|
} else if (state.currentStudyTarget.type === 'spell') {
|
|
set({
|
|
currentStudyTarget: null,
|
|
currentAction: 'meditate',
|
|
spells: {
|
|
...state.spells,
|
|
[state.currentStudyTarget.id]: {
|
|
...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
|
|
studyProgress: savedProgress,
|
|
},
|
|
},
|
|
log: [`📖 Study interrupted. Progress saved.`, ...state.log.slice(0, 49)],
|
|
});
|
|
}
|
|
},
|
|
|
|
convertMana: (element: string, amount: number = 1) => {
|
|
const state = get();
|
|
const e = state.elements[element];
|
|
if (!e?.unlocked) return;
|
|
|
|
const cost = MANA_PER_ELEMENT * amount;
|
|
if (state.rawMana < cost) return;
|
|
if (e.current >= e.max) return;
|
|
|
|
const canConvert = Math.min(
|
|
amount,
|
|
Math.floor(state.rawMana / MANA_PER_ELEMENT),
|
|
e.max - e.current
|
|
);
|
|
|
|
set({
|
|
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
|
|
elements: {
|
|
...state.elements,
|
|
[element]: { ...e, current: e.current + canConvert },
|
|
},
|
|
});
|
|
},
|
|
|
|
unlockElement: (element: string) => {
|
|
const state = get();
|
|
if (state.elements[element]?.unlocked) return;
|
|
|
|
const cost = 500;
|
|
if (state.rawMana < cost) return;
|
|
|
|
set({
|
|
rawMana: state.rawMana - cost,
|
|
elements: {
|
|
...state.elements,
|
|
[element]: { ...state.elements[element], unlocked: true },
|
|
},
|
|
log: [`✨ ${ELEMENTS[element].name} affinity unlocked!`, ...state.log.slice(0, 49)],
|
|
});
|
|
},
|
|
|
|
craftComposite: (target: string) => {
|
|
const state = get();
|
|
const edef = ELEMENTS[target];
|
|
if (!edef?.recipe) return;
|
|
|
|
const recipe = edef.recipe;
|
|
const costs: Record<string, number> = {};
|
|
recipe.forEach((r) => {
|
|
costs[r] = (costs[r] || 0) + 1;
|
|
});
|
|
|
|
// Check ingredients
|
|
for (const [r, amt] of Object.entries(costs)) {
|
|
if ((state.elements[r]?.current || 0) < amt) return;
|
|
}
|
|
|
|
const newElems = { ...state.elements };
|
|
for (const [r, amt] of Object.entries(costs)) {
|
|
newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
|
|
}
|
|
|
|
// Elemental crafting bonus
|
|
const craftBonus = 1 + (state.skills.elemCrafting || 0) * 0.25;
|
|
const outputAmount = Math.floor(craftBonus);
|
|
|
|
const effects = getUnifiedEffects(state);
|
|
const elemMax = computeElementMax(state, effects);
|
|
newElems[target] = {
|
|
...(newElems[target] || { current: 0, max: elemMax, unlocked: false }),
|
|
current: (newElems[target]?.current || 0) + outputAmount,
|
|
max: elemMax,
|
|
unlocked: true,
|
|
};
|
|
|
|
set({
|
|
elements: newElems,
|
|
log: [`🧪 Crafted ${outputAmount} ${ELEMENTS[target].name} mana!`, ...state.log.slice(0, 49)],
|
|
});
|
|
},
|
|
|
|
doPrestige: (id: string) => {
|
|
const state = get();
|
|
const pd = PRESTIGE_DEF[id];
|
|
if (!pd) return;
|
|
|
|
const lvl = state.prestigeUpgrades[id] || 0;
|
|
if (lvl >= pd.max || state.insight < pd.cost) return;
|
|
|
|
const newPU = { ...state.prestigeUpgrades, [id]: lvl + 1 };
|
|
set({
|
|
insight: state.insight - pd.cost,
|
|
prestigeUpgrades: newPU,
|
|
memorySlots: id === 'deepMemory' ? state.memorySlots + 1 : state.memorySlots,
|
|
log: [`⭐ ${pd.name} upgraded to Lv.${lvl + 1}!`, ...state.log.slice(0, 49)],
|
|
});
|
|
},
|
|
|
|
startNewLoop: () => {
|
|
const state = get();
|
|
const insightGained = state.loopInsight || calcInsight(state);
|
|
const total = state.insight + insightGained;
|
|
|
|
// Keep some spells through temporal memory
|
|
let spellsToKeep: string[] = [];
|
|
if (state.skills.temporalMemory) {
|
|
const learnedSpells = Object.entries(state.spells)
|
|
.filter(([, s]) => s.learned)
|
|
.map(([id]) => id);
|
|
spellsToKeep = learnedSpells.slice(0, state.skills.temporalMemory);
|
|
}
|
|
|
|
const newState = makeInitial({
|
|
loopCount: state.loopCount + 1,
|
|
insight: total,
|
|
totalInsight: (state.totalInsight || 0) + insightGained,
|
|
prestigeUpgrades: state.prestigeUpgrades,
|
|
memories: state.memories,
|
|
skills: state.skills, // Keep skills through temporal memory for now
|
|
});
|
|
|
|
// Add kept spells
|
|
if (spellsToKeep.length > 0) {
|
|
spellsToKeep.forEach(spellId => {
|
|
newState.spells[spellId] = { learned: true, level: 1, studyProgress: 0 };
|
|
});
|
|
}
|
|
|
|
set(newState);
|
|
},
|
|
|
|
togglePause: () => {
|
|
set((state) => ({ paused: !state.paused }));
|
|
},
|
|
|
|
resetGame: () => {
|
|
// Clear localStorage and reset
|
|
localStorage.removeItem('mana-loop-storage');
|
|
set(makeInitial());
|
|
},
|
|
|
|
selectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
|
set((state) => {
|
|
const current = state.skillUpgrades?.[skillId] || [];
|
|
if (current.includes(upgradeId)) return state;
|
|
if (current.length >= 2) return state; // Max 2 upgrades per milestone
|
|
return {
|
|
skillUpgrades: {
|
|
...state.skillUpgrades,
|
|
[skillId]: [...current, upgradeId],
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
deselectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
|
set((state) => {
|
|
const current = state.skillUpgrades?.[skillId] || [];
|
|
return {
|
|
skillUpgrades: {
|
|
...state.skillUpgrades,
|
|
[skillId]: current.filter(id => id !== upgradeId),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
commitSkillUpgrades: (skillId: string, upgradeIds: string[]) => {
|
|
set((state) => ({
|
|
skillUpgrades: {
|
|
...state.skillUpgrades,
|
|
[skillId]: upgradeIds,
|
|
},
|
|
}));
|
|
},
|
|
|
|
tierUpSkill: (skillId: string) => {
|
|
const state = get();
|
|
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
|
const currentTier = state.skillTiers?.[baseSkillId] || 1;
|
|
const nextTier = currentTier + 1;
|
|
|
|
if (nextTier > 5) return; // Max tier is 5
|
|
|
|
const nextTierSkillId = `${baseSkillId}_t${nextTier}`;
|
|
const currentLevel = state.skills[skillId] || 0;
|
|
|
|
set({
|
|
skillTiers: {
|
|
...state.skillTiers,
|
|
[baseSkillId]: nextTier,
|
|
},
|
|
skills: {
|
|
...state.skills,
|
|
[nextTierSkillId]: currentLevel, // Carry over level to new tier
|
|
[skillId]: 0, // Reset old tier
|
|
},
|
|
skillUpgrades: {
|
|
...state.skillUpgrades,
|
|
[nextTierSkillId]: [], // Start fresh with upgrades for new tier
|
|
},
|
|
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
|
|
});
|
|
},
|
|
|
|
startParallelStudySkill: (skillId: string) => {
|
|
const state = get();
|
|
if (state.parallelStudyTarget) return; // Already have parallel study
|
|
if (!state.currentStudyTarget) return; // Need primary study
|
|
|
|
const sk = SKILLS_DEF[skillId];
|
|
if (!sk) return;
|
|
|
|
const currentLevel = state.skills[skillId] || 0;
|
|
if (currentLevel >= sk.max) return;
|
|
|
|
// Can't study same thing in parallel
|
|
if (state.currentStudyTarget.id === skillId) return;
|
|
|
|
set({
|
|
parallelStudyTarget: {
|
|
type: 'skill',
|
|
id: skillId,
|
|
progress: state.skillProgress[skillId] || 0,
|
|
required: sk.studyTime,
|
|
},
|
|
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
|
|
});
|
|
},
|
|
|
|
cancelParallelStudy: () => {
|
|
set((state) => {
|
|
if (!state.parallelStudyTarget) return state;
|
|
return {
|
|
parallelStudyTarget: null,
|
|
log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
|
|
};
|
|
});
|
|
},
|
|
|
|
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
|
|
const state = get();
|
|
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
|
const tier = state.skillTiers?.[baseSkillId] || 1;
|
|
|
|
const path = SKILL_EVOLUTION_PATHS[baseSkillId];
|
|
if (!path) return { available: [], selected: [] };
|
|
|
|
const tierDef = path.tiers.find(t => t.tier === tier);
|
|
if (!tierDef) return { available: [], selected: [] };
|
|
|
|
const available = tierDef.upgrades.filter(u => u.milestone === milestone);
|
|
const selected = state.skillUpgrades?.[skillId]?.filter(id =>
|
|
available.some(u => u.id === id)
|
|
) || [];
|
|
|
|
return { available, selected };
|
|
},
|
|
|
|
// ─── Crafting Actions (from crafting slice) ─────────────────────────────────
|
|
|
|
createEquipmentInstance: (typeId: string) => {
|
|
const type = EQUIPMENT_TYPES[typeId];
|
|
if (!type) return null;
|
|
|
|
const instanceId = `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
const instance: EquipmentInstance = {
|
|
instanceId,
|
|
typeId,
|
|
name: type.name,
|
|
enchantments: [],
|
|
usedCapacity: 0,
|
|
totalCapacity: type.baseCapacity,
|
|
rarity: 'common',
|
|
quality: 100,
|
|
};
|
|
|
|
set((state) => ({
|
|
equipmentInstances: {
|
|
...state.equipmentInstances,
|
|
[instanceId]: instance,
|
|
},
|
|
}));
|
|
|
|
return instanceId;
|
|
},
|
|
|
|
equipItem: (instanceId: string, slot: EquipmentSlot) => {
|
|
const state = get();
|
|
const instance = state.equipmentInstances[instanceId];
|
|
if (!instance) return false;
|
|
|
|
const type = EQUIPMENT_TYPES[instance.typeId];
|
|
if (!type) return false;
|
|
|
|
// Check if equipment can go in this slot
|
|
const validSlots = type.category === 'accessory'
|
|
? ['accessory1', 'accessory2']
|
|
: [type.slot];
|
|
|
|
if (!validSlots.includes(slot)) return false;
|
|
|
|
// Check if slot is occupied
|
|
const currentEquipped = state.equippedInstances[slot];
|
|
if (currentEquipped === instanceId) return true; // Already equipped here
|
|
|
|
// If this item is equipped elsewhere, unequip it first
|
|
let newEquipped = { ...state.equippedInstances };
|
|
for (const [s, id] of Object.entries(newEquipped)) {
|
|
if (id === instanceId) {
|
|
newEquipped[s as EquipmentSlot] = null;
|
|
}
|
|
}
|
|
|
|
// Equip to new slot
|
|
newEquipped[slot] = instanceId;
|
|
|
|
set(() => ({ equippedInstances: newEquipped }));
|
|
return true;
|
|
},
|
|
|
|
unequipItem: (slot: EquipmentSlot) => {
|
|
set((state) => ({
|
|
equippedInstances: {
|
|
...state.equippedInstances,
|
|
[slot]: null,
|
|
},
|
|
}));
|
|
},
|
|
|
|
deleteEquipmentInstance: (instanceId: string) => {
|
|
set((state) => {
|
|
// First unequip if equipped
|
|
let newEquipped = { ...state.equippedInstances };
|
|
for (const [slot, id] of Object.entries(newEquipped)) {
|
|
if (id === instanceId) {
|
|
newEquipped[slot as EquipmentSlot] = null;
|
|
}
|
|
}
|
|
|
|
// Remove from instances
|
|
const newInstances = { ...state.equipmentInstances };
|
|
delete newInstances[instanceId];
|
|
|
|
return {
|
|
equippedInstances: newEquipped,
|
|
equipmentInstances: newInstances,
|
|
};
|
|
});
|
|
},
|
|
|
|
startDesigningEnchantment: (name: string, equipmentTypeId: string, effects: DesignEffect[]) => {
|
|
const state = get();
|
|
|
|
// Check if player has enchanting skill
|
|
const enchantingLevel = state.skills.enchanting || 0;
|
|
if (enchantingLevel < 1) return false;
|
|
|
|
// Validate effects for equipment category
|
|
const type = EQUIPMENT_TYPES[equipmentTypeId];
|
|
if (!type) return false;
|
|
|
|
for (const eff of effects) {
|
|
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
|
|
if (!effectDef) return false;
|
|
if (!effectDef.allowedEquipmentCategories.includes(type.category)) return false;
|
|
if (eff.stacks > effectDef.maxStacks) return false;
|
|
}
|
|
|
|
// Calculate capacity cost
|
|
const efficiencyBonus = (state.skills.efficientEnchant || 0) * 0.05;
|
|
const totalCapacityCost = effects.reduce((total, eff) =>
|
|
total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus), 0);
|
|
|
|
// Calculate design time
|
|
let designTime = 1;
|
|
for (const eff of effects) {
|
|
designTime += 0.5 * eff.stacks;
|
|
}
|
|
|
|
// Store pending design in designProgress
|
|
set(() => ({
|
|
currentAction: 'design',
|
|
designProgress: {
|
|
designId: `design_${Date.now()}`,
|
|
progress: 0,
|
|
required: designTime,
|
|
},
|
|
// Store design data temporarily
|
|
log: [`🔮 Designing enchantment: ${name}...`, ...state.log.slice(0, 49)],
|
|
}));
|
|
|
|
return true;
|
|
},
|
|
|
|
cancelDesign: () => {
|
|
set(() => ({
|
|
currentAction: 'meditate',
|
|
designProgress: null,
|
|
}));
|
|
},
|
|
|
|
saveDesign: (design: EnchantmentDesign) => {
|
|
set((state) => ({
|
|
enchantmentDesigns: [...state.enchantmentDesigns, design],
|
|
designProgress: null,
|
|
currentAction: 'meditate',
|
|
log: [`📜 Enchantment design "${design.name}" saved!`, ...state.log.slice(0, 49)],
|
|
}));
|
|
},
|
|
|
|
deleteDesign: (designId: string) => {
|
|
set((state) => ({
|
|
enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== designId),
|
|
}));
|
|
},
|
|
|
|
startPreparing: (equipmentInstanceId: string) => {
|
|
const state = get();
|
|
const instance = state.equipmentInstances[equipmentInstanceId];
|
|
if (!instance) return false;
|
|
|
|
// Prep time: 2 hours base + 1 hour per 50 capacity
|
|
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
|
|
const manaCost = instance.totalCapacity * 10;
|
|
|
|
if (state.rawMana < manaCost) return false;
|
|
|
|
set(() => ({
|
|
currentAction: 'prepare',
|
|
preparationProgress: {
|
|
equipmentInstanceId,
|
|
progress: 0,
|
|
required: prepTime,
|
|
manaCostPaid: 0,
|
|
},
|
|
log: [`⚙️ Preparing ${instance.name} for enchanting...`, ...state.log.slice(0, 49)],
|
|
}));
|
|
|
|
return true;
|
|
},
|
|
|
|
cancelPreparation: () => {
|
|
set(() => ({
|
|
currentAction: 'meditate',
|
|
preparationProgress: null,
|
|
}));
|
|
},
|
|
|
|
startApplying: (equipmentInstanceId: string, designId: string) => {
|
|
const state = get();
|
|
const instance = state.equipmentInstances[equipmentInstanceId];
|
|
const design = state.enchantmentDesigns.find(d => d.id === designId);
|
|
|
|
if (!instance || !design) return false;
|
|
|
|
// Check capacity
|
|
if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) {
|
|
return false;
|
|
}
|
|
|
|
// Application time: 2 hours base + 1 hour per effect stack
|
|
const applicationTime = 2 + design.effects.reduce((total, eff) => total + eff.stacks, 0);
|
|
const manaPerHour = 20 + design.effects.reduce((total, eff) => total + eff.stacks * 5, 0);
|
|
|
|
set(() => ({
|
|
currentAction: 'enchant',
|
|
applicationProgress: {
|
|
equipmentInstanceId,
|
|
designId,
|
|
progress: 0,
|
|
required: applicationTime,
|
|
manaPerHour,
|
|
paused: false,
|
|
manaSpent: 0,
|
|
},
|
|
log: [`✨ Applying "${design.name}" to ${instance.name}...`, ...state.log.slice(0, 49)],
|
|
}));
|
|
|
|
return true;
|
|
},
|
|
|
|
pauseApplication: () => {
|
|
set((state) => {
|
|
if (!state.applicationProgress) return {};
|
|
return {
|
|
applicationProgress: {
|
|
...state.applicationProgress,
|
|
paused: true,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
resumeApplication: () => {
|
|
set((state) => {
|
|
if (!state.applicationProgress) return {};
|
|
return {
|
|
applicationProgress: {
|
|
...state.applicationProgress,
|
|
paused: false,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
cancelApplication: () => {
|
|
set(() => ({
|
|
currentAction: 'meditate',
|
|
applicationProgress: null,
|
|
}));
|
|
},
|
|
|
|
disenchantEquipment: (instanceId: string) => {
|
|
const state = get();
|
|
const instance = state.equipmentInstances[instanceId];
|
|
if (!instance || instance.enchantments.length === 0) return;
|
|
|
|
const disenchantLevel = state.skills.disenchanting || 0;
|
|
const recoveryRate = 0.1 + disenchantLevel * 0.2; // 10% base + 20% per level
|
|
|
|
let totalRecovered = 0;
|
|
for (const ench of instance.enchantments) {
|
|
totalRecovered += Math.floor(ench.actualCost * recoveryRate);
|
|
}
|
|
|
|
set((state) => ({
|
|
rawMana: state.rawMana + totalRecovered,
|
|
equipmentInstances: {
|
|
...state.equipmentInstances,
|
|
[instanceId]: {
|
|
...instance,
|
|
enchantments: [],
|
|
usedCapacity: 0,
|
|
},
|
|
},
|
|
log: [`✨ Disenchanted ${instance.name}, recovered ${totalRecovered} mana.`, ...state.log.slice(0, 49)],
|
|
}));
|
|
},
|
|
|
|
getEquipmentSpells: () => {
|
|
const state = get();
|
|
const spells: string[] = [];
|
|
|
|
for (const instanceId of Object.values(state.equippedInstances)) {
|
|
if (!instanceId) continue;
|
|
const instance = state.equipmentInstances[instanceId];
|
|
if (!instance) continue;
|
|
|
|
for (const ench of instance.enchantments) {
|
|
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
|
|
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
|
|
spells.push(effectDef.effect.spellId);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...new Set(spells)]; // Remove duplicates
|
|
},
|
|
|
|
getEquipmentEffects: () => {
|
|
const state = get();
|
|
const effects: Record<string, number> = {};
|
|
|
|
for (const instanceId of Object.values(state.equippedInstances)) {
|
|
if (!instanceId) continue;
|
|
const instance = state.equipmentInstances[instanceId];
|
|
if (!instance) continue;
|
|
|
|
for (const ench of instance.enchantments) {
|
|
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
|
|
if (!effectDef) continue;
|
|
|
|
if (effectDef.effect.type === 'bonus' && effectDef.effect.stat && effectDef.effect.value) {
|
|
effects[effectDef.effect.stat] = (effects[effectDef.effect.stat] || 0) + effectDef.effect.value * ench.stacks;
|
|
}
|
|
}
|
|
}
|
|
|
|
return effects;
|
|
},
|
|
|
|
getAvailableCapacity: (instanceId: string) => {
|
|
const state = get();
|
|
const instance = state.equipmentInstances[instanceId];
|
|
if (!instance) return 0;
|
|
return instance.totalCapacity - instance.usedCapacity;
|
|
},
|
|
|
|
// Attunement XP and leveling
|
|
addAttunementXP: (attunementId: string, amount: number) => {
|
|
const state = get();
|
|
const attState = state.attunements[attunementId];
|
|
if (!attState?.active) return;
|
|
|
|
let newXP = attState.experience + amount;
|
|
let newLevel = attState.level;
|
|
|
|
// Check for level ups
|
|
while (newLevel < MAX_ATTUNEMENT_LEVEL) {
|
|
const xpNeeded = getAttunementXPForLevel(newLevel + 1);
|
|
if (newXP >= xpNeeded) {
|
|
newXP -= xpNeeded;
|
|
newLevel++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Cap XP at max level
|
|
if (newLevel >= MAX_ATTUNEMENT_LEVEL) {
|
|
newXP = 0;
|
|
}
|
|
|
|
set({
|
|
attunements: {
|
|
...state.attunements,
|
|
[attunementId]: {
|
|
...attState,
|
|
level: newLevel,
|
|
experience: newXP,
|
|
},
|
|
},
|
|
log: newLevel > attState.level
|
|
? [`🌟 ${attunementId} attunement reached Level ${newLevel}!`, ...state.log.slice(0, 49)]
|
|
: state.log,
|
|
});
|
|
},
|
|
|
|
// Debug functions
|
|
debugUnlockAttunement: (attunementId: string) => {
|
|
const state = get();
|
|
const def = ATTUNEMENTS_DEF[attunementId];
|
|
if (!def) return;
|
|
|
|
set({
|
|
attunements: {
|
|
...state.attunements,
|
|
[attunementId]: {
|
|
id: attunementId,
|
|
active: true,
|
|
level: 1,
|
|
experience: 0,
|
|
},
|
|
},
|
|
// Unlock the primary mana type if applicable
|
|
elements: def.primaryManaType && state.elements[def.primaryManaType]
|
|
? {
|
|
...state.elements,
|
|
[def.primaryManaType]: {
|
|
...state.elements[def.primaryManaType],
|
|
unlocked: true,
|
|
},
|
|
}
|
|
: state.elements,
|
|
log: [`🔓 Debug: Unlocked ${def.name} attunement!`, ...state.log.slice(0, 49)],
|
|
});
|
|
},
|
|
|
|
debugAddElementalMana: (element: string, amount: number) => {
|
|
const state = get();
|
|
const elem = state.elements[element];
|
|
if (!elem?.unlocked) return;
|
|
|
|
set({
|
|
elements: {
|
|
...state.elements,
|
|
[element]: {
|
|
...elem,
|
|
current: Math.min(elem.current + amount, elem.max * 10), // Allow overflow
|
|
},
|
|
},
|
|
});
|
|
},
|
|
|
|
debugSetTime: (day: number, hour: number) => {
|
|
set({
|
|
day,
|
|
hour,
|
|
incursionStrength: getIncursionStrength(day, hour),
|
|
});
|
|
},
|
|
|
|
debugAddAttunementXP: (attunementId: string, amount: number) => {
|
|
const state = get();
|
|
const attState = state.attunements[attunementId];
|
|
if (!attState) return;
|
|
|
|
let newXP = attState.experience + amount;
|
|
let newLevel = attState.level;
|
|
|
|
while (newLevel < MAX_ATTUNEMENT_LEVEL) {
|
|
const xpNeeded = getAttunementXPForLevel(newLevel + 1);
|
|
if (newXP >= xpNeeded) {
|
|
newXP -= xpNeeded;
|
|
newLevel++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
set({
|
|
attunements: {
|
|
...state.attunements,
|
|
[attunementId]: {
|
|
...attState,
|
|
level: newLevel,
|
|
experience: newXP,
|
|
},
|
|
},
|
|
});
|
|
},
|
|
|
|
debugSetFloor: (floor: number) => {
|
|
const state = get();
|
|
set({
|
|
currentFloor: floor,
|
|
floorHP: getFloorMaxHP(floor),
|
|
floorMaxHP: getFloorMaxHP(floor),
|
|
maxFloorReached: Math.max(state.maxFloorReached, floor),
|
|
});
|
|
},
|
|
}),
|
|
{
|
|
name: 'mana-loop-storage',
|
|
version: 2,
|
|
migrate: (persistedState: unknown, version: number) => {
|
|
const state = persistedState as Record<string, unknown>;
|
|
// Migration from version 0/1 to version 2 - add missing fields
|
|
if (version < 2) {
|
|
return {
|
|
...state,
|
|
castProgress: state.castProgress ?? 0,
|
|
skillUpgrades: state.skillUpgrades ?? {},
|
|
skillTiers: state.skillTiers ?? {},
|
|
parallelStudyTarget: state.parallelStudyTarget ?? null,
|
|
};
|
|
}
|
|
return state;
|
|
},
|
|
partialize: (state) => ({
|
|
day: state.day,
|
|
hour: state.hour,
|
|
loopCount: state.loopCount,
|
|
rawMana: state.rawMana,
|
|
meditateTicks: state.meditateTicks,
|
|
totalManaGathered: state.totalManaGathered,
|
|
attunements: state.attunements,
|
|
elements: state.elements,
|
|
currentFloor: state.currentFloor,
|
|
floorHP: state.floorHP,
|
|
floorMaxHP: state.floorMaxHP,
|
|
maxFloorReached: state.maxFloorReached,
|
|
signedPacts: state.signedPacts,
|
|
activeSpell: state.activeSpell,
|
|
currentAction: state.currentAction,
|
|
castProgress: state.castProgress,
|
|
combo: state.combo,
|
|
spells: state.spells,
|
|
skills: state.skills,
|
|
skillProgress: state.skillProgress,
|
|
skillUpgrades: state.skillUpgrades,
|
|
skillTiers: state.skillTiers,
|
|
currentStudyTarget: state.currentStudyTarget,
|
|
parallelStudyTarget: state.parallelStudyTarget,
|
|
achievements: state.achievements,
|
|
totalSpellsCast: state.totalSpellsCast,
|
|
totalDamageDealt: state.totalDamageDealt,
|
|
totalCraftsCompleted: state.totalCraftsCompleted,
|
|
insight: state.insight,
|
|
totalInsight: state.totalInsight,
|
|
prestigeUpgrades: state.prestigeUpgrades,
|
|
memorySlots: state.memorySlots,
|
|
memories: state.memories,
|
|
log: state.log,
|
|
// Equipment system
|
|
equippedInstances: state.equippedInstances,
|
|
equipmentInstances: state.equipmentInstances,
|
|
enchantmentDesigns: state.enchantmentDesigns,
|
|
designProgress: state.designProgress,
|
|
preparationProgress: state.preparationProgress,
|
|
applicationProgress: state.applicationProgress,
|
|
equipmentCraftingProgress: state.equipmentCraftingProgress,
|
|
unlockedEffects: state.unlockedEffects,
|
|
// Loot inventory
|
|
lootInventory: state.lootInventory,
|
|
}),
|
|
}
|
|
)
|
|
);
|
|
|
|
// ─── Game Loop Hook ───────────────────────────────────────────────────────────
|
|
|
|
export function useGameLoop() {
|
|
const tick = useGameStore((s) => s.tick);
|
|
|
|
// Use useEffect in the component that uses this hook
|
|
return {
|
|
start: () => {
|
|
const interval = setInterval(tick, TICK_MS);
|
|
return () => clearInterval(interval);
|
|
},
|
|
};
|
|
}
|