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

14 KiB
Raw Permalink Blame History

33 · 敌人掉落系统Enemy Loot System

命名空间 BaseGames.Loot
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.WorldCollectible / SaveManager· BaseGames.Progression(难度缩放)


目录

  1. 系统总览
  2. LootTableSO — 掉落表数据
  3. LootEntry — 单条掉落项
  4. LootResolver — 掉落计算
  5. 难度缩放
  6. 敌人集成方式
  7. 掉落物实体LootPickup
  8. SaveData 与唯一掉落
  9. 内置敌人掉落清单
  10. 事件频道
  11. 编辑器友好设计

1. 系统总览

掉落系统解决"敌人死亡后掉出哪些物品、多少 Geo"的核心数值问题。数据完全由 SO 驱动,关卡策划可在不改代码的前提下调整任何敌人的掉落配置。

掉落系统职责:
  ├─ LootTableSO      → 掉落表 SO基础 Geo + 掉落项列表)
  ├─ LootEntry        → 单条掉落项(物品/Geo增量/法术灵魂)+ 概率 + 数量
  ├─ LootResolver     → 运行时计算本次实际掉落(权重抽取 + 难度修正)
  ├─ EnemyBase 集成   → 死亡时调用 LootResolver生成 LootPickup 实体
  └─ LootPickup       → 世界中可拾取的掉落物Geo 弧线飞行 / 物品直接拾取)

零耦合原则LootResolver 不持有敌人引用,EnemyBase 死亡时传入 LootTableSO + 死亡位置,结果通过 SO 事件频道发布。


2. LootTableSO — 掉落表数据

[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 — 单条掉落项

[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 — 掉落计算

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 = trueentries 视为确定掉落,不掷骰)。


6. 敌人集成方式

EnemyBase.Die() 中调用 LootResolver

// 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 实体:

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

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 秒内存在(防止贴地后无法拾取):

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

"loot": {
  "collectedUniqueItems": [
    "Charm_QuickSlash",
    "Charm_LongNail"
  ]
}
// 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 EquipmentManagerAchievementManager
OnGeoChanged.asset IntEventChannelSO LootPickupGeo 型) PlayerStatsHUD
OnSoulChanged.asset IntEventChannelSO LootPickupSoul 型) SpellCasterHUD

11. 编辑器友好设计

LootTableSO Inspector 预览

[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_*.assetisUnique 物品 ID 在 SaveData Schema 中是否已注册
  • 检查 Boss 的 entries 是否均为 isUnique(防止 Boss 魅力可重复获取)
  • 检查 dropChance 总和是否合理(>1.5 时警告"掉落过多"