2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* @typedef {import("./types.js").ClassMastery} ClassMastery
|
|
|
|
|
* @typedef {import("./types.js").Equipment} Equipment
|
|
|
|
|
*/
|
|
|
|
|
|
2025-12-19 16:38:22 +00:00
|
|
|
import { Unit } from "./Unit.js";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Explorer.js
|
|
|
|
|
* Player character class supporting Multi-Class Mastery and Persistent Progression.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @class
|
2025-12-19 16:38:22 +00:00
|
|
|
*/
|
|
|
|
|
export class Explorer extends Unit {
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* @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
|
|
|
|
|
*/
|
2025-12-19 16:38:22 +00:00
|
|
|
constructor(id, name, startingClassId, classDefinition) {
|
|
|
|
|
super(id, name, "EXPLORER", `${startingClassId}_MODEL`);
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {string} */
|
2025-12-19 16:38:22 +00:00
|
|
|
this.activeClassId = startingClassId;
|
|
|
|
|
|
|
|
|
|
// Persistent Mastery: Tracks progress for EVERY class this character has played
|
|
|
|
|
// Key: ClassID, Value: { level, xp, skillPoints, unlockedNodes[] }
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {Record<string, ClassMastery>} */
|
2025-12-19 16:38:22 +00:00
|
|
|
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
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {Equipment} */
|
2025-12-19 16:38:22 +00:00
|
|
|
this.equipment = {
|
|
|
|
|
weapon: null,
|
|
|
|
|
armor: null,
|
|
|
|
|
utility: null,
|
|
|
|
|
relic: null,
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-28 00:54:03 +00:00
|
|
|
// Loadout (New inventory system per spec)
|
|
|
|
|
/** @type {Object} */
|
|
|
|
|
this.loadout = {
|
|
|
|
|
mainHand: null,
|
|
|
|
|
offHand: null,
|
|
|
|
|
body: null,
|
|
|
|
|
accessory: null,
|
|
|
|
|
belt: [null, null], // Fixed 2 slots
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-19 16:38:22 +00:00
|
|
|
// Active Skills (Populated by Skill Tree)
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {unknown[]} */
|
2025-12-19 16:38:22 +00:00
|
|
|
this.actions = [];
|
2025-12-22 20:55:41 +00:00
|
|
|
/** @type {unknown[]} */
|
2025-12-19 16:38:22 +00:00
|
|
|
this.passives = [];
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Initializes mastery data for a class.
|
|
|
|
|
* @param {string} classId - Class ID to initialize
|
|
|
|
|
*/
|
2025-12-19 16:38:22 +00:00
|
|
|
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.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @param {Record<string, unknown>} classDef - The JSON definition of the class stats.
|
2025-12-19 16:38:22 +00:00
|
|
|
*/
|
|
|
|
|
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).
|
2025-12-22 20:55:41 +00:00
|
|
|
* @param {string} newClassId - New class ID
|
|
|
|
|
* @param {Record<string, unknown>} newClassDef - New class definition
|
2025-12-19 16:38:22 +00:00
|
|
|
*/
|
|
|
|
|
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.
|
2025-12-22 20:55:41 +00:00
|
|
|
* @param {number} amount - XP amount to add
|
2025-12-19 16:38:22 +00:00
|
|
|
*/
|
|
|
|
|
gainExperience(amount) {
|
|
|
|
|
const mastery = this.classMastery[this.activeClassId];
|
|
|
|
|
mastery.xp += amount;
|
|
|
|
|
// Level up logic would be handled by a system checking XP curves
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 20:55:41 +00:00
|
|
|
/**
|
|
|
|
|
* Gets the current level of the active class.
|
|
|
|
|
* @returns {number} - Current level
|
|
|
|
|
*/
|
2025-12-19 16:38:22 +00:00
|
|
|
getLevel() {
|
|
|
|
|
return this.classMastery[this.activeClassId].level;
|
|
|
|
|
}
|
2025-12-28 00:54:03 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-19 16:38:22 +00:00
|
|
|
}
|