- Isometric canvas renderer (depth-sorted, FOV/fog, additive lighting) - 3 classes x 20 skills, 4 acts x 4 floors + boss lairs, torment I-X - Diablo-style loot: rarities, affix tiers, 14 legendaries, vendor, stash - Rogue camp with 6 NPCs: Charsi/Akara/Kashya/Cain/Gheed/storage - NPC quest chain (accept -> hunt -> turn in) with rewards & gating - Procedural WebAudio SFX + generative music, EN/VI localization - Saves, settings, waypoints, hardcore mode, PWA manifest - 93-assertion headless suite + browser E2E via CDP
328 lines
11 KiB
JavaScript
328 lines
11 KiB
JavaScript
/* ============================================================
|
|
* 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);
|