/** * @typedef {import("./types.js").ClassMastery} ClassMastery * @typedef {import("./types.js").Equipment} Equipment */ import { Unit } from "./Unit.js"; /** * Explorer.js * Player character class supporting Multi-Class Mastery and Persistent Progression. * @class */ export class Explorer extends Unit { /** * @param {string} id - Unique unit identifier * @param {string} name - Explorer name * @param {string} startingClassId - Starting class ID * @param {Record} classDefinition - Class definition data */ constructor(id, name, startingClassId, classDefinition) { super(id, name, "EXPLORER", `${startingClassId}_MODEL`); /** @type {string} */ this.activeClassId = startingClassId; // Persistent Mastery: Tracks progress for EVERY class this character has played // Key: ClassID, Value: { level, xp, skillPoints, unlockedNodes[] } /** @type {Record} */ this.classMastery = {}; // Initialize the starting class entry this.initializeMastery(startingClassId); // Hydrate stats based on the provided definition if (classDefinition) { this.recalculateBaseStats(classDefinition); this.currentHealth = this.baseStats.health; } // Inventory /** @type {Equipment} */ this.equipment = { weapon: null, armor: null, utility: null, relic: null, }; // Loadout (New inventory system per spec) /** @type {Object} */ this.loadout = { mainHand: null, offHand: null, body: null, accessory: null, belt: [null, null], // Fixed 2 slots }; // Active Skills (Populated by Skill Tree) /** @type {unknown[]} */ this.actions = []; /** @type {unknown[]} */ this.passives = []; } /** * Initializes mastery data for a class. * @param {string} classId - Class ID to initialize */ initializeMastery(classId) { if (!this.classMastery[classId]) { this.classMastery[classId] = { level: 1, xp: 0, skillPoints: 0, unlockedNodes: [], }; } } /** * Updates base stats based on the active class's base + growth rates * level. * @param {Record} classDef - The JSON definition of the class stats. */ recalculateBaseStats(classDef) { if (classDef.id !== this.activeClassId) { console.warn( `Mismatch: Recalculating stats for ${this.activeClassId} using definition for ${classDef.id}` ); } const mastery = this.classMastery[this.activeClassId]; // 1. Start with Class Defaults let stats = { ...classDef.base_stats }; // 2. Add Level Growth // (Level 1 is base, so growth applies for levels 2+) const levelsGained = mastery.level - 1; if (levelsGained > 0) { for (let stat in classDef.growth_rates) { if (stats[stat] !== undefined) { stats[stat] += classDef.growth_rates[stat] * levelsGained; } } } this.baseStats = stats; this.maxHealth = stats.health; // Update MaxHP cap } /** * Swaps the active class logic. * NOTE: Does NOT check unlock requirements (handled by UI/MetaSystem). * @param {string} newClassId - New class ID * @param {Record} newClassDef - New class definition */ changeClass(newClassId, newClassDef) { // 1. Ensure mastery record exists this.initializeMastery(newClassId); // 2. Switch ID this.activeClassId = newClassId; // 3. Update Model ID (Visuals) this.voxelModelID = `${newClassId}_MODEL`; // 4. Recalculate Stats for the new job this.recalculateBaseStats(newClassDef); // 5. Reset Current HP to new Max (or keep percentage? Standard is reset) this.currentHealth = this.baseStats.health; } /** * Adds XP to the *current* class. * @param {number} amount - XP amount to add */ gainExperience(amount) { const mastery = this.classMastery[this.activeClassId]; mastery.xp += amount; // Level up logic would be handled by a system checking XP curves } /** * Gets the current level of the active class. * @returns {number} - Current level */ getLevel() { return this.classMastery[this.activeClassId].level; } /** * Initializes starting equipment from class definition. * Creates ItemInstance objects and equips them to appropriate slots. * @param {Object} itemRegistry - Item registry to get item definitions * @param {Record} classDefinition - Class definition with starting_equipment array */ initializeStartingEquipment(itemRegistry, classDefinition) { if (!itemRegistry || !classDefinition) { return; } const startingEquipment = classDefinition.starting_equipment; if (!Array.isArray(startingEquipment) || startingEquipment.length === 0) { return; } // Map item types to loadout slots const typeToSlot = { WEAPON: "mainHand", ARMOR: "body", UTILITY: "offHand", // Default to offHand, but could be belt RELIC: "accessory", CONSUMABLE: null, // Consumables go to belt or stash }; let beltIndex = 0; for (const itemDefId of startingEquipment) { const itemDef = itemRegistry.get(itemDefId); if (!itemDef) { console.warn(`Starting equipment item not found: ${itemDefId}`); continue; } // Create ItemInstance const itemInstance = { uid: `${itemDefId}_${this.id}_${Date.now()}_${Math.random() .toString(36) .substr(2, 9)}`, defId: itemDefId, isNew: false, quantity: 1, }; // Determine slot based on item type const itemType = itemDef.type; let targetSlot = typeToSlot[itemType]; // Special handling for consumables - put in belt if (itemType === "CONSUMABLE" || itemType === "MATERIAL") { if (beltIndex < 2) { this.loadout.belt[beltIndex] = itemInstance; beltIndex++; continue; } else { // Belt is full, skip or add to stash later console.warn(`Belt full, cannot equip consumable: ${itemDefId}`); continue; } } // Handle UTILITY items - can go to offHand or belt if (itemType === "UTILITY") { // If offHand is empty, use it; otherwise try belt if (!this.loadout.offHand) { targetSlot = "offHand"; } else if (beltIndex < 2) { this.loadout.belt[beltIndex] = itemInstance; beltIndex++; continue; } else { // Both offHand and belt full, skip console.warn(`Cannot equip utility item, slots full: ${itemDefId}`); continue; } } // Equip to determined slot if (targetSlot && this.loadout[targetSlot] === null) { this.loadout[targetSlot] = itemInstance; } else if (targetSlot) { // Slot occupied, skip or log warning console.warn( `Starting equipment slot ${targetSlot} already occupied, skipping: ${itemDefId}` ); } } // Recalculate stats after equipping starting gear this.recalculateStats(itemRegistry); } /** * Recalculates effective stats including equipment bonuses and skill tree stat boosts. * This method should be called whenever equipment or skill tree nodes change. * @param {Object} [itemRegistry] - Optional item registry to get item definitions * @param {Object} [treeDef] - Optional skill tree definition to get stat boosts from unlocked nodes */ recalculateStats(itemRegistry = null, treeDef = null) { // Start with base stats (already calculated from class + level) const effectiveStats = { ...this.baseStats }; // Apply equipment bonuses if itemRegistry is provided if (itemRegistry) { // Check mainHand if (this.loadout.mainHand) { const itemDef = itemRegistry.get(this.loadout.mainHand.defId); if (itemDef && itemDef.stats) { for (const [stat, value] of Object.entries(itemDef.stats)) { effectiveStats[stat] = (effectiveStats[stat] || 0) + value; } } } // Check offHand if (this.loadout.offHand) { const itemDef = itemRegistry.get(this.loadout.offHand.defId); if (itemDef && itemDef.stats) { for (const [stat, value] of Object.entries(itemDef.stats)) { effectiveStats[stat] = (effectiveStats[stat] || 0) + value; } } } // Check body if (this.loadout.body) { const itemDef = itemRegistry.get(this.loadout.body.defId); if (itemDef && itemDef.stats) { for (const [stat, value] of Object.entries(itemDef.stats)) { effectiveStats[stat] = (effectiveStats[stat] || 0) + value; } } } // Check accessory if (this.loadout.accessory) { const itemDef = itemRegistry.get(this.loadout.accessory.defId); if (itemDef && itemDef.stats) { for (const [stat, value] of Object.entries(itemDef.stats)) { effectiveStats[stat] = (effectiveStats[stat] || 0) + value; } } } // Belt items don't affect stats (they're consumables) } // Apply skill tree stat boosts from unlocked nodes if (treeDef && this.classMastery) { const mastery = this.classMastery[this.activeClassId]; if (mastery && mastery.unlockedNodes) { for (const nodeId of mastery.unlockedNodes) { const nodeDef = treeDef.nodes?.[nodeId]; if ( nodeDef && nodeDef.type === "STAT_BOOST" && nodeDef.data && nodeDef.data.stat ) { const statName = nodeDef.data.stat; const boostValue = nodeDef.data.value || 0; effectiveStats[statName] = (effectiveStats[statName] || 0) + boostValue; } } } } // Update maxHealth if health stat changed if (effectiveStats.health !== undefined) { const oldMaxHealth = this.maxHealth; this.maxHealth = effectiveStats.health; // Update currentHealth proportionally using the old maxHealth if (oldMaxHealth > 0) { const healthRatio = this.currentHealth / oldMaxHealth; this.currentHealth = Math.floor(effectiveStats.health * healthRatio); } else { this.currentHealth = effectiveStats.health; } } } }