- 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
150 lines
5.4 KiB
JavaScript
150 lines
5.4 KiB
JavaScript
/* ============================================================
|
|
* Diablo2D — loot.js : drops, gold, pickups
|
|
* ============================================================ */
|
|
'use strict';
|
|
window.D2 = window.D2 || {};
|
|
(function (D2) {
|
|
|
|
const rnd = Math.random;
|
|
|
|
function goldFindMult(player) {
|
|
return 1 + ((player.stats && player.stats.goldFind) || 0) / 100;
|
|
}
|
|
|
|
function magicFindOf(player) {
|
|
return (player.stats && player.stats.magicFind) || 0;
|
|
}
|
|
|
|
/** main drop routine when a monster dies */
|
|
function dropFor(game, m) {
|
|
const p = game.player;
|
|
const DC = D2.BAL.dropChances;
|
|
const x = m.x, y = m.y;
|
|
|
|
/* --- gold --- */
|
|
let goldChance = DC.gold + (m.isElite ? 0.4 : 0) + (m.isBoss ? 1 : 0);
|
|
if (m.isBoss) goldChance = 1;
|
|
if (rnd() < goldChance) {
|
|
let amount = D2.BAL.goldDropBase(m.level) * goldFindMult(p);
|
|
if (m.isElite) amount *= 2.6;
|
|
if (m.isBoss) amount *= 7;
|
|
amount = Math.max(1, Math.round(amount));
|
|
const piles = m.isBoss ? 4 : amount > 40 ? 2 : 1;
|
|
for (let i = 0; i < piles; i++) {
|
|
game.pickups.push(new D2.entities.Pickup('gold',
|
|
x + (rnd() - .5) * 0.8, y + (rnd() - .5) * 0.8,
|
|
{ amount: Math.max(1, Math.round(amount / piles)) }));
|
|
}
|
|
}
|
|
|
|
/* --- potions --- */
|
|
const potChance = (DC.potionHp + DC.potionMp) * (m.isElite ? 1.8 : 1);
|
|
if (rnd() < potChance || m.isBoss) {
|
|
const type = rnd() < 0.62 ? 'hp' : 'mp';
|
|
game.pickups.push(new D2.entities.Pickup('potion',
|
|
x + (rnd() - .5) * .6, y + (rnd() - .5) * .6, { potionType: type }));
|
|
}
|
|
if (m.isBoss) {
|
|
game.pickups.push(new D2.entities.Pickup('potion', x + .5, y, { potionType: 'hp' }));
|
|
game.pickups.push(new D2.entities.Pickup('potion', x - .5, y, { potionType: 'mp' }));
|
|
}
|
|
|
|
/* --- gear --- */
|
|
let gearChance = DC.gear + (m.isElite ? DC.gearEliteBonus : 0);
|
|
if (m.isBoss) gearChance = DC.gearBoss;
|
|
const rolls = m.isBoss ? 4 : 1;
|
|
for (let i = 0; i < rolls; i++) {
|
|
if (rnd() >= gearChance && !(m.isBoss && i === 0)) continue;
|
|
const opts = {
|
|
magicFind: magicFindOf(p),
|
|
elite: m.isElite,
|
|
boss: m.isBoss,
|
|
slot: rnd() < 0.55 ? undefined :
|
|
D2.Items.SLOTS[(rnd() * D2.Items.SLOTS.length) | 0],
|
|
};
|
|
if (m.isBoss && i === 0) {
|
|
/* guaranteed flagship drop: legendary 40%, else rare */
|
|
opts.rarity = rnd() < 0.4 ? 'legendary' : 'rare';
|
|
}
|
|
const item = D2.Items.rollItem(Math.max(1, m.level), opts);
|
|
if (item) {
|
|
game.pickups.push(new D2.entities.Pickup('item',
|
|
x + (rnd() - .5) * 1.2, y + (rnd() - .5) * 1.2, { item }));
|
|
if (item.rarity === 'legendary') {
|
|
game.sfx('legendary');
|
|
game.toast('✦ ' + item.name, 'gold');
|
|
game.addLight(x, y, 3, '#ef8f34', 0.9, 2.5);
|
|
} else if (item.rarity === 'rare') {
|
|
game.sfx('pickup');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** barrels / urns / chests */
|
|
function rollPropLoot(game, x, y, propType) {
|
|
if (propType === 'shrine' || String(propType).startsWith('npc_')) return;
|
|
|
|
if (propType === 'chest') {
|
|
/* chests are generous */
|
|
const n = 1 + (rnd() < 0.4 ? 1 : 0);
|
|
for (let i = 0; i < n; i++) {
|
|
const item = D2.Items.rollItem(Math.max(1, game.monsterLevel()),
|
|
{ magicFind: magicFindOf(game.player) + 15 });
|
|
if (item) game.pickups.push(new D2.entities.Pickup('item', x + (rnd() - .5), y + (rnd() - .5) * .6, { item }));
|
|
}
|
|
const g = Math.round(D2.BAL.goldDropBase(game.monsterLevel()) * 2 * goldFindMult(game.player));
|
|
game.pickups.push(new D2.entities.Pickup('gold', x, y + .3, { amount: g }));
|
|
game.sfx('coin');
|
|
return;
|
|
}
|
|
|
|
/* barrels & urns */
|
|
if (rnd() < 0.45) {
|
|
const amount = Math.max(1, Math.round(D2.BAL.goldDropBase(Math.max(1, game.monsterLevel() - 2)) * 0.5 * goldFindMult(game.player)));
|
|
game.pickups.push(new D2.entities.Pickup('gold', x, y, { amount }));
|
|
} else if (rnd() < 0.14) {
|
|
game.pickups.push(new D2.entities.Pickup('potion', x, y, {
|
|
potionType: rnd() < 0.65 ? 'hp' : 'mp'
|
|
}));
|
|
} else if (rnd() < 0.05) {
|
|
const item = D2.Items.rollItem(Math.max(1, game.monsterLevel() - 1));
|
|
if (item) game.pickups.push(new D2.entities.Pickup('item', x, y, { item }));
|
|
}
|
|
}
|
|
|
|
/** collect a pickup; returns true if consumed */
|
|
function collect(game, pk) {
|
|
const p = game.player;
|
|
switch (pk.kind) {
|
|
case 'gold': {
|
|
p.gold += pk.amount;
|
|
game.sfx('coin', { vol: .35 });
|
|
return true;
|
|
}
|
|
case 'potion': {
|
|
if (p.potions[pk.potionType] >= D2.BAL.potionStackMax) return false;
|
|
p.potions[pk.potionType]++;
|
|
game.sfx('pickup', { vol: .3 });
|
|
return true;
|
|
}
|
|
case 'item': {
|
|
if (p.inventory.length >= 40) {
|
|
game.toast(D2.i18n.t('msg.inventory_full'), 'bad');
|
|
return false;
|
|
}
|
|
p.inventory.push(pk.item);
|
|
game.sfx(pk.item.rarity === 'legendary' ? 'legendary' : 'pickup', { vol: .45 });
|
|
if (pk.item.rarity === 'magic' || pk.item.rarity === 'rare' || pk.item.rarity === 'legendary') {
|
|
game.toast(pk.item.name, pk.item.rarity === 'legendary' ? 'gold' : '');
|
|
}
|
|
if (D2.ui) D2.ui.refreshInventory();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
D2.loot = { dropFor, rollPropLoot, collect };
|
|
})(window.D2);
|