14 KiB
14 KiB
33 · 敌人掉落系统(Enemy Loot System)
命名空间
BaseGames.Loot
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.World(Collectible / SaveManager)·BaseGames.Progression(难度缩放)
目录
- 系统总览
- LootTableSO — 掉落表数据
- LootEntry — 单条掉落项
- LootResolver — 掉落计算
- 难度缩放
- 敌人集成方式
- 掉落物实体(LootPickup)
- SaveData 与唯一掉落
- 内置敌人掉落清单
- 事件频道
- 编辑器友好设计
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.5;Boss 死亡时特殊掉落(如魅力/能力道具)")]
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 = true时entries视为确定掉落,不掷骰)。
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 = 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 预览
[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 设置规范
EnemyBase(或子类)挂载LootTableSO引用字段- 在 Prefab 的 Inspector 中直接拖入对应
LootTable_XXX.asset - 资产命名规范:
LootTable_{EnemyId}.asset,存放于Assets/Data/Loot/
批量检查工具
菜单 Zeling / Tools / LootTable Validator:
- 检查所有
LootTable_*.asset中isUnique物品 ID 在 SaveData Schema 中是否已注册 - 检查 Boss 的 entries 是否均为
isUnique(防止 Boss 魅力可重复获取) - 检查
dropChance总和是否合理(>1.5 时警告"掉落过多")