feat: Enemies drop loot on death

This commit is contained in:
Peter Stockings
2026-01-25 17:01:19 +11:00
parent 9552364a60
commit 1931482abd
2 changed files with 176 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
import { type Item, type ActorType } from "../../core/types";
import {
createMeleeWeapon,
createArmour,
createConsumable,
createAmmo,
MELEE_WEAPONS,
ARMOUR
} from "../../core/config/Items";
import {
WEAPON_VARIANTS,
ARMOUR_VARIANTS,
type WeaponVariantId,
type ArmourVariantId
} from "../../core/config/ItemVariants";
import { UpgradeManager } from "../systems/UpgradeManager";
/**
* Loot drop configuration.
* Chances are cumulative (checked in order).
*/
export const LOOT_CONFIG = {
// Base chance any item drops at all (per enemy)
baseDropChance: 0.25,
// Type weights (what kind of item drops)
typeWeights: {
weapon: 30,
armour: 25,
consumable: 35,
ammo: 10,
},
// Rarity chances (applied after type is chosen)
rarityChances: {
base: 0.60, // 60% just base item
variant: 0.30, // 30% has a variant
upgraded: 0.10, // 10% has upgrade applied
},
// Per-enemy type drop chance modifiers
enemyDropModifiers: {
rat: 0.8,
bat: 0.9,
// Add more enemy types as needed
} as Record<ActorType, number>,
};
/**
* Generate a random loot item based on the loot configuration.
* Returns null if no item drops.
*/
export function generateLoot(
random: () => number,
enemyType?: ActorType,
floorLevel: number = 1
): Item | null {
// Check base drop chance (modified by enemy type)
let dropChance: number = LOOT_CONFIG.baseDropChance;
if (enemyType && LOOT_CONFIG.enemyDropModifiers[enemyType]) {
dropChance *= LOOT_CONFIG.enemyDropModifiers[enemyType];
}
// Higher floor = slightly more drops
dropChance += floorLevel * 0.02;
dropChance = Math.min(dropChance, 0.6); // Cap at 60%
if (random() > dropChance) {
return null;
}
// Determine item type
const itemType = pickWeightedRandom(LOOT_CONFIG.typeWeights, random);
// Determine rarity tier
const rarityRoll = random();
let hasVariant = false;
let hasUpgrade = false;
if (rarityRoll >= (1 - LOOT_CONFIG.rarityChances.upgraded)) {
// Top 10%: upgraded (implies has variant too)
hasVariant = true;
hasUpgrade = true;
} else if (rarityRoll >= (1 - LOOT_CONFIG.rarityChances.upgraded - LOOT_CONFIG.rarityChances.variant)) {
// Next 30%: variant only
hasVariant = true;
}
// Otherwise: base item (60%)
// Generate the item
let item: Item | null = null;
switch (itemType) {
case "weapon": {
const weaponIds = Object.keys(MELEE_WEAPONS) as (keyof typeof MELEE_WEAPONS)[];
const weaponId = weaponIds[Math.floor(random() * weaponIds.length)];
let variant: WeaponVariantId | undefined;
if (hasVariant) {
const variantIds = Object.keys(WEAPON_VARIANTS) as WeaponVariantId[];
variant = variantIds[Math.floor(random() * variantIds.length)];
}
item = createMeleeWeapon(weaponId, variant);
break;
}
case "armour": {
const armourIds = Object.keys(ARMOUR) as (keyof typeof ARMOUR)[];
const armourId = armourIds[Math.floor(random() * armourIds.length)];
let variant: ArmourVariantId | undefined;
if (hasVariant) {
const variantIds = Object.keys(ARMOUR_VARIANTS) as ArmourVariantId[];
variant = variantIds[Math.floor(random() * variantIds.length)];
}
item = createArmour(armourId, variant);
break;
}
case "consumable": {
// Only drop health potions and throwing daggers, not upgrade scrolls
const droppableConsumables = ["health_potion", "throwing_dagger"] as const;
const consumableId = droppableConsumables[Math.floor(random() * droppableConsumables.length)];
const quantity = 1 + Math.floor(random() * 2); // 1-2
item = createConsumable(consumableId, quantity);
break;
}
case "ammo": {
const quantity = 5 + Math.floor(random() * 10); // 5-14
item = createAmmo("ammo_9mm", quantity);
break;
}
}
// Apply upgrade if rolled
if (item && hasUpgrade) {
UpgradeManager.applyUpgrade(item);
}
return item;
}
/**
* Pick from weighted options.
*/
function pickWeightedRandom(
weights: Record<string, number>,
random: () => number
): string {
const entries = Object.entries(weights);
const total = entries.reduce((sum, [, w]) => sum + w, 0);
let roll = random() * total;
for (const [key, weight] of entries) {
roll -= weight;
if (roll <= 0) {
return key;
}
}
return entries[entries.length - 1][0];
}

View File

@@ -29,6 +29,7 @@ import { SystemRegistry } from "../engine/ecs/System";
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
import { EventBus } from "../engine/ecs/EventBus";
import { generateLoot } from "../engine/systems/LootSystem";
export class GameScene extends Phaser.Scene {
private world!: World;
@@ -690,6 +691,15 @@ export class GameScene extends Phaser.Scene {
this.dungeonRenderer.showHeal(ev.x, ev.y, ev.amount);
} else if (ev.type === "killed") {
this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
// Try to drop loot from killed enemy
if (ev.victimType && ev.victimType !== "player") {
const loot = generateLoot(Math.random, ev.victimType, this.floorIndex);
if (loot) {
this.itemManager.spawnItem(loot, { x: ev.x, y: ev.y });
this.dungeonRenderer.showFloatingText(ev.x, ev.y, `${loot.name}!`, "#ffd700");
}
}
} else if (ev.type === "waited" && ev.actorId === this.playerId) {
const player = this.world.actors.get(this.playerId) as CombatantActor;
if (player) {