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
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/* ============================================================
|
||||
* Diablo2D — balance.js : tuning constants & progression curves
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
window.D2 = window.D2 || {};
|
||||
(function (D2) {
|
||||
|
||||
const BAL = {
|
||||
/* --- progression --- */
|
||||
maxLevel: 40,
|
||||
statPointsPerLevel: 5,
|
||||
skillPointsPerLevel: 1,
|
||||
xpForLevel(l) { return Math.floor(58 * Math.pow(l, 1.72) + 24 * l); },
|
||||
|
||||
/* --- player base --- */
|
||||
baseHp: 42, hpPerVit: 3.4, hpPerLevel: 7.5,
|
||||
baseMana: 18, manaPerEne: 2.2, manaPerLevel: 3.5,
|
||||
baseArmor: 3,
|
||||
baseCrit: 0.05, baseCritDmg: 1.5,
|
||||
baseSpeed: 3.4, // tiles / second
|
||||
baseAttackCd: 0.62, // seconds (modified by weapon aps)
|
||||
hpRegenPerSec: 0.35, manaRegenPerSec: 1.4,
|
||||
|
||||
/* primary stat -> derived */
|
||||
meleeDmgPerStr: 0.011, // +1.1% weapon dmg / str
|
||||
rangedDmgPerDex: 0.011,
|
||||
castDmgPerEne: 0.012,
|
||||
armorPerStr: 0.25,
|
||||
critPerDex: 0.0018, // +0.18% / dex
|
||||
dodgePerDex: 0.0022,
|
||||
|
||||
/* caps */
|
||||
resistCap: 0.72,
|
||||
armorDrCap: 0.78,
|
||||
critCap: 0.75,
|
||||
speedCap: 6.2,
|
||||
|
||||
/* --- monsters --- */
|
||||
monsterLevel(act, floorIdx, torment) { return 1 + act * 4 + floorIdx + (torment || 0) * 9; },
|
||||
mHp(mlvl) { return Math.floor(15 + 9.2 * Math.pow(mlvl, 1.36)); },
|
||||
mDmg(mlvl) { return Math.floor(2.4 + 2.1 * Math.pow(mlvl, 1.28)); },
|
||||
mXp(mlvl) { return Math.floor(7 + 5.2 * Math.pow(mlvl, 1.35)); },
|
||||
mArmor(mlvl) { return Math.floor(2 + 1.6 * mlvl); },
|
||||
|
||||
eliteHpMult: 2.2, eliteDmgMult: 1.38, eliteXpMult: 2.4, eliteSizeMult: 1.22,
|
||||
bossHpMult: 7.0, bossDmgMult: 1.55, bossXpMult: 12, bossSizeMult: 1.85,
|
||||
|
||||
/* --- combat --- */
|
||||
armorK: 34 + 6.2, // DR = armor/(armor + K*mlvl) — K applied as armorK*mlvl
|
||||
knockback: 0.16, // tiles
|
||||
hitFlashTime: 0.12,
|
||||
corpseTime: 14,
|
||||
goldDropBase(mlvl) { return Math.floor(3 + 2.6 * mlvl * (0.8 + Math.random() * 0.5)); },
|
||||
|
||||
/* --- drops --- */
|
||||
dropChances: {
|
||||
gold: 0.42, potionHp: 0.11, potionMp: 0.075, gear: 0.145, gearEliteBonus: 0.35, gearBoss: 1.0,
|
||||
},
|
||||
rarityWeights: { common: 100, magic: 34, rare: 9.5, legendary: 1.35 },
|
||||
magicFindRarityBoost(mf) { return 1 + mf / 100 * 0.85; }, // mf stat in percent points
|
||||
potionStackMax: 12,
|
||||
potionHealPct: 0.32,
|
||||
potionManaPct: 0.38,
|
||||
potionHealFlat(mlvl) { return 14 + 6 * mlvl; },
|
||||
|
||||
/* --- economy --- */
|
||||
vendorStockSize: [8, 13],
|
||||
vendorRerollCostMult: 0.0,
|
||||
sellRatio: 0.28,
|
||||
buyMarkup: 1.0,
|
||||
healerCost(level) { return 12 + level * 6; },
|
||||
stashRows: 7,
|
||||
|
||||
/* --- death --- */
|
||||
deathGoldPenalty: 0.12,
|
||||
|
||||
/* --- acts --- */
|
||||
/* ---- quest chain (Diablo-style: accept from NPCs, hunt, turn in) ---- */
|
||||
questKillBase(act) { return 12 + act * 4; },
|
||||
gambleCost(level) { return 80 + level * 45; },
|
||||
tomeCost(level) { return 120 + level * 60; },
|
||||
respecCost(level) { return 300 + level * 90; },
|
||||
QUESTS: [
|
||||
// Act I
|
||||
{ id: 'a0_cull', giver: 'kashya', act: 0, type: 'kills', target: 15,
|
||||
title: 'quest.cull', desc: 'quest.cull.desc', reward: { gold: 180, xp: 90 } },
|
||||
{ id: 'a0_boss', giver: 'cain', act: 0, requires: 'a0_cull', type: 'boss',
|
||||
title: 'quest.boss', desc: 'quest.boss.desc', reward: { gold: 450, xp: 320, itemLevelBonus: 2 } },
|
||||
// Act II
|
||||
{ id: 'a1_cull', giver: 'kashya', act: 1, type: 'kills', target: 18,
|
||||
title: 'quest.cull', desc: 'quest.cull.desc', reward: { gold: 380, xp: 700 } },
|
||||
{ id: 'a1_boss', giver: 'cain', act: 1, requires: 'a1_cull', type: 'boss',
|
||||
title: 'quest.boss', desc: 'quest.boss.desc', reward: { gold: 800, xp: 1500, itemLevelBonus: 3 } },
|
||||
// Act III
|
||||
{ id: 'a2_cull', giver: 'kashya', act: 2, type: 'kills', target: 22,
|
||||
title: 'quest.cull', desc: 'quest.cull.desc', reward: { gold: 900, xp: 4200 } },
|
||||
{ id: 'a2_boss', giver: 'cain', act: 2, requires: 'a2_cull', type: 'boss',
|
||||
title: 'quest.boss', desc: 'quest.boss.desc', reward: { gold: 1600, xp: 8000, itemLevelBonus: 4 } },
|
||||
// Act IV
|
||||
{ id: 'a3_cull', giver: 'kashya', act: 3, type: 'kills', target: 26,
|
||||
title: 'quest.cull', desc: 'quest.cull.desc', reward: { gold: 2000, xp: 22000 } },
|
||||
{ id: 'a3_boss', giver: 'cain', act: 3, requires: 'a3_cull', type: 'boss',
|
||||
title: 'quest.boss', desc: 'quest.boss.desc', reward: { gold: 3500, xp: 40000, itemLevelBonus: 5 } },
|
||||
// Post-victory repeatable hook
|
||||
{ id: 'torment_1', giver: 'cain', act: 3, requiresVictory: true, type: 'boss', torment: true,
|
||||
title: 'quest.torment', desc: 'quest.torment.desc', reward: { gold: 5000, xp: 60000, itemLevelBonus: 6 } },
|
||||
],
|
||||
questById(id) { return this.QUESTS.find(q => q.id === id); },
|
||||
|
||||
acts: [
|
||||
{ name: 'act.names.0', floors: 4, boss: 'butcher', theme: 'cathedral', music: 'crypt' },
|
||||
{ name: 'act.names.1', floors: 4, boss: 'boneking', theme: 'catacombs', music: 'crypt' },
|
||||
{ name: 'act.names.2', floors: 4, boss: 'spiderqueen', theme: 'caves', music: 'cave' },
|
||||
{ name: 'act.names.3', floors: 4, boss: 'terrorlord', theme: 'hell', music: 'hell' },
|
||||
],
|
||||
floorsPerAct: 4, // last floor of each act is the boss lair
|
||||
maxTorment: 10,
|
||||
|
||||
/* --- shrine buffs --- */
|
||||
shrineDuration: 45,
|
||||
shrineBuffPower: { dmg: 0.35, speed: 0.3, armor: 0.5, xp: 0.5, regen: 4 },
|
||||
|
||||
/* --- skills --- */
|
||||
skillMaxRank: 5,
|
||||
tierUnlockLevels: [1, 4, 8, 13, 19],
|
||||
manaCostRank(base) { return Math.round(base * (1 + 0.14 * 4)); }, // computed per rank elsewhere
|
||||
cooldownReductionCap: 0.55,
|
||||
|
||||
/* --- misc --- */
|
||||
interactRange: 1.35,
|
||||
pickupRange: 1.15,
|
||||
aggroRange: 7.5,
|
||||
deaggroLeash: 14,
|
||||
maxProjectiles: 260,
|
||||
maxParticles: 700,
|
||||
maxFloatingText: 90,
|
||||
};
|
||||
|
||||
BAL.armorK = 40; // final constant
|
||||
|
||||
D2.BAL = BAL;
|
||||
})(window.D2);
|
||||
@@ -0,0 +1,410 @@
|
||||
/* ============================================================
|
||||
* Diablo2D — items.js : item bases, affixes, legendaries, rolling
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
window.D2 = window.D2 || {};
|
||||
(function (D2) {
|
||||
|
||||
const RARITIES = {
|
||||
common: { color: '#c8c8c8', mult: 1.00, affixes: [0, 0], weight: 100 },
|
||||
magic: { color: '#7c9cf5', mult: 1.08, affixes: [1, 2], weight: 34 },
|
||||
rare: { color: '#f2e34c', mult: 1.16, affixes: [3, 4], weight: 9.5 },
|
||||
legendary: { color: '#ef8f34', mult: 1.28, affixes: [0, 1], weight: 1.35 },
|
||||
};
|
||||
|
||||
const SLOTS = ['weapon', 'offhand', 'head', 'chest', 'hands', 'feet', 'amulet', 'ring'];
|
||||
|
||||
/* ---------------- base items ---------------- */
|
||||
/* dmg:[min,max] at minIlvl; armor base; aps = attacks per second */
|
||||
const BASES = [
|
||||
/* weapons */
|
||||
{ id: 'dagger', name: { en: 'Dagger', vi: 'Dao Găm' }, slot: 'weapon', kind: 'melee', minIlvl: 1, tier: 1, dmg: [2, 5], aps: 1.62 },
|
||||
{ id: 'shortsword', name: { en: 'Short Sword', vi: 'Kiếm Ngắn' }, slot: 'weapon', kind: 'melee', minIlvl: 1, tier: 1, dmg: [3, 7], aps: 1.38 },
|
||||
{ id: 'club', name: { en: 'Club', vi: 'Gậy Thô' }, slot: 'weapon', kind: 'melee', minIlvl: 1, tier: 1, dmg: [4, 8], aps: 1.18 },
|
||||
{ id: 'longsword', name: { en: 'Long Sword', vi: 'Kiếm Dài' }, slot: 'weapon', kind: 'melee', minIlvl: 7, tier: 2, dmg: [6, 12], aps: 1.32 },
|
||||
{ id: 'battleaxe', name: { en: 'Battle Axe', vi: 'Rìu Chiến' }, slot: 'weapon', kind: 'melee', minIlvl: 10, tier: 2, dmg: [9, 16], aps: 1.08 },
|
||||
{ id: 'mace', name: { en: 'Mace', vi: 'Chùy' }, slot: 'weapon', kind: 'melee', minIlvl: 9, tier: 2, dmg: [8, 14], aps: 1.15 },
|
||||
{ id: 'warhammer', name: { en: 'War Hammer', vi: 'Búa Chiến' }, slot: 'weapon', kind: 'melee', minIlvl: 17, tier: 3, dmg: [13, 21], aps: 0.95 },
|
||||
{ id: 'greatsword', name: { en: 'Great Sword', vi: 'Đại Kiếm' }, slot: 'weapon', kind: 'melee', minIlvl: 19, tier: 3, dmg: [15, 24], aps: 1.0 },
|
||||
{ id: 'reaver', name: { en: 'Reaver Axe', vi: 'Rìu Hủy Diệt' }, slot: 'weapon', kind: 'melee', minIlvl: 26, tier: 4, dmg: [20, 33], aps: 1.02 },
|
||||
|
||||
{ id: 'shortbow', name: { en: 'Short Bow', vi: 'Cung Ngắn' }, slot: 'weapon', kind: 'ranged', minIlvl: 1, tier: 1, dmg: [3, 6], aps: 1.45 },
|
||||
{ id: 'huntingbow', name: { en: 'Hunting Bow', vi: 'Cung Săn Bắn' }, slot: 'weapon', kind: 'ranged', minIlvl: 8, tier: 2, dmg: [7, 12], aps: 1.32 },
|
||||
{ id: 'longbow', name: { en: 'Long Bow', vi: 'Cung Dài' }, slot: 'weapon', kind: 'ranged', minIlvl: 16, tier: 3, dmg: [11, 19], aps: 1.18 },
|
||||
{ id: 'shadowbow', name: { en: 'Shadow Bow', vi: 'Cung Bóng Đêm' }, slot: 'weapon', kind: 'ranged', minIlvl: 25, tier: 4, dmg: [17, 28], aps: 1.24 },
|
||||
|
||||
{ id: 'gnarledstaff', name: { en: 'Gnarled Staff', vi: 'Trượng Gồ Ghề' }, slot: 'weapon', kind: 'cast', minIlvl: 1, tier: 1, dmg: [3, 6], aps: 1.28, implicit: { manaFlat: 4 } },
|
||||
{ id: 'oakstaff', name: { en: 'Oak Staff', vi: 'Trượng Sồi' }, slot: 'weapon', kind: 'cast', minIlvl: 9, tier: 2, dmg: [6, 11], aps: 1.22, implicit: { manaFlat: 8 } },
|
||||
{ id: 'runestaff', name: { en: 'Rune Staff', vi: 'Trượng Phù Văn' }, slot: 'weapon', kind: 'cast', minIlvl: 17, tier: 3, dmg: [10, 18], aps: 1.15, implicit: { manaFlat: 14 } },
|
||||
{ id: 'archspire', name: { en: 'Arch Spire', vi: 'Tháp Pháp Thuật' }, slot: 'weapon', kind: 'cast', minIlvl: 26, tier: 4, dmg: [15, 27], aps: 1.1, implicit: { manaFlat: 22 } },
|
||||
|
||||
/* offhand */
|
||||
{ id: 'buckler', name: { en: 'Buckler', vi: 'Khiên Nhỏ' }, slot: 'offhand', kind: 'shield', minIlvl: 1, tier: 1, armor: 4, implicit: { blockChance: 8 } },
|
||||
{ id: 'kiteshield', name: { en: 'Kite Shield', vi: 'Khiên Diềm' }, slot: 'offhand', kind: 'shield', minIlvl: 10, tier: 2, armor: 10, implicit: { blockChance: 12 } },
|
||||
{ id: 'towerward', name: { en: 'Tower Ward', vi: 'Lá Chắn Tháp' }, slot: 'offhand', kind: 'shield', minIlvl: 20, tier: 3, armor: 19, implicit: { blockChance: 16 } },
|
||||
{ id: 'quiver', name: { en: 'Quiver', vi: 'Túi Đao' }, slot: 'offhand', kind: 'focus', minIlvl: 1, tier: 1, armor: 1, implicit: { attackSpeedPct: 5 } },
|
||||
{ id: 'focusorb', name: { en: 'Focus Orb', vi: 'Cầu Tập Trung' }, slot: 'offhand', kind: 'focus', minIlvl: 1, tier: 1, armor: 1, implicit: { manaRegen: 0.5 } },
|
||||
|
||||
/* head */
|
||||
{ id: 'cap', name: { en: 'Cap', vi: 'Mũ Vải' }, slot: 'head', kind: 'armor', minIlvl: 1, tier: 1, armor: 3 },
|
||||
{ id: 'helm', name: { en: 'Helm', vi: 'Mũ Giáp' }, slot: 'head', kind: 'armor', minIlvl: 8, tier: 2, armor: 8 },
|
||||
{ id: 'great Helm', name: { en: 'Great Helm', vi: 'Mũ Giáp Lớn' }, slot: 'head', kind: 'armor', minIlvl: 18, tier: 3, armor: 15 },
|
||||
{ id: 'crownplate', name: { en: 'Crown Plate', vi: 'Vương Miện Thép' }, slot: 'head', kind: 'armor', minIlvl: 27, tier: 4, armor: 23 },
|
||||
|
||||
/* chest */
|
||||
{ id: 'leatherarmor', name: { en: 'Leather Armor', vi: 'Giáp Da' }, slot: 'chest', kind: 'armor', minIlvl: 1, tier: 1, armor: 6 },
|
||||
{ id: 'chainmail', name: { en: 'Chain Mail', vi: 'Giáp Xích' }, slot: 'chest', kind: 'armor', minIlvl: 9, tier: 2, armor: 14 },
|
||||
{ id: 'platearmor', name: { en: 'Plate Armor', vi: 'Giáp Tấm' }, slot: 'chest', kind: 'armor', minIlvl: 19, tier: 3, armor: 26 },
|
||||
{ id: 'doomplate', name: { en: 'War Plate', vi: 'Giáp Chiến Thần' }, slot: 'chest', kind: 'armor', minIlvl: 28, tier: 4, armor: 40 },
|
||||
|
||||
/* hands */
|
||||
{ id: 'gloves', name: { en: 'Gloves', vi: 'Găng Vải' }, slot: 'hands', kind: 'armor', minIlvl: 1, tier: 1, armor: 2 },
|
||||
{ id: 'gauntlets', name: { en: 'Gauntlets', vi: 'Găng Sắt' }, slot: 'hands', kind: 'armor', minIlvl: 10, tier: 2, armor: 6 },
|
||||
{ id: 'warmitts', name: { en: 'War Mitts', vi: 'Găng Chiến' }, slot: 'hands', kind: 'armor', minIlvl: 21, tier: 3, armor: 11 },
|
||||
|
||||
/* feet */
|
||||
{ id: 'boots', name: { en: 'Boots', vi: 'Ủng' }, slot: 'feet', kind: 'armor', minIlvl: 1, tier: 1, armor: 2, implicit: { moveSpeedPct: 4 } },
|
||||
{ id: 'greaves', name: { en: 'Greaves', vi: 'Giáp Chân' }, slot: 'feet', kind: 'armor', minIlvl: 11, tier: 2, armor: 6, implicit: { moveSpeedPct: 6 } },
|
||||
{ id: 'warboots', name: { en: 'War Treads', vi: 'Ủng Chiến Binh' }, slot: 'feet', kind: 'armor', minIlvl: 22, tier: 3, armor: 11, implicit: { moveSpeedPct: 8 } },
|
||||
|
||||
/* jewelry */
|
||||
{ id: 'amulet', name: { en: 'Amulet', vi: 'Mặt Dây' }, slot: 'amulet', kind: 'jewelry', minIlvl: 1, tier: 1 },
|
||||
{ id: 'ring', name: { en: 'Ring', vi: 'Nhẫn' }, slot: 'ring', kind: 'jewelry', minIlvl: 1, tier: 1 },
|
||||
];
|
||||
// fix accidental key with space
|
||||
BASES.find(b => b.id === 'great Helm').id = 'greathelm';
|
||||
|
||||
/* ---------------- affixes ---------------- */
|
||||
/* tiers: choose highest tier with t.ilvl <= ilvl; value uniform in [min,max] */
|
||||
const AFFIXES = [
|
||||
{ id: 'str', pre: { en: 'Mighty', vi: 'Uy Lực' }, suf: { en: 'of Strength', vi: 'của Sức Mạnh' }, slots: 'any', stat: 'str',
|
||||
tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 9, min: 2, max: 5 }, { ilvl: 18, min: 4, max: 8 }, { ilvl: 27, min: 7, max: 13 }] },
|
||||
{ id: 'dex', pre: { en: 'Deft', vi: 'Thoăn' }, suf: { en: 'of Dexterity', vi: 'của Nhuệ' }, slots: 'any', stat: 'dex',
|
||||
tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 9, min: 2, max: 5 }, { ilvl: 18, min: 4, max: 8 }, { ilvl: 27, min: 7, max: 13 }] },
|
||||
{ id: 'vit', pre: { en: 'Stout', vi: 'Vững' }, suf: { en: 'of Vitality', vi: 'của Sinh Lực' }, slots: 'any', stat: 'vit',
|
||||
tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 9, min: 2, max: 5 }, { ilvl: 18, min: 4, max: 8 }, { ilvl: 27, min: 7, max: 13 }] },
|
||||
{ id: 'ene', pre: { en: 'Arcane', vi: 'Huyền Bí' }, suf: { en: 'of Energy', vi: 'của Năng Lượng' }, slots: 'any', stat: 'ene',
|
||||
tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 9, min: 2, max: 5 }, { ilvl: 18, min: 4, max: 8 }, { ilvl: 27, min: 7, max: 13 }] },
|
||||
{ id: 'dmgpct', pre: { en: 'Brutal', vi: 'Man Dại' }, suf: null, slots: ['weapon', 'amulet', 'ring'], stat: 'dmgPct',
|
||||
tiers: [{ ilvl: 1, min: 6, max: 12 }, { ilvl: 12, min: 10, max: 20 }, { ilvl: 24, min: 18, max: 32 }] },
|
||||
{ id: 'flatdmg', pre: { en: 'Cruel', vi: 'Tàn Nhẫn' }, suf: { en: 'of Wounds', vi: 'của Vết Thương' }, slots: ['weapon', 'ring', 'amulet'], stat: '_flatDmg',
|
||||
tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 12, min: 3, max: 7 }, { ilvl: 24, min: 6, max: 13 }] },
|
||||
{ id: 'crit', pre: { en: 'Keen', vi: 'Sắc Bén' }, suf: { en: 'of Precision', vi: 'của Chuẩn Xác' }, slots: 'any', stat: 'critChance',
|
||||
tiers: [{ ilvl: 1, min: 2, max: 4 }, { ilvl: 12, min: 3, max: 6 }, { ilvl: 24, min: 5, max: 9 }] },
|
||||
{ id: 'critdmg', pre: { en: 'Savage', vi: 'Hung Tợn' }, suf: null, slots: ['weapon', 'hands', 'ring', 'amulet'], stat: 'critDmgPct',
|
||||
tiers: [{ ilvl: 4, min: 10, max: 22 }, { ilvl: 16, min: 18, max: 36 }, { ilvl: 26, min: 30, max: 55 }] },
|
||||
{ id: 'aspd', pre: { en: 'Swift', vi: 'Nhanh Nhẹn' }, suf: null, slots: ['weapon', 'hands', 'ring'], stat: 'attackSpeedPct',
|
||||
tiers: [{ ilvl: 1, min: 4, max: 8 }, { ilvl: 14, min: 7, max: 13 }, { ilvl: 25, min: 11, max: 19 }] },
|
||||
{ id: 'mspd', pre: { en: 'Fleet', vi: 'Thanh Nhẹ' }, suf: null, slots: ['feet', 'ring', 'amulet'], stat: 'moveSpeedPct',
|
||||
tiers: [{ ilvl: 1, min: 4, max: 8 }, { ilvl: 14, min: 7, max: 12 }] },
|
||||
{ id: 'armor', pre: { en: 'Stalwart', vi: 'Kiên Cố' }, suf: { en: 'of Warding', vi: 'của Che Chở' }, slots: ['head', 'chest', 'hands', 'feet', 'offhand'], stat: 'armor',
|
||||
tiers: [{ ilvl: 1, min: 3, max: 7 }, { ilvl: 12, min: 8, max: 16 }, { ilvl: 24, min: 16, max: 32 }] },
|
||||
{ id: 'hp', pre: { en: 'Robust', vi: 'Trâu Bò' }, suf: { en: 'of Life', vi: 'của Máu' }, slots: 'any', stat: 'hpFlat',
|
||||
tiers: [{ ilvl: 1, min: 6, max: 12 }, { ilvl: 10, min: 12, max: 24 }, { ilvl: 20, min: 24, max: 46 }, { ilvl: 29, min: 42, max: 80 }] },
|
||||
{ id: 'mana', pre: { en: 'Clear', vi: 'Trong Trời' }, suf: { en: 'of Mana', vi: 'của Mana' }, slots: 'any', stat: 'manaFlat',
|
||||
tiers: [{ ilvl: 1, min: 4, max: 9 }, { ilvl: 12, min: 9, max: 18 }, { ilvl: 24, min: 16, max: 32 }] },
|
||||
{ id: 'hpreg', pre: { en: 'Renewing', vi: 'Hồi Phục' }, suf: { en: 'of Regeneration', vi: 'của Tái Tạo' }, slots: ['chest', 'amulet', 'ring'], stat: 'hpRegen',
|
||||
tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 15, min: 3, max: 7 }] },
|
||||
{ id: 'mpreg', pre: { en: 'Flowing', vi: 'Chảy Chảy' }, suf: { en: 'of the Well', vi: 'của Giếng Mana' }, slots: ['head', 'hands', 'amulet', 'ring', 'offhand'], stat: 'manaRegen',
|
||||
tiers: [{ ilvl: 1, min: .4, max: 1 }, { ilvl: 15, min: 1, max: 2.2 }] },
|
||||
{ id: 'lok', pre: null, suf: { en: 'of Feasting', vi: 'của Thịnh Soạn' }, slots: ['weapon', 'ring', 'amulet'], stat: 'lifeOnKill',
|
||||
tiers: [{ ilvl: 1, min: 1, max: 3 }, { ilvl: 14, min: 3, max: 7 }, { ilvl: 26, min: 6, max: 13 }] },
|
||||
{ id: 'mf', pre: { en: 'Lucky', vi: 'May Mắn' }, suf: { en: 'of Fortune', vi: 'của Vận May' }, slots: 'any', stat: 'magicFind',
|
||||
tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 15, min: 10, max: 22 }, { ilvl: 26, min: 18, max: 35 }] },
|
||||
{ id: 'gf', pre: { en: 'Golden', vi: 'Vàng Óng' }, suf: { en: 'of Greed', vi: 'của Tham Lam' }, slots: 'any', stat: 'goldFind',
|
||||
tiers: [{ ilvl: 1, min: 8, max: 18 }, { ilvl: 15, min: 15, max: 32 }] },
|
||||
{ id: 'fire', pre: { en: 'Flaming', vi: 'Rực Lửa' }, suf: { en: 'of Embers', vi: 'của Than Hồng' }, slots: ['weapon', 'ring', 'amulet'], stat: 'fireDmg',
|
||||
tiers: [{ ilvl: 1, min: 2, max: 5 }, { ilvl: 12, min: 5, max: 11 }, { ilvl: 24, min: 10, max: 21 }] },
|
||||
{ id: 'cold', pre: { en: 'Frozen', vi: 'Băng Giá' }, suf: { en: 'of Frost', vi: 'của Sương Muối' }, slots: ['weapon', 'ring', 'amulet'], stat: 'coldDmg',
|
||||
tiers: [{ ilvl: 1, min: 2, max: 4 }, { ilvl: 12, min: 4, max: 9 }, { ilvl: 24, min: 8, max: 17 }] },
|
||||
{ id: 'lit', pre: { en: 'Crackling', vi: 'Rét Rít' }, suf: { en: 'of Storms', vi: 'của Bão Tố' }, slots: ['weapon', 'ring', 'amulet'], stat: 'litDmg',
|
||||
tiers: [{ ilvl: 1, min: 1, max: 6 }, { ilvl: 12, min: 4, max: 13 }, { ilvl: 24, min: 9, max: 24 }] },
|
||||
{ id: 'pois', pre: { en: 'Venomous', vi: 'Nhiễm Độc' }, suf: { en: 'of the Serpent', vi: 'của Con Rắn' }, slots: ['weapon', 'ring', 'amulet'], stat: 'poisDmg',
|
||||
tiers: [{ ilvl: 1, min: 3, max: 7 }, { ilvl: 12, min: 6, max: 14 }, { ilvl: 24, min: 12, max: 26 }] },
|
||||
{ id: 'resfire', pre: null, suf: { en: 'of Flame Ward', vi: 'chống Hỏa' }, slots: 'any', stat: 'resFire',
|
||||
tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 16, min: 10, max: 22 }, { ilvl: 27, min: 16, max: 34 }] },
|
||||
{ id: 'rescold', pre: null, suf: { en: 'of Ice Ward', vi: 'chống Băng' }, slots: 'any', stat: 'resCold',
|
||||
tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 16, min: 10, max: 22 }, { ilvl: 27, min: 16, max: 34 }] },
|
||||
{ id: 'reslit', pre: null, suf: { en: 'of Storm Ward', vi: 'chống Lôi' }, slots: 'any', stat: 'resLit',
|
||||
tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 16, min: 10, max: 22 }, { ilvl: 27, min: 16, max: 34 }] },
|
||||
{ id: 'respois', pre: null, suf: { en: 'of Venom Ward', vi: 'chống Độc' }, slots: 'any', stat: 'resPois',
|
||||
tiers: [{ ilvl: 1, min: 5, max: 12 }, { ilvl: 16, min: 10, max: 22 }, { ilvl: 27, min: 16, max: 34 }] },
|
||||
{ id: 'cdr', pre: { en: 'Temporal', vi: 'Thời Không' }, suf: null, slots: ['head', 'hands', 'amulet', 'ring'], stat: 'cdr',
|
||||
tiers: [{ ilvl: 6, min: 4, max: 8 }, { ilvl: 20, min: 7, max: 13 }] },
|
||||
{ id: 'thorns', pre: { en: 'Spiked', vi: 'Gai Góc' }, suf: { en: 'of Retribution', vi: 'của Trừng Phạt' }, slots: ['head', 'chest', 'offhand', 'amulet'], stat: 'thorns',
|
||||
tiers: [{ ilvl: 1, min: 2, max: 5 }, { ilvl: 14, min: 5, max: 12 }, { ilvl: 25, min: 10, max: 22 }] },
|
||||
];
|
||||
|
||||
/* ---------------- legendaries ---------------- */
|
||||
const LEGENDARIES = [
|
||||
{ id: 'butchers_cleaver', base: 'battleaxe', name: { en: "Butcher's Cleaver", vi: 'Cán Dao Của Đồ Tể' },
|
||||
special: 'cleaveEcho', stats: { dmgPct: 20, str: 8, hpFlat: 25 },
|
||||
flavor: { en: 'Fresh meat!', vi: 'Thịt tươi đây!' } },
|
||||
{ id: 'windstring', base: 'longbow', name: { en: 'Windstring', vi: 'Dây Gió' },
|
||||
special: 'doubleShot', stats: { attackSpeedPct: 14, dex: 10 },
|
||||
flavor: { en: 'Twice the arrows, twice the silence.', vi: 'Gấp đôi mũi tên, gấp đôi im lặng.' } },
|
||||
{ id: 'emberheart', base: 'runestaff', name: { en: 'Emberheart', vi: 'Tim Than Hồng' },
|
||||
special: 'fireNovaOnKill', stats: { fireDmg: 18, ene: 12 },
|
||||
flavor: { en: 'It still beats.', vi: 'Nó vẫn còn đập.' } },
|
||||
{ id: 'frostweave', base: 'platearmor', name: { en: 'Frostweave', vi: 'Vải Băng' },
|
||||
special: 'chillAttacker', stats: { armor: 20, resCold: 25 },
|
||||
flavor: { en: 'Cold embraces those who dare.', vi: 'Băng giá ôm lấy kẻ dám lại gần.' } },
|
||||
{ id: 'abyss_ring', base: 'ring', name: { en: 'Ring of the Abyss', vi: 'Nhẫn Vực Sâu' },
|
||||
special: 'allSkills', stats: { manaFlat: 20, ene: 8 },
|
||||
flavor: { en: 'Stare long enough and it stares back.', vi: 'Nhìn đủ lâu, nó sẽ nhìn lại.' } },
|
||||
{ id: 'gravebind', base: 'amulet', name: { en: 'Gravebind', vi: 'Dây Mộ' },
|
||||
special: 'execHeal', stats: { lifeOnKill: 8, vit: 10 },
|
||||
flavor: { en: 'Every death feeds the root.', vi: 'Mỗi cái chết nuôi cái rễ.' } },
|
||||
{ id: 'stormrunners', base: 'warboots', name: { en: 'Stormrunners', vi: 'Dạo Bước Bão' },
|
||||
special: 'stormDash', stats: { moveSpeedPct: 16, cdr: 10 },
|
||||
flavor: { en: 'The ground remembers nothing.', vi: 'Mặt đất chẳng nhớ gì cả.' } },
|
||||
{ id: 'aegis_ash', base: 'greathelm', name: { en: 'Aegis of Ash', vi: 'Hộ Khiên Tro Tàn' },
|
||||
special: null, stats: { thorns: 18, resFire: 30, armor: 22, str: 8 },
|
||||
flavor: { en: 'What burned once cannot burn again.', vi: 'Đã cháy một lần thì không cháy lần nữa.' } },
|
||||
{ id: 'bloodletter', base: 'dagger', name: { en: 'Bloodletter', vi: 'Xả Máu' },
|
||||
special: 'lifesteal', stats: { critChance: 8, aspd: 10 },
|
||||
flavor: { en: 'It drinks before you feast.', vi: 'Nó uống trước khi bạn ăn mừng.' } },
|
||||
{ id: 'crown_torment', base: 'crownplate', name: { en: 'Crown of Torment', vi: 'Vương Miện Đày Ái' },
|
||||
special: 'eliteSlayer', stats: { dmgPct: 12, hpFlat: 30 },
|
||||
flavor: { en: 'Kneel, champions. Your king is here.', vi: 'Quỳ xuống, các tinh anh. Vua của chúng mày tới rồi.' } },
|
||||
{ id: 'vortex_gauntlets', base: 'gauntlets', name: { en: 'Vortex Gauntlets', vi: 'Găng Xoáy Ốc' },
|
||||
special: 'chainOnHit', stats: { litDmg: 12, attackSpeedPct: 8 },
|
||||
flavor: { en: 'One strike becomes a storm.', vi: 'Một đòn hóa thành cơn bão.' } },
|
||||
{ id: 'doomplate_leg', base: 'doomplate', name: { en: 'Doomplate of Ruin', vi: 'Giáp Tấm Hủy Diệt' },
|
||||
special: 'corpseBoom', stats: { armor: 34, vit: 12, thorns: 12 },
|
||||
flavor: { en: 'They die standing closer than they think.', vi: 'Chúng chết ở khoảng cách gần hơn chúng tưởng.' } },
|
||||
{ id: 'serpent_coil', base: 'ring', name: { en: 'Serpent Coil', vi: 'Vòng Xuyên Rắn' },
|
||||
special: 'venomStrike', stats: { poisDmg: 14, dex: 8 },
|
||||
flavor: { en: 'The bite you never saw.', vi: 'Cú cắn bạn không bao giờ thấy.' } },
|
||||
{ id: 'voidwalker_treads', base: 'greaves', name: { en: 'Voidwalker Treads', vi: 'Dạo Bước Hư Không' },
|
||||
special: 'voidstep', stats: { moveSpeedPct: 10, cdr: 12 },
|
||||
flavor: { en: 'Distance is a rumor.', vi: 'Khoảng cách chỉ là tin đồn.' } },
|
||||
];
|
||||
|
||||
/* ---------------- name generation ---------------- */
|
||||
|
||||
const RARE_PRE = ['Doom', 'Grim', 'Blood', 'Storm', 'Night', 'Ash', 'Bone', 'Vile', 'Wraith', 'Storm', 'Sun', 'Shadow', 'Hell', 'Raven'];
|
||||
const RARE_SUF = ['bane', 'song', 'weaver', 'fang', 'howl', 'brand', 'shroud', 'mark', 'veil', 'claw', 'render', 'wail'];
|
||||
const RARE_OF = {
|
||||
en: ['the Lost', 'the Fallen', 'the Deep', 'the Hunt', 'the Void', 'Sorrow', 'Ruin', 'the Pact', 'the Pyre', 'the Plague'],
|
||||
vi: ['Người Lạc', 'Kẻ Sa Đọa', 'Vực Sâu', 'Cuộc Săn', 'Hư Không', 'Nỗi Đau', 'Sự Hủy Diệt', 'Khế Ước', 'Lửa Thiêu', 'Cơn Dịch'],
|
||||
};
|
||||
|
||||
function lang() { return (D2.i18n && D2.i18n.getLang()) || 'en'; }
|
||||
function pick(arr) { return arr[(Math.random() * arr.length) | 0]; }
|
||||
function ri(a, b) { return a + Math.floor(Math.random() * (b - a + 1)); }
|
||||
|
||||
function baseName(base) {
|
||||
const n = base.name[lang()];
|
||||
return n || base.name.en;
|
||||
}
|
||||
|
||||
function makeName(base, rarity, rolled) {
|
||||
if (rarity === 'common') return baseName(base);
|
||||
if (rarity === 'magic') {
|
||||
const withPre = rolled.find(a => AFFIX_MAP[a.id].pre);
|
||||
const withSuf = rolled.find(a => AFFIX_MAP[a.id].suf);
|
||||
let n = baseName(base);
|
||||
if (withPre && (!withSuf || Math.random() < 0.5)) n = AFFIX_MAP[withPre.id].pre[lang()] + ' ' + n;
|
||||
else if (withSuf) n += ' ' + AFFIX_MAP[withSuf.id].suf[lang()];
|
||||
else if (withPre) n = AFFIX_MAP[withPre.id].pre[lang()] + ' ' + n;
|
||||
return n;
|
||||
}
|
||||
if (rarity === 'rare') {
|
||||
let n = pick(RARE_PRE) + pick(RARE_SUF);
|
||||
if (Math.random() < 0.4) n += ' ' + pick(RARE_OF[lang()] || RARE_OF.en);
|
||||
return n;
|
||||
}
|
||||
return baseName(base);
|
||||
}
|
||||
|
||||
const AFFIX_MAP = {};
|
||||
for (const a of AFFIXES) AFFIX_MAP[a.id] = a;
|
||||
|
||||
/* ---------------- rolling ---------------- */
|
||||
|
||||
function tierValue(affix, ilvl) {
|
||||
let best = affix.tiers[0];
|
||||
for (const t of affix.tiers) if (t.ilvl <= ilvl) best = t;
|
||||
const v = ri(best.min * 10, best.max * 10) / 10;
|
||||
return Math.round(v * 10) / 10;
|
||||
}
|
||||
|
||||
function eligibleAffixes(slot) {
|
||||
return AFFIXES.filter(a => a.slots === 'any' || (Array.isArray(a.slots) && a.slots.includes(slot)));
|
||||
}
|
||||
|
||||
function scaleBase(base, ilvl, rarityMult) {
|
||||
const grow = 1 + 0.105 * Math.max(0, ilvl - base.minIlvl);
|
||||
const out = {};
|
||||
if (base.dmg) {
|
||||
out.dmgMin = Math.max(1, Math.round(base.dmg[0] * grow * rarityMult));
|
||||
out.dmgMax = Math.max(out.dmgMin + 1, Math.round(base.dmg[1] * grow * rarityMult));
|
||||
out.aps = base.aps;
|
||||
}
|
||||
if (base.armor) out.armor = Math.max(1, Math.round(base.armor * grow * rarityMult));
|
||||
if (base.implicit) out.stats = Object.assign({}, base.implicit);
|
||||
else out.stats = {};
|
||||
return out;
|
||||
}
|
||||
|
||||
/** main entry: roll a randomized item */
|
||||
function rollItem(ilvl, opts = {}) {
|
||||
ilvl = Math.max(1, Math.round(ilvl));
|
||||
const slot = opts.slot || pick(SLOTS);
|
||||
|
||||
/* pick base among eligible, favoring higher tiers */
|
||||
const cands = BASES.filter(b => b.slot === slot && b.minIlvl <= ilvl);
|
||||
if (!cands.length) return null;
|
||||
const weighted = cands.map(b => ({ v: b, w: Math.pow(b.tier, 1.6) }));
|
||||
const base = weightedPick(weighted);
|
||||
|
||||
/* rarity */
|
||||
let rarity = opts.rarity;
|
||||
if (!rarity) {
|
||||
const mf = opts.magicFind || 0;
|
||||
const boost = D2.BAL.magicFindRarityBoost(mf);
|
||||
const wts = [
|
||||
{ v: 'legendary', w: RARITIES.legendary.weight * boost * (opts.boss ? 7 : 1) * (opts.elite ? 2.6 : 1) },
|
||||
{ v: 'rare', w: RARITIES.rare.weight * boost * (opts.elite ? 1.7 : 1) },
|
||||
{ v: 'magic', w: RARITIES.magic.weight * (opts.elite ? 1.3 : 1) },
|
||||
{ v: 'common', w: RARITIES.common.weight },
|
||||
];
|
||||
rarity = weightedPick(wts);
|
||||
}
|
||||
|
||||
const rar = RARITIES[rarity];
|
||||
const item = {
|
||||
uid: D2.util.uid(),
|
||||
slot, baseId: base.id, kind: base.kind,
|
||||
rarity, ilvl,
|
||||
reqLevel: Math.max(1, ilvl - 1),
|
||||
};
|
||||
Object.assign(item, scaleBase(base, ilvl, rar.mult));
|
||||
|
||||
/* legendary: fixed identity */
|
||||
if (rarity === 'legendary') {
|
||||
const pool = opts.legendaryPool || LEGENDARIES;
|
||||
let leg = pick(pool);
|
||||
if (opts.slot) {
|
||||
const matching = pool.filter(l => {
|
||||
const lb = BASES.find(b => b.id === l.base);
|
||||
return lb && lb.slot === slot;
|
||||
});
|
||||
if (matching.length) leg = pick(matching);
|
||||
}
|
||||
item.baseId = leg.base;
|
||||
const lb = BASES.find(b => b.id === leg.base);
|
||||
Object.assign(item, scaleBase(lb, ilvl, RARITIES.legendary.mult));
|
||||
item.legendaryId = leg.id;
|
||||
item.special = leg.special;
|
||||
item.flavor = leg.flavor ? (leg.flavor[lang()] || leg.flavor.en) : null;
|
||||
item.name = leg.name[lang()] || leg.name.en;
|
||||
for (const [k, vBase] of Object.entries(leg.stats)) {
|
||||
const grow = 1 + 0.055 * Math.max(0, ilvl - lb.minIlvl);
|
||||
item.stats[k] = (item.stats[k] || 0) + Math.round(vBase * grow);
|
||||
}
|
||||
if (Math.random() < 0.65) {
|
||||
const extra = rollAffixes(eligibleAffixes(slot), 1, ilvl, item.stats);
|
||||
for (const a of extra) item.stats[a.stat === '_flatDmg' ? 'flatDmg' : a.stat] =
|
||||
(item.stats[a.stat === '_flatDmg' ? 'flatDmg' : a.stat] || 0) + a.val;
|
||||
}
|
||||
item.value = price(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/* affix count */
|
||||
const count = ri(rar.affixes[0], rar.affixes[1]);
|
||||
const rolled = rollAffixes(eligibleAffixes(slot), count, ilvl, item.stats);
|
||||
for (const a of rolled) {
|
||||
if (a.stat === '_flatDmg') {
|
||||
// flat damage adds both sides
|
||||
const f = Math.round(a.val);
|
||||
item.flatDmg = f;
|
||||
} else {
|
||||
item.stats[a.stat] = (item.stats[a.stat] || 0) + a.val;
|
||||
}
|
||||
}
|
||||
item.name = makeName(base, rarity, rolled);
|
||||
item.value = price(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
function rollAffixes(pool, count, ilvl, existingStats) {
|
||||
const out = [];
|
||||
const usedIds = new Set();
|
||||
const avail = [...pool];
|
||||
while (out.length < count && avail.length) {
|
||||
const a = weightedPick(avail.map(x => ({ v: x, w: 1 })));
|
||||
avail.splice(avail.indexOf(a), 1);
|
||||
if (usedIds.has(a.id)) continue;
|
||||
usedIds.add(a.id);
|
||||
out.push({ id: a.id, stat: a.stat, val: tierValue(a, ilvl) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function weightedPick(entries) {
|
||||
let total = 0;
|
||||
for (const e of entries) total += e.w;
|
||||
let r = Math.random() * total;
|
||||
for (const e of entries) { r -= e.w; if (r <= 0) return e.v; }
|
||||
return entries[entries.length - 1].v;
|
||||
}
|
||||
|
||||
function price(item) {
|
||||
let core = 0;
|
||||
if (item.dmgMax) core = (item.dmgMin + item.dmgMax) / 2 * item.aps * 6;
|
||||
else if (item.armor) core = item.armor * 4.2;
|
||||
else core = 10;
|
||||
let statScore = 0;
|
||||
for (const v of Object.values(item.stats || {})) statScore += typeof v === 'number' ? Math.abs(v) : 0;
|
||||
if (item.flatDmg) statScore += item.flatDmg * 2;
|
||||
return Math.max(5, Math.round((core + statScore * 2.2) * (1 + item.ilvl * 0.06) *
|
||||
({ common: 1, magic: 1.6, rare: 2.6, legendary: 6 }[item.rarity])));
|
||||
}
|
||||
|
||||
/* starter gear per class */
|
||||
function startingWeapon(cls) {
|
||||
const baseId = cls === 'crusader' ? 'shortsword' : cls === 'ranger' ? 'shortbow' : 'gnarledstaff';
|
||||
const base = BASES.find(b => b.id === baseId);
|
||||
return {
|
||||
uid: D2.util.uid(), slot: 'weapon', baseId, kind: base.kind, rarity: 'common',
|
||||
ilvl: 1, reqLevel: 1, name: baseName(base),
|
||||
dmgMin: base.dmg[0], dmgMax: base.dmg[1], aps: base.aps,
|
||||
stats: base.implicit ? { ...base.implicit } : {},
|
||||
value: 10,
|
||||
};
|
||||
}
|
||||
function startingArmor() {
|
||||
const base = BASES.find(b => b.id === 'leatherarmor');
|
||||
return {
|
||||
uid: D2.util.uid(), slot: 'chest', baseId: 'leatherarmor', kind: 'armor', rarity: 'common',
|
||||
ilvl: 1, reqLevel: 1, name: baseName(base),
|
||||
armor: base.armor, stats: {}, value: 8,
|
||||
};
|
||||
}
|
||||
function startingPotion() {
|
||||
const base = BASES.find(b => b.id === 'cap');
|
||||
void base;
|
||||
return null;
|
||||
}
|
||||
|
||||
/* vendor stock */
|
||||
function vendorStock(mlvl, count) {
|
||||
const out = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const it = rollItem(Math.max(1, mlvl + ri(-1, 2)), { magicFind: 0 });
|
||||
if (it) out.push(it);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
D2.Items = {
|
||||
RARITIES, SLOTS, BASES, AFFIXES, LEGENDARIES, AFFIX_MAP,
|
||||
rollItem, price, baseName, startingWeapon, startingArmor, vendorStock,
|
||||
baseById: id => BASES.find(b => b.id === id),
|
||||
legendaryById: id => LEGENDARIES.find(l => l.id === id),
|
||||
isWeapon: it => !!it.dmgMax,
|
||||
};
|
||||
})(window.D2);
|
||||
@@ -0,0 +1,258 @@
|
||||
/* ============================================================
|
||||
* Diablo2D — monsters.js : bestiary, elite affixes, bosses
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
window.D2 = window.D2 || {};
|
||||
(function (D2) {
|
||||
|
||||
/* ai archetypes: melee | ranged | caster | charger | swarm | flyer | summoner */
|
||||
const SPECIES = [
|
||||
/* ---- ACT I : The Ravaged Cathedral ---- */
|
||||
{ id: 'skeleton', name: { en: 'Skeleton', vi: 'Xương Khô' }, acts: [0], ai: 'melee',
|
||||
hpMult: 0.9, dmgMult: 1.0, speed: 2.5, radius: 0.32, range: 0.95, cd: 1.15, xpMult: 1.0,
|
||||
palette: { body: '#cfc7ae', accent: '#8a8272' }, shape: 'skeleton' },
|
||||
{ id: 'skel_archer', name: { en: 'Skeleton Archer', vi: 'Cung Thủ Xương' }, acts: [0], ai: 'ranged',
|
||||
hpMult: 0.75, dmgMult: 0.9, speed: 2.2, radius: 0.3, range: 6.5, cd: 1.7, xpMult: 1.15,
|
||||
proj: { kind: 'arrow', speed: 8.5, color: '#d8cba8' },
|
||||
palette: { body: '#cfc7ae', accent: '#6e5b34' }, shape: 'skeleton_bow' },
|
||||
{ id: 'fallen', name: { en: 'Fallen One', vi: 'Kẻ Sa Đọa' }, acts: [0], ai: 'swarm',
|
||||
hpMult: 0.55, dmgMult: 0.75, speed: 3.6, radius: 0.26, range: 0.85, cd: 0.85, xpMult: 0.85,
|
||||
palette: { body: '#b0522f', accent: '#e8c26a' }, shape: 'imp' },
|
||||
{ id: 'zombie', name: { en: 'Zombie', vi: 'Thi Sống' }, acts: [0, 1], ai: 'melee',
|
||||
hpMult: 1.6, dmgMult: 1.1, speed: 1.35, radius: 0.36, range: 1.0, cd: 1.5, xpMult: 1.2,
|
||||
palette: { body: '#7a8a62', accent: '#4c5840' }, shape: 'zombie' },
|
||||
{ id: 'bat', name: { en: 'Cave Bat', vi: 'Dơi Hang' }, acts: [0, 2], ai: 'flyer',
|
||||
hpMult: 0.45, dmgMult: 0.7, speed: 4.3, radius: 0.24, range: 0.8, cd: 0.8, xpMult: 0.8,
|
||||
palette: { body: '#4a3a52', accent: '#8a6aa2' }, shape: 'bat' },
|
||||
{ id: 'cultist', name: { en: 'Dark Cultist', vi: 'Giáo Đồ Bóng Tối' }, acts: [0], ai: 'caster',
|
||||
hpMult: 0.8, dmgMult: 1.15, speed: 2.1, radius: 0.32, range: 6.0, cd: 2.1, xpMult: 1.4,
|
||||
proj: { kind: 'shadowbolt', speed: 6.5, color: '#a06ad8' },
|
||||
special: 'blinkAway',
|
||||
palette: { body: '#5a2a4a', accent: '#c23a3a' }, shape: 'cultist' },
|
||||
|
||||
/* ---- ACT II : The Weeping Catacombs ---- */
|
||||
{ id: 'bone_warrior', name: { en: 'Bone Warrior', vi: 'Chiến Binh Xương' }, acts: [1], ai: 'melee',
|
||||
hpMult: 1.25, dmgMult: 1.2, speed: 2.8, radius: 0.34, range: 1.0, cd: 1.05, xpMult: 1.25,
|
||||
palette: { body: '#d8d0bc', accent: '#7a3040' }, shape: 'skeleton' },
|
||||
{ id: 'tomb_spider', name: { en: 'Tomb Spider', vi: 'Nhện Mộ' }, acts: [1, 2], ai: 'ranged',
|
||||
hpMult: 0.9, dmgMult: 0.95, speed: 2.6, radius: 0.33, range: 5.0, cd: 1.6, xpMult: 1.3,
|
||||
proj: { kind: 'spit', speed: 7.0, color: '#8adf5a', elem: 'pois', slow: 0.35, slowDur: 1.6 },
|
||||
palette: { body: '#3a4a2e', accent: '#8adf5a' }, shape: 'spider' },
|
||||
{ id: 'wraith', name: { en: 'Wraith', vi: 'Bóng Ma' }, acts: [1], ai: 'flyer',
|
||||
hpMult: 0.85, dmgMult: 1.1, speed: 3.4, radius: 0.3, range: 0.9, cd: 1.1, xpMult: 1.5,
|
||||
elem: 'cold', chillOnHit: true,
|
||||
palette: { body: '#6a86b8', accent: '#cfe4ff' }, shape: 'wraith' },
|
||||
{ id: 'cursed_priest', name: { en: 'Cursed Priest', vi: 'Tu Sĩ Nguyền Rủa' }, acts: [1], ai: 'summoner',
|
||||
hpMult: 1.0, dmgMult: 0.8, speed: 1.9, radius: 0.33, range: 6.5, cd: 3.4, xpMult: 1.8,
|
||||
summon: { species: 'skeleton', count: 2, maxAlive: 5 },
|
||||
proj: { kind: 'shadowbolt', speed: 6.0, color: '#b08ae8' },
|
||||
special: 'blinkAway',
|
||||
palette: { body: '#443a5e', accent: '#e8e0b0' }, shape: 'cultist' },
|
||||
{ id: 'hellhound', name: { en: 'Hellhound', vi: 'Hầu Địa Ngục' }, acts: [1, 3], ai: 'charger',
|
||||
hpMult: 1.1, dmgMult: 1.3, speed: 3.0, radius: 0.34, range: 1.0, cd: 1.2, xpMult: 1.45,
|
||||
chargeRange: 6, elem: 'fire',
|
||||
palette: { body: '#702a20', accent: '#ff9a3a' }, shape: 'hound' },
|
||||
|
||||
/* ---- ACT III : The Fungal Depths ---- */
|
||||
{ id: 'sporeling', name: { en: 'Sporeling', vi: 'Bào Tử Linh' }, acts: [2], ai: 'swarm',
|
||||
hpMult: 0.6, dmgMult: 0.8, speed: 3.2, radius: 0.27, range: 0.85, cd: 0.9, xpMult: 0.9,
|
||||
corpsePoison: true,
|
||||
palette: { body: '#5a8a3a', accent: '#c8e88a' }, shape: 'shroom' },
|
||||
{ id: 'cave_troll', name: { en: 'Cave Troll', vi: 'Troll Hang' }, acts: [2], ai: 'charger',
|
||||
hpMult: 2.2, dmgMult: 1.5, speed: 1.9, radius: 0.46, range: 1.2, cd: 1.6, xpMult: 2.2,
|
||||
chargeRange: 7,
|
||||
palette: { body: '#5e6a56', accent: '#a8b89a' }, shape: 'troll' },
|
||||
{ id: 'bog_witch', name: { en: 'Bog Witch', vi: 'Phù Thủy Đầm Lầy' }, acts: [2], ai: 'caster',
|
||||
hpMult: 0.95, dmgMult: 1.2, speed: 2.0, radius: 0.32, range: 6.0, cd: 2.0, xpMult: 1.7,
|
||||
proj: { kind: 'venomorb', speed: 6.0, color: '#9adf3a', elem: 'pois', pool: true },
|
||||
special: 'blinkAway',
|
||||
palette: { body: '#3e5a2e', accent: '#b8e86a' }, shape: 'cultist' },
|
||||
{ id: 'acid_spitter', name: { en: 'Acid Spitter', vi: 'Phun Axit' }, acts: [2], ai: 'ranged',
|
||||
hpMult: 1.0, dmgMult: 1.05, speed: 2.3, radius: 0.35, range: 5.5, cd: 1.5, xpMult: 1.35,
|
||||
proj: { kind: 'acid', speed: 7.5, color: '#d8f04a', elem: 'pois' },
|
||||
palette: { body: '#6a7a2a', accent: '#e8f56a' }, shape: 'zombie' },
|
||||
{ id: 'mushroom_cap', name: { en: 'Spore Cap', vi: 'Nấm Bào Tử' }, acts: [2], ai: 'melee',
|
||||
hpMult: 1.4, dmgMult: 0.9, speed: 1.6, radius: 0.38, range: 1.0, cd: 1.4, xpMult: 1.4,
|
||||
explodeOnDeath: { elem: 'pois', radius: 2.2, dmgMult: 1.4 },
|
||||
palette: { body: '#8a5a3a', accent: '#c88a5a' }, shape: 'shroom_big' },
|
||||
|
||||
/* ---- ACT IV : Hell's Threshold ---- */
|
||||
{ id: 'demon_imp', name: { en: 'Demon Imp', vi: 'Yêu Tinh' }, acts: [3], ai: 'swarm',
|
||||
hpMult: 0.7, dmgMult: 0.95, speed: 3.8, radius: 0.27, range: 0.9, cd: 0.8, xpMult: 1.1,
|
||||
elem: 'fire',
|
||||
palette: { body: '#a83a2a', accent: '#ffb03a' }, shape: 'imp' },
|
||||
{ id: 'hell_knight', name: { en: 'Hell Knight', vi: 'Kỵ Sĩ Địa Ngục' }, acts: [3], ai: 'melee',
|
||||
hpMult: 1.9, dmgMult: 1.35, speed: 2.5, radius: 0.38, range: 1.05, cd: 1.1, xpMult: 1.9,
|
||||
palette: { body: '#3a3038', accent: '#c22a2a' }, shape: 'knight' },
|
||||
{ id: 'flame_archer', name: { en: 'Flame Archer', vi: 'Cung Thủ Lửa' }, acts: [3], ai: 'ranged',
|
||||
hpMult: 0.9, dmgMult: 1.2, speed: 2.6, radius: 0.31, range: 7.0, cd: 1.5, xpMult: 1.5,
|
||||
proj: { kind: 'firearrow', speed: 9.0, color: '#ff7a3a', elem: 'fire' },
|
||||
palette: { body: '#5e2a20', accent: '#ff9a3a' }, shape: 'skeleton_bow' },
|
||||
{ id: 'succubus', name: { en: 'Succubus', vi: 'Ác Nữ' }, acts: [3], ai: 'caster',
|
||||
hpMult: 1.0, dmgMult: 1.35, speed: 2.4, radius: 0.32, range: 6.5, cd: 1.9, xpMult: 1.9,
|
||||
proj: { kind: 'fireball', speed: 7.0, color: '#ff5a2a', elem: 'fire', aoe: 1.2 },
|
||||
special: 'blinkAway',
|
||||
palette: { body: '#7a2a4e', accent: '#ffb0c8' }, shape: 'cultist' },
|
||||
{ id: 'pit_lord', name: { en: 'Pit Lord', vi: 'Chúa Vực' }, acts: [3], ai: 'charger',
|
||||
hpMult: 2.6, dmgMult: 1.65, speed: 1.8, radius: 0.5, range: 1.25, cd: 1.5, xpMult: 2.6,
|
||||
chargeRange: 8, elem: 'fire',
|
||||
palette: { body: '#5a1e18', accent: '#ff6a2a' }, shape: 'troll' },
|
||||
{ id: 'void_walker', name: { en: 'Void Walker', vi: 'Bước Qua Hư Không' }, acts: [3], ai: 'melee',
|
||||
hpMult: 1.2, dmgMult: 1.4, speed: 2.9, radius: 0.33, range: 1.0, cd: 1.0, xpMult: 1.8,
|
||||
special: 'blinkTo',
|
||||
palette: { body: '#2a2440', accent: '#8a6aff' }, shape: 'wraith' },
|
||||
];
|
||||
|
||||
const SPECIES_MAP = {};
|
||||
for (const s of SPECIES) SPECIES_MAP[s.id] = s;
|
||||
|
||||
/* ---------------- elite (champion) affixes ---------------- */
|
||||
const ELITE_AFFIXES = [
|
||||
{ id: 'extrafast', name: { en: 'Extra Fast', vi: 'Siêu Nhanh' }, color: '#ffd24a',
|
||||
apply: m => { m.speed *= 1.4; } },
|
||||
{ id: 'extrastrong', name: { en: 'Extra Strong', vi: 'Siêu Mạnh' }, color: '#ff5a4a',
|
||||
apply: m => { m.dmg *= 1.42; } },
|
||||
{ id: 'tough', name: { en: 'Ironback', vi: 'Lưng Thép' }, color: '#9ab0c8',
|
||||
apply: m => { m.maxHp *= 1.6; m.hp = m.maxHp; } },
|
||||
{ id: 'fireench', name: { en: 'Fire Enchanted', vi: 'Phép Hỏa' }, color: '#ff8a3a',
|
||||
apply: m => { m.elem = 'fire'; m.deathNova = { elem: 'fire', radius: 2.4 }; } },
|
||||
{ id: 'coldench', name: { en: 'Cold Enchanted', vi: 'Phép Băng' }, color: '#6ab8ff',
|
||||
apply: m => { m.elem = 'cold'; m.chillOnHit = true; m.deathNova = { elem: 'cold', radius: 2.4, chill: true }; } },
|
||||
{ id: 'litench', name: { en: 'Lightning Enchanted', vi: 'Phép Lôi' }, color: '#ffe86a',
|
||||
apply: m => { m.chainOnHitChance = 0.35; } },
|
||||
{ id: 'vampiric', name: { en: 'Vampiric', vi: 'Hút Máu' }, color: '#c83a6a',
|
||||
apply: m => { m.lifestealPct = 0.12; } },
|
||||
{ id: 'regen', name: { en: 'Regenerator', vi: 'Tái Tạo' }, color: '#6ade8a',
|
||||
apply: m => { m.regenPct = 0.02; } },
|
||||
{ id: 'teleport', name: { en: 'Teleporter', vi: 'Dịch Chuyển' }, color: '#b08aff',
|
||||
apply: m => { m.special = 'blinkTo'; } },
|
||||
{ id: 'fanatic', name: { en: 'Fanatic', vi: 'Cuồng Tín' }, color: '#ffa04a',
|
||||
apply: m => { m.cdMult = 0.6; } },
|
||||
];
|
||||
|
||||
function rollEliteAffixes() {
|
||||
const pool = [...ELITE_AFFIXES];
|
||||
const out = [];
|
||||
const n = Math.random() < 0.42 ? 2 : 1;
|
||||
for (let i = 0; i < n && pool.length; i++) {
|
||||
out.push(pool.splice((Math.random() * pool.length) | 0, 1)[0]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------------- bosses ---------------- */
|
||||
const BOSSES = [
|
||||
{ id: 'butcher', name: { en: 'The Flesh Butcher', vi: 'Đồ Tể Xác Thịt' }, act: 0,
|
||||
base: 'zombie', size: 1.9, hpMult: 1.0, dmgMult: 1.0, speed: 2.3, radius: 0.62,
|
||||
palette: { body: '#8a4a3a', accent: '#d84a2a' }, shape: 'butcher',
|
||||
attacks: ['cleaveArc', 'chargeAt', 'summonFallen', 'frenzy'],
|
||||
intro: { en: 'Fresh meat!', vi: 'Thịt tươi đây!' } },
|
||||
{ id: 'boneking', name: { en: 'The Bone King', vi: 'Vua Xương Khô' }, act: 1,
|
||||
base: 'skeleton', size: 1.8, hpMult: 1.05, dmgMult: 1.0, speed: 2.2, radius: 0.58,
|
||||
palette: { body: '#e0d8c0', accent: '#5a8ac8' }, shape: 'boneking',
|
||||
attacks: ['boneSpearVolley', 'summonSkeletons', 'curseRing', 'blinkAway'],
|
||||
intro: { en: 'My legions are endless...', vi: 'Quân đội của ta là vô tận...' } },
|
||||
{ id: 'spiderqueen', name: { en: 'Aranea, Spider Queen', vi: 'Aranea, Nữ Hoàng Nhện' }, act: 2,
|
||||
base: 'tomb_spider', size: 2.0, hpMult: 1.1, dmgMult: 1.05, speed: 2.4, radius: 0.64,
|
||||
palette: { body: '#2e3a24', accent: '#a8e84a' }, shape: 'queenspider',
|
||||
attacks: ['poisonPools', 'webVolley', 'spawnSpiderlings', 'lunge'],
|
||||
intro: { en: 'You will make a fine broodmother.', vi: 'Bạn sẽ làm mẫu vật nuôi tốt đấy.' } },
|
||||
{ id: 'terrorlord', name: { en: "Malgor, Lord of Terror", vi: 'Malgor, Chúa Tể Kinh Hoàng' }, act: 3,
|
||||
base: 'pit_lord', size: 2.3, hpMult: 1.35, dmgMult: 1.1, speed: 2.2, radius: 0.72,
|
||||
palette: { body: '#6e1a10', accent: '#ffb02a' }, shape: 'terrorlord',
|
||||
attacks: ['fireNovaRing', 'lightningSpiral', 'meteorRain', 'chargeAt', 'enrage'],
|
||||
intro: { en: 'I AM THE SIN OF THIS WORLD!', vi: 'TA LÀ TỘI LỖI CỦA THẾ GIAN NÀY!' } },
|
||||
];
|
||||
|
||||
const BOSS_MAP = {};
|
||||
for (const b of BOSSES) BOSS_MAP[b.id] = b;
|
||||
|
||||
/* ---------------- stat rolling ---------------- */
|
||||
|
||||
/**
|
||||
* Build concrete combat stats for a monster instance.
|
||||
* Returns plain object used by entities.Monster.
|
||||
*/
|
||||
function buildMonster(speciesId, mlvl, opts = {}) {
|
||||
const sp = typeof speciesId === 'string' ? SPECIES_MAP[speciesId] : speciesId;
|
||||
if (!sp) throw new Error('unknown species ' + speciesId);
|
||||
const BAL = D2.BAL;
|
||||
let eliteAffixes = [];
|
||||
if (opts.elite) eliteAffixes = rollEliteAffixes();
|
||||
|
||||
const boss = !!opts.bossDef;
|
||||
const bd = opts.bossDef || null;
|
||||
|
||||
const m = {
|
||||
speciesId: sp.id, def: sp,
|
||||
level: mlvl,
|
||||
isElite: eliteAffixes.length > 0,
|
||||
isBoss: boss,
|
||||
bossDef: bd,
|
||||
name: boss ? (bd.name[lang()] || bd.name.en) : (sp.name[lang()] || sp.name.en),
|
||||
shape: bd ? bd.shape : sp.shape,
|
||||
palette: bd ? bd.palette : sp.palette,
|
||||
|
||||
maxHp: Math.round(BAL.mHp(mlvl) * sp.hpMult * (bd ? bd.hpMult : 1)),
|
||||
dmg: BAL.mDmg(mlvl) * sp.dmgMult,
|
||||
speed: sp.speed,
|
||||
radius: sp.radius * (bd ? bd.size : 1),
|
||||
attackRange: sp.range,
|
||||
attackCd: sp.cd,
|
||||
cdMult: 1,
|
||||
xpReward: Math.round(BAL.mXp(mlvl) * sp.xpMult),
|
||||
elem: sp.elem || null,
|
||||
proj: sp.proj || null,
|
||||
summon: sp.summon || null,
|
||||
special: sp.special || null,
|
||||
chargeRange: sp.chargeRange || 0,
|
||||
explodeOnDeath: sp.explodeOnDeath || null,
|
||||
corpsePoison: !!sp.corpsePoison,
|
||||
chillOnHit: !!sp.chillOnHit,
|
||||
resists: { fire: 0, cold: 0, lit: 0, pois: 0 },
|
||||
};
|
||||
if (bd && bd.attacks) m.attacks = bd.attacks.slice();
|
||||
|
||||
if (m.isElite) {
|
||||
m.maxHp = Math.round(m.maxHp * BAL.eliteHpMult);
|
||||
m.dmg *= BAL.eliteDmgMult;
|
||||
m.xpReward = Math.round(m.xpReward * BAL.eliteXpMult);
|
||||
m.radius *= BAL.eliteSizeMult;
|
||||
m.eliteColor = eliteAffixes[0].color;
|
||||
m.eliteName = eliteAffixes.map(a => a.name[lang()] || a.name.en).join(' ');
|
||||
for (const af of eliteAffixes) af.apply(m);
|
||||
}
|
||||
if (boss) {
|
||||
m.maxHp = Math.round(m.maxHp * BAL.bossHpMult);
|
||||
m.dmg *= BAL.bossDmgMult;
|
||||
m.xpReward = Math.round(m.xpReward * BAL.bossXpMult);
|
||||
m.radius = sp.radius * bd.size;
|
||||
m.speed = bd.speed || m.speed;
|
||||
m.cdMult = 1.15;
|
||||
}
|
||||
m.hp = m.maxHp;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** weighted random species valid for act */
|
||||
function rollSpeciesForAct(actIdx) {
|
||||
const pool = SPECIES.filter(s => s.acts.includes(actIdx));
|
||||
if (!pool.length) return SPECIES[0];
|
||||
return weighted(pool.map(s => ({ v: s, w: s.ai === 'melee' ? 2.2 : 1 })));
|
||||
}
|
||||
|
||||
function weighted(entries) {
|
||||
let total = 0;
|
||||
for (const e of entries) total += e.w;
|
||||
let r = Math.random() * total;
|
||||
for (const e of entries) { r -= e.w; if (r <= 0) return e.v; }
|
||||
return entries[entries.length - 1].v;
|
||||
}
|
||||
|
||||
function lang() { return (D2.i18n && D2.i18n.getLang()) || 'en'; }
|
||||
|
||||
D2.Monsters = {
|
||||
SPECIES, SPECIES_MAP, ELITE_AFFIXES, BOSSES, BOSS_MAP,
|
||||
buildMonster, rollSpeciesForAct, rollEliteAffixes,
|
||||
};
|
||||
})(window.D2);
|
||||
@@ -0,0 +1,227 @@
|
||||
/* ============================================================
|
||||
* Diablo2D — skills.js : class skill trees (data-driven)
|
||||
* Engine implements each `castType`; params scale with rank.
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
window.D2 = window.D2 || {};
|
||||
(function (D2) {
|
||||
|
||||
const MAX_RANK = D2.BAL.skillMaxRank;
|
||||
|
||||
function L(o) { return o[(D2.i18n && D2.i18n.getLang()) || 'en'] || o.en; }
|
||||
|
||||
/* ---------------- CRUSADER ---------------- */
|
||||
const crusader = [
|
||||
/* Zeal */
|
||||
{ id: 'cleave', branch: 'zeal', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'cleave',
|
||||
name: { en: 'Cleave', vi: 'Chém Rộng' },
|
||||
desc: r => ({ en: `Sweeping strike hits all enemies in front for ${100 + 20 * r}% weapon damage.`, vi: `Vung chém trúng mọi kẻ địch phía trước với ${100 + 20 * r}% sát thương vũ khí.` }),
|
||||
manaCost: () => 0, cooldown: 0.42,
|
||||
castType: 'meleeArc', params: r => ({ arcDeg: 160, range: 1.65, dmgPct: 100 + 20 * r }) },
|
||||
{ id: 'whirlwind', branch: 'zeal', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'whirlwind',
|
||||
name: { en: 'Whirlwind', vi: 'Vòng Xoáy' },
|
||||
desc: r => ({ en: `Spin for 1s, shredding nearby enemies for ${55 + 11 * r}% weapon damage per tick. You may move while spinning.`, vi: `Xoay trong 1s, xé nát kẻ địch gần với ${55 + 11 * r}% ST vũ khí mỗi nhịp. Di chuyển được khi xoay.` }),
|
||||
manaCost: r => 8 + r, cooldown: 5.5,
|
||||
castType: 'whirlwind', params: r => ({ range: 1.8, dmgPct: 55 + 11 * r, duration: 1.0 }) },
|
||||
{ id: 'consecrate', branch: 'zeal', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'consecrate',
|
||||
name: { en: 'Consecration', vi: 'Thánh Hóa' },
|
||||
desc: r => ({ en: `Sanctify the ground at your cursor. Deals ${140 + 34 * r}% weapon damage over 3s to enemies within it.`, vi: `Thánh hóa mặt đất tại con trỏ. Gây ${140 + 34 * r}% ST vũ khí trong 3s lên kẻ địch trong vùng.` }),
|
||||
manaCost: r => 14 + 2 * r, cooldown: 8,
|
||||
castType: 'groundAoE', params: r => ({ radius: 2.6, delay: 0.25, duration: 3, dmgPct: 140 + 34 * r, elem: 'holy' }) },
|
||||
|
||||
/* Wrath */
|
||||
{ id: 'charge', branch: 'wrath', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'charge',
|
||||
name: { en: 'Charge', vi: 'Xung Phong' },
|
||||
desc: r => ({ en: `Dash forward, damaging enemies in your path for ${130 + 26 * r}% weapon damage and stunning them ${0.5 + 0.14 * r}s.`, vi: `Lao về phía trước, gây ${130 + 26 * r}% ST vũ khí và làm choáng ${0.5 + 0.14 * r}s.` }),
|
||||
manaCost: () => 6, cooldown: 6,
|
||||
castType: 'dash', params: r => ({ distance: 5.2, speed: 16, dmgPct: 130 + 26 * r, stun: 0.5 + 0.14 * r }) },
|
||||
{ id: 'warcry', branch: 'wrath', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'warcry',
|
||||
name: { en: 'War Cry', vi: 'Chiến Khúc' },
|
||||
desc: r => ({ en: `Roar: +${15 + 5 * r}% damage and +${12 + 6 * r}% armor for 8s.`, vi: `Gầm thét: +${15 + 5 * r}% sát thương và +${12 + 6 * r}% giáp trong 8s.` }),
|
||||
manaCost: r => 10 + r, cooldown: 18,
|
||||
castType: 'buffSelf', params: r => ({ dur: 8, mods: { dmgPct: 15 + 5 * r, armorPct: 12 + 6 * r }, icon: 'warcry', name: { en: 'War Cry', vi: 'Chiến Khúc' } }) },
|
||||
{ id: 'seismic', branch: 'wrath', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'seismic',
|
||||
name: { en: 'Seismic Slam', vi: 'Trấn Địa Kích' },
|
||||
desc: r => ({ en: `Slam the earth: cone shockwave deals ${170 + 38 * r}% weapon damage, knocks back and stuns ${0.8 + 0.2 * r}s.`, vi: `Đập đất: sóng xung kích hình nón gây ${170 + 38 * r}% ST vũ khí, hất văng và choáng ${0.8 + 0.2 * r}s.` }),
|
||||
manaCost: r => 16 + 3 * r, cooldown: 9,
|
||||
castType: 'cone', params: r => ({ arcDeg: 70, range: 4.6, dmgPct: 170 + 38 * r, knockback: 2.4, stun: 0.8 + 0.2 * r }) },
|
||||
|
||||
/* Bulwark (passives) */
|
||||
{ id: 'ironskin', branch: 'bulwark', tier: 0, type: 'passive', maxRank: MAX_RANK, icon: 'ironskin',
|
||||
name: { en: 'Iron Skin', vi: 'Da Thép' },
|
||||
desc: r => ({ en: `+${6 + 4 * r}% total armor.`, vi: `+${6 + 4 * r}% tổng giáp.` }),
|
||||
mods: r => ({ armorPct: 6 + 4 * r }) },
|
||||
{ id: 'renewal', branch: 'bulwark', tier: 1, type: 'passive', maxRank: MAX_RANK, icon: 'renewal',
|
||||
name: { en: 'Renewal', vi: 'Tái Sinh' },
|
||||
desc: r => ({ en: `Regenerate ${(0.8 + 0.7 * r).toFixed(1)}% max life per second.`, vi: `Hồi ${(0.8 + 0.7 * r).toFixed(1)}% máu tối đa mỗi giây.` }),
|
||||
mods: r => ({ regenHpPct: 0.8 + 0.7 * r }) },
|
||||
{ id: 'laststand', branch: 'bulwark', tier: 2, type: 'passive', maxRank: MAX_RANK, icon: 'laststand',
|
||||
name: { en: 'Last Stand', vi: 'Đường Lui Cuối' },
|
||||
desc: r => ({ en: `Below 35% life: take ${8 + 4 * r}% less damage and deal +${10 + 5 * r}% more.`, vi: `Dưới 35% máu: nhận ít hơn ${8 + 4 * r}% sát thương và gây thêm +${10 + 5 * r}%.` }),
|
||||
mods: r => ({ lowLifeDR: 8 + 4 * r, lowLifeDmg: 10 + 5 * r }) },
|
||||
];
|
||||
|
||||
/* ---------------- RANGER ---------------- */
|
||||
const ranger = [
|
||||
/* Marksmanship */
|
||||
{ id: 'multishot', branch: 'mark', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'multishot',
|
||||
name: { en: 'Multishot', vi: 'Liên Thanh' },
|
||||
desc: r => ({ en: `Fire ${4 + Math.floor((r - 1) / 2)} arrows in a wide arc for ${85 + 17 * r}% weapon damage each.`, vi: `Bắn ${4 + Math.floor((r - 1) / 2)} mũi tên hình quạt, mỗi mũi ${85 + 17 * r}% ST vũ khí.` }),
|
||||
manaCost: r => 8 + r, cooldown: 0.9,
|
||||
castType: 'projSpread', params: r => ({ count: 4 + Math.floor((r - 1) / 2), spreadDeg: 42, dmgPct: 85 + 17 * r, speed: 11 }) },
|
||||
{ id: 'piercingbolt', branch: 'mark', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'piercingbolt',
|
||||
name: { en: 'Piercing Bolt', vi: 'Mũi Tên Xuyên Phá' },
|
||||
desc: r => ({ en: `A heavy bolt pierces all enemies in a line for ${190 + 42 * r}% weapon damage.`, vi: `Một mũi tên nặng xuyên qua mọi kẻ địch trên đường đi, gây ${190 + 42 * r}% ST vũ khí.` }),
|
||||
manaCost: r => 12 + 2 * r, cooldown: 4.5,
|
||||
castType: 'projSpread', params: r => ({ count: 1, spreadDeg: 0, dmgPct: 190 + 42 * r, speed: 15, pierce: true, size: 1.4 }) },
|
||||
{ id: 'arrowstorm', branch: 'mark', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'arrowstorm',
|
||||
name: { en: 'Arrow Storm', vi: 'Mưa Mũi Tên' },
|
||||
desc: r => ({ en: `Rain ${3 + Math.floor(r / 2)} waves of arrows on an area, each dealing ${70 + 15 * r}% weapon damage.`, vi: `Gây ${3 + Math.floor(r / 2)} đợt mưa tên lên một vùng, mỗi đợt ${70 + 15 * r}% ST vũ khí.` }),
|
||||
manaCost: r => 18 + 3 * r, cooldown: 11,
|
||||
castType: 'volleyArea', params: r => ({ waves: 3 + Math.floor(r / 2), waveDelay: 0.4, radius: 2.4, dmgPct: 70 + 15 * r }) },
|
||||
|
||||
/* Shadowcraft */
|
||||
{ id: 'shadowstep', branch: 'shadow', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'shadowstep',
|
||||
name: { en: 'Shadow Step', vi: 'Bóng Đêm Bước' },
|
||||
desc: r => ({ en: `Blink toward the cursor. Your next attack within 3s is a guaranteed critical (+${50 + 25 * r}% crit damage).`, vi: `Dịch chuyển tới con trỏ. Đòn đánh tiếp theo trong 3s chắc chắn chí mạng (+${50 + 25 * r}% ST chí mạng).` }),
|
||||
manaCost: () => 10, cooldown: 7,
|
||||
castType: 'blink', params: r => ({ range: 5.5, guaranteedCritDur: 3, bonusCritDmg: 50 + 25 * r }) },
|
||||
{ id: 'fanofknives', branch: 'shadow', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'fanofknives',
|
||||
name: { en: 'Fan of Knives', vi: 'Quạt Dao Găm' },
|
||||
desc: r => ({ en: `Hurl ${8 + 2 * r} knives in all directions for ${95 + 19 * r}% weapon damage, causing bleeding.`, vi: `Phóng ${8 + 2 * r} dao găm ra mọi hướng, gây ${95 + 19 * r}% ST vũ khí và làm chảy máu.` }),
|
||||
manaCost: r => 14 + 2 * r, cooldown: 8,
|
||||
castType: 'novaProj', params: r => ({ count: 8 + 2 * r, dmgPct: 95 + 19 * r, speed: 9, bleed: true }) },
|
||||
{ id: 'smokeveil', branch: 'shadow', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'smokeveil',
|
||||
name: { en: 'Smoke Veil', vi: 'Màn Khói' },
|
||||
desc: r => ({ en: `Vanish into smoke: +${25 + 7 * r}% dodge for 5s. Enemies lose track of you.`, vi: `Tan vào khói: +${25 + 7 * r}% né tránh trong 5s. Kẻ địch mất dấu bạn.` }),
|
||||
manaCost: r => 12 + 2 * r, cooldown: 16,
|
||||
castType: 'buffSelf', params: r => ({ dur: 5, mods: { dodgePct: 25 + 7 * r }, icon: 'smokeveil', name: { en: 'Smoke Veil', vi: 'Màn Khói' } }) },
|
||||
|
||||
/* Poacher (passives) */
|
||||
{ id: 'predator', branch: 'poach', tier: 0, type: 'passive', maxRank: MAX_RANK, icon: 'predator',
|
||||
name: { en: 'Predator Instinct', vi: 'Bản Năng Thợ Săn' },
|
||||
desc: r => ({ en: `+${8 + 6 * r}% critical damage.`, vi: `+${8 + 6 * r}% sát thương chí mạng.` }),
|
||||
mods: r => ({ critDmgPct: 8 + 6 * r }) },
|
||||
{ id: 'bleedingedge', branch: 'poach', tier: 1, type: 'passive', maxRank: MAX_RANK, icon: 'bleedingedge',
|
||||
name: { en: 'Bleeding Edge', vi: 'Lưỡi Chảy Máu' },
|
||||
desc: r => ({ en: `Attacks cause bleeding: ${10 + 10 * r}% weapon damage over 3s.`, vi: `Đòn đánh gây chảy máu: ${10 + 10 * r}% ST vũ khí trong 3s.` }),
|
||||
mods: r => ({ bleedOnHitPct: 10 + 10 * r }) },
|
||||
{ id: 'swiftvantage', branch: 'poach', tier: 2, type: 'passive', maxRank: MAX_RANK, icon: 'swiftvantage',
|
||||
name: { en: 'Swift Vantage', vi: 'Lợi Thế Thần Tốc' },
|
||||
desc: r => ({ en: `+${4 + 2 * r}% move speed and +${3 + 2 * r}% attack speed.`, vi: `+${4 + 2 * r}% tốc chạy và +${3 + 2 * r}% tốc đánh.` }),
|
||||
mods: r => ({ moveSpeedPct: 4 + 2 * r, attackSpeedPct: 3 + 2 * r }) },
|
||||
];
|
||||
|
||||
/* ---------------- SORCERESS ---------------- */
|
||||
const sorceress = [
|
||||
/* Pyromancy */
|
||||
{ id: 'firebolt', branch: 'pyro', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'firebolt',
|
||||
name: { en: 'Fire Bolt', vi: 'Tiểu Hỏa' },
|
||||
desc: r => ({ en: `Hurl a bolt of fire for ${135 + 32 * r}% weapon damage. Cheap and reliable.`, vi: `Phóng một mũi lửa gây ${135 + 32 * r}% ST vũ khí. Rẻ và đáng tin.` }),
|
||||
manaCost: () => 4, cooldown: 0.55,
|
||||
castType: 'projSpread', params: r => ({ count: 1, spreadDeg: 0, dmgPct: 135 + 32 * r, speed: 10, elem: 'fire', size: 1.15 }) },
|
||||
{ id: 'meteor', branch: 'pyro', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'meteor',
|
||||
name: { en: 'Meteor', vi: 'Thiên Thạch' },
|
||||
desc: r => ({ en: `Call a meteor to crash after 0.8s: ${260 + 58 * r}% weapon damage in a large area, leaving burning ground.`, vi: `Triệu hồi thiên thạch rơi sau 0.8s: ${260 + 58 * r}% ST vũ khí vùng lớn, để lại mặt đất cháy.` }),
|
||||
manaCost: r => 22 + 4 * r, cooldown: 9,
|
||||
castType: 'meteor', params: r => ({ radius: 2.6, delay: 0.8, dmgPct: 260 + 58 * r, burnGround: 2.5 }) },
|
||||
{ id: 'flamewave', branch: 'pyro', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'flamewave',
|
||||
name: { en: 'Flame Wave', vi: 'Sóng Lửa' },
|
||||
desc: r => ({ en: `Erupt in a ring of fire: ${200 + 44 * r}% weapon damage around you and ignite enemies.`, vi: `Bùng nổ vòng lửa quanh bạn: ${200 + 44 * r}% ST vũ khí và thiêu rụi kẻ địch.` }),
|
||||
manaCost: r => 20 + 4 * r, cooldown: 7,
|
||||
castType: 'nova', params: r => ({ radius: 3.4, dmgPct: 200 + 44 * r, elem: 'fire', ignite: true }) },
|
||||
|
||||
/* Cryomancy */
|
||||
{ id: 'iceshards', branch: 'cryo', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'iceshards',
|
||||
name: { en: 'Ice Shards', vi: 'Mảnh Băng' },
|
||||
desc: r => ({ en: `Launch ${5} piercing shards for ${90 + 20 * r}% weapon damage each, chilling enemies.`, vi: `Phóng 5 mảnh băng xuyên phá, mỗi mảnh ${90 + 20 * r}% ST vũ khí, làm lạnh kẻ địch.` }),
|
||||
manaCost: r => 9 + 2 * r, cooldown: 1.4,
|
||||
castType: 'projSpread', params: r => ({ count: 5, spreadDeg: 36, dmgPct: 90 + 20 * r, speed: 9.5, elem: 'cold', pierce: true, slow: 0.35, slowDur: 1.8 }) },
|
||||
{ id: 'frostnova', branch: 'cryo', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'frostnova',
|
||||
name: { en: 'Frost Nova', vi: 'Tinh Băng' },
|
||||
desc: r => ({ en: `Freeze everything near you for ${1.0 + 0.22 * r}s and deal ${110 + 24 * r}% weapon damage.`, vi: `Đóng băng mọi thứ gần bạn ${1.0 + 0.22 * r}s và gây ${110 + 24 * r}% ST vũ khí.` }),
|
||||
manaCost: r => 18 + 3 * r, cooldown: 8,
|
||||
castType: 'nova', params: r => ({ radius: 3.0, dmgPct: 110 + 24 * r, elem: 'cold', freeze: 1.0 + 0.22 * r }) },
|
||||
{ id: 'blizzard', branch: 'cryo', tier: 2, type: 'active', maxRank: MAX_RANK, icon: 'blizzard',
|
||||
name: { en: 'Blizzard', vi: 'Bão Tuyết' },
|
||||
desc: r => ({ en: `A storm batters an area for 3s: ${90 + 20 * r}% weapon damage per second, heavily chilled.`, vi: `Bão tuyết cuồn cuộn 3s: ${90 + 20 * r}% ST vũ khí mỗi giây, làm lạnh mạnh.` }),
|
||||
manaCost: r => 24 + 5 * r, cooldown: 12,
|
||||
castType: 'groundAoE', params: r => ({ radius: 2.8, delay: 0.3, duration: 3, dmgPct: 90 + 20 * r, elem: 'cold', slow: 0.5, slowDur: 1.2 }) },
|
||||
|
||||
/* Stormweaving */
|
||||
{ id: 'chainlightning', branch: 'storm', tier: 0, type: 'active', maxRank: MAX_RANK, icon: 'chainlightning',
|
||||
name: { en: 'Chain Lightning', vi: 'Lôi Điện Xích' },
|
||||
desc: r => ({ en: `Lightning arcs between ${2 + r} enemies for ${150 + 33 * r}% weapon damage.`, vi: `Sét lan truyền giữa ${2 + r} kẻ địch, gây ${150 + 33 * r}% ST vũ khí.` }),
|
||||
manaCost: r => 14 + 2 * r, cooldown: 2.6,
|
||||
castType: 'chainLightning', params: r => ({ jumps: 2 + r, range: 5.5, dmgPct: 150 + 33 * r }) },
|
||||
{ id: 'teleport', branch: 'storm', tier: 1, type: 'active', maxRank: MAX_RANK, icon: 'teleport',
|
||||
name: { en: 'Teleport', vi: 'Di Chuyển' },
|
||||
desc: r => ({ en: `Instantly teleport to the cursor location (${4.5 + 0.5 * r} tile range).`, vi: `Dịch chuyển tức thời đến vị trí con trỏ (${(4.5 + 0.5 * r).toFixed(1)} ô).` }),
|
||||
manaCost: r => 12 + r, cooldown: 4.5,
|
||||
castType: 'blink', params: r => ({ range: 4.5 + 0.5 * r }) },
|
||||
{ id: 'arcaneecho', branch: 'storm', tier: 2, type: 'passive', maxRank: MAX_RANK, icon: 'arcaneecho',
|
||||
name: { en: 'Arcane Echo', vi: 'Vọng Âm Huyền Thuật' },
|
||||
desc: r => ({ en: `-(${3 + 3 * r})% cooldowns and +${0.3 + 0.25 * r} mana per second.`, vi: `-(giảm) ${3 + 3 * r}% thời gian hồi kỹ năng và +${(0.3 + 0.25 * r).toFixed(2)} mana/giây.` }),
|
||||
mods: r => ({ cdrPct: 3 + 3 * r, manaRegenFlat: 0.3 + 0.25 * r }) },
|
||||
];
|
||||
|
||||
const CLASSES = {
|
||||
crusader: {
|
||||
id: 'crusader',
|
||||
name: { en: 'Crusader', vi: 'Thập Tự Quân' },
|
||||
branches: [
|
||||
{ id: 'zeal', name: { en: 'Zeal', vi: 'Cuồng Tín' } },
|
||||
{ id: 'wrath', name: { en: 'Wrath', vi: 'Thịnh Nộ' } },
|
||||
{ id: 'bulwark', name: { en: 'Bulwark', vi: 'Bức Tường Thép' } },
|
||||
],
|
||||
skills: crusader,
|
||||
baseMods: { str: 8, dex: 4, vit: 8, ene: 3 },
|
||||
weaponKind: 'melee',
|
||||
palette: { body: '#8a8f9c', accent: '#c8a35a', cloth: '#5a2a2a' },
|
||||
basic: { castType: 'meleeArc', params: { arcDeg: 120, range: 1.45, dmgPct: 100 } },
|
||||
descKey: 'class.crusader.desc',
|
||||
},
|
||||
ranger: {
|
||||
id: 'ranger',
|
||||
name: { en: 'Shadow Huntress', vi: 'Nữ Thợ Săn Bóng Đêm' },
|
||||
branches: [
|
||||
{ id: 'mark', name: { en: 'Marksmanship', vi: 'Bách Bộ Xuyên Dương' } },
|
||||
{ id: 'shadow', name: { en: 'Shadowcraft', vi: 'Nghệ Thuật Bóng Tối' } },
|
||||
{ id: 'poach', name: { en: 'Poacher', vi: 'Thợ Săn Mồi' } },
|
||||
],
|
||||
skills: ranger,
|
||||
baseMods: { str: 4, dex: 9, vit: 5, ene: 5 },
|
||||
weaponKind: 'ranged',
|
||||
palette: { body: '#3e5a46', accent: '#8adf5a', cloth: '#2a3a30' },
|
||||
basic: { castType: 'projSpread', params: { count: 1, spreadDeg: 0, dmgPct: 100, speed: 12 } },
|
||||
descKey: 'class.crusader.desc'.replace('crusader', 'ranger'),
|
||||
},
|
||||
sorceress: {
|
||||
id: 'sorceress',
|
||||
name: { en: 'Sorceress', vi: 'Pháp Sư' },
|
||||
branches: [
|
||||
{ id: 'pyro', name: { en: 'Pyromancy', vi: 'Hỏa Thuật' } },
|
||||
{ id: 'cryo', name: { en: 'Cryomancy', vi: 'Băng Thuật' } },
|
||||
{ id: 'storm', name: { en: 'Stormweaving', vi: 'Lôi Pháp' } },
|
||||
],
|
||||
skills: sorceress,
|
||||
baseMods: { str: 3, dex: 4, vit: 5, ene: 10 },
|
||||
weaponKind: 'cast',
|
||||
palette: { body: '#5e3a72', accent: '#c86aff', cloth: '#3a2450' },
|
||||
basic: { castType: 'projSpread', params: { count: 1, spreadDeg: 0, dmgPct: 100, speed: 9, size: 1.2 } },
|
||||
descKey: 'class.sorceress.desc',
|
||||
},
|
||||
};
|
||||
|
||||
/** all skills of a class flattened, with tier unlock level attached */
|
||||
function skillsFor(clsId) {
|
||||
const c = CLASSES[clsId];
|
||||
return c.skills.map(s => Object.assign({}, s, { unlockLevel: D2.BAL.tierUnlockLevels[s.tier] || 1 }));
|
||||
}
|
||||
|
||||
function findSkill(clsId, skillId) {
|
||||
return CLASSES[clsId].skills.find(s => s.id === skillId);
|
||||
}
|
||||
|
||||
D2.Skills = { CLASSES, skillsFor, findSkill, MAX_RANK };
|
||||
})(window.D2);
|
||||
Reference in New Issue
Block a user