Files
deepseek fc1fa2d51e Diablo2D — Shadows of Tristram: complete browser ARPG
- 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
2026-08-23 06:59:36 +00:00

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);