466 lines
14 KiB
Markdown
466 lines
14 KiB
Markdown
# 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
|
||
{
|
||
/// <summary>
|
||
/// 根据 LootTableSO 与难度,计算本次实际掉落结果。
|
||
/// </summary>
|
||
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<LootEntry> 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<Rigidbody2D>().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<Rigidbody2D>();
|
||
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 时警告"掉落过多")
|