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:
2026-08-23 06:59:36 +00:00
commit fc1fa2d51e
42 changed files with 11784 additions and 0 deletions
+142
View File
@@ -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);
+410
View File
@@ -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);
+258
View File
@@ -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);
+227
View File
@@ -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);