/* ============================================================ * Diablo2D — player.js : stats aggregation, leveling, skills UI logic * ============================================================ */ 'use strict'; window.D2 = window.D2 || {}; (function (D2) { /* special legendary flags -> stat keys */ const SPECIAL_MAP = { cleaveEcho: 'cleaveEcho', doubleShot: 'doubleShot', fireNovaOnKill: 'fireNovaOnKill', chillAttacker: 'chillAttacker', allSkills: null, // handled via skillBonusLevels execHeal: 'execHeal', stormDash: 'dashCdrPct', // value applied below lifesteal: 'lifestealPct', eliteSlayer: 'eliteDmgPct', chainOnHit: 'chainOnHitPct', corpseBoom: 'corpseBoom', venomStrike: 'venomOnHit', voidstep: 'blinkCdrPct', }; function createPlayer(classId) { const p = new D2.entities.Player(classId); /* baseMods are applied inside recompute(); attributes hold allocated points only */ /* starter gear */ p.equip.weapon = D2.Items.startingWeapon(classId); p.equip.chest = D2.Items.startingArmor(); /* auto-assign starting actives to hotbar */ const actives = D2.Skills.skillsFor(classId).filter(s => s.type === 'active' && s.tier === 0); let slot = 1; for (const a of actives) { if (slot >= 5) break; p.skills[a.id] = 0; // rank 0 = known-but-untrained? no: grant rank 1 free p.skills[a.id] = 1; p.hotbar[slot] = a.id; slot++; } recompute(p); p.hp = p.maxHp; p.mana = p.maxMana; return p; } /** full stat aggregation */ function recompute(p) { const oldMaxHp = p.maxHp, oldMaxMana = p.maxMana; const BAL = D2.BAL; const s = {}; const add = (k, v) => { s[k] = (s[k] || 0) + v; }; /* class base */ const cls = D2.Skills.CLASSES[p.classId]; for (const [k, v] of Object.entries(cls.baseMods)) add(k, v); add('armor', D2.BAL.baseArmor); /* allocated attributes */ for (const [k, v] of Object.entries(p.attributes)) if (v) add(k, v); /* per-level growth (small) */ add('str', Math.floor(p.level / 4)); add('dex', Math.floor(p.level / 4)); add('vit', Math.floor(p.level / 3)); add('ene', Math.floor(p.level / 4)); /* equipment */ let skillBonusLevels = 0; for (const slotKey of Object.keys(p.equip)) { const it = p.equip[slotKey]; if (!it) continue; if (it.stats) for (const [k, v] of Object.entries(it.stats)) add(k, v); if (it.armor) add('armor', it.armor); if (it.flatDmg) add('flatDmg', it.flatDmg); if (it.special === 'allSkills') skillBonusLevels = 1; const flag = SPECIAL_MAP[it.special]; if (flag === 'lifestealPct') s.lifestealPct = 0.06; else if (flag === 'chainOnHitPct') s.chainOnHitPct = 0.15; else if (flag === 'eliteDmgPct') s.eliteDmgPct = 25; else if (flag === 'dashCdrPct') s.dashCdrPct = 30; else if (flag === 'blinkCdrPct') s.blinkCdrPct = 30; else if (flag) s[flag] = true; } /* passives from learned skills */ for (const [skillId, rank] of Object.entries(p.skills)) { if (!rank || rank <= 0) continue; const sk = D2.Skills.findSkill(p.classId, skillId); if (!sk || sk.type !== 'passive') continue; const mods = sk.mods(rank); for (const [k, v] of Object.entries(mods)) add(k, v); } /* active buffs */ for (const b of p.buffs) { if (b.mods) for (const [k, v] of Object.entries(b.mods)) add(k, v); } /* shrine/regen derived */ p.stats = s; /* derived attributes */ p.maxHp = Math.round(BAL.baseHp + s.vit * BAL.hpPerVit + (p.level - 1) * BAL.hpPerLevel + (s.hpFlat || 0)); p.maxMana = Math.round(BAL.baseMana + s.ene * BAL.manaPerEne + (p.level - 1) * BAL.manaPerLevel + (s.manaFlat || 0)); /* keep ratio when max grows */ if (oldMaxHp > 0 && p.maxHp > oldMaxHp) p.hp += p.maxHp - oldMaxHp; if (oldMaxMana > 0 && p.maxMana > oldMaxMana) p.mana += p.maxMana - oldMaxMana; p.hp = D2.util.clamp(p.hp, 0, p.maxHp); p.mana = D2.util.clamp(p.mana, 0, p.maxMana); p.moveSpeed = Math.min(BAL.speedCap, BAL.baseSpeed * (1 + (s.moveSpeedPct || 0) / 100)); p.skillBonusLevels = skillBonusLevels; } function buffMod(p, key) { let v = 0; for (const b of p.buffs) if (b.mods && b.mods[key]) v += b.mods[key]; return v; } PlayerProtoInit(); function PlayerProtoInit() { D2.entities.Player.prototype.buffMod = function (key) { return buffMod(this, key); }; } /* ---------------- XP & levels ---------------- */ function gainXp(game, amount, source) { const p = game.player; amount = Math.round(amount * (1 + buffMod(p, 'xpGain') / 100)); p.xp += amount; game.addFloatText( source ? source.x : p.x, source ? source.y - 0.5 : p.y - 0.5, '+' + amount + ' xp', '#c8a35a', 11); while (p.level < D2.BAL.maxLevel && p.xp >= D2.BAL.xpForLevel(p.level)) { levelUp(game); } if (D2.ui) { D2.ui.refreshHud(); } } function levelUp(game) { const p = game.player; p.level++; p.statPoints += D2.BAL.statPointsPerLevel; p.skillPoints += D2.BAL.skillPointsPerLevel; recompute(p); p.hp = p.maxHp; p.mana = p.maxMana; game.sfx('levelup'); game.toast(D2.i18n.t('msg.levelup'), 'gold'); game.addFloatText(p.x, p.y, 'LEVEL ' + p.level + '!', '#ffe86a', 18); game.spawnParticles(p.x, p.y, { count: 26, color: '#ffe86a', speed: 4.5, life: 0.9, size: 2.8, z: 14 }); game.addLight(p.x, p.y, 5, '#ffe86a', 0.9, 1); if (D2.ui) { D2.ui.refreshHud(); D2.ui.refreshCharacter(); D2.ui.refreshSkills(); } } function allocateStat(p, key) { if (p.statPoints <= 0) return false; if (!(key in p.attributes)) return false; p.attributes[key]++; p.statPoints--; recompute(p); return true; } /* ---------------- skills ---------------- */ function getSkillRank(p, skillId) { const r = p.skills[skillId] || 0; if (r > 0 && p.skillBonusLevels) { const sk = D2.Skills.findSkill(p.classId, skillId); if (sk && sk.type === 'active') return Math.min(sk.maxRank, r + p.skillBonusLevels); } return r; } function canLearnSkill(p, skill) { if ((p.skills[skill.id] || 0) >= skill.maxRank) return 'maxed'; if (p.level < skill.unlockLevel) return 'level'; if (p.skillPoints <= 0) return 'points'; return true; } function learnSkill(game, skill) { const p = game.player; const ok = canLearnSkill(p, skill); if (ok !== true) { if (game && game.sfx) game.sfx('error'); return false; } p.skills[skill.id] = (p.skills[skill.id] || 0) + 1; p.skillPoints--; /* auto-assign to hotbar on first rank */ if (p.skills[skill.id] === 1 && !p.hotbar.includes(skill.id)) { const empty = p.hotbar.findIndex((v, i) => i > 0 && v == null); if (empty >= 0) p.hotbar[empty] = skill.id; } if (game && game.sfx) game.sfx('buff'); recompute(p); if (D2.ui) { D2.ui.refreshSkills(); D2.ui.refreshHotbar(); } return true; } /* ---------------- potions ---------------- */ let beltCd = 0; function usePotion(game, type) { const p = game.player; if (beltCd > 0) return false; if (p.potions[type] <= 0) { game.toast(D2.i18n.t('msg.potion_none'), 'bad'); if (game && game.sfx) game.sfx('error'); return false; } const BAL = D2.BAL; if (type === 'hp') { if (p.hp >= p.maxHp) return false; const heal = p.maxHp * BAL.potionHealPct + BAL.potionHealFlat(p.level); D2.combat.healPlayer(game, heal); } else { if (p.mana >= p.maxMana) return false; const restore = p.maxMana * BAL.potionManaPct + BAL.potionHealFlat(p.level) * 0.6; p.mana = Math.min(p.maxMana, p.mana + restore); game.addFloatText(p.x, p.y - .3, '+' + Math.round(restore), '#6f9fe8', 11); } p.potions[type]--; beltCd = 0.8; game.sfx('potion'); if (D2.ui) D2.ui.refreshHotbar(); return true; } function tickBelt(dt) { if (beltCd > 0) beltCd -= dt; } /* ---------------- equip/unequip ---------------- */ function resolvePlayer(g) { return g && g.player ? g.player : g; } function equipItem(game, item, fromInventoryIdx) { const p = resolvePlayer(game); let slotKey = item.slot; if (item.slot === 'ring') { if (!p.equip.ring1) slotKey = 'ring1'; else if (!p.equip.ring2) slotKey = 'ring2'; else slotKey = 'ring1'; } const prev = p.equip[slotKey]; p.equip[slotKey] = item; if (fromInventoryIdx != null) { p.inventory.splice(fromInventoryIdx, 1); } if (prev) p.inventory.push(prev); recompute(p); if (game && game.sfx) game.sfx('pickup'); if (D2.ui) { D2.ui.refreshInventory(); D2.ui.refreshCharacter(); D2.ui.refreshHotbar(); } return true; } function unequipSlot(game, slotKey) { const p = resolvePlayer(game); const it = p.equip[slotKey]; if (!it) return false; if (p.inventory.length >= 40) { if (game && game.toast) game.toast(D2.i18n.t('msg.inventory_full'), 'bad'); return false; } p.equip[slotKey] = null; p.inventory.push(it); recompute(p); if (game && game.sfx) game.sfx('click'); if (D2.ui) { D2.ui.refreshInventory(); D2.ui.refreshCharacter(); } return true; } function sellItem(game, invIdxOrItem, priceOverride) { const p = resolvePlayer(game); let item, idx = invIdxOrItem; if (typeof invIdxOrItem === 'object') { item = invIdxOrItem; idx = p.inventory.indexOf(item); } else item = p.inventory[idx]; if (!item) return false; const val = priceOverride != null ? priceOverride : Math.round(item.value * D2.BAL.sellRatio); if (idx >= 0) p.inventory.splice(idx, 1); else { /* equipped? */ for (const k of Object.keys(p.equip)) if (p.equip[k] === item) p.equip[k] = null; } p.gold += val; if (game && game.sfx) game.sfx('sell'); recompute(p); if (D2.ui) { D2.ui.refreshVendor(); D2.ui.refreshInventory(); D2.ui.refreshCharacter(); } return val; } function buyItem(game, item) { const p = resolvePlayer(game); if (p.gold < item.value) { if (game && game.toast) game.toast(D2.i18n.t('msg.not_enough_mana').replace(/mana/i, 'gold'), 'bad'); if (game && game.sfx) game.sfx('error'); return false; } if (p.inventory.length >= 40) { if (game && game.toast) game.toast(D2.i18n.t('msg.inventory_full'), 'bad'); return false; } p.gold -= item.value; p.inventory.push(item); if (game && game.sfx) game.sfx('buy'); if (D2.ui) { D2.ui.refreshVendor(); D2.ui.refreshInventory(); } return true; } D2.player = { createPlayer, recompute, gainXp, allocateStat, getSkillRank, canLearnSkill, learnSkill, usePotion, tickBelt, equipItem, unequipSlot, sellItem, buyItem, buffMod, }; })(window.D2);