aether-shards/src/units/Explorer.js

335 lines
10 KiB
JavaScript
Raw Normal View History

/**
* @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<string, unknown>} 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<string, ClassMastery>} */
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<string, unknown>} 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<string, unknown>} 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<string, unknown>} 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;
}
}
}
}