# 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 时警告"掉落过多")