多轮审查和修复
This commit is contained in:
@@ -6,7 +6,9 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ 当前状态:**架构完整度 100%**
|
||||
## ⚠️ 当前状态:**架构主干已完成,少量条目仍为设计稿或文档先行**
|
||||
|
||||
> 说明:本索引区分“主干已实现”和“类型级完全一致”。少量条目在模块层已落地,但具体接口名或辅助类型仍停留在设计文档中,不能再统一标记为“100% 完整”。
|
||||
|
||||
---
|
||||
|
||||
@@ -14,11 +16,11 @@
|
||||
|
||||
| 维度 | 状态 |
|
||||
|------|------|
|
||||
| 核心游戏循环(移动/战斗/存档) | ✅ 完整 |
|
||||
| 核心游戏循环(移动/战斗/存档) | ⚠️ 主干完整;Save 扩展校验条目仍有文档先行内容 |
|
||||
| 敌人与 Boss 基础框架 | ✅ 完整(含 BossSkillModule §23) |
|
||||
| UI / 音频 / 事件系统 | ✅ 完整 |
|
||||
| UI / 音频 / 事件系统 | ⚠️ 主流程完整;少量 UI 抽象接口仍未完全落地 |
|
||||
| 世界交互与地图 | ✅ 完整(含 MovingPlatform/CrumblePlatform/SkillInteractable §11-15) |
|
||||
| 进度/装备/技能 | ✅ 完整 |
|
||||
| 进度/装备/技能 | ⚠️ 主体完整;AbilityType 文档需以当前代码实现回写 |
|
||||
| 叙事与对话 | ✅ 完整(含 CutsceneSO/CutsceneTrigger) |
|
||||
| 玩家能力扩展系统 | ✅ 完整(Shield §20、SwimState §21、DifficultyModule §19) |
|
||||
| 世界互动机制 | ✅ 完整(Puzzle §21、LootSystem §07、LiquidSwim §21) |
|
||||
@@ -29,7 +31,7 @@
|
||||
|
||||
## 二、逐 Design 文档覆盖矩阵
|
||||
|
||||
> 标记说明:✅ 覆盖 | ⚠️ 部分覆盖 | ❌ 未覆盖 | 📖 非技术文档(艺术/叙事/策划,不需要 Architecture)
|
||||
> 标记说明:✅ 覆盖 | ⚠️ 部分覆盖 | 📝 文档先行/设计稿 | ❌ 未覆盖 | 📖 非技术文档(艺术/叙事/策划,不需要 Architecture)
|
||||
|
||||
| # | Design 文档 | Architecture 对应 | 状态 | 说明 |
|
||||
|---|------------|-----------------|------|------|
|
||||
@@ -42,11 +44,11 @@
|
||||
| 07 | FeedbackSystem | **18_VFXFeedbackModule** | ✅ | FeedbackConfigSO/VFXPool/HitFXSpawner 全部定义 |
|
||||
| 08 | WorldSystem | 08_WorldModule | ✅ | 完整(含 §11-15 MovingPlatform/MagicWall/SoftTerrain/PhantomInteractable) |
|
||||
| 09 | EditorExtensions | — | 📖 | 编辑器工具指南,无需独立 Architecture |
|
||||
| 10 | UISystem | 10_UIModule | ✅ | 完整 |
|
||||
| 10 | UISystem | 10_UIModule | ⚠️ | 主流程完整;BossHPBar 已实现,但 IBossHPProvider 目前仍主要停留在文档抽象层 |
|
||||
| 11 | GameManager | 03_CoreModule | ✅ | 完整(含 DifficultyManager 初始化顺序引用) |
|
||||
| 12 | AudioSystem | 11_AudioModule | ✅ | 完整 |
|
||||
| 13 | ProjectileSystem | 06_CombatModule §5 | ✅ | ProjectileConfigSO、LinearProjectile、ParryableProjectile 已定义 |
|
||||
| 14 | ProgressionSystem | 09_ProgressionModule | ✅ | 完整 |
|
||||
| 14 | ProgressionSystem | 09_ProgressionModule | ⚠️ | 主系统完整;AbilityType 以当前代码枚举为准,旧文档示例已回收 |
|
||||
| 15 | DialogueSystem | 14_NarrativeModule | ✅ | 完整(含 CutsceneSO/CutsceneTrigger) |
|
||||
| 16 | MapSystem | 15_MapShopModule §1 | ✅ | 完整 |
|
||||
| 17 | EquipmentSystem | 09_ProgressionModule | ✅ | 完整 |
|
||||
@@ -57,13 +59,13 @@
|
||||
| 22 | LocalizationSystem | 16_SupportingModules §1 | ✅ | 完整 |
|
||||
| 23 | GameFeelTuningGuide | — | 📖 | 数值调参指南,无需 Architecture |
|
||||
| 24 | GroundDetectionSystem | 05_PlayerModule §3 | ✅ | 合并入 PlayerMovement,GroundDetectionConfigSO 已定义 |
|
||||
| 25 | InputRebindingUI | 04_InputModule §6 | ✅ | RebindPanel/ConflictDetector/RebindPersistence 已定义 |
|
||||
| 25 | InputRebindingUI | 04_InputModule §6 | ⚠️ | RebindPanel/ConflictDetector 已实现;RebindPersistence 当前作为 InputReaderSO 内部持久化能力,而非独立类型 |
|
||||
| 26 | WallMechanicsSystem | 05_PlayerModule §3 | ✅ | 合并入 PlayerMovement,WallMechanicsConfigSO 已定义 |
|
||||
| 27 | PerformanceBudgetGuide | — | 📖 | 性能预算指南,无需 Architecture |
|
||||
| 28 | ShopSystem | 15_MapShopModule §2 | ✅ | 完整 |
|
||||
| 29 | DifficultyModesGuide | **19_DifficultyModule** | ✅ | DifficultyLevel enum/DifficultyScalerSO/DifficultyManager 全部定义 |
|
||||
| 30 | ShieldMechanicsSystem | **20_ShieldModule** | ✅ | ShieldComponent/ShieldConfigSO/IShieldable 全部定义;HurtBox 护盾管道已修正 |
|
||||
| 31 | SaveDataSchema | 12_SaveModule | ✅ | 完整(含 EmergencySaveService/CrashReporter §9) |
|
||||
| 31 | SaveDataSchema | 12_SaveModule | ⚠️ | SaveData/SaveManager/EmergencySaveService/CrashReporter 已实现;SaveValidator 与 IDlcSaveExtension 仍属文档先行条目 |
|
||||
| 32 | AchievementSystem | 16_SupportingModules §2 | ✅ | 完整 |
|
||||
| 33 | EnemyLootSystem | 07_EnemyModule §14 | ✅ | LootTableSO/LootResolver/LootPickup 已定义;EnemyBase.Die() 已集成 |
|
||||
| 34 | EventChainSystem | 14_NarrativeModule §5-6 | ✅ | 完整 |
|
||||
|
||||
@@ -52,14 +52,18 @@ public class RoomTransition : MonoBehaviour
|
||||
{
|
||||
[Header("Config")]
|
||||
[SerializeField] private string _transitionId; // 唯一 ID(目标出口用于匹配出生点)
|
||||
[SerializeField] private string _targetSceneName; // 目标场景(AddressKeys 常量)
|
||||
[SerializeField] private string _targetSceneAddress; // 目标场景 Addressable key(AddressKeys 常量)
|
||||
[SerializeField] private string _targetTransitionId; // 目标场景中对应出口的 ID
|
||||
[SerializeField] private bool _requiresKeyItem; // 是否需要持有钥匙物品
|
||||
[SerializeField] private string _requiredItemId; // 钥匙物品 ID
|
||||
[SerializeField] private bool _autoTrigger = true; // true = 进入触发器自动触发;false = 需交互
|
||||
[SerializeField] private bool _requiresKeyItem; // 是否需要持有鑰匙物品
|
||||
[SerializeField] private string _requiredItemId; // 鑰匙物品 ID
|
||||
|
||||
[Header("Event Channel")]
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
[Header("世界状态")]
|
||||
[SerializeField] private WorldStateRegistry _worldState; // 持有物品检查
|
||||
|
||||
// 玩家进入触发器
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
@@ -75,7 +79,12 @@ public class RoomTransition : MonoBehaviour
|
||||
});
|
||||
}
|
||||
|
||||
private bool HasItem(string itemId); // 查询 PlayerStats 或 Inventory
|
||||
private bool HasItem(string itemId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(itemId)) return true;
|
||||
if (_worldState == null) return false; // 未配置则拦截(警告日志)
|
||||
return _worldState.IsCollected(itemId);
|
||||
}
|
||||
|
||||
// Editor:在 Scene View 显示箭头 Gizmo
|
||||
private void OnDrawGizmos();
|
||||
@@ -222,8 +231,12 @@ public class Collectible : MonoBehaviour
|
||||
|
||||
private void Despawn(); // 归还对象池
|
||||
|
||||
// 敌人死亡时生成 Geo Collectible(由 EnemyBase 调用)
|
||||
public static void SpawnGeo(Vector2 position, int amount, ObjectPoolManager pool);
|
||||
// ―― 运行时配置(由 CollectibleSpawner 在实例化后调用)――――――――――――――――
|
||||
/// <summary>将此 Collectible 配置为 Geo 掉落。</summary>
|
||||
public void SetGeo(int amount);
|
||||
|
||||
/// <summary>将此 Collectible 配置为道具掉落。</summary>
|
||||
public void SetItem(string itemId);
|
||||
}
|
||||
|
||||
public enum CollectibleType { Geo, Item, HPOrb }
|
||||
@@ -231,6 +244,30 @@ public enum CollectibleType { Geo, Item, HPOrb }
|
||||
|
||||
---
|
||||
|
||||
## 5b. CollectibleSpawner
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/CollectibleSpawner.cs + CollectibleSpawnerConfig.cs
|
||||
// 封装 Collectible Prefab 实例化逻辑,供 LootResolver 等静态调用。
|
||||
// CollectibleSpawnerConfig 挂在 Persistent 场景 [World] GameObject 上持有 Prefab 引用,
|
||||
// Awake 调用 CollectibleSpawner.Register(this) 注入静态配置。
|
||||
public static class CollectibleSpawner
|
||||
{
|
||||
internal static void Register(CollectibleSpawnerConfig config);
|
||||
public static void SpawnGeo(Vector2 position, int amount); // 实例化 GeoPrefab 并调用 SetGeo(amount)
|
||||
public static void SpawnItem(Vector2 position, string itemId); // 实例化 ItemPrefab 并调用 SetItem(itemId)
|
||||
}
|
||||
|
||||
public class CollectibleSpawnerConfig : MonoBehaviour
|
||||
{
|
||||
[SerializeField] internal GameObject GeoPrefab;
|
||||
[SerializeField] internal GameObject ItemPrefab;
|
||||
private void Awake() => CollectibleSpawner.Register(this);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. AbilityUnlock
|
||||
|
||||
```csharp
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 09 · 进度模块
|
||||
|
||||
> **命名空间** `BaseGames.Equipment`、`BaseGames.Skills`、`BaseGames.Progression`
|
||||
> **命名空间** `BaseGames.Player`、`BaseGames.Equipment`、`BaseGames.Skills`、`BaseGames.Progression`
|
||||
> **程序集** `BaseGames.Equipment`、`BaseGames.Skills`
|
||||
> **路径** `Assets/Scripts/Equipment/`、`Assets/Scripts/Skills/`
|
||||
> **路径** `Assets/Scripts/Player/`、`Assets/Scripts/World/`、`Assets/Scripts/Equipment/`、`Assets/Scripts/Skills/`
|
||||
> **依赖** `BaseGames.Core.Events`、`BaseGames.Combat`、`BaseGames.Player`
|
||||
|
||||
---
|
||||
@@ -33,6 +33,7 @@
|
||||
> - 原 `Dictionary<string, bool>` 存档方案每次查询需 `.ToString()` 装箱 + 字典哈希,在 AbilityGate.Start() 及 ParrySystem.TryActivateParry() 等热路径产生不必要开销
|
||||
> - 改为 bitmask 后:存档只存一个 `uint`,查询为单次位运算 `(_flags & ability) != 0`,兼容序列化
|
||||
> - **新增能力只需追加新的 `1 << N`,禁止修改已有枚举值**(防止存档数据错位)
|
||||
> - 本节以当前仓库中的 `Assets/Scripts/Player/AbilityType.cs` 为准;历史文档中的旧命名不再作为当前实现事实来源
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Player/AbilityType.cs
|
||||
@@ -40,36 +41,41 @@
|
||||
[System.Flags]
|
||||
public enum AbilityType : uint
|
||||
{
|
||||
None = 0,
|
||||
None = 0,
|
||||
|
||||
// 移动
|
||||
WallCling = 1u << 0,
|
||||
WallJump = 1u << 1,
|
||||
Dash = 1u << 2,
|
||||
AerialDash = 1u << 3, // 空中冲刺(默认锁定,升级后解锁)
|
||||
InvincibleDash= 1u << 4, // 冲刺全程无敌(Dash 的升级版,默认锁定)
|
||||
DoubleJump = 1u << 5,
|
||||
ClimbVines = 1u << 6,
|
||||
Swim = 1u << 7, // 游泳(LiquidZone 内切换 SwimState)
|
||||
// 移动能力
|
||||
WallCling = 1u << 0, // 贴墙悬挂
|
||||
WallJump = 1u << 1, // 墙跳
|
||||
Dash = 1u << 2, // 地面冲刺
|
||||
AirDash = 1u << 3, // 空中冲刺(二段冲刺)
|
||||
DoubleJump = 1u << 4, // 二段跳
|
||||
SuperJump = 1u << 5, // 超级跳(聚气跳)
|
||||
Swim = 1u << 6, // 游泳(液体中自由移动)
|
||||
Dive = 1u << 7, // 下劈(空中下突)
|
||||
|
||||
// 战斗
|
||||
Parry = 1u << 8,
|
||||
Spring = 1u << 9, // 灵泉反弹
|
||||
UseTools = 1u << 10,
|
||||
// 法术能力
|
||||
Spell1 = 1u << 8, // 法术槽 1(策划自定义)
|
||||
Spell2 = 1u << 9, // 法术槽 2
|
||||
Spell3 = 1u << 10, // 法术槽 3
|
||||
|
||||
// 互动
|
||||
ReadShrine = 1u << 11,
|
||||
UseGrapple = 1u << 12,
|
||||
// 灵魄形态
|
||||
SpiritForm = 1u << 11, // 灵魄形态切换
|
||||
SpiritDash = 1u << 12, // 灵魄冲刺(穿透地形)
|
||||
|
||||
// 预留扩展位(13~31):新能力在此追加,禁止复用已有值
|
||||
// 战斗能力
|
||||
Parry = 1u << 13, // 格挡/弹反
|
||||
ChargeAttack = 1u << 14, // 蓄力攻击
|
||||
DownSlash = 1u << 15, // 下斩
|
||||
|
||||
// 互动能力
|
||||
Interact = 1u << 16, // 互动(NPC/机关)
|
||||
FastTravel = 1u << 17, // 快速旅行解锁
|
||||
|
||||
// 组合掩码
|
||||
AllMovement = WallCling | WallJump | Dash | AirDash | DoubleJump | SuperJump | Swim | Dive,
|
||||
AllSpells = Spell1 | Spell2 | Spell3,
|
||||
AllSpirit = SpiritForm | SpiritDash,
|
||||
}
|
||||
|
||||
// ── SaveMigrator 兼容说明 ─────────────────────────────────────────────────
|
||||
// SaveMeta.Version < "2.1" 的存档仍持有 Dictionary<string, bool> Abilities
|
||||
// SaveMigrator.MigrateV2ToV21 负责将其转换为 AbilityFlags uint:
|
||||
// foreach (var kv in old.Abilities)
|
||||
// if (kv.Value && Enum.TryParse<AbilityType>(kv.Key, out var a))
|
||||
// newFlags |= (uint)a;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -418,10 +424,11 @@ public class WeaponOverrideEffect : ICharmEffect
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Equipment/EquipmentManager.cs
|
||||
public class EquipmentManager : MonoBehaviour
|
||||
public class EquipmentManager : MonoBehaviour, ISaveable
|
||||
{
|
||||
[Header("配置")]
|
||||
[SerializeField] private EquipmentConfigSO _config; // 初始 Notch 数量等
|
||||
[SerializeField] private EquipmentConfigSO _config; // 初始 Notch 数量等
|
||||
[SerializeField] private CharmCatalogSO _charmCatalog; // CharmSO 查找表(Assets/Data/Equipment/CharmCatalog.asset)
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private CharmEventChannelSO _onCharmEquipped;
|
||||
@@ -475,13 +482,38 @@ public class EquipmentManager : MonoBehaviour
|
||||
_onEquipmentChanged.Raise();
|
||||
}
|
||||
|
||||
public void AddToCollection(string charmId); // 加入收藏(存档)
|
||||
public void AddToCollection(string charmId)
|
||||
{
|
||||
// 通过 _charmCatalog.Find(charmId) 查找 CharmSO,去重后加入 _collected
|
||||
if (_charmCatalog == null) return;
|
||||
var charm = _charmCatalog.Find(charmId);
|
||||
if (charm != null && !_collected.Contains(charm)) _collected.Add(charm);
|
||||
}
|
||||
|
||||
public void IncreaseNotches(int amount) => _currentNotchCapacity += amount;
|
||||
|
||||
// 存档集成
|
||||
public EquipmentSaveData GetSaveData();
|
||||
public void LoadSaveData(EquipmentSaveData data);
|
||||
// 存档集成(ISaveable)
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
data.Equipment.EquippedCharmIds = _equipped.Select(c => c.charmId).ToArray();
|
||||
data.Equipment.OwnedCharmIds = _collected.Select(c => c.charmId).ToArray();
|
||||
data.Equipment.NotchesUsed = UsedNotches;
|
||||
}
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
// 清除当前装备,恢复 _collected,再装备 _equipped
|
||||
foreach (var c in _equipped.ToList()) UnequipCharm(c);
|
||||
_collected.Clear();
|
||||
if (data.Equipment.OwnedCharmIds != null)
|
||||
foreach (var id in data.Equipment.OwnedCharmIds) AddToCollection(id);
|
||||
if (data.Equipment.EquippedCharmIds != null)
|
||||
foreach (var id in data.Equipment.EquippedCharmIds)
|
||||
{
|
||||
var charm = _charmCatalog?.Find(id);
|
||||
if (charm != null) TryEquipCharm(charm);
|
||||
}
|
||||
_onEquipmentChanged.Raise();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -531,6 +563,7 @@ public class ToolSlotManager : MonoBehaviour, ISaveable
|
||||
|
||||
[SerializeField] private ToolSO[] _slots = new ToolSO[SlotCount];
|
||||
[SerializeField] private int[] _remainingUses = new int[SlotCount]; // -1 = 无限
|
||||
[SerializeField] private ToolCatalogSO _toolCatalog; // ToolSO 查找表(Assets/Data/Equipment/ToolCatalog.asset)
|
||||
[SerializeField] private ToolUsedEventChannelSO _onToolUsed;
|
||||
|
||||
// 当前冷却倒计时(秒)
|
||||
@@ -582,7 +615,13 @@ public class ToolSlotManager : MonoBehaviour, ISaveable
|
||||
data.Tools.ToolSlot0 = _slots[0]?.toolId; // 注:工具数据归属 SaveData.Tools,不在 Equipment
|
||||
data.Tools.ToolSlot1 = _slots[1]?.toolId;
|
||||
}
|
||||
public void OnLoad(SaveData data) { /* 从 data.Tools 恢复 ToolSO 引用 */ }
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
// 重置冷却,通过 _toolCatalog.Find() 恢复两个槽位的 ToolSO 引用
|
||||
for (int i = 0; i < SlotCount; i++) _cooldowns[i] = 0f;
|
||||
EquipTool(0, _toolCatalog?.Find(data.Tools.ToolSlot0));
|
||||
EquipTool(1, _toolCatalog?.Find(data.Tools.ToolSlot1));
|
||||
}
|
||||
}
|
||||
|
||||
// 可选接口:带冷却时间的工具
|
||||
|
||||
@@ -284,100 +284,101 @@ namespace BaseGames.VFX
|
||||
/// <summary>
|
||||
/// ParticleSystem 专用对象池,挂在 Persistent 场景 [VFXPool] GameObject 上。
|
||||
/// 粒子播放完成(或超过 MaxLifetime)后自动回池,调用方无需手动归还。
|
||||
/// 不依赖 UniTask,使用 Coroutine 驱动回收。
|
||||
/// </summary>
|
||||
public class VFXPool : MonoBehaviour
|
||||
{
|
||||
public static VFXPool Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 全局兜底超时(秒)。单个特效可在 Prefab 的 VFXPoolEntry 组件上覆盖。
|
||||
/// 防止循环粒子或异常长时间特效永不回池导致内存膨胀。
|
||||
/// </summary>
|
||||
[SerializeField, Min(1f)] private float _globalMaxLifetime = 10f;
|
||||
|
||||
readonly Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>> _pools = new();
|
||||
private readonly Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>> _pools
|
||||
= new Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>>();
|
||||
|
||||
void Awake() => Instance = this;
|
||||
private void Awake() => Instance = this;
|
||||
|
||||
/// <summary>
|
||||
/// 在世界坐标播放一次特效。Fire-and-forget(UniTask 自动回池)。
|
||||
/// 在世界坐标播放一次特效(Fire-and-forget,Coroutine 自动回池)。
|
||||
/// </summary>
|
||||
/// <param name="maxLifetime">
|
||||
/// > 0 时覆盖全局超时;≤ 0 时使用 <see cref="_globalMaxLifetime"/>。
|
||||
/// </param>
|
||||
public async UniTaskVoid Play(AssetReferenceGameObject vfxRef,
|
||||
Vector3 position,
|
||||
Quaternion rotation = default,
|
||||
float maxLifetime = 0f)
|
||||
public void Play(AssetReferenceGameObject vfxRef,
|
||||
Vector3 position,
|
||||
Quaternion rotation = default,
|
||||
float maxLifetime = 0f)
|
||||
{
|
||||
var ps = await GetOrCreateAsync(vfxRef);
|
||||
float limit = maxLifetime > 0f ? maxLifetime : _globalMaxLifetime;
|
||||
StartCoroutine(PlayCoroutine(vfxRef, position, rotation, maxLifetime));
|
||||
}
|
||||
|
||||
/// <summary>预热:预先创建若干实例避免首次播放卡顿。</summary>
|
||||
public void Warmup(AssetReferenceGameObject vfxRef, int count)
|
||||
{
|
||||
StartCoroutine(WarmupCoroutine(vfxRef, count));
|
||||
}
|
||||
|
||||
private IEnumerator PlayCoroutine(AssetReferenceGameObject vfxRef,
|
||||
Vector3 position, Quaternion rotation, float maxLifetime)
|
||||
{
|
||||
ParticleSystem ps = null;
|
||||
if (!TryDequeue(vfxRef, out ps))
|
||||
{
|
||||
var op = Addressables.InstantiateAsync(vfxRef, transform);
|
||||
yield return op;
|
||||
if (op.Result == null) { Debug.LogError($"[VFXPool] Failed: {vfxRef.RuntimeKey}"); yield break; }
|
||||
ps = op.Result.GetComponent<ParticleSystem>();
|
||||
if (ps == null) { Addressables.ReleaseInstance(op.Result); yield break; }
|
||||
ps.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
ps.transform.SetPositionAndRotation(position, rotation);
|
||||
ps.gameObject.SetActive(true);
|
||||
ps.Play();
|
||||
|
||||
// 双重退出条件:粒子自然结束 OR 超时强制回收
|
||||
using var cts = new System.Threading.CancellationTokenSource(
|
||||
System.TimeSpan.FromSeconds(limit));
|
||||
try
|
||||
float limit = maxLifetime > 0f ? maxLifetime : _globalMaxLifetime;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < limit && ps.IsAlive(true))
|
||||
{
|
||||
await UniTask.WaitUntil(() => !ps.IsAlive(true),
|
||||
cancellationToken: cts.Token);
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
|
||||
if (ps.IsAlive(true))
|
||||
{
|
||||
// 超时:强制停止
|
||||
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||
Debug.LogWarning(
|
||||
$"[VFXPool] '{vfxRef.RuntimeKey}' 超过 {limit:F1}s 强制回收。" +
|
||||
"请检查粒子是否设为 Loop 或 Duration 过长。");
|
||||
Debug.LogWarning($"[VFXPool] '{vfxRef.RuntimeKey}' 超过 {limit:F1}s 强制回收。");
|
||||
}
|
||||
|
||||
ps.gameObject.SetActive(false);
|
||||
_pools[vfxRef].Enqueue(ps);
|
||||
Enqueue(vfxRef, ps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预热:预先创建若干实例避免首次播放卡顿。
|
||||
/// </summary>
|
||||
public async UniTask WarmupAsync(AssetReferenceGameObject vfxRef, int count)
|
||||
private IEnumerator WarmupCoroutine(AssetReferenceGameObject vfxRef, int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var go = await Addressables.InstantiateAsync(vfxRef, transform).Task;
|
||||
var ps = go.GetComponent<ParticleSystem>();
|
||||
var op = Addressables.InstantiateAsync(vfxRef, transform);
|
||||
yield return op;
|
||||
if (op.Result == null) continue;
|
||||
var ps = op.Result.GetComponent<ParticleSystem>();
|
||||
if (ps == null) { Addressables.ReleaseInstance(op.Result); continue; }
|
||||
ps.gameObject.SetActive(false);
|
||||
if (!_pools.ContainsKey(vfxRef)) _pools[vfxRef] = new Queue<ParticleSystem>();
|
||||
_pools[vfxRef].Enqueue(ps);
|
||||
Enqueue(vfxRef, ps);
|
||||
}
|
||||
}
|
||||
|
||||
async UniTask<ParticleSystem> GetOrCreateAsync(AssetReferenceGameObject vfxRef)
|
||||
private bool TryDequeue(AssetReferenceGameObject key, out ParticleSystem ps)
|
||||
{
|
||||
if (_pools.TryGetValue(vfxRef, out var q) && q.Count > 0)
|
||||
return q.Dequeue();
|
||||
ps = null;
|
||||
return _pools.TryGetValue(key, out var q) && q.Count > 0 && (ps = q.Dequeue()) != null;
|
||||
}
|
||||
|
||||
// 池不存在时初始化
|
||||
if (!_pools.ContainsKey(vfxRef))
|
||||
_pools[vfxRef] = new Queue<ParticleSystem>();
|
||||
|
||||
var go = await Addressables.InstantiateAsync(vfxRef, transform).Task;
|
||||
return go.GetComponent<ParticleSystem>();
|
||||
private void Enqueue(AssetReferenceGameObject key, ParticleSystem ps)
|
||||
{
|
||||
if (!_pools.ContainsKey(key)) _pools[key] = new Queue<ParticleSystem>();
|
||||
_pools[key].Enqueue(ps);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`maxLifetime` 使用指南**:
|
||||
|
||||
| 特效类型 | 建议 `maxLifetime` | 说明 |
|
||||
|----------|-------------------|------|
|
||||
| 命中火花 / 数字 | 默认(3s) | 时长已知,无需覆盖 |
|
||||
| 爆炸 / 大范围 | `5f` | 稍长,粒子散逸需时间 |
|
||||
| Boss 相位特效 | `15f` | 长动画,须显式指定 |
|
||||
| 环境氛围循环粒子 | **不使用 VFXPool** | Loop 粒子应手动管理生命周期 |
|
||||
|
||||
---
|
||||
|
||||
## 7. VFXCatalogSO — VFX 映射字典
|
||||
@@ -448,16 +449,32 @@ namespace BaseGames.VFX
|
||||
/// </summary>
|
||||
public class HitFXSpawner : MonoBehaviour
|
||||
{
|
||||
[SerializeField] HitConfirmedEventChannelSO _onHitConfirmed;
|
||||
[SerializeField] VFXCatalogSO _catalog;
|
||||
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
|
||||
[SerializeField] private VFXCatalogSO _catalog;
|
||||
|
||||
void OnEnable() => _onHitConfirmed.OnEventRaised += HandleHit;
|
||||
void OnDisable() => _onHitConfirmed.OnEventRaised -= HandleHit;
|
||||
|
||||
void HandleHit(HitInfo info)
|
||||
private void Awake()
|
||||
{
|
||||
if (_catalog.TryGetHitFX(info.DamageInfo.HitFxType, out var vfxRef))
|
||||
VFXPool.Instance.Play(vfxRef, info.HitPoint).Forget();
|
||||
if (_catalog != null)
|
||||
_catalog.Initialize();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onHitConfirmed != null)
|
||||
_onHitConfirmed.OnEventRaised += HandleHit;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onHitConfirmed != null)
|
||||
_onHitConfirmed.OnEventRaised -= HandleHit;
|
||||
}
|
||||
|
||||
private void HandleHit(HitInfo info)
|
||||
{
|
||||
if (_catalog == null || VFXPool.Instance == null) return;
|
||||
if (_catalog.TryGetHitFX(info.DamageInfo.FxType, out var vfxRef))
|
||||
VFXPool.Instance.Play(vfxRef, info.HitPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,32 +491,48 @@ namespace BaseGames.VFX
|
||||
/// 受击白闪效果,通过 Material Property Block 修改 Shader 参数。
|
||||
/// 挂在玩家/敌人的 SpriteRenderer 所在 GameObject 上。
|
||||
/// 不复制 Material(避免 GC),通过 MaterialPropertyBlock 实现。
|
||||
/// 若上一次闪白未结束则重置计时器(支持连击打断重置)。
|
||||
/// </summary>
|
||||
public class HurtFlashController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] SpriteRenderer _renderer;
|
||||
[SerializeField] FeedbackConfigSO _config;
|
||||
[SerializeField] private SpriteRenderer _renderer;
|
||||
[SerializeField] private FeedbackConfigSO _config;
|
||||
|
||||
static readonly int FlashColorID = Shader.PropertyToID("_FlashColor");
|
||||
static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount");
|
||||
private static readonly int FlashColorID = Shader.PropertyToID("_FlashColor");
|
||||
private static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount");
|
||||
|
||||
MaterialPropertyBlock _block;
|
||||
private MaterialPropertyBlock _block;
|
||||
private Coroutine _flashCoroutine;
|
||||
|
||||
void Awake() => _block = new MaterialPropertyBlock();
|
||||
private void Awake()
|
||||
{
|
||||
if (_renderer == null)
|
||||
_renderer = GetComponent<SpriteRenderer>();
|
||||
_block = new MaterialPropertyBlock();
|
||||
}
|
||||
|
||||
/// <summary>触发一次受击白闪(由 IFeedbackPlayer.PlayTakeHit 间接调用)。</summary>
|
||||
public async UniTaskVoid Flash(CancellationToken ct = default)
|
||||
/// <summary>触发一次受击白闪。若上一次未结束则重置计时器。</summary>
|
||||
public void Flash()
|
||||
{
|
||||
if (_flashCoroutine != null)
|
||||
StopCoroutine(_flashCoroutine);
|
||||
_flashCoroutine = StartCoroutine(FlashCoroutine());
|
||||
}
|
||||
|
||||
private IEnumerator FlashCoroutine()
|
||||
{
|
||||
SetFlash(1f);
|
||||
yield return new WaitForSeconds(_config != null ? _config.HurtFlashDuration : 0.12f);
|
||||
SetFlash(0f);
|
||||
_flashCoroutine = null;
|
||||
}
|
||||
|
||||
private void SetFlash(float amount)
|
||||
{
|
||||
_renderer.GetPropertyBlock(_block);
|
||||
_block.SetColor(FlashColorID, _config.HurtFlashColor);
|
||||
_block.SetFloat(FlashAmountID, 1f);
|
||||
_renderer.SetPropertyBlock(_block);
|
||||
|
||||
await UniTask.Delay(
|
||||
TimeSpan.FromSeconds(_config.HurtFlashDuration),
|
||||
cancellationToken: ct);
|
||||
|
||||
_block.SetFloat(FlashAmountID, 0f);
|
||||
if (_config != null)
|
||||
_block.SetColor(FlashColorID, _config.HurtFlashColor);
|
||||
_block.SetFloat(FlashAmountID, amount);
|
||||
_renderer.SetPropertyBlock(_block);
|
||||
}
|
||||
}
|
||||
@@ -519,21 +552,28 @@ namespace BaseGames.VFX
|
||||
/// 形态切换时替换玩家精灵调色板。
|
||||
/// 通过 Texture2D 查找表(LUT)Shader 实现,不换 Sprite 资产。
|
||||
/// 挂在玩家 SpriteRenderer 所在 GameObject 上。
|
||||
/// 由 FormController 在切换形态时调用 ApplyPalette(FormType)。
|
||||
/// </summary>
|
||||
public class PaletteSwapSystem : MonoBehaviour
|
||||
{
|
||||
[SerializeField] SpriteRenderer _renderer;
|
||||
[SerializeField] PaletteCatalogSO _catalog;
|
||||
[SerializeField] private SpriteRenderer _renderer;
|
||||
[SerializeField] private PaletteCatalogSO _catalog;
|
||||
|
||||
static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
|
||||
private static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
|
||||
|
||||
MaterialPropertyBlock _block;
|
||||
private MaterialPropertyBlock _block;
|
||||
|
||||
void Awake() => _block = new MaterialPropertyBlock();
|
||||
private void Awake()
|
||||
{
|
||||
if (_renderer == null)
|
||||
_renderer = GetComponent<SpriteRenderer>();
|
||||
_block = new MaterialPropertyBlock();
|
||||
}
|
||||
|
||||
/// <summary>切换到指定形态的调色板。由 FormController 调用。</summary>
|
||||
/// <summary>切换到指定形态的调色板。由 FormController.SwitchForm() 调用。</summary>
|
||||
public void ApplyPalette(FormType form)
|
||||
{
|
||||
if (_catalog == null || _renderer == null) return;
|
||||
if (!_catalog.TryGetPalette(form, out var tex)) return;
|
||||
_renderer.GetPropertyBlock(_block);
|
||||
_block.SetTexture(PaletteTexID, tex);
|
||||
@@ -544,13 +584,16 @@ namespace BaseGames.VFX
|
||||
[CreateAssetMenu(menuName = "VFX/PaletteCatalog")]
|
||||
public class PaletteCatalogSO : ScriptableObject
|
||||
{
|
||||
public PaletteEntry[] entries;
|
||||
[SerializeField] private PaletteEntry[] _entries;
|
||||
|
||||
public bool TryGetPalette(FormType form, out Texture2D tex)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
if (_entries != null)
|
||||
{
|
||||
if (e.form == form) { tex = e.paletteLUT; return true; }
|
||||
foreach (var e in _entries)
|
||||
{
|
||||
if (e.form == form) { tex = e.paletteLUT; return true; }
|
||||
}
|
||||
}
|
||||
tex = null;
|
||||
return false;
|
||||
@@ -560,8 +603,8 @@ namespace BaseGames.VFX
|
||||
[Serializable]
|
||||
public struct PaletteEntry
|
||||
{
|
||||
public FormType form;
|
||||
public Texture2D paletteLUT; // 1D 查找表纹理(256×1 px)
|
||||
public FormType form;
|
||||
public Texture2D paletteLUT; // 1D 查找表纹理(256×1 px)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -575,97 +618,129 @@ namespace BaseGames.VFX
|
||||
{
|
||||
/// <summary>
|
||||
/// 后处理 Volume 分区管理器,挂在 Persistent 场景 [PostProcess] GameObject 上。
|
||||
/// 通过 DOTween 平滑 blend Weight,监听游戏状态事件。
|
||||
/// 通过 Coroutine 平滑 blend Weight,监听游戏状态/Boss/死亡/胜利事件。
|
||||
/// 注意:水下后处理由 UnderwaterPostProcessingController(World.Liquid)独立负责,此组件不处理。
|
||||
/// </summary>
|
||||
public class PostProcessManager : MonoBehaviour
|
||||
{
|
||||
[Header("Volume 引用(Persistent 场景内)")]
|
||||
[SerializeField] Volume _underwaterVolume; // Priority=10
|
||||
[SerializeField] Volume _bossArenaVolume; // Priority=10
|
||||
[SerializeField] Volume _deathVolume; // Priority=20
|
||||
[SerializeField] Volume _victoryVolume; // Priority=10
|
||||
[SerializeField] private Volume _bossArenaVolume; // Priority=10
|
||||
[SerializeField] private Volume _deathVolume; // Priority=20
|
||||
[SerializeField] private Volume _victoryVolume; // Priority=10
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] VoidEventChannelSO _onLiquidEntered;
|
||||
[SerializeField] VoidEventChannelSO _onLiquidExited;
|
||||
[SerializeField] VoidEventChannelSO _onBossFightStarted;
|
||||
[SerializeField] VoidEventChannelSO _onBossFightEnded;
|
||||
[SerializeField] VoidEventChannelSO _onPlayerDied;
|
||||
[SerializeField] VoidEventChannelSO _onPlayerRespawned;
|
||||
[SerializeField] VoidEventChannelSO _onBossDefeated;
|
||||
[SerializeField] private VoidEventChannelSO _onBossFightStarted;
|
||||
[SerializeField] private VoidEventChannelSO _onBossFightEnded;
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerRespawned;
|
||||
[SerializeField] private VoidEventChannelSO _onBossDefeated;
|
||||
|
||||
[SerializeField] float _blendDuration = 0.4f;
|
||||
[SerializeField] private float _blendDuration = 0.4f;
|
||||
|
||||
private Volume[] _nonDefaultVolumes;
|
||||
private Volume[] _managedVolumes;
|
||||
private Coroutine _blendCoroutine;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_nonDefaultVolumes = new[] { _underwaterVolume, _bossArenaVolume, _deathVolume, _victoryVolume };
|
||||
_managedVolumes = new[] { _bossArenaVolume, _deathVolume, _victoryVolume };
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onLiquidEntered.OnEventRaised += () => BlendTo(_underwaterVolume);
|
||||
_onLiquidExited.OnEventRaised += ResetAll;
|
||||
_onBossFightStarted.OnEventRaised += () => BlendTo(_bossArenaVolume);
|
||||
_onBossFightEnded.OnEventRaised += ResetAll;
|
||||
_onPlayerDied.OnEventRaised += () => BlendTo(_deathVolume);
|
||||
_onPlayerRespawned.OnEventRaised += ResetAll;
|
||||
_onBossDefeated.OnEventRaised += () => BlendTo(_victoryVolume);
|
||||
if (_onBossFightStarted != null) _onBossFightStarted.OnEventRaised += HandleBossFightStarted;
|
||||
if (_onBossFightEnded != null) _onBossFightEnded.OnEventRaised += HandleBossFightEnded;
|
||||
if (_onPlayerDied != null) _onPlayerDied.OnEventRaised += HandlePlayerDied;
|
||||
if (_onPlayerRespawned != null) _onPlayerRespawned.OnEventRaised += HandlePlayerRespawned;
|
||||
if (_onBossDefeated != null) _onBossDefeated.OnEventRaised += HandleBossDefeated;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_onLiquidEntered.OnEventRaised -= () => BlendTo(_underwaterVolume);
|
||||
_onLiquidExited.OnEventRaised -= ResetAll;
|
||||
_onBossFightStarted.OnEventRaised -= () => BlendTo(_bossArenaVolume);
|
||||
_onBossFightEnded.OnEventRaised -= ResetAll;
|
||||
_onPlayerDied.OnEventRaised -= () => BlendTo(_deathVolume);
|
||||
_onPlayerRespawned.OnEventRaised -= ResetAll;
|
||||
_onBossDefeated.OnEventRaised -= () => BlendTo(_victoryVolume);
|
||||
if (_onBossFightStarted != null) _onBossFightStarted.OnEventRaised -= HandleBossFightStarted;
|
||||
if (_onBossFightEnded != null) _onBossFightEnded.OnEventRaised -= HandleBossFightEnded;
|
||||
if (_onPlayerDied != null) _onPlayerDied.OnEventRaised -= HandlePlayerDied;
|
||||
if (_onPlayerRespawned != null) _onPlayerRespawned.OnEventRaised -= HandlePlayerRespawned;
|
||||
if (_onBossDefeated != null) _onBossDefeated.OnEventRaised -= HandleBossDefeated;
|
||||
}
|
||||
|
||||
private void HandleBossFightStarted() => BlendTo(_bossArenaVolume);
|
||||
private void HandleBossFightEnded() => ResetAll();
|
||||
private void HandlePlayerDied() => BlendTo(_deathVolume);
|
||||
private void HandlePlayerRespawned() => ResetAll();
|
||||
private void HandleBossDefeated() => BlendTo(_victoryVolume);
|
||||
|
||||
private void BlendTo(Volume target)
|
||||
{
|
||||
foreach (var v in _nonDefaultVolumes)
|
||||
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
|
||||
.SetAutoKill(true).SetLink(gameObject);
|
||||
|
||||
DOTween.To(() => target.weight, x => target.weight = x, 1f, _blendDuration)
|
||||
.SetAutoKill(true).SetLink(gameObject);
|
||||
if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
|
||||
_blendCoroutine = StartCoroutine(BlendCoroutine(target, 1f));
|
||||
}
|
||||
|
||||
private void ResetAll()
|
||||
{
|
||||
foreach (var v in _nonDefaultVolumes)
|
||||
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
|
||||
.SetAutoKill(true).SetLink(gameObject);
|
||||
if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
|
||||
_blendCoroutine = StartCoroutine(ResetAllCoroutine());
|
||||
}
|
||||
|
||||
private IEnumerator BlendCoroutine(Volume target, float targetWeight)
|
||||
{
|
||||
float elapsed = 0f;
|
||||
float[] startWeights = new float[_managedVolumes.Length];
|
||||
for (int i = 0; i < _managedVolumes.Length; i++)
|
||||
startWeights[i] = _managedVolumes[i] != null ? _managedVolumes[i].weight : 0f;
|
||||
|
||||
while (elapsed < _blendDuration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
float t = Mathf.Clamp01(elapsed / _blendDuration);
|
||||
for (int i = 0; i < _managedVolumes.Length; i++)
|
||||
{
|
||||
if (_managedVolumes[i] == null) continue;
|
||||
float dest = _managedVolumes[i] == target ? targetWeight : 0f;
|
||||
_managedVolumes[i].weight = Mathf.Lerp(startWeights[i], dest, t);
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator ResetAllCoroutine()
|
||||
{
|
||||
float elapsed = 0f;
|
||||
float[] startWeights = new float[_managedVolumes.Length];
|
||||
for (int i = 0; i < _managedVolumes.Length; i++)
|
||||
startWeights[i] = _managedVolumes[i] != null ? _managedVolumes[i].weight : 0f;
|
||||
|
||||
while (elapsed < _blendDuration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
float t = Mathf.Clamp01(elapsed / _blendDuration);
|
||||
for (int i = 0; i < _managedVolumes.Length; i++)
|
||||
{
|
||||
if (_managedVolumes[i] == null) continue;
|
||||
_managedVolumes[i].weight = Mathf.Lerp(startWeights[i], 0f, t);
|
||||
}
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **⚠️ 实现说明**:
|
||||
> - 使用 `Time.unscaledDeltaTime`,暂停期间 blend 不受 `Time.timeScale` 影响
|
||||
> - 水下 Volume(`_underwaterVolume`)由 `World.Liquid` 模块中的 `UnderwaterPostProcessingController` 独立管理,不在此组件中
|
||||
> - 不使用 DOTween;不使用 Lambda 订阅(Lambda 无法从事件中移除)
|
||||
|
||||
### Volume 结构与 Profile 参数
|
||||
|
||||
```
|
||||
Persistent 场景 [PostProcess]:
|
||||
├── Volume_Default Priority=0 Weight=1.0(始终生效基础 Profile)
|
||||
├── Volume_Underwater Priority=10 Weight=0(进水时 blend 到 1.0)
|
||||
├── Volume_Underwater Priority=10 Weight=0(由 UnderwaterPostProcessingController 管理)
|
||||
├── Volume_BossArena Priority=10 Weight=0(Boss 战开始时 blend 到 1.0)
|
||||
├── Volume_Death Priority=20 Weight=0(玩家死亡时 blend 到 1.0)
|
||||
└── Volume_Victory Priority=10 Weight=0(Boss 击败时 blend 到 1.0)
|
||||
```
|
||||
|
||||
| Volume | Bloom | Color Grading | Vignette | Chromatic Aberration |
|
||||
|--------|-------|--------------|----------|---------------------|
|
||||
| Default | Intensity 0.3 | 正常 | 0.2 | 关闭 |
|
||||
| Underwater | 0.1 | 青绿 Filter -0.3 | 0.45 | 0.4 |
|
||||
| BossArena | 0.5 | 饱和度 +20% | 0.35 | 0.15 |
|
||||
| Death | 0 | 去饱和度 -80% | 0.7(黑色)| 0.8 |
|
||||
| Victory | 0.8(白色)| 亮度 +0.4 | 0 | 0 |
|
||||
|
||||
> **DOTween 规范**:所有 `DOTween.To()` 必须链式调用 `.SetAutoKill(true).SetLink(gameObject)`;禁止使用 `DOTween.KillAll()`。
|
||||
|
||||
---
|
||||
|
||||
## 12. RegionLightController
|
||||
@@ -676,28 +751,64 @@ namespace BaseGames.VFX
|
||||
/// <summary>
|
||||
/// 区域进出时平滑切换 Global Light 2D 颜色和强度。
|
||||
/// 挂在 Persistent 场景 [Lighting] GameObject 上,监听 OnRegionEntered 事件频道。
|
||||
/// 使用 Coroutine 实现平滑过渡,不依赖 DOTween。
|
||||
/// </summary>
|
||||
public class RegionLightController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] Light2D _globalLight;
|
||||
[SerializeField] RegionLightCatalogSO _catalog; // RegionId → 颜色 + 强度
|
||||
[SerializeField] StringEventChannelSO _onRegionEntered;
|
||||
[SerializeField] float _transitionDuration = 1.5f;
|
||||
[SerializeField] private Light2D _globalLight;
|
||||
[SerializeField] private RegionLightCatalogSO _catalog;
|
||||
[SerializeField] private StringEventChannelSO _onRegionEntered;
|
||||
[SerializeField] private float _transitionDuration = 1.5f;
|
||||
|
||||
private void OnEnable() => _onRegionEntered.OnEventRaised += OnRegionEntered;
|
||||
private void OnDisable() => _onRegionEntered.OnEventRaised -= OnRegionEntered;
|
||||
private Coroutine _colorCoroutine;
|
||||
private Coroutine _intensityCoroutine;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onRegionEntered != null)
|
||||
_onRegionEntered.OnEventRaised += OnRegionEntered;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onRegionEntered != null)
|
||||
_onRegionEntered.OnEventRaised -= OnRegionEntered;
|
||||
}
|
||||
|
||||
private void OnRegionEntered(string regionId)
|
||||
{
|
||||
if (_catalog == null || _globalLight == null) return;
|
||||
if (!_catalog.TryGet(regionId, out var config)) return;
|
||||
DOTween.To(() => _globalLight.color,
|
||||
x => _globalLight.color = x,
|
||||
config.Color, _transitionDuration)
|
||||
.SetAutoKill(true).SetLink(gameObject);
|
||||
DOTween.To(() => _globalLight.intensity,
|
||||
x => _globalLight.intensity = x,
|
||||
config.Intensity, _transitionDuration)
|
||||
.SetAutoKill(true).SetLink(gameObject);
|
||||
|
||||
if (_colorCoroutine != null) StopCoroutine(_colorCoroutine);
|
||||
if (_intensityCoroutine != null) StopCoroutine(_intensityCoroutine);
|
||||
|
||||
_colorCoroutine = StartCoroutine(TweenColor(_globalLight.color, config.Color, _transitionDuration));
|
||||
_intensityCoroutine = StartCoroutine(TweenIntensity(_globalLight.intensity, config.Intensity, _transitionDuration));
|
||||
}
|
||||
|
||||
private IEnumerator TweenColor(Color from, Color to, float duration)
|
||||
{
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
_globalLight.color = Color.Lerp(from, to, Mathf.Clamp01(elapsed / duration));
|
||||
yield return null;
|
||||
}
|
||||
_globalLight.color = to;
|
||||
}
|
||||
|
||||
private IEnumerator TweenIntensity(float from, float to, float duration)
|
||||
{
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
_globalLight.intensity = Mathf.Lerp(from, to, Mathf.Clamp01(elapsed / duration));
|
||||
yield return null;
|
||||
}
|
||||
_globalLight.intensity = to;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,13 +823,16 @@ namespace BaseGames.VFX
|
||||
[Range(0f, 1f)] public float Intensity;
|
||||
}
|
||||
|
||||
[SerializeField] RegionLightConfig[] _entries;
|
||||
[SerializeField] private RegionLightConfig[] _entries;
|
||||
|
||||
public bool TryGet(string regionId, out RegionLightConfig cfg)
|
||||
{
|
||||
foreach (var e in _entries)
|
||||
if (_entries != null)
|
||||
{
|
||||
if (e.regionId == regionId) { cfg = e; return true; }
|
||||
foreach (var e in _entries)
|
||||
{
|
||||
if (e.regionId == regionId) { cfg = e; return true; }
|
||||
}
|
||||
}
|
||||
cfg = default;
|
||||
return false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 20 · 护盾模块(Shield Module)
|
||||
|
||||
> **命名空间** `BaseGames.Player.Shield`
|
||||
> **程序集** `BaseGames.Player`(并入玩家程序集)
|
||||
> **命名空间** `BaseGames.Combat`
|
||||
> **程序集** `BaseGames.Combat`(`Assets/Scripts/Combat/`)
|
||||
> **依赖** `BaseGames.Core.Events` · `BaseGames.Combat`(DamageInfo · HurtBox)· `BaseGames.UI`(HUDController)
|
||||
> **Design 来源** [30_ShieldMechanicsSystem](../Design/30_ShieldMechanicsSystem.md)
|
||||
|
||||
@@ -86,7 +86,7 @@ _hurtBox.SetShieldable(_shieldComponent);
|
||||
## 3. ShieldConfigSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Player.Shield
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Player/ShieldConfig")]
|
||||
public class ShieldConfigSO : ScriptableObject
|
||||
@@ -122,20 +122,19 @@ namespace BaseGames.Player.Shield
|
||||
## 4. ShieldComponent
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Player.Shield
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在 PlayerController 子节点 [Shield] 上。
|
||||
/// 在 HurtBox 和 IDamageable(PlayerStats)之间担当拦截层。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-40)]
|
||||
public class ShieldComponent : MonoBehaviour, IShieldable
|
||||
{
|
||||
// ── Inspector ───────────────────────────────────────
|
||||
[SerializeField] ShieldConfigSO _config;
|
||||
[SerializeField] IntEventChannelSO _onShieldHPChanged; // 广播当前耐久整数
|
||||
[SerializeField] VoidEventChannelSO _onShieldBroken;
|
||||
[SerializeField] VoidEventChannelSO _onShieldRestored;
|
||||
[SerializeField] IntEventChannelSO _onShieldHPChanged; // 广播当前耐久整数
|
||||
[SerializeField] VoidEventChannelSO _onShieldBrokenChannel;
|
||||
[SerializeField] VoidEventChannelSO _onShieldRestoredChannel;
|
||||
[SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;
|
||||
|
||||
// ── Runtime State ────────────────────────────────────
|
||||
@@ -208,7 +207,7 @@ namespace BaseGames.Player.Shield
|
||||
_currentShieldHP = 0;
|
||||
_isBroken = true;
|
||||
_brokenTimer = 0f;
|
||||
_onShieldBroken.Raise();
|
||||
_onShieldBrokenChannel.Raise();
|
||||
}
|
||||
|
||||
_onShieldHPChanged.Raise(_currentShieldHP); // 更新 ShieldBarUI
|
||||
@@ -223,7 +222,7 @@ namespace BaseGames.Player.Shield
|
||||
_currentShieldHP = _config.MaxShieldHP;
|
||||
_isBroken = false;
|
||||
_brokenTimer = 0f;
|
||||
_onShieldRestored.Raise();
|
||||
_onShieldRestoredChannel.Raise();
|
||||
}
|
||||
|
||||
/// <summary>存档加载时恢复护盾状态。由 PlayerController.LoadFromSaveData() 调用。</summary>
|
||||
@@ -255,7 +254,7 @@ namespace BaseGames.Player.Shield
|
||||
## 5. IShieldable 接口
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Player.Shield
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 可拥有护盾的实体接口。HurtBox 持有此接口引用,在受击时优先检查护盾。
|
||||
@@ -301,21 +300,21 @@ public class ShieldBarUI : MonoBehaviour
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] IntEventChannelSO _onShieldHPChanged; // 订阅耐久变化
|
||||
[SerializeField] VoidEventChannelSO _onShieldBroken;
|
||||
[SerializeField] VoidEventChannelSO _onShieldRestored;
|
||||
[SerializeField] VoidEventChannelSO _onShieldBrokenChannel;
|
||||
[SerializeField] VoidEventChannelSO _onShieldRestoredChannel;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
_onShieldHPChanged.OnEventRaised += RefreshFill;
|
||||
_onShieldBroken.OnEventRaised += ShowBroken;
|
||||
_onShieldRestored.OnEventRaised += HideBroken;
|
||||
_onShieldHPChanged.OnEventRaised += RefreshFill;
|
||||
_onShieldBrokenChannel.OnEventRaised += ShowBroken;
|
||||
_onShieldRestoredChannel.OnEventRaised += HideBroken;
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
_onShieldHPChanged.OnEventRaised -= RefreshFill;
|
||||
_onShieldBroken.OnEventRaised -= ShowBroken;
|
||||
_onShieldRestored.OnEventRaised -= HideBroken;
|
||||
_onShieldHPChanged.OnEventRaised -= RefreshFill;
|
||||
_onShieldBrokenChannel.OnEventRaised -= ShowBroken;
|
||||
_onShieldRestoredChannel.OnEventRaised -= HideBroken;
|
||||
}
|
||||
|
||||
private void RefreshFill(int currentHP)
|
||||
|
||||
@@ -202,16 +202,21 @@ namespace BaseGames.Quest
|
||||
public bool unlocksAbility = false; // ⚠️ AbilityType 无 None 值,用 bool 标识是否解锁能力(架构 09 §1)
|
||||
public AbilityType unlockedAbility; // 仅当 unlocksAbility == true 时有效
|
||||
|
||||
[Header("物品发放事件")]
|
||||
[Tooltip("EVT_CollectiblePickup:向 QuestManager/EquipmentManager 广播 itemId")]
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
|
||||
|
||||
/// <summary>将奖励应用到游戏状态(由 QuestManager.CompleteQuest 调用)。</summary>
|
||||
public void Apply(PlayerStats player)
|
||||
{
|
||||
if (geo > 0) player.AddGeo(geo);
|
||||
if (soulBonus > 0) player.ExtendSoulMax(soulBonus);
|
||||
if (soulBonus > 0) player.AddSoulPower(soulBonus);
|
||||
if (unlocksAbility) // ⚠️ 替代 AbilityType.None 判断
|
||||
player.UnlockAbility(unlockedAbility);
|
||||
// 物品/护符通过 InventoryManager 发放
|
||||
foreach (var id in itemIds)
|
||||
InventoryManager.Instance.AddItem(id);
|
||||
// 物品/护符通过 EVT_CollectiblePickup 事件频道广播(InventoryManager 不存在于本项目)
|
||||
if (itemIds != null && _onCollectiblePickup != null)
|
||||
foreach (var id in itemIds)
|
||||
_onCollectiblePickup.Raise(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| [06](./06_CombatModule.md) | 战斗模块 | DamageInfo、HitBox、HurtBox、Parry、Projectile、StatusEffects | 04、05、13、30、54 |
|
||||
| [07](./07_EnemyModule.md) | 敌人模块 | EnemyBase、EnemyStats、AI Tasks、Navigation、Boss Patterns、Telegraph | 06、19、47、48 |
|
||||
| [08](./08_WorldModule.md) | 世界模块 | 场景结构、RoomTransition、SavePoint、Collectible、HazardZone、WorldStateRegistry | 08、34、49 |
|
||||
| [09](./09_ProgressionModule.md) | 进度模块 | AbilityGate、Equipment/Charms、Skills/Spells、Quest、Challenge | 14、17、21、37、38、39 |
|
||||
| [09](./09_ProgressionModule.md) | 进度模块 | AbilityType、AbilityGate、Equipment/Charms、Skills/Spells、Quest、Challenge | 14、17、21、37、38、39 |
|
||||
| [10](./10_UIModule.md) | UI 模块 | UIManager、HUD、PauseMenu、DeathScreen、Panel 层级、UI Toolkit 规范 | 10、53_HUDSpec 参考 74 |
|
||||
| [11](./11_AudioModule.md) | 音频模块 | AudioManager、BGMController、SFX Pool、AudioZone、FMOD 集成 | 12、63 |
|
||||
| [12](./12_SaveModule.md) | 存档模块 | SaveData schema(C# 完整结构)、SaveManager、ISaveStorage、SaveMigrator、Checksum | 31 |
|
||||
@@ -27,12 +27,12 @@
|
||||
| [15](./15_MapShopModule.md) | 地图与商店模块 | MapManager、RoomReveal、FastTravel、ShopController、ShopInventorySO | 16、28 |
|
||||
| [16](./16_SupportingModules.md) | 支撑模块 | Localization、Platform Integration、Analytics、Achievement、Tutorial、Debug | 22、32、42、45、46、55 |
|
||||
| [17](./17_CameraModule.md) | 摄像机模块 | CameraStateController、Cinemachine 虚拟相机、Zone-based 切换、CameraBounds | 03、26 |
|
||||
| [18](./18_VFXFeedbackModule.md) | VFX 与反馈模块 | FeedbackPresetSO、IFeedbackPlayer、HitFxPool、ScreenShake、FeedbackEventChannelSO | 04、12 |
|
||||
| [18](./18_VFXFeedbackModule.md) | VFX 与反馈模块 | FeedbackConfigSO、VFXPool、HitFXSpawner、HurtFlashController、PostProcessManager | 04、12 |
|
||||
| [19](./19_DifficultyModule.md) | 难度模块 | DifficultySettingsSO、DifficultyManager、IScalable、SteelSoul 模式 | 11 |
|
||||
| [20](./20_ShieldModule.md) | 护盾模块 | ShieldComponent、ShieldConfigSO、IShieldable、护盾破碎/恢复管道 | 05、13 |
|
||||
| [21](./21_LiquidPuzzleModule.md) | 液体谜题模块 | LiquidSimulator、LiquidTile、LiquidTriggerZone、SwimState、HazardLiquid | 08、41 |
|
||||
| [21](./21_LiquidPuzzleModule.md) | 液体谜题模块 | LiquidZone、LiquidPhysicsConfigSO、SwimState、PuzzleSwitch/PuzzleReceiver、WorldMarker | 08、41 |
|
||||
| [22](./22_QuestChallengeModule.md) | 任务与挑战模块 | QuestManager、QuestSO、QuestObjectiveSO、ChallengeRoom、QuestEventChannelSO | 37、38、39 |
|
||||
| [23](./23_BossSkillModule.md) | Boss 技能模块 | BossSkillSO、BossSkillExecutor、SkillSequenceSO、VulnerabilityWindow、BossPhaseController | 19、47、48 |
|
||||
| [23](./23_BossSkillModule.md) | Boss 技能模块 | BossSkillSO、BossSkillExecutor、SkillSequenceSO、VulnerabilityWindow、WeakPointSystem | 19、47、48 |
|
||||
| [24](./24_AnimEventModule.md) | 动画事件模块 | PlayerAnimationEvents、EnemyAnimationEvents、AnimEventBridge、Animancer 事件回调 | 03 |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user