408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
/**
|
|
* Galaxy Strike Online - Client Skill System
|
|
* Skill definitions are loaded from the server; this file handles
|
|
* local progression tracking, UI rendering, and skill-point spending.
|
|
*/
|
|
|
|
class SkillSystem {
|
|
constructor(gameEngine) {
|
|
this.game = gameEngine;
|
|
|
|
// Populated after server responds to 'get_skills'
|
|
this.skills = { combat: {}, science: {}, crafting: {} };
|
|
|
|
this.categories = {
|
|
combat: 'Combat',
|
|
science: 'Science',
|
|
crafting: 'Crafting'
|
|
};
|
|
|
|
this.experienceRates = { combat: 1.0, science: 0.8, crafting: 0.6 };
|
|
this.activeBuffs = {};
|
|
|
|
// Loading state
|
|
this._loaded = false;
|
|
this._loading = false;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ //
|
|
// Initialisation — request skill definitions from the server
|
|
// ------------------------------------------------------------------ //
|
|
async initialize() {
|
|
if (this._loaded || this._loading) return;
|
|
this._loading = true;
|
|
|
|
console.log('[SKILL SYSTEM] Requesting skill definitions from server');
|
|
|
|
if (!window.game?.socket) {
|
|
console.warn('[SKILL SYSTEM] No socket connection — skills will load when connected');
|
|
this._loading = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const serverSkills = await this._fetchSkillsFromServer();
|
|
this._applyServerDefinitions(serverSkills);
|
|
this._loaded = true;
|
|
console.log('[SKILL SYSTEM] Skill definitions loaded from server');
|
|
} catch (err) {
|
|
console.error('[SKILL SYSTEM] Failed to load skills from server:', err);
|
|
} finally {
|
|
this._loading = false;
|
|
}
|
|
}
|
|
|
|
_fetchSkillsFromServer() {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = window.game.socket;
|
|
|
|
const timeout = setTimeout(() => {
|
|
socket.off('skills_data', handler);
|
|
reject(new Error('Skill data request timed out'));
|
|
}, 10000);
|
|
|
|
const handler = (data) => {
|
|
clearTimeout(timeout);
|
|
socket.off('skills_data', handler);
|
|
if (data && (Array.isArray(data) || typeof data === 'object')) {
|
|
resolve(data);
|
|
} else {
|
|
reject(new Error('Invalid skill data from server'));
|
|
}
|
|
};
|
|
|
|
socket.on('skills_data', handler);
|
|
socket.emit('get_skills');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Merge server skill definitions into the local skill map.
|
|
* Preserves any progress already loaded from playerData.
|
|
*/
|
|
_applyServerDefinitions(serverSkills) {
|
|
// Server may return an array or a categorised object
|
|
const asList = Array.isArray(serverSkills)
|
|
? serverSkills
|
|
: Object.values(serverSkills).flat();
|
|
|
|
// Reset to empty categories first
|
|
this.skills = { combat: {}, science: {}, crafting: {} };
|
|
|
|
for (const skill of asList) {
|
|
const cat = skill.category || 'combat';
|
|
if (!this.skills[cat]) this.skills[cat] = {};
|
|
|
|
// Keep any existing progress if already loaded from save data
|
|
const existing = this.skills[cat][skill.id] || {};
|
|
this.skills[cat][skill.id] = {
|
|
name: skill.name,
|
|
description: skill.description,
|
|
maxLevel: skill.maxLevel || 100,
|
|
effects: skill.bonuses || skill.effects || {},
|
|
icon: skill.icon || 'fa-star',
|
|
// Progress fields — kept from existing save data if present
|
|
currentLevel: existing.currentLevel ?? 0,
|
|
experience: existing.experience ?? 0,
|
|
experienceToNext: existing.experienceToNext ?? (skill.experiencePerLevel || 1000),
|
|
unlocked: existing.unlocked ?? (skill.defaultUnlocked !== false),
|
|
requiredLevel: skill.requiredLevel ?? null
|
|
};
|
|
}
|
|
|
|
console.log('[SKILL SYSTEM] Applied server definitions. Categories:', Object.keys(this.skills));
|
|
}
|
|
|
|
// ------------------------------------------------------------------ //
|
|
// Skill progression
|
|
// ------------------------------------------------------------------ //
|
|
addSkillExperience(category, skillId, amount) {
|
|
const skill = this.skills[category]?.[skillId];
|
|
if (!skill || skill.currentLevel >= skill.maxLevel) return false;
|
|
|
|
skill.experience += amount;
|
|
|
|
while (skill.experience >= skill.experienceToNext && skill.currentLevel < skill.maxLevel) {
|
|
this.levelUpSkill(category, skillId);
|
|
}
|
|
|
|
this.applySkillEffects();
|
|
return true;
|
|
}
|
|
|
|
levelUpSkill(category, skillId) {
|
|
const skill = this.skills[category][skillId];
|
|
const excess = skill.experience - skill.experienceToNext;
|
|
|
|
skill.currentLevel++;
|
|
skill.experienceToNext = Math.floor(skill.experienceToNext * 1.5);
|
|
skill.experience = Math.max(0, excess);
|
|
|
|
this.applySkillEffects();
|
|
this.game.showNotification(`${skill.name} leveled up to ${skill.currentLevel}!`, 'success', 4000);
|
|
}
|
|
|
|
upgradeSkill(category, skillId) {
|
|
const skill = this.skills[category]?.[skillId];
|
|
const player = this.game.systems.player;
|
|
|
|
if (!skill) { this.game.showNotification('Skill not found', 'error', 3000); return false; }
|
|
if (!skill.unlocked) { this.game.showNotification('Skill is locked', 'error', 3000); return false; }
|
|
if (skill.currentLevel >= skill.maxLevel){ this.game.showNotification('Skill is at maximum level', 'warning', 3000); return false; }
|
|
if (player.stats.skillPoints < 1) { this.game.showNotification('Not enough skill points', 'error', 3000); return false; }
|
|
|
|
player.stats.skillPoints--;
|
|
this.levelUpSkill(category, skillId);
|
|
|
|
if (window.smartSaveManager?.isMultiplayer || this.game?.isRunning) {
|
|
this.updateUI();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
unlockSkill(category, skillId) {
|
|
const skill = this.skills[category]?.[skillId];
|
|
const player = this.game.systems.player;
|
|
|
|
if (!skill) { this.game.showNotification('Skill not found', 'error', 3000); return false; }
|
|
if (skill.unlocked) { this.game.showNotification('Skill is already unlocked', 'warning', 3000); return false; }
|
|
if (skill.requiredLevel && player.stats.level < skill.requiredLevel) {
|
|
this.game.showNotification(`Requires level ${skill.requiredLevel}`, 'error', 3000);
|
|
return false;
|
|
}
|
|
if (player.stats.skillPoints < 2) {
|
|
this.game.showNotification('Requires 2 skill points to unlock', 'error', 3000);
|
|
return false;
|
|
}
|
|
|
|
player.stats.skillPoints -= 2;
|
|
skill.unlocked = true;
|
|
skill.currentLevel = 1;
|
|
this.applySkillEffects();
|
|
|
|
if (window.smartSaveManager?.isMultiplayer || this.game?.isRunning) {
|
|
this.updateUI();
|
|
}
|
|
this.game.showNotification(`${skill.name} unlocked!`, 'success', 4000);
|
|
return true;
|
|
}
|
|
|
|
applySkillEffects() {
|
|
const player = this.game.systems.player;
|
|
this.resetToBaseStats();
|
|
|
|
for (const category of Object.values(this.skills)) {
|
|
for (const skill of Object.values(category)) {
|
|
if (!skill.unlocked || skill.currentLevel <= 0) continue;
|
|
|
|
for (const [effect, value] of Object.entries(skill.effects || {})) {
|
|
const total = value * skill.currentLevel;
|
|
switch (effect) {
|
|
case 'attack': player.attributes.attack += total; break;
|
|
case 'defense': player.attributes.defense += total; break;
|
|
case 'speed': player.attributes.speed += total; break;
|
|
case 'health':
|
|
case 'maxHealth': player.attributes.maxHealth += total; break;
|
|
case 'maxEnergy': player.attributes.maxEnergy += total; break;
|
|
case 'criticalChance': player.attributes.criticalChance += total; break;
|
|
case 'criticalDamage': player.attributes.criticalDamage += total; break;
|
|
default:
|
|
if (!this.activeBuffs[effect]) this.activeBuffs[effect] = 0;
|
|
this.activeBuffs[effect] += total;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
player.updateUI();
|
|
}
|
|
|
|
resetToBaseStats() {
|
|
const player = this.game.systems.player;
|
|
const lvl = player.stats.level || 1;
|
|
Object.assign(player.attributes, {
|
|
attack: 10 + (lvl - 1) * 2,
|
|
defense: 5 + (lvl - 1) * 1,
|
|
speed: 10,
|
|
maxHealth: 100 + (lvl - 1) * 10,
|
|
maxEnergy: 100 + (lvl - 1) * 5,
|
|
criticalChance: 0.05,
|
|
criticalDamage: 1.5
|
|
});
|
|
this.activeBuffs = {};
|
|
}
|
|
|
|
// ------------------------------------------------------------------ //
|
|
// Combat / science / crafting XP helpers
|
|
// ------------------------------------------------------------------ //
|
|
awardCombatExperience(amount) {
|
|
this.addSkillExperience('combat', 'weapons_mastery', amount);
|
|
this.addSkillExperience('combat', 'tactical_analysis', amount * 0.5);
|
|
}
|
|
|
|
awardScienceExperience(amount) {
|
|
this.addSkillExperience('science', 'energy_manipulation', amount);
|
|
this.addSkillExperience('science', 'alien_technology', amount * 0.3);
|
|
}
|
|
|
|
awardCraftingExperience(amount) {
|
|
this.addSkillExperience('crafting', 'weapons_crafting', amount);
|
|
this.addSkillExperience('crafting', 'armor_forging', amount * 0.5);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ //
|
|
// Queries
|
|
// ------------------------------------------------------------------ //
|
|
getSkillLevel(category, skillId) {
|
|
// Support single-arg form used by CraftingSystem: getSkillLevel('crafting')
|
|
if (skillId === undefined) {
|
|
let max = 0;
|
|
for (const cat of Object.values(this.skills)) {
|
|
if (cat[category]) max = Math.max(max, cat[category].currentLevel || 0);
|
|
}
|
|
return max;
|
|
}
|
|
return this.skills[category]?.[skillId]?.currentLevel || 0;
|
|
}
|
|
|
|
getSkillExperience(skillId) {
|
|
for (const cat of Object.values(this.skills)) {
|
|
if (cat[skillId]) return cat[skillId].experience || 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
getExperienceNeeded(skillId) {
|
|
for (const cat of Object.values(this.skills)) {
|
|
if (cat[skillId]) return cat[skillId].experienceToNext || 0;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
hasSkill(category, skillId, minimumLevel = 1) {
|
|
const skill = this.skills[category]?.[skillId];
|
|
return skill && skill.unlocked && skill.currentLevel >= minimumLevel;
|
|
}
|
|
|
|
getSkillBonus(effect) {
|
|
return this.activeBuffs[effect] || 0;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ //
|
|
// UI
|
|
// ------------------------------------------------------------------ //
|
|
updateUI() {
|
|
this.updateSkillsGrid();
|
|
this.updateSkillPointsDisplay();
|
|
}
|
|
|
|
updateSkillsGrid() {
|
|
const grid = document.getElementById('skillsGrid');
|
|
if (!grid) return;
|
|
|
|
const activeCategory = document.querySelector('.skill-cat-btn.active')?.dataset.category || 'combat';
|
|
const skills = this.skills[activeCategory] || {};
|
|
|
|
if (!this._loaded) {
|
|
grid.innerHTML = '<div style="text-align:center;padding:2rem;color:var(--text-secondary)"><i class="fas fa-spinner fa-spin"></i><p>Loading skills from server...</p></div>';
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = '';
|
|
|
|
Object.entries(skills).forEach(([skillId, skill]) => {
|
|
const el = document.createElement('div');
|
|
el.className = `skill-item ${!skill.unlocked ? 'locked' : ''}`;
|
|
|
|
const progressPercent = skill.currentLevel > 0
|
|
? (skill.experience / skill.experienceToNext) * 100
|
|
: 0;
|
|
|
|
const iconClass = this.game.systems.textureManager
|
|
? this.game.systems.textureManager.getIcon(skill.icon)
|
|
: (skill.icon || 'fa-question');
|
|
|
|
el.innerHTML = `
|
|
<div class="skill-header">
|
|
<div class="skill-icon"><i class="fas ${iconClass}"></i></div>
|
|
<div class="skill-info">
|
|
<div class="skill-name">${skill.name}</div>
|
|
<div class="skill-level">Lv. ${skill.currentLevel}/${skill.maxLevel}</div>
|
|
</div>
|
|
</div>
|
|
<div class="skill-description">${skill.description}</div>
|
|
${skill.currentLevel > 0 && skill.currentLevel < skill.maxLevel ? `
|
|
<div class="skill-progress">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width:${progressPercent}%"></div>
|
|
</div>
|
|
<span>${skill.experience}/${skill.experienceToNext} XP</span>
|
|
</div>
|
|
` : skill.currentLevel >= skill.maxLevel ? `
|
|
<div class="skill-max-level"><span>MAX LEVEL</span></div>
|
|
` : ''}
|
|
<div class="skill-actions">
|
|
${!skill.unlocked ? `
|
|
<button class="btn btn-warning" onclick="if(window.game&&window.game.systems)window.game.systems.skillSystem.unlockSkill('${activeCategory}','${skillId}')">
|
|
Unlock (2 Points)
|
|
</button>
|
|
` : skill.currentLevel < skill.maxLevel ? `
|
|
<button class="btn btn-primary" onclick="if(window.game&&window.game.systems)window.game.systems.skillSystem.upgradeSkill('${activeCategory}','${skillId}')">
|
|
Upgrade (1 Point)
|
|
</button>
|
|
` : `<span class="max-level">MAX LEVEL</span>`}
|
|
</div>
|
|
${skill.requiredLevel && !skill.unlocked ? `
|
|
<div class="skill-requirement">Requires Level ${skill.requiredLevel}</div>
|
|
` : ''}
|
|
`;
|
|
|
|
grid.appendChild(el);
|
|
});
|
|
}
|
|
|
|
updateSkillPointsDisplay() {
|
|
const player = this.game.systems.player;
|
|
document.querySelectorAll('.skill-points').forEach(el => {
|
|
el.textContent = `Skill Points: ${player.stats.skillPoints}`;
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------------ //
|
|
// Save / Load
|
|
// ------------------------------------------------------------------ //
|
|
save() {
|
|
return { skills: this.skills, activeBuffs: this.activeBuffs };
|
|
}
|
|
|
|
load(data) {
|
|
if (data.skills) {
|
|
for (const [category, skills] of Object.entries(data.skills)) {
|
|
if (!this.skills[category]) this.skills[category] = {};
|
|
for (const [skillId, skillData] of Object.entries(skills)) {
|
|
if (this.skills[category][skillId]) {
|
|
Object.assign(this.skills[category][skillId], skillData);
|
|
} else {
|
|
// Store progress even before server definitions arrive
|
|
this.skills[category][skillId] = { ...skillData };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (data.activeBuffs) this.activeBuffs = data.activeBuffs;
|
|
this.applySkillEffects();
|
|
}
|
|
|
|
reset() {
|
|
this.activeBuffs = {};
|
|
for (const category of Object.values(this.skills)) {
|
|
for (const skill of Object.values(category)) {
|
|
skill.currentLevel = 0;
|
|
skill.experience = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
clear() { this.reset(); }
|
|
}
|