Files
zeling_v2/Docs/Design/33_EnemyLootSystem.md
2026-05-08 11:04:00 +08:00

466 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.5Boss 死亡时特殊掉落(如魅力/能力道具)")]
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 = trueGeo ×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 时警告"掉落过多"