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,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);
|
||||
Reference in New Issue
Block a user