feat: Enemies drop loot on death
This commit is contained in:
166
src/engine/systems/LootSystem.ts
Normal file
166
src/engine/systems/LootSystem.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { SystemRegistry } from "../engine/ecs/System";
|
|||||||
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem";
|
||||||
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem";
|
||||||
import { EventBus } from "../engine/ecs/EventBus";
|
import { EventBus } from "../engine/ecs/EventBus";
|
||||||
|
import { generateLoot } from "../engine/systems/LootSystem";
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private world!: World;
|
private world!: World;
|
||||||
@@ -690,6 +691,15 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.dungeonRenderer.showHeal(ev.x, ev.y, ev.amount);
|
this.dungeonRenderer.showHeal(ev.x, ev.y, ev.amount);
|
||||||
} else if (ev.type === "killed") {
|
} else if (ev.type === "killed") {
|
||||||
this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
|
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) {
|
} else if (ev.type === "waited" && ev.actorId === this.playerId) {
|
||||||
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
const player = this.world.actors.get(this.playerId) as CombatantActor;
|
||||||
if (player) {
|
if (player) {
|
||||||
|
|||||||
Reference in New Issue
Block a user