# 33 · 敌人掉落系统(Enemy Loot System)
> **命名空间** `BaseGames.Loot`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · `BaseGames.World`(Collectible / SaveManager)· `BaseGames.Progression`(难度缩放)
---
## 目录
1. [系统总览](#1-系统总览)
2. [LootTableSO — 掉落表数据](#2-loottableso--掉落表数据)
3. [LootEntry — 单条掉落项](#3-lootentry--单条掉落项)
4. [LootResolver — 掉落计算](#4-lootresolver--掉落计算)
5. [难度缩放](#5-难度缩放)
6. [敌人集成方式](#6-敌人集成方式)
7. [掉落物实体(LootPickup)](#7-掉落物实体lootpickup)
8. [SaveData 与唯一掉落](#8-savedata-与唯一掉落)
9. [内置敌人掉落清单](#9-内置敌人掉落清单)
10. [事件频道](#10-事件频道)
11. [编辑器友好设计](#11-编辑器友好设计)
---
## 1. 系统总览
掉落系统解决"敌人死亡后掉出哪些物品、多少 Geo"的核心数值问题。数据完全由 SO 驱动,关卡策划可在不改代码的前提下调整任何敌人的掉落配置。
```
掉落系统职责:
├─ LootTableSO → 掉落表 SO(基础 Geo + 掉落项列表)
├─ LootEntry → 单条掉落项(物品/Geo增量/法术灵魂)+ 概率 + 数量
├─ LootResolver → 运行时计算本次实际掉落(权重抽取 + 难度修正)
├─ EnemyBase 集成 → 死亡时调用 LootResolver,生成 LootPickup 实体
└─ LootPickup → 世界中可拾取的掉落物(Geo 弧线飞行 / 物品直接拾取)
```
**零耦合原则**:`LootResolver` 不持有敌人引用,`EnemyBase` 死亡时传入 `LootTableSO` + 死亡位置,结果通过 SO 事件频道发布。
---
## 2. LootTableSO — 掉落表数据
```csharp
[CreateAssetMenu(menuName = "Loot/LootTable")]
public class LootTableSO : ScriptableObject
{
[Header("基础 Geo 掉落")]
[Tooltip("击杀必定掉落的 Geo 量(Normal 难度基准值)")]
public int baseGeo = 5;
[Header("概率掉落项")]
public LootEntry[] entries;
[Header("精英/Boss 标记")]
[Tooltip("精英敌人 Geo x1.5;Boss 死亡时特殊掉落(如魅力/能力道具)")]
public bool isElite;
public bool isBoss;
}
```
---
## 3. LootEntry — 单条掉落项
```csharp
[Serializable]
public class LootEntry
{
[Header("掉落内容")]
public LootType type; // Geo / Soul / Charm / Item / Nothing
// type == Geo 时有效
public int geoBonus; // 额外 Geo(叠加在 baseGeo 上)
// type == Soul 时有效
[Range(5, 33)]
public int soulAmount = 11;
// type == Charm / Item 时有效
public string itemId; // CharmSO.charmId 或 ItemSO.itemId
[Header("概率")]
[Range(0f, 1f)]
[Tooltip("独立概率(0~1),各项独立掷骰")]
public float dropChance = 0.3f;
[Header("数量(仅 Geo 堆叠时有效)")]
public int minQuantity = 1;
public int maxQuantity = 1;
[Header("唯一掉落")]
[Tooltip("勾选后:全局只掉落一次(已拾取则不再掉落,SaveData 记录)")]
public bool isUnique;
}
public enum LootType
{
Geo, // 货币
Soul, // 法术灵魂值
Charm, // 魅力道具
Item, // 消耗品/关键道具
Nothing, // 空项(用于填充概率空白,使总体掉落率可控)
}
```
---
## 4. LootResolver — 掉落计算
```csharp
namespace BaseGames.Loot
{
public static class LootResolver
{
///
/// 根据 LootTableSO 与难度,计算本次实际掉落结果。
///
public static LootResult Resolve(LootTableSO table, DifficultyLevel difficulty)
{
var result = new LootResult();
// 1. 基础 Geo(含难度修正)
float geoMult = GetGeoMultiplier(difficulty, table.isElite, table.isBoss);
result.geo = Mathf.RoundToInt(table.baseGeo * geoMult);
// 2. 各概率掉落项(独立掷骰)
foreach (var entry in table.entries)
{
if (entry.isUnique && SaveManager.Instance.IsLootCollected(entry.itemId))
continue; // 唯一物品已拾取,跳过
if (Random.value <= entry.dropChance * GetDropMultiplier(difficulty))
result.AddEntry(entry);
}
return result;
}
static float GetGeoMultiplier(DifficultyLevel diff, bool isElite, bool isBoss)
{
float mult = diff switch
{
DifficultyLevel.Easy => 1.2f,
DifficultyLevel.Normal => 1.0f,
DifficultyLevel.Hard => 0.85f,
_ => 1.0f
};
if (isElite) mult *= 1.5f;
if (isBoss) mult *= 3.0f;
return mult;
}
static float GetDropMultiplier(DifficultyLevel diff) => diff switch
{
DifficultyLevel.Easy => 1.3f,
DifficultyLevel.Normal => 1.0f,
DifficultyLevel.Hard => 0.7f,
_ => 1.0f
};
}
public class LootResult
{
public int geo;
public List entries = new();
public void AddEntry(LootEntry e) => entries.Add(e);
}
}
```
---
## 5. 难度缩放
| 难度 | Geo 倍率 | 掉落率倍率 | 备注 |
|------|---------|----------|------|
| Easy | ×1.2 | ×1.3 | 更多资源辅助新手 |
| Normal | ×1.0 | ×1.0 | 设计基准值 |
| Hard | ×0.85 | ×0.7 | 资源稀缺,更紧张 |
| 钢铁之魂 | ×0.85 | ×0.7 | 同 Hard,不影响死亡掉落(因为没有死亡)|
> **精英倍率**:精英变体敌人(名称前缀 Elite_)在普通倍率基础上额外 ×1.5 Geo。
> **Boss 特殊**:Boss 死亡不走概率掉落,直接掉落配置中的固定物品(`isBoss = true` 时 `entries` 视为确定掉落,不掷骰)。
---
## 6. 敌人集成方式
在 `EnemyBase.Die()` 中调用 `LootResolver`:
```csharp
// EnemyBase.cs(片段)
[SerializeField] LootTableSO _lootTable;
protected virtual void Die()
{
if (_lootTable != null)
{
var result = LootResolver.Resolve(
_lootTable,
DifficultyManager.Instance.CurrentDifficulty
);
LootSpawner.Instance.Spawn(result, transform.position);
}
_onEnemyDied.Raise(enemyId);
// 播放死亡演出、延迟销毁...
}
```
`LootSpawner` 是 Persistent 场景中的单例,负责将 `LootResult` 转为世界中的 `LootPickup` 实体:
```csharp
public class LootSpawner : MonoBehaviour
{
[SerializeField] LootPickup _geoPrefab;
[SerializeField] LootPickup _charmPrefab;
[SerializeField] LootPickup _soulOrbPrefab;
public void Spawn(LootResult result, Vector2 origin)
{
// 生成 Geo 飞行球(分散抛出)
if (result.geo > 0)
{
int bigGeo = result.geo / 25; // 每 25 Geo 一个大球
int smallGeo = result.geo % 25; // 剩余碎 Geo
for (int i = 0; i < bigGeo; i++)
SpawnGeoPickup(25, origin);
if (smallGeo > 0)
SpawnGeoPickup(smallGeo, origin);
}
// 生成物品掉落
foreach (var entry in result.entries)
{
var prefab = entry.type switch
{
LootType.Charm => _charmPrefab,
LootType.Soul => _soulOrbPrefab,
_ => _geoPrefab
};
var pickup = Instantiate(prefab, origin, Quaternion.identity);
pickup.Initialize(entry);
}
}
void SpawnGeoPickup(int value, Vector2 origin)
{
var pickup = Instantiate(_geoPrefab, origin, Quaternion.identity);
pickup.Initialize(value, RandomScatterVelocity());
}
Vector2 RandomScatterVelocity()
=> new Vector2(Random.Range(-3f, 3f), Random.Range(3f, 7f));
}
```
---
## 7. 掉落物实体(LootPickup)
```csharp
public class LootPickup : MonoBehaviour
{
[SerializeField] SpriteRenderer _renderer;
[SerializeField] MMF_Player _collectFeedback;
LootEntry _entry;
int _geoValue;
bool _collected;
// Geo 堆飞行初始化
public void Initialize(int geoValue, Vector2 launchVelocity)
{
_geoValue = geoValue;
GetComponent().velocity = launchVelocity;
}
// 物品初始化
public void Initialize(LootEntry entry)
{
_entry = entry;
// 根据 entry.type 设置 Sprite
}
void OnTriggerEnter2D(Collider2D other)
{
if (_collected) return;
if (!other.CompareTag("Player")) return;
_collected = true;
Collect();
}
void Collect()
{
_collectFeedback.PlayFeedbacks();
if (_entry == null)
{
// Geo 拾取
_onGeoChanged.Raise(_geoValue);
}
else
{
switch (_entry.type)
{
case LootType.Charm:
_onCharmPickedUp.Raise(_entry.itemId);
if (_entry.isUnique)
SaveManager.Instance.SetLootCollected(_entry.itemId);
break;
case LootType.Soul:
_onSoulChanged.Raise(_entry.soulAmount);
break;
}
}
Destroy(gameObject, 0.3f);
}
}
```
### Geo 飞行物理(弧线效果)
Geo 掉落使用 `Rigidbody2D` + `Gravity Scale = 2` 形成抛物线弧线。落地后禁用重力,1 秒内存在(防止贴地后无法拾取):
```csharp
void OnCollisionEnter2D(Collision2D col)
{
if (col.gameObject.layer == LayerMask.NameToLayer("Ground"))
{
var rb = GetComponent();
rb.velocity = Vector2.zero;
rb.gravityScale = 0f;
// 落地后触发磁吸效果(玩家靠近自动吸收)
_magnetRadius = 2.5f;
}
}
```
---
## 8. SaveData 与唯一掉落
唯一掉落(魅力、关键道具)全局只掉落一次,存入 SaveData:
```json
"loot": {
"collectedUniqueItems": [
"Charm_QuickSlash",
"Charm_LongNail"
]
}
```
```csharp
// SaveManager 扩展
public bool IsLootCollected(string itemId)
=> _saveData.loot.collectedUniqueItems.Contains(itemId);
public void SetLootCollected(string itemId)
{
if (!_saveData.loot.collectedUniqueItems.Contains(itemId))
{
_saveData.loot.collectedUniqueItems.Add(itemId);
WriteDirty();
}
}
```
---
## 9. 内置敌人掉落清单
### 普通敌人
| 敌人 | 基础 Geo | 概率掉落 | 备注 |
|------|---------|---------|------|
| `GruntWarrior` | 8 | Soul×11 (30%) | 基础近战敌人 |
| `SkullArcher` | 6 | Soul×11 (25%) | 远程弓箭手 |
| `SporeShroom` | 4 | — | 爆炸蘑菇,不掉物品 |
| `StoneGuard` | 12 | Geo+5 (20%) | 重甲守卫 |
| `SilkWorm` | 5 | Soul×11 (40%) | 群体蠕虫 |
### 精英敌人(isElite = true,Geo ×1.5)
| 敌人 | 基础 Geo | 概率掉落 | 备注 |
|------|---------|---------|------|
| `Elite_GruntWarrior` | 12 | Soul×22 (50%), Geo+8 (30%) | 精英近战,红色变体 |
| `Elite_SkullArcher` | 9 | Soul×22 (40%) | 精英弓手,火焰箭 |
### Boss 掉落(isBoss = true,全为确定掉落)
| Boss | 固定掉落 | 备注 |
|------|---------|------|
| `Boss_Forest` | Geo×120 + `Charm_ThornsOfAgony`(唯一)| 魅力道具来自 Boss 掉落 |
| `Boss_Cave` | Geo×180 + 能力解锁 `Dash`(唯一)| 能力道具 |
| `Boss_Ruins` | Geo×240 + `Charm_QuickSlash`(唯一)| — |
| `Boss_Abyss` | Geo×320 + 能力解锁 `DoubleJump`(唯一)| — |
| `Boss_Core` | Geo×500 | 最终Boss,专属结局演出 |
---
## 10. 事件频道
| 频道资产 | 类型 | 发布方 | 主要订阅方 |
|---------|------|--------|----------|
| `OnLootSpawned.asset` | `VoidEventChannelSO` | `LootSpawner` | 分析/统计 |
| `OnCharmPickedUp.asset` | `StringEventChannelSO` | `LootPickup` | `EquipmentManager`、`AchievementManager` |
| `OnGeoChanged.asset` | `IntEventChannelSO` | `LootPickup`(Geo 型)| `PlayerStats`、`HUD` |
| `OnSoulChanged.asset` | `IntEventChannelSO` | `LootPickup`(Soul 型)| `SpellCaster`、`HUD` |
---
## 11. 编辑器友好设计
### LootTableSO Inspector 预览
```csharp
[CustomEditor(typeof(LootTableSO))]
public class LootTableSOEditor : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
var table = (LootTableSO)target;
EditorGUILayout.Space();
EditorGUILayout.LabelField("─── 掉落预览(Normal 难度)───", EditorStyles.boldLabel);
EditorGUILayout.LabelField($"保底 Geo: {table.baseGeo}");
float totalDropChance = 0f;
foreach (var e in table.entries)
{
totalDropChance += e.dropChance;
var label = e.type == LootType.Charm
? $"{e.type} [{e.itemId}] {e.dropChance * 100:F0}%"
: $"{e.type} +{e.geoBonus} {e.dropChance * 100:F0}%";
EditorGUILayout.LabelField(label);
}
EditorGUILayout.Space();
EditorGUILayout.LabelField($"总掉落率(含重叠): {totalDropChance * 100:F0}%",
totalDropChance > 1f ? EditorStyles.boldLabel : EditorStyles.label);
}
}
```
### 敌人 Prefab 设置规范
1. `EnemyBase`(或子类)挂载 `LootTableSO` 引用字段
2. 在 Prefab 的 Inspector 中直接拖入对应 `LootTable_XXX.asset`
3. 资产命名规范:`LootTable_{EnemyId}.asset`,存放于 `Assets/Data/Loot/`
### 批量检查工具
菜单 `Zeling / Tools / LootTable Validator`:
- 检查所有 `LootTable_*.asset` 中 `isUnique` 物品 ID 在 SaveData Schema 中是否已注册
- 检查 Boss 的 entries 是否均为 `isUnique`(防止 Boss 魅力可重复获取)
- 检查 `dropChance` 总和是否合理(>1.5 时警告"掉落过多")