chore: initial commit
This commit is contained in:
181
Docs/Architecture/00_CoverageIndex.md
Normal file
181
Docs/Architecture/00_CoverageIndex.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# 架构文档索引 / Design 覆盖矩阵
|
||||
|
||||
> **建立日期**:2026-04-29
|
||||
> **对比对象**:`Docs/Design/`(74 份设计文档)←→ `Docs/Architecture/`(24 份架构文档)
|
||||
> **用途**:查询某 Design 文档对应哪份 Architecture 文档及章节;开发 code review checklist
|
||||
|
||||
---
|
||||
|
||||
## ✅ 当前状态:**架构完整度 100%**
|
||||
|
||||
---
|
||||
|
||||
## 一、总体评分
|
||||
|
||||
| 维度 | 状态 |
|
||||
|------|------|
|
||||
| 核心游戏循环(移动/战斗/存档) | ✅ 完整 |
|
||||
| 敌人与 Boss 基础框架 | ✅ 完整(含 BossSkillModule §23) |
|
||||
| UI / 音频 / 事件系统 | ✅ 完整 |
|
||||
| 世界交互与地图 | ✅ 完整(含 MovingPlatform/CrumblePlatform/SkillInteractable §11-15) |
|
||||
| 进度/装备/技能 | ✅ 完整 |
|
||||
| 叙事与对话 | ✅ 完整(含 CutsceneSO/CutsceneTrigger) |
|
||||
| 玩家能力扩展系统 | ✅ 完整(Shield §20、SwimState §21、DifficultyModule §19) |
|
||||
| 世界互动机制 | ✅ 完整(Puzzle §21、LootSystem §07、LiquidSwim §21) |
|
||||
| 游戏系统层 | ✅ 完整(Difficulty §19、SpeedrunTimer §16、CrashRecovery §12) |
|
||||
| 视觉反馈层 | ✅ 完整(VFXFeedback §18、CameraModule §17、AnimEvent §24) |
|
||||
|
||||
---
|
||||
|
||||
## 二、逐 Design 文档覆盖矩阵
|
||||
|
||||
> 标记说明:✅ 覆盖 | ⚠️ 部分覆盖 | ❌ 未覆盖 | 📖 非技术文档(艺术/叙事/策划,不需要 Architecture)
|
||||
|
||||
| # | Design 文档 | Architecture 对应 | 状态 | 说明 |
|
||||
|---|------------|-----------------|------|------|
|
||||
| 01 | InputSystem | 04_InputModule | ✅ | 完整(含 RebindPanel/ConflictDetector §6) |
|
||||
| 02 | CameraSystem | **17_CameraModule** | ✅ | CameraStateController/RoomVisibleArea/CameraTriggerZone 全部定义 |
|
||||
| 03 | PlayerSystem | 05_PlayerModule | ✅ | 完整(含 SwimState、ShieldComponent 引用) |
|
||||
| 04 | CombatSystem | 06_CombatModule | ✅ | 完整(含 ProjectileConfigSO/LinearProjectile/ParryableProjectile) |
|
||||
| 05 | ParrySystem | 06_CombatModule §4 | ✅ | 完整 |
|
||||
| 06 | EnemySystem | 07_EnemyModule | ✅ | 完整(含 LootTableSO/LootResolver/LootPickup) |
|
||||
| 07 | FeedbackSystem | **18_VFXFeedbackModule** | ✅ | FeedbackConfigSO/VFXPool/HitFXSpawner 全部定义 |
|
||||
| 08 | WorldSystem | 08_WorldModule | ✅ | 完整(含 §11-15 MovingPlatform/MagicWall/SoftTerrain/PhantomInteractable) |
|
||||
| 09 | EditorExtensions | — | 📖 | 编辑器工具指南,无需独立 Architecture |
|
||||
| 10 | UISystem | 10_UIModule | ✅ | 完整 |
|
||||
| 11 | GameManager | 03_CoreModule | ✅ | 完整(含 DifficultyManager 初始化顺序引用) |
|
||||
| 12 | AudioSystem | 11_AudioModule | ✅ | 完整 |
|
||||
| 13 | ProjectileSystem | 06_CombatModule §5 | ✅ | ProjectileConfigSO、LinearProjectile、ParryableProjectile 已定义 |
|
||||
| 14 | ProgressionSystem | 09_ProgressionModule | ✅ | 完整 |
|
||||
| 15 | DialogueSystem | 14_NarrativeModule | ✅ | 完整(含 CutsceneSO/CutsceneTrigger) |
|
||||
| 16 | MapSystem | 15_MapShopModule §1 | ✅ | 完整 |
|
||||
| 17 | EquipmentSystem | 09_ProgressionModule | ✅ | 完整 |
|
||||
| 18 | CutsceneSystem | 14_NarrativeModule §7 | ✅ | CutsceneTrigger/CutsceneSO 已定义 |
|
||||
| 19 | BossPatternLibrary | **23_BossSkillModule** | ✅ | BossSkillSO/SkillSequenceSO/WeakPointSystem 全部定义 |
|
||||
| 20 | AnimationEventSystem | **24_AnimEventModule** | ✅ | PlayerAnimationEvents/EnemyAnimationEvents/AnimationEventBinder 全部定义 |
|
||||
| 21 | SpellSystem | 09_ProgressionModule | ✅ | 完整 |
|
||||
| 22 | LocalizationSystem | 16_SupportingModules §1 | ✅ | 完整 |
|
||||
| 23 | GameFeelTuningGuide | — | 📖 | 数值调参指南,无需 Architecture |
|
||||
| 24 | GroundDetectionSystem | 05_PlayerModule §3 | ✅ | 合并入 PlayerMovement,GroundDetectionConfigSO 已定义 |
|
||||
| 25 | InputRebindingUI | 04_InputModule §6 | ✅ | RebindPanel/ConflictDetector/RebindPersistence 已定义 |
|
||||
| 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) |
|
||||
| 32 | AchievementSystem | 16_SupportingModules §2 | ✅ | 完整 |
|
||||
| 33 | EnemyLootSystem | 07_EnemyModule §14 | ✅ | LootTableSO/LootResolver/LootPickup 已定义;EnemyBase.Die() 已集成 |
|
||||
| 34 | EventChainSystem | 14_NarrativeModule §5-6 | ✅ | 完整 |
|
||||
| 35 | PuzzleArchitecture | **21_LiquidPuzzleModule** Part B | ✅ | ISwitchable/IActivatable/PuzzleSwitch/PuzzleReceiver/PuzzleWire 全部定义 |
|
||||
| 36 | NavigationHintSystem | **21_LiquidPuzzleModule** §NavHint | ✅ | WorldMarker/BreadcrumbTracker 已定义 |
|
||||
| 37 | ToolSystem | 09_ProgressionModule §7.5 | ✅ | ToolSlotManager/ToolHUD 已补充 |
|
||||
| 38 | QuestSystem | **22_QuestChallengeModule** Part A | ✅ | QuestSO/QuestManager/QuestGiver/RewardSO 全部定义 |
|
||||
| 39 | ChallengeRoomSystem | **22_QuestChallengeModule** Part B | ✅ | ChallengeRoomSO/ChallengeRoomManager 全部定义 |
|
||||
| 40 | LiquidSwimSystem | **21_LiquidPuzzleModule** Part A | ✅ | LiquidZone/SwimState/LiquidPhysicsConfigSO 全部定义;FSM 已补 SwimState |
|
||||
| 41 | VFXArchitecture | **18_VFXFeedbackModule** | ✅ | VFXPool/HitFXSpawner/HurtFlashController/VFXCatalogSO/FeedbackConfigSO 全部定义 |
|
||||
| 42 | DebugCheatSystem | 16_SupportingModules §4 | ✅ | 完整 |
|
||||
| 43 | AddressablesWorkflow | 13_AssetPoolModule | ✅ | 完整 |
|
||||
| 44 | LevelDesignGuide | — | 📖 | 关卡设计规范,无需 Architecture |
|
||||
| 45 | TutorialSystem | **21_LiquidPuzzleModule** §Tutorial | ✅ | TutorialManager/ContextualHintTrigger 已定义 |
|
||||
| 46 | PlatformIntegration | 16_SupportingModules §3 | ✅ | IPlatformService/SteamPlatformService/NullPlatformService(ServiceLocator 模式)已定义 |
|
||||
| 47 | BossSkillSystem | **23_BossSkillModule** | ✅ | BossSkillSO/SkillSequenceSO/BossSkillExecutor/WeakPointSystem 全部定义 |
|
||||
| 48 | EnemyRoster | — | 📖 | 敌人名单(策划),无需 Architecture |
|
||||
| 49 | AntiSoftlockSystem | 16_SupportingModules §5 | ✅ | 完整 |
|
||||
| 50 | NarrativeDesignSystem | — | 📖 | 叙事结构设计,无需 Architecture |
|
||||
| 51 | EconomyBalanceDesign | — | 📖 | 经济平衡设计,无需 Architecture |
|
||||
| 52 | CompletionEndingDesign | — | 📖 | 结局设计,无需 Architecture |
|
||||
| 53 | WeaponSystem | 05_PlayerModule §5-7 | ✅ | WeaponSO(per-attack ClipTransition + DamageSourceSO + WeaponVFXConfig)/ WeaponManager(FormSO 事件驱动 + override 词典)/ FormSO.defaultWeapon / WeaponOverrideEffect 均已定义 |
|
||||
| 54 | PoiseSystem | 06_CombatModule **§13** | ✅ | 已重写为等级比较系统(PoiseLevel/BreakLevel 独立枚举,玩家+敌人均支持,IPoiseSource/PoiseWindowConfig/PoiseOverrideTableSO)|
|
||||
| 55 | AnalyticsTelemetrySystem | 16_SupportingModules **§8** | ✅ | AnalyticsManager/IAnalyticsBackend 已定义 |
|
||||
| 56 | CrashRecoverySystem | 12_SaveModule §9 | ✅ | EmergencySaveService/CrashReporter 已定义 |
|
||||
| 57 | PhysicsLayerMatrix | 06_CombatModule **§12** | ✅ | 完整(含 32 层 Layer ID 表、Ghost/MagicWall/PhantomBody 补充矩阵)|
|
||||
| 58 | SpeedrunModeSystem | 16_SupportingModules **§9** | ✅ | SpeedrunTimer IGT/RTA 已定义 |
|
||||
| 59 | QATestingFramework | — | 📖 | QA 流程文档 |
|
||||
| 60-61 | (Lore/Art 等) | — | 📖 | 非技术文档 |
|
||||
| 62 | Accessibility_System | 16_SupportingModules **§6** | ✅ | AccessibilitySettingsSO(视觉/运动/字幕/输入辅助/音频)/ AccessibilityManager / ColorBlindFilter(URP ScriptableRendererFeature)已完整定义 |
|
||||
| 63-73 | (Music/UI-Flow 等) | — | 📖 | 非技术文档 |
|
||||
| 74 | UIScreenFlowDocument | 10_UIModule | ✅ | 流程覆盖 |
|
||||
|
||||
---
|
||||
|
||||
## 三、设计理念对齐检查(当前状态)
|
||||
|
||||
### ✅ 全部对齐
|
||||
|
||||
| 设计原则 | 状态 |
|
||||
|---------|------|
|
||||
| 零耦合:禁止 `FindObjectOfType`、禁止直接 `GetComponent` 跨 GO | ✅ 所有 Module 使用 SO 事件频道 |
|
||||
| 数据驱动:所有数值均在 SO 中配置 | ✅ 每个系统均有对应 ConfigSO |
|
||||
| 对象池:所有运行时 Prefab 通过 `ObjectPoolManager` | ✅ 13_AssetPoolModule 完整定义 |
|
||||
| Addressables:禁止 `Resources.Load`,使用 `AddressKeys` | ✅ 13_AssetPoolModule 覆盖 |
|
||||
| Animancer 双层动画 | ✅ 05_PlayerModule §14 |
|
||||
| 伤害管道:HurtBox → Shield → IDamageable | ✅ 20_ShieldModule 已修正管道 |
|
||||
| 难度系数穿透:EnemyStats/PlayerStats/Shop 均订阅 EVT_DifficultyChanged | ✅ 19_DifficultyModule 已定义 |
|
||||
| FSM 完整:SwimState 覆盖液体入水 | ✅ 21_LiquidPuzzleModule 已补充 |
|
||||
| VFX 落地:HitFxType 有消费者 HitFXSpawner | ✅ 18_VFXFeedbackModule 已定义 |
|
||||
| 平台抽象:IPlatformService ServiceLocator | ✅ 16_SupportingModules §3 已重构 |
|
||||
|
||||
---
|
||||
|
||||
## 四、架构文档新增历史
|
||||
|
||||
| 文件 | 覆盖 Design 文档 | 内容摘要 |
|
||||
|------|----------------|---------|
|
||||
| [17_CameraModule.md](17_CameraModule.md) | 02 | CameraStateController、RoomVisibleArea、CameraTriggerZone、CameraConfigSO |
|
||||
| [18_VFXFeedbackModule.md](18_VFXFeedbackModule.md) | 41、07 | VFXPool、HitFXSpawner、HurtFlashController、VFXCatalogSO、FeedbackConfigSO |
|
||||
| [19_DifficultyModule.md](19_DifficultyModule.md) | 29 | DifficultyLevel enum、DifficultyScalerSO、DifficultyManager |
|
||||
| [20_ShieldModule.md](20_ShieldModule.md) | 30 | ShieldComponent、ShieldConfigSO、IShieldable、伤害管道修正 |
|
||||
| [21_LiquidPuzzleModule.md](21_LiquidPuzzleModule.md) | 40、35、36、45 | LiquidZone、SwimState、PuzzleSwitch/Receiver/Wire、WorldMarker、TutorialManager |
|
||||
| [22_QuestChallengeModule.md](22_QuestChallengeModule.md) | 38、39 | QuestSO、QuestManager、QuestGiver、ChallengeRoomSO、ChallengeRoomManager |
|
||||
| [23_BossSkillModule.md](23_BossSkillModule.md) | 47、19 | BossSkillSO、SkillSequenceSO、BossSkillExecutor、WeakPointSystem |
|
||||
| [24_AnimEventModule.md](24_AnimEventModule.md) | 20 | PlayerAnimationEvents、EnemyAnimationEvents、AnimationEventBinder |
|
||||
|
||||
---
|
||||
|
||||
## 五、现有文档已应用的 Patch(历史记录)
|
||||
|
||||
| 文件 | 应用内容 | 状态 |
|
||||
|------|---------|------|
|
||||
| [05_PlayerModule.md](05_PlayerModule.md) | SwimState 加入 FSM 列表;PlayerController 添加 ShieldComponent 引用 | ✅ 已应用 |
|
||||
| [06_CombatModule.md](06_CombatModule.md) | ProjectileConfigSO、LinearProjectile、ParryableProjectile;HurtBox 护盾检查步骤 | ✅ 已应用 |
|
||||
| [07_EnemyModule.md](07_EnemyModule.md) | EnemyBase._lootTable 字段;Die() 调用 LootResolver;§14 LootTableSO/LootResolver/LootPickup | ✅ 已应用 |
|
||||
| [09_ProgressionModule.md](09_ProgressionModule.md) | §7.5 ToolSlotManager(两槽 + 冷却)+ ToolHUD | ✅ 已应用 |
|
||||
| [12_SaveModule.md](12_SaveModule.md) | §9 EmergencySaveService + CrashReporter | ✅ 已应用 |
|
||||
| [04_InputModule.md](04_InputModule.md) | §6 RebindPanel、ConflictDetector、RebindPersistence | ✅ 已应用 |
|
||||
| [14_NarrativeModule.md](14_NarrativeModule.md) | §7.5 CutsceneSO + CutsceneTrigger | ✅ 已应用 |
|
||||
| [16_SupportingModules.md](16_SupportingModules.md) | §3 IPlatformService 补全(IncrementStat/GetStat/IsAchievementUnlocked/RichPresence/Rumble/Lifecycle);§6 AccessibilityManager 全面重写(AccessibilitySettingsSO + ColorBlindFilter);**§8** AnalyticsManager;**§9** SpeedrunTimer | ✅ 已应用 |
|
||||
| [05_PlayerModule.md](05_PlayerModule.md) | §5 PlayerCombat 更新(HitBox 挂角色/RefreshWeaponData/SetComboSegmentSource);§6 FormController 改 CurrentForm→FormSO + C# event;§7 WeaponManager 全面重写(FormSO 驱动/override 词典);WeaponSO 改为纯数据 SO;FormSO.defaultWeapon;WeaponOverrideEffect;§18 FormConfigSO 改为 forms:FormSO[];AttackState 更新连击读取逻辑 | ✅ 已应用 |
|
||||
| [06_CombatModule.md](06_CombatModule.md) | §12 Layer 矩阵重写(PlayerHitBox/EnemyHitBox 正确命名;完整 32 层 Layer ID 表;Ghost/MagicWall/PhantomBody 补充矩阵) | ✅ 已应用 |
|
||||
| [03_CoreModule.md](03_CoreModule.md) | §10 初始化序列注释 DifficultyManager 加载顺序与事件广播说明 | ✅ 已应用 |
|
||||
| [02_EventSystem.md](02_EventSystem.md) | `EVT_AbilityUnlocked` 类型从 `AbilityTypeEventChannelSO` 改为 `StringEventChannelSO`(payload 改为 abilityId string);订阅方补充 `AbilityGate` | ✅ 已应用 |
|
||||
| [09_ProgressionModule.md](09_ProgressionModule.md) | §2 AbilityGate 重写(StringEventChannelSO、_hintUI、_saveData 注入、OnEnable/OnDisable 订阅、handler 改为 string 参数);§4 EquipmentContext 改 class→struct、字段对齐 Design/17;§5 内置魅力效果全面更新(StatModifierEffect/AttackSpeedEffect/OnHitEffect API 对齐 Design;新增 SoulSpellEffect、SkillSlotOverrideEffect、WeaponOverrideEffect);§6 EquipmentManager 改 TryEquipCharm/UnequipCharm、返回 string|null、_ctx 在 Awake 初始化、添加 CharmEventChannelSO 频道;§15 事件表 EVT_AbilityUnlocked 类型同步 | ✅ 已应用 |
|
||||
| [10_UIModule.md](10_UIModule.md) | §4 BossHPBar 事件频道从 StringEventChannelSO _onBossFightStarted + BoolEventChannelSO _onBossFightEnded + FloatEventChannelSO _onBossHPRatioChanged 改为 BoolEventChannelSO _onBossFightToggled + IntEventChannelSO _onBossHPChanged + StringEventChannelSO _onBossNameSet + IntEventChannelSO _onBossHPMaxSet;同步底部事件表;EVT_AbilityUnlocked 类型同步 | ✅ 已应用 |
|
||||
| [11_AudioModule.md](11_AudioModule.md) | §3 BGMController 字段 _onAudioZoneEntered→_onRegionEntered、_currentZoneId→_currentRegion、方法 SwitchZoneBGM→OnRegionEntered;§3.5 状态机图同步;§4 AudioZone _onAudioZoneEntered→_onRegionEntered;事件表 EVT_AudioZoneEntered→EVT_RegionEntered | ✅ 已应用 |
|
||||
| [14_NarrativeModule.md](14_NarrativeModule.md) | 头部命名空间/路径新增 `BaseGames.EventChain`;**§5 EventChain 完全重写**:Step-based(IEventChainStep/ECS_*/EventChainContext)→ Condition+Action SO 驱动(7 种 ChainCondition + 10 种 ChainAction,含 BossDefeatedCondition/FlagSetCondition/AbilityUnlockedCondition 等);**§6 EventChainManager 完全重写**:路径从 Cutscene/ 改为 EventChain/,字段改为 `EventChainSO[] _chains` + 5 个 StringEventChannelSO,中继 C# 事件供 ChainCondition.Register(),EvaluateAll()+ExecuteChain() 自动评估执行,`_completedChains HashSet` 持久化,`OnChainCompleted` 事件广播;**§6.1** SaveData 集成说明 | ✅ 已应用 |
|
||||
| [02_EventSystem.md](02_EventSystem.md) | 战斗系统表新增 `EVT_BossFightToggled`(BoolEventChannelSO)、`EVT_BossHPChanged`(IntEventChannelSO)、`EVT_BossNameSet`(StringEventChannelSO)、`EVT_BossHPMaxSet`(IntEventChannelSO)四条 BossHPBar 所需频道;音频系统表 `EVT_AudioZoneEntered`→`EVT_RegionEntered`(对齐 Architecture/11);新增 **§ 事件链/EventChain** 区段(EVT_ChainCompleted / EVT_DoorOpened / EVT_FlagChanged) | ✅ 已应用 |
|
||||
| [09_ProgressionModule.md](09_ProgressionModule.md) | AbilityType 枚举新增 `AerialDash`(空中冲刺,默认锁定)和 `InvincibleDash`(冲刺全程无敌);§10 SkillModifierRegistry 新增 `GetEffectiveParams(FormSkillSO)→EffectiveSkillParams` 主 API 及 `EffectiveSkillParams` 结构体(含 effectiveCost/effectiveCooldown/damageMult/rangeMult/effectiveFeedback/effectiveAnimation 字段),保留 `GetModifiedValue()` 向后兼容 | ✅ 已应用 |
|
||||
| [11_AudioModule.md](11_AudioModule.md) | **§9** 脚步声材质分层系统(FootstepMaterial 枚举、FootstepAudioConfigSO、FootstepMaterialMarker、播放时机说明);**§10** 水下音效处理(UnderwaterAudioController、Underwater Snapshot DSP 配置、水下专属 SFX 对照表) | ✅ 已应用 |
|
||||
| [12_SaveModule.md](12_SaveModule.md) | **§10** SaveValidator 静态类(Result 结构体、HP/Geo/Scene/Version 四项校验规则,SaveAsync 前调用);**§11** IDlcSaveExtension 接口(DlcId/Serialize/Deserialize/MigrateIfNeeded/OnNgPlusReset 五个成员)及 SaveManager 集成(RegisterDlcExtension、SaveAsync/LoadAsync 循环调用) | ✅ 已应用 |
|
||||
| [14_NarrativeModule.md](14_NarrativeModule.md) | **§7.6** SignalEmitterClip 自定义 PlayableAsset(零耦合 Timeline→SO 事件桥接):SignalEmitterClip(PlayableAsset/ITimelineClipAsset)+ SignalEmitterBehaviour(PlayableBehaviour,含 _fired 防重复机制)及使用场景示例 | ✅ 已应用 |
|
||||
| [16_SupportingModules.md](16_SupportingModules.md) | **§1.1** LanguageManagerSO(SO 单例,PlayerPrefs 持久化,SetLocale/GetCurrentLocaleCode/LoadSavedLocale 三方法);与静态 LocalizationManager 职责对比说明(文本查询 vs 语言切换+持久化) | ✅ 已应用 |
|
||||
| [12_SaveModule.md](12_SaveModule.md) | EquipmentSaveData 移除重复工具字段(ToolSlot0/ToolSlot1/OwnedToolIds/ToolStates)——这些字段仅属于 ToolsSaveData(顶层 SaveData.Tools);添加注释说明归属 | ✅ 已应用 |
|
||||
| [09_ProgressionModule.md](09_ProgressionModule.md) | ToolSlotManager.OnSave() 数据写入路径从 `data.Equipment.ToolSlotX` 修正为 `data.Tools.ToolSlotX`;OnLoad 注释同步为"从 data.Tools 恢复" | ✅ 已应用 |
|
||||
| [23_BossSkillModule.md](23_BossSkillModule.md) | BossSkillSO 补全 `attackPatterns: AttackPatternSO[]`、`counterResponses: PlayerCounterResponse[]`、`arenaEvents: ArenaEventTrigger[]`、`resourceCost: BossResourceCost`、`buildsRage: bool`、`poiseWindow: PoiseWindowConfig` 六个缺失字段;新增 §4.1 PlayerCounterResponse + CounterType 枚举、§4.2 ArenaEventTrigger/ArenaEventType/ArenaEventParams/ArenaEventData/IArenaInteractable、§4.3 BossResourceCost + BossResourceConfigSO 定义 | ✅ 已应用 |
|
||||
| [16_SupportingModules.md](16_SupportingModules.md) | §2 AchievementManager 完全重写(对齐 Design/32 SO 策略模式):AchievementDef/AchievementDatabaseSO 替换为 AchievementSO + AchievementType/AchievementTier 枚举 + AchievementCondition 抽象基类(RegisterListeners/UnregisterListeners/IsMet)+ 内置 12 种条件类型表 + DefeatedBossCondition 示例 + AchievementManager(_allAchievements: AchievementSO[],中继 C# 事件,EvaluateAll/Unlock)+ AchievementRuntimeState | ✅ 已应用 |
|
||||
| [24_AnimEventModule.md](24_AnimEventModule.md) | `IAnimEventReceiver` 重命名为 `IAnimationEventHandler`;方法签名 `OnAnimationEvent(type, data)` 改为 `HandleEvent(type, payload)`;AnimationEventBinder.Bind() 参数类型、PlayerAnimationEvents/EnemyAnimationEvents 实现声明及方法体内 payload 用法全部同步 | ✅ 已应用 |
|
||||
| [16_SupportingModules.md](16_SupportingModules.md) | §5 AntiSoftlockSystem 后新增 §5.1 RoomEscapeInfoSO(设计时房间逃离 SO,含 EscapeRoute 嵌套类,对齐 Design/49 §3)+ §5.2 HardAbilityGate(AbilityGate 子类,_requirePhysicalValidation + _sequenceBreakRisk,对齐 Design/49 §4.2) | ✅ 已应用 |
|
||||
| [13_AssetPoolModule.md](13_AssetPoolModule.md) | AddressKeys 常量从下划线分隔(`Scene_Persistent`/`Pfx_Enemy_Grunt`/`Label_Enemy` 等)改为驼峰式无下划线(`ScenePersistent`/`PrefabEnemyGrunt`/`LabelEnemy` 等),对齐 Design/43 §8 命名规范;`ScenePersistent` 值修正 `"Persistent"` → `"Scene_Persistent"`;`ObjectPoolManager` 类全部重命名为 `GlobalObjectPool`(类名/Instance/PooledObject.Setup 参数/内部 _pool 字段/文件路径注释均更新) | ✅ 已应用 |
|
||||
| [14_NarrativeModule.md](14_NarrativeModule.md) | §1 IInteractable 命名空间从 `BaseGames.Dialogue` 修正为 `BaseGames.World`,文件路径从 `Dialogue/` 改为 `World/`;新增 §2 InteractionPromptController(提示图标组件,Show/Hide/图标切换);DialogueLineSO 段补加 §3 标题;§4 DialogueManager 重编号(原§3);新增 §5 DialogueUI(ShowLine/SkipTyping/Hide + StringBuilder 打字机零分配实现);§6 InteractableNPC 重编号并添加 `Interact_Internal`/`GetCurrentDialogue` 虚方法;新增 §7 NarrativeNPC(DialogueVersion[],CheckConditions 条件对话)+ DialogueVersion 结构体;新增 §8 WorldStateRegistry(SO 形式,HashSet 存储,SetFlag/HasFlag/LoadFromSave/GetAllFlags);EventChain/EventChainManager 等段落重编号为 §9-12;CutsceneTrigger 修正:改用 `[SerializeField] WorldStateRegistry _worldState` SO 注入,将 `WorldStateRegistry.Instance.IsFlagSet` 替换为 `_worldState.HasFlag`,将 `SetFlag(..., true)` 改为单参数 `SetFlag(...)` | ✅ 已应用 |
|
||||
| [20_ShieldModule.md](20_ShieldModule.md) | `IShieldable.AbsorbDamage` 签名从 `void AbsorbDamage(ref DamageInfo info)` 改为 `int AbsorbDamage(int incomingDamage)`(返回穿透伤害剩余量,对齐 Design/30 §2.3);ShieldComponent 实现同步(移除 ref 参数,return passThrough,注释更新);HurtBox 调用方式更新:`_shieldable.AbsorbDamage(info.Amount)` 取返回值再 TakeDamage | ✅ 已应用 |
|
||||
| [21_LiquidPuzzleModule.md](21_LiquidPuzzleModule.md) | PuzzleWire 中 `WireLogic` 枚举重命名为 `LogicType`(对齐 Design/35);字段 `_logic: WireLogic` 更新为 `_logic: LogicType`;switch case 常量同步 | ✅ 已应用 |
|
||||
| [22_QuestChallengeModule.md](22_QuestChallengeModule.md) | §3 QuestObjectiveSO 从单类+ObjectiveType 枚举重写为多态体系(抽象基类 + TalkToNPCObjective/DefeatEnemyObjective/CollectItemObjective/ReachAreaObjective/UseSkillObjective 五个子类,对齐 Design/38 §3);QuestManager.IsObjectiveComplete 改用 `obj.EvaluateCompletion(state)` 多态调用;§6 QuestGiver 从 `[RequireComponent] MonoBehaviour` 改为 `QuestGiver : InteractableNPC`(继承方式),字段改为 `QuestSO[] _offeredQuests` 数组,通过 `override Interact_Internal`+`override GetCurrentDialogue` 实现任务接受/完成和对话切换(对齐 Design/38 §5) | ✅ 已应用 |
|
||||
| [08_WorldModule.md](08_WorldModule.md) | §7 IInteractable 命名空间从 `BaseGames.Dialogue` 修正为 `BaseGames.World`,文件路径注释从 `Assets/Scripts/Dialogue/IInteractable.cs(位于 Dialogue 命名空间,World 引用它)` 改为 `Assets/Scripts/World/IInteractable.cs`(对齐 Architecture/14 §1 权威定义) | ✅ 已应用 |
|
||||
| [04_InputModule.md](04_InputModule.md) | §4 InputBuffer 从单一 `_bufferTime = 0.15f` 改为三个独立缓冲时长字段(`_jumpBufferDuration=0.15f`/`_attackBufferDuration=0.12f`/`_dashBufferDuration=0.10f`),对齐 Design/01 §4 缓冲时长配置表;注释补充弹反/UseSpring/SpiritSkill 不缓冲说明;OnEnable/OnDisable 订阅改用各自独立时长字段 | ✅ 已应用 |
|
||||
| [06_CombatModule.md](06_CombatModule.md) | §5 HurtBox ReceiveDamage 步骤 4 护盾拦截:移除旧 `_shieldable.AbsorbDamage(ref info)` + `return` 模式,改为 `int passThrough = _shieldable.AbsorbDamage(info.Amount)`,passThrough ≤ 0 时 return,否则将穿透量赋回 `info.Amount` 继续走后续防御减免→TakeDamage 流程(对齐 Architecture/20 IShieldable 新签名) | ✅ 已应用 |
|
||||
| [07_EnemyModule.md](07_EnemyModule.md) | §1 EnemyBase 新增 `public virtual void Knockback(DamageInfo info)` 方法(检查 DamageFlags.NoKnockback,调用 `_movement.ApplyKnockback`,对齐 Design/06 §2 方法表);§6 EnemyStatsSO `[Header("Attack")]` 后新增 `[Range(0f,1f)] public float StaggerResistance = 0f` 字段(0=正常硬直,1=完全免疫,对齐 Design/06 §3 属性表) | ✅ 已应用 |
|
||||
| [01_ProjectStructure.md](01_ProjectStructure.md) | §5 AddressKeys 类常量名从下划线分隔(`Pfx_Player_Main`/`Pfx_UI_HUD`/`Pfx_VFX_HitSpark` 等)全部改为驼峰式(`PrefabPlayer`/`PrefabUIHUD`/`PrefabVFXHitSpark` 等),命名规范注释同步;§6 推荐模式代码示例 `AddressKeys.Pfx_Player_Main` 改为 `AddressKeys.PrefabPlayer`,`ObjectPoolManager.Instance.Spawn` 改为 `GlobalObjectPool.Instance.Spawn`,常量引用同步 | ✅ 已应用 |
|
||||
| [18_VFXFeedbackModule.md](18_VFXFeedbackModule.md) | 头部依赖说明 `BaseGames.Core(ObjectPoolManager)` 改为 `BaseGames.Core(GlobalObjectPool)`,对齐 Architecture/13 全局对象池命名 | ✅ 已应用 |
|
||||
| [05_PlayerModule.md](05_PlayerModule.md) | §1 设计原则从「HitBox 不挂载在 Player Prefab 本体上 / 独立武器 Prefab」改为「HitBox 固定挂载于 Player Prefab / PlayerCombat 管理四向 HitBox / WeaponSO 纯数据驱动」,对齐 §5 PlayerCombat 及 §7 WeaponManager 的实际实现(注释一致性修正) | ✅ 已应用 |
|
||||
| [06_CombatModule.md](06_CombatModule.md) | §1 DamageInfo 结构体字段:移除所有 `readonly` 关键字(Builder 类需从外部为 `_d` 的字段赋值,C# 禁止为 class 内嵌 struct 的 readonly 字段赋值);注释改为「Builder 工具类负责逐字段构建,Amount/FinalDamage 在 HurtBox 流水线内可就地修改(局部变量)」,确保代码可编译 | ✅ 已应用 |
|
||||
| [02_EventSystem.md](02_EventSystem.md) | 末尾新增 §7 EventChannelRegistry(全文定义:MonoBehaviour 单例,Dictionary<string, SO> 注册表,Register/Get<T> 两方法,DontDestroyOnLoad,注册时机说明);对齐 Architecture/09 §4 EquipmentContext.Events 字段及 EquipmentManager.Awake() 的 `EventChannelRegistry.Instance` 调用 | ✅ 已应用 |
|
||||
473
Docs/Architecture/01_ProjectStructure.md
Normal file
473
Docs/Architecture/01_ProjectStructure.md
Normal file
@@ -0,0 +1,473 @@
|
||||
# 01 · 项目结构与规范
|
||||
|
||||
> **作用**:定义文件夹布局、Assembly Definition 清单、命名规范、ScriptableObject 资产路径、代码风格约束。
|
||||
> **所有程序员必读**,开始任何模块开发前先阅读本文档。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [文件夹布局](#1-文件夹布局)
|
||||
2. [Assembly Definitions(asmdef)](#2-assembly-definitions)
|
||||
3. [命名规范](#3-命名规范)
|
||||
4. [ScriptableObject 资产组织](#4-scriptableobject-资产组织)
|
||||
5. [Addressables 资产组织](#5-addressables-资产组织)
|
||||
6. [代码风格约束](#6-代码风格约束)
|
||||
7. [Prefab 组织规范](#7-prefab-组织规范)
|
||||
8. [场景组织规范](#8-场景组织规范)
|
||||
|
||||
---
|
||||
|
||||
## 1. 文件夹布局
|
||||
|
||||
```
|
||||
Assets/
|
||||
├── Scripts/ ← 所有游戏代码(按模块分文件夹)
|
||||
│ ├── Core/ BaseGames.Core + BaseGames.Core.Events
|
||||
│ │ ├── Events/ SO 事件频道类型
|
||||
│ │ └── Save/ SaveManager + ISaveStorage + SaveData
|
||||
│ ├── Input/ BaseGames.Input
|
||||
│ ├── Camera/ BaseGames.Camera
|
||||
│ ├── Player/ BaseGames.Player
|
||||
│ │ └── States/ BaseGames.Player.States
|
||||
│ ├── Combat/ BaseGames.Combat
|
||||
│ │ └── StatusEffects/ BaseGames.Combat.StatusEffects
|
||||
│ ├── Parry/ BaseGames.Parry
|
||||
│ ├── Enemies/ BaseGames.Enemies
|
||||
│ │ ├── AI/ BaseGames.Enemies.AI(Behavior Designer Tasks)
|
||||
│ │ ├── Boss/
|
||||
│ │ │ └── Patterns/ BaseGames.Enemies.Boss.Patterns
|
||||
│ │ └── Navigation/ BaseGames.Enemies.Navigation
|
||||
│ ├── Feedback/ BaseGames.Feedback
|
||||
│ ├── World/ BaseGames.World
|
||||
│ │ ├── Map/ BaseGames.World.Map
|
||||
│ │ └── Shop/ BaseGames.World.Shop
|
||||
│ ├── UI/ BaseGames.UI
|
||||
│ ├── Audio/ BaseGames.Audio
|
||||
│ ├── Progression/ BaseGames.Progression
|
||||
│ ├── Dialogue/ BaseGames.Dialogue
|
||||
│ ├── Equipment/ BaseGames.Equipment
|
||||
│ ├── Cutscene/ BaseGames.Cutscene
|
||||
│ ├── Animation/ BaseGames.Animation
|
||||
│ ├── Spells/ BaseGames.Spells
|
||||
│ ├── Localization/ BaseGames.Localization
|
||||
│ ├── Tutorial/ BaseGames.Tutorial
|
||||
│ ├── Platform/ BaseGames.Platform
|
||||
│ └── Editor/ BaseGames.Editor(Editor Only)
|
||||
│
|
||||
├── Data/ ← ScriptableObject 资产(按模块分文件夹)
|
||||
│ ├── Events/ 所有事件频道 SO
|
||||
│ ├── Player/ PlayerStatsSO、PlayerMovementConfigSO 等
|
||||
│ ├── Combat/ WeaponSO、ProjectileConfigSO 等
|
||||
│ ├── Enemies/ EnemyStatsSO、AttackPatternSO 等
|
||||
│ ├── Progression/ SkillSO、CharmSO、AbilityConfigSO 等
|
||||
│ ├── Audio/ AudioCueSO、BGMPlaylistSO 等
|
||||
│ ├── World/ MapRoomDataSO、ShopInventorySO 等
|
||||
│ ├── UI/ UIConfigSO 等
|
||||
│ └── Settings/ GlobalSettingsSO
|
||||
│
|
||||
├── Prefabs/ ← 预制体
|
||||
│ ├── Player/
|
||||
│ ├── Enemies/
|
||||
│ ├── World/
|
||||
│ ├── UI/
|
||||
│ ├── Combat/ HitBox、HurtBox、Projectile 等
|
||||
│ ├── Effects/ VFX Prefabs
|
||||
│ └── Persistent/ Persistent 场景专用 Prefabs
|
||||
│
|
||||
├── Scenes/
|
||||
│ ├── Persistent.unity 常驻场景
|
||||
│ ├── MainMenu.unity
|
||||
│ ├── Room_*/ 各关卡房间场景
|
||||
│ └── Boss_*/ Boss 战场景
|
||||
│
|
||||
├── Art/ 美术资源(不在此文档范围)
|
||||
├── Audio/ 音频资源(FMOD 项目)
|
||||
└── StreamingAssets/ FMOD 音频包等
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Assembly Definitions
|
||||
|
||||
所有 asmdef 均位于对应的 `Scripts/` 子文件夹下,文件名与程序集名称一致。
|
||||
|
||||
### 依赖层次(底层 → 上层)
|
||||
|
||||
```
|
||||
BaseGames.Core.Events
|
||||
└─→ BaseGames.Core
|
||||
├─→ BaseGames.Input
|
||||
├─→ BaseGames.Camera
|
||||
├─→ BaseGames.Audio
|
||||
├─→ BaseGames.Localization
|
||||
├─→ BaseGames.Platform
|
||||
├─→ BaseGames.World
|
||||
│ └─→ BaseGames.World.Map
|
||||
│ └─→ BaseGames.World.Shop
|
||||
└─→ BaseGames.Combat
|
||||
├─→ BaseGames.Parry
|
||||
├─→ BaseGames.Combat.StatusEffects
|
||||
└─→ BaseGames.Player
|
||||
├─→ BaseGames.Player.States
|
||||
├─→ BaseGames.Progression
|
||||
├─→ BaseGames.Equipment
|
||||
├─→ BaseGames.Spells
|
||||
└─→ BaseGames.Enemies
|
||||
├─→ BaseGames.Enemies.AI
|
||||
├─→ BaseGames.Enemies.Navigation
|
||||
└─→ BaseGames.Enemies.Boss.Patterns
|
||||
|
||||
BaseGames.Feedback(依赖:Core.Events、Player、Enemies)
|
||||
BaseGames.Animation(依赖:Core.Events、Player)
|
||||
BaseGames.UI(依赖:Core.Events、Core、Progression)
|
||||
BaseGames.Dialogue(依赖:Core.Events、UI)
|
||||
BaseGames.Cutscene(依赖:Core.Events、UI、Dialogue)
|
||||
BaseGames.Tutorial(依赖:Core.Events、Progression)
|
||||
BaseGames.Editor(Editor Only,依赖全部运行时程序集)
|
||||
```
|
||||
|
||||
### asmdef 文件清单
|
||||
|
||||
| 文件名 | 程序集名称 | 编辑器 | 关键外部引用 |
|
||||
|--------|----------|--------|------------|
|
||||
| `BaseGames.Core.Events.asmdef` | `BaseGames.Core.Events` | ✗ | — |
|
||||
| `BaseGames.Core.asmdef` | `BaseGames.Core` | ✗ | `Newtonsoft.Json` |
|
||||
| `BaseGames.Input.asmdef` | `BaseGames.Input` | ✗ | `Unity.InputSystem` |
|
||||
| `BaseGames.Camera.asmdef` | `BaseGames.Camera` | ✗ | `Cinemachine` |
|
||||
| `BaseGames.Audio.asmdef` | `BaseGames.Audio` | ✗ | `FMODUnity`(可选) |
|
||||
| `BaseGames.Localization.asmdef` | `BaseGames.Localization` | ✗ | `Unity.Localization` |
|
||||
| `BaseGames.Platform.asmdef` | `BaseGames.Platform` | ✗ | `Steamworks.NET`(条件编译) |
|
||||
| `BaseGames.Combat.asmdef` | `BaseGames.Combat` | ✗ | — |
|
||||
| `BaseGames.Combat.StatusEffects.asmdef` | `BaseGames.Combat.StatusEffects` | ✗ | — |
|
||||
| `BaseGames.Parry.asmdef` | `BaseGames.Parry` | ✗ | — |
|
||||
| `BaseGames.World.asmdef` | `BaseGames.World` | ✗ | — |
|
||||
| `BaseGames.World.Map.asmdef` | `BaseGames.World.Map` | ✗ | — |
|
||||
| `BaseGames.World.Shop.asmdef` | `BaseGames.World.Shop` | ✗ | — |
|
||||
| `BaseGames.Player.asmdef` | `BaseGames.Player` | ✗ | `Kybernetik.Animancer` |
|
||||
| `BaseGames.Player.States.asmdef` | `BaseGames.Player.States` | ✗ | `Kybernetik.Animancer` |
|
||||
| `BaseGames.Progression.asmdef` | `BaseGames.Progression` | ✗ | — |
|
||||
| `BaseGames.Equipment.asmdef` | `BaseGames.Equipment` | ✗ | — |
|
||||
| `BaseGames.Spells.asmdef` | `BaseGames.Spells` | ✗ | — |
|
||||
| `BaseGames.Enemies.asmdef` | `BaseGames.Enemies` | ✗ | `Kybernetik.Animancer` |
|
||||
| `BaseGames.Enemies.AI.asmdef` | `BaseGames.Enemies.AI` | ✗ | `BehaviorDesigner.Runtime` |
|
||||
| `BaseGames.Enemies.Navigation.asmdef` | `BaseGames.Enemies.Navigation` | ✗ | `PathBerserker2d` |
|
||||
| `BaseGames.Enemies.Boss.Patterns.asmdef` | `BaseGames.Enemies.Boss.Patterns` | ✗ | — |
|
||||
| `BaseGames.Feedback.asmdef` | `BaseGames.Feedback` | ✗ | `MoreMountains.Tools`, `MoreMountains.Feedbacks` |
|
||||
| `BaseGames.Animation.asmdef` | `BaseGames.Animation` | ✗ | `Kybernetik.Animancer` |
|
||||
| `BaseGames.UI.asmdef` | `BaseGames.UI` | ✗ | — |
|
||||
| `BaseGames.Dialogue.asmdef` | `BaseGames.Dialogue` | ✗ | `Unity.Localization` |
|
||||
| `BaseGames.Cutscene.asmdef` | `BaseGames.Cutscene` | ✗ | `Unity.Timeline` |
|
||||
| `BaseGames.Tutorial.asmdef` | `BaseGames.Tutorial` | ✗ | — |
|
||||
| `BaseGames.Editor.asmdef` | `BaseGames.Editor` | ✓ | 所有运行时程序集 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 命名规范
|
||||
|
||||
### 类型名称
|
||||
|
||||
| 类型 | 后缀 / 规则 | 示例 |
|
||||
|------|-----------|------|
|
||||
| MonoBehaviour 组件 | 无后缀 | `PlayerController`、`EnemyBase` |
|
||||
| ScriptableObject | `SO` 后缀 | `PlayerStatsSO`、`ShopItemSO` |
|
||||
| 事件频道 SO | `EventChannelSO` 后缀 | `VoidEventChannelSO`、`IntEventChannelSO` |
|
||||
| 接口 | `I` 前缀 | `ISaveable`、`IInteractable`、`ICharmEffect` |
|
||||
| 枚举 | PascalCase 无后缀 | `GameState`、`AbilityType`、`DamageType` |
|
||||
| 泛型基类 | `Base` 后缀 | `PlayerStateBase`、`EnemyStateBase` |
|
||||
| Editor 扩展 | `Editor` 后缀 | `PlayerControllerEditor`、`EnemyBaseEditor` |
|
||||
| 协程方法 | `Coroutine` 后缀 | `LoadSceneCoroutine()`、`DeathSequenceCoroutine()` |
|
||||
|
||||
### 字段命名
|
||||
|
||||
```csharp
|
||||
// 私有序列化字段:_camelCase(下划线前缀)
|
||||
[SerializeField] private PlayerMovementConfigSO _movementConfig;
|
||||
|
||||
// 私有非序列化字段:_camelCase
|
||||
private float _currentSpeed;
|
||||
|
||||
// 属性:PascalCase
|
||||
public float CurrentSpeed => _currentSpeed;
|
||||
|
||||
// 常量:ALL_CAPS
|
||||
private const float MAX_SPEED = 10f;
|
||||
|
||||
// 局部变量:camelCase
|
||||
float deltaSpeed = targetSpeed - _currentSpeed;
|
||||
```
|
||||
|
||||
### 文件命名
|
||||
|
||||
| 类型 | 规则 | 示例 |
|
||||
|------|------|------|
|
||||
| C# 脚本 | 与类名完全一致 | `PlayerController.cs` |
|
||||
| SO 资产 | `[SystemPrefix]_[Name]` | `PLY_Stats_Default.asset`、`EVT_PlayerDied.asset` |
|
||||
| Prefab | `[SystemPrefix]_[Name]` | `PLY_Player.prefab`、`ENM_GruntWarrior.prefab` |
|
||||
| 场景 | `Room_{Region}_{Index:D2}` | `Room_Forest_01.unity` |
|
||||
| asmdef | 与程序集名称一致 | `BaseGames.Player.asmdef` |
|
||||
|
||||
### SO 资产前缀表
|
||||
|
||||
| 前缀 | 系统 |
|
||||
|------|------|
|
||||
| `EVT_` | 事件频道 |
|
||||
| `PLY_` | 玩家配置 |
|
||||
| `CMB_` | 战斗配置 |
|
||||
| `ENM_` | 敌人配置 |
|
||||
| `WPN_` | 武器配置 |
|
||||
| `SKL_` | 技能 / 法术 |
|
||||
| `CHM_` | 护身符 |
|
||||
| `SHP_` | 商店 |
|
||||
| `MAP_` | 地图 |
|
||||
| `AUD_` | 音频 |
|
||||
| `UI_` | UI 配置 |
|
||||
| `SET_` | 设置 |
|
||||
|
||||
---
|
||||
|
||||
## 4. ScriptableObject 资产组织
|
||||
|
||||
### 目录结构(`Assets/Data/`)
|
||||
|
||||
```
|
||||
Assets/Data/
|
||||
├── Events/
|
||||
│ ├── Core/ EVT_GameStateChanged.asset, EVT_SceneLoadRequest.asset …
|
||||
│ ├── Player/ EVT_PlayerDied.asset, EVT_HPChanged.asset …
|
||||
│ ├── Combat/ EVT_DamageDealt.asset …
|
||||
│ ├── World/ EVT_RoomTransition.asset …
|
||||
│ └── UI/ EVT_ShowPanel.asset …
|
||||
├── Player/
|
||||
│ ├── PLY_Stats_Default.asset
|
||||
│ ├── PLY_MovementConfig.asset
|
||||
│ ├── PLY_AnimConfig.asset
|
||||
│ └── PLY_FormConfig.asset
|
||||
├── Combat/
|
||||
│ ├── CMB_WeaponBase.asset
|
||||
│ └── CMB_ProjectileConfig_*.asset
|
||||
├── Enemies/
|
||||
│ ├── ENM_Stats_*.asset
|
||||
│ └── ENM_AttackPattern_*.asset
|
||||
├── Progression/
|
||||
│ ├── SKL_SoulSpell_*.asset
|
||||
│ ├── CHM_Charm_*.asset
|
||||
│ └── ABL_AbilityConfig_*.asset
|
||||
├── Audio/
|
||||
│ ├── AUD_BGMPlaylist_*.asset
|
||||
│ └── AUD_SFXCue_*.asset
|
||||
├── World/
|
||||
│ ├── MAP_RoomData_*.asset
|
||||
│ └── SHP_ShopInventory_*.asset
|
||||
└── Settings/
|
||||
└── SET_GlobalSettings.asset
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Addressables 资产组织
|
||||
|
||||
### AddressKeys 静态类(路径:`Scripts/Core/AddressKeys.cs`)
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/Assets/AddressKeys.cs
|
||||
// ⚠️ 命名规范:驼峰式,无下划线分隔(`PrefabPlayer` 不是 `Pfx_Player_Main`)
|
||||
// 地址值与 Addressables Groups 窗口 Address 列保持完全一致
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
public static class AddressKeys
|
||||
{
|
||||
// ── Scenes ──────────────────────────────────────────────
|
||||
public const string ScenePersistent = "Scene_Persistent";
|
||||
public const string SceneMainMenu = "Scene_MainMenu";
|
||||
public const string SceneRoomPrefix = "Room_";
|
||||
public const string SceneBossPrefix = "Boss_";
|
||||
|
||||
// ── Player ──────────────────────────────────────────────
|
||||
public const string PrefabPlayer = "PLY_Player";
|
||||
|
||||
// ── UI ──────────────────────────────────────────────────
|
||||
public const string PrefabUIHUD = "UI_HUD";
|
||||
public const string PrefabUIPauseMenu = "UI_PauseMenu";
|
||||
public const string PrefabUIDeathScreen = "UI_DeathScreen";
|
||||
public const string PrefabUILoadingScreen = "UI_LoadingScreen";
|
||||
|
||||
// ── VFX ─────────────────────────────────────────────────
|
||||
public const string PrefabVFXHitSpark = "VFX_HitSpark";
|
||||
public const string PrefabVFXDeathBurst = "VFX_DeathBurst";
|
||||
public const string PrefabVFXParryFlash = "VFX_ParryFlash";
|
||||
|
||||
// ── Audio ────────────────────────────────────────────────
|
||||
public const string PrefabAudioMasterMixer = "MasterMixer";
|
||||
|
||||
// ── Labels(用于 Addressables.LoadAssetsAsync 批量加载)───
|
||||
public const string LabelEnemy = "Enemy";
|
||||
public const string LabelPoolable = "Poolable";
|
||||
public const string LabelBGM = "BGM";
|
||||
public const string LabelCharms = "Charms";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **完整 `AddressKeys` 定义见 `13_AssetPoolModule.md §2`**,本节仅展示命名约定示例。
|
||||
|
||||
### Addressables 分组策略
|
||||
|
||||
| 组名 | 内容 | 加载时机 |
|
||||
|------|------|---------|
|
||||
| `DefaultLocalGroup` | 常驻资源(GameManager Prefab、HUD 等) | 启动时预加载 |
|
||||
| `Room_{Region}` | 各区域房间所有资产 | 进入区域时加载 |
|
||||
| `Boss_{Name}` | Boss 战专属资产 | Boss 战开始前加载 |
|
||||
| `UI` | 所有 UI Prefab | 启动时预加载 |
|
||||
| `VFX_Common` | 通用特效 | 启动时预加载 |
|
||||
| `Audio_Music` | BGM(FMOD 包) | 按需加载 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 代码风格约束
|
||||
|
||||
### 禁止模式
|
||||
|
||||
```csharp
|
||||
// ❌ 禁止:FindObjectOfType
|
||||
var player = FindObjectOfType<PlayerController>();
|
||||
|
||||
// ❌ 禁止:跨 GameObject GetComponent(组件不在同一 Prefab 内)
|
||||
var stats = otherGO.GetComponent<PlayerStats>();
|
||||
|
||||
// ❌ 禁止:静态单例暴露子系统
|
||||
public static AudioManager Instance { get; private set; } // 禁止全局访问
|
||||
|
||||
// ❌ 禁止:Resources.Load
|
||||
var sprite = Resources.Load<Sprite>("Enemies/goblin");
|
||||
|
||||
// ❌ 禁止:跨系统 Inspector 序列化引用
|
||||
[SerializeField] private PlayerController _player; // 在 EnemyBase 中引用玩家 ❌
|
||||
|
||||
// ❌ 禁止:硬编码 Addressable 字符串
|
||||
var handle = Addressables.LoadAsset<GameObject>("PLY_Player"); // ❌ 用 AddressKeys 常量
|
||||
```
|
||||
|
||||
### 推荐模式
|
||||
|
||||
```csharp
|
||||
// ✅ SO 事件频道(跨模块通信)
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||||
private void OnEnable() => _onPlayerDied.OnEventRaised += HandlePlayerDied;
|
||||
private void OnDisable() => _onPlayerDied.OnEventRaised -= HandlePlayerDied;
|
||||
|
||||
// ✅ Inspector 序列化(同 Prefab 内)
|
||||
[SerializeField] private PlayerMovement _movement; // 在 PlayerController 中 ✅
|
||||
|
||||
// ✅ Addressables 加载
|
||||
Addressables.InstantiateAsync(AddressKeys.PrefabPlayer).Completed += OnPlayerLoaded;
|
||||
|
||||
// ✅ 对象池
|
||||
GlobalObjectPool.Instance.Spawn<HitEffect>(AddressKeys.PrefabVFXHitSpark, position, rotation);
|
||||
```
|
||||
|
||||
### OnEnable / OnDisable 规则
|
||||
|
||||
每个订阅 SO 事件频道的组件**必须**在 `OnDisable` 中取消订阅,防止内存泄漏:
|
||||
|
||||
```csharp
|
||||
private void OnEnable()
|
||||
{
|
||||
_channel.OnEventRaised += HandleEvent;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_channel.OnEventRaised -= HandleEvent;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Prefab 组织规范
|
||||
|
||||
### Player Prefab 层级
|
||||
|
||||
```
|
||||
[PLY_Player] ← PlayerController(协调器)
|
||||
├── PlayerMovement ← Rigidbody2D 封装
|
||||
├── PlayerStats ← 属性容器
|
||||
├── PlayerCombat ← 攻击逻辑
|
||||
├── FormController ← 形态管理
|
||||
├── WeaponManager ← 武器切换
|
||||
├── SkillManager ← 技能执行
|
||||
├── SpringSystem ← 灵泉管理
|
||||
├── ParrySystem ← 弹反逻辑
|
||||
├── AnimancerComponent ← Animancer 入口
|
||||
├── PlayerFeedback ← MMF_Player
|
||||
├── HurtBox ← Composite Collider 受击区域
|
||||
├── HitBox_Ground ← BoxCollider2D 地面攻击判定
|
||||
├── HitBox_Up ← BoxCollider2D 上劈判定
|
||||
├── HitBox_Down ← BoxCollider2D 下劈判定
|
||||
├── HitBox_Air ← BoxCollider2D 空中判定
|
||||
└── SpriteRenderer
|
||||
```
|
||||
|
||||
### Enemy Prefab 通用层级
|
||||
|
||||
```
|
||||
[ENM_{EnemyName}] ← EnemyBase(协调器)+ BehaviorTree
|
||||
├── EnemyMovement ← PathBerserker2d EnemyNavAgent 封装
|
||||
├── EnemyStats ← HP/攻击等属性
|
||||
├── EnemyCombat ← HitBox 管理
|
||||
├── AnimancerComponent ← 动画
|
||||
├── EnemyFeedback ← MMF_Player
|
||||
├── HurtBox ← 受击区域
|
||||
└── HitBox_{N} ← 各攻击 HitBox
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 场景组织规范
|
||||
|
||||
### 场景文件命名
|
||||
|
||||
```
|
||||
Persistent ← 常驻场景(GameManager、AudioManager 等单例)
|
||||
MainMenu ← 主菜单
|
||||
Room_{Region}_{Index:D2} ← 关卡房间 例: Room_Forest_01
|
||||
Boss_{Region} ← Boss 战 例: Boss_Forest
|
||||
Hub_Town ← 区域枢纽 例: Hub_RestCamp
|
||||
```
|
||||
|
||||
### 房间场景层级结构
|
||||
|
||||
```
|
||||
Scene: Room_{Region}_{Index}
|
||||
├── [Level]
|
||||
│ ├── Tilemap_Ground
|
||||
│ ├── Tilemap_Background
|
||||
│ ├── Tilemap_Foreground
|
||||
│ ├── Tilemap_OneWay
|
||||
│ └── Tilemap_Destructible
|
||||
├── [NavMesh]
|
||||
│ ├── NavSurface
|
||||
│ └── NavLink_{N}
|
||||
├── [Enemies]
|
||||
│ └── Enemy_*(Prefab 实例)
|
||||
├── [World]
|
||||
│ ├── RoomTransition_{Direction}
|
||||
│ ├── SavePoint(可选)
|
||||
│ └── Collectible_*
|
||||
├── [Camera]
|
||||
│ ├── CinemachineVirtualCamera
|
||||
│ └── CameraConfiner(PolygonCollider2D)
|
||||
└── [Lighting]
|
||||
└── GlobalLight2D
|
||||
```
|
||||
|
||||
### Persistent 场景组织
|
||||
|
||||
```
|
||||
Scene: Persistent
|
||||
├── GameManager ← DontDestroyOnLoad 协调器
|
||||
├── AudioManager ← FMOD 封装
|
||||
├── ObjectPoolManager ← 对象池
|
||||
├── SettingsManager ← 设置管理
|
||||
└── InputReader ← InputReaderSO(持久化)
|
||||
```
|
||||
797
Docs/Architecture/02_EventSystem.md
Normal file
797
Docs/Architecture/02_EventSystem.md
Normal file
@@ -0,0 +1,797 @@
|
||||
# 02 · SO 事件系统
|
||||
|
||||
> **命名空间** `BaseGames.Core.Events`
|
||||
> **程序集** `BaseGames.Core.Events`(无任何运行时依赖,最底层)
|
||||
> **路径** `Assets/Scripts/Core/Events/`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [设计原则](#1-设计原则)
|
||||
2. [泛型事件频道基类](#2-泛型事件频道基类)
|
||||
3. [所有事件频道类型](#3-所有事件频道类型)
|
||||
4. [全局事件频道 SO 资产清单](#4-全局事件频道-so-资产清单)
|
||||
5. [发布 / 订阅模式](#5-发布--订阅模式)
|
||||
6. [MMEventManager(备用广播)](#6-mmeventmanager)
|
||||
7. [EventChannelRegistry](#7-eventchannelregistry)
|
||||
8. [事件订阅生命周期管理 — 自动注销机制](#8-事件订阅生命周期管理--自动注销机制)
|
||||
9. [EventBusMonitorWindow — 运行时事件监控面板](#9-eventbusmonitorwindow--运行时事件监控面板)
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
- 每个事件频道是一个 **ScriptableObject**,在 `Assets/Data/Events/` 下预建资产,Inspector 拖拽引用
|
||||
- 发布者和订阅者**只持有频道 SO 的引用**,彼此完全不知道对方的存在
|
||||
- 事件频道**不存储状态**;仅在 `Raise()` 调用时执行 C# Action 委托链
|
||||
- **禁止在代码中 `new` 出事件频道**;必须使用预建的 `.asset` 资产
|
||||
- 所有频道订阅在 `OnEnable` 注册,在 `OnDisable` 取消注册
|
||||
|
||||
---
|
||||
|
||||
## 2. 泛型事件频道基类
|
||||
|
||||
### 文件:`Assets/Scripts/Core/Events/BaseEventChannelSO.cs`
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 泛型 SO 事件频道基类。T 为负载类型。
|
||||
/// </summary>
|
||||
public abstract class BaseEventChannelSO<T> : ScriptableObject
|
||||
{
|
||||
// Editor 备注(Inspector 可见)
|
||||
[Multiline] public string description;
|
||||
|
||||
public event Action<T> OnEventRaised;
|
||||
|
||||
/// <summary>
|
||||
/// 发布事件,执行所有订阅委托。
|
||||
/// </summary>
|
||||
public void Raise(T value)
|
||||
{
|
||||
OnEventRaised?.Invoke(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 无负载事件频道基类。
|
||||
/// </summary>
|
||||
public abstract class VoidBaseEventChannelSO : ScriptableObject
|
||||
{
|
||||
[Multiline] public string description;
|
||||
|
||||
public event Action OnEventRaised;
|
||||
|
||||
public void Raise()
|
||||
{
|
||||
OnEventRaised?.Invoke();
|
||||
#if UNITY_EDITOR
|
||||
EventBusMonitor.Record(name, "<void>",
|
||||
OnEventRaised?.GetInvocationList().Length ?? 0);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 所有事件频道类型
|
||||
|
||||
### 基础类型频道
|
||||
|
||||
```csharp
|
||||
// Assets/Scripts/Core/Events/VoidEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/Void")]
|
||||
public class VoidEventChannelSO : VoidBaseEventChannelSO { }
|
||||
|
||||
// Assets/Scripts/Core/Events/BoolEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/Bool")]
|
||||
public class BoolEventChannelSO : BaseEventChannelSO<bool> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/IntEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/Int")]
|
||||
public class IntEventChannelSO : BaseEventChannelSO<int> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/FloatEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/Float")]
|
||||
public class FloatEventChannelSO : BaseEventChannelSO<float> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/StringEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/String")]
|
||||
public class StringEventChannelSO : BaseEventChannelSO<string> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/Vector2EventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/Vector2")]
|
||||
public class Vector2EventChannelSO : BaseEventChannelSO<Vector2> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/TransformEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/Transform")]
|
||||
public class TransformEventChannelSO : BaseEventChannelSO<Transform> { }
|
||||
```
|
||||
|
||||
### 游戏专用负载频道
|
||||
|
||||
```csharp
|
||||
// Assets/Scripts/Core/Events/GameStateEventChannelSO.cs
|
||||
// ⚠️ payload 类型为 GameStateId(值类型 struct),非旧 GameState 枚举
|
||||
[CreateAssetMenu(menuName = "Events/GameState")]
|
||||
public class GameStateEventChannelSO : BaseEventChannelSO<GameStateId> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/SceneLoadRequestEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/SceneLoadRequest")]
|
||||
public class SceneLoadRequestEventChannelSO : BaseEventChannelSO<SceneLoadRequest> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/DamageInfoEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/DamageInfo")]
|
||||
public class DamageInfoEventChannelSO : BaseEventChannelSO<DamageInfo> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/ShopPurchaseEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/ShopPurchase")]
|
||||
public class ShopPurchaseEventChannelSO : BaseEventChannelSO<ShopPurchaseEvent> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/DialogueEventChannelSO.cs
|
||||
[CreateAssetMenu(menuName = "Events/DialogueRequest")]
|
||||
public class DialogueEventChannelSO : BaseEventChannelSO<DialogueDataSO> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/BossSkillEventChannelSO.cs
|
||||
[System.Serializable]
|
||||
public struct BossSkillEvent { public string BossId; public string SkillId; }
|
||||
[CreateAssetMenu(menuName = "Events/BossSkill")]
|
||||
public class BossSkillEventChannelSO : BaseEventChannelSO<BossSkillEvent> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/BossPhaseEventChannelSO.cs
|
||||
[System.Serializable]
|
||||
public struct BossPhaseEvent { public string BossId; public int Phase; }
|
||||
[CreateAssetMenu(menuName = "Events/BossPhase")]
|
||||
public class BossPhaseEventChannelSO : BaseEventChannelSO<BossPhaseEvent> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/AbilityTypeEventChannelSO.cs
|
||||
// AbilityType 定义于 09_ProgressionModule §1(enum AbilityType)
|
||||
[CreateAssetMenu(menuName = "Events/AbilityType")]
|
||||
public class AbilityTypeEventChannelSO : BaseEventChannelSO<AbilityType> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/HitConfirmedEventChannelSO.cs
|
||||
[System.Serializable]
|
||||
public struct HitInfo { public DamageInfo DamageInfo; public Vector3 HitPoint; }
|
||||
[CreateAssetMenu(menuName = "Events/HitConfirmed")]
|
||||
public class HitConfirmedEventChannelSO : BaseEventChannelSO<HitInfo> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/ColorblindModeEventChannelSO.cs
|
||||
// ColorblindMode 枚举定义于 16_SupportingModules §AccessibilityManager
|
||||
[CreateAssetMenu(menuName = "Events/ColorblindMode")]
|
||||
public class ColorblindModeEventChannelSO : BaseEventChannelSO<ColorblindMode> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/DifficultyChangedEventChannel.cs
|
||||
// ⚠️ 命名按 Architecture 19 约定:不加 "SO" 后缀(与其他频道类有意区分以标识自定义 payload)
|
||||
// DifficultyScalerSO 定义于 19_DifficultyModule §3
|
||||
[CreateAssetMenu(menuName = "Events/DifficultyChanged")]
|
||||
public class DifficultyChangedEventChannel : BaseEventChannelSO<DifficultyScalerSO> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/LiquidEventChannelSO.cs
|
||||
// LiquidZone 定义于 21_LiquidPuzzleModule §4
|
||||
[CreateAssetMenu(menuName = "Events/LiquidZone")]
|
||||
public class LiquidEventChannelSO : BaseEventChannelSO<LiquidZone> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/WorldMarkerEventChannelSO.cs
|
||||
// WorldMarker 定义于 21_LiquidPuzzleModule §14
|
||||
[CreateAssetMenu(menuName = "Events/WorldMarker")]
|
||||
public class WorldMarkerEventChannelSO : BaseEventChannelSO<WorldMarker> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/QuestStateChangedEventChannel.cs
|
||||
// QuestState 枚举定义于 22_QuestChallengeModule §QuestSO
|
||||
[System.Serializable]
|
||||
public struct QuestStateChangedEvent { public string QuestId; public QuestState State; }
|
||||
[CreateAssetMenu(menuName = "Events/QuestStateChanged")]
|
||||
public class QuestStateChangedEventChannel : BaseEventChannelSO<QuestStateChangedEvent> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/QuestObjectiveEventChannelSO.cs
|
||||
// 任务目标进度频道(用于 QuestManager 逐目标通知订阅者)
|
||||
[System.Serializable]
|
||||
public struct QuestObjectiveEvent { public string QuestId; public string ObjectiveId; public int Progress; public int Required; }
|
||||
[CreateAssetMenu(menuName = "Events/QuestObjective")]
|
||||
public class QuestObjectiveEventChannelSO : BaseEventChannelSO<QuestObjectiveEvent> { }
|
||||
|
||||
// Assets/Scripts/Core/Events/StatusEffectEventChannelSO.cs
|
||||
// 状态效果频道(StatusEffectManager 施加/过期时广播;StatusEffectType 定义于 06_CombatModule §11)
|
||||
[CreateAssetMenu(menuName = "Events/StatusEffect")]
|
||||
public class StatusEffectEventChannelSO : BaseEventChannelSO<StatusEffectType> { }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 全局事件频道 SO 资产清单
|
||||
|
||||
所有频道资产预建于 `Assets/Data/Events/`,资产文件名格式:`EVT_{描述}.asset`
|
||||
|
||||
### Core 系统
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_GameStateChanged` | `GameStateEventChannelSO` | `GameManager` | `UIManager`、`AudioManager`、`InputManager` |
|
||||
| `EVT_SceneLoadRequest` | `SceneLoadRequestEventChannelSO` | `RoomTransition`、`GameManager` | `SceneLoader` |
|
||||
| `EVT_SceneLoaded` | `StringEventChannelSO` | `SceneLoader` | `GameManager`、`MapManager` |
|
||||
| `EVT_PauseRequested` | `VoidEventChannelSO` | `InputReader`(Pause 键) | `GameManager` |
|
||||
| `EVT_DifficultyChanged` | `DifficultyChangedEventChannel` | `DifficultyManager.Apply()` | `PlayerStats`、`EnemyStats`(缩放属性) |
|
||||
|
||||
### 玩家系统
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_PlayerDied` | `VoidEventChannelSO` | `PlayerStats`(HP ≤ 0) | `GameManager`、`UIManager`、`AudioManager` |
|
||||
| `EVT_DeathScreenConfirmed` | `VoidEventChannelSO` | `DeathScreenController`(Respawn 按钮) | `GameManager`(启动 RespawnCoroutine) |
|
||||
| `EVT_PlayerRespawned` | `VoidEventChannelSO` | `GameManager`(复活流程末) | `UIManager`、`AudioManager` |
|
||||
| `EVT_HPChanged` | `IntEventChannelSO` | `PlayerStats.TakeDamage/HealHP` | `HUDController`(血量 UI) |
|
||||
| `EVT_MaxHPChanged` | `IntEventChannelSO` | `PlayerStats.UnlockHP` | `HUDController` |
|
||||
| `EVT_SoulPowerChanged` | `IntEventChannelSO` | `PlayerStats.AddSoulPower` | `HUDController` |
|
||||
| `EVT_SpiritPowerChanged` | `IntEventChannelSO` | `PlayerStats.AddSpiritPower` | `HUDController` |
|
||||
| `EVT_SpringChargesChanged` | `IntEventChannelSO` | `PlayerStats.UseSpring/RestoreSpring` | `HUDController` |
|
||||
| `EVT_GeoChanged` | `IntEventChannelSO` | `PlayerStats.AddGeo` | `HUDController` |
|
||||
| `EVT_AbilityUnlocked` | `StringEventChannelSO`(abilityId) | `PlayerStats.UnlockAbility` | `AbilityGate`、`HUDController`、`TutorialManager` |
|
||||
| `EVT_PlayerFormChanged` | `IntEventChannelSO`(FormType) | `FormController` | `HUDController`、`AudioManager` |
|
||||
| `EVT_SkillSetChanged` | `VoidEventChannelSO` | `FormController` | `SkillHUD`(刷新技能栏 UI) |
|
||||
| `EVT_ParrySuccess` | `VoidEventChannelSO` | `ParrySystem` | `PlayerStats`(+20 SoulPower)、`Feedback`、`AudioManager` |
|
||||
| `EVT_ShieldHPChanged` | `IntEventChannelSO` | `ShieldComponent`(盾牌受击/修复) | `HUDController`(盾槽 UI) |
|
||||
|
||||
### 战斗系统
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_EnemyDied` | `TransformEventChannelSO` | `EnemyBase`(HP ≤ 0) | `PlayerStats`(击杀点)、`QuestManager` |
|
||||
| `EVT_HitConfirmed` | `HitConfirmedEventChannelSO`(HitInfo) | `HurtBox.ReceiveDamage` | `HitFXSpawner`、`AudioManager`、`PlayerFeedback`(受击方) |
|
||||
| `EVT_DamageDealt` | `DamageInfoEventChannelSO` | `HurtBox.ReceiveDamage` | `AnalyticsManager` |
|
||||
| `EVT_BossFightStarted` | `StringEventChannelSO`(bossId) | `BossOrchestrator` | `GameManager`(切换 BossFight 状态)、`AudioManager` |
|
||||
| `EVT_BossFightEnded` | `BoolEventChannelSO`(胜利/失败) | `BossBase.Die()` | `GameManager`、`AudioManager`、`UIManager` |
|
||||
| `EVT_BossFightToggled` | `BoolEventChannelSO`(true=开始,false=结束) | `BossOrchestrator`(开始)、`BossBase.Die()`(结束) | `BossHPBar`(显示/隐藏 Boss 血条) |
|
||||
| `EVT_BossHPChanged` | `IntEventChannelSO` | `BossBase`(受击/回血) | `BossHPBar`(HP 进度条更新) |
|
||||
| `EVT_BossNameSet` | `StringEventChannelSO`(bossName) | `BossOrchestrator`(战斗开始时) | `BossHPBar`(显示 Boss 名称标签) |
|
||||
| `EVT_BossHPMaxSet` | `IntEventChannelSO` | `BossOrchestrator`(战斗开始时) | `BossHPBar`(初始化满血值) |
|
||||
| `EVT_BossPhaseChanged` | `BossPhaseEventChannelSO`(bossId, phase) | `BossBase.EnterPhase()` | `BossHUD`(相机切换通过 `CameraStateController.Instance` 直接调用,不订阅事件)|
|
||||
| `EVT_BossSkillStarted` | `BossSkillEventChannelSO`(bossId, skillId) | `BossSkillExecutor` | `BossHUD`(显示技能名)(震动通过 `CameraStateController.Instance.TriggerImpulse()` 直接调用)|
|
||||
| `EVT_BossSkillEnded` | `BossSkillEventChannelSO`(bossId, skillId) | `BossSkillExecutor` | `BossOrchestrator`(BD 决策推进) |
|
||||
| `EVT_BossVulnerabilityWindowOpened` | `StringEventChannelSO`(bossId) | `WeakPointSystem` | `PlayerFeedback`(提示音效)、`HUDController` |
|
||||
| `EVT_NailClash` | `VoidEventChannelSO` | `ClashResolver`(玩家与敌人 HitBox 对碰) | `VFXSpawner`(拼刀特效)、`AudioManager`(拼刀音效)、`CameraStateController`(轻振动) |
|
||||
| `EVT_StatusEffectApplied` | `StatusEffectEventChannelSO` | `StatusEffectManager.ApplyEffect()` | `HUDController`(状态图标)、`PlayerFeedback`(特效) |
|
||||
| `EVT_StatusEffectExpired` | `StatusEffectEventChannelSO` | `StatusEffectManager.Tick()` | `HUDController`(移除状态图标) |
|
||||
|
||||
### 世界系统
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_RoomTransitionRequest` | `SceneLoadRequestEventChannelSO` | `RoomTransition` | `SceneLoader` |
|
||||
| `EVT_SavePointActivated` | `StringEventChannelSO`(saveId) | `SavePoint` | `GameManager`(触发存档)、`HUDController` |
|
||||
| `EVT_FastTravelOpen` | `VoidEventChannelSO` | `SavePoint`(快速旅行已解锁) | `UIManager`(显示快速旅行面板) |
|
||||
| `EVT_CollectiblePickup` | `StringEventChannelSO`(itemId) | `Collectible` | `PlayerStats`、`QuestManager`、`AnalyticsManager` |
|
||||
| `EVT_GeoRecovered` | `StringEventChannelSO`(sceneId) | `DeathShade.Interact()` | `SaveManager`(标记该场景遗骸已回收) |
|
||||
| `EVT_ShowInteractPrompt` | `StringEventChannelSO`(promptText) | `InteractableDetector` | `HUDController`(显示交互提示 UI) |
|
||||
| `EVT_HideInteractPrompt` | `VoidEventChannelSO` | `InteractableDetector` | `HUDController`(隐藏交互提示 UI) |
|
||||
|
||||
### UI / 对话
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_DialogueStartRequest` | `DialogueEventChannelSO` | `InteractableNPC` | `DialogueManager` |
|
||||
| `EVT_DialogueStarted` | `VoidEventChannelSO` | `DialogueManager`(对话正式开始) | `InputReaderSO`(切 UI 输入)、`PlayerController`(锁定输入) |
|
||||
| `EVT_DialogueEnded` | `VoidEventChannelSO` | `DialogueManager` | `GameManager`(恢复 Gameplay)、`InputReaderSO`(切回 Gameplay) |
|
||||
| `EVT_NpcDialogueCompleted` | `StringEventChannelSO`(npcId) | `DialogueManager`(与特定 NPC 对话结束) | `QuestManager`(任务目标进度追踪) |
|
||||
| `EVT_CutsceneStarted` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(隐藏 HUD)、`InputReaderSO`(禁用输入) |
|
||||
| `EVT_CutsceneEnded` | `VoidEventChannelSO` | `CutsceneManager` | `HUDController`(恢复 HUD) |
|
||||
| `EVT_ShowPanel` | `StringEventChannelSO`(panelId) | 各触发源 | `UIManager` |
|
||||
| `EVT_HidePanel` | `StringEventChannelSO`(panelId) | 各触发源 | `UIManager` |
|
||||
|
||||
### 商店系统
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_ShopOpened` | `StringEventChannelSO`(shopId) | `ShopController.Open()` | `UIManager`(显示 ShopPanel) |
|
||||
| `EVT_ShopClosed` | `VoidEventChannelSO` | `ShopPanel` 关闭按钮 | `UIManager`(隐藏 ShopPanel) |
|
||||
| `EVT_ItemPurchased` | `ShopPurchaseEventChannelSO` | `ShopController` | `PlayerStats`(扣 Geo)、`AchievementManager`(购买成就) |
|
||||
|
||||
### 存档系统
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_SaveIndicatorVisible` | `BoolEventChannelSO`(true=显示,false=隐藏) | `SaveManager.SaveAsync()` | `HUDController`(显示/隐藏保存中图标) |
|
||||
|
||||
### 音频系统
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_RegionEntered` | `StringEventChannelSO`(zoneId) | `AudioZone` | `BGMController`(按 zoneId 查 AudioConfigSO 切换 BGM) |
|
||||
| `EVT_PlayBGM` | `StringEventChannelSO`(bgmKey) | `GameManager` 等非区域触发源 | `BGMController` |
|
||||
| `EVT_StopBGM` | `VoidEventChannelSO` | `GameManager` | `BGMController` |
|
||||
| `EVT_PlaySFX` | `StringEventChannelSO`(sfxKey) | 各触发源 | `AudioManager` |
|
||||
|
||||
### 可访问性 / 成就 / 防软锁
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_AchievementUnlocked` | `StringEventChannelSO`(achievementId) | `AchievementManager` | `ToastManager`(显示成就 Toast) |
|
||||
| `EVT_SoftlockDetected` | `VoidEventChannelSO` | `AntiSoftlockSystem` | `UIManager`(显示确认对话框) |
|
||||
| `EVT_ColorblindModeChanged` | `ColorblindModeEventChannelSO` | `AccessibilityManager` | URP Feature(色觉 LUT 切换) |
|
||||
| `EVT_SubtitlesToggled` | `BoolEventChannelSO` | `AccessibilityManager` | `DialogueBox`(字幕显隐) |
|
||||
| `EVT_HighContrastToggled` | `BoolEventChannelSO` | `AccessibilityManager` | `UIManager`(高对比度 UI Theme 切换) |
|
||||
|
||||
### 液体 / 导航标记
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_LiquidEntered` | `LiquidEventChannelSO`(LiquidZone) | `LiquidZone.OnTriggerEnter2D()` | `PlayerController`(切换 SwimState) |
|
||||
| `EVT_LiquidExited` | `LiquidEventChannelSO`(LiquidZone) | `LiquidZone.OnTriggerExit2D()` | `PlayerController`(退出 SwimState) |
|
||||
| `EVT_DrownProgress` | `FloatEventChannelSO`(0–1 进度) | `LiquidZone`(窒息计时器每帧) | `HUDController`(窒息条 UI) |
|
||||
| `EVT_PlayerDrowned` | `VoidEventChannelSO` | `LiquidZone`(窒息计时器归零) | `GameManager`(触发死亡流程) |
|
||||
| `EVT_WorldMarkerActivated` | `WorldMarkerEventChannelSO`(WorldMarker) | `WorldMarker.Activate()` | `HUDController`(指引箭头)、`MapManager`(地图图标) |
|
||||
| `EVT_WorldMarkerDeactivated` | `WorldMarkerEventChannelSO`(WorldMarker) | `WorldMarker.Deactivate()` | `HUDController`、`MapManager` |
|
||||
|
||||
### 任务 / 挑战
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_QuestStarted` | `StringEventChannelSO`(questId) | `QuestManager.StartQuest()` | `QuestLogUI`(新增条目)、`HUDController`(Toast 提示) |
|
||||
| `EVT_QuestCompleted` | `StringEventChannelSO`(questId) | `QuestManager.CompleteQuest()` | `QuestGiver`(刷新对话)、`QuestLogUI`、`AchievementManager` |
|
||||
| `EVT_QuestFailed` | `StringEventChannelSO`(questId) | `QuestManager.FailQuest()` | `QuestLogUI`(标记失败)、`HUDController`(失败提示) |
|
||||
| `EVT_ObjectiveUpdated` | `QuestObjectiveEventChannelSO`(QuestObjectiveEvent) | `QuestManager.UpdateObjective()` | `QuestLogUI`(进度刷新)、`HUDController`(目标追踪 UI) |
|
||||
| `EVT_ChallengeCompleted` | `StringEventChannelSO`(challengeId) | `ChallengeRoomManager` | `HUDController`(结算界面)、`AchievementManager` |
|
||||
| `EVT_ChallengeFailed` | `StringEventChannelSO`(challengeId) | `ChallengeRoomManager` | `SaveManager`(触发读档)、`HUDController` |
|
||||
|
||||
### 事件链 / EventChain
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_ChainCompleted` | `StringEventChannelSO`(chainId) | `EventChainManager` | `ChainCompletedCondition`(链间依赖) |
|
||||
| `EVT_DoorOpened` | `StringEventChannelSO`(doorId) | `OpenDoorAction` | `DoorController`(物理开门动画) |
|
||||
| `EVT_FlagChanged` | `StringEventChannelSO`(flagId) | `SetFlagAction` | `InteractableNPC`(条件对话刷新) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 发布 / 订阅模式
|
||||
|
||||
### 标准订阅写法
|
||||
|
||||
```csharp
|
||||
public class HUDController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private IntEventChannelSO _onHPChanged;
|
||||
[SerializeField] private IntEventChannelSO _onSoulPowerChanged;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onHPChanged.OnEventRaised += UpdateHPBar;
|
||||
_onSoulPowerChanged.OnEventRaised += UpdateSoulBar;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_onHPChanged.OnEventRaised -= UpdateHPBar;
|
||||
_onSoulPowerChanged.OnEventRaised -= UpdateSoulBar;
|
||||
}
|
||||
|
||||
private void UpdateHPBar(int newHP) { /* ... */ }
|
||||
private void UpdateSoulBar(int newSoul) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### 标准发布写法
|
||||
|
||||
```csharp
|
||||
public class PlayerStats : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private IntEventChannelSO _onHPChanged;
|
||||
|
||||
public void TakeDamage(int amount)
|
||||
{
|
||||
_currentHP = Mathf.Max(0, _currentHP - amount);
|
||||
_onHPChanged.Raise(_currentHP);
|
||||
|
||||
if (_currentHP == 0)
|
||||
_onPlayerDied.Raise();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. MMEventManager(备用广播)
|
||||
|
||||
用于**无需预建 SO 资产的临时广播**,或 Feel 框架自带事件(如 `MMTopDownEngineEvent`)。
|
||||
|
||||
```csharp
|
||||
// 发布(任意位置)
|
||||
MMEventManager.TriggerEvent(new PlayerDiedEvent());
|
||||
|
||||
// 订阅(实现 MMEventListener<T>)
|
||||
public class AudioManager : MonoBehaviour, MMEventListener<PlayerDiedEvent>
|
||||
{
|
||||
private void OnEnable() => this.MMEventStartListening<PlayerDiedEvent>();
|
||||
private void OnDisable() => this.MMEventStopListening<PlayerDiedEvent>();
|
||||
|
||||
public void OnMMEvent(PlayerDiedEvent eventType)
|
||||
{
|
||||
// 响应
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**仅在 Feel 框架集成场景下使用**;其他跨系统通信统一使用 SO 事件频道。
|
||||
|
||||
---
|
||||
|
||||
## 7. EventChannelRegistry
|
||||
|
||||
`EventChannelRegistry` 是运行时频道查找辅助单例,专为**无法在 Inspector 中序列化 SO 引用的动态场景**设计(典型用例:`ICharmEffect` SO 实例需要在运行时订阅事件频道)。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/Events/EventChannelRegistry.cs
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 运行时事件频道注册表。
|
||||
/// 在 Persistent 场景 Awake 时由 EventChannelRegistrar 注册所有频道 SO,
|
||||
/// 供不能持有 [SerializeField] 的动态对象(CharmEffect SO 等)按类型名查找频道。
|
||||
/// </summary>
|
||||
public class EventChannelRegistry : MonoBehaviour
|
||||
{
|
||||
public static EventChannelRegistry Instance { get; private set; }
|
||||
|
||||
private readonly Dictionary<string, ScriptableObject> _channels = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
/// <summary>由 EventChannelRegistrar 在场景初始化时批量注册频道 SO。</summary>
|
||||
public void Register(string key, ScriptableObject channel)
|
||||
=> _channels[key] = channel;
|
||||
|
||||
/// <summary>
|
||||
/// 按 key 查找频道。key 约定 = SO 资产文件名(不含扩展名),如 "EVT_OnHitConfirmed"。
|
||||
/// 找不到时输出 Error 并返回 null。
|
||||
/// </summary>
|
||||
public T Get<T>(string key) where T : ScriptableObject
|
||||
{
|
||||
if (_channels.TryGetValue(key, out var ch) && ch is T typed) return typed;
|
||||
Debug.LogError($"[EventChannelRegistry] Key '{key}' not found or wrong type.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注册时机**:Persistent 场景中的 `EventChannelRegistrar` 组件在 `Awake`(最早执行)时调用
|
||||
> `EventChannelRegistry.Instance.Register(key, channelSO)` 完成全部频道注册,
|
||||
> 确保 `EquipmentManager.Awake()` 构建 `EquipmentContext` 时 `Instance` 已就绪。
|
||||
> 普通 MonoBehaviour 仍优先使用 `[SerializeField]` 直接引用 SO,Registry 仅供动态 SO 对象使用。
|
||||
|
||||
---
|
||||
|
||||
## 8. 事件订阅生命周期管理 — 自动注销机制
|
||||
|
||||
> **P1 优化**:手动在 `OnDisable` 配对 `OnEnable` 的取消注册,在多场景 Additive 加载时易遗漏导致重复注册或 NullRef。引入 `EventSubscription` / `CompositeDisposable` 模式统一管理。
|
||||
|
||||
### 8.1 EventSubscription — 单订阅 Disposable
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/Events/EventSubscription.cs
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 单条订阅的 Disposable 句柄。Dispose() 自动取消注册。
|
||||
/// 配合 using 块或 CompositeDisposable 批量释放。
|
||||
/// </summary>
|
||||
public readonly struct EventSubscription : System.IDisposable
|
||||
{
|
||||
private readonly System.Action _unsubscribe;
|
||||
|
||||
public EventSubscription(System.Action unsubscribe)
|
||||
=> _unsubscribe = unsubscribe;
|
||||
|
||||
public void Dispose() => _unsubscribe?.Invoke();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 CompositeDisposable — 批量注销容器
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/Events/CompositeDisposable.cs
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 批量管理多条订阅,统一在 Dispose / Clear 时取消所有注册。
|
||||
/// MonoBehaviour 生命周期:OnEnable 调用 Subscribe,OnDisable 调用 Clear。
|
||||
/// </summary>
|
||||
public sealed class CompositeDisposable : System.IDisposable
|
||||
{
|
||||
private readonly List<System.IDisposable> _items = new();
|
||||
|
||||
public void Add(System.IDisposable item) => _items.Add(item);
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var item in _items) item.Dispose();
|
||||
_items.Clear();
|
||||
}
|
||||
|
||||
public void Dispose() => Clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 BaseEventChannelSO — Subscribe 扩展重载
|
||||
|
||||
```csharp
|
||||
// 追加到 BaseEventChannelSO<T>(路径: Assets/Scripts/Core/Events/BaseEventChannelSO.cs)
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
public abstract class BaseEventChannelSO<T> : ScriptableObject
|
||||
{
|
||||
public event Action<T> OnEventRaised;
|
||||
|
||||
public void Raise(T value) => OnEventRaised?.Invoke(value);
|
||||
|
||||
/// <summary>
|
||||
/// 订阅并返回可 Dispose 的订阅句柄。
|
||||
/// 推荐与 CompositeDisposable 配合使用,代替手动 OnDisable 取消注册。
|
||||
/// </summary>
|
||||
public EventSubscription Subscribe(Action<T> callback)
|
||||
{
|
||||
OnEventRaised += callback;
|
||||
return new EventSubscription(() => OnEventRaised -= callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 使用模式
|
||||
|
||||
**标准用法**(替代 OnEnable/OnDisable 手动配对):
|
||||
|
||||
```csharp
|
||||
public class HUDController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] VoidEventChannelSO _onCutsceneStarted;
|
||||
[SerializeField] VoidEventChannelSO _onCutsceneEnded;
|
||||
[SerializeField] IntEventChannelSO _onHealthChanged;
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
// ── 只写 OnEnable,OnDisable 统一 Clear ──────────────────────────
|
||||
private void OnEnable()
|
||||
{
|
||||
_subs.Add(_onCutsceneStarted.Subscribe(_ => HideHUD()));
|
||||
_subs.Add(_onCutsceneEnded.Subscribe(_ => ShowHUD()));
|
||||
_subs.Add(_onHealthChanged.Subscribe(UpdateHealthBar));
|
||||
}
|
||||
|
||||
private void OnDisable() => _subs.Clear();
|
||||
}
|
||||
```
|
||||
|
||||
**动态订阅**(运行时按需订阅,对象销毁时自动取消):
|
||||
|
||||
```csharp
|
||||
// CharmEffect SO 动态订阅(不持有 MonoBehaviour 生命周期)
|
||||
public class CharmEffect : StatusEffectSO
|
||||
{
|
||||
private EventSubscription _sub;
|
||||
|
||||
public override void OnApply(GameObject target)
|
||||
{
|
||||
var channel = EventChannelRegistry.Instance
|
||||
.Get<VoidEventChannelSO>("EVT_CharmExpired");
|
||||
_sub = channel.Subscribe(_ => OnCharmExpired());
|
||||
}
|
||||
|
||||
public override void OnRemove(GameObject target)
|
||||
{
|
||||
_sub.Dispose(); // 精确注销,无需持有 channel 引用
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.5 迁移规范
|
||||
|
||||
| 旧模式 | 新模式 | 备注 |
|
||||
|--------|--------|------|
|
||||
| `OnEnable` + `OnDisable` 手动 `+=` / `-=` | `CompositeDisposable` + `Subscribe()` | 一对多订阅的 MonoBehaviour |
|
||||
| 单条订阅 + 手动 `-=` | `EventSubscription` (`using` 或字段) | 精确生命周期控制 |
|
||||
| SO 内部动态订阅 | `EventSubscription` 字段 + `OnRemove Dispose` | CharmEffect 等运行时 SO |
|
||||
|
||||
> **注意**:`VoidEventChannelSO`(无参版)不继承 `BaseEventChannelSO<T>`,需补充等价 `Subscribe(Action callback)` 方法。原有 `OnEnable/OnDisable` 写法仍合法,迁移以新增代码为主,不强制改旧代码。
|
||||
|
||||
---
|
||||
|
||||
## 9. EventBusMonitorWindow — 运行时事件监控面板
|
||||
|
||||
> **P0 优化**:生产级调试能力。调试"玩家死了但死亡画面没有弹出"、"Boss HP 归零但胜利
|
||||
> 事件未触发"等问题时,依靠 `Debug.Log` 逐行追踪效率极低。
|
||||
> `EventBusMonitorWindow` 在 Play Mode 中实时显示所有 SO 事件频道的最近触发记录,
|
||||
> 一眼定位"谁 Raise 了什么事件、何时触发、订阅者当前有几个"。
|
||||
|
||||
### 9.1 运行时埋点 — BaseEventChannelSO 修改
|
||||
|
||||
```csharp
|
||||
// 修改: Assets/Scripts/Core/Events/BaseEventChannelSO.cs
|
||||
public abstract class BaseEventChannelSO<T> : ScriptableObject
|
||||
{
|
||||
[Multiline] public string description;
|
||||
public event Action<T> OnEventRaised;
|
||||
|
||||
public void Raise(T value)
|
||||
{
|
||||
OnEventRaised?.Invoke(value);
|
||||
#if UNITY_EDITOR
|
||||
EventBusMonitor.Record(name, value?.ToString() ?? "<void>",
|
||||
OnEventRaised?.GetInvocationList().Length ?? 0);
|
||||
#endif
|
||||
}
|
||||
// Subscribe() 见 §8.3
|
||||
}
|
||||
|
||||
// ✅ VoidBaseEventChannelSO.Raise() 已同步添加 EventBusMonitor.Record(见 §2 VoidBaseEventChannelSO)
|
||||
```
|
||||
|
||||
### 9.2 EventBusMonitor — 静态记录缓冲
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/Events/Editor/EventBusMonitor.cs
|
||||
#if UNITY_EDITOR
|
||||
namespace BaseGames.Core.Events.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 运行时事件触发记录(仅 Editor)。
|
||||
/// BaseEventChannelSO.Raise() 内部调用,CircularBuffer 避免无限增长。
|
||||
/// </summary>
|
||||
public static class EventBusMonitor
|
||||
{
|
||||
public const int MaxRecords = 200;
|
||||
|
||||
public struct EventRecord
|
||||
{
|
||||
public double Timestamp; // Time.realtimeSinceStartupAsDouble
|
||||
public string ChannelName; // SO 资产名称(如 "EVT_PlayerDied")
|
||||
public string PayloadText; // value.ToString()
|
||||
public int SubscriberCount; // 触发时订阅者数量
|
||||
public int Frame; // Time.frameCount
|
||||
}
|
||||
|
||||
private static readonly Queue<EventRecord> _records = new(MaxRecords + 1);
|
||||
public static IReadOnlyCollection<EventRecord> Records => _records;
|
||||
|
||||
// 供 EditorWindow 订阅"有新记录"通知
|
||||
public static event System.Action OnRecordAdded;
|
||||
|
||||
public static void Record(string channelName, string payload, int subCount)
|
||||
{
|
||||
if (_records.Count >= MaxRecords) _records.Dequeue();
|
||||
_records.Enqueue(new EventRecord
|
||||
{
|
||||
Timestamp = UnityEngine.Time.realtimeSinceStartupAsDouble,
|
||||
ChannelName = channelName,
|
||||
PayloadText = payload,
|
||||
SubscriberCount = subCount,
|
||||
Frame = UnityEngine.Time.frameCount,
|
||||
});
|
||||
OnRecordAdded?.Invoke();
|
||||
}
|
||||
|
||||
public static void Clear() => _records.Clear();
|
||||
|
||||
// 搜索过滤(大小写不敏感)
|
||||
public static IEnumerable<EventRecord> Filter(string keyword)
|
||||
=> string.IsNullOrEmpty(keyword)
|
||||
? _records
|
||||
: _records.Where(r => r.ChannelName.IndexOf(keyword,
|
||||
System.StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
### 9.3 EventBusMonitorWindow — EditorWindow
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Editor/EventBusMonitorWindow.cs
|
||||
#if UNITY_EDITOR
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 菜单路径: BaseGames/Tools/Event Bus Monitor
|
||||
/// 快捷键: Ctrl+Shift+E
|
||||
/// </summary>
|
||||
public class EventBusMonitorWindow : EditorWindow
|
||||
{
|
||||
[MenuItem("BaseGames/Tools/Event Bus Monitor %#e")]
|
||||
public static void Open()
|
||||
=> GetWindow<EventBusMonitorWindow>("Event Bus Monitor");
|
||||
|
||||
// ── UI 状态 ────────────────────────────────────────────────────────
|
||||
private string _filterText = "";
|
||||
private bool _autoScroll = true;
|
||||
private bool _pauseCapture = false;
|
||||
private Vector2 _scrollPos;
|
||||
private static readonly Color _zeroSubColor = new Color(1f, 0.4f, 0.4f); // 红:零订阅者触发
|
||||
private static readonly Color _normalColor = Color.white;
|
||||
|
||||
private void OnEnable()
|
||||
=> EventBusMonitor.OnRecordAdded += Repaint;
|
||||
private void OnDisable()
|
||||
=> EventBusMonitor.OnRecordAdded -= Repaint;
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
// ── 工具栏 ──────────────────────────────────────────────────
|
||||
using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
|
||||
{
|
||||
GUILayout.Label("Filter:", GUILayout.Width(40));
|
||||
_filterText = EditorGUILayout.TextField(_filterText,
|
||||
EditorStyles.toolbarSearchField, GUILayout.Width(200));
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
_pauseCapture = GUILayout.Toggle(_pauseCapture, "Pause",
|
||||
EditorStyles.toolbarButton, GUILayout.Width(50));
|
||||
_autoScroll = GUILayout.Toggle(_autoScroll, "Auto Scroll",
|
||||
EditorStyles.toolbarButton, GUILayout.Width(80));
|
||||
|
||||
if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(45)))
|
||||
EventBusMonitor.Clear();
|
||||
}
|
||||
|
||||
// ── 列标题 ──────────────────────────────────────────────────
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField("Time", GUILayout.Width(70));
|
||||
EditorGUILayout.LabelField("Frame", GUILayout.Width(55));
|
||||
EditorGUILayout.LabelField("Channel", GUILayout.Width(220));
|
||||
EditorGUILayout.LabelField("Payload", GUILayout.MinWidth(120));
|
||||
EditorGUILayout.LabelField("Subs", GUILayout.Width(40));
|
||||
}
|
||||
EditorGUILayout.LabelField("", GUI.skin.horizontalSlider);
|
||||
|
||||
// ── 记录列表 ─────────────────────────────────────────────────
|
||||
using var scroll = new EditorGUILayout.ScrollViewScope(_scrollPos);
|
||||
_scrollPos = scroll.scrollPosition;
|
||||
|
||||
var records = EventBusMonitor.Filter(_filterText).ToArray();
|
||||
foreach (var r in records)
|
||||
{
|
||||
var prevColor = GUI.color;
|
||||
GUI.color = r.SubscriberCount == 0 ? _zeroSubColor : _normalColor;
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField($"{r.Timestamp:F2}s", GUILayout.Width(70));
|
||||
EditorGUILayout.LabelField($"#{r.Frame}", GUILayout.Width(55));
|
||||
EditorGUILayout.LabelField(r.ChannelName, GUILayout.Width(220));
|
||||
EditorGUILayout.LabelField(r.PayloadText, GUILayout.MinWidth(120));
|
||||
EditorGUILayout.LabelField(r.SubscriberCount.ToString(), GUILayout.Width(40));
|
||||
}
|
||||
GUI.color = prevColor;
|
||||
}
|
||||
|
||||
if (_autoScroll && Application.isPlaying)
|
||||
_scrollPos.y = float.MaxValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
### 9.4 使用指南
|
||||
|
||||
| 场景 | 操作 |
|
||||
|------|------|
|
||||
| 调试事件未触发 | Filter 输入频道名,观察 Records;空 = Raise 从未调用 |
|
||||
| 调试无响应(Subs=0) | 行显示红色 = 有 Raise 但零订阅者,检查 OnEnable 注册 |
|
||||
| 调试重复触发 | 观察同一频道在同一 Frame 内多次出现 |
|
||||
| 性能分析 | 高频帧内 Raise 次数过多时考虑节流 |
|
||||
| 生产构建 | `#if UNITY_EDITOR` 包裹,零运行时开销 |
|
||||
967
Docs/Architecture/03_CoreModule.md
Normal file
967
Docs/Architecture/03_CoreModule.md
Normal file
@@ -0,0 +1,967 @@
|
||||
# 03 · Core 核心模块
|
||||
|
||||
> **命名空间** `BaseGames.Core`
|
||||
> **程序集** `BaseGames.Core`
|
||||
> **路径** `Assets/Scripts/Core/`
|
||||
> **依赖** `BaseGames.Core.Events`、`Newtonsoft.Json`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [GameManager](#1-gamemanager)
|
||||
2. [GameState 枚举与合法转换表](#2-gamestate-枚举与合法转换表)
|
||||
3. [SceneLoader](#3-sceneloader)
|
||||
4. [SceneLoadRequest 数据结构](#4-sceneloadrequest-数据结构)
|
||||
5. [GlobalObjectPool(引用 13_AssetPoolModule)](#5-globalobjectpool)
|
||||
6. [SettingsManager](#6-settingsmanager)
|
||||
7. [GlobalSettingsSO](#7-globalsettingsso)
|
||||
8. [死亡复活流程(时序)](#8-死亡复活流程时序)
|
||||
9. [Boss 战切换流程(时序)](#9-boss-战切换流程时序)
|
||||
10. [初始化序列(ExecutionOrder)](#10-初始化序列)
|
||||
11. [ServiceLocator — 轻量依赖注入](#11-servicelocator--轻量依赖注入)
|
||||
12. [DeathRespawnService — 死亡/复活服务拆分](#12-deathrespawnservice--死亡复活服务拆分)
|
||||
13. [SceneService — 场景管理服务拆分](#13-sceneservice--场景管理服务拆分)
|
||||
|
||||
---
|
||||
|
||||
## 1. GameManager
|
||||
|
||||
```
|
||||
路径: Assets/Scripts/Core/GameManager.cs
|
||||
程序集: BaseGames.Core
|
||||
[DefaultExecutionOrder(-1000)]
|
||||
```
|
||||
|
||||
### 字段
|
||||
|
||||
```csharp
|
||||
[Header("Event Channels - Listen")]
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||||
[SerializeField] private VoidEventChannelSO _onSavePointActivated;
|
||||
[SerializeField] private StringEventChannelSO _onBossFightStarted;
|
||||
[SerializeField] private BoolEventChannelSO _onBossFightEnded;
|
||||
[SerializeField] private VoidEventChannelSO _onPauseRequested;
|
||||
[SerializeField] private StringEventChannelSO _onSceneLoaded;
|
||||
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed; // DeathScreenController 按钮点击
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerRespawned;
|
||||
|
||||
[Header("Sub Managers(同 Persistent 场景内引用)")]
|
||||
[SerializeField] private SceneLoader _sceneLoader;
|
||||
[SerializeField] private GlobalObjectPool _objectPool; // ⚠️ 权威类名为 GlobalObjectPool(见 13_AssetPoolModule §3)
|
||||
[SerializeField] private SettingsManager _settingsManager;
|
||||
// SaveManager 是独立单例,不通过 GameManager 暴露
|
||||
|
||||
private GameState _currentState = GameState.Initializing;
|
||||
private string _lastSavePointId;
|
||||
private string _currentSceneName;
|
||||
```
|
||||
|
||||
### 公开接口
|
||||
|
||||
```csharp
|
||||
// 状态管理
|
||||
public GameState CurrentState => _currentState;
|
||||
public void TransitionTo(GameState newState); // 内含合法性检查,发布 _onGameStateChanged
|
||||
|
||||
// 场景控制(通过 SceneLoader 编排)
|
||||
public void LoadRoom(string roomSceneName, string entryTransitionId = null);
|
||||
public void ReturnToMainMenu();
|
||||
|
||||
// 暂停
|
||||
public void Pause();
|
||||
public void Resume();
|
||||
|
||||
// 复活(由死亡流程 Coroutine 最终调用)
|
||||
internal void CompleteRespawn();
|
||||
```
|
||||
|
||||
### 私有/内部方法
|
||||
|
||||
```csharp
|
||||
// 生命周期
|
||||
private void Awake(); // 初始化序列(见 §10)
|
||||
private void Start(); // 首帧后跳转 MainMenu
|
||||
|
||||
// 事件处理
|
||||
private void HandlePlayerDied(); // → TransitionTo(Dead) → 启动 DeathCoroutine
|
||||
private void HandleSavePointActivated(string saveId); // → SaveManager.SaveAsync()
|
||||
private void HandleBossFightStarted(string bossId); // → TransitionTo(BossFight)
|
||||
private void HandleBossFightEnded(bool victory); // → 根据结果决策
|
||||
private void HandlePauseRequested(); // → Pause() / Resume() 切换
|
||||
|
||||
// Coroutines
|
||||
private IEnumerator DeathSequenceCoroutine(); // 死亡动画 → 显示 DeathScreen → 等待输入
|
||||
private IEnumerator RespawnCoroutine(); // 淡出 → LoadLastSaveScene → 淡入 → 初始化
|
||||
private IEnumerator LoadSceneCoroutine(SceneLoadRequest request);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. GameState 插件化状态机
|
||||
|
||||
> **设计动机**:原 `enum GameState` 在编译期固定,DLC 若需引入新游戏模式必须修改核心枚举,违反开闭原则。本节将状态机改为 **`IGameState` 接口 + 运行时注册表**,内置 8 个状态保持不变,外部模块可通过 `RuntimeInitializeOnLoad` 注入新状态,无需重新编译核心程序集。
|
||||
|
||||
### 2.1 GameStateId — 运行时可注册状态标识
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/GameStateId.cs
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 轻量状态标识符(值类型,无堆分配)。
|
||||
/// 内置状态通过 <see cref="GameStates"/> 静态字段访问,
|
||||
/// 扩展状态在 [RuntimeInitializeOnLoad] 中调用 Register() 注入。
|
||||
/// </summary>
|
||||
public readonly struct GameStateId : System.IEquatable<GameStateId>
|
||||
{
|
||||
public readonly int Value;
|
||||
private GameStateId(int v) => Value = v;
|
||||
|
||||
private static int _nextId;
|
||||
private static readonly System.Collections.Generic.Dictionary<string, GameStateId>
|
||||
_registry = new();
|
||||
|
||||
/// <summary>注册状态;已注册则返回现有 ID(幂等)。</summary>
|
||||
public static GameStateId Register(string name)
|
||||
{
|
||||
if (_registry.TryGetValue(name, out var existing)) return existing;
|
||||
var id = new GameStateId(_nextId++);
|
||||
_registry[name] = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
public static bool TryResolve(string name, out GameStateId id)
|
||||
=> _registry.TryGetValue(name, out id);
|
||||
|
||||
public static IEnumerable<GameStateId> All => _registry.Values;
|
||||
|
||||
public bool Equals(GameStateId other) => Value == other.Value;
|
||||
public override bool Equals(object obj) => obj is GameStateId g && Equals(g);
|
||||
public override int GetHashCode() => Value;
|
||||
public static bool operator ==(GameStateId a, GameStateId b) => a.Value == b.Value;
|
||||
public static bool operator !=(GameStateId a, GameStateId b) => a.Value != b.Value;
|
||||
public override string ToString()
|
||||
{
|
||||
foreach (var kv in _registry)
|
||||
if (kv.Value == this) return kv.Key;
|
||||
return $"GameState({Value})";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>内置状态常量(静态初始化顺序确保在任何 Awake 前完成)。</summary>
|
||||
public static class GameStates
|
||||
{
|
||||
public static readonly GameStateId Initializing = GameStateId.Register("Initializing");
|
||||
public static readonly GameStateId MainMenu = GameStateId.Register("MainMenu");
|
||||
public static readonly GameStateId LoadingScene = GameStateId.Register("LoadingScene");
|
||||
public static readonly GameStateId Gameplay = GameStateId.Register("Gameplay");
|
||||
public static readonly GameStateId BossFight = GameStateId.Register("BossFight");
|
||||
public static readonly GameStateId Paused = GameStateId.Register("Paused");
|
||||
public static readonly GameStateId Dead = GameStateId.Register("Dead");
|
||||
public static readonly GameStateId Cutscene = GameStateId.Register("Cutscene");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 IGameState — 状态行为接口
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/IGameState.cs
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 可插件化的游戏状态行为接口。
|
||||
/// 每个状态封装自身的进入/退出/Tick 逻辑,不再依赖 GameManager 中的 switch 分支。
|
||||
/// </summary>
|
||||
public interface IGameState
|
||||
{
|
||||
GameStateId Id { get; }
|
||||
|
||||
/// <summary>合法的后继状态集合。GameManager.TransitionTo() 据此校验。</summary>
|
||||
IReadOnlyCollection<GameStateId> ValidNextStates { get; }
|
||||
|
||||
void OnEnter(GameStateId previousState);
|
||||
void OnExit(GameStateId nextState);
|
||||
|
||||
/// <summary>每帧由 GameManager.Update() 调用(可为空实现)。</summary>
|
||||
void Tick(float deltaTime) { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 GameStateMachine — 轻量状态机核心
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/GameStateMachine.cs
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态机核心,持有状态注册表与当前状态。
|
||||
/// GameManager 持有一个实例;状态对象在 Awake 注入(内置 + 可选 DLC 追加)。
|
||||
/// </summary>
|
||||
public class GameStateMachine
|
||||
{
|
||||
private readonly Dictionary<GameStateId, IGameState> _states = new();
|
||||
private IGameState _current;
|
||||
|
||||
public GameStateId CurrentStateId => _current?.Id ?? default;
|
||||
|
||||
/// <summary>注册状态实现(同 Id 注册多次以最后一次为准)。</summary>
|
||||
public void Register(IGameState state) => _states[state.Id] = state;
|
||||
|
||||
public bool TransitionTo(GameStateId nextId, out string error)
|
||||
{
|
||||
if (!_states.TryGetValue(nextId, out var next))
|
||||
{
|
||||
error = $"[GameStateMachine] 未知状态 '{nextId}'";
|
||||
return false;
|
||||
}
|
||||
if (_current != null && !_current.ValidNextStates.Contains(nextId))
|
||||
{
|
||||
error = $"[GameStateMachine] 非法转换 {_current.Id} → {nextId}";
|
||||
return false;
|
||||
}
|
||||
var prev = _current?.Id ?? default;
|
||||
_current?.OnExit(nextId);
|
||||
_current = next;
|
||||
_current.OnEnter(prev);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Tick(float deltaTime) => _current?.Tick(deltaTime);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 GameManager 集成
|
||||
|
||||
```csharp
|
||||
// GameManager.cs — 关键修改(其余字段不变)
|
||||
public class GameManager : MonoBehaviour
|
||||
{
|
||||
// 状态机实例(不再使用 enum currentState)
|
||||
private readonly GameStateMachine _fsm = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 注册内置状态
|
||||
_fsm.Register(new InitializingState());
|
||||
_fsm.Register(new MainMenuState(_onMainMenuEntered));
|
||||
_fsm.Register(new LoadingSceneState(_onLoadingStarted));
|
||||
_fsm.Register(new GameplayState(_inputReader, _onGameplayStarted));
|
||||
_fsm.Register(new BossFightState(_onBossFightEntered));
|
||||
_fsm.Register(new PausedState(_onGamePaused));
|
||||
_fsm.Register(new DeadState(_onPlayerDied));
|
||||
_fsm.Register(new CutsceneState(_onCutsceneStarted));
|
||||
|
||||
// DLC / 模块注入(可选;通过 [RuntimeInitializeOnLoad] 在 Awake 前注入)
|
||||
foreach (var ext in _externalStateFactories)
|
||||
_fsm.Register(ext.Create());
|
||||
|
||||
_fsm.TransitionTo(GameStates.Initializing, out _);
|
||||
}
|
||||
|
||||
// 对外仍使用 GameStateId(或兼容属性)
|
||||
public GameStateId CurrentState => _fsm.CurrentStateId;
|
||||
|
||||
public void TransitionTo(GameStateId nextState)
|
||||
{
|
||||
if (!_fsm.TransitionTo(nextState, out var err))
|
||||
Debug.LogWarning(err);
|
||||
else
|
||||
_onGameStateChanged.Raise(nextState);
|
||||
}
|
||||
|
||||
private void Update() => _fsm.Tick(Time.deltaTime);
|
||||
|
||||
// ── DLC 扩展点 ────────────────────────────────────────────────────────
|
||||
// 外部模块在 [RuntimeInitializeOnLoad(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
// 调用 GameManager.RegisterStateFactory(factory) 注入新状态
|
||||
private static readonly List<IGameStateFactory> _externalStateFactories = new();
|
||||
public static void RegisterStateFactory(IGameStateFactory factory)
|
||||
=> _externalStateFactories.Add(factory);
|
||||
}
|
||||
|
||||
/// <summary>DLC 模块实现此接口以注入新游戏状态。</summary>
|
||||
public interface IGameStateFactory
|
||||
{
|
||||
IGameState Create();
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 内置状态示例
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/States/GameplayState.cs
|
||||
public class GameplayState : IGameState
|
||||
{
|
||||
private readonly VoidEventChannelSO _onEnterChannel;
|
||||
private readonly InputReaderSO _inputReader;
|
||||
|
||||
public GameplayState(InputReaderSO input, VoidEventChannelSO onEnter)
|
||||
{
|
||||
_inputReader = input;
|
||||
_onEnterChannel = onEnter;
|
||||
}
|
||||
|
||||
public GameStateId Id => GameStates.Gameplay;
|
||||
|
||||
public IReadOnlyCollection<GameStateId> ValidNextStates { get; } =
|
||||
new HashSet<GameStateId>
|
||||
{
|
||||
GameStates.LoadingScene,
|
||||
GameStates.BossFight,
|
||||
GameStates.Paused,
|
||||
GameStates.Dead,
|
||||
GameStates.Cutscene,
|
||||
};
|
||||
|
||||
public void OnEnter(GameStateId prev)
|
||||
{
|
||||
_inputReader.EnableGameplayMap();
|
||||
_onEnterChannel.Raise();
|
||||
}
|
||||
|
||||
public void OnExit(GameStateId next)
|
||||
{
|
||||
if (next == GameStates.Paused) return; // 暂停不禁用 Gameplay map
|
||||
_inputReader.DisableGameplayMap();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**合法转换一览(同前)**:
|
||||
|
||||
```
|
||||
Initializing → MainMenu
|
||||
MainMenu → LoadingScene
|
||||
LoadingScene → MainMenu | Gameplay
|
||||
Gameplay → LoadingScene | BossFight | Paused | Dead | Cutscene
|
||||
BossFight → LoadingScene | Gameplay | Paused | Dead
|
||||
Paused → Gameplay | BossFight | MainMenu
|
||||
Dead → LoadingScene
|
||||
Cutscene → Gameplay
|
||||
```
|
||||
|
||||
> **向后兼容**:现有订阅 `GameStateEventChannelSO`(泛型参数改为 `GameStateId`)的代码,将事件类型从 `GameState`(enum)替换为 `GameStateId`(struct)即可,API 形态不变。`GameStateId == GameStateId` 值比较性能与 enum 相同(int 比较)。
|
||||
|
||||
|
||||
|
||||
```
|
||||
路径: Assets/Scripts/Core/SceneLoader.cs
|
||||
程序集: BaseGames.Core
|
||||
```
|
||||
|
||||
### 职责
|
||||
|
||||
- 监听 `EVT_SceneLoadRequest` 频道,执行异步加载
|
||||
- 管理 Persistent 场景常驻(加法加载/卸载)
|
||||
- 加载完成后发布 `EVT_SceneLoaded`
|
||||
|
||||
### 字段
|
||||
|
||||
```csharp
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
[SerializeField] private StringEventChannelSO _onSceneLoaded;
|
||||
[SerializeField] private VoidEventChannelSO _onFadeInRequest; // 触发 UI 淡入
|
||||
[SerializeField] private VoidEventChannelSO _onFadeOutRequest; // 触发 UI 淡出
|
||||
[SerializeField] private float _fadeDuration = 0.3f;
|
||||
|
||||
private string _currentRoomScene; // 当前已加载的房间场景名
|
||||
```
|
||||
|
||||
### 接口
|
||||
|
||||
```csharp
|
||||
// 由 GameManager 或 RoomTransition 触发
|
||||
public void RequestLoad(SceneLoadRequest request);
|
||||
|
||||
// 内部流程
|
||||
private IEnumerator LoadSceneCoroutine(SceneLoadRequest request);
|
||||
// 1. Raise FadeOut
|
||||
// 2. yield LoadSceneAsync(房间场景)
|
||||
// 3. 卸载旧房间场景(保留 Persistent)
|
||||
// 4. 激活新场景
|
||||
// 5. Raise SceneLoaded(供 GameManager 切状态、MapManager 更新)
|
||||
// 6. Raise FadeIn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. SceneLoadRequest 数据结构
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/SceneLoadRequest.cs
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
[System.Serializable]
|
||||
public struct SceneLoadRequest
|
||||
{
|
||||
public string SceneName; // Addressable 场景键(AddressKeys 常量)
|
||||
public string EntryTransitionId; // 玩家出生点 ID(RoomTransition 与之匹配)
|
||||
public bool ShowLoadingScreen; // 跨大区域时 true
|
||||
public bool IsRespawn; // 死亡复活时 true,不执行过渡动画
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. GlobalObjectPool
|
||||
|
||||
> ⚠️ **权威实现见 [`13_AssetPoolModule.md §3`](../Architecture/13_AssetPoolModule.md)**
|
||||
> 类名为 `GlobalObjectPool`(命名空间 `BaseGames.Core`)。
|
||||
> 本模块不重复定义,以下仅列出与 CoreModule 相关的调用入口摘要。
|
||||
|
||||
```csharp
|
||||
// 使用方式(CoreModule 内部调用)
|
||||
GlobalObjectPool.Instance.WarmupAsync(); // SceneLoader 步骤 4 调用
|
||||
GlobalObjectPool.Instance.Spawn(addressKey, pos, rot); // 取出实例
|
||||
GlobalObjectPool.Instance.Despawn(addressKey, instance); // 归还实例
|
||||
GlobalObjectPool.Instance.ClearPool(addressKey); // 场景卸载时清除指定池
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. SettingsManager
|
||||
|
||||
```
|
||||
路径: Assets/Scripts/Core/SettingsManager.cs
|
||||
程序集: BaseGames.Core
|
||||
[DefaultExecutionOrder(-800)]
|
||||
```
|
||||
|
||||
### 职责
|
||||
|
||||
- 从 `GlobalSettingsSO` 读取默认值,从 PlayerPrefs / 独立设置文件加载用户覆盖
|
||||
- 应用设置到 AudioMixer、Screen、InputSystem
|
||||
- 提供运行时修改接口(供 SettingsPanel 调用)
|
||||
|
||||
### 字段
|
||||
|
||||
```csharp
|
||||
[SerializeField] private GlobalSettingsSO _defaultSettings;
|
||||
[SerializeField] private FloatEventChannelSO _onMasterVolumeChanged; // 同步 HUD
|
||||
|
||||
private GlobalSettingsData _current; // 运行时值(从文件读取)
|
||||
```
|
||||
|
||||
### 接口
|
||||
|
||||
```csharp
|
||||
public void Initialize(); // GameManager.Awake 调用
|
||||
|
||||
// 音量(0-1 线性;内部调用 AudioManager.Instance.SetVolume(AudioMixerKeys.*, value) 写入 AudioMixer)
|
||||
public void SetMasterVolume(float value) => AudioManager.Instance.SetVolume(AudioMixerKeys.Master, value);
|
||||
public void SetBGMVolume(float value) => AudioManager.Instance.SetVolume(AudioMixerKeys.BGM, value);
|
||||
public void SetSFXVolume(float value) => AudioManager.Instance.SetVolume(AudioMixerKeys.SFX, value);
|
||||
public void SetAmbientVolume(float value) => AudioManager.Instance.SetVolume(AudioMixerKeys.Ambient, value);
|
||||
|
||||
// 画面
|
||||
public void SetResolution(int width, int height, FullScreenMode mode);
|
||||
public void SetVSync(bool enabled);
|
||||
public void SetTargetFrameRate(int fps); // 仅非 VSync 时生效
|
||||
|
||||
// 语言
|
||||
public void SetLanguage(string localeCode); // 调用 LocalizationSettings.SelectedLocale
|
||||
|
||||
// 保存(写入独立设置文件)
|
||||
public void Save();
|
||||
|
||||
// 当前值访问
|
||||
public GlobalSettingsData Current => _current;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. GlobalSettingsSO
|
||||
|
||||
```
|
||||
路径: Assets/Scripts/Core/GlobalSettingsSO.cs
|
||||
资产: Assets/Data/Settings/SET_GlobalSettings.asset
|
||||
```
|
||||
|
||||
```csharp
|
||||
[CreateAssetMenu(menuName = "Settings/GlobalSettings")]
|
||||
public class GlobalSettingsSO : ScriptableObject
|
||||
{
|
||||
[Header("Audio")]
|
||||
public float DefaultMasterVolume = 1f;
|
||||
public float DefaultBGMVolume = 0.8f;
|
||||
public float DefaultSFXVolume = 1f;
|
||||
public float DefaultAmbientVolume = 0.8f; // ⚠️ 与 AudioMixerKeys.Ambient 对应
|
||||
|
||||
[Header("Display")]
|
||||
public int DefaultTargetFPS = 60;
|
||||
public bool DefaultVSync = false;
|
||||
|
||||
[Header("Language")]
|
||||
public string DefaultLocaleCode = "zh-CN";
|
||||
|
||||
[Header("Accessibility")]
|
||||
public bool DefaultHighContrast = false;
|
||||
public bool DefaultScreenShake = true;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public class GlobalSettingsData
|
||||
{
|
||||
public float MasterVolume;
|
||||
public float BGMVolume;
|
||||
public float SFXVolume;
|
||||
public float AmbientVolume; // ⚠️ 与 AudioMixerKeys.Ambient 对应
|
||||
public int TargetFPS;
|
||||
public bool VSync;
|
||||
public string LocaleCode;
|
||||
public bool HighContrast;
|
||||
public bool ScreenShake;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 死亡复活流程(时序)
|
||||
|
||||
```
|
||||
[PlayerStats] HP ≤ 0
|
||||
→ Raise EVT_PlayerDied
|
||||
|
||||
[GameManager] HandlePlayerDied()
|
||||
→ TransitionTo(GameState.Dead)
|
||||
→ Raise EVT_GameStateChanged(Dead)
|
||||
← [UIManager] 禁用 HUD 输入
|
||||
← [AudioManager] 播放死亡音乐/SFX
|
||||
→ StartCoroutine(DeathSequenceCoroutine)
|
||||
1. 等待死亡动画完成(约 1.5s)
|
||||
2. (DeathScreenController 订阅 EVT_PlayerDied,延迟 1.5s 后自动显示死亡画面;无需 GameManager 直接调用 UI)
|
||||
3. 等待玩家按下"重试"(监听 EVT_DeathScreenConfirmed)
|
||||
|
||||
[GameManager] RespawnCoroutine()
|
||||
→ TransitionTo(GameState.LoadingScene)
|
||||
→ SceneLoader.RequestLoad(lastSaveScene, isRespawn: true)
|
||||
→ 淡出
|
||||
→ 卸载当前房间场景
|
||||
→ 加载存档点对应场景
|
||||
→ SaveManager 恢复玩家状态(HP = MaxHP×0.67,灵泉=1)
|
||||
→ 淡入
|
||||
→ TransitionTo(GameState.Gameplay)
|
||||
→ Raise EVT_PlayerRespawned
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Boss 战切换流程(时序)
|
||||
|
||||
```
|
||||
[BossOrchestrator] 玩家进入 Boss 房间触发器
|
||||
→ Raise EVT_BossFightStarted("boss_forest")
|
||||
|
||||
[GameManager] HandleBossFightStarted(bossId)
|
||||
→ TransitionTo(GameState.BossFight)
|
||||
← [AudioManager] 切换 Boss BGM(AudioMixer 快照)
|
||||
← [UIManager] 显示 Boss 血量条
|
||||
← [CinemachineCamera] 切换为 BossCamera 虚拟摄像机
|
||||
|
||||
[BossOrchestrator] Boss 死亡
|
||||
→ Raise EVT_BossFightEnded(victory: true)
|
||||
|
||||
[GameManager] HandleBossFightEnded(true)
|
||||
→ TransitionTo(GameState.Gameplay)
|
||||
← [AudioManager] 恢复常规 BGM
|
||||
← [UIManager] 隐藏 Boss 血量条
|
||||
→ 触发剧情 / 过场(如有)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 初始化序列
|
||||
|
||||
```csharp
|
||||
// GameManager.Awake()
|
||||
[DefaultExecutionOrder(-1000)]
|
||||
private void Awake()
|
||||
{
|
||||
DontDestroyOnLoad(gameObject); // Persistent 场景随 GameManager 常驻
|
||||
|
||||
Application.targetFrameRate = 60;
|
||||
Physics2D.simulationMode = SimulationMode2D.Script; // 手动物理步进
|
||||
|
||||
// 子系统初始化(按依赖顺序)
|
||||
_settingsManager.Initialize(); // 1. 读取设置文件,应用音量/分辨率
|
||||
// SaveManager 自初始化(SaveManager.Awake 先于 GameManager.Start)
|
||||
// GlobalObjectPool 无 Initialize() 方法;预热由 SceneLoader.LoadSceneCoroutine 步骤 4
|
||||
// 调用 GlobalObjectPool.Instance.WarmupAsync() 触发(见 13_AssetPoolModule §6)
|
||||
// DifficultyManager 自初始化(DefaultExecutionOrder: -900,早于本 Awake)
|
||||
// → 读取 SaveData.Meta.CurrentDifficulty
|
||||
// → Apply(level) → 广播 EVT_DifficultyChanged
|
||||
// → EnemyStats / PlayerStats / ShopController 等系统订阅此事件后注入难度系数
|
||||
// → 详见 19_DifficultyModule.md
|
||||
|
||||
TransitionTo(GameState.Initializing);
|
||||
}
|
||||
|
||||
private IEnumerator Start()
|
||||
{
|
||||
yield return null; // 等待首帧渲染
|
||||
TransitionTo(GameState.MainMenu);
|
||||
// → UIManager 收到 EVT_GameStateChanged(MainMenu) → 显示 MainMenuPanel
|
||||
// → AudioManager 收到 → 播放主菜单 BGM
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. ServiceLocator — 轻量依赖注入
|
||||
|
||||
> **P0 优化**:原项目中 `AudioManager.Instance`、`EventChannelRegistry.Instance`、`SaveManager` 等均为静态单例,
|
||||
> 在多场景 Play Mode 快速进入(Direct Play from Room scene)时若 Persistent 场景未加载,
|
||||
> `Instance` 为 null 导致崩溃;测试时也无法替换为 Mock 实现。
|
||||
> 引入 `ServiceLocator` 替代所有硬编码 `Instance`,保留 Inspector 序列化注入路径(无需引入 Zenject/VContainer 等外部 DI 框架)。
|
||||
|
||||
### 11.1 ServiceLocator 核心
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/ServiceLocator.cs
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 轻量服务定位器。
|
||||
/// - 通过类型键注册/查找服务,支持接口类型注册(依赖倒置)
|
||||
/// - 在 Persistent 场景 Awake(ExecutionOrder -2000)批量注册全局服务
|
||||
/// - 支持 Fallback:找不到服务时返回 NullObject 而非 null(防崩溃)
|
||||
/// - Editor 下 Play Mode "Enter Play Mode Without Domain Reload" 友好(静态字典不清空)
|
||||
/// </summary>
|
||||
public static class ServiceLocator
|
||||
{
|
||||
private static readonly Dictionary<Type, object> _services = new();
|
||||
|
||||
// ── 注册 ────────────────────────────────────────────────────────
|
||||
/// <summary>以接口类型 TInterface 注册实现 impl。</summary>
|
||||
public static void Register<TInterface>(TInterface impl)
|
||||
=> _services[typeof(TInterface)] = impl;
|
||||
|
||||
/// <summary>仅当尚未注册时才注册(防多场景重复注册同一服务)。</summary>
|
||||
public static void RegisterIfAbsent<TInterface>(TInterface impl)
|
||||
{
|
||||
if (!_services.ContainsKey(typeof(TInterface)))
|
||||
_services[typeof(TInterface)] = impl;
|
||||
}
|
||||
|
||||
// ── 查找 ────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 查找服务。未注册时返回 default(T为class则为null)并输出LogError。
|
||||
/// </summary>
|
||||
public static TInterface Get<TInterface>()
|
||||
{
|
||||
if (_services.TryGetValue(typeof(TInterface), out var svc) && svc is TInterface typed)
|
||||
return typed;
|
||||
// 未注册时抛出明确异常(带上下文),而非返回 null 造成延迟 NullReferenceException
|
||||
throw new InvalidOperationException(
|
||||
$"[ServiceLocator] Service '{typeof(TInterface).Name}' is not registered. "
|
||||
+ "Ensure GameServiceRegistrar.Awake() has run before this call "
|
||||
+ "(ExecutionOrder -2000). For optional services use GetOrDefault<T>().");
|
||||
}
|
||||
|
||||
/// <summary>安全版 Get:未注册时返回 fallback,不报错(适用于可选服务)。</summary>
|
||||
public static TInterface GetOrDefault<TInterface>(TInterface fallback = default)
|
||||
=> _services.TryGetValue(typeof(TInterface), out var svc) && svc is TInterface typed
|
||||
? typed : fallback;
|
||||
|
||||
// ── 测试支持 ─────────────────────────────────────────────────────
|
||||
/// <summary>单元测试中替换服务实现(仅在 UNITY_EDITOR 或 TEST 编译条件下可用)。</summary>
|
||||
[System.Diagnostics.Conditional("UNITY_EDITOR")]
|
||||
public static void OverrideForTest<TInterface>(TInterface mock)
|
||||
=> _services[typeof(TInterface)] = mock;
|
||||
|
||||
[System.Diagnostics.Conditional("UNITY_EDITOR")]
|
||||
public static void Reset() => _services.Clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.2 全局服务接口清单
|
||||
|
||||
| 接口 | 实现类 | NullObject 实现 |
|
||||
|------|--------|----------------|
|
||||
| `IAudioService` | `AudioManager` | `NullAudioService` |
|
||||
| `ISaveService` | `SaveManager` | — |
|
||||
| `ISceneService` | `SceneService`(见 §13) | — |
|
||||
| `IDeathRespawnService` | `DeathRespawnService`(见 §12) | — |
|
||||
| `IEventChannelRegistry` | `EventChannelRegistry` | — |
|
||||
|
||||
### 11.3 服务注册器
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/GameServiceRegistrar.cs
|
||||
// Persistent 场景内挂载,最早执行(ExecutionOrder -2000)
|
||||
[DefaultExecutionOrder(-2000)]
|
||||
public class GameServiceRegistrar : MonoBehaviour
|
||||
{
|
||||
[Header("服务实现引用(Inspector 拖拽)")]
|
||||
[SerializeField] private AudioManager _audioManager;
|
||||
[SerializeField] private SaveManager _saveManager;
|
||||
[SerializeField] private SceneService _sceneService;
|
||||
[SerializeField] private DeathRespawnService _deathRespawnService;
|
||||
[SerializeField] private EventChannelRegistry _eventChannelRegistry;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
ServiceLocator.Register<IAudioService>(_audioManager);
|
||||
ServiceLocator.Register<ISaveService>(_saveManager);
|
||||
ServiceLocator.Register<ISceneService>(_sceneService);
|
||||
ServiceLocator.Register<IDeathRespawnService>(_deathRespawnService);
|
||||
ServiceLocator.Register<IEventChannelRegistry>(_eventChannelRegistry);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.4 消费示例(替代旧 Singleton)
|
||||
|
||||
```csharp
|
||||
// ❌ 旧写法(直接单例,测试不友好)
|
||||
AudioManager.Instance.PlaySFX(clip);
|
||||
|
||||
// ✅ 新写法(ServiceLocator,可 Mock)
|
||||
ServiceLocator.Get<IAudioService>().PlaySFX(clip);
|
||||
|
||||
// ✅ 可选服务(如 PlatformManager 仅 Steam 平台存在)
|
||||
ServiceLocator.GetOrDefault<IPlatformService>(NullPlatformService.Instance)
|
||||
.UnlockAchievement("ACH_FirstKill");
|
||||
```
|
||||
|
||||
### 11.5 IAudioService 接口
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/IAudioService.cs
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
public interface IAudioService
|
||||
{
|
||||
void PlayBGM(AudioCueSO cue, float fadeInTime = 1.0f);
|
||||
void StopBGM(float fadeOutTime = 1.0f);
|
||||
void PlaySFX(AudioCueSO cue);
|
||||
void PlaySFXAtPosition(AudioCueSO cue, Vector2 position);
|
||||
void SetMixerVolume(string paramName, float linearVolume); // 0..1 → dB
|
||||
void TransitionToSnapshot(string snapshotName, float transitionTime);
|
||||
AudioCueSO GetCurrentBGM();
|
||||
}
|
||||
|
||||
/// <summary>静默实现,用于测试和 BGM 静默场景。</summary>
|
||||
public class NullAudioService : IAudioService
|
||||
{
|
||||
public static readonly NullAudioService Instance = new();
|
||||
public void PlayBGM(AudioCueSO c, float t = 1f) { }
|
||||
public void StopBGM(float t = 1f) { }
|
||||
public void PlaySFX(AudioCueSO c) { }
|
||||
public void PlaySFXAtPosition(AudioCueSO c, Vector2 p) { }
|
||||
public void SetMixerVolume(string n, float v) { }
|
||||
public void TransitionToSnapshot(string n, float t) { }
|
||||
public AudioCueSO GetCurrentBGM() => null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. DeathRespawnService — 死亡/复活服务拆分
|
||||
|
||||
> **P1 优化**:原 `GameManager` 同时管理状态机 + 死亡序列协程 + 场景切换,违反 SRP。
|
||||
> 将死亡/复活流程提取为独立的 `DeathRespawnService`,GameManager 只负责
|
||||
> 监听事件 → 委托给对应服务 → 进行状态转换。
|
||||
|
||||
### 12.1 IDeathRespawnService 接口
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/IDeathRespawnService.cs
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
public interface IDeathRespawnService
|
||||
{
|
||||
/// <summary>玩家死亡时由 GameManager 调用,启动死亡演出流程。</summary>
|
||||
UniTask StartDeathSequenceAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>DeathScreen 确认按钮点击后调用,执行复活流程。</summary>
|
||||
UniTask StartRespawnAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>SteelSoul 模式:HP 归零后直接清档并返回主菜单。</summary>
|
||||
UniTask StartGameOverAsync(CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.2 DeathRespawnService 实现
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/DeathRespawnService.cs
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 死亡 / 复活流程的独立服务。
|
||||
/// 依赖:ISceneService(场景切换)、ISaveService(加载存档)、
|
||||
/// GameStateMachine(状态转换通知)、LoadingOverlay(淡入淡出)。
|
||||
/// 所有依赖由 ServiceLocator 在 Awake 时解析,不持有 GameManager 引用。
|
||||
/// </summary>
|
||||
public class DeathRespawnService : MonoBehaviour, IDeathRespawnService
|
||||
{
|
||||
[Header("Config")]
|
||||
[SerializeField] private float _deathAnimDuration = 1.2f; // 死亡动画时长
|
||||
[SerializeField] private float _deathScreenDelay = 0.5f; // 动画结束 → 死亡画面
|
||||
[SerializeField] private float _respawnFadeDuration = 0.4f; // 淡出/淡入时长
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnStarted;
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnCompleted;
|
||||
|
||||
[Header("Event Channels - Listen")]
|
||||
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
|
||||
|
||||
// 内部等待 DeathScreen 用户确认
|
||||
private UniTaskCompletionSource _deathConfirmTcs;
|
||||
|
||||
private void OnEnable()
|
||||
=> _onDeathScreenConfirmed.OnEventRaised += HandleDeathScreenConfirmed;
|
||||
private void OnDisable()
|
||||
=> _onDeathScreenConfirmed.OnEventRaised -= HandleDeathScreenConfirmed;
|
||||
private void HandleDeathScreenConfirmed()
|
||||
=> _deathConfirmTcs?.TrySetResult();
|
||||
|
||||
// ── IDeathRespawnService ──────────────────────────────────────
|
||||
public async UniTask StartDeathSequenceAsync(CancellationToken ct = default)
|
||||
{
|
||||
// 1. 等待死亡动画
|
||||
await UniTask.Delay(TimeSpan.FromSeconds(_deathAnimDuration), cancellationToken: ct);
|
||||
// 2. 等待 DeathScreen 延迟
|
||||
await UniTask.Delay(TimeSpan.FromSeconds(_deathScreenDelay), cancellationToken: ct);
|
||||
// 3. 等待玩家在死亡画面确认(UI → EVT_DeathScreenConfirmed)
|
||||
_deathConfirmTcs = new UniTaskCompletionSource();
|
||||
await _deathConfirmTcs.Task.AttachExternalCancellation(ct);
|
||||
}
|
||||
|
||||
public async UniTask StartRespawnAsync(CancellationToken ct = default)
|
||||
{
|
||||
var sceneService = ServiceLocator.Get<ISceneService>();
|
||||
var saveService = ServiceLocator.Get<ISaveService>();
|
||||
|
||||
_onRespawnStarted.Raise();
|
||||
// 1. 淡出
|
||||
await ServiceLocator.Get<LoadingOverlay>().FadeOutAsync(_respawnFadeDuration, ct);
|
||||
// 2. 加载最后存档场景
|
||||
var saveData = await saveService.LoadCurrentSlotAsync(ct);
|
||||
await sceneService.LoadSceneAsync(new SceneLoadRequest
|
||||
{
|
||||
SceneName = saveData.Player.Scene,
|
||||
EntryTransitionId = saveData.Meta.SavePointId,
|
||||
IsRespawn = true,
|
||||
}, ct);
|
||||
// 3. 淡入
|
||||
await ServiceLocator.Get<LoadingOverlay>().FadeInAsync(_respawnFadeDuration, ct);
|
||||
_onRespawnCompleted.Raise();
|
||||
}
|
||||
|
||||
public async UniTask StartGameOverAsync(CancellationToken ct = default)
|
||||
{
|
||||
// SteelSoul:清档 → 返回主菜单
|
||||
await ServiceLocator.Get<ISaveService>().DeleteCurrentSlotAsync(ct);
|
||||
await ServiceLocator.Get<ISceneService>().LoadMainMenuAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 12.3 重构后 GameManager 变化
|
||||
|
||||
```csharp
|
||||
// GameManager 精简为:事件监听 → 委托服务 → 状态转换
|
||||
private async UniTaskVoid HandlePlayerDied()
|
||||
{
|
||||
_stateMachine.TransitionTo(GameStates.Dead);
|
||||
await ServiceLocator.Get<IDeathRespawnService>().StartDeathSequenceAsync(_cts.Token);
|
||||
// DeathScreen 确认后触发复活
|
||||
_stateMachine.TransitionTo(GameStates.Loading);
|
||||
await ServiceLocator.Get<IDeathRespawnService>().StartRespawnAsync(_cts.Token);
|
||||
_stateMachine.TransitionTo(GameStates.Playing);
|
||||
}
|
||||
```
|
||||
|
||||
> **协程 → UniTask 迁移**:`DeathRespawnService` 全程使用 UniTask,消除 `IEnumerator` 混用问题。
|
||||
> `GameManager.Start()` 改为 `private async UniTaskVoid Start()`。
|
||||
> `_cts = new CancellationTokenSource()` 在 `OnDestroy()` 时 `.Cancel()` + `.Dispose()`。
|
||||
|
||||
---
|
||||
|
||||
## 13. SceneService — 场景管理服务拆分
|
||||
|
||||
> **P1 优化**:原 `GameManager.LoadSceneCoroutine` 内嵌了场景加载、LoadingOverlay 控制、
|
||||
> Addressables 预热触发等逻辑。提取为独立的 `SceneService`,GameManager 调用
|
||||
> `ServiceLocator.Get<ISceneService>().LoadSceneAsync()` 即可。
|
||||
|
||||
### 13.1 ISceneService 接口
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/ISceneService.cs
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
public interface ISceneService
|
||||
{
|
||||
/// <summary>通用场景加载(带 LoadingOverlay + Addressables 预热)。</summary>
|
||||
UniTask LoadSceneAsync(SceneLoadRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>返回主菜单(清理所有 Additive 场景后加载 MainMenu)。</summary>
|
||||
UniTask LoadMainMenuAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>当前已加载场景名称(Persistent 除外)。</summary>
|
||||
string CurrentSceneName { get; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 13.2 SceneService 实现
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Core/SceneService.cs
|
||||
[DefaultExecutionOrder(-900)]
|
||||
public class SceneService : MonoBehaviour, ISceneService
|
||||
{
|
||||
[Header("Config")]
|
||||
[SerializeField] private WarmupManifestSO _warmupManifest; // 见 13_AssetPoolModule §12
|
||||
[SerializeField] private LoadingOverlay _loadingOverlay;
|
||||
|
||||
public string CurrentSceneName { get; private set; }
|
||||
|
||||
public async UniTask LoadSceneAsync(SceneLoadRequest request, CancellationToken ct = default)
|
||||
{
|
||||
// 1. 淡出
|
||||
await _loadingOverlay.FadeOutAsync(0.3f, ct);
|
||||
|
||||
// 2. 卸载当前场景(保留 Persistent)
|
||||
if (!string.IsNullOrEmpty(CurrentSceneName))
|
||||
await Addressables.UnloadSceneAsync(CurrentSceneName).ToUniTask(ct);
|
||||
|
||||
// 3. 加载新场景
|
||||
var handle = Addressables.LoadSceneAsync(request.SceneName,
|
||||
LoadSceneMode.Additive, activateOnLoad: false);
|
||||
await handle.ToUniTask(ct);
|
||||
|
||||
// 4. Addressables 预热(见 13_AssetPoolModule §9)
|
||||
await ServiceLocator.Get<GlobalObjectPool>()
|
||||
.WarmupFromManifestAsync(_warmupManifest, ct);
|
||||
|
||||
// 5. 激活场景
|
||||
await handle.Result.ActivateAsync().ToUniTask(ct);
|
||||
CurrentSceneName = request.SceneName;
|
||||
|
||||
// 6. 淡入
|
||||
await _loadingOverlay.FadeInAsync(0.3f, ct);
|
||||
}
|
||||
|
||||
public async UniTask LoadMainMenuAsync(CancellationToken ct = default)
|
||||
=> await LoadSceneAsync(new SceneLoadRequest
|
||||
{ SceneName = AddressKeys.SceneMainMenu }, ct);
|
||||
}
|
||||
```
|
||||
503
Docs/Architecture/04_InputModule.md
Normal file
503
Docs/Architecture/04_InputModule.md
Normal file
@@ -0,0 +1,503 @@
|
||||
# 04 · 输入模块
|
||||
|
||||
> **命名空间** `BaseGames.Input`
|
||||
> **程序集** `BaseGames.Input`
|
||||
> **路径** `Assets/Scripts/Input/`
|
||||
> **依赖** `BaseGames.Core.Events`、`Unity.InputSystem`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [InputReaderSO](#1-inputreaderso)
|
||||
2. [InputReaderSO 字段与事件完整列表](#2-inputreaderso-字段与事件完整列表)
|
||||
3. [Input Actions 资产结构](#3-input-actions-资产结构)
|
||||
4. [InputBuffer — 输入缓冲](#4-inputbuffer--输入缓冲)
|
||||
5. [Action Map 切换规则](#5-action-map-切换规则)
|
||||
6. [按键重绑定接口](#6-按键重绑定接口)
|
||||
7. [Persistent 场景挂载](#7-persistent-场景挂载)
|
||||
|
||||
---
|
||||
|
||||
## 1. InputReaderSO
|
||||
|
||||
```
|
||||
路径: Assets/Scripts/Input/InputReaderSO.cs
|
||||
资产: Assets/Data/Player/PLY_InputReader.asset(全局单例 SO)
|
||||
程序集: BaseGames.Input
|
||||
```
|
||||
|
||||
`InputReaderSO` 是输入系统的**唯一门面**。内部持有生成的 `PlayerInputActions` C# 类,向外暴露纯 C# `event Action` 委托,各游戏系统通过订阅这些事件获取输入,**不直接持有 `PlayerInput` 组件引用**。
|
||||
|
||||
```csharp
|
||||
[CreateAssetMenu(menuName = "Input/InputReader")]
|
||||
public class InputReaderSO : ScriptableObject, IInputActionCollection2
|
||||
{
|
||||
// 内部持有生成的 Input Actions
|
||||
private PlayerInputActions _actions;
|
||||
|
||||
// EventChannel 引用(SO→SO 注入,供无法直接订阅 C# 事件的全局系统使用)
|
||||
[SerializeField] private VoidEventChannelSO _onPauseRequested; // → EVT_PauseRequested(GameManager 订阅)
|
||||
|
||||
// 在首次访问时懒初始化(OnEnable 兜底)
|
||||
private void OnEnable() => _actions ??= new PlayerInputActions();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. InputReaderSO 字段与事件完整列表
|
||||
|
||||
### 移动
|
||||
|
||||
```csharp
|
||||
public event Action<Vector2> MoveEvent; // 持续:方向向量
|
||||
public Vector2 MoveInput { get; } // 当前帧值(Polling 用)
|
||||
```
|
||||
|
||||
### 跳跃
|
||||
|
||||
```csharp
|
||||
public event Action JumpStartedEvent; // 按下(触发跳跃)
|
||||
public event Action JumpCancelledEvent; // 松开(可变跳跃高度 CutJump)
|
||||
```
|
||||
|
||||
### 攻击
|
||||
|
||||
```csharp
|
||||
public event Action AttackEvent; // 普通攻击按下
|
||||
public event Action DownAttackEvent; // 下劈(按住下+攻击,或独立键位)
|
||||
public event Action UpAttackEvent; // 上劈(按住上+攻击,或独立键位)
|
||||
```
|
||||
|
||||
### 弹反
|
||||
|
||||
```csharp
|
||||
public event Action ParryEvent; // 弹反按下
|
||||
```
|
||||
|
||||
### 冲刺
|
||||
|
||||
```csharp
|
||||
public event Action DashEvent; // 冲刺按下
|
||||
```
|
||||
|
||||
### 灵泉
|
||||
|
||||
```csharp
|
||||
public event Action UseSpringEvent; // 使用灵泉(消耗 SpringCharges)
|
||||
```
|
||||
|
||||
### 形态切换
|
||||
|
||||
```csharp
|
||||
public event Action SwitchSkyFormEvent; // 天魂形态
|
||||
public event Action SwitchEarthFormEvent; // 地魂形态
|
||||
public event Action SwitchDeathFormEvent; // 命魂形态
|
||||
```
|
||||
|
||||
### 技能
|
||||
|
||||
```csharp
|
||||
public event Action SoulSkillEvent; // 当前形态魂技能(消耗灵力)
|
||||
public event Action SpiritSkill1StartedEvent; // 魄技能 1 按下
|
||||
public event Action SpiritSkill1CancelledEvent; // 魄技能 1 松开(蓄力型)
|
||||
public event Action SpiritSkill2StartedEvent; // 魄技能 2 按下
|
||||
public event Action SpiritSkill2CancelledEvent; // 魄技能 2 松开(蓄力型)
|
||||
```
|
||||
|
||||
### 交互
|
||||
|
||||
```csharp
|
||||
public event Action InteractEvent; // 与 NPC/物件交互
|
||||
```
|
||||
|
||||
### UI
|
||||
|
||||
```csharp
|
||||
public event Action PauseEvent; // 暂停键:同时 Raise _onPauseRequested(→ EVT_PauseRequested)
|
||||
public event Action<Vector2> NavigateEvent; // UI 导航
|
||||
public event Action SubmitEvent; // 确认
|
||||
public event Action CancelEvent; // 取消
|
||||
public event Action<Vector2> PointEvent; // 鼠标/触摸位置(UI Action Map Point)
|
||||
```
|
||||
|
||||
### Action Map 切换
|
||||
|
||||
```csharp
|
||||
public void EnableGameplayInput(); // 启用 Gameplay Map,禁用 UI Map
|
||||
public void EnableUIInput(); // 启用 UI Map,禁用 Gameplay Map
|
||||
public void DisableAllInput(); // 全部禁用(过场/加载中)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Actions 资产结构
|
||||
|
||||
```
|
||||
资产路径: Assets/Settings/PlayerInputActions.inputactions
|
||||
```
|
||||
|
||||
### Gameplay Action Map
|
||||
|
||||
| Action 名 | 类型 | 绑定(键盘/手柄) |
|
||||
|-----------|------|----------------|
|
||||
| `Move` | Value Vector2 | WASD / 左摇杆 |
|
||||
| `Jump` | Button | Space / 南键(×/A) |
|
||||
| `Attack` | Button | J / 西键(□/X) |
|
||||
| `Parry` | Button | K / 北键(△/Y) |
|
||||
| `Dash` | Button | L / 东键(○/B) |
|
||||
| `UseSpring` | Button | H / R1 |
|
||||
| `SwitchSkyForm` | Button | 1 / D-Pad Up |
|
||||
| `SwitchEarthForm` | Button | 2 / D-Pad Down |
|
||||
| `SwitchDeathForm` | Button | 3 / D-Pad Right |
|
||||
| `SoulSkill` | Button | U / L2 |
|
||||
| `SpiritSkill1` | Button | I / R2(带 hold 取消事件)|
|
||||
| `SpiritSkill2` | Button | O / L1(带 hold 取消事件)|
|
||||
| `Interact` | Button | F / D-Pad Left |
|
||||
| `Pause` | Button | Esc / Start |
|
||||
|
||||
### UI Action Map
|
||||
|
||||
| Action 名 | Value 类型 | 绑定 |
|
||||
|-----------|-----------|------|
|
||||
| `Navigate` | Vector2 | 方向键 / 左摇杆 |
|
||||
| `Submit` | Button | Enter / 南键 |
|
||||
| `Cancel` | Button | Esc / 东键 |
|
||||
| `Pause` | Button | Esc / Start |
|
||||
| `Point` | Value (Vector2) | 鼠标/触摸位置(UI 点击坐标) |
|
||||
|
||||
---
|
||||
|
||||
## 4. InputBuffer — 输入缓冲
|
||||
|
||||
`InputBuffer` 是挂在 `PlayerController` 同一 GameObject 上的组件,不独立存在。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Input/InputBuffer.cs
|
||||
public class InputBuffer : MonoBehaviour
|
||||
{
|
||||
// 各动作独立缓冲时长(与 Design/01 §4 缓冲时长配置表一致)
|
||||
[SerializeField] private float _jumpBufferDuration = 0.15f; // 跳跃:宽容窗口
|
||||
[SerializeField] private float _attackBufferDuration = 0.12f; // 攻击:接续连段
|
||||
[SerializeField] private float _dashBufferDuration = 0.10f; // 冲刺:小量容错
|
||||
// 弹反 / UseSpring / SoulSkill / SpiritSkill 缓冲时长为 0s(不缓冲)
|
||||
[SerializeField] private InputReaderSO _inputReader;
|
||||
|
||||
// 各动作的缓冲计时器
|
||||
private float _jumpBuffer;
|
||||
private float _attackBuffer;
|
||||
private float _dashBuffer;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 每帧倒计时
|
||||
_jumpBuffer = Mathf.Max(0, _jumpBuffer - Time.deltaTime);
|
||||
_attackBuffer = Mathf.Max(0, _attackBuffer - Time.deltaTime);
|
||||
_dashBuffer = Mathf.Max(0, _dashBuffer - Time.deltaTime);
|
||||
}
|
||||
|
||||
// 检测(各 State 在 OnStateEnter 调用)
|
||||
public bool ConsumeJump() { if (_jumpBuffer > 0) { _jumpBuffer = 0; return true; } return false; }
|
||||
public bool ConsumeAttack() { if (_attackBuffer > 0) { _attackBuffer = 0; return true; } return false; }
|
||||
public bool ConsumeDash() { if (_dashBuffer > 0) { _dashBuffer = 0; return true; } return false; }
|
||||
|
||||
// 写入(订阅 InputReaderSO 事件)
|
||||
private void OnEnable()
|
||||
{
|
||||
_inputReader.JumpStartedEvent += () => _jumpBuffer = _jumpBufferDuration;
|
||||
_inputReader.AttackEvent += () => _attackBuffer = _attackBufferDuration;
|
||||
_inputReader.DashEvent += () => _dashBuffer = _dashBufferDuration;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_inputReader.JumpStartedEvent -= () => _jumpBuffer = _jumpBufferDuration;
|
||||
_inputReader.AttackEvent -= () => _attackBuffer = _attackBufferDuration;
|
||||
_inputReader.DashEvent -= () => _dashBuffer = _dashBufferDuration;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Coyote Time** 实现位于 `PlayerMovement`(不在此模块),见[05_PlayerModule.md](./05_PlayerModule.md)。
|
||||
|
||||
---
|
||||
|
||||
## 5. Action Map 切换规则
|
||||
|
||||
| 游戏状态(GameState) | 输入 Map | 调用方 |
|
||||
|----------------------|---------|--------|
|
||||
| `Initializing` / `LoadingScene` | `DisableAllInput()` | `GameManager`(订阅 EVT_GameStateChanged) |
|
||||
| `MainMenu` | `EnableUIInput()` | `GameManager` |
|
||||
| `Gameplay` / `BossFight` | `EnableGameplayInput()` | `GameManager` |
|
||||
| `Paused` | `EnableUIInput()` | `GameManager` |
|
||||
| `Dead` | `DisableAllInput()`,然后 DeathScreen 出现后 `EnableUIInput()` | `GameManager` |
|
||||
| `Cutscene` | `DisableAllInput()` | `GameManager` / `CutsceneManager` |
|
||||
| 对话(Dialogue)中 | `EnableUIInput()` | `DialogueManager`(对话开始/结束时调用)|
|
||||
|
||||
---
|
||||
|
||||
## 6. 按键重绑定接口
|
||||
|
||||
```csharp
|
||||
// InputReaderSO 提供的重绑定 API(供 SettingsPanel 调用)
|
||||
public class InputReaderSO : ScriptableObject
|
||||
{
|
||||
// 开始交互式重绑定(返回 RebindOperation,调用方负责 Dispose)
|
||||
public InputActionRebindingExtensions.RebindingOperation
|
||||
StartRebinding(string actionName, int bindingIndex, Action onComplete, Action onCancel);
|
||||
|
||||
// 保存当前绑定覆盖(JSON)→ PlayerPrefs
|
||||
public void SaveBindingOverrides();
|
||||
|
||||
// 从 PlayerPrefs 加载并应用绑定覆盖
|
||||
public void LoadBindingOverrides();
|
||||
|
||||
// 重置为默认绑定
|
||||
public void ResetBindings();
|
||||
}
|
||||
```
|
||||
|
||||
### RebindPanel — 完整重绑定 UI
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/Settings/RebindPanel.cs
|
||||
// 设置界面中的完整按键重绑定面板
|
||||
// 每个可绑定 Action 对应一行 RebindActionRow
|
||||
public class RebindPanel : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private InputReaderSO _inputReader;
|
||||
[SerializeField] private RebindActionRow[] _rows; // Inspector 配置,顺序对应 HUD 布局
|
||||
[SerializeField] private Button _resetAllButton;
|
||||
[SerializeField] private ConflictDetector _conflictDetector;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_resetAllButton.onClick.AddListener(OnResetAll);
|
||||
foreach (var row in _rows)
|
||||
row.Initialize(_inputReader, _conflictDetector, OnRebindRequested);
|
||||
}
|
||||
|
||||
private void OnRebindRequested(RebindActionRow row)
|
||||
{
|
||||
// 禁用其他行的交互,防止同时启动多个重绑定操作
|
||||
foreach (var r in _rows) r.SetInteractable(r == row);
|
||||
row.StartRebind(onFinished: () =>
|
||||
{
|
||||
foreach (var r in _rows) r.SetInteractable(true);
|
||||
_inputReader.SaveBindingOverrides();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnResetAll()
|
||||
{
|
||||
_inputReader.ResetBindings();
|
||||
_inputReader.SaveBindingOverrides();
|
||||
foreach (var row in _rows) row.RefreshDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
// 路径: Assets/Scripts/UI/Settings/RebindActionRow.cs
|
||||
// 单行:Action 名 + 当前绑定显示 + 点击启动重绑定
|
||||
public class RebindActionRow : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string _actionName; // Input Action 名称
|
||||
[SerializeField] private int _bindingIndex; // 0 = 主绑定,1 = 副绑定
|
||||
[SerializeField] private TMP_Text _actionLabel;
|
||||
[SerializeField] private Button _bindButton;
|
||||
[SerializeField] private TMP_Text _currentBindingText;
|
||||
|
||||
private InputReaderSO _inputReader;
|
||||
private ConflictDetector _conflictDetector;
|
||||
private Action<RebindActionRow> _onRebindRequested;
|
||||
|
||||
public void Initialize(
|
||||
InputReaderSO reader,
|
||||
ConflictDetector detector,
|
||||
Action<RebindActionRow> onRequest)
|
||||
{
|
||||
_inputReader = reader;
|
||||
_conflictDetector = detector;
|
||||
_onRebindRequested = onRequest;
|
||||
_bindButton.onClick.AddListener(() => _onRebindRequested?.Invoke(this));
|
||||
RefreshDisplay();
|
||||
}
|
||||
|
||||
public void StartRebind(Action onFinished)
|
||||
{
|
||||
_currentBindingText.text = "按下新按键…";
|
||||
_inputReader.StartRebinding(_actionName, _bindingIndex,
|
||||
onComplete: () => { RefreshDisplay(); CheckConflicts(); onFinished?.Invoke(); },
|
||||
onCancel: () => { RefreshDisplay(); onFinished?.Invoke(); });
|
||||
}
|
||||
|
||||
public void RefreshDisplay()
|
||||
{
|
||||
var action = _inputReader.FindAction(_actionName);
|
||||
_currentBindingText.text = action != null
|
||||
? InputControlPath.ToHumanReadableString(
|
||||
action.bindings[_bindingIndex].effectivePath,
|
||||
InputControlPath.HumanReadableStringOptions.OmitDevice)
|
||||
: "—";
|
||||
}
|
||||
|
||||
public void SetInteractable(bool interactable) => _bindButton.interactable = interactable;
|
||||
|
||||
private void CheckConflicts()
|
||||
{
|
||||
var conflicts = _conflictDetector.FindConflicts(_inputReader.GetAllActionMap());
|
||||
// 高亮冲突行(通过 ConflictDetector 回调)
|
||||
foreach (var row in FindObjectsOfType<RebindActionRow>())
|
||||
row.SetConflictHighlight(conflicts.Contains(row._actionName));
|
||||
}
|
||||
|
||||
public void SetConflictHighlight(bool conflict)
|
||||
=> _currentBindingText.color = conflict ? Color.red : Color.white;
|
||||
}
|
||||
```
|
||||
|
||||
### ConflictDetector — 冲突检测
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Input/ConflictDetector.cs
|
||||
// 检测两个 Action 是否绑定了相同的按键路径
|
||||
public class ConflictDetector : MonoBehaviour
|
||||
{
|
||||
// 返回存在冲突的 Action 名称集合
|
||||
public HashSet<string> FindConflicts(IEnumerable<InputAction> actions)
|
||||
{
|
||||
var pathToActions = new Dictionary<string, List<string>>();
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
foreach (var binding in action.bindings)
|
||||
{
|
||||
if (binding.isComposite || string.IsNullOrEmpty(binding.effectivePath)) continue;
|
||||
|
||||
if (!pathToActions.ContainsKey(binding.effectivePath))
|
||||
pathToActions[binding.effectivePath] = new List<string>();
|
||||
pathToActions[binding.effectivePath].Add(action.name);
|
||||
}
|
||||
}
|
||||
|
||||
var conflicted = new HashSet<string>();
|
||||
foreach (var kv in pathToActions)
|
||||
if (kv.Value.Count > 1)
|
||||
foreach (var name in kv.Value)
|
||||
conflicted.Add(name);
|
||||
|
||||
return conflicted;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### RebindPersistence — 持久化绑定
|
||||
|
||||
```csharp
|
||||
// InputReaderSO 内部的持久化实现(扩展上方 SaveBindingOverrides/LoadBindingOverrides)
|
||||
// 序列化格式: PlayerPrefs key = "InputBindings", value = JSON 覆盖字符串
|
||||
|
||||
public partial class InputReaderSO : ScriptableObject
|
||||
{
|
||||
private const string PrefKey = "InputBindings";
|
||||
|
||||
public void SaveBindingOverrides()
|
||||
{
|
||||
string json = _actions.asset.SaveBindingOverridesAsJson();
|
||||
PlayerPrefs.SetString(PrefKey, json);
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
public void LoadBindingOverrides()
|
||||
{
|
||||
if (PlayerPrefs.HasKey(PrefKey))
|
||||
{
|
||||
string json = PlayerPrefs.GetString(PrefKey);
|
||||
_actions.asset.LoadBindingOverridesFromJson(json);
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetBindings()
|
||||
{
|
||||
_actions.asset.RemoveAllBindingOverrides();
|
||||
PlayerPrefs.DeleteKey(PrefKey);
|
||||
}
|
||||
|
||||
// 辅助:获取当前 Gameplay map 所有 InputAction(供 ConflictDetector 使用)
|
||||
public IEnumerable<InputAction> GetAllActionMap()
|
||||
=> _actions.asset.FindActionMap("Gameplay").actions;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Persistent 场景挂载
|
||||
|
||||
`InputReaderSO` 本身是 ScriptableObject(Asset),不需要 GameObject 挂载。
|
||||
但 Unity InputSystem 的 `PlayerInput` 组件可选(InputReaderSO 直接 `new PlayerInputActions()` 即可,无需 `PlayerInput` 组件)。
|
||||
|
||||
```
|
||||
Scene: Persistent
|
||||
└── InputReader (GameObject)
|
||||
└── InputReaderInitializer.cs
|
||||
├── [SerializeField] InputReaderSO _inputReader
|
||||
└── Awake():
|
||||
_inputReader.Initialize(); // 创建 PlayerInputActions 实例
|
||||
_inputReader.LoadBindingOverrides(); // 加载用户重映射
|
||||
_inputReader.EnableGameplayInput(); // 默认游戏play输入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. KeyDisplayNameResolver — 按键名本地化
|
||||
|
||||
`KeyDisplayNameResolver` 将 InputSystem 返回的英文按键路径名转换为本地化显示字符串,供 `RebindActionRow` 渲染到 UI。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Input/KeyDisplayNameResolver.cs
|
||||
public static class KeyDisplayNameResolver
|
||||
{
|
||||
// 中文覆盖字典(键 = InputControlPath 的 Human-Readable 片段,值 = 中文显示名)
|
||||
private static readonly Dictionary<string, string> _overrides = new Dictionary<string, string>
|
||||
{
|
||||
{ "Space", "空格" },
|
||||
{ "Enter", "回车" },
|
||||
{ "Backspace", "退格" },
|
||||
{ "Escape", "退出" },
|
||||
{ "Left Arrow","←" },
|
||||
{ "Right Arrow","→" },
|
||||
{ "Up Arrow", "↑" },
|
||||
{ "Down Arrow","↓" },
|
||||
{ "Left Shift","左 Shift" },
|
||||
{ "Right Shift","右 Shift" },
|
||||
{ "Left Ctrl", "左 Ctrl" },
|
||||
{ "Right Ctrl","右 Ctrl" },
|
||||
{ "Left Alt", "左 Alt" },
|
||||
{ "Right Alt", "右 Alt" },
|
||||
{ "Tab", "Tab" },
|
||||
{ "Caps Lock", "大写锁定" },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 将 effectivePath 转换为本地化显示字符串。
|
||||
/// 中文覆盖优先;未找到覆盖时回落到 InputControlPath.ToHumanReadableString。
|
||||
/// </summary>
|
||||
public static string Resolve(string effectivePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(effectivePath)) return "—";
|
||||
|
||||
string human = InputControlPath.ToHumanReadableString(
|
||||
effectivePath,
|
||||
InputControlPath.HumanReadableStringOptions.OmitDevice);
|
||||
|
||||
return _overrides.TryGetValue(human, out string localized) ? localized : human;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`RebindActionRow.RefreshDisplay()` 改为调用 `KeyDisplayNameResolver.Resolve()` 替代直接的 `ToHumanReadableString`:
|
||||
|
||||
```csharp
|
||||
_currentBindingText.text = KeyDisplayNameResolver.Resolve(
|
||||
action.bindings[_bindingIndex].effectivePath);
|
||||
```
|
||||
1714
Docs/Architecture/05_PlayerModule.md
Normal file
1714
Docs/Architecture/05_PlayerModule.md
Normal file
File diff suppressed because it is too large
Load Diff
1712
Docs/Architecture/06_CombatModule.md
Normal file
1712
Docs/Architecture/06_CombatModule.md
Normal file
File diff suppressed because it is too large
Load Diff
1112
Docs/Architecture/07_EnemyModule.md
Normal file
1112
Docs/Architecture/07_EnemyModule.md
Normal file
File diff suppressed because it is too large
Load Diff
922
Docs/Architecture/08_WorldModule.md
Normal file
922
Docs/Architecture/08_WorldModule.md
Normal file
@@ -0,0 +1,922 @@
|
||||
# 08 · 世界模块
|
||||
|
||||
> **命名空间** `BaseGames.World`
|
||||
> **程序集** `BaseGames.World`
|
||||
> **路径** `Assets/Scripts/World/`
|
||||
> **依赖** `BaseGames.Core`、`BaseGames.Core.Events`、`BaseGames.Core.Save`
|
||||
> (通过 `IRestoreOnSave`(定义于 Core)与玩家层解耦,无需直接引用 `BaseGames.Player`)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [场景结构规范(总览)](#1-场景结构规范)
|
||||
2. [RoomTransition](#2-roomtransition)
|
||||
3. [SavePoint](#3-savepoint)
|
||||
4. [HazardZone](#4-hazardzone)
|
||||
5. [Collectible](#5-collectible)
|
||||
6. [AbilityUnlock](#6-abilityunlock)
|
||||
7. [IInteractable 接口](#7-iinteractable-接口)
|
||||
8. [InteractableDetector](#8-interactabledetector)
|
||||
9. [WorldStateRegistry](#9-worldstateregistry)
|
||||
10. [DestructibleTile](#10-destructibletile)
|
||||
11. [MovingPlatform](#11-movingplatform)
|
||||
12. [DirectionalDestructible — 单向可破坏墙](#12-directionaldestructible--单向可破坏墙)
|
||||
13. [DirectionalInteractable — 单向触发机关](#13-directionalinteractable--单向触发机关)
|
||||
14. [CrumblePlatform — 碎裂平台](#14-crumbleplatform--碎裂平台)
|
||||
15. [SkillInteractable — 技能专属交互物](#15-skillinteractable--技能专属交互物)
|
||||
16. [世界事件频道清单](#16-世界事件频道清单)
|
||||
|
||||
---
|
||||
|
||||
## 1. 场景结构规范
|
||||
|
||||
**场景命名**(见 [01_ProjectStructure.md](./01_ProjectStructure.md) §8)。
|
||||
|
||||
**房间场景标准层级**(详见 Architecture README)。
|
||||
重要约束:
|
||||
|
||||
- 每个房间场景必须包含 `RoomController` 组件(挂在 `[RoomRoot]` GameObject 上)
|
||||
- 必须有至少一个 `RoomTransition`(出入口)
|
||||
- 玩家出生点:`PlayerSpawnPoint` 组件,由 `SceneLoadRequest.EntryTransitionId` 匹配
|
||||
|
||||
---
|
||||
|
||||
## 2. RoomTransition
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/RoomTransition.cs
|
||||
// 挂在出入口 Trigger Collider2D 上
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class RoomTransition : MonoBehaviour
|
||||
{
|
||||
[Header("Config")]
|
||||
[SerializeField] private string _transitionId; // 唯一 ID(目标出口用于匹配出生点)
|
||||
[SerializeField] private string _targetSceneName; // 目标场景(AddressKeys 常量)
|
||||
[SerializeField] private string _targetTransitionId; // 目标场景中对应出口的 ID
|
||||
[SerializeField] private bool _requiresKeyItem; // 是否需要持有钥匙物品
|
||||
[SerializeField] private string _requiredItemId; // 钥匙物品 ID
|
||||
|
||||
[Header("Event Channel")]
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
// 玩家进入触发器
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
if (_requiresKeyItem && !HasItem(_requiredItemId)) return;
|
||||
|
||||
_onSceneLoadRequest.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = _targetSceneName,
|
||||
EntryTransitionId = _targetTransitionId,
|
||||
ShowLoadingScreen = false,
|
||||
IsRespawn = false
|
||||
});
|
||||
}
|
||||
|
||||
private bool HasItem(string itemId); // 查询 PlayerStats 或 Inventory
|
||||
|
||||
// Editor:在 Scene View 显示箭头 Gizmo
|
||||
private void OnDrawGizmos();
|
||||
}
|
||||
|
||||
// 玩家出生点,与 RoomTransition.transitionId 对应
|
||||
public class PlayerSpawnPoint : MonoBehaviour
|
||||
{
|
||||
public string TransitionId;
|
||||
public Vector2 SpawnPosition => transform.position;
|
||||
public int FacingDirection = 1; // +1 右, -1 左
|
||||
|
||||
private void OnDrawGizmos() { /* 绿色标记 */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. SavePoint
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/SavePoint.cs
|
||||
// 实现 IInteractable + ISaveable,玩家交互时触发存档
|
||||
// 架构决策:通过 IRestoreOnSave(定义于 BaseGames.Core)调用玩家回血/灵泉,
|
||||
// 避免 World 层反向依赖 BaseGames.Player,World.asmdef 无需引用 Player 程序集。
|
||||
public class SavePoint : MonoBehaviour, IInteractable, ISaveable
|
||||
{
|
||||
[Header("Config")]
|
||||
[SerializeField] private string _savePointId;
|
||||
[SerializeField] private bool _restoreSpring = true;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onSavePointActivated;
|
||||
[SerializeField] private VoidEventChannelSO _onFastTravelOpen;
|
||||
|
||||
private bool _isActivated;
|
||||
|
||||
// IInteractable(⚠️ 参数为 Transform,与 14_NarrativeModule §1 / 07 §7 对齐)
|
||||
public bool CanInteract => true;
|
||||
public string InteractPrompt => _isActivated ? "休息" : "激活";
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
_isActivated = true;
|
||||
// 1. 通过 IRestoreOnSave 恢复玩家(World 不感知具体 Player 类型)
|
||||
var restorer = player.GetComponentInChildren<IRestoreOnSave>();
|
||||
if (restorer != null)
|
||||
{
|
||||
restorer.FullRestore();
|
||||
if (_restoreSpring) restorer.RestoreSpring();
|
||||
}
|
||||
// 2. 广播存档点激活(GameManager 响应并调用 SaveManager.SaveAsync)
|
||||
_onSavePointActivated?.Raise(_savePointId);
|
||||
// 3. 若该场景已有多个存档点激活,打开快速旅行 UI
|
||||
// 4. 播放激活动画 / 特效
|
||||
}
|
||||
|
||||
// ISaveable 存档集成
|
||||
public bool IsActivated => _isActivated;
|
||||
public void SetActivated(bool val) => _isActivated = val;
|
||||
}
|
||||
```
|
||||
|
||||
> **IRestoreOnSave 接口**(`Assets/Scripts/Core/IRestoreOnSave.cs`,命名空间 `BaseGames.Core`)
|
||||
> `PlayerStats` 显式实现:`FullRestore()` → `FullHeal()`,`RestoreSpring()` → `RestoreSpringCharges()`。
|
||||
> 同一接口可扩展至其他可被存档点恢复的对象(伙伴、坐骑等),无需修改 SavePoint 本身。
|
||||
|
||||
---
|
||||
|
||||
## 4. HazardZone
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/HazardZone.cs
|
||||
// 即死区域(深坑、岩浆等)
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class HazardZone : MonoBehaviour
|
||||
{
|
||||
public enum RespawnType { AtLastSavePoint, AtRoomEntry }
|
||||
|
||||
[SerializeField] private bool _isInstantKill = true;
|
||||
[SerializeField] private int _damage = 9999;
|
||||
[SerializeField] private RespawnType _respawnType = RespawnType.AtLastSavePoint;
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
|
||||
var stats = other.GetComponentInParent<PlayerStats>();
|
||||
if (stats == null) return;
|
||||
|
||||
if (_isInstantKill)
|
||||
stats.TakeDamage(stats.MaxHP * 2); // 确保即死
|
||||
else
|
||||
stats.TakeDamage(_damage);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Collectible
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Collectible.cs
|
||||
// Geo 货币 / 物品掉落
|
||||
public class Collectible : MonoBehaviour
|
||||
{
|
||||
[Header("Config")]
|
||||
[SerializeField] private CollectibleType _type;
|
||||
[SerializeField] private int _geoAmount; // type = Geo 时
|
||||
[SerializeField] private string _itemId; // type = Item 时
|
||||
[SerializeField] private bool _isPersistent; // false = 敌人掉落(不存档); true = 固定位置(存档)
|
||||
|
||||
[Header("Physics")]
|
||||
[SerializeField] private float _bounceForce = 5f;
|
||||
|
||||
[Header("Event Channel")]
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup;
|
||||
|
||||
private string _collectibleId; // 用于存档(持久化 Collectible 专用)
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
|
||||
var player = other.GetComponentInParent<PlayerController>();
|
||||
if (player == null) return;
|
||||
|
||||
switch (_type)
|
||||
{
|
||||
case CollectibleType.Geo:
|
||||
player.Stats.AddGeo(_geoAmount);
|
||||
break;
|
||||
case CollectibleType.Item:
|
||||
// 通知 Inventory / QuestManager
|
||||
_onCollectiblePickup.Raise(_itemId);
|
||||
break;
|
||||
}
|
||||
|
||||
if (_isPersistent)
|
||||
_onCollectiblePickup.Raise(_collectibleId); // 存档标记
|
||||
|
||||
Despawn();
|
||||
}
|
||||
|
||||
private void Despawn(); // 归还对象池
|
||||
|
||||
// 敌人死亡时生成 Geo Collectible(由 EnemyBase 调用)
|
||||
public static void SpawnGeo(Vector2 position, int amount, ObjectPoolManager pool);
|
||||
}
|
||||
|
||||
public enum CollectibleType { Geo, Item, HPOrb }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. AbilityUnlock
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/AbilityUnlock.cs
|
||||
// 世界中固定位置的能力解锁物(获取新技能)
|
||||
public class AbilityUnlock : MonoBehaviour, IInteractable
|
||||
{
|
||||
[SerializeField] private AbilityType _abilityToUnlock;
|
||||
[SerializeField] private string _unlockId; // 存档用
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onCollectiblePickup; // 通知存档已拾取
|
||||
|
||||
private bool _isCollected = false;
|
||||
|
||||
public bool CanInteract => !_isCollected;
|
||||
public string InteractPrompt => "获得能力";
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (_isCollected) return;
|
||||
_isCollected = true;
|
||||
// ⚠️ PlayerController 无 Instance;通过 player 参数获取
|
||||
player.GetComponent<PlayerController>()?.Stats.UnlockAbility(_abilityToUnlock);
|
||||
_onCollectiblePickup.Raise(_unlockId);
|
||||
// 触发解锁演出(Cutscene / UI 提示)
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
public void SetCollected(bool val)
|
||||
{
|
||||
_isCollected = val;
|
||||
if (val) gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. IInteractable 接口
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/IInteractable.cs
|
||||
// ⚠️ 与 14_NarrativeModule §1 对齐(权威定义):Transform 参数 + 5 成员
|
||||
namespace BaseGames.World
|
||||
{
|
||||
public interface IInteractable
|
||||
{
|
||||
bool CanInteract { get; } // 当前是否可交互
|
||||
string InteractPrompt { get; } // 显示在交互提示 UI 上的文字
|
||||
void Interact(Transform player); // 传入玩家 Transform;需要 PlayerController 时通过 player.GetComponent<PlayerController>() 获取(PlayerController 无 Instance)
|
||||
void OnPlayerEnterRange(Transform player); // 进入检测范围
|
||||
void OnPlayerExitRange(); // 离开检测范围
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. InteractableDetector
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/InteractableDetector.cs
|
||||
// 挂在玩家上,检测周围可交互物并驱动交互 UI
|
||||
public class InteractableDetector : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private float _detectRadius = 1.5f;
|
||||
[SerializeField] private LayerMask _interactableLayer;
|
||||
[SerializeField] private InputReaderSO _inputReader;
|
||||
[SerializeField] private StringEventChannelSO _onShowInteractPrompt; // 发布:显示交互提示
|
||||
[SerializeField] private VoidEventChannelSO _onHideInteractPrompt; // 发布:隐藏交互提示
|
||||
|
||||
private IInteractable _nearest;
|
||||
private IInteractable _previousNearest;
|
||||
|
||||
private void OnEnable() => _inputReader.InteractEvent += TryInteract;
|
||||
private void OnDisable() => _inputReader.InteractEvent -= TryInteract;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// OverlapCircle → 找最近 IInteractable
|
||||
var hits = Physics2D.OverlapCircleAll(transform.position, _detectRadius, _interactableLayer);
|
||||
_nearest = FindNearest(hits);
|
||||
if (_nearest != _previousNearest)
|
||||
{
|
||||
if (_previousNearest != null) { _previousNearest.OnPlayerExitRange(); _onHideInteractPrompt.Raise(); }
|
||||
if (_nearest != null) { _nearest.OnPlayerEnterRange(transform); _onShowInteractPrompt.Raise(_nearest.InteractPrompt); }
|
||||
_previousNearest = _nearest;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryInteract()
|
||||
{
|
||||
_nearest?.Interact(transform); // 传入玩家自身 Transform;IInteractable 内部通过 player.GetComponent<PlayerController>() 获取组件(PlayerController 无 Instance)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. WorldStateRegistry
|
||||
|
||||
> **⚠️ 与 `14_NarrativeModule §8` 统一**:`WorldStateRegistry` 已改为 **ScriptableObject**(`CreateAssetMenu`),
|
||||
> 通过 `[SerializeField]` 注入,不再使用静态 `Instance`。`HasFlag` / `SetFlag(key)` 接口与 Architecture 14 §8 保持一致。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Narrative/WorldStateRegistry.cs
|
||||
// 管理世界持久化状态(已收集物、已激活存档点、已开门、已销毁对象、通用标志)
|
||||
// ScriptableObject 形式,各组件通过 [SerializeField] 注入,零耦合;与 14_NarrativeModule §8 统一
|
||||
namespace BaseGames.World
|
||||
{
|
||||
[CreateAssetMenu(menuName = "World/WorldStateRegistry")]
|
||||
public class WorldStateRegistry : ScriptableObject
|
||||
{
|
||||
private HashSet<string> _collectedIds = new();
|
||||
private HashSet<string> _activatedSavePoints = new();
|
||||
private HashSet<string> _openedDoors = new();
|
||||
private HashSet<string> _destroyedObjects = new();
|
||||
|
||||
public bool IsCollected(string id) => _collectedIds.Contains(id);
|
||||
public void MarkCollected(string id) => _collectedIds.Add(id);
|
||||
|
||||
public bool IsSavePointActivated(string id) => _activatedSavePoints.Contains(id);
|
||||
public void MarkSavePointActivated(string id) => _activatedSavePoints.Add(id);
|
||||
|
||||
public bool IsDestroyed(string id) => _destroyedObjects.Contains(id);
|
||||
public void MarkDestroyed(string id) => _destroyedObjects.Add(id);
|
||||
|
||||
public bool IsDoorOpened(string id) => _openedDoors.Contains(id);
|
||||
public void MarkDoorOpened(string id) => _openedDoors.Add(id);
|
||||
|
||||
// 通用世界状态标记(过场记录、剧情事件等)
|
||||
// ⚠️ 接口与 14_NarrativeModule §8 统一:HasFlag(非 IsFlagSet);SetFlag(key) 单参数添加
|
||||
private HashSet<string> _flags = new();
|
||||
public bool HasFlag(string key) => _flags.Contains(key);
|
||||
public void SetFlag(string key) => _flags.Add(key);
|
||||
|
||||
// SaveManager 集成(非 ISaveable,由 SaveManager 在 SaveAsync/LoadAsync 中直接调用)
|
||||
public void LoadFromSave(WorldSaveData data);
|
||||
public HashSet<string> GetAllFlags();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **SaveManager 集成**:`WorldStateRegistry` 不实现 `ISaveable` 接口(ScriptableObject,非 MonoBehaviour)。
|
||||
> `SaveManager` 在保存/加载时直接调用:
|
||||
> ```csharp
|
||||
> // SaveManager.CollectAllData() 内:
|
||||
> saveData.World = WorldStateRegistry.Instance.GetSaveData(); // 通过 SO 引用而非静态 Instance
|
||||
>
|
||||
> // SaveManager.ApplyLoadedData() 内:
|
||||
> WorldStateRegistry.Instance.LoadFromSave(saveData.World);
|
||||
> ```
|
||||
> `WorldStateRegistry` SO 资产路径:`Assets/Data/World/WorldStateRegistry.asset`。
|
||||
|
||||
---
|
||||
|
||||
## 10. DestructibleTile
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/DestructibleTile.cs
|
||||
// 可被攻击破坏的地形块(影响导航网格)
|
||||
public class DestructibleTile : MonoBehaviour, IDamageable
|
||||
{
|
||||
[SerializeField] private int _maxHP = 1;
|
||||
[SerializeField] private string _destructedId; // 存档唯一 ID
|
||||
|
||||
private bool _isDestroyed = false;
|
||||
|
||||
// IDamageable
|
||||
public bool IsInvincible => _isDestroyed;
|
||||
public int Defense => 0;
|
||||
public void TakeDamage(DamageInfo info)
|
||||
{
|
||||
if (_isDestroyed) return;
|
||||
if (!CheckDestroyCondition(info)) return; // 子类可覆盖(DirectionalDestructible 方向校验)
|
||||
_isDestroyed = true;
|
||||
// 禁用 Renderer + 碰撞体
|
||||
// 通知 PathBerserker2d 重新烘焙局部导航网格
|
||||
// 记录到 WorldStateRegistry
|
||||
}
|
||||
|
||||
// 子类覆盖以附加销毁前提条件(默认:无条件销毁)
|
||||
protected virtual bool CheckDestroyCondition(DamageInfo info) => true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. MovingPlatform
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/MovingPlatform.cs
|
||||
// 动态移动平台:Kinematic Rigidbody2D,乘客自动跟随(Passenger Pattern)
|
||||
[RequireComponent(typeof(Rigidbody2D))]
|
||||
public class MovingPlatform : MonoBehaviour
|
||||
{
|
||||
public enum MoveType { LinearAB, WayPoints, TriggeredLinear }
|
||||
|
||||
[Header("移动配置")]
|
||||
[SerializeField] private MoveType _moveType = MoveType.LinearAB;
|
||||
[SerializeField] private Transform[] _wayPoints; // LinearAB 仅用 [0][1]
|
||||
[SerializeField] private float _speed = 3f; // u/s
|
||||
[SerializeField] private float _waitAtEndpoint = 0.5f; // 端点停留秒数
|
||||
|
||||
[Header("TriggeredLinear 模式")]
|
||||
[SerializeField] private VoidEventChannelSO _activationChannel; // 接收信号后单程运动
|
||||
|
||||
// 乘客检测:顶面上方 0.05f 的 IsTrigger BoxCollider2D,检测到 Player/Enemy 层
|
||||
[Header("乘客检测")]
|
||||
[SerializeField] private BoxCollider2D _passengerSensor; // Trigger,仅用于检测
|
||||
|
||||
private Rigidbody2D _rb;
|
||||
private List<Transform> _passengers = new();
|
||||
private int _waypointIndex;
|
||||
private bool _movingForward = true;
|
||||
private bool _triggered;
|
||||
|
||||
private void Awake() => _rb = GetComponent<Rigidbody2D>();
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
if (_moveType == MoveType.TriggeredLinear && !_triggered) return;
|
||||
MoveTowardsNextWaypoint();
|
||||
}
|
||||
|
||||
private void MoveTowardsNextWaypoint()
|
||||
{
|
||||
var target = _wayPoints[_waypointIndex].position;
|
||||
var next = Vector2.MoveTowards(_rb.position, target, _speed * Time.fixedDeltaTime);
|
||||
_rb.MovePosition(next);
|
||||
|
||||
if (Vector2.Distance(_rb.position, target) < 0.02f)
|
||||
StartCoroutine(WaitAndAdvance());
|
||||
}
|
||||
|
||||
private IEnumerator WaitAndAdvance()
|
||||
{
|
||||
yield return new WaitForSeconds(_waitAtEndpoint);
|
||||
AdvanceWaypoint();
|
||||
}
|
||||
|
||||
private void AdvanceWaypoint()
|
||||
{
|
||||
// LinearAB: 往返; WayPoints: 环形; TriggeredLinear: 到达终点后停止
|
||||
if (_moveType == MoveType.TriggeredLinear)
|
||||
{
|
||||
_waypointIndex = Mathf.Min(_waypointIndex + 1, _wayPoints.Length - 1);
|
||||
if (_waypointIndex == _wayPoints.Length - 1) _triggered = false;
|
||||
return;
|
||||
}
|
||||
if (_moveType == MoveType.LinearAB)
|
||||
{
|
||||
_movingForward = !_movingForward;
|
||||
_waypointIndex = _movingForward ? 1 : 0;
|
||||
}
|
||||
else // WayPoints
|
||||
{
|
||||
_waypointIndex = (_waypointIndex + 1) % _wayPoints.Length;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 乘客跟随(Passenger Pattern)─────────────────────────────────
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if ((1 << other.gameObject.layer & LayerMask.GetMask("Player", "Enemy")) == 0) return;
|
||||
other.transform.SetParent(transform);
|
||||
_passengers.Add(other.transform);
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (!_passengers.Contains(other.transform)) return;
|
||||
other.transform.SetParent(null);
|
||||
_passengers.Remove(other.transform);
|
||||
// 继承平台当前速度(仅玩家)
|
||||
if (other.CompareTag("Player"))
|
||||
other.GetComponentInParent<Rigidbody2D>()?.AddForce(
|
||||
_rb.velocity, ForceMode2D.Impulse);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_activationChannel != null)
|
||||
_activationChannel.OnEventRaised += OnTriggered;
|
||||
}
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_activationChannel != null)
|
||||
_activationChannel.OnEventRaised -= OnTriggered;
|
||||
}
|
||||
private void OnTriggered() => _triggered = true;
|
||||
}
|
||||
```
|
||||
|
||||
**MoveType 说明**:
|
||||
|
||||
| 类型 | 行为 |
|
||||
|------|------|
|
||||
| `LinearAB` | `_wayPoints[0]` ↔ `[1]` 往返循环 |
|
||||
| `WayPoints` | 按 `_wayPoints[]` 顺序环形循环 |
|
||||
| `TriggeredLinear` | 监听 `_activationChannel`,收到信号后单程 `[0]→[n-1]`,到达后停止 |
|
||||
|
||||
> **NavSurface 联动**:每个移动平台挂载独立 `LocalNavSurface`(局部坐标系),附着其上的敌人 NavAgent 使用该 LocalNavSurface 寻路;参见 Guides/PathBerserker2d_Technical_Evaluation.md §5。
|
||||
|
||||
---
|
||||
|
||||
## 12. DirectionalDestructible — 单向可破坏墙
|
||||
|
||||
继承 `DestructibleTile`,在其基础上增加**攻击方向校验**:
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/DirectionalDestructible.cs
|
||||
public class DirectionalDestructible : DestructibleTile
|
||||
{
|
||||
public enum AttackSide { Left, Right, Top, Bottom, Any }
|
||||
|
||||
[SerializeField] private AttackSide _validAttackSide = AttackSide.Any;
|
||||
|
||||
protected override bool CheckDestroyCondition(DamageInfo info)
|
||||
{
|
||||
if (_validAttackSide == AttackSide.Any)
|
||||
return base.CheckDestroyCondition(info);
|
||||
|
||||
// 判断攻击来源方向(info.SourcePosition 由 HitBox 传入)
|
||||
var dir = (info.SourcePosition - (Vector2)transform.position).normalized;
|
||||
bool valid = _validAttackSide switch
|
||||
{
|
||||
AttackSide.Left => dir.x < -0.5f,
|
||||
AttackSide.Right => dir.x > 0.5f,
|
||||
AttackSide.Top => dir.y > 0.5f,
|
||||
AttackSide.Bottom => dir.y < -0.5f,
|
||||
_ => true
|
||||
};
|
||||
return valid && base.CheckDestroyCondition(info);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
// 有效攻击方向:橙色箭头;无效方向:灰色叉号
|
||||
var arrow = _validAttackSide switch
|
||||
{
|
||||
AttackSide.Left => Vector2.left,
|
||||
AttackSide.Right => Vector2.right,
|
||||
AttackSide.Top => Vector2.up,
|
||||
AttackSide.Bottom => Vector2.down,
|
||||
_ => Vector2.zero
|
||||
};
|
||||
if (arrow == Vector2.zero) return;
|
||||
Gizmos.color = new Color(1f, 0.5f, 0f, 0.9f);
|
||||
var origin = (Vector2)transform.position;
|
||||
Gizmos.DrawLine(origin, origin + arrow * 0.8f);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
```
|
||||
|
||||
| 典型场景 | 配置 |
|
||||
|---------|------|
|
||||
| 地板薄板:只能从下方砸穿 | `_validAttackSide = Bottom` |
|
||||
| 密室封墙:仅能从房间内打开 | `_validAttackSide = Right`(依据朝向) |
|
||||
| 普通脆弱墙 | 基类 `DestructibleTile`(`AnyAttack`)即可,不需此子类 |
|
||||
|
||||
---
|
||||
|
||||
## 13. DirectionalInteractable — 单向触发机关
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/DirectionalInteractable.cs
|
||||
// 可从特定方向触发的单向机关(零耦合:通过 SO 事件频道连接受体)
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class DirectionalInteractable : MonoBehaviour, IInteractable
|
||||
{
|
||||
public enum TriggerSide { Left, Right, Top, Any }
|
||||
public enum TriggerCondition { PlayerAttack, PlayerBody, InteractKey }
|
||||
|
||||
[Header("触发条件")]
|
||||
[SerializeField] private TriggerSide _triggerSide = TriggerSide.Any;
|
||||
[SerializeField] private TriggerCondition _triggerCondition = TriggerCondition.InteractKey;
|
||||
|
||||
[Header("行为")]
|
||||
[SerializeField] private bool _isOneShot; // 一次性,触发后永久激活
|
||||
[SerializeField] private string _interactableId; // 存档用唯一 ID
|
||||
|
||||
[Header("事件频道(零耦合连接关卡受体)")]
|
||||
[SerializeField] private VoidEventChannelSO _activationChannel;
|
||||
[SerializeField] private VoidEventChannelSO _deactivationChannel; // 非 OneShot 离开时
|
||||
|
||||
[Header("反馈")]
|
||||
[SerializeField] private MMF_Player _activateFeedback;
|
||||
|
||||
private bool _activated;
|
||||
|
||||
// ── IInteractable(InteractKey 模式)─────────────────────────────
|
||||
public string InteractPrompt => _activated ? "已激活" : "交互";
|
||||
public void Interact(Transform player) // ⚠️ Transform 参数(与 §7 IInteractable 对齐)
|
||||
{
|
||||
if (_triggerCondition != TriggerCondition.InteractKey) return;
|
||||
if (!CheckSide(player.position)) return;
|
||||
TryActivate();
|
||||
}
|
||||
|
||||
// ── PlayerBody / PlayerAttack 模式 ───────────────────────────────
|
||||
// PlayerBody:OnTriggerEnter2D(Collider IsTrigger)
|
||||
// PlayerAttack:挂配套 HurtBox → DamageInfo → TryInteractFromDamage
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_triggerCondition != TriggerCondition.PlayerBody) return;
|
||||
if (!other.CompareTag("Player")) return;
|
||||
if (!CheckSide(other.transform.position)) return;
|
||||
TryActivate();
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (_triggerCondition != TriggerCondition.PlayerBody) return;
|
||||
if (!other.CompareTag("Player") || _isOneShot) return;
|
||||
_activated = false;
|
||||
_deactivationChannel?.Raise();
|
||||
}
|
||||
|
||||
// 由外部 HurtBox 转发(PlayerAttack 模式)
|
||||
public void TryInteractFromDamage(DamageInfo info)
|
||||
{
|
||||
if (_triggerCondition != TriggerCondition.PlayerAttack) return;
|
||||
if (!CheckSide(info.SourcePosition)) return;
|
||||
TryActivate();
|
||||
}
|
||||
|
||||
private void TryActivate()
|
||||
{
|
||||
if (_isOneShot && _activated) return;
|
||||
_activated = true;
|
||||
_activateFeedback?.PlayFeedbacks();
|
||||
_activationChannel?.Raise();
|
||||
if (_isOneShot)
|
||||
{
|
||||
// 持久化
|
||||
SaveManager.Instance?.SetMechanismState(_interactableId, true);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CheckSide(Vector2 sourcePos)
|
||||
{
|
||||
if (_triggerSide == TriggerSide.Any) return true;
|
||||
var dir = (sourcePos - (Vector2)transform.position).normalized;
|
||||
return _triggerSide switch
|
||||
{
|
||||
TriggerSide.Left => dir.x < -0.4f,
|
||||
TriggerSide.Right => dir.x > 0.4f,
|
||||
TriggerSide.Top => dir.y > 0.4f,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 读档恢复
|
||||
if (_isOneShot && !string.IsNullOrEmpty(_interactableId)
|
||||
&& (SaveManager.Instance?.GetMechanismState(_interactableId) ?? false))
|
||||
{
|
||||
_activated = true;
|
||||
_activationChannel?.Raise(); // 静默恢复联动状态
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**零耦合连接示例**(Inspector 拖入同一 SO 资产):
|
||||
|
||||
```
|
||||
Switch_Forest_01._activationChannel ──► MovingPlatform._activationChannel
|
||||
──► Door_Locked._openChannel
|
||||
──► HazardZone_Spikes._disableChannel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. CrumblePlatform — 碎裂平台
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/CrumblePlatform.cs
|
||||
[RequireComponent(typeof(BoxCollider2D))]
|
||||
public class CrumblePlatform : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private float _warningDuration = 0.6f; // 踩上后警告时长(抖动)
|
||||
[SerializeField] private float _crumbleDuration = 0.3f; // 碎裂动画时长
|
||||
[SerializeField] private float _respawnDelay = 3.0f; // 0 = 永久消失
|
||||
[SerializeField] private bool _isOneShot = false; // true = 碎裂后永久消失
|
||||
[SerializeField] private MMF_Player _crumbleFeedback; // 预警震动 + 碎裂粒子 + 音效
|
||||
[SerializeField] private BoxCollider2D _passengerSensor; // Trigger,检测玩家踩踏
|
||||
|
||||
private BoxCollider2D _col;
|
||||
private SpriteRenderer _sr;
|
||||
private bool _isCrumbling;
|
||||
|
||||
private static readonly int[] StateFrames = { 0, 1, 2, 3 }; // Idle/Warning/Crumbling/Gone
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_col = GetComponent<BoxCollider2D>();
|
||||
_sr = GetComponent<SpriteRenderer>();
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_isCrumbling || !other.CompareTag("Player")) return;
|
||||
StartCoroutine(CrumbleSequence());
|
||||
}
|
||||
|
||||
private IEnumerator CrumbleSequence()
|
||||
{
|
||||
_isCrumbling = true;
|
||||
|
||||
// 1. Warning(抖动)
|
||||
_crumbleFeedback?.PlayFeedbacks();
|
||||
yield return new WaitForSeconds(_warningDuration);
|
||||
|
||||
// 2. Crumbling
|
||||
yield return new WaitForSeconds(_crumbleDuration);
|
||||
|
||||
// 3. Gone:禁用碰撞体 + 隐藏 Sprite
|
||||
_col.enabled = false;
|
||||
_sr.enabled = false;
|
||||
_passengerSensor.enabled = false;
|
||||
|
||||
if (_isOneShot || _respawnDelay <= 0f)
|
||||
{
|
||||
yield break; // 永久消失
|
||||
}
|
||||
|
||||
// 4. Respawn
|
||||
yield return new WaitForSeconds(_respawnDelay);
|
||||
_col.enabled = true;
|
||||
_sr.enabled = true;
|
||||
_passengerSensor.enabled = true;
|
||||
_isCrumbling = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**状态机**:
|
||||
|
||||
```
|
||||
[玩家踩上]
|
||||
Idle ────────────► Warning ──[warningDuration]──► Crumbling ──[动画]──► Gone
|
||||
抖动 │
|
||||
←──[respawnDelay,非 OneShot]─┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. SkillInteractable — 技能专属交互物
|
||||
|
||||
> 这类物体不走伤害管线,而是监听**角色技能状态**或**物理层叠加**实现交互。
|
||||
> 三种类型对应游戏内三个形态的专属技能机关。
|
||||
|
||||
### 15.1 MagicWall — 魔法障壁(太虚斩专属)
|
||||
|
||||
太虚斩(命魂 SoulSkill)施放时,玩家进入 `PhysicsLayer: Ghost`;`MagicWall` 对 `Ghost` 层**无碰撞**,允许穿越。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/MagicWall.cs
|
||||
// 组件挂法:MagicWall GO 同时挂 TilemapCollider2D / BoxCollider2D
|
||||
// 不与 Ghost 层碰撞(Physics Layer Matrix 配置,非代码控制)
|
||||
// 脚本职责:Gizmo 可视化 + 颜色联动(普通/幽灵两态视觉区分)
|
||||
[ExecuteAlways]
|
||||
public class MagicWall : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Color _normalColor = new(0.4f, 0.2f, 1f, 0.8f); // 紫色(可见)
|
||||
[SerializeField] private Color _ghostColor = new(0.4f, 0.2f, 1f, 0.15f); // 淡紫(穿越提示)
|
||||
|
||||
// 在 FormSkillSO(太虚斩)canPassMagicWalls = true 时,
|
||||
// SkillManager 在技能开始/结束时切换玩家的物理层:
|
||||
// 开始: player.gameObject.layer = LayerMask.NameToLayer("Ghost")
|
||||
// 结束: player.gameObject.layer = LayerMask.NameToLayer("Player")
|
||||
// Physics Layer Matrix 设置: Ghost vs MagicWall = IgnoreCollision
|
||||
// 因此 MagicWall 本身无需额外代码,只靠层矩阵实现穿越。
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
Gizmos.color = _normalColor;
|
||||
var b = GetComponent<Collider2D>();
|
||||
if (b != null)
|
||||
Gizmos.DrawWireCube(transform.position, b.bounds.size);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
```
|
||||
|
||||
**Physics Layer Matrix 配置**:
|
||||
|
||||
| Layer A | Layer B | 碰撞 |
|
||||
|---------|---------|------|
|
||||
| `Player` | `MagicWall` | ✅ 碰撞(正常阻挡)|
|
||||
| `Ghost` | `MagicWall` | ❌ 忽略(太虚斩穿越)|
|
||||
| `Enemy` | `MagicWall` | ✅ 碰撞(敌人不能穿越)|
|
||||
|
||||
> 参见 [57_PhysicsLayerMatrix.md](../Design/57_PhysicsLayerMatrix.md)。
|
||||
> `SkillManager.TrySoulSkill()` 在技能激活/结束时调用 `SetPlayerLayer("Ghost"/"Player")`。
|
||||
|
||||
---
|
||||
|
||||
### 15.2 SoftTerrain — 松软地面(地行术专属)
|
||||
|
||||
地行术(地魂 SoulSkill)的 `GroundDive` 状态中,玩家进入地面移动。`SoftTerrain` 地块降低地行术**灵力消耗速率**(松软地面不消耗灵力)。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/SoftTerrain.cs
|
||||
// 挂在松软地面的 Tilemap/GameObject 上
|
||||
// GroundDiveState 通过 OverlapPoint 检测当前站立/穿行瓦片,查询是否 IsSoftTerrain
|
||||
public class SoftTerrain : MonoBehaviour
|
||||
{
|
||||
// Marker 组件——无逻辑,仅用于 GetComponent<SoftTerrain>() 检测
|
||||
// GroundDiveState(PlayerFSM)在每帧对角色脚下 Physics2D.OverlapPoint() 检测:
|
||||
// 若碰到实现了 SoftTerrain 的 Tilemap → SetSoulDrainRate(0)
|
||||
// 否则 → SetSoulDrainRate(FormSkillSO.soulCostPerSecond)
|
||||
}
|
||||
```
|
||||
|
||||
**关卡搭建**:
|
||||
- 在 `[Level]` 下新增 `Tilemap_SoftGround` 层,铺设松软地面 Tile
|
||||
- 该 Tilemap GameObject 挂载 `SoftTerrain` 组件
|
||||
- `TilemapCollider2D.isTrigger = false`(正常地面碰撞,`GroundDiveState` 穿越时物理层切换为 `Ghost` 忽略该层)
|
||||
|
||||
**与 MagicWall 的关键区别**:
|
||||
|
||||
| | MagicWall | SoftTerrain |
|
||||
|-|-----------|-------------|
|
||||
| 穿越条件 | 太虚斩激活(`Ghost` 层)| 地行术激活(另一 `Ghost` 变体层)|
|
||||
| 其余情况 | 实体阻挡 | 实体地面 |
|
||||
| 游戏效果 | 到达秘密区域 / 跑图捷径 | 降低灵力消耗 / 速度加成 |
|
||||
|
||||
---
|
||||
|
||||
### 15.3 PhantomInteractable — 幻影机关(残阴术专属)
|
||||
|
||||
残阴术(命魂 SpiritSkill1)在原地留下灵体,灵体可代替玩家触发特定机关。
|
||||
普通 `PressurePlate` 仅响应玩家,`PhantomInteractable` 额外响应 `PhantomBody` 层。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/PhantomInteractable.cs
|
||||
// 继承 DirectionalInteractable,额外监听 PhantomBody 层的 Collider 进入
|
||||
// 用途:需要延迟触发的机关(先放灵体踩住,再操控玩家本体做其他事)
|
||||
public class PhantomInteractable : DirectionalInteractable
|
||||
{
|
||||
// 残阴术(SpiritSkill1)实例化 PhantomBody Prefab,
|
||||
// PhantomBody 挂载 Rigidbody2D,Layer = "PhantomBody"
|
||||
// 本组件的 Collider(Trigger)对 PhantomBody 层同样响应
|
||||
|
||||
private new void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
bool isPlayer = other.CompareTag("Player");
|
||||
bool isPhantom = other.gameObject.layer ==
|
||||
LayerMask.NameToLayer("PhantomBody");
|
||||
if (!isPlayer && !isPhantom) return;
|
||||
|
||||
// 方向校验:幻影机关通常 TriggerSide = Any(灵体无方向约束)
|
||||
TryActivate();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**典型谜题**:
|
||||
|
||||
```
|
||||
场景设置:
|
||||
PhantomInteractable(PressurePlate 型)── _activationChannel ──► Door
|
||||
|
||||
解谜流程:
|
||||
1. 玩家施放残阴术 → 留下灵体踩住 PhantomInteractable → 门打开
|
||||
2. 玩家快速通过门洞
|
||||
3. 残阴术持续时间结束 → 灵体消失 → PhantomInteractable 失活 → 门关闭(若非 OneShot)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 世界事件频道清单
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_SavePointActivated` | `StringEventChannelSO` | `SavePoint` | `GameManager`(触发存档)、`HUDController`(显示提示) |
|
||||
| `EVT_RoomTransitionRequest` | `SceneLoadRequestEventChannelSO` | `RoomTransition` | `SceneLoader` |
|
||||
| `EVT_CollectiblePickup` | `StringEventChannelSO` | `Collectible`、`AbilityUnlock` | `WorldStateRegistry`、`QuestManager`、`AnalyticsManager` |
|
||||
| `EVT_FastTravelOpen` | `VoidEventChannelSO` | `SavePoint` | `UIManager`(显示 FastTravel 面板) |
|
||||
| `EVT_ShowInteractPrompt` | `StringEventChannelSO` | `InteractableDetector` | `HUDController` |
|
||||
| `EVT_HideInteractPrompt` | `VoidEventChannelSO` | `InteractableDetector` | `HUDController` |
|
||||
1021
Docs/Architecture/09_ProgressionModule.md
Normal file
1021
Docs/Architecture/09_ProgressionModule.md
Normal file
File diff suppressed because it is too large
Load Diff
694
Docs/Architecture/10_UIModule.md
Normal file
694
Docs/Architecture/10_UIModule.md
Normal file
@@ -0,0 +1,694 @@
|
||||
# 10 · UI 模块
|
||||
|
||||
> **命名空间** `BaseGames.UI`
|
||||
> **程序集** `BaseGames.UI`
|
||||
> **路径** `Assets/Scripts/UI/`
|
||||
> **依赖** `BaseGames.Core.Events`、`TextMeshPro`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [Canvas 架构(Persistent 场景内)](#1-canvas-架构)
|
||||
2. [UIManager](#2-uimanager)
|
||||
3. [HUDController](#3-hudcontroller)
|
||||
4. [BossHPBar](#4-bosshpbar)
|
||||
5. [PauseMenuController](#5-pausemenucontroller)
|
||||
6. [DeathScreenController](#6-deathscreencontroller)
|
||||
7. [SettingsPanelController](#7-settingspanelcontroller)
|
||||
8. [SaveSlotController](#75-saveslotcontroller)
|
||||
9. [SaveIndicator](#76-saveindicator)
|
||||
10. [LoadingScreenManager](#77-loadingscreenmanager)
|
||||
11. [IBossHPProvider 接口](#78-ibosshpprovider-接口)
|
||||
12. [LoadingOverlay](#8-loadingoverlay)
|
||||
13. [DialogueBox(HUD Overlay)](#9-dialoguebox)
|
||||
10. [FloatingDamageText(伤害数字)](#10-floatingdamagetext)
|
||||
11. [ToastNotification(通知弹窗)](#11-toastnotification)
|
||||
12. [InputDeviceIconSwitcher](#12-inputdeviceiconswitcher)
|
||||
13. [PanelStack(控制器导航)](#13-panelstack)
|
||||
14. [UI 事件频道清单](#14-ui-事件频道清单)
|
||||
|
||||
---
|
||||
|
||||
## 1. Canvas 架构
|
||||
|
||||
所有 Canvas 挂在 **Persistent 场景**内,全程常驻:
|
||||
|
||||
```
|
||||
[UIRoot]
|
||||
├── Canvas_HUD Sorting Order: 10 (Screen Space - Overlay)
|
||||
│ ├── HPContainer ← HP 格子列表(水平 HorizontalLayoutGroup)
|
||||
│ ├── SoulGauge ← 灵力弧形进度条(Image.fillAmount)
|
||||
│ ├── SpiritGauge ← 魄元进度条
|
||||
│ ├── GeoCounter ← TMP 数字 + 图标
|
||||
│ ├── SpringCharges ← 灵泉次数图标列
|
||||
│ ├── FormIndicator ← 当前形态图标(3 种形态)
|
||||
│ ├── ToolSlotHUD ← 工具槽图标 + 次数
|
||||
│ ├── AbilityHints ← 已解锁技能图标
|
||||
│ ├── BossHPBar ← 默认隐藏
|
||||
│ └── InteractPrompt ← 交互提示文字(默认隐藏)
|
||||
│
|
||||
├── Canvas_Menu Sorting Order: 20 (Screen Space - Overlay)
|
||||
│ ├── MainMenuPanel ← 主菜单(游戏启动时显示)
|
||||
│ ├── SaveSlotPanel ← 存档槽选择(新游戏/继续,主菜单子面板)
|
||||
│ ├── PauseMenuPanel ← 暂停菜单(默认隐藏)
|
||||
│ ├── DeathScreenPanel ← 死亡画面(默认隐藏)
|
||||
│ ├── SettingsPanel ← 设置菜单(默认隐藏)
|
||||
│ ├── MapPanel ← 地图(默认隐藏)
|
||||
│ └── ShopPanel ← 商店(默认隐藏)
|
||||
│
|
||||
└── Canvas_Overlay Sorting Order: 30 (Screen Space - Overlay)
|
||||
├── LoadingOverlay ← 全屏黑幕(过场遮罩)
|
||||
├── DialogueBox ← 对话框(底部)
|
||||
└── ToastContainer ← 通知弹窗容器(右上)
|
||||
```
|
||||
|
||||
**SaveSlotPanel** 展示 3 个存档槽卡片,每张卡片显示角色形态图标、区域名称、游玩时长、存档时间、完成度百分比;由 `SaveSlotController` 驱动(`SaveManager.GetSlotSummaryAsync(slotIndex)` 提供数据)。
|
||||
|
||||
**SaveIndicator**:右下角小图标(软盘 + 旋转动画),在自动存档流程中显示(订阅 `EVT_SaveIndicatorVisible` BoolEventChannelSO,`true` 触发淡入,`false` 触发淡出),告知玩家正在保存中。
|
||||
|
||||
---
|
||||
|
||||
## 2. UIManager
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/UIManager.cs
|
||||
[DefaultExecutionOrder(+50)]
|
||||
public class UIManager : MonoBehaviour
|
||||
{
|
||||
[Header("Canvas Roots")]
|
||||
[SerializeField] private GameObject _hudRoot;
|
||||
[SerializeField] private GameObject _pauseMenuRoot;
|
||||
[SerializeField] private GameObject _deathScreenRoot;
|
||||
[SerializeField] private GameObject _settingsRoot;
|
||||
[SerializeField] private GameObject _mapRoot;
|
||||
[SerializeField] private GameObject _shopRoot;
|
||||
|
||||
[Header("Event Channels - Subscribe")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[SerializeField] private VoidEventChannelSO _onPauseRequested;
|
||||
[SerializeField] private VoidEventChannelSO _onFastTravelOpen;
|
||||
[SerializeField] private StringEventChannelSO _onShopOpen;
|
||||
[SerializeField] private VoidEventChannelSO _onMapOpen;
|
||||
|
||||
private Stack<GameObject> _panelStack = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onGameStateChanged.OnEventRaised += HandleGameStateChanged;
|
||||
_onPauseRequested.OnEventRaised += TogglePause;
|
||||
_onFastTravelOpen.OnEventRaised += OpenMap;
|
||||
_onShopOpen.OnEventRaised += OpenShop;
|
||||
_onMapOpen.OnEventRaised += OpenMap;
|
||||
}
|
||||
private void OnDisable()
|
||||
{
|
||||
_onGameStateChanged.OnEventRaised -= HandleGameStateChanged;
|
||||
_onPauseRequested.OnEventRaised -= TogglePause;
|
||||
_onFastTravelOpen.OnEventRaised -= OpenMap;
|
||||
_onShopOpen.OnEventRaised -= OpenShop;
|
||||
_onMapOpen.OnEventRaised -= OpenMap;
|
||||
}
|
||||
|
||||
private void HandleGameStateChanged(GameStateId state)
|
||||
{
|
||||
// HUD 在 Gameplay 和 BossFight 状态下均显示
|
||||
bool showHud = state == GameStates.Gameplay || state == GameStates.BossFight;
|
||||
_hudRoot.SetActive(showHud);
|
||||
|
||||
// ⚠️ GameStateId 为 struct,不可用 switch;用 if/else 比较
|
||||
if (state == GameStates.Dead)
|
||||
_deathScreenRoot.SetActive(true);
|
||||
else if (state == GameStates.Cutscene)
|
||||
_hudRoot.SetActive(false); // 过场动画隐藏 HUD
|
||||
}
|
||||
|
||||
public void OpenPanel(GameObject panel)
|
||||
{
|
||||
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(false);
|
||||
panel.SetActive(true);
|
||||
_panelStack.Push(panel);
|
||||
}
|
||||
|
||||
public void CloseTopPanel()
|
||||
{
|
||||
if (_panelStack.Count == 0) return;
|
||||
_panelStack.Pop().SetActive(false);
|
||||
if (_panelStack.Count > 0) _panelStack.Peek().SetActive(true);
|
||||
}
|
||||
|
||||
private void TogglePause() => OpenPanel(_pauseMenuRoot);
|
||||
private void OpenShop(string npcId) => OpenPanel(_shopRoot);
|
||||
private void OpenMap() => OpenPanel(_mapRoot);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. HUDController
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/HUD/HUDController.cs
|
||||
public class HUDController : MonoBehaviour
|
||||
{
|
||||
[Header("HP")]
|
||||
[SerializeField] private Transform _hpContainer;
|
||||
[SerializeField] private GameObject _hpCellPrefab; // 单格 HP 图标
|
||||
|
||||
[Header("Gauges")]
|
||||
[SerializeField] private Image _soulGaugeFill;
|
||||
[SerializeField] private Image _spiritGaugeFill;
|
||||
[SerializeField] private TMP_Text _geoText;
|
||||
|
||||
[Header("Spring Charges")]
|
||||
[SerializeField] private Transform _springContainer;
|
||||
[SerializeField] private GameObject _springIconPrefab;
|
||||
|
||||
[Header("Form")]
|
||||
[SerializeField] private Image[] _formIcons; // 3 forms
|
||||
|
||||
[Header("Interact Prompt")]
|
||||
[SerializeField] private TMP_Text _interactText;
|
||||
[SerializeField] private GameObject _interactPromptRoot;
|
||||
|
||||
[Header("Event Channels - Subscribe")]
|
||||
[SerializeField] private IntEventChannelSO _onHPChanged;
|
||||
[SerializeField] private IntEventChannelSO _onMaxHPChanged;
|
||||
[SerializeField] private IntEventChannelSO _onSoulPowerChanged;
|
||||
[SerializeField] private IntEventChannelSO _onSpiritPowerChanged;
|
||||
[SerializeField] private IntEventChannelSO _onGeoChanged;
|
||||
[SerializeField] private IntEventChannelSO _onSpringChargesChanged;
|
||||
[SerializeField] private IntEventChannelSO _onFormChanged;
|
||||
[SerializeField] private StringEventChannelSO _onShowInteractPrompt;
|
||||
[SerializeField] private VoidEventChannelSO _onHideInteractPrompt;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onHPChanged.OnEventRaised += UpdateHP;
|
||||
_onMaxHPChanged.OnEventRaised += RebuildHPCells;
|
||||
_onSoulPowerChanged.OnEventRaised += val => _soulGaugeFill.fillAmount = val / 100f;
|
||||
_onSpiritPowerChanged.OnEventRaised += val => _spiritGaugeFill.fillAmount = val / 100f;
|
||||
_onGeoChanged.OnEventRaised += val => _geoText.text = val.ToString();
|
||||
_onSpringChargesChanged.OnEventRaised += RebuildSpringIcons;
|
||||
_onFormChanged.OnEventRaised += UpdateFormIcon;
|
||||
_onShowInteractPrompt.OnEventRaised += ShowInteractPrompt;
|
||||
_onHideInteractPrompt.OnEventRaised += HideInteractPrompt;
|
||||
}
|
||||
private void OnDisable() { /* 对称 -= */ }
|
||||
|
||||
private void UpdateHP(int current); // 更新 HP 格子激活/灰化状态
|
||||
private void RebuildHPCells(int max); // 重建 HP 格子列表(MaxHP 改变时)
|
||||
private void RebuildSpringIcons(int charges);
|
||||
private void UpdateFormIcon(int formIndex);
|
||||
private void ShowInteractPrompt(string text);
|
||||
private void HideInteractPrompt();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. BossHPBar
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/HUD/BossHPBar.cs
|
||||
public class BossHPBar : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text _bossNameText;
|
||||
[SerializeField] private Image _hpFill;
|
||||
[SerializeField] private Transform _phaseMarkersRoot;
|
||||
[SerializeField] private GameObject _phaseMarkerPrefab;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private BoolEventChannelSO _onBossFightToggled; // true=开始,false=结束
|
||||
[SerializeField] private IntEventChannelSO _onBossHPChanged;
|
||||
[SerializeField] private StringEventChannelSO _onBossNameSet;
|
||||
[SerializeField] private IntEventChannelSO _onBossHPMaxSet;
|
||||
|
||||
private int _maxHP;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onBossFightToggled.OnEventRaised += OnBossFightToggled;
|
||||
_onBossHPChanged.OnEventRaised += hp => _hpFill.fillAmount = (float)hp / _maxHP;
|
||||
_onBossNameSet.OnEventRaised += name => _bossNameText.text = name;
|
||||
_onBossHPMaxSet.OnEventRaised += max => _maxHP = max;
|
||||
}
|
||||
private void OnDisable()
|
||||
{
|
||||
_onBossFightToggled.OnEventRaised -= OnBossFightToggled;
|
||||
_onBossHPChanged.OnEventRaised -= hp => _hpFill.fillAmount = (float)hp / _maxHP;
|
||||
_onBossNameSet.OnEventRaised -= name => _bossNameText.text = name;
|
||||
_onBossHPMaxSet.OnEventRaised -= max => _maxHP = max;
|
||||
}
|
||||
|
||||
private void OnBossFightToggled(bool started)
|
||||
{
|
||||
if (started) StartCoroutine(SlideIn());
|
||||
else StartCoroutine(SlideOut());
|
||||
}
|
||||
|
||||
private IEnumerator SlideIn(); // 动画:Boss 血条从屏幕底部滑入
|
||||
private IEnumerator SlideOut(); // 动画:Boss 血条滑出并隐藏
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. PauseMenuController
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/Menus/PauseMenuController.cs
|
||||
public class PauseMenuController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private UIManager _uiManager;
|
||||
[SerializeField] private Button _btnResume;
|
||||
[SerializeField] private Button _btnSettings;
|
||||
[SerializeField] private Button _btnMainMenu;
|
||||
[SerializeField] private Button _btnQuit;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[SerializeField] private VoidEventChannelSO _onResumeRequested;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_btnResume.onClick.AddListener(Resume);
|
||||
_btnSettings.onClick.AddListener(() => _uiManager.OpenPanel(_settingsRoot));
|
||||
_btnMainMenu.onClick.AddListener(GoToMainMenu);
|
||||
_btnQuit.onClick.AddListener(Application.Quit);
|
||||
}
|
||||
|
||||
private void Resume()
|
||||
{
|
||||
_onResumeRequested.Raise();
|
||||
_uiManager.CloseTopPanel();
|
||||
}
|
||||
|
||||
private void GoToMainMenu();
|
||||
// 广播 EVT_SceneLoadRequest(目标 = MainMenuScene)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. DeathScreenController
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/Menus/DeathScreenController.cs
|
||||
public class DeathScreenController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text _deathMessage;
|
||||
[SerializeField] private Button _btnRespawn;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||||
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed; // Raise → GameManager.RespawnCoroutine
|
||||
|
||||
private void OnEnable() => _onPlayerDied.OnEventRaised += OnPlayerDied;
|
||||
private void OnDisable() => _onPlayerDied.OnEventRaised -= OnPlayerDied;
|
||||
|
||||
// ⚠️ EVT_PlayerDied 发出后需等待 1.5s 死亡动画,否则死亡画面会提前弹出
|
||||
private void OnPlayerDied() => StartCoroutine(ShowAfterDelay(1.5f));
|
||||
|
||||
private IEnumerator ShowAfterDelay(float delay)
|
||||
{
|
||||
yield return new WaitForSeconds(delay);
|
||||
Show();
|
||||
}
|
||||
|
||||
private void Show()
|
||||
{
|
||||
gameObject.SetActive(true);
|
||||
_btnRespawn.onClick.RemoveAllListeners();
|
||||
_btnRespawn.onClick.AddListener(Confirm);
|
||||
}
|
||||
|
||||
private void Confirm()
|
||||
{
|
||||
gameObject.SetActive(false);
|
||||
_onDeathScreenConfirmed.Raise(); // GameManager 监听后执行 RespawnCoroutine
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. SettingsPanelController
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/Menus/SettingsPanelController.cs
|
||||
// 驱动 SettingsManager 的全部 Set* 方法
|
||||
public class SettingsPanelController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private SettingsManager _settings;
|
||||
|
||||
[Header("Audio")]
|
||||
[SerializeField] private Slider _masterVolume;
|
||||
[SerializeField] private Slider _bgmVolume;
|
||||
[SerializeField] private Slider _sfxVolume;
|
||||
[SerializeField] private Slider _ambientVolume;
|
||||
|
||||
[Header("Video")]
|
||||
[SerializeField] private Toggle _vSyncToggle;
|
||||
[SerializeField] private TMP_Dropdown _fpsDropdown;
|
||||
[SerializeField] private TMP_Dropdown _resolutionDropdown;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 从 SettingsManager 读取当前值并填充控件
|
||||
// 绑定 onChange 事件 → 调用对应 _settings.Set*()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7.5 SaveSlotController
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/Menus/SaveSlotController.cs
|
||||
// 驱动主菜单存档槽选择(新游戏 / 继续)
|
||||
public class SaveSlotController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private SaveSlotUI[] _slotUIs; // 3 个存档槽 UI
|
||||
[SerializeField] private SaveManager _saveManager;
|
||||
|
||||
public async UniTask RefreshAsync()
|
||||
{
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var summary = await _saveManager.GetSlotSummaryAsync(i);
|
||||
_slotUIs[i].Refresh(summary); // null = 空槽(显示“新局”)
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSlotSelected(int slotIndex);
|
||||
// 新局:_saveManager.CreateSlot(slotIndex) → 启动游戏
|
||||
// 继续:_saveManager.LoadAsync(slotIndex) → 载入存档
|
||||
}
|
||||
|
||||
// 单个存档槽卡片组件
|
||||
public class SaveSlotUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text _playtimeText;
|
||||
[SerializeField] private TMP_Text _regionText;
|
||||
[SerializeField] private TMP_Text _percentText;
|
||||
[SerializeField] private Image _formIcon;
|
||||
[SerializeField] private TMP_Text _lastSavedText;
|
||||
[SerializeField] private GameObject _emptyIndicator; // 空槽提示
|
||||
|
||||
public void Refresh(SlotSummary summary);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7.6 SaveIndicator
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/SaveIndicator.cs
|
||||
// 右下角存档进行中提示字
|
||||
[RequireComponent(typeof(CanvasGroup))]
|
||||
public class SaveIndicator : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private CanvasGroup _cg;
|
||||
[SerializeField] private float _fadeDuration = 0.2f;
|
||||
|
||||
[Header("Event Channels")]
|
||||
// ⚠️ 统一使用单一 BoolEventChannelSO(对齐 02_EventSystem §4 EVT_SaveIndicatorVisible 和 12_SaveModule §4/§6)
|
||||
[SerializeField] private BoolEventChannelSO _onSaveIndicatorVisible; // → EVT_SaveIndicatorVisible
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onSaveIndicatorVisible.OnEventRaised += visible => FadeTo(visible ? 1f : 0f);
|
||||
}
|
||||
private void OnDisable()
|
||||
{
|
||||
_onSaveIndicatorVisible.OnEventRaised -= visible => FadeTo(visible ? 1f : 0f);
|
||||
}
|
||||
|
||||
private void FadeTo(float target) => StartCoroutine(FadeCoroutine(target));
|
||||
private IEnumerator FadeCoroutine(float target)
|
||||
{
|
||||
float start = _cg.alpha;
|
||||
float t = 0;
|
||||
while (t < _fadeDuration)
|
||||
{
|
||||
_cg.alpha = Mathf.Lerp(start, target, t / _fadeDuration);
|
||||
t += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
_cg.alpha = target;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7.7 LoadingScreenManager
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/LoadingScreenManager.cs
|
||||
// 全屏加载面:进度条 + 提示文字 + 随机背景图
|
||||
public class LoadingScreenManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private GameObject _loadingRoot;
|
||||
[SerializeField] private Image _progressFill; // 进度条 fillAmount
|
||||
[SerializeField] private TMP_Text _tipText; // 载入提示
|
||||
[SerializeField] private Image[] _backgroundArts; // 随机切换的背景图
|
||||
[SerializeField] private string[] _tipKeys; // 本地化 Key 数组
|
||||
[SerializeField] private float _minDisplayTime = 0.5f; // 载入面最少展示时长
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private VoidEventChannelSO _onLoadingStarted;
|
||||
[SerializeField] private VoidEventChannelSO _onLoadingComplete;
|
||||
[SerializeField] private FloatEventChannelSO _onLoadingProgressUpdated; // 0–1
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onLoadingStarted.OnEventRaised += Show;
|
||||
_onLoadingComplete.OnEventRaised += Hide;
|
||||
_onLoadingProgressUpdated.OnEventRaised += SetProgress;
|
||||
}
|
||||
private void OnDisable()
|
||||
{
|
||||
_onLoadingStarted.OnEventRaised -= Show;
|
||||
_onLoadingComplete.OnEventRaised -= Hide;
|
||||
_onLoadingProgressUpdated.OnEventRaised -= SetProgress;
|
||||
}
|
||||
|
||||
private void Show()
|
||||
{
|
||||
_loadingRoot.SetActive(true);
|
||||
_progressFill.fillAmount = 0f;
|
||||
// 随机选取背景图和提示文字
|
||||
foreach (var bg in _backgroundArts) bg.enabled = false;
|
||||
_backgroundArts[Random.Range(0, _backgroundArts.Length)].enabled = true;
|
||||
_tipText.text = LocalizationManager.Get(_tipKeys[Random.Range(0, _tipKeys.Length)]);
|
||||
}
|
||||
|
||||
private void Hide() => StartCoroutine(HideAfterMinTime());
|
||||
private IEnumerator HideAfterMinTime()
|
||||
{
|
||||
// 确保载入面至少展示 _minDisplayTime 秒
|
||||
yield return new WaitForSecondsRealtime(_minDisplayTime);
|
||||
_loadingRoot.SetActive(false);
|
||||
}
|
||||
|
||||
private void SetProgress(float value) => _progressFill.fillAmount = value;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7.8 IBossHPProvider 接口
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/HUD/IBossHPProvider.cs
|
||||
// 解耦接口:让 BossHPBar 不直接依赖 BossBase(UI → Combat 逆向耐合)
|
||||
// BossBase 在运行时实现此接口,BossOrchestrator 配置到 BossHPBar._provider 中
|
||||
public interface IBossHPProvider
|
||||
{
|
||||
string BossId { get; } // Boss 前缀 ID
|
||||
string BossNameKey { get; } // 本地化 Key
|
||||
float HPRatio { get; } // 0–1 实时 HP 比例
|
||||
int TotalPhases { get; } // Boss 总阶段数为阶段标记数
|
||||
float[] PhaseThresholds { get; } // 各阶段切换 HP 阈值
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. LoadingOverlay
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/LoadingOverlay.cs
|
||||
// 由 SceneLoader 直接调用(或通过事件),控制全屏黑幕渐入渐出
|
||||
public class LoadingOverlay : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private CanvasGroup _canvasGroup;
|
||||
[SerializeField] private float _fadeDuration = 0.3f;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private BoolEventChannelSO _onLoadingOverlayRequested;
|
||||
|
||||
private void OnEnable() => _onLoadingOverlayRequested.OnEventRaised += SetVisible;
|
||||
private void OnDisable() => _onLoadingOverlayRequested.OnEventRaised -= SetVisible;
|
||||
|
||||
private void SetVisible(bool visible) => StartCoroutine(FadeCoroutine(visible ? 1f : 0f));
|
||||
|
||||
private IEnumerator FadeCoroutine(float target)
|
||||
{
|
||||
float start = _canvasGroup.alpha;
|
||||
float t = 0;
|
||||
while (t < _fadeDuration)
|
||||
{
|
||||
_canvasGroup.alpha = Mathf.Lerp(start, target, t / _fadeDuration);
|
||||
t += Time.unscaledDeltaTime;
|
||||
yield return null;
|
||||
}
|
||||
_canvasGroup.alpha = target;
|
||||
_canvasGroup.blocksRaycasts = target > 0.5f;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. DialogueBox
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/DialogueBox.cs
|
||||
// 挂在 Canvas_Overlay 下;由 DialogueManager 控制(见 14_NarrativeModule.md)
|
||||
public class DialogueBox : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text _speakerNameText;
|
||||
[SerializeField] private TMP_Text _dialogueText;
|
||||
[SerializeField] private GameObject _continuePrompt;
|
||||
|
||||
// DialogueManager 直接调用(不通过事件频道,避免帧延迟)
|
||||
public void Show(string speakerName, string text, bool showContinue);
|
||||
public void Hide();
|
||||
|
||||
// 文字逐字打印协程
|
||||
public IEnumerator TypeText(string text, float charDelay = 0.03f);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. FloatingDamageText
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/FloatingDamageText.cs
|
||||
// 从对象池取出,显示伤害数字,向上飘动后归还
|
||||
public class FloatingDamageText : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text _text;
|
||||
[SerializeField] private float _floatDistance = 1.5f;
|
||||
[SerializeField] private float _duration = 0.8f;
|
||||
|
||||
private string _poolKey = AddressKeys.UI_FloatingDmgText;
|
||||
|
||||
public void Show(Vector2 worldPosition, int damage, DamageType type);
|
||||
// 1. 设置世界坐标(Camera.main.WorldToScreenPoint → RectTransform)
|
||||
// 2. 颜色:Normal=白, Fire=橙, Poison=绿, True=黄
|
||||
// 3. 协程向上漂移 + alpha 淡出
|
||||
// 4. 归还对象池
|
||||
|
||||
// 由 EVT_DamageDealt 频道触发(HUDController 订阅后调用)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. ToastNotification
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/ToastNotification.cs
|
||||
// 右上角通知弹窗(能力解锁、成就、提示)
|
||||
public class ToastNotification : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TMP_Text _titleText;
|
||||
[SerializeField] private TMP_Text _bodyText;
|
||||
[SerializeField] private Image _icon;
|
||||
[SerializeField] private float _displayDuration = 3f;
|
||||
|
||||
public void Show(string title, string body, Sprite icon = null);
|
||||
private IEnumerator AutoHide();
|
||||
}
|
||||
|
||||
// ToastManager:管理通知队列(一次只显示一条)
|
||||
public class ToastManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private ToastNotification _toast;
|
||||
private Queue<(string title, string body, Sprite icon)> _queue = new();
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onAchievementUnlocked; // 广播成就名
|
||||
// ... 其他通知来源
|
||||
|
||||
public void Enqueue(string title, string body, Sprite icon = null);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. InputDeviceIconSwitcher
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/UI/InputDeviceIconSwitcher.cs
|
||||
// 检测输入设备切换(KB/手柄),自动替换 UI 按键图标
|
||||
public class InputDeviceIconSwitcher : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private InputDeviceIconSetSO _kbIconSet;
|
||||
[SerializeField] private InputDeviceIconSetSO _padIconSet;
|
||||
|
||||
[Header("Event Channel")]
|
||||
[SerializeField] private BoolEventChannelSO _onDeviceChanged; // true=手柄
|
||||
|
||||
private void OnEnable() => _onDeviceChanged.OnEventRaised += SwitchIconSet;
|
||||
private void OnDisable() => _onDeviceChanged.OnEventRaised -= SwitchIconSet;
|
||||
|
||||
private void SwitchIconSet(bool isGamepad);
|
||||
// 广播给所有 InputIconImage 组件(自行从 IconSet 查找对应 Sprite)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. PanelStack
|
||||
|
||||
```csharp
|
||||
// 已集成在 UIManager 内部(OpenPanel / CloseTopPanel)
|
||||
// 控制器导航规则:
|
||||
// - 每次 OpenPanel 时设置 EventSystem.SetSelectedGameObject(panel.defaultButton)
|
||||
// - Escape / 手柄 B 键 → 触发 CloseTopPanel
|
||||
// - Stack 为空时 → 若在 Gameplay 状态则无操作,若在 MainMenu 则弹出退出确认
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. UI 事件频道清单
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_GameStateChanged` | `GameStateEventChannelSO` | `GameManager` | `UIManager` |
|
||||
| `EVT_PlayerDied` | `VoidEventChannelSO` | `PlayerStats` | `DeathScreenController`、`GameManager` |
|
||||
| `EVT_DeathScreenConfirmed` | `VoidEventChannelSO` | `DeathScreenController`(Respawn 按钮) | `GameManager`(启动 RespawnCoroutine) |
|
||||
| `EVT_ShowInteractPrompt` | `StringEventChannelSO` | `InteractableDetector` | `HUDController` |
|
||||
| `EVT_HideInteractPrompt` | `VoidEventChannelSO` | `InteractableDetector` | `HUDController` |
|
||||
| `EVT_BossFightToggled` | `BoolEventChannelSO`(true=开始,false=结束) | `BossOrchestrator` | `BossHPBar`、`AudioManager`(切 Boss BGM) |
|
||||
| `EVT_BossHPChanged` | `IntEventChannelSO` | `BossBase` | `BossHPBar` |
|
||||
| `EVT_BossNameSet` | `StringEventChannelSO` | `BossOrchestrator` | `BossHPBar` |
|
||||
| `EVT_BossHPMaxSet` | `IntEventChannelSO` | `BossBase` | `BossHPBar` |
|
||||
| `EVT_LoadingOverlay` | `BoolEventChannelSO` | `SceneLoader` | `LoadingOverlay` |
|
||||
| `EVT_DamageDealt` | `DamageInfoEventChannelSO` | `HurtBox` | `HUDController`(生成伤害数字)、`AchievementManager` |
|
||||
| `EVT_AchievementUnlocked` | `StringEventChannelSO` | `AchievementManager` | `ToastManager` |
|
||||
| `EVT_AbilityUnlocked` | `StringEventChannelSO`(abilityId) | `PlayerStats.UnlockAbility` | `ToastManager`、`HUDController` |
|
||||
| `EVT_PlayerFormChanged` | `IntEventChannelSO` | `FormController` | `HUDController`、`SkillHUD` |
|
||||
| `EVT_InputDeviceChanged` | `BoolEventChannelSO` | 输入系统(设备切换回调) | `InputDeviceIconSwitcher` |
|
||||
| `EVT_LoadingStarted` | `VoidEventChannelSO` | `SceneLoader` | `LoadingScreenManager` |
|
||||
| `EVT_LoadingComplete` | `VoidEventChannelSO` | `SceneLoader` | `LoadingScreenManager` |
|
||||
| `EVT_LoadingProgressUpdated` | `FloatEventChannelSO` | `SceneLoader` | `LoadingScreenManager` |
|
||||
| `EVT_SaveIndicatorVisible` | `BoolEventChannelSO`(true=显示,false=隐藏) | `SaveManager.SaveAsync()` | `SaveIndicator` |
|
||||
526
Docs/Architecture/11_AudioModule.md
Normal file
526
Docs/Architecture/11_AudioModule.md
Normal file
@@ -0,0 +1,526 @@
|
||||
# 11 · 音频模块
|
||||
|
||||
> **命名空间** `BaseGames.Audio`
|
||||
> **程序集** `BaseGames.Audio`
|
||||
> **路径** `Assets/Scripts/Audio/`
|
||||
> **依赖** `BaseGames.Core.Events`、`Unity AudioMixer`
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [AudioMixer 架构](#1-audiomixer-架构)
|
||||
2. [AudioManager](#2-audiomanager)
|
||||
3. [BGMController](#3-bgmcontroller)
|
||||
4. [AudioZone](#4-audiozone)
|
||||
5. [AudioEventSO(SFX 集成)](#5-audioeventsso)
|
||||
6. [GlobalSFXPlayer](#6-globalsfxplayer)
|
||||
7. [AudioConfigSO](#7-audioconfigso)
|
||||
8. [音频事件频道清单](#8-音频事件频道清单)
|
||||
|
||||
---
|
||||
|
||||
## 1. AudioMixer 架构
|
||||
|
||||
**资产路径**:`Assets/Audio/MainMixer.mixer`
|
||||
|
||||
### 混音组层级
|
||||
|
||||
```
|
||||
Master
|
||||
├── BGM (背景音乐)
|
||||
├── SFX (所有音效)
|
||||
│ ├── SFX_Player
|
||||
│ ├── SFX_Enemy
|
||||
│ └── SFX_World
|
||||
└── Ambient (环境音)
|
||||
```
|
||||
|
||||
### Exposed Parameters(代码用字符串常量)
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioMixerKeys.cs
|
||||
public static class AudioMixerKeys
|
||||
{
|
||||
public const string Master = "MasterVolume";
|
||||
public const string BGM = "BGMVolume";
|
||||
public const string SFX = "SFXVolume";
|
||||
public const string Ambient = "AmbientVolume";
|
||||
}
|
||||
```
|
||||
|
||||
所有参数范围:`-80 ~ 0 dB`
|
||||
|
||||
### AudioMixer 快照
|
||||
|
||||
| 快照名 | 触发条件 | 主要差异 |
|
||||
|--------|---------|---------|
|
||||
| `Default` | 正常 Gameplay | 全组正常 |
|
||||
| `Paused` | `GameState.Paused` | BGM/SFX -12 dB + 低通滤波 |
|
||||
| `Dead` | 玩家死亡 | BGM 渐出 -80 dB(1.5s)|
|
||||
| `BossFight` | Boss 战开始 | Ambient -20 dB |
|
||||
|
||||
切换方式:
|
||||
```csharp
|
||||
_mixer.TransitionToSnapshots(new[] { snapshot }, new[] { 1f }, transitionTime);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. AudioManager
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioManager.cs
|
||||
[DefaultExecutionOrder(-500)]
|
||||
public class AudioManager : MonoBehaviour
|
||||
{
|
||||
[Header("AudioMixer")]
|
||||
[SerializeField] private AudioMixer _mixer;
|
||||
|
||||
[Header("BGM Sources(双 Source 交叉淡入淡出)")]
|
||||
[SerializeField] private AudioSource _bgmSourceA;
|
||||
[SerializeField] private AudioSource _bgmSourceB;
|
||||
|
||||
[Header("SFX Pool(4~8 源轮转,防高密度战斗音效戳断)")]
|
||||
[SerializeField] private AudioSource[] _sfxSources; // Inspector 预挂,建议 6 个,均路由到 SFX MixerGroup
|
||||
private int _sfxRoundRobin; // 轮转指针(无锁)
|
||||
|
||||
[Header("Event Channels - Subscribe")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ bossId: string(与 02_EventSystem §4 / §3 BGMController 一致)
|
||||
|
||||
// ── Singleton(已废弃:新代码请使用 ServiceLocator.Get<IAudioService>())─────────────
|
||||
/// <summary>
|
||||
/// 已废弃。请改用 ServiceLocator.Get<IAudioService>() 访问音频服务。
|
||||
/// 保留此属性仅为历史层兴范过渡期兼容,将在下一个大版本移除。
|
||||
/// </summary>
|
||||
[System.Obsolete("Use ServiceLocator.Get<IAudioService>() instead. AudioManager.Instance will be removed in a future version.")]
|
||||
public static AudioManager Instance { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
_activeBGMSource = _bgmSourceA;
|
||||
_inactiveBGMSource = _bgmSourceB;
|
||||
}
|
||||
|
||||
private AudioSource _activeBGMSource;
|
||||
private AudioSource _inactiveBGMSource;
|
||||
private Coroutine _crossfadeCoroutine;
|
||||
|
||||
// ── 音量(统一入口;SettingsManager / 设置面板调用)────────────
|
||||
public void Initialize(); // 读取 GlobalSettings 并应用所有音量
|
||||
|
||||
/// <summary>
|
||||
/// 将指定混音器参数设置为 0-1 线性值(内部转换为 dB)。
|
||||
/// 唯一音量写入入口——所有调用方均使用此方法。
|
||||
/// </summary>
|
||||
/// <param name="exposedParam">AudioMixerKeys.* 常量(Master / BGM / SFX / Ambient)</param>
|
||||
public void SetVolume(string exposedParam, float linear)
|
||||
=> _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
|
||||
|
||||
// BGM 播放(带两段淡变时长)
|
||||
public void PlayBGM(AudioClip clip, float fadeOutDur = 1f, float fadeInDur = 1f);
|
||||
public void StopBGM(float fadeDuration = 1f);
|
||||
|
||||
// SFX 一次性播放(轮转多源,避免高密度战斗时音效相互戳断)
|
||||
public void PlaySFX(AudioClip clip, float volumeScale = 1f)
|
||||
{
|
||||
var src = NextSFXSource();
|
||||
src.volume = volumeScale;
|
||||
src.PlayOneShot(clip); // PlayOneShot 不打断当前正在播放的其他音效
|
||||
}
|
||||
|
||||
// 2D 游戏中位置参数不做 3D 衰减,统一个多源池路由
|
||||
public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f)
|
||||
=> PlaySFX(clip, volumeScale);
|
||||
|
||||
// 轮转返回下一个 AudioSource;PlayOneShot 下无需检查 isPlaying
|
||||
private AudioSource NextSFXSource()
|
||||
=> _sfxSources[_sfxRoundRobin++ % _sfxSources.Length];
|
||||
|
||||
// 快照切换
|
||||
public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f);
|
||||
|
||||
private IEnumerator CrossfadeCoroutine(AudioClip newClip, float fadeOutDur, float fadeInDur);
|
||||
private static float LinearToDecibel(float linear)
|
||||
=> linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. BGMController
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/BGMController.cs
|
||||
// 订阅世界 / Boss 事件,指挥 AudioManager 切换 BGM
|
||||
public class BGMController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private AudioManager _audioManager;
|
||||
[SerializeField] private AudioConfigSO _config;
|
||||
|
||||
[Header("Event Channels - Subscribe")]
|
||||
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
|
||||
[SerializeField] private BoolEventChannelSO _onBossFightToggled; // true=开始, false=结束
|
||||
[SerializeField] private StringEventChannelSO _onRegionEntered; // region id
|
||||
|
||||
private MusicState _musicState = MusicState.Exploration;
|
||||
|
||||
private string _currentRegion = "Forest";
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onBossFightToggled.OnEventRaised += OnBossFightToggled;
|
||||
_onRegionEntered.OnEventRaised += OnRegionEntered;
|
||||
_onGameStateChanged.OnEventRaised += HandleStateChanged;
|
||||
}
|
||||
private void OnDisable() { /* -= */ }
|
||||
|
||||
private void OnBossFightToggled(bool started)
|
||||
{
|
||||
if (started)
|
||||
{
|
||||
_musicState = MusicState.Boss;
|
||||
var clip = _config.GetBossBGM(_currentRegion);
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
|
||||
_audioManager.TransitionToSnapshot("BossFight", 0.5f);
|
||||
}
|
||||
else
|
||||
{
|
||||
StartCoroutine(PlayVictoryThenRestore());
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator PlayVictoryThenRestore()
|
||||
{
|
||||
_musicState = MusicState.Victory;
|
||||
_audioManager.PlayBGM(_config.VictoryStingBGM, fadeOutDur: 0.3f, fadeInDur: 0.1f);
|
||||
yield return new WaitForSecondsRealtime(_config.VictoryStingDuration);
|
||||
_musicState = MusicState.Exploration;
|
||||
OnRegionEntered(_currentRegion);
|
||||
_audioManager.TransitionToSnapshot("Default", 1.0f);
|
||||
}
|
||||
|
||||
private void OnRegionEntered(string regionId)
|
||||
{
|
||||
if (regionId == _currentRegion) return;
|
||||
_currentRegion = regionId;
|
||||
if (_musicState == MusicState.Exploration)
|
||||
{
|
||||
var clip = _config.GetZoneBGM(regionId);
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStateChanged(GameStateId state)
|
||||
{
|
||||
// ⚠️ GameStateId 是 struct,不能用 switch;使用 if/else + GameStates 常量(架构 03_CoreModule §2)
|
||||
if (state == GameStates.MainMenu)
|
||||
_audioManager.PlayBGM(_config.MainMenuBGM, fadeOutDur: 0.5f, fadeInDur: 1.0f);
|
||||
else if (state == GameStates.Paused)
|
||||
_audioManager.TransitionToSnapshot("Paused", 0.2f);
|
||||
else if (state == GameStates.Dead)
|
||||
_audioManager.TransitionToSnapshot("Dead", 1.5f);
|
||||
else if (state == GameStates.Gameplay)
|
||||
_audioManager.TransitionToSnapshot("Default", 0.3f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3.5 MusicState 音乐状态机
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/BGMController.cs
|
||||
// 由 BGMController 内部维护,控制 BGM 切换逻辑
|
||||
public enum MusicState
|
||||
{
|
||||
Exploration, // 默认:区域探索 BGM
|
||||
Boss, // Boss 战:Boss 主题 BGM
|
||||
Victory, // Boss 击败后短暂胜利音乐
|
||||
None, // 过场/死亡/主菜单时由 BGMController 直接切换
|
||||
}
|
||||
```
|
||||
|
||||
**状态转换**:
|
||||
|
||||
```
|
||||
Exploration ──[开始 Boss 战]─────────────► Boss
|
||||
◄─[结束 Boss 战]──────────────
|
||||
Boss ──[Boss 击败]────────────────► Victory
|
||||
Victory ──[VictorySting 播放完毕]─────► Exploration
|
||||
Exploration ──[OnRegionEntered]─────────► Exploration(切换同状态内不同曲目)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. AudioZone
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioZone.cs
|
||||
// 触发器:进入区域时切换 BGM
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class AudioZone : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string _zoneId; // 与 AudioConfigSO 中的 key 对应
|
||||
|
||||
[Header("Event Channel")]
|
||||
[SerializeField] private StringEventChannelSO _onRegionEntered;
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (other.CompareTag("Player"))
|
||||
_onRegionEntered.Raise(_zoneId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AudioEventSO
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioEventSO.cs
|
||||
// 可在 Inspector 配置的 SFX 数据:支持随机音量/音调、随机片段
|
||||
[CreateAssetMenu(menuName = "Audio/AudioEvent")]
|
||||
public class AudioEventSO : ScriptableObject
|
||||
{
|
||||
public AudioClip[] Clips; // 随机挑选一个播放
|
||||
[Range(0f, 1f)]
|
||||
public float VolumeMin = 0.9f;
|
||||
[Range(0f, 1f)]
|
||||
public float VolumeMax = 1.0f;
|
||||
[Range(0.5f, 2f)]
|
||||
public float PitchMin = 0.95f;
|
||||
[Range(0.5f, 2f)]
|
||||
public float PitchMax = 1.05f;
|
||||
public AudioMixerGroup MixerGroup; // 指定路由到哪个子混音组(如 SFX_Player)
|
||||
|
||||
public void Play(AudioSource source)
|
||||
{
|
||||
if (Clips == null || Clips.Length == 0) return;
|
||||
source.outputAudioMixerGroup = MixerGroup;
|
||||
source.clip = Clips[Random.Range(0, Clips.Length)];
|
||||
source.volume = Random.Range(VolumeMin, VolumeMax);
|
||||
source.pitch = Random.Range(PitchMin, PitchMax);
|
||||
source.Play();
|
||||
}
|
||||
|
||||
public void PlayOneShot(AudioSource source)
|
||||
{
|
||||
if (Clips == null || Clips.Length == 0) return;
|
||||
var clip = Clips[Random.Range(0, Clips.Length)];
|
||||
source.outputAudioMixerGroup = MixerGroup;
|
||||
source.PlayOneShot(clip, Random.Range(VolumeMin, VolumeMax));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径**:`Assets/ScriptableObjects/Audio/`
|
||||
**命名规范**:`AUD_{Category}_{Name}.asset`(例 `AUD_Player_SwordSlash.asset`)
|
||||
|
||||
---
|
||||
|
||||
## 6. GlobalSFXPlayer
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/GlobalSFXPlayer.cs
|
||||
// 提供静态方法入口,配合 AudioEventSO 在任何地方播放 SFX
|
||||
// Feel(MMF_Player)的 MMSoundManagerSoundSO 也通过此路由
|
||||
public class GlobalSFXPlayer : MonoBehaviour
|
||||
{
|
||||
private static GlobalSFXPlayer _instance;
|
||||
|
||||
[SerializeField] private AudioMixerGroup _sfxGroup;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null) { Destroy(gameObject); return; }
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
// 路由到 AudioManager.PlaySFX(多源轮转池,见 §2)
|
||||
// AudioEventSO.GetClip() 返回对应音频片段(含随机变体支持)
|
||||
public static void Play(AudioEventSO audioEvent, Vector2? worldPos = null)
|
||||
{
|
||||
// 2D 游戏不需要 3D 空间音效衰减;统一委托 AudioManager 多源池播放
|
||||
var clip = audioEvent.GetClip();
|
||||
if (clip != null)
|
||||
AudioManager.Instance.PlaySFX(clip);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. AudioConfigSO
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/AudioConfigSO.cs
|
||||
[CreateAssetMenu(menuName = "Audio/AudioConfig")]
|
||||
public class AudioConfigSO : ScriptableObject
|
||||
{
|
||||
[System.Serializable]
|
||||
public struct ZoneBGMEntry
|
||||
{
|
||||
public string ZoneId;
|
||||
public AudioClip BGMClip;
|
||||
public float FadeDuration;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
public struct BossBGMEntry
|
||||
{
|
||||
public string BossId;
|
||||
public AudioClip BGMClip;
|
||||
}
|
||||
|
||||
public ZoneBGMEntry[] ZoneBGMs;
|
||||
public BossBGMEntry[] BossBGMs;
|
||||
public AudioClip MainMenuBGM;
|
||||
public AudioClip GameOverSting; // 死亡时短音乐片段
|
||||
public AudioClip VictoryStingBGM; // Boss 击败后胜利音乐片段
|
||||
public float VictoryStingDuration = 4f; // 胜利音乐播放时长(秒)
|
||||
|
||||
public AudioClip GetZoneBGM(string zoneId)
|
||||
{
|
||||
foreach (var e in ZoneBGMs)
|
||||
if (e.ZoneId == zoneId) return e.BGMClip;
|
||||
return null;
|
||||
}
|
||||
|
||||
public AudioClip GetBossBGM(string bossId)
|
||||
{
|
||||
foreach (var e in BossBGMs)
|
||||
if (e.BossId == bossId) return e.BGMClip;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径**:`Assets/ScriptableObjects/Audio/AUD_Config.asset`
|
||||
|
||||
---
|
||||
|
||||
## 8. 音频事件频道清单
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_RegionEntered` | `StringEventChannelSO` | `AudioZone` | `BGMController` |
|
||||
| `EVT_BossFightStarted` | `StringEventChannelSO` | `BossOrchestrator` | `BGMController`、`BossHPBar` |
|
||||
| `EVT_BossFightEnded` | `BoolEventChannelSO` | `BossBase` | `BGMController` |
|
||||
| `EVT_GameStateChanged` | `GameStateEventChannelSO` | `GameManager` | `BGMController`(暂停/恢复)、`AudioManager`(快照) |
|
||||
| `EVT_PlayerDied` | `VoidEventChannelSO` | `PlayerStats` | `AudioManager`(播放 GameOverSting、切 Dead 快照) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 脚步声材质分层(Footstep Material System)
|
||||
|
||||
> **Design 来源**:[12_AudioSystem](../Design/12_AudioSystem.md) §14
|
||||
|
||||
脚步声根据脚下地面材质动态切换,不使用单一 SFX 以增强环境真实感。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/FootstepMaterial.cs
|
||||
public enum FootstepMaterial
|
||||
{
|
||||
Stone, // 石板地(默认)
|
||||
Dirt, // 泥土/草地
|
||||
Wood, // 木板
|
||||
Metal, // 金属格栅
|
||||
Water, // 浅水区(溅水声)
|
||||
Sand, // 沙地
|
||||
Grass, // 草丛
|
||||
Cave, // 洞穴(回响加强)
|
||||
}
|
||||
|
||||
// 路径: Assets/Scripts/Audio/FootstepAudioConfigSO.cs
|
||||
[CreateAssetMenu(menuName = "BaseGames/Audio/FootstepAudioConfig")]
|
||||
public class FootstepAudioConfigSO : ScriptableObject
|
||||
{
|
||||
[System.Serializable]
|
||||
public struct MaterialEntry
|
||||
{
|
||||
public FootstepMaterial material;
|
||||
public AudioClip[] clips; // 随机选一个,防止重复感
|
||||
[Range(0f, 1f)] public float volume;
|
||||
[Range(0.8f, 1.2f)] public float pitchVariance; // 每次随机 pitch 偏移范围
|
||||
}
|
||||
|
||||
public MaterialEntry[] entries;
|
||||
|
||||
public MaterialEntry? GetEntry(FootstepMaterial mat)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
if (e.material == mat) return e;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 路径: Assets/Scripts/Audio/FootstepMaterialMarker.cs
|
||||
// 挂载到地面碰撞体所在 GameObject(Tilemap 图层 or 单体地形 Prefab)
|
||||
public class FootstepMaterialMarker : MonoBehaviour
|
||||
{
|
||||
public FootstepMaterial material;
|
||||
}
|
||||
```
|
||||
|
||||
**播放时机**:
|
||||
- **落地**:`PlayerController.OnLanded()` 触发(同 MaterialEntry,音量 ×1.5)
|
||||
- **行走**:Animancer 动画事件 `FootstepL` / `FootstepR`(见 `24_AnimEventModule`)触发
|
||||
- **冲刺起步**:Dash 动画第 2 帧触发专属 `DashSFX`(不走 Footstep 通道)
|
||||
|
||||
玩家若脚下 GameObject 无 `FootstepMaterialMarker`,默认使用 `Stone`。
|
||||
|
||||
---
|
||||
|
||||
## 10. 水下音效处理(Underwater Audio)
|
||||
|
||||
> **Design 来源**:[12_AudioSystem](../Design/12_AudioSystem.md) §15
|
||||
|
||||
进入 `LiquidZone`(见 `21_LiquidPuzzleModule`)时,全局音效自动应用水下 DSP 处理。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Audio/UnderwaterAudioController.cs
|
||||
// 挂载于 PlayerController 所在 GameObject;LiquidZone 调用 EnterWater/ExitWater
|
||||
public class UnderwaterAudioController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] AudioMixer _mixer;
|
||||
[SerializeField] float _transitionDuration = 0.3f;
|
||||
|
||||
/// <summary>LiquidZone.OnTriggerEnter2D 时调用</summary>
|
||||
public void EnterWater()
|
||||
{
|
||||
_mixer.FindSnapshot("Underwater")
|
||||
.TransitionTo(_transitionDuration);
|
||||
}
|
||||
|
||||
/// <summary>LiquidZone.OnTriggerExit2D 时调用</summary>
|
||||
public void ExitWater()
|
||||
{
|
||||
_mixer.FindSnapshot("Default")
|
||||
.TransitionTo(_transitionDuration);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Underwater Snapshot DSP 配置**(AudioMixer 中预设):
|
||||
|
||||
| Bus | 处理 |
|
||||
|-----|------|
|
||||
| BGM | Low-Pass 800 Hz(水下声音沉闷)|
|
||||
| SFX | Low-Pass 1200 Hz + Volume ×0.7 |
|
||||
| Ambient | Volume ×0,替换为水下环境音(气泡声)|
|
||||
| PlayerSFX | Low-Pass 1000 Hz |
|
||||
|
||||
**水下专属 SFX 对照**:
|
||||
|
||||
| 动作 | 水上 SFX | 水下 SFX |
|
||||
|------|---------|---------|
|
||||
| 攻击 | `sfx_player_slash` | `sfx_player_slash_underwater` |
|
||||
| 浮出水面 | — | `sfx_splash_exit` |
|
||||
| 入水 | — | `sfx_splash_enter` |
|
||||
| 游泳移动 | — | `sfx_swim_loop`(循环,pitch 随速度变化)|
|
||||
1123
Docs/Architecture/12_SaveModule.md
Normal file
1123
Docs/Architecture/12_SaveModule.md
Normal file
File diff suppressed because it is too large
Load Diff
1105
Docs/Architecture/13_AssetPoolModule.md
Normal file
1105
Docs/Architecture/13_AssetPoolModule.md
Normal file
File diff suppressed because it is too large
Load Diff
1199
Docs/Architecture/14_NarrativeModule.md
Normal file
1199
Docs/Architecture/14_NarrativeModule.md
Normal file
File diff suppressed because it is too large
Load Diff
675
Docs/Architecture/15_MapShopModule.md
Normal file
675
Docs/Architecture/15_MapShopModule.md
Normal file
@@ -0,0 +1,675 @@
|
||||
# 15 · 地图与商店模块
|
||||
|
||||
> **命名空间** `BaseGames.World.Map`、`BaseGames.World.Shop`
|
||||
> **程序集** `BaseGames.World`
|
||||
> **路径** `Assets/Scripts/World/Map/`、`Assets/Scripts/World/Shop/`
|
||||
> **依赖** `BaseGames.Core.Events`、`BaseGames.Core.Save`、`BaseGames.Dialogue`(IInteractable)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [地图系统](#1-地图系统)
|
||||
- [MapRoomDataSO](#11-maproomdataso)
|
||||
- [MapManager](#12-mapmanager)
|
||||
- [MapPanel(全屏地图 UI)](#13-mappanel)
|
||||
2. [商店系统](#2-商店系统)
|
||||
- [ShopItemSO](#21-shopitemso)
|
||||
- [ShopInventorySO](#22-shopinventoryso)
|
||||
- [ShopController](#23-shopcontroller)
|
||||
- [ShopNPC](#24-shopnpc)
|
||||
3. [ISaveable 集成](#3-isaveable-集成)
|
||||
4. [事件频道清单](#4-事件频道清单)
|
||||
|
||||
---
|
||||
|
||||
## 1. 地图系统
|
||||
|
||||
### 1.1 MapRoomDataSO
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Map/MapRoomDataSO.cs
|
||||
[CreateAssetMenu(menuName = "World/Map/RoomData")]
|
||||
public class MapRoomDataSO : ScriptableObject
|
||||
{
|
||||
[Header("基础信息")]
|
||||
public string RoomId; // 与场景名一致,如 "Room_Forest_01"
|
||||
public string RegionId; // 所属区域,如 "Forest"
|
||||
public string DisplayName; // 可选,地图 Tooltip
|
||||
|
||||
[Header("地图布局(格子坐标,单位:格)")]
|
||||
public Vector2Int GridPosition; // 左下角坐标
|
||||
public Vector2Int GridSize; // 宽×高(格)
|
||||
|
||||
[Header("房间轮廓纹理")]
|
||||
public Texture2D RoomOutlineTex; // 用于地图 UI 显示房间形状(可空,回退到矩形格子)
|
||||
|
||||
[Header("出口信息")]
|
||||
public RoomExitData[] Exits; // 该房间所有出口定义
|
||||
|
||||
[Header("特殊标记")]
|
||||
public bool IsBossRoom;
|
||||
public bool IsSavePoint;
|
||||
public bool IsShop;
|
||||
public Sprite MapIconOverride; // null = 按 isXxx 自动选择图标
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct RoomExitData
|
||||
{
|
||||
public string TargetRoomId; // 连接的目标房间 ID
|
||||
public Vector2Int ExitGridPos; // 出口在格子地图上的位置
|
||||
public ExitDirection Direction; // 出口方向
|
||||
}
|
||||
|
||||
public enum ExitDirection { Up, Down, Left, Right }
|
||||
|
||||
// 全局地图数据库 SO(编辑器配置一次,不重复)
|
||||
[CreateAssetMenu(menuName = "World/Map/MapDatabase")]
|
||||
public class MapDatabaseSO : ScriptableObject
|
||||
{
|
||||
public MapRoomDataSO[] AllRooms;
|
||||
|
||||
// 运行时快速查找
|
||||
private Dictionary<string, MapRoomDataSO> _index;
|
||||
public MapRoomDataSO GetRoom(string roomId)
|
||||
{
|
||||
if (_index == null)
|
||||
_index = AllRooms.ToDictionary(r => r.RoomId);
|
||||
_index.TryGetValue(roomId, out var r);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 MapManager
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Map/MapManager.cs
|
||||
[DefaultExecutionOrder(-700)]
|
||||
public class MapManager : MonoBehaviour, ISaveable
|
||||
{
|
||||
public static MapManager Instance { get; private set; }
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
[SerializeField] private MapDatabaseSO _database;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onRoomEntered; // 订阅:房间进入时
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 发布:房间发现时刷新地图
|
||||
|
||||
// 三级可见性:
|
||||
// Unknown → 未进入过(默认)
|
||||
// Explored → 进入过但未购买地图(显示轮廓/格子)
|
||||
// Mapped → 已完整获取地图信息(显示图标/名称)
|
||||
private HashSet<string> _exploredRooms = new(); // 玩家踏入过
|
||||
private HashSet<string> _mappedRooms = new(); // 完整地图信息(购买 MapFragment 或存档点揭示)
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
data.Map.ExploredRooms = _exploredRooms.ToList();
|
||||
data.Map.MappedRooms = _mappedRooms.ToList();
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
_exploredRooms = new HashSet<string>(data.Map.ExploredRooms ?? new List<string>());
|
||||
_mappedRooms = new HashSet<string>(data.Map.MappedRooms ?? new List<string>());
|
||||
}
|
||||
|
||||
// ── 事件驱动房间发现 ─────────────────────────────────────────────
|
||||
private void OnEnable()
|
||||
=> _onRoomEntered.OnEventRaised += OnRoomEntered;
|
||||
private void OnDisable()
|
||||
=> _onRoomEntered.OnEventRaised -= OnRoomEntered;
|
||||
|
||||
private void OnRoomEntered(string roomId)
|
||||
{
|
||||
bool changed = _exploredRooms.Add(roomId);
|
||||
if (changed) _onMapUpdated.Raise(roomId);
|
||||
}
|
||||
|
||||
/// <summary>标记为已完整获取地图信息(购买 MapFragment SO 触发)。</summary>
|
||||
public void SetMapped(string roomId)
|
||||
{
|
||||
_exploredRooms.Add(roomId);
|
||||
if (_mappedRooms.Add(roomId))
|
||||
_onMapUpdated.Raise(roomId);
|
||||
}
|
||||
|
||||
public bool IsExplored(string roomId) => _exploredRooms.Contains(roomId);
|
||||
public bool IsMapped(string roomId) => _mappedRooms.Contains(roomId);
|
||||
// 向后兼容:仅检查已探索
|
||||
public bool IsDiscovered(string roomId) => _exploredRooms.Contains(roomId);
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 MapPanel
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Map/MapPanel.cs
|
||||
// 全屏地图 UI,由 UIManager PanelStack 管理
|
||||
public class MapPanel : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private MapDatabaseSO _database;
|
||||
[SerializeField] private RectTransform _roomContainer; // 格子图放置根节点
|
||||
[SerializeField] private MapRoomCellUI _cellPrefab; // 地图格子预制
|
||||
|
||||
[Header("图标 Sprites")]
|
||||
[SerializeField] private Sprite _iconSavePoint;
|
||||
[SerializeField] private Sprite _iconBossRoom;
|
||||
[SerializeField] private Sprite _iconShop;
|
||||
[SerializeField] private Sprite _iconPlayerPos;
|
||||
|
||||
[Header("颜色")]
|
||||
[SerializeField] private Color _colorDiscovered = Color.white;
|
||||
[SerializeField] private Color _colorUndiscovered = Color.black;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onMapUpdated; // 房间发现时刷新
|
||||
|
||||
private Dictionary<string, MapRoomCellUI> _cells = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
BuildGrid();
|
||||
_onMapUpdated.OnEventRaised += OnMapUpdated;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
=> _onMapUpdated.OnEventRaised -= OnMapUpdated;
|
||||
|
||||
// 根据 MapDatabaseSO 生成格子 UI
|
||||
private void BuildGrid()
|
||||
{
|
||||
foreach (var room in _database.AllRooms)
|
||||
{
|
||||
var cell = Instantiate(_cellPrefab, _roomContainer);
|
||||
cell.Setup(room, MapManager.Instance.IsDiscovered(room.RoomId));
|
||||
_cells[room.RoomId] = cell;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMapUpdated(string roomId)
|
||||
{
|
||||
if (_cells.TryGetValue(roomId, out var cell))
|
||||
cell.SetDiscovered(true);
|
||||
}
|
||||
}
|
||||
|
||||
// 单个地图格子 UI 组件
|
||||
public class MapRoomCellUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Image _bg;
|
||||
[SerializeField] private Image _icon;
|
||||
|
||||
public void Setup(MapRoomDataSO room, bool discovered) { /* 设置 grid 位置+颜色 */ }
|
||||
public void SetDiscovered(bool v) => _bg.color = v ? Color.white : Color.black;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 MapPlayerTracker
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Map/MapPlayerTracker.cs
|
||||
// 将玩家世界坐标转换为地图像素/格子坐标,供 MapPanel 显示玩家位置图标
|
||||
public class MapPlayerTracker : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Transform _playerTransform;
|
||||
[SerializeField] private MapDatabaseSO _database;
|
||||
[SerializeField] private MapManager _mapManager;
|
||||
|
||||
[Header("世界坐标 → 格子坐标换算参数")]
|
||||
[SerializeField] private float _worldUnitsPerCell = 18f; // 1 格 = N 世界单位
|
||||
|
||||
/// <summary>返回玩家当前所在房间 ID(用于地图高亮当前房间)。</summary>
|
||||
public string CurrentRoomId { get; private set; }
|
||||
|
||||
/// <summary>玩家在当前格子房间内的归一化坐标(0~1)。</summary>
|
||||
public Vector2 NormalizedPositionInRoom { get; private set; }
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_playerTransform == null) return;
|
||||
Vector2 worldPos = _playerTransform.position;
|
||||
Vector2Int cellPos = WorldToCell(worldPos);
|
||||
|
||||
// 遍历已知房间,找到包含该格子的房间
|
||||
foreach (var room in _database.AllRooms)
|
||||
{
|
||||
var rect = new RectInt(room.GridPosition, room.GridSize);
|
||||
if (rect.Contains(cellPos))
|
||||
{
|
||||
CurrentRoomId = room.RoomId;
|
||||
Vector2 inRoom = (Vector2)(cellPos - room.GridPosition);
|
||||
NormalizedPositionInRoom = new Vector2(
|
||||
inRoom.x / room.GridSize.x,
|
||||
inRoom.y / room.GridSize.y);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2Int WorldToCell(Vector2 worldPos)
|
||||
=> new Vector2Int(
|
||||
Mathf.FloorToInt(worldPos.x / _worldUnitsPerCell),
|
||||
Mathf.FloorToInt(worldPos.y / _worldUnitsPerCell));
|
||||
}
|
||||
```
|
||||
|
||||
### 1.5 MapPin 系统
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Map/MapPin.cs
|
||||
/// <summary>玩家在地图上放置的自定义标记。</summary>
|
||||
[Serializable]
|
||||
public class MapPin
|
||||
{
|
||||
public string RoomId; // 所在房间 ID
|
||||
public Vector2 NormalizedPos; // 房间内归一化位置(0~1)
|
||||
public PinType Type;
|
||||
public string Note; // 玩家文字备注(可选,最多 64 字符)
|
||||
}
|
||||
|
||||
public enum PinType
|
||||
{
|
||||
Marker, // 通用标记
|
||||
Chest, // 宝箱/收藏品
|
||||
Enemy, // 危险/敌人
|
||||
Path, // 路径指引
|
||||
Note, // 笔记
|
||||
}
|
||||
|
||||
// MapPinManager 负责增删查;存档通过 ISaveable 持久化
|
||||
public class MapPinManager : MonoBehaviour, ISaveable
|
||||
{
|
||||
private List<MapPin> _pins = new();
|
||||
|
||||
public IReadOnlyList<MapPin> Pins => _pins;
|
||||
|
||||
public void AddPin(MapPin pin) => _pins.Add(pin);
|
||||
public void RemovePin(MapPin pin) => _pins.Remove(pin);
|
||||
|
||||
public void OnSave(SaveData data) => data.Map.Pins = _pins;
|
||||
public void OnLoad(SaveData data) => _pins = data.Map.Pins ?? new List<MapPin>();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 商店系统
|
||||
|
||||
### 2.1 ShopItemSO
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Shop/ShopItemSO.cs
|
||||
[CreateAssetMenu(menuName = "World/Shop/ShopItem")]
|
||||
public class ShopItemSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string ItemId;
|
||||
public string DisplayName;
|
||||
[TextArea(2, 5)]
|
||||
public string Description;
|
||||
public Sprite Icon;
|
||||
|
||||
[Header("价格")]
|
||||
public int BasePrice;
|
||||
public bool IsUnique; // 购买一次后永久从库存移除
|
||||
|
||||
[Header("商品类型")]
|
||||
public ShopItemType ItemType;
|
||||
|
||||
// 按 ItemType 填写以下字段(其余留空)
|
||||
public int HealthRestoreAmount; // HealthRestoration
|
||||
public CharmSO CharmReference; // CharmItem
|
||||
public string KeyItemId; // KeyItem
|
||||
public int MaxPurchaseCount = -1; // -1 = 无限
|
||||
}
|
||||
|
||||
public enum ShopItemType
|
||||
{
|
||||
HealthRestoration,
|
||||
CharmItem,
|
||||
KeyItem,
|
||||
ConsumableBuff,
|
||||
MapFragment,
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 ShopInventorySO
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Shop/ShopInventorySO.cs
|
||||
[CreateAssetMenu(menuName = "World/Shop/ShopInventory")]
|
||||
public class ShopInventorySO : ScriptableObject
|
||||
{
|
||||
public string ShopId; // 全局唯一
|
||||
public List<ShopItemSO> DefaultInventory; // 初始商品列表
|
||||
public int MaxDisplaySlots = 6; // UI 最多同时显示的商品格数
|
||||
public RestockPolicy RestockPolicy = RestockPolicy.Never;
|
||||
public Sprite KeeperPortrait;
|
||||
public string KeeperName;
|
||||
}
|
||||
|
||||
/// <summary>库存补货时机策略。</summary>
|
||||
public enum RestockPolicy
|
||||
{
|
||||
Never, // 永不补货(唯一商品卖完即消失)
|
||||
OnSavePoint, // 激活存档点时补货
|
||||
OnBossDefeat, // 击败 Boss 后补货
|
||||
Periodic, // 周期性补货(由 ShopController 定时或条件检查)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 ShopController
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Shop/ShopController.cs
|
||||
public class ShopController : MonoBehaviour, ISaveable
|
||||
{
|
||||
[SerializeField] private ShopInventorySO _inventory;
|
||||
[SerializeField] private ShopPanel _shopPanel;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private StringEventChannelSO _onShopOpen; // Raise 商店开启
|
||||
[SerializeField] private ShopPurchaseEventChannelSO _onItemPurchased; // Raise → PlayerStats 扣 Geo
|
||||
[SerializeField] private StringEventChannelSO _onBossDefeated; // 订阅 → 可能触发补货
|
||||
[SerializeField] private VoidEventChannelSO _onSavePointActivated; // 订阅 → 可能触发补货
|
||||
|
||||
// key = itemId,value = 已购次数
|
||||
private Dictionary<string, int> _purchaseCounts = new();
|
||||
private HashSet<string> _soldUniqueItems = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_inventory.RestockPolicy == RestockPolicy.OnBossDefeat && _onBossDefeated != null)
|
||||
_onBossDefeated.OnEventRaised += _ => Restock();
|
||||
if (_inventory.RestockPolicy == RestockPolicy.OnSavePoint && _onSavePointActivated != null)
|
||||
_onSavePointActivated.OnEventRaised += Restock;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onBossDefeated != null) _onBossDefeated.OnEventRaised -= _ => Restock();
|
||||
if (_onSavePointActivated != null) _onSavePointActivated.OnEventRaised -= Restock;
|
||||
}
|
||||
|
||||
public void Open()
|
||||
{
|
||||
_shopPanel.Show(GetAvailableItems(), this);
|
||||
_onShopOpen.Raise(_inventory.ShopId);
|
||||
}
|
||||
public void Close() => _shopPanel.Hide();
|
||||
|
||||
public List<ShopItemSO> GetAvailableItems()
|
||||
{
|
||||
return _inventory.DefaultInventory
|
||||
.Take(_inventory.MaxDisplaySlots)
|
||||
.Where(item =>
|
||||
!_soldUniqueItems.Contains(item.ItemId) &&
|
||||
(item.MaxPurchaseCount < 0 || GetPurchaseCount(item.ItemId) < item.MaxPurchaseCount))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 RestockPolicy 补货:重置非唯一商品的购买次数(唯一商品已售出不恢复)。
|
||||
/// </summary>
|
||||
public void Restock()
|
||||
{
|
||||
var nonUniqueIds = _inventory.DefaultInventory
|
||||
.Where(i => !i.IsUnique)
|
||||
.Select(i => i.ItemId);
|
||||
|
||||
foreach (var id in nonUniqueIds)
|
||||
_purchaseCounts.Remove(id);
|
||||
}
|
||||
|
||||
// 由 ShopPanel 的购买按钮调用
|
||||
public bool TryPurchase(ShopItemSO item, int playerGeo)
|
||||
{
|
||||
if (playerGeo < item.BasePrice) return false;
|
||||
if (_soldUniqueItems.Contains(item.ItemId)) return false;
|
||||
|
||||
// 扣 Geo(通过事件频道,PlayerStats 监听)
|
||||
_onItemPurchased.Raise(new ShopPurchaseEvent { Item = item, Price = item.BasePrice });
|
||||
|
||||
// 更新库存
|
||||
_purchaseCounts[item.ItemId] = GetPurchaseCount(item.ItemId) + 1;
|
||||
if (item.IsUnique) _soldUniqueItems.Add(item.ItemId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private int GetPurchaseCount(string id)
|
||||
=> _purchaseCounts.TryGetValue(id, out var c) ? c : 0;
|
||||
|
||||
// ── ISaveable ────────────────────────────────────────────────────
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
if (!data.Shops.ShopRecords.ContainsKey(_inventory.ShopId))
|
||||
data.Shops.ShopRecords[_inventory.ShopId] = new ShopRecord();
|
||||
|
||||
var record = data.Shops.ShopRecords[_inventory.ShopId];
|
||||
record.SoldUniqueItems = _soldUniqueItems.ToList();
|
||||
record.PurchaseCounts = new Dictionary<string, int>(_purchaseCounts);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
if (data.Shops.ShopRecords.TryGetValue(_inventory.ShopId, out var record))
|
||||
{
|
||||
_soldUniqueItems = new HashSet<string>(record.SoldUniqueItems ?? new List<string>());
|
||||
_purchaseCounts = record.PurchaseCounts ?? new Dictionary<string, int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 ShopNPC
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/World/Shop/ShopNPC.cs
|
||||
public class ShopNPC : MonoBehaviour, IInteractable
|
||||
{
|
||||
[SerializeField] private ShopController _shopController;
|
||||
[SerializeField] private DialogueSequenceSO _greetDialogue; // 可选开场白
|
||||
[SerializeField] private DialogueManager _dialogueManager;
|
||||
[SerializeField] private VoidEventChannelSO _onDialogueEnded; // 订阅:对话结束后开商店
|
||||
|
||||
public bool CanInteract => true;
|
||||
public string InteractPrompt => "购物";
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (_greetDialogue != null)
|
||||
{
|
||||
_dialogueManager.StartDialogue(_greetDialogue);
|
||||
// 等对话结束后再 Open Shop:订阅 EVT_DialogueEnded 一次性
|
||||
void OpenAfterDialogue()
|
||||
{
|
||||
_shopController.Open();
|
||||
_onDialogueEnded.OnEventRaised -= OpenAfterDialogue;
|
||||
}
|
||||
_onDialogueEnded.OnEventRaised += OpenAfterDialogue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_shopController.Open();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. ISaveable 集成
|
||||
|
||||
| 组件 | SaveData 目标字段 |
|
||||
|------|-----------------|
|
||||
| `MapManager` | `SaveData.Map.DiscoveredRooms` |
|
||||
| `ShopController` | `SaveData.ExtensionData["shops"]` (JObject, key=ShopId) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 事件频道清单
|
||||
|
||||
| 资产名 | 类型 | Raise 方 | Subscribe 方 |
|
||||
|--------|------|---------|-------------|
|
||||
| `EVT_RoomEntered` | `StringEventChannelSO` | `RoomController` | `MapManager`(标记发现)|
|
||||
| `EVT_MapUpdated` | `StringEventChannelSO` | `MapManager` | `MapPanel`(刷新格子)|
|
||||
| `EVT_ShopOpened` | `StringEventChannelSO`(shopId) | `ShopController.Open()` | `HUDController`(隐藏 HUD)、`InputReaderSO`(切 UI)|
|
||||
| `EVT_ShopClosed` | `VoidEventChannelSO` | `ShopController` | `HUDController`(恢复 HUD)|
|
||||
| `EVT_ItemPurchased` | `ShopPurchaseEventChannelSO` | `ShopController` | `PlayerStats`(扣 Geo)、`AchievementManager`(购买成就)|
|
||||
|
||||
---
|
||||
|
||||
## 5. MapRoomDataEditor — Scene Handles 可视化编辑
|
||||
|
||||
> **P3 优化**:`MapRoomDataSO` 的 `GridPosition` / `GridSize` 依赖手动输入整数,易与实际场景房间大小错位。`MapRoomDataEditor` 在 Scene View 中叠加可拖拽边界框,直接将世界坐标映射为格子坐标,消除手动对齐误差。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Editor/Map/MapRoomDataEditor.cs
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Editor.Map
|
||||
{
|
||||
[CustomEditor(typeof(MapRoomDataSO))]
|
||||
public class MapRoomDataEditor : UnityEditor.Editor
|
||||
{
|
||||
// 每格对应的世界单位大小(与 MapPanel 的 cellSize 一致)
|
||||
private const float CELL_SIZE = 1f;
|
||||
|
||||
private static readonly Color FillColor = new(0.2f, 0.6f, 1f, 0.15f);
|
||||
private static readonly Color OutlineColor = new(0.2f, 0.6f, 1f, 0.9f);
|
||||
private static readonly Color HandleColor = new(1f, 0.85f, 0.2f, 1f);
|
||||
|
||||
private MapRoomDataSO _target;
|
||||
|
||||
private void OnEnable() => _target = (MapRoomDataSO)target;
|
||||
|
||||
// ── Inspector 覆盖:保留默认 Inspector 外加 "Edit in Scene" 按钮 ──
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
EditorGUILayout.HelpBox(
|
||||
"在 Scene View 中可直接拖拽房间角点调整 GridPosition / GridSize。\n" +
|
||||
"需要 Scene View 处于激活状态。",
|
||||
MessageType.Info);
|
||||
|
||||
if (GUILayout.Button("居中 Scene View 到此房间", GUILayout.Height(28)))
|
||||
FocusSceneViewOnRoom();
|
||||
}
|
||||
|
||||
// ── Scene GUI:绘制可拖拽边界框 ──────────────────────────────────
|
||||
private void OnSceneGUI()
|
||||
{
|
||||
if (_target == null) return;
|
||||
|
||||
var origin = (Vector3)(Vector2)_target.GridPosition * CELL_SIZE;
|
||||
var size = (Vector3)(Vector2)_target.GridSize * CELL_SIZE;
|
||||
|
||||
// 填充矩形
|
||||
Handles.DrawSolidRectangleWithOutline(
|
||||
new Rect(origin.x, origin.y, size.x, size.y),
|
||||
FillColor, OutlineColor);
|
||||
|
||||
// 房间 ID 标签(居中)
|
||||
Handles.Label(origin + size * 0.5f, _target.RoomId,
|
||||
new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontStyle = FontStyle.Bold,
|
||||
normal = { textColor = Color.white }
|
||||
});
|
||||
|
||||
// ── 四角 FreeMoveHandle ──────────────────────────────────────
|
||||
EditorGUI.BeginChangeCheck();
|
||||
|
||||
// 左下角(= GridPosition)
|
||||
var newBL = DragHandle(origin, "BL");
|
||||
// 右上角(= GridPosition + GridSize)
|
||||
var newTR = DragHandle(origin + size, "TR");
|
||||
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
Undo.RecordObject(_target, "Resize MapRoom");
|
||||
|
||||
// 保证 BL ≤ TR
|
||||
var bl = new Vector2(
|
||||
Mathf.Min(newBL.x, newTR.x),
|
||||
Mathf.Min(newBL.y, newTR.y));
|
||||
var tr = new Vector2(
|
||||
Mathf.Max(newBL.x, newTR.x),
|
||||
Mathf.Max(newBL.y, newTR.y));
|
||||
|
||||
_target.GridPosition = ToGrid(bl);
|
||||
_target.GridSize = ToGrid(tr) - _target.GridPosition;
|
||||
// GridSize 最小 1×1
|
||||
_target.GridSize = new Vector2Int(
|
||||
Mathf.Max(1, _target.GridSize.x),
|
||||
Mathf.Max(1, _target.GridSize.y));
|
||||
|
||||
EditorUtility.SetDirty(_target);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 拖拽把手(黄色圆点)────────────────────────────────────────
|
||||
private static Vector3 DragHandle(Vector3 pos, string id)
|
||||
{
|
||||
float size = HandleUtility.GetHandleSize(pos) * 0.12f;
|
||||
var oldColor = Handles.color;
|
||||
Handles.color = HandleColor;
|
||||
var result = Handles.FreeMoveHandle(pos, size, Vector3.zero, Handles.DotHandleCap);
|
||||
Handles.color = oldColor;
|
||||
return SnapToGrid(result);
|
||||
}
|
||||
|
||||
// 吸附到格子
|
||||
private static Vector3 SnapToGrid(Vector3 world)
|
||||
=> new(Mathf.Round(world.x / CELL_SIZE) * CELL_SIZE,
|
||||
Mathf.Round(world.y / CELL_SIZE) * CELL_SIZE,
|
||||
0f);
|
||||
|
||||
private static Vector2Int ToGrid(Vector2 world)
|
||||
=> new(Mathf.RoundToInt(world.x / CELL_SIZE),
|
||||
Mathf.RoundToInt(world.y / CELL_SIZE));
|
||||
|
||||
// ── Scene View 定位 ───────────────────────────────────────────
|
||||
private void FocusSceneViewOnRoom()
|
||||
{
|
||||
var sv = SceneView.lastActiveSceneView;
|
||||
if (sv == null) return;
|
||||
var center = ((Vector2)_target.GridPosition +
|
||||
(Vector2)_target.GridSize * 0.5f) * CELL_SIZE;
|
||||
sv.pivot = new Vector3(center.x, center.y, 0f);
|
||||
sv.size = Mathf.Max(_target.GridSize.x, _target.GridSize.y) * CELL_SIZE * 1.5f;
|
||||
sv.Repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
**工作流**:
|
||||
|
||||
1. 在 Project 面板选中 `MapRoomDataSO` 资产(或在 Inspector 固定它)
|
||||
2. 打开 Scene View,即可看到蓝色房间边界框 + 黄色角点把手
|
||||
3. 拖动左下角把手调整房间起点;拖动右上角把手调整房间大小
|
||||
4. 所有拖动操作自动吸附到 1 格精度,并支持 Undo
|
||||
5. 点击 **"居中 Scene View 到此房间"** 可快速定位视图
|
||||
|
||||
> **`CELL_SIZE` 配置**:若地图格子实际对应世界坐标不是 1 单位(如 16px 像素游戏 = 0.16 世界单位),修改 `MapRoomDataEditor` 顶部 `const float CELL_SIZE` 即可,无需改运行时代码。
|
||||
1183
Docs/Architecture/16_SupportingModules.md
Normal file
1183
Docs/Architecture/16_SupportingModules.md
Normal file
File diff suppressed because it is too large
Load Diff
921
Docs/Architecture/17_CameraModule.md
Normal file
921
Docs/Architecture/17_CameraModule.md
Normal file
@@ -0,0 +1,921 @@
|
||||
# 17 · 相机模块(Camera Module)
|
||||
|
||||
> **命名空间** `BaseGames.Camera`
|
||||
> **程序集** `BaseGames.Camera`(`Assets/Scripts/Camera/`)
|
||||
> **依赖** Cinemachine 3 · `BaseGames.Core.Events`
|
||||
> **Design 来源** [02_CameraSystem](../Design/02_CameraSystem.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [模块职责](#1-模块职责)
|
||||
2. [场景结构与 Prefab 层级](#2-场景结构与-prefab-层级)
|
||||
3. [CameraStateController](#3-camerastatecontroller)
|
||||
4. [RoomVisibleArea](#4-roomvisiblearea)
|
||||
5. [CameraTriggerZone](#5-cameratriggerzone)
|
||||
6. [RoomCamera(可选)](#6-roomcamera可选)
|
||||
7. [CameraConfigSO](#7-cameraconfigso)
|
||||
8. [CameraBlendProfileSO](#8-camerablendprofileso)
|
||||
9. [镜头震动集成](#9-镜头震动集成)
|
||||
10. [Pixel Perfect 集成](#10-pixel-perfect-集成)
|
||||
11. [事件频道](#11-事件频道)
|
||||
12. [房间切换时序](#12-房间切换时序)
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
- 所有镜头行为由 **Cinemachine 3** 驱动,禁止手动操作 `Camera.transform`
|
||||
- **全局 A/B 双机交替复用**:避免 Confiner 跳变
|
||||
- **触发区域驱动切换**:`CameraTriggerZone` Collider2D 触发镜头切换
|
||||
- **房间专用相机可选**:挂载 `RoomCamera` 组件时自动优先
|
||||
|
||||
---
|
||||
|
||||
## 2. 场景结构与 Prefab 层级
|
||||
|
||||
### Persistent 场景(`CameraRig` Prefab)
|
||||
|
||||
```
|
||||
[CameraRig]
|
||||
├── Main Camera
|
||||
│ ├── Camera.cs
|
||||
│ ├── CinemachineBrain ← defaultBlend 通过代码动态赋值
|
||||
│ ├── PixelPerfectCamera ← Unity 2D Pixel Perfect
|
||||
│ └── CinemachinePixelPerfect ← 消除亚像素抖动
|
||||
│
|
||||
├── VCam_Global_A ← Priority 10(初始活跃)
|
||||
│ ├── CinemachineCamera
|
||||
│ ├── CinemachinePositionComposer ← 跟随玩家 + 偏移/阻尼
|
||||
│ ├── CinemachineConfiner2D ← BoundingShape2D 动态更换
|
||||
│ ├── CinemachineImpulseListener
|
||||
│ └── CinemachinePixelPerfect
|
||||
│
|
||||
├── VCam_Global_B ← Priority 9(待机)
|
||||
│ └── (结构同 A)
|
||||
│
|
||||
├── [SpecialCameras] ← 特殊状态相机(独立 GameObject 组)
|
||||
│ ├── VCam_Boss ← Priority 30(Boss 战激活)
|
||||
│ │ ├── CinemachineCamera(_lookaheadTime=0, _orthographicSize 由 ConfigSO 驱动)
|
||||
│ │ ├── CinemachineConfiner2D
|
||||
│ │ └── CinemachineImpulseListener
|
||||
│ ├── VCam_Death ← Priority 50(死亡时激活,慢速 ZoomIn)
|
||||
│ │ └── CinemachineCamera
|
||||
│ └── VCam_Cutscene ← Priority 40(CutsceneManager 注册后激活)
|
||||
│ └── CinemachineCamera(剧情用,位置由 Timeline/CutsceneManager 控制)
|
||||
│
|
||||
└── CameraStateController.cs ← 全局相机控制器
|
||||
```
|
||||
|
||||
### 房间场景(可选)
|
||||
|
||||
```
|
||||
[VCam_Room_XXX] ← Priority 15(高于全局)
|
||||
├── CinemachineCamera
|
||||
├── CinemachineConfiner2D
|
||||
├── CinemachineImpulseListener
|
||||
└── RoomCamera.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. CameraStateController
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局相机状态控制器,挂在 CameraRig GameObject 上。
|
||||
/// 管理全局 A/B 双机切换和房间专用相机注册。
|
||||
/// </summary>
|
||||
public class CameraStateController : MonoBehaviour
|
||||
{
|
||||
// ── Inspector References ───────────────────────────────
|
||||
[SerializeField] CinemachineCamera _vcamA;
|
||||
[SerializeField] CinemachineCamera _vcamB;
|
||||
[SerializeField] CinemachineBrain _brain;
|
||||
[SerializeField] CameraConfigSO _defaultConfig;
|
||||
|
||||
[Header("特殊状态相机")]
|
||||
[SerializeField] CinemachineCamera _vcamBoss; // Priority 30
|
||||
[SerializeField] CinemachineCamera _vcamDeath; // Priority 50
|
||||
[SerializeField] CinemachineCamera _vcamCutscene; // Priority 40
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] BoolEventChannelSO _onBossFightToggled; // true=开始 false=结束
|
||||
[SerializeField] VoidEventChannelSO _onPlayerDied;
|
||||
[SerializeField] VoidEventChannelSO _onPlayerRespawned;
|
||||
[SerializeField] GameStateEventChannelSO _onGameStateChanged;
|
||||
|
||||
// ── Runtime State ──────────────────────────────────────
|
||||
CinemachineCamera _activeCam;
|
||||
CinemachineCamera _inactiveCam;
|
||||
RoomCamera _currentRoomCam; // null = 使用全局双机
|
||||
|
||||
// ── Singleton ─────────────────────────────────────────
|
||||
public static CameraStateController Instance { get; private set; }
|
||||
|
||||
void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
_activeCam = _vcamA;
|
||||
_inactiveCam = _vcamB;
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
_onBossFightToggled.OnEventRaised += OnBossFightToggled;
|
||||
_onPlayerDied.OnEventRaised += OnPlayerDied;
|
||||
_onPlayerRespawned.OnEventRaised += OnPlayerRespawned;
|
||||
}
|
||||
void OnDisable()
|
||||
{
|
||||
_onBossFightToggled.OnEventRaised -= OnBossFightToggled;
|
||||
_onPlayerDied.OnEventRaised -= OnPlayerDied;
|
||||
_onPlayerRespawned.OnEventRaised -= OnPlayerRespawned;
|
||||
}
|
||||
|
||||
private void OnBossFightToggled(bool started)
|
||||
{
|
||||
_vcamBoss.Priority = started ? 30 : 0; // 升/降优先级
|
||||
if (started) _brain.DefaultBlend =
|
||||
new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 0.8f);
|
||||
else _brain.DefaultBlend = new CinemachineBlendDefinition(
|
||||
_defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
|
||||
}
|
||||
|
||||
private void OnPlayerDied()
|
||||
{
|
||||
_vcamDeath.Priority = 50; // 死亡相机接管,1.0s EaseIn
|
||||
_brain.DefaultBlend =
|
||||
new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 1.0f);
|
||||
}
|
||||
|
||||
private void OnPlayerRespawned()
|
||||
{
|
||||
_vcamDeath.Priority = 0;
|
||||
_brain.DefaultBlend = new CinemachineBlendDefinition(
|
||||
_defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
|
||||
}
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 切换到新房间(由 RoomTransition 调用)。
|
||||
/// </summary>
|
||||
public void SwitchRoom(RoomCameraData data)
|
||||
{
|
||||
if (_currentRoomCam != null) return; // 房间专用相机接管时,全局切换延迟
|
||||
|
||||
// 1. 更新 inactive 机
|
||||
var confiner = _inactiveCam.GetComponent<CinemachineConfiner2D>();
|
||||
confiner.BoundingShape2D = data.ConfinerCollider;
|
||||
_inactiveCam.GetCinemachineComponent<CinemachinePositionComposer>()
|
||||
.TargetOffset = data.CameraOffset;
|
||||
|
||||
// 2. 应用混合配置
|
||||
if (data.BlendProfile != null)
|
||||
_brain.DefaultBlend = data.BlendProfile.ToBlendDefinition();
|
||||
|
||||
// 3. 升高 inactive 机优先级触发 Blend
|
||||
_inactiveCam.Priority = _activeCam.Priority + 1;
|
||||
|
||||
// 4. Blend 完成后交换引用(通过 CinemachineBrain 回调)
|
||||
_brain.BlendFinished += OnBlendFinished;
|
||||
}
|
||||
|
||||
void OnBlendFinished(ICinemachineCamera _)
|
||||
{
|
||||
_brain.BlendFinished -= OnBlendFinished;
|
||||
_activeCam.Priority = 9;
|
||||
(_activeCam, _inactiveCam) = (_inactiveCam, _activeCam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 房间专用相机注册(由 RoomCamera.OnEnable 调用)。
|
||||
/// </summary>
|
||||
public void RegisterRoomCamera(RoomCamera rc)
|
||||
{
|
||||
_currentRoomCam = rc;
|
||||
// 房间相机 Priority=15,自动接管全局相机
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 房间专用相机注销(由 RoomCamera.OnDisable 调用)。
|
||||
/// </summary>
|
||||
public void UnregisterRoomCamera(RoomCamera rc)
|
||||
{
|
||||
if (_currentRoomCam == rc)
|
||||
_currentRoomCam = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发布镜头震动冲量(由 IFeedbackPlayer 调用)。
|
||||
/// </summary>
|
||||
public void TriggerImpulse(CameraShakePreset preset)
|
||||
{
|
||||
var impulse = _activeCam.GetComponent<CinemachineImpulseSource>();
|
||||
impulse.m_ImpulseDefinition.m_AmplitudeGain = preset.Amplitude;
|
||||
impulse.GenerateImpulse(Vector3.down * preset.Force);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 房间切换时传入的相机参数包。
|
||||
/// </summary>
|
||||
public struct RoomCameraData
|
||||
{
|
||||
public Collider2D ConfinerCollider; // 房间 RoomVisibleArea 的 Collider2D
|
||||
public Vector3 CameraOffset; // 镜头偏移(可用于房间内向上/向下偏移)
|
||||
public CameraBlendProfileSO BlendProfile; // null = 使用全局默认
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. RoomVisibleArea
|
||||
|
||||
> **所见即所得原则**:设计者在 Scene 视图中绘制的矩形 = 玩家运行时实际能看到的总区域。
|
||||
> 组件内部自动按 `confinerSize = roomSize − viewportSize` 公式将"房间可视区域"转换为
|
||||
> Cinemachine Confiner2D 所需的"相机中心约束多边形",无需手动调整 Collider 顶点。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Camera/RoomVisibleArea.cs
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 定义一个房间的相机可视区域。
|
||||
/// _roomSize = 玩家可见的总矩形(世界单位)。
|
||||
/// Gizmo 在 Scene 视图中实时预览:
|
||||
/// 绿色线框 = 房间边界(_roomSize)
|
||||
/// 青色填充 = 相机视口(_viewportSize)= 运行时实际画面
|
||||
/// 当 roomSize == viewportSize 时为固定相机(锁定不滚动)。
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[RequireComponent(typeof(PolygonCollider2D))]
|
||||
public class RoomVisibleArea : MonoBehaviour
|
||||
{
|
||||
[Header("可视区域(所见即所得)")]
|
||||
[Tooltip("玩家能看到的总矩形(世界单位)。Scene 中绿色框即为此范围。")]
|
||||
[SerializeField] private Vector2 _roomSize = new(20f, 11.25f); // 默认一屏 320×180 / 16 PPU
|
||||
[Tooltip("相机视口尺寸(世界单位),需与 PixelPerfectCamera 设置一致。" +
|
||||
"建议由 CameraConfigSO 统一管理后注入,或手动填写。")]
|
||||
[SerializeField] private Vector2 _viewportSize = new(20f, 11.25f); // 320×180 / 16 PPU
|
||||
|
||||
// ── 派生属性 ───────────────────────────────────────────────────
|
||||
/// <summary>true = 单屏固定相机(房间不超过一个视口)。</summary>
|
||||
public bool IsFixedCamera
|
||||
=> _roomSize.x <= _viewportSize.x + 0.01f
|
||||
&& _roomSize.y <= _viewportSize.y + 0.01f;
|
||||
|
||||
/// <summary>Confiner2D 使用的 Collider(自动维护)。</summary>
|
||||
public Collider2D Collider { get; private set; }
|
||||
|
||||
// ── 生命周期 ───────────────────────────────────────────────────
|
||||
private void Awake() => Collider = GetComponent<PolygonCollider2D>();
|
||||
private void OnValidate() => RebuildCollider(); // Inspector 修改时实时重建
|
||||
|
||||
// ── 核心:将"房间可视区"换算为"相机中心约束多边形" ──────────────
|
||||
private void RebuildCollider()
|
||||
{
|
||||
var col = GetComponent<PolygonCollider2D>();
|
||||
if (col == null) return;
|
||||
|
||||
// Cinemachine Confiner2D 约束的是相机「中心」,不是相机边缘
|
||||
// 因此约束区域 = 房间尺寸 - 相机视口尺寸(最小为零,即固定相机)
|
||||
var confiner = Vector2.Max(Vector2.zero, _roomSize - _viewportSize);
|
||||
var h = confiner * 0.5f;
|
||||
|
||||
col.SetPath(0, new Vector2[]
|
||||
{
|
||||
new(-h.x, -h.y),
|
||||
new( h.x, -h.y),
|
||||
new( h.x, h.y),
|
||||
new(-h.x, h.y),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Editor Gizmo(所见即所得可视化)──────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private static readonly Color _roomColor = new(0.2f, 1f, 0.2f, 0.9f); // 绿
|
||||
private static readonly Color _viewFill = new(0f, 0.8f, 1f, 0.08f); // 青色半透明填充
|
||||
private static readonly Color _viewBorder = new(0f, 0.8f, 1f, 0.85f); // 青色边框
|
||||
private static readonly Color _fixedColor = new(1f, 0.9f, 0f, 0.9f); // 黄色(固定相机)
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
var center = (Vector2)transform.position;
|
||||
|
||||
// 1. 绘制房间边界(绿色)
|
||||
Gizmos.color = _roomColor;
|
||||
DrawWireRect(center, _roomSize);
|
||||
|
||||
// 2. 绘制相机视口预览(青色)= 运行时玩家实际看到的画面大小
|
||||
// 视口锚定在房间中心(如需偏移可在 CameraOffset 中调整)
|
||||
Gizmos.color = _viewFill;
|
||||
Gizmos.DrawCube(center, new Vector3(_viewportSize.x, _viewportSize.y, 0f));
|
||||
Gizmos.color = IsFixedCamera ? _fixedColor : _viewBorder;
|
||||
DrawWireRect(center, _viewportSize);
|
||||
|
||||
// 3. 固定相机标注:用黄色虚线框 + 标签区分
|
||||
if (IsFixedCamera)
|
||||
{
|
||||
// 用黄色加粗边框覆盖提示"此房间为固定单屏相机"
|
||||
Gizmos.color = _fixedColor;
|
||||
DrawWireRect(center + Vector2.up * (_roomSize.y * 0.5f + 0.05f),
|
||||
new Vector2(_roomSize.x, 0.1f));
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawWireRect(Vector2 center, Vector2 size)
|
||||
{
|
||||
var h = size * 0.5f;
|
||||
var bl = center + new Vector2(-h.x, -h.y);
|
||||
var br = center + new Vector2( h.x, -h.y);
|
||||
var tr = center + new Vector2( h.x, h.y);
|
||||
var tl = center + new Vector2(-h.x, h.y);
|
||||
Gizmos.DrawLine(bl, br);
|
||||
Gizmos.DrawLine(br, tr);
|
||||
Gizmos.DrawLine(tr, tl);
|
||||
Gizmos.DrawLine(tl, bl);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### RoomVisibleAreaEditor(Editor 脚本)
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Editor/Camera/RoomVisibleAreaEditor.cs
|
||||
// Scene 视图内直接拖动 8 个控制点调整房间大小,无需修改 Inspector 数字
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
|
||||
namespace BaseGames.Camera.Editor
|
||||
{
|
||||
[CustomEditor(typeof(RoomVisibleArea))]
|
||||
public class RoomVisibleAreaEditor : UnityEditor.Editor
|
||||
{
|
||||
private SerializedProperty _roomSize;
|
||||
private SerializedProperty _viewportSize;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_roomSize = serializedObject.FindProperty("_roomSize");
|
||||
_viewportSize = serializedObject.FindProperty("_viewportSize");
|
||||
}
|
||||
|
||||
private void OnSceneGUI()
|
||||
{
|
||||
var area = (RoomVisibleArea)target;
|
||||
var center = (Vector2)area.transform.position;
|
||||
var size = _roomSize.vector2Value;
|
||||
var h = size * 0.5f;
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Handles.color = new Color(0.2f, 1f, 0.2f, 0.9f);
|
||||
|
||||
// 8 个控制点:四角 + 四边中点
|
||||
Vector2 newSize = size;
|
||||
newSize = DragHandle(center + new Vector2( h.x, 0f ), size, center, HandleDir.Right);
|
||||
newSize = DragHandle(center + new Vector2(-h.x, 0f ), newSize, center, HandleDir.Left);
|
||||
newSize = DragHandle(center + new Vector2( 0f, h.y ), newSize, center, HandleDir.Up);
|
||||
newSize = DragHandle(center + new Vector2( 0f, -h.y ), newSize, center, HandleDir.Down);
|
||||
newSize = DragHandle(center + new Vector2( h.x, h.y ), newSize, center, HandleDir.TR);
|
||||
newSize = DragHandle(center + new Vector2(-h.x, h.y ), newSize, center, HandleDir.TL);
|
||||
newSize = DragHandle(center + new Vector2( h.x, -h.y ), newSize, center, HandleDir.BR);
|
||||
newSize = DragHandle(center + new Vector2(-h.x, -h.y ), newSize, center, HandleDir.BL);
|
||||
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
Undo.RecordObject(area, "Resize RoomVisibleArea");
|
||||
_roomSize.vector2Value = Vector2.Max(newSize, _viewportSize.vector2Value);
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private enum HandleDir { Right, Left, Up, Down, TR, TL, BR, BL }
|
||||
|
||||
private Vector2 DragHandle(Vector2 worldPos, Vector2 currentSize,
|
||||
Vector2 center, HandleDir dir)
|
||||
{
|
||||
float size = HandleUtility.GetHandleSize(worldPos) * 0.12f;
|
||||
var newPos = (Vector2)Handles.FreeMoveHandle(worldPos, size,
|
||||
Vector3.zero, Handles.RectangleHandleCap);
|
||||
if (newPos == worldPos) return currentSize;
|
||||
|
||||
var delta = newPos - worldPos;
|
||||
return dir switch
|
||||
{
|
||||
HandleDir.Right => currentSize + new Vector2( delta.x * 2, 0),
|
||||
HandleDir.Left => currentSize + new Vector2(-delta.x * 2, 0),
|
||||
HandleDir.Up => currentSize + new Vector2(0, delta.y * 2),
|
||||
HandleDir.Down => currentSize + new Vector2(0, -delta.y * 2),
|
||||
HandleDir.TR => currentSize + new Vector2( delta.x * 2, delta.y * 2),
|
||||
HandleDir.TL => currentSize + new Vector2(-delta.x * 2, delta.y * 2),
|
||||
HandleDir.BR => currentSize + new Vector2( delta.x * 2, -delta.y * 2),
|
||||
HandleDir.BL => currentSize + new Vector2(-delta.x * 2, -delta.y * 2),
|
||||
_ => currentSize
|
||||
};
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
var area = (RoomVisibleArea)target;
|
||||
|
||||
EditorGUILayout.PropertyField(_roomSize,
|
||||
new GUIContent("房间可视区域", "玩家能看到的总区域(世界单位)。Scene 中绿色框。"));
|
||||
EditorGUILayout.PropertyField(_viewportSize,
|
||||
new GUIContent("相机视口尺寸", "运行时相机实际画面尺寸(世界单位)。青色框。\n" +
|
||||
"= 参考分辨率 / PPU(例:320×180 / 16PPU = 20×11.25)"));
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
var style = new GUIStyle(EditorStyles.helpBox) { richText = true };
|
||||
string msg = area.IsFixedCamera
|
||||
? "<color=#FFD700>■ 固定相机</color>:房间 = 单屏,相机锁定不滚动。"
|
||||
: $"<color=#00CCFF>■ 可滚动</color>:" +
|
||||
$"滚动范围 {_roomSize.vector2Value - _viewportSize.vector2Value:F2} 世界单位。";
|
||||
EditorGUILayout.LabelField(msg, style);
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
### Gizmo 图例
|
||||
|
||||
```
|
||||
Scene 视图中的显示效果:
|
||||
|
||||
┌──────────────────────────────────────┐ ← 绿色框:_roomSize(房间总可视区)
|
||||
│ │
|
||||
│ ┌────────────────────┐ │ ← 青色框:_viewportSize(相机视口)
|
||||
│ │░░░░░░░░░░░░░░░░░░░░│ │ 青色填充 = 运行时玩家看到的画面
|
||||
│ │░░░ 玩家实际画面 ░░░│ │
|
||||
│ │░░░░░░░░░░░░░░░░░░░░│ │
|
||||
│ └────────────────────┘ │
|
||||
│ ← 滚动空间 → │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
当 roomSize == viewportSize(固定相机):
|
||||
╔══════════════════════╗ ← 黄色框(双框叠合 = 锁定提示)
|
||||
║░░░░░░░░░░░░░░░░░░░░░░║
|
||||
║░░ 房间 = 相机视口 ░░║
|
||||
╚══════════════════════╝
|
||||
```
|
||||
|
||||
### 视口尺寸换算
|
||||
|
||||
| 参考分辨率 | PPU | viewportSize(宽 × 高,世界单位) |
|
||||
|-----------|-----|----------------------------------|
|
||||
| 320 × 180 | 16 | 20.00 × 11.25 |
|
||||
| 320 × 180 | 32 | 10.00 × 5.625 |
|
||||
| 640 × 360 | 16 | 40.00 × 22.50 |
|
||||
|
||||
> `viewportSize = (referenceResolution / PPU)`
|
||||
> 建议在 `CameraConfigSO` 中暴露 `ViewportSizeInWorldUnits` 属性,让所有 `RoomVisibleArea` 实例引用同一个数值。
|
||||
|
||||
---
|
||||
|
||||
## 5. CameraTriggerZone
|
||||
|
||||
> **独立可编辑原则**:触发区域的形状与位置由自身 `_center`/`_size` 决定,
|
||||
> 与 `RoomVisibleArea` 完全解耦——一个房间可以有多个入口方向的触发线,
|
||||
> 同一个触发线也可以指向不同的目标房间(双向过渡)。
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Camera/CameraTriggerZone.cs
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 独立的相机切换触发区域,与房间可视区域(RoomVisibleArea)解耦。
|
||||
/// 形状由 _center/_size 控制,[ExecuteAlways] 实时同步至 BoxCollider2D。
|
||||
/// Scene 视图中显示黄色矩形(触发范围)+ 青色箭头(指向目标房间)。
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
[RequireComponent(typeof(BoxCollider2D))]
|
||||
public class CameraTriggerZone : MonoBehaviour
|
||||
{
|
||||
// ── 触发区域形状(WYSIWYG,与房间区域无关)────────────────────────
|
||||
[Header("触发区域(独立编辑)")]
|
||||
[Tooltip("触发区域中心(相对于 GameObject 原点的局部偏移)")]
|
||||
[SerializeField] private Vector2 _center = Vector2.zero;
|
||||
[Tooltip("触发区域尺寸(世界单位)。典型值:入口竖线 = (0.5, 4)")]
|
||||
[SerializeField] private Vector2 _size = new(0.5f, 4f);
|
||||
|
||||
// ── 切换目标 ──────────────────────────────────────────────────────
|
||||
[Header("切换目标")]
|
||||
[Tooltip("玩家进入后切换至此房间的可视区域")]
|
||||
[SerializeField] private RoomVisibleArea _targetRoom;
|
||||
[Tooltip("相机偏移(可选,微调构图)")]
|
||||
[SerializeField] private Vector3 _cameraOffset;
|
||||
[Tooltip("混合配置覆盖(null = 使用全局默认)")]
|
||||
[SerializeField] private CameraBlendProfileSO _blendOverride;
|
||||
|
||||
// ── 触发行为 ──────────────────────────────────────────────────────
|
||||
[Header("触发行为")]
|
||||
[Tooltip("true = 只触发一次(单向过渡);false = 玩家来回均触发(区段分割线)")]
|
||||
[SerializeField] private bool _triggerOnce = false;
|
||||
|
||||
private bool _triggered;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────
|
||||
private void OnValidate() => SyncCollider();
|
||||
|
||||
private void SyncCollider()
|
||||
{
|
||||
var col = GetComponent<BoxCollider2D>();
|
||||
if (col == null) return;
|
||||
col.isTrigger = true;
|
||||
col.offset = _center;
|
||||
col.size = _size;
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
if (_triggerOnce && _triggered) return;
|
||||
if (_targetRoom == null) return;
|
||||
|
||||
_triggered = true;
|
||||
CameraStateController.Instance.SwitchRoom(new RoomCameraData
|
||||
{
|
||||
ConfinerCollider = _targetRoom.Collider,
|
||||
CameraOffset = _cameraOffset,
|
||||
BlendProfile = _blendOverride,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Editor Gizmo ──────────────────────────────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private static readonly Color _triggerColor = new(1f, 0.85f, 0f, 0.9f); // 黄
|
||||
private static readonly Color _triggerFill = new(1f, 0.85f, 0f, 0.06f);
|
||||
private static readonly Color _arrowColor = new(0f, 0.8f, 1f, 0.9f); // 青
|
||||
private static readonly Color _triggeredColor = new(0.5f,0.5f, 0.5f,0.5f); // 灰(已触发)
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
var worldCenter = (Vector2)transform.position + _center;
|
||||
|
||||
// 触发矩形
|
||||
bool fired = Application.isPlaying && _triggerOnce && _triggered;
|
||||
Gizmos.color = fired ? _triggeredColor : _triggerFill;
|
||||
Gizmos.DrawCube(worldCenter, new Vector3(_size.x, _size.y, 0f));
|
||||
Gizmos.color = fired ? _triggeredColor : _triggerColor;
|
||||
DrawWireRect(worldCenter, _size);
|
||||
|
||||
// 箭头:指向目标房间
|
||||
if (_targetRoom != null)
|
||||
{
|
||||
Gizmos.color = _arrowColor;
|
||||
var dest = (Vector2)_targetRoom.transform.position;
|
||||
Gizmos.DrawLine(worldCenter, dest);
|
||||
Gizmos.DrawSphere(dest, 0.2f);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DrawWireRect(Vector2 center, Vector2 size)
|
||||
{
|
||||
var h = size * 0.5f;
|
||||
var bl = center + new Vector2(-h.x, -h.y);
|
||||
var br = center + new Vector2( h.x, -h.y);
|
||||
var tr = center + new Vector2( h.x, h.y);
|
||||
var tl = center + new Vector2(-h.x, h.y);
|
||||
Gizmos.DrawLine(bl, br); Gizmos.DrawLine(br, tr);
|
||||
Gizmos.DrawLine(tr, tl); Gizmos.DrawLine(tl, bl);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CameraTriggerZoneEditor(Editor 脚本)
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Editor/Camera/CameraTriggerZoneEditor.cs
|
||||
// Scene 内拖动 8 个控制点调整触发区域,支持整体平移(中心点)
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
|
||||
namespace BaseGames.Camera.Editor
|
||||
{
|
||||
[CustomEditor(typeof(CameraTriggerZone))]
|
||||
public class CameraTriggerZoneEditor : UnityEditor.Editor
|
||||
{
|
||||
private SerializedProperty _center;
|
||||
private SerializedProperty _size;
|
||||
private SerializedProperty _targetRoom;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_center = serializedObject.FindProperty("_center");
|
||||
_size = serializedObject.FindProperty("_size");
|
||||
_targetRoom = serializedObject.FindProperty("_targetRoom");
|
||||
}
|
||||
|
||||
private void OnSceneGUI()
|
||||
{
|
||||
var zone = (CameraTriggerZone)target;
|
||||
var worldPos = (Vector2)zone.transform.position;
|
||||
var center = worldPos + _center.vector2Value;
|
||||
var size = _size.vector2Value;
|
||||
var h = size * 0.5f;
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Handles.color = new Color(1f, 0.85f, 0f, 0.9f);
|
||||
|
||||
// ① 中心点:整体平移
|
||||
float dotSize = HandleUtility.GetHandleSize(center) * 0.12f;
|
||||
var newCenter = (Vector2)Handles.FreeMoveHandle(
|
||||
center, dotSize, Vector3.zero, Handles.CircleHandleCap);
|
||||
|
||||
// ② 8 个边缘控制点:缩放
|
||||
Vector2 newSize = size;
|
||||
newSize = DragEdge(center + new Vector2( h.x, 0 ), newSize, center, 1, 0);
|
||||
newSize = DragEdge(center + new Vector2(-h.x, 0 ), newSize, center, -1, 0);
|
||||
newSize = DragEdge(center + new Vector2( 0, h.y ), newSize, center, 0, 1);
|
||||
newSize = DragEdge(center + new Vector2( 0, -h.y ), newSize, center, 0, -1);
|
||||
newSize = DragCorner(center + new Vector2( h.x, h.y), newSize, center, 1, 1);
|
||||
newSize = DragCorner(center + new Vector2(-h.x, h.y), newSize, center, -1, 1);
|
||||
newSize = DragCorner(center + new Vector2( h.x, -h.y), newSize, center, 1, -1);
|
||||
newSize = DragCorner(center + new Vector2(-h.x, -h.y), newSize, center, -1, -1);
|
||||
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
Undo.RecordObject(zone, "Edit CameraTriggerZone");
|
||||
_center.vector2Value = newCenter - worldPos;
|
||||
_size.vector2Value = Vector2.Max(newSize, new Vector2(0.1f, 0.1f));
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 DragEdge(Vector2 wp, Vector2 size, Vector2 center,
|
||||
int signX, int signY)
|
||||
{
|
||||
float s = HandleUtility.GetHandleSize(wp) * 0.1f;
|
||||
var newWp = (Vector2)Handles.FreeMoveHandle(wp, s,
|
||||
Vector3.zero, Handles.RectangleHandleCap);
|
||||
if (newWp == wp) return size;
|
||||
var delta = newWp - wp;
|
||||
return size + new Vector2(signX * delta.x * 2, signY * delta.y * 2);
|
||||
}
|
||||
|
||||
private Vector2 DragCorner(Vector2 wp, Vector2 size, Vector2 center,
|
||||
int signX, int signY)
|
||||
{
|
||||
float s = HandleUtility.GetHandleSize(wp) * 0.1f;
|
||||
var newWp = (Vector2)Handles.FreeMoveHandle(wp, s,
|
||||
Vector3.zero, Handles.RectangleHandleCap);
|
||||
if (newWp == wp) return size;
|
||||
var delta = newWp - wp;
|
||||
return size + new Vector2(signX * delta.x * 2, signY * delta.y * 2);
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
DrawDefaultInspector();
|
||||
|
||||
var zone = (CameraTriggerZone)target;
|
||||
if (_targetRoom.objectReferenceValue == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"未指定目标房间(_targetRoom),触发后不会切换相机。", MessageType.Warning);
|
||||
}
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
### 独立编辑 vs 绑定房间的区别
|
||||
|
||||
```
|
||||
旧设计(绑定房间):
|
||||
RoomA ──→ CameraTriggerZone(Collider 尺寸手动管理,无 WYSIWYG)
|
||||
└── 依赖 BoxCollider2D 自带编辑,无法直观预览触发与房间的关系
|
||||
|
||||
新设计(独立编辑):
|
||||
场景层级示例:
|
||||
[Room_A]
|
||||
├── RoomVisibleArea ← 绿色框:房间可视区域
|
||||
│
|
||||
[TriggerZone_A→B] ← 独立 GameObject,不挂在任何房间下
|
||||
├── CameraTriggerZone ← 黄色框:触发区域(可自由移动/缩放)
|
||||
│ ├── _targetRoom → RoomB.RoomVisibleArea
|
||||
│ └── _size = (0.5, 4) ← 入口竖线,覆盖通道高度
|
||||
|
||||
[TriggerZone_B→A] ← 反向触发(指向 RoomA,_triggerOnce=false)
|
||||
└── CameraTriggerZone
|
||||
|
||||
同一房间多个入口示例:
|
||||
TriggerZone_A_Left → RoomA (从左侧进入)
|
||||
TriggerZone_A_Right → RoomA (从右侧进入)
|
||||
TriggerZone_A_Down → RoomA (从上方坠入)
|
||||
```
|
||||
|
||||
### 常见尺寸约定
|
||||
|
||||
| 触发线类型 | `_size` 建议值 | 备注 |
|
||||
|-----------|:-------------:|------|
|
||||
| 竖向门洞(窄走廊)| `(0.5, 3.0)` | 宽 = 0.5 ≤ 玩家宽,高覆盖通道 |
|
||||
| 横向过渡(上下层)| `(8.0, 0.5)` | 宽覆盖平台,高 = 0.5 |
|
||||
| 大区域入口 | `(2.0, 4.0)` | 宽松触发,允许斜角进入 |
|
||||
|
||||
---
|
||||
|
||||
## 6. RoomCamera(可选)
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在房间场景中的 VCam_Room_XXX 上。
|
||||
/// 存在时 Priority=15,自动优先于全局双机。
|
||||
/// </summary>
|
||||
public class RoomCamera : MonoBehaviour
|
||||
{
|
||||
[SerializeField] CinemachineCamera _vcam;
|
||||
[SerializeField] RoomVisibleArea _visibleArea;
|
||||
[SerializeField] CameraBlendProfileSO _enterBlend; // 可留空
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
_vcam.Priority = 15;
|
||||
CameraStateController.Instance.RegisterRoomCamera(this);
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
_vcam.Priority = 0;
|
||||
CameraStateController.Instance.UnregisterRoomCamera(this);
|
||||
}
|
||||
|
||||
public CinemachineCamera Vcam => _vcam;
|
||||
public RoomVisibleArea VisibleArea => _visibleArea;
|
||||
public CameraBlendProfileSO EnterBlend => _enterBlend;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CameraConfigSO
|
||||
|
||||
```csharp
|
||||
[CreateAssetMenu(menuName = "Camera/CameraConfig")]
|
||||
public class CameraConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("探索跟随")]
|
||||
public Vector3 DefaultFollowOffset = new(0f, 1f, -10f); // 玩家偏移
|
||||
[Range(0f, 5f)]
|
||||
public float HorizontalDamping = 0.5f;
|
||||
[Range(0f, 5f)]
|
||||
public float VerticalDamping = 0.3f;
|
||||
public float ExploreAheadDistance = 2.5f; // 向移动方向前瞻距离(世界单位)
|
||||
public float ExploreLookUpOffset = 1.5f; // 玩家向上按键时额外偏移(如查看头顶)
|
||||
public float ExploreLookDownOffset = 0.5f; // 玩家向下按键时额外偏移
|
||||
|
||||
[Header("战斗模式")]
|
||||
public float CombatZoomOut = 1.2f; // 战斗时镜头拉远倍数
|
||||
public float CombatForwardOffset = 1.0f; // 朝敌人方向的额外偏移
|
||||
|
||||
[Header("Boss 战")]
|
||||
public float BossZoomLevel = 6.0f; // Boss 相机正交尺寸(世界单位半高)
|
||||
|
||||
[Header("默认混合")]
|
||||
public float DefaultBlendDuration = 0.5f;
|
||||
public CinemachineBlendDefinition.Style DefaultBlendStyle
|
||||
= CinemachineBlendDefinition.Style.EaseInOut;
|
||||
|
||||
[Header("Pixel Perfect")]
|
||||
public int ReferenceResolutionX = 480; // 参考分辨率宽
|
||||
public int ReferenceResolutionY = 270; // 参考分辨率高
|
||||
public int PixelsPerUnit = 32; // PPU,与 SpriteImportSettings 一致
|
||||
public bool CropFrameX = false;
|
||||
public bool CropFrameY = false;
|
||||
public bool UpscaleRenderTexture = true;
|
||||
|
||||
/// <summary>
|
||||
/// 正交尺寸公式:OrthographicSize = RefResHeight / (2 × PPU)
|
||||
/// 例:270 / (2 × 32) = 4.21875
|
||||
/// </summary>
|
||||
public float OrthographicSize
|
||||
=> (float)ReferenceResolutionY / (2f * PixelsPerUnit);
|
||||
|
||||
/// <summary>
|
||||
/// 相机视口的世界单位尺寸(供 RoomVisibleArea 引用,保证所有房间使用一致的视口基准)。
|
||||
/// = ReferenceResolution / PixelsPerUnit
|
||||
/// 例:480×270 / 32PPU = 15×8.4375
|
||||
/// </summary>
|
||||
public Vector2 ViewportSizeInWorldUnits
|
||||
=> new Vector2((float)ReferenceResolutionX / PixelsPerUnit,
|
||||
(float)ReferenceResolutionY / PixelsPerUnit);
|
||||
|
||||
[Header("大房间滚动限制")]
|
||||
public bool EnableScrollClamp = false;
|
||||
public float MaxScrollSpeed = 8f; // 像素/秒
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径**:`Assets/ScriptableObjects/Camera/Camera_Config.asset`
|
||||
|
||||
---
|
||||
|
||||
## 8. CameraBlendProfileSO
|
||||
|
||||
```csharp
|
||||
[CreateAssetMenu(menuName = "Camera/BlendProfile")]
|
||||
public class CameraBlendProfileSO : ScriptableObject
|
||||
{
|
||||
public float Duration = 0.5f;
|
||||
public CinemachineBlendDefinition.Style Style
|
||||
= CinemachineBlendDefinition.Style.EaseInOut;
|
||||
|
||||
public CinemachineBlendDefinition ToBlendDefinition()
|
||||
=> new CinemachineBlendDefinition(Style, Duration);
|
||||
}
|
||||
```
|
||||
|
||||
**内置预设(`Assets/ScriptableObjects/Camera/Blends/`)**:
|
||||
|
||||
| 资产名 | 用途 | 混合风格 | Duration |
|
||||
|--------|------|---------|:---:|
|
||||
| `Blend_Default.asset` | 正常房间切换 | EaseInOut | 0.5s |
|
||||
| `Blend_Instant.asset` | 传送点、剧情跳切 | Cut | 0s |
|
||||
| `Blend_Slow.asset` | Boss 房间进入 | EaseIn | 1.0s |
|
||||
| `Blend_BossExit.asset` | Boss 击败后退出 | EaseOut | 0.8s |
|
||||
| `Blend_Boss.asset` | Boss 战切换(OnBossFightToggled) | EaseIn | 0.8s |
|
||||
| `Blend_Death.asset` | 玩家死亡 | EaseIn | 1.0s |
|
||||
| `Blend_Cutscene.asset` | 进入剧情镜头(Cut 接管) | Cut | 0s |
|
||||
| `Blend_CutsceneExit.asset` | 剧情结束恢复探索 | EaseOut | 0.3s |
|
||||
|
||||
---
|
||||
|
||||
## 9. 镜头震动集成
|
||||
|
||||
`CinemachineImpulseSource` 挂在 `VCam_Global_A/B` 上,通过 `CameraStateController.TriggerImpulse()` 调用:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// 镜头震动预设(数值来自 FeedbackConfigSO 中配置)。
|
||||
/// </summary>
|
||||
public struct CameraShakePreset
|
||||
{
|
||||
public float Amplitude; // 震动幅度
|
||||
public float Force; // 冲量强度
|
||||
public float Duration; // Impulse Duration(在 CinemachineImpulseDefinition 设置)
|
||||
}
|
||||
```
|
||||
|
||||
**与 Feel 集成**:`MMF_CinemachineImpulse` feedback 直接操作 `CinemachineImpulseSource`,不经过 `CameraStateController`(二者不冲突,Feel 仍走标准 Cinemachine 管线)。
|
||||
|
||||
---
|
||||
|
||||
## 10. Pixel Perfect 集成
|
||||
|
||||
- `PixelPerfectCamera` 挂在 Main Camera,设置 `ReferenceResolutionX/Y = 320×180`
|
||||
- `CinemachinePixelPerfect` 扩展组件挂在每个 `CinemachineCamera` 上,使 Cinemachine 跟随位置对齐到像素网格
|
||||
- 禁用 Anti-aliasing(与像素风格不兼容)
|
||||
|
||||
---
|
||||
|
||||
## 11. 接口调用(直接调用,不使用事件频道)
|
||||
|
||||
Camera 模块采用 **直接调用** 而非事件频道:
|
||||
|
||||
| 调用者 | 调用方法 | 被调用方 |
|
||||
|--------|---------|---------|
|
||||
| `CameraTriggerZone` | `CameraStateController.Instance.SwitchRoom(RoomCameraData)` | `CameraStateController` |
|
||||
| `IFeedbackPlayer`(`PlayerFeedback`、`EnemyFeedback`) | `CameraStateController.Instance.TriggerImpulse(CameraShakePreset)` | `CameraStateController` |
|
||||
| `RoomCamera.OnEnable` | `CameraStateController.Instance.RegisterRoomCamera(rc)` | `CameraStateController` |
|
||||
| `RoomCamera.OnDisable` | `CameraStateController.Instance.UnregisterRoomCamera(rc)` | `CameraStateController` |
|
||||
|
||||
> **设计决策**:相机切换对延迟敏感(GC 压力影响帧率),使用 Singleton 直接调用而非 EventChannelSO。
|
||||
|
||||
---
|
||||
|
||||
## 12. 房间切换时序
|
||||
|
||||
```
|
||||
玩家穿过 CameraTriggerZone
|
||||
│
|
||||
▼
|
||||
CameraStateController.SwitchRoom(RoomCameraData)
|
||||
│
|
||||
├─ 1. inactive 机更新 Confiner + Offset
|
||||
├─ 2. brain.DefaultBlend ← BlendProfile
|
||||
├─ 3. inactive.Priority = active.Priority + 1
|
||||
│ → CinemachineBrain 自动 Blend
|
||||
└─ 4. BlendFinished → 交换 active/inactive 引用
|
||||
active.Priority = 9
|
||||
```
|
||||
|
||||
**大房间(超出单屏)**:在同一房间内放置多个 `CameraTriggerZone` 指向相同 `RoomVisibleArea`,并调整 `CameraOffset` 实现子区域镜头引导,不切换 Confiner。
|
||||
823
Docs/Architecture/18_VFXFeedbackModule.md
Normal file
823
Docs/Architecture/18_VFXFeedbackModule.md
Normal file
@@ -0,0 +1,823 @@
|
||||
# 18 · VFX 与反馈模块(VFX & Feedback Module)
|
||||
|
||||
> **命名空间** `BaseGames.VFX` / `BaseGames.Feedback`
|
||||
> **程序集** `BaseGames.VFX`(`Assets/Scripts/VFX/`)
|
||||
> **依赖** Feel v4.3 · `BaseGames.Core`(GlobalObjectPool)· `BaseGames.Combat`(HitFxType · DamageInfo)· `BaseGames.Camera`(CameraStateController)
|
||||
> **Design 来源** [41_VFXArchitecture](../Design/41_VFXArchitecture.md) · [07_FeedbackSystem](../Design/07_FeedbackSystem.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [模块职责](#1-模块职责)
|
||||
2. [IFeedbackPlayer 接口](#2-ifeedbackplayer-接口)
|
||||
3. [PlayerFeedback](#3-playerfeedback)
|
||||
4. [EnemyFeedback](#4-enemyfeedback)
|
||||
5. [FeedbackConfigSO](#5-feedbackconfigso)
|
||||
6. [VFXPool — 粒子对象池](#6-vfxpool--粒子对象池)
|
||||
7. [VFXCatalogSO — VFX 映射字典](#7-vfxcatalogso--vfx-映射字典)
|
||||
8. [HitFXSpawner](#8-hitfxspawner)
|
||||
9. [HurtFlashController](#9-hurtflashcontroller)
|
||||
10. [PaletteSwapSystem](#10-paletteswapsystem)
|
||||
11. [PostProcessManager](#11-postprocessmanager)
|
||||
12. [RegionLightController](#12-regionlightcontroller)
|
||||
13. [事件频道](#13-事件频道)
|
||||
14. [VFX 分类与生命周期策略](#14-vfx-分类与生命周期策略)
|
||||
15. [VFX 性能约束](#15-vfx-性能约束)
|
||||
16. [MMF_Player 命名规范](#16-mmf_player-命名规范)
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
```
|
||||
VFX & Feedback 模块职责:
|
||||
├─ IFeedbackPlayer → 反馈抽象接口(GameLogic 依赖此接口,不依赖 Feel)
|
||||
├─ PlayerFeedback → 玩家侧 MMF_Player 聚合,实现 IFeedbackPlayer
|
||||
├─ EnemyFeedback → 敌人侧 MMF_Player 聚合
|
||||
├─ FeedbackConfigSO → 全局反馈参数(冻帧时长、震屏强度等)
|
||||
├─ VFXPool → ParticleSystem 专用对象池
|
||||
├─ VFXCatalogSO → HitFxType → VFX Prefab AddressKey 映射
|
||||
├─ HitFXSpawner → 监听 OnHitConfirmed,路由命中特效
|
||||
├─ HurtFlashController → Shader 属性块驱动的受击白闪
|
||||
└─ PaletteSwapSystem → 形态切换时精灵调色板替换
|
||||
```
|
||||
|
||||
**零耦合**:`HitFXSpawner` 通过 `EVT_HitConfirmed` 事件频道获取命中信息,不直接引用战斗系统。
|
||||
|
||||
---
|
||||
|
||||
## 2. IFeedbackPlayer 接口
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Feedback
|
||||
{
|
||||
/// <summary>
|
||||
/// 反馈执行器的抽象接口。
|
||||
/// GameLogic(PlayerCombat / EnemyBase)依赖此接口,不依赖 Feel 资产。
|
||||
/// 测试时可替换为 NullFeedbackPlayer。
|
||||
/// </summary>
|
||||
public interface IFeedbackPlayer
|
||||
{
|
||||
void PlayHit(HitWeight weight); // 命中反馈(轻/中/重)
|
||||
void PlayParrySuccess(); // 弹反成功
|
||||
void PlayTakeHit(); // 玩家/敌人受击
|
||||
void PlayDeath(); // 死亡演出
|
||||
void PlayHeal(); // 治疗
|
||||
void PlayLandImpact(); // 落地冲击
|
||||
void PlayAttackWhoosh(); // 攻击挥动音效
|
||||
void PlayJumpLaunch(); // 起跳
|
||||
void TriggerPreset(string presetId); // 通过 ID 触发任意预设(AnimEvent 用)
|
||||
void PlaySFXById(string sfxId); // 通过 ID 播放音效(AnimEvent 用)
|
||||
}
|
||||
|
||||
/// <summary>空实现,用于测试和无反馈需求场景。</summary>
|
||||
public class NullFeedbackPlayer : IFeedbackPlayer
|
||||
{
|
||||
public void PlayHit(HitWeight w) { }
|
||||
public void PlayParrySuccess() { }
|
||||
public void PlayTakeHit() { }
|
||||
public void PlayDeath() { }
|
||||
public void PlayHeal() { }
|
||||
public void PlayLandImpact() { }
|
||||
public void PlayAttackWhoosh() { }
|
||||
public void PlayJumpLaunch() { }
|
||||
public void TriggerPreset(string id) { }
|
||||
public void PlaySFXById(string id) { }
|
||||
}
|
||||
|
||||
public enum HitWeight { Light, Medium, Heavy }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. PlayerFeedback
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Feedback
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在 Player Prefab 根节点下的 [Feedback] 子 GameObject 上。
|
||||
/// 实现 IFeedbackPlayer,聚合所有玩家相关 MMF_Player。
|
||||
/// </summary>
|
||||
public class PlayerFeedback : MonoBehaviour, IFeedbackPlayer
|
||||
{
|
||||
// ── MMF_Player 引用(Inspector 配置)────────────────
|
||||
[Header("命中反馈")]
|
||||
[SerializeField] MMF_Player _onHitLight;
|
||||
[SerializeField] MMF_Player _onHitMedium;
|
||||
[SerializeField] MMF_Player _onHitHeavy;
|
||||
|
||||
[Header("战斗反馈")]
|
||||
[SerializeField] MMF_Player _onParrySuccess;
|
||||
[SerializeField] MMF_Player _onTakeHit;
|
||||
[SerializeField] MMF_Player _onDeath;
|
||||
|
||||
[Header("移动反馈")]
|
||||
[SerializeField] MMF_Player _onHeal;
|
||||
[SerializeField] MMF_Player _onLandImpact;
|
||||
[SerializeField] MMF_Player _onAttackWhoosh;
|
||||
[SerializeField] MMF_Player _onJumpLaunch;
|
||||
|
||||
[Header("配置")]
|
||||
[SerializeField] FeedbackConfigSO _config;
|
||||
|
||||
// ── Dictionary 预设(runtime,用于 TriggerPreset)───
|
||||
Dictionary<string, MMF_Player> _presetMap;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_presetMap = new Dictionary<string, MMF_Player>
|
||||
{
|
||||
{ "HitLight", _onHitLight },
|
||||
{ "HitMedium", _onHitMedium },
|
||||
{ "HitHeavy", _onHitHeavy },
|
||||
{ "ParrySuccess", _onParrySuccess },
|
||||
{ "TakeHit", _onTakeHit },
|
||||
{ "Death", _onDeath },
|
||||
{ "LandImpact", _onLandImpact },
|
||||
};
|
||||
}
|
||||
|
||||
// ── IFeedbackPlayer 实现 ─────────────────────────────
|
||||
public void PlayHit(HitWeight weight)
|
||||
{
|
||||
switch (weight)
|
||||
{
|
||||
case HitWeight.Light: _onHitLight?.PlayFeedbacks(); break;
|
||||
case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
|
||||
case HitWeight.Heavy: _onHitHeavy?.PlayFeedbacks(); break;
|
||||
}
|
||||
}
|
||||
public void PlayParrySuccess() => _onParrySuccess?.PlayFeedbacks();
|
||||
public void PlayTakeHit() => _onTakeHit?.PlayFeedbacks();
|
||||
public void PlayDeath() => _onDeath?.PlayFeedbacks();
|
||||
public void PlayHeal() => _onHeal?.PlayFeedbacks();
|
||||
public void PlayLandImpact() => _onLandImpact?.PlayFeedbacks();
|
||||
public void PlayAttackWhoosh() => _onAttackWhoosh?.PlayFeedbacks();
|
||||
public void PlayJumpLaunch() => _onJumpLaunch?.PlayFeedbacks();
|
||||
|
||||
public void TriggerPreset(string presetId)
|
||||
{
|
||||
if (_presetMap.TryGetValue(presetId, out var player))
|
||||
player?.PlayFeedbacks();
|
||||
}
|
||||
|
||||
public void PlaySFXById(string sfxId)
|
||||
{
|
||||
// ⚠️ AudioManager.PlaySFX 接受 AudioClip(非 string)(架构 11_AudioModule §2)
|
||||
// sfxId → AudioClip 解析需通过 SFX 目录;此处留空实现待扩展
|
||||
// 具体用法:持有 SFXCatalogSO 引用,查表后调用 AudioManager.Instance.PlaySFX(clip)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Player Prefab 层级**:
|
||||
|
||||
```
|
||||
[Player]
|
||||
└── [Feedback]
|
||||
├── PlayerFeedback.cs
|
||||
├── MMF_Player_OnHitLight
|
||||
├── MMF_Player_OnHitMedium
|
||||
├── MMF_Player_OnHitHeavy
|
||||
├── MMF_Player_OnParrySuccess
|
||||
├── MMF_Player_OnTakeHit
|
||||
├── MMF_Player_OnDeath
|
||||
├── MMF_Player_OnHeal
|
||||
├── MMF_Player_OnLandImpact
|
||||
├── MMF_Player_OnAttackWhoosh
|
||||
└── MMF_Player_OnJumpLaunch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. EnemyFeedback
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Feedback
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在 EnemyBase Prefab 下的 [Feedback] 子 GameObject 上。
|
||||
/// EnemyBase 通过 [SerializeField] EnemyFeedback _feedback 引用。
|
||||
/// </summary>
|
||||
public class EnemyFeedback : MonoBehaviour, IFeedbackPlayer
|
||||
{
|
||||
[SerializeField] MMF_Player _onTakeHit;
|
||||
[SerializeField] MMF_Player _onDeath;
|
||||
[SerializeField] MMF_Player _onHitLight;
|
||||
[SerializeField] MMF_Player _onHitMedium;
|
||||
[SerializeField] MMF_Player _onHitHeavy;
|
||||
|
||||
// IFeedbackPlayer 实现(敌人侧只需命中/受击/死亡)
|
||||
public void PlayHit(HitWeight weight)
|
||||
{
|
||||
switch (weight)
|
||||
{
|
||||
case HitWeight.Light: _onHitLight?.PlayFeedbacks(); break;
|
||||
case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
|
||||
case HitWeight.Heavy: _onHitHeavy?.PlayFeedbacks(); break;
|
||||
}
|
||||
}
|
||||
public void PlayTakeHit() => _onTakeHit?.PlayFeedbacks();
|
||||
public void PlayDeath() => _onDeath?.PlayFeedbacks();
|
||||
|
||||
// 未使用的接口方法(空实现)
|
||||
public void PlayParrySuccess() { }
|
||||
public void PlayHeal() { }
|
||||
public void PlayLandImpact() { }
|
||||
public void PlayAttackWhoosh() { }
|
||||
public void PlayJumpLaunch() { }
|
||||
public void TriggerPreset(string id) { }
|
||||
public void PlaySFXById(string id) { }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. FeedbackConfigSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Feedback
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Feedback/FeedbackConfig")]
|
||||
public class FeedbackConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("冻帧")]
|
||||
[Range(0f, 0.2f)]
|
||||
public float FreezeFrameDuration = 0.033f; // 默认 2 帧(60fps)
|
||||
[Range(0f, 0.2f)]
|
||||
public float ParryFreezeFrameDuration = 0.067f; // 弹反冻帧更长
|
||||
|
||||
[Header("子弹时间")]
|
||||
[Range(0.01f, 1f)]
|
||||
public float BulletTimeScale = 0.15f;
|
||||
[Range(0f, 1f)]
|
||||
public float BulletTimeDuration = 0.3f;
|
||||
|
||||
[Header("镜头震动强度")]
|
||||
public float ShakeLightForce = 0.2f;
|
||||
public float ShakeMediumForce = 0.5f;
|
||||
public float ShakeHeavyForce = 1.0f;
|
||||
public float ShakeParryForce = 0.8f;
|
||||
|
||||
[Header("受击白闪")]
|
||||
public Color HurtFlashColor = Color.white;
|
||||
[Range(0f, 0.5f)]
|
||||
public float HurtFlashDuration = 0.15f;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径**:`Assets/ScriptableObjects/Feedback/Feedback_Config.asset`
|
||||
|
||||
---
|
||||
|
||||
## 6. VFXPool — 粒子对象池
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.VFX
|
||||
{
|
||||
/// <summary>
|
||||
/// ParticleSystem 专用对象池,挂在 Persistent 场景 [VFXPool] GameObject 上。
|
||||
/// 粒子播放完成(或超过 MaxLifetime)后自动回池,调用方无需手动归还。
|
||||
/// </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();
|
||||
|
||||
void Awake() => Instance = this;
|
||||
|
||||
/// <summary>
|
||||
/// 在世界坐标播放一次特效。Fire-and-forget(UniTask 自动回池)。
|
||||
/// </summary>
|
||||
/// <param name="maxLifetime">
|
||||
/// > 0 时覆盖全局超时;≤ 0 时使用 <see cref="_globalMaxLifetime"/>。
|
||||
/// </param>
|
||||
public async UniTaskVoid Play(AssetReferenceGameObject vfxRef,
|
||||
Vector3 position,
|
||||
Quaternion rotation = default,
|
||||
float maxLifetime = 0f)
|
||||
{
|
||||
var ps = await GetOrCreateAsync(vfxRef);
|
||||
float limit = maxLifetime > 0f ? maxLifetime : _globalMaxLifetime;
|
||||
|
||||
ps.transform.SetPositionAndRotation(position, rotation);
|
||||
ps.gameObject.SetActive(true);
|
||||
ps.Play();
|
||||
|
||||
// 双重退出条件:粒子自然结束 OR 超时强制回收
|
||||
using var cts = new System.Threading.CancellationTokenSource(
|
||||
System.TimeSpan.FromSeconds(limit));
|
||||
try
|
||||
{
|
||||
await UniTask.WaitUntil(() => !ps.IsAlive(true),
|
||||
cancellationToken: cts.Token);
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
// 超时:强制停止
|
||||
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||
Debug.LogWarning(
|
||||
$"[VFXPool] '{vfxRef.RuntimeKey}' 超过 {limit:F1}s 强制回收。" +
|
||||
"请检查粒子是否设为 Loop 或 Duration 过长。");
|
||||
}
|
||||
|
||||
ps.gameObject.SetActive(false);
|
||||
_pools[vfxRef].Enqueue(ps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预热:预先创建若干实例避免首次播放卡顿。
|
||||
/// </summary>
|
||||
public async UniTask WarmupAsync(AssetReferenceGameObject vfxRef, int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var go = await Addressables.InstantiateAsync(vfxRef, transform).Task;
|
||||
var ps = go.GetComponent<ParticleSystem>();
|
||||
ps.gameObject.SetActive(false);
|
||||
if (!_pools.ContainsKey(vfxRef)) _pools[vfxRef] = new Queue<ParticleSystem>();
|
||||
_pools[vfxRef].Enqueue(ps);
|
||||
}
|
||||
}
|
||||
|
||||
async UniTask<ParticleSystem> GetOrCreateAsync(AssetReferenceGameObject vfxRef)
|
||||
{
|
||||
if (_pools.TryGetValue(vfxRef, out var q) && q.Count > 0)
|
||||
return q.Dequeue();
|
||||
|
||||
// 池不存在时初始化
|
||||
if (!_pools.ContainsKey(vfxRef))
|
||||
_pools[vfxRef] = new Queue<ParticleSystem>();
|
||||
|
||||
var go = await Addressables.InstantiateAsync(vfxRef, transform).Task;
|
||||
return go.GetComponent<ParticleSystem>();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`maxLifetime` 使用指南**:
|
||||
|
||||
| 特效类型 | 建议 `maxLifetime` | 说明 |
|
||||
|----------|-------------------|------|
|
||||
| 命中火花 / 数字 | 默认(3s) | 时长已知,无需覆盖 |
|
||||
| 爆炸 / 大范围 | `5f` | 稍长,粒子散逸需时间 |
|
||||
| Boss 相位特效 | `15f` | 长动画,须显式指定 |
|
||||
| 环境氛围循环粒子 | **不使用 VFXPool** | Loop 粒子应手动管理生命周期 |
|
||||
|
||||
---
|
||||
|
||||
## 7. VFXCatalogSO — VFX 映射字典
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.VFX
|
||||
{
|
||||
[CreateAssetMenu(menuName = "VFX/VFXCatalog")]
|
||||
public class VFXCatalogSO : ScriptableObject
|
||||
{
|
||||
[Header("命中特效映射")]
|
||||
public VFXEntry[] hitEffects; // HitFxType → VFX Prefab
|
||||
|
||||
[Header("预热配置")]
|
||||
public VFXWarmupEntry[] warmups;
|
||||
|
||||
private Dictionary<HitFxType, AssetReferenceGameObject> _map;
|
||||
|
||||
/// <summary>在 GameManager.OnGameplayStarted 中调用,建立快速查表字典。</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
_map = new Dictionary<HitFxType, AssetReferenceGameObject>();
|
||||
foreach (var e in hitEffects) _map[e.type] = e.vfxRef;
|
||||
}
|
||||
|
||||
/// <summary>根据 HitFxType 查找对应的 VFX Prefab 引用。</summary>
|
||||
public bool TryGetHitFX(HitFxType type, out AssetReferenceGameObject vfxRef)
|
||||
{
|
||||
if (_map != null) return _map.TryGetValue(type, out vfxRef);
|
||||
// 未初始化时回退至线性查找
|
||||
foreach (var e in hitEffects)
|
||||
{
|
||||
if (e.type == type) { vfxRef = e.vfxRef; return true; }
|
||||
}
|
||||
vfxRef = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct VFXEntry
|
||||
{
|
||||
public HitFxType type;
|
||||
public AssetReferenceGameObject vfxRef;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct VFXWarmupEntry
|
||||
{
|
||||
public AssetReferenceGameObject vfxRef;
|
||||
[Min(1)] public int warmupCount; // 建议 3~5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径**:`Assets/ScriptableObjects/VFX/VFX_Catalog.asset`
|
||||
|
||||
---
|
||||
|
||||
## 8. HitFXSpawner
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.VFX
|
||||
{
|
||||
/// <summary>
|
||||
/// 监听 EVT_HitConfirmed 事件频道,从 VFXPool 取粒子播放命中特效。
|
||||
/// 挂在 Persistent 场景 [VFXSystem] GameObject 上。
|
||||
/// </summary>
|
||||
public class HitFXSpawner : MonoBehaviour
|
||||
{
|
||||
[SerializeField] HitConfirmedEventChannelSO _onHitConfirmed;
|
||||
[SerializeField] VFXCatalogSO _catalog;
|
||||
|
||||
void OnEnable() => _onHitConfirmed.OnEventRaised += HandleHit;
|
||||
void OnDisable() => _onHitConfirmed.OnEventRaised -= HandleHit;
|
||||
|
||||
void HandleHit(HitInfo info)
|
||||
{
|
||||
if (_catalog.TryGetHitFX(info.DamageInfo.HitFxType, out var vfxRef))
|
||||
VFXPool.Instance.Play(vfxRef, info.HitPoint).Forget();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. HurtFlashController
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.VFX
|
||||
{
|
||||
/// <summary>
|
||||
/// 受击白闪效果,通过 Material Property Block 修改 Shader 参数。
|
||||
/// 挂在玩家/敌人的 SpriteRenderer 所在 GameObject 上。
|
||||
/// 不复制 Material(避免 GC),通过 MaterialPropertyBlock 实现。
|
||||
/// </summary>
|
||||
public class HurtFlashController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] SpriteRenderer _renderer;
|
||||
[SerializeField] FeedbackConfigSO _config;
|
||||
|
||||
static readonly int FlashColorID = Shader.PropertyToID("_FlashColor");
|
||||
static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount");
|
||||
|
||||
MaterialPropertyBlock _block;
|
||||
|
||||
void Awake() => _block = new MaterialPropertyBlock();
|
||||
|
||||
/// <summary>触发一次受击白闪(由 IFeedbackPlayer.PlayTakeHit 间接调用)。</summary>
|
||||
public async UniTaskVoid Flash(CancellationToken ct = default)
|
||||
{
|
||||
_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);
|
||||
_renderer.SetPropertyBlock(_block);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Shader 要求**:`SpriteRenderer` 所用 Shader 需支持 `_FlashColor`(Color)和 `_FlashAmount`(float 0~1)属性(URP Sprite-Lit-Flash 变体)。
|
||||
|
||||
---
|
||||
|
||||
## 10. PaletteSwapSystem
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.VFX
|
||||
{
|
||||
/// <summary>
|
||||
/// 形态切换时替换玩家精灵调色板。
|
||||
/// 通过 Texture2D 查找表(LUT)Shader 实现,不换 Sprite 资产。
|
||||
/// 挂在玩家 SpriteRenderer 所在 GameObject 上。
|
||||
/// </summary>
|
||||
public class PaletteSwapSystem : MonoBehaviour
|
||||
{
|
||||
[SerializeField] SpriteRenderer _renderer;
|
||||
[SerializeField] PaletteCatalogSO _catalog;
|
||||
|
||||
static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
|
||||
|
||||
MaterialPropertyBlock _block;
|
||||
|
||||
void Awake() => _block = new MaterialPropertyBlock();
|
||||
|
||||
/// <summary>切换到指定形态的调色板。由 FormController 调用。</summary>
|
||||
public void ApplyPalette(FormType form)
|
||||
{
|
||||
if (!_catalog.TryGetPalette(form, out var tex)) return;
|
||||
_renderer.GetPropertyBlock(_block);
|
||||
_block.SetTexture(PaletteTexID, tex);
|
||||
_renderer.SetPropertyBlock(_block);
|
||||
}
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "VFX/PaletteCatalog")]
|
||||
public class PaletteCatalogSO : ScriptableObject
|
||||
{
|
||||
public PaletteEntry[] entries;
|
||||
|
||||
public bool TryGetPalette(FormType form, out Texture2D tex)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e.form == form) { tex = e.paletteLUT; return true; }
|
||||
}
|
||||
tex = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct PaletteEntry
|
||||
{
|
||||
public FormType form;
|
||||
public Texture2D paletteLUT; // 1D 查找表纹理(256×1 px)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. PostProcessManager
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.VFX
|
||||
{
|
||||
/// <summary>
|
||||
/// 后处理 Volume 分区管理器,挂在 Persistent 场景 [PostProcess] GameObject 上。
|
||||
/// 通过 DOTween 平滑 blend Weight,监听游戏状态事件。
|
||||
/// </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
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] VoidEventChannelSO _onLiquidEntered;
|
||||
[SerializeField] VoidEventChannelSO _onLiquidExited;
|
||||
[SerializeField] VoidEventChannelSO _onBossFightStarted;
|
||||
[SerializeField] VoidEventChannelSO _onBossFightEnded;
|
||||
[SerializeField] VoidEventChannelSO _onPlayerDied;
|
||||
[SerializeField] VoidEventChannelSO _onPlayerRespawned;
|
||||
[SerializeField] VoidEventChannelSO _onBossDefeated;
|
||||
|
||||
[SerializeField] float _blendDuration = 0.4f;
|
||||
|
||||
private Volume[] _nonDefaultVolumes;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_nonDefaultVolumes = new[] { _underwaterVolume, _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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private void ResetAll()
|
||||
{
|
||||
foreach (var v in _nonDefaultVolumes)
|
||||
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
|
||||
.SetAutoKill(true).SetLink(gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Volume 结构与 Profile 参数
|
||||
|
||||
```
|
||||
Persistent 场景 [PostProcess]:
|
||||
├── Volume_Default Priority=0 Weight=1.0(始终生效基础 Profile)
|
||||
├── Volume_Underwater Priority=10 Weight=0(进水时 blend 到 1.0)
|
||||
├── 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
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.VFX
|
||||
{
|
||||
/// <summary>
|
||||
/// 区域进出时平滑切换 Global Light 2D 颜色和强度。
|
||||
/// 挂在 Persistent 场景 [Lighting] GameObject 上,监听 OnRegionEntered 事件频道。
|
||||
/// </summary>
|
||||
public class RegionLightController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] Light2D _globalLight;
|
||||
[SerializeField] RegionLightCatalogSO _catalog; // RegionId → 颜色 + 强度
|
||||
[SerializeField] StringEventChannelSO _onRegionEntered;
|
||||
[SerializeField] float _transitionDuration = 1.5f;
|
||||
|
||||
private void OnEnable() => _onRegionEntered.OnEventRaised += OnRegionEntered;
|
||||
private void OnDisable() => _onRegionEntered.OnEventRaised -= OnRegionEntered;
|
||||
|
||||
private void OnRegionEntered(string regionId)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "VFX/RegionLightCatalog")]
|
||||
public class RegionLightCatalogSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct RegionLightConfig
|
||||
{
|
||||
public string regionId;
|
||||
public Color Color;
|
||||
[Range(0f, 1f)] public float Intensity;
|
||||
}
|
||||
|
||||
[SerializeField] RegionLightConfig[] _entries;
|
||||
|
||||
public bool TryGet(string regionId, out RegionLightConfig cfg)
|
||||
{
|
||||
foreach (var e in _entries)
|
||||
{
|
||||
if (e.regionId == regionId) { cfg = e; return true; }
|
||||
}
|
||||
cfg = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### URP 2D 光照层(Light Layer)规范
|
||||
|
||||
| Light Layer | 名称 | 用途 |
|
||||
|------------|------|------|
|
||||
| 0 | `Default` | 全局环境光(所有对象默认受光)|
|
||||
| 1 | `Environment` | 蜡烛、火把、发光水晶等环境光源 |
|
||||
| 2 | `PlayerLight` | 玩家携带光源(如灵力发光)|
|
||||
| 3 | `EnemyLight` | Boss 爆炸闪光、特殊敌人发光体 |
|
||||
| 4 | `FXOnly` | 仅特效 ParticleSystem 受此层光照 |
|
||||
| 5–7 | _预留_ | 未来扩展 |
|
||||
|
||||
**规则**:地形 Tilemap 仅勾选 Layer 0;粒子特效勾选 Layer 0 + 4;敌人勾选 Layer 0 + 3。
|
||||
|
||||
### 区域 Global Light 2D 参数速查
|
||||
|
||||
| 区域 | 颜色 | 强度 |
|
||||
|------|------|------|
|
||||
| Forest(扎根森林)| `#C8E8D0`(淡绿)| 0.8 |
|
||||
| Cave(腐蚀洞穴)| `#1A0A2E`(深紫)| 0.2 |
|
||||
| Ruins(坍塌废墟)| `#3D3028`(暖褐)| 0.5 |
|
||||
| Abyss(深渊裂隙)| `#000820`(极暗蓝)| 0.1 |
|
||||
| Core(核心熔炉)| `#4A1800`(暗红橙)| 0.6 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 事件频道
|
||||
|
||||
| 频道 SO | Payload | 发布者 | 订阅者 |
|
||||
|--------|---------|--------|--------|
|
||||
| `EVT_HitConfirmed` | `HitInfo` | `HurtBox.ReceiveDamage` | `HitFXSpawner`、`AudioManager`、`PlayerFeedback`(受击方) |
|
||||
| `EVT_RegionEntered` | `string regionId` | `RegionTrigger` | `RegionLightController`、`AudioManager` |
|
||||
|
||||
> 注:`HurtFlashController.Flash()` 和 `CameraStateController.TriggerImpulse()` 均由 `IFeedbackPlayer`(`PlayerFeedback`)通过直接调用触发,不使用独立事件频道。
|
||||
|
||||
---
|
||||
|
||||
## 14. VFX 分类与生命周期策略
|
||||
|
||||
| 分类 | 示例 | 生命周期策略 | 说明 |
|
||||
|------|------|------------|------|
|
||||
| **命中特效** | 刀击火花、魔法爆炸 | 对象池(VFXPool)| 频繁触发,必须池化 |
|
||||
| **状态特效** | 持续燃烧、中毒气泡 | Prefab 跟随目标 Transform | 随目标销毁,不需要池 |
|
||||
| **环境特效** | 灰尘、落叶、水泡 | 场景内预放置,常驻播放 | 不产生运行时 Instantiate |
|
||||
| **能力特效** | 冲刺残影、双跳光圈 | 对象池(VFXPool)| 中等频率,建议池化 |
|
||||
| **死亡特效** | 敌人爆裂、Boss 死亡烟花 | Addressables.InstantiateAsync | 低频但复杂,不值得池化 |
|
||||
| **UI 特效** | 升级闪光、货币飞入 | Timeline Signal / MMF_Player | 在 UI Canvas 子层用 UI Particle |
|
||||
|
||||
**原则**:帧内可能多次触发(命中、脚步等)→ 池化;每关卡最多触发数次 → Addressables 按需加载。
|
||||
|
||||
### HitFxType 枚举值速查
|
||||
|
||||
| 枚举值 | 特效 | 建议粒子数 |
|
||||
|-------|------|----------|
|
||||
| `Spark` | 金属碰撞火花 | ≤ 8 |
|
||||
| `Blood` | 敌人受击血迹 | ≤ 12 |
|
||||
| `Magic` | 法术命中光环 | ≤ 15 |
|
||||
| `Crit` | 暴击大爆炸 | ≤ 20 |
|
||||
| `Void` | 深渊伤害涟漪 | ≤ 10 |
|
||||
| `Heal` | 治疗绿光粒子 | ≤ 8 |
|
||||
| `Parry` | 弹反白光爆散 | ≤ 20 |
|
||||
|
||||
---
|
||||
|
||||
## 15. VFX 性能约束
|
||||
|
||||
| 约束项 | 限制 | 备注 |
|
||||
|--------|------|------|
|
||||
| 同屏活跃粒子总数 | ≤ 500 | 超过时 GPU 帧时上升 |
|
||||
| 单次命中特效粒子数 | ≤ 20 | HitFxType 表格已约束 |
|
||||
| 同屏 Light 2D 数量 | ≤ 16 | 超过时触发 URP 2D 光照降级 |
|
||||
| Shadow Caster 2D 数量 | ≤ 32 | Composite 模式下 1 Tilemap = 1 |
|
||||
| 新增 VFX Prefab | 必须通过 VFXPool 统一管理 | PR Review 清单检查项 |
|
||||
| `Material.SetXxx()` 直接调用 | 禁止 | 一律用 MaterialPropertyBlock |
|
||||
| `Instantiate(ParticleSystem)` 运行时调用 | 禁止 | 一律从 VFXPool.Play() 取 |
|
||||
|
||||
---
|
||||
|
||||
## 16. MMF_Player 命名规范
|
||||
|
||||
| 格式 | 示例 |
|
||||
|------|------|
|
||||
| `MMF_{Owner}_{EventName}` | `MMF_Player_OnTakeHit`、`MMF_Enemy_OnDeath`、`MMF_Boss_OnPhaseChange` |
|
||||
|
||||
**常用 Feedback 类型速查**:
|
||||
|
||||
| Feel Feedback | 用途 | 关键参数 |
|
||||
|--------------|------|---------|
|
||||
| `MMF_Flash` | Sprite 受击白闪 | `FlashColor`, `Duration` |
|
||||
| `MMF_FreezeFrame` | 命中冻帧 | `FreezeDuration`(来自 FeedbackConfigSO) |
|
||||
| `MMF_TimeScale` | 子弹时间 | `TimeScale`, `Duration` |
|
||||
| `MMF_CinemachineImpulse` | 镜头震动 | `ImpulseSource`, `Velocity` |
|
||||
| `MMF_Particles` | 命中粒子 | `ParticleSystem` 引用 |
|
||||
| `MMF_AudioSource` | 音效 | `AudioClip`, `PitchVariance` |
|
||||
| `MMF_TextMeshPro` | 伤害数字弹出 | 浮动文字动画 |
|
||||
328
Docs/Architecture/19_DifficultyModule.md
Normal file
328
Docs/Architecture/19_DifficultyModule.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 19 · 难度系统模块(Difficulty Module)
|
||||
|
||||
> **命名空间** `BaseGames.Core`
|
||||
> **程序集** `BaseGames.Core`(`Assets/Scripts/Core/`,并入核心程序集)
|
||||
> **依赖** `BaseGames.Core.Events` · `BaseGames.Player`(PlayerStats)· `BaseGames.Enemies`(EnemyStats)
|
||||
> **Design 来源** [29_DifficultyModesGuide](../Design/29_DifficultyModesGuide.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [模块职责](#1-模块职责)
|
||||
2. [DifficultyLevel 枚举](#2-difficultylevel-枚举)
|
||||
3. [DifficultyScalerSO](#3-difficultyscalerso)
|
||||
4. [DifficultyManager](#4-difficultymanager)
|
||||
5. [各系统集成钩子](#5-各系统集成钩子)
|
||||
6. [钢铁之魂模式特殊规则](#6-钢铁之魂模式特殊规则)
|
||||
7. [SaveData 集成](#7-savedata-集成)
|
||||
8. [事件频道](#8-事件频道)
|
||||
9. [资产路径](#9-资产路径)
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
```
|
||||
难度系统职责:
|
||||
├─ DifficultyLevel enum → 四档难度标识
|
||||
├─ DifficultyScalerSO → 各难度的数值缩放配置(四份资产,分别对应四档)
|
||||
├─ DifficultyManager → 常驻 Persistent 场景,持有当前难度,广播变更
|
||||
└─ 系统集成钩子 → PlayerStats / EnemyStats / ShopController
|
||||
在 Initialize 时注入缩放系数
|
||||
```
|
||||
|
||||
**零耦合原则**:各系统**不持有** `DifficultyManager` 引用,只在初始化时读取对应难度的 `DifficultyScalerSO`,或订阅 `EVT_DifficultyChanged` 事件频道动态更新。
|
||||
|
||||
---
|
||||
|
||||
## 2. DifficultyLevel 枚举
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
public enum DifficultyLevel
|
||||
{
|
||||
Easy = 0, // 协助模式
|
||||
Normal = 1, // 标准模式(默认)
|
||||
Hard = 2, // 穿刺模式
|
||||
SteelSoul = 3, // 钢铁之魂(一命,选择后不可降级)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DifficultyScalerSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Core/DifficultyScaler")]
|
||||
public class DifficultyScalerSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public DifficultyLevel level;
|
||||
|
||||
[Header("玩家属性缩放")]
|
||||
[Range(0.1f, 3.0f)]
|
||||
public float PlayerMaxHPMultiplier = 1.0f; // 最大 HP 倍率
|
||||
[Range(0.1f, 3.0f)]
|
||||
public float PlayerDamageMultiplier = 1.0f; // 玩家造成的伤害倍率
|
||||
[Range(0.0f, 2.0f)]
|
||||
public float InvincibilityFrameScale = 1.0f; // 无敌帧时长倍率
|
||||
|
||||
[Header("敌人属性缩放")]
|
||||
[Range(0.1f, 3.0f)]
|
||||
public float EnemyDamageMultiplier = 1.0f; // 敌人造成的伤害倍率
|
||||
[Range(0.1f, 3.0f)]
|
||||
public float EnemyHPMultiplier = 1.0f; // 敌人 HP 倍率
|
||||
[Range(0.1f, 3.0f)]
|
||||
public float BossDamageMultiplier = 1.0f; // Boss 伤害单独控制
|
||||
[Range(0.1f, 3.0f)]
|
||||
public float BossHPMultiplier = 1.0f;
|
||||
|
||||
[Header("商店价格")]
|
||||
[Range(0.5f, 2.0f)]
|
||||
public float ShopPriceMultiplier = 1.0f; // 商品价格倍率(Easy 可折扣)
|
||||
|
||||
[Header("游戏规则")]
|
||||
public bool CanReviveWithGeoLoss = true; // 死亡时 Geo 掉落至遗骸
|
||||
public bool InstantDeathOnZeroHP = false; // SteelSoul:HP 归零直接清档
|
||||
public bool GeoPenaltyOnDeath = true; // false = Easy 无 Geo 损失
|
||||
|
||||
[Header("AI 行为(Behavior Designer 黑板变量名)")]
|
||||
public float EnemyAttackIntervalScale = 1.0f; // 攻击间隔倍率(Hard < 1 = 更频繁)
|
||||
public float EnemyAggroRangeScale = 1.0f; // 感知范围倍率
|
||||
[Range(0.3f, 2.0f)]
|
||||
public float EnemyReactionTimeScale = 1.0f; // 反应时间倍率(>1 = 更慢 = 更简单)
|
||||
[Range(0, 5)]
|
||||
public int EnemyAggressionLevel = 2; // 0=被动 … 5=全力出击(影响 BT 决策权重)
|
||||
|
||||
[Header("掉落与奖励")]
|
||||
[Range(0.0f, 3.0f)]
|
||||
public float GeoDropMultiplier = 1.0f; // Geo 掉落量倍率(Easy 可给更多)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**四档预设资产**(`Assets/ScriptableObjects/Core/Difficulty/`):
|
||||
|
||||
| 资产 | 玩家HP | 玩家伤害 | 敌人伤害 | 敌人HP | 反应时间 | 侵略等级 | Geo倍率 | 规则 |
|
||||
|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|------|
|
||||
| `Difficulty_Easy.asset` | ×1.5 | ×1.0 | ×0.7 | ×0.9 | ×1.4 | 1 | ×1.2 | 无 Geo 损失 |
|
||||
| `Difficulty_Normal.asset` | ×1.0 | ×1.0 | ×1.0 | ×1.0 | ×1.0 | 2 | ×1.0 | 标准 |
|
||||
| `Difficulty_Hard.asset` | ×0.75 | ×1.0 | ×1.3 | ×1.2 | ×0.7 | 3 | ×1.0 | 攻击间隔 ×0.8 |
|
||||
| `Difficulty_SteelSoul.asset` | ×1.2 | ×1.0 | ×1.5 | ×1.5 | ×0.6 | 4 | ×1.0 | `InstantDeathOnZeroHP=true` |
|
||||
|
||||
---
|
||||
|
||||
## 4. DifficultyManager
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局难度管理器,挂在 Persistent 场景 [GameManagers] 下。
|
||||
/// 持有当前难度 ScalerSO,提供静态访问入口,广播难度变更事件。
|
||||
/// DefaultExecutionOrder(-900):确保在 PlayerStats(-800)/EnemyStats(-800) 的
|
||||
/// Awake 之前完成初始化,使它们能在 Start 时通过 DifficultyManager.Instance.CurrentScaler
|
||||
/// 读取到正确的难度系数(无需等待 EVT_DifficultyChanged 广播)。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-900)]
|
||||
public class DifficultyManager : MonoBehaviour
|
||||
{
|
||||
// ── Inspector ────────────────────────────────────────
|
||||
[SerializeField] DifficultyScalerSO[] _allScalers; // 4 档资产
|
||||
[SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;
|
||||
|
||||
// ── Singleton ────────────────────────────────────────
|
||||
public static DifficultyManager Instance { get; private set; }
|
||||
|
||||
// ── Runtime State ────────────────────────────────────
|
||||
public DifficultyLevel CurrentLevel { get; private set; } = DifficultyLevel.Normal;
|
||||
public DifficultyScalerSO CurrentScaler { get; private set; }
|
||||
|
||||
void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
// 默认初始化为 Normal;SaveData 加载后由 GameManager.Start 调用 Apply(saveData.DifficultyLevel)
|
||||
// 注意:因 DefaultExecutionOrder(-900) 早于 PlayerStats(-800)/EnemyStats(-800),
|
||||
// 它们的 Awake 执行时 DifficultyManager.Instance 已就绪,可直接读取 CurrentScaler。
|
||||
Apply(DifficultyLevel.Normal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用难度。新游戏开始/读档时由 GameManager 调用。
|
||||
/// </summary>
|
||||
public void Apply(DifficultyLevel level)
|
||||
{
|
||||
// SteelSoul 不可降级
|
||||
if (CurrentLevel == DifficultyLevel.SteelSoul && level != DifficultyLevel.SteelSoul)
|
||||
{
|
||||
Debug.LogWarning("[DifficultyManager] SteelSoul 模式不可降级");
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentLevel = level;
|
||||
CurrentScaler = GetScaler(level);
|
||||
_onDifficultyChanged.Raise(CurrentScaler);
|
||||
}
|
||||
|
||||
public DifficultyScalerSO GetScaler(DifficultyLevel level)
|
||||
{
|
||||
foreach (var s in _allScalers)
|
||||
if (s.level == level) return s;
|
||||
|
||||
Debug.LogError($"[DifficultyManager] 找不到 {level} 的 ScalerSO");
|
||||
return _allScalers[0]; // fallback
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 游戏进行中切换难度(仅允许 Easy ↔ Normal ↔ Hard)。
|
||||
/// </summary>
|
||||
public void ChangeDifficulty(DifficultyLevel newLevel)
|
||||
{
|
||||
if (newLevel == DifficultyLevel.SteelSoul)
|
||||
{
|
||||
Debug.LogWarning("[DifficultyManager] 游戏进行中不可切换到 SteelSoul");
|
||||
return;
|
||||
}
|
||||
Apply(newLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 各系统集成钩子
|
||||
|
||||
### PlayerStats
|
||||
|
||||
> ⚠️ `PlayerStats` **无** `Initialize(PlayerStatsSO, DifficultyScalerSO)` 方法。
|
||||
> `PlayerStatsSO _config` 通过 Inspector `[SerializeField]` 注入(见 `05_PlayerModule §4`)。
|
||||
> 难度集成**纯事件驱动**:订阅 `_onDifficultyChanged` 频道,在回调中按比例调整 HP 等属性。
|
||||
|
||||
```csharp
|
||||
// PlayerStats.cs(见 05_PlayerModule §4):
|
||||
// _config 为 Inspector 注入的 PlayerStatsSO,无 Initialize 方法
|
||||
|
||||
[SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;
|
||||
|
||||
void OnEnable() => _onDifficultyChanged.OnEventRaised += OnDifficultyChanged;
|
||||
void OnDisable() => _onDifficultyChanged.OnEventRaised -= OnDifficultyChanged;
|
||||
|
||||
void OnDifficultyChanged(DifficultyScalerSO scaler)
|
||||
{
|
||||
// 按比例调整当前 HP
|
||||
float hpRatio = (float)CurrentHP / MaxHP;
|
||||
MaxHP = Mathf.RoundToInt(_config.BaseMaxHP * scaler.PlayerMaxHPMultiplier);
|
||||
CurrentHP = Mathf.RoundToInt(MaxHP * hpRatio);
|
||||
_damageMultiplier = scaler.PlayerDamageMultiplier;
|
||||
_iFrameScale = scaler.InvincibilityFrameScale;
|
||||
}
|
||||
```
|
||||
|
||||
### EnemyStats
|
||||
|
||||
> ⚠️ `EnemyStats.Initialize` 签名为 `Initialize(EnemyStatsSO so)`(仅 1 个参数,见 `07_EnemyModule §2`)。
|
||||
> 难度缩放通过订阅 `_onDifficultyChanged` 事件频道动态应用,**不通过 Initialize 注入 scaler**。
|
||||
|
||||
```csharp
|
||||
// EnemyStats.cs(见 07_EnemyModule §2):
|
||||
// Initialize 签名:public void Initialize(EnemyStatsSO so);
|
||||
|
||||
[SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;
|
||||
|
||||
void OnEnable() => _onDifficultyChanged.OnEventRaised += OnDifficultyChanged;
|
||||
void OnDisable() => _onDifficultyChanged.OnEventRaised -= OnDifficultyChanged;
|
||||
|
||||
void OnDifficultyChanged(DifficultyScalerSO scaler)
|
||||
{
|
||||
MaxHP = Mathf.RoundToInt(_config.BaseHP * scaler.EnemyHPMultiplier);
|
||||
CurrentHP = Mathf.Min(CurrentHP, MaxHP);
|
||||
// AI 黑板变量(AttackIntervalScale)
|
||||
_behaviorTree.SetVariableValue("AttackIntervalScale", scaler.EnemyAttackIntervalScale);
|
||||
}
|
||||
```
|
||||
|
||||
### ShopController
|
||||
|
||||
```csharp
|
||||
// ShopController.GetPrice() 中:
|
||||
public int GetPrice(ShopItemSO item)
|
||||
{
|
||||
var scaler = DifficultyManager.Instance.CurrentScaler;
|
||||
return Mathf.RoundToInt(item.BasePrice * scaler.ShopPriceMultiplier);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 钢铁之魂模式特殊规则
|
||||
|
||||
| 规则 | 实现位置 |
|
||||
|------|---------|
|
||||
| HP 归零立即清档(删除存档文件) | `GameManager.HandlePlayerDeath()` 检查 `DifficultyScalerSO.InstantDeathOnZeroHP` |
|
||||
| 死亡界面显示"钢铁之魂终结"专属 UI | `DeathScreen` 读取 `DifficultyManager.CurrentLevel` 选择显示内容 |
|
||||
| 存档 UI 显示钢铁之魂徽章 | `SaveSlotUI` 读取 `SaveData.DifficultyLevel` |
|
||||
| 不可降级 | `DifficultyManager.Apply()` 中强制校验 |
|
||||
|
||||
**SteelSoul 死亡流程(GameManager)**:
|
||||
|
||||
```csharp
|
||||
// GameManager.HandlePlayerDeath()(伪码)
|
||||
if (DifficultyManager.Instance.CurrentScaler.InstantDeathOnZeroHP)
|
||||
{
|
||||
// 1. 黑屏淡出(CameraStateController 广播黑屏事件)
|
||||
_onPlayerDied.Raise();
|
||||
|
||||
// 2. 等待动画结束(UniTask)
|
||||
await UniTask.Delay(TimeSpan.FromSeconds(2f), cancellationToken: _cts.Token);
|
||||
|
||||
// 3. 删除存档文件
|
||||
SaveManager.Instance.DeleteSave(SaveManager.Instance.CurrentSlotIndex);
|
||||
|
||||
// 4. 返回主菜单(SceneLoader)
|
||||
SceneLoader.Instance.LoadScene("MainMenu");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 普通死亡:显示死亡 UI,等待复活
|
||||
_onPlayerDied.Raise();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. SaveData 集成
|
||||
|
||||
`SaveData.DifficultyLevel`(`int`,存枚举原始值)在 `GameManager.LoadGame` 后调用:
|
||||
|
||||
```csharp
|
||||
DifficultyManager.Instance.Apply((DifficultyLevel)saveData.DifficultyLevel);
|
||||
```
|
||||
|
||||
新游戏开始时(角色创建界面选择难度后)同样调用 `Apply()`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 事件频道
|
||||
|
||||
| 频道 SO | Payload | 发布者 | 订阅者 |
|
||||
|--------|---------|--------|--------|
|
||||
| `EVT_DifficultyChanged` | `DifficultyScalerSO` | `DifficultyManager` | `PlayerStats`、`EnemyStats`(动态调整)、`HUDController`(刷新难度标识) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 资产路径
|
||||
|
||||
```
|
||||
Assets/ScriptableObjects/Core/
|
||||
└── Difficulty/
|
||||
├── Difficulty_Easy.asset
|
||||
├── Difficulty_Normal.asset
|
||||
├── Difficulty_Hard.asset
|
||||
└── Difficulty_SteelSoul.asset
|
||||
```
|
||||
388
Docs/Architecture/20_ShieldModule.md
Normal file
388
Docs/Architecture/20_ShieldModule.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# 20 · 护盾模块(Shield Module)
|
||||
|
||||
> **命名空间** `BaseGames.Player.Shield`
|
||||
> **程序集** `BaseGames.Player`(并入玩家程序集)
|
||||
> **依赖** `BaseGames.Core.Events` · `BaseGames.Combat`(DamageInfo · HurtBox)· `BaseGames.UI`(HUDController)
|
||||
> **Design 来源** [30_ShieldMechanicsSystem](../Design/30_ShieldMechanicsSystem.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [模块职责](#1-模块职责)
|
||||
2. [伤害管道修正](#2-伤害管道修正)
|
||||
3. [ShieldConfigSO](#3-shieldconfigso)
|
||||
4. [ShieldComponent](#4-shieldcomponent)
|
||||
5. [IShieldable 接口](#5-ishieldable-接口)
|
||||
6. [护盾恢复机制](#6-护盾恢复机制)
|
||||
7. [护盾 UI 集成](#7-护盾-ui-集成)
|
||||
8. [弹反集成(P1)](#8-弹反集成p1)
|
||||
9. [SaveData 集成](#9-savedata-集成)
|
||||
10. [事件频道](#10-事件频道)
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
护盾是独立于玩家 HP 之外的**第二道防御层**:
|
||||
|
||||
```
|
||||
伤害输入(来自 HurtBox)
|
||||
│
|
||||
▼
|
||||
ShieldComponent.AbsorbDamage(incomingDamage)
|
||||
├─ 护盾耐久 > 0 且未破碎
|
||||
│ ├─ 吸收 = damage × DamageAbsorptionRatio
|
||||
│ ├─ 扣除护盾耐久
|
||||
│ ├─ 耐久归零 → 触发护盾破碎(EVT_ShieldBroken)
|
||||
│ └─ 返回穿透量 → HurtBox 直接调用 _damageable.TakeDamage(passInfo)
|
||||
│
|
||||
└─ 护盾已破碎或耐久 = 0
|
||||
└─ HurtBox 走原始伤害流程(直接 TakeDamage)
|
||||
```
|
||||
|
||||
**零耦合**:`ShieldComponent.AbsorbDamage()` 只返回穿透量,不持有 `PlayerStats` 引用。穿透伤害由 `HurtBox.ReceiveDamage()` 负责转交给 `IDamageable.TakeDamage()`(见 §2)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 伤害管道修正
|
||||
|
||||
`HurtBox.ReceiveDamage()` 需要在调用 `IDamageable.TakeDamage` 之前检查护盾:
|
||||
|
||||
```csharp
|
||||
// HurtBox.cs(修改 06_CombatModule §2 的实现)
|
||||
public void ReceiveDamage(DamageInfo info)
|
||||
{
|
||||
if (!_isActive) return;
|
||||
if (_shieldable != null && _shieldable.HasShield)
|
||||
{
|
||||
// 护盾优先拦截:AbsorbDamage 返回穿透伤害剧量
|
||||
int passThrough = _shieldable.AbsorbDamage(info.Amount);
|
||||
if (passThrough > 0)
|
||||
{
|
||||
var passInfo = info;
|
||||
passInfo.Amount = passThrough;
|
||||
_damageable?.TakeDamage(passInfo);
|
||||
}
|
||||
// ShieldComponent 内部已通过事件频道更新 ShieldBarUI
|
||||
return;
|
||||
}
|
||||
// 无护盾或已穿透,走原始流程
|
||||
_damageable?.TakeDamage(info);
|
||||
_onDamageDealt.Raise(info); // EVT_DamageDealt(AnalyticsManager)
|
||||
_onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
|
||||
}
|
||||
```
|
||||
|
||||
`PlayerController` 在 Awake 时将 `ShieldComponent` 注入 `HurtBox._shieldable`:
|
||||
|
||||
```csharp
|
||||
// PlayerController.Awake()
|
||||
_hurtBox.SetShieldable(_shieldComponent);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. ShieldConfigSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Player.Shield
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Player/ShieldConfig")]
|
||||
public class ShieldConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("耐久")]
|
||||
[Min(1)]
|
||||
public int MaxShieldHP = 60;
|
||||
[Range(0f, 1f)]
|
||||
public float DamageAbsorptionRatio = 1.0f; // 1.0 = 完全吸收
|
||||
|
||||
[Header("恢复")]
|
||||
[Min(0f)]
|
||||
public float RechargeDelay = 2.5f; // 最后一次受击后延迟恢复的秒数
|
||||
[Min(0f)]
|
||||
public float RechargeRate = 20f; // 每秒恢复耐久点数
|
||||
public bool FullRechargeOnSavePoint = true; // 激活存档点时护盾立即满值
|
||||
|
||||
[Header("破碎惩罚")]
|
||||
[Min(0f)]
|
||||
public float BrokenPenaltyDuration = 3.0f; // 护盾破碎后无法恢复的时间
|
||||
|
||||
[Header("弹反加成(P1)")]
|
||||
[Range(0f, 1f)]
|
||||
public float ParryRestoreRatio = 0.3f; // 成功格挡时恢复护盾耐久比例
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径**:`Assets/ScriptableObjects/Player/Shield_Config.asset`
|
||||
|
||||
---
|
||||
|
||||
## 4. ShieldComponent
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Player.Shield
|
||||
{
|
||||
/// <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] DifficultyChangedEventChannel _onDifficultyChanged;
|
||||
|
||||
// ── Runtime State ────────────────────────────────────
|
||||
int _currentShieldHP;
|
||||
bool _isBroken;
|
||||
float _timeSinceLastHit;
|
||||
float _brokenTimer;
|
||||
|
||||
// ── IShieldable ─────────────────────────────────────
|
||||
public bool HasShield => _currentShieldHP > 0 && !_isBroken;
|
||||
public int CurrentShieldHP => _currentShieldHP;
|
||||
public int MaxShieldHP => _config.MaxShieldHP;
|
||||
|
||||
void Awake() => _currentShieldHP = _config.MaxShieldHP;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
_onDifficultyChanged.OnEventRaised += OnDifficultyChanged;
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
_onDifficultyChanged.OnEventRaised -= OnDifficultyChanged;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (_isBroken)
|
||||
{
|
||||
_brokenTimer += Time.deltaTime;
|
||||
if (_brokenTimer >= _config.BrokenPenaltyDuration)
|
||||
{
|
||||
_isBroken = false;
|
||||
_brokenTimer = 0f;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentShieldHP < _config.MaxShieldHP)
|
||||
{
|
||||
_timeSinceLastHit += Time.deltaTime;
|
||||
if (_timeSinceLastHit >= _config.RechargeDelay)
|
||||
{
|
||||
_currentShieldHP = Mathf.Min(
|
||||
_config.MaxShieldHP,
|
||||
_currentShieldHP + Mathf.RoundToInt(_config.RechargeRate * Time.deltaTime)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 护盾拦截伤害。由 HurtBox.ReceiveDamage 调用。
|
||||
/// 返回剩余穿透伤害値(0 = 完全吸收)。
|
||||
/// 通过 EVT_ShieldHPChanged 更新 ShieldBarUI,不直接修改 DamageInfo。
|
||||
/// </summary>
|
||||
public int AbsorbDamage(int incomingDamage)
|
||||
{
|
||||
_timeSinceLastHit = 0f;
|
||||
|
||||
int absorbed = Mathf.RoundToInt(incomingDamage * _config.DamageAbsorptionRatio);
|
||||
int passThrough = incomingDamage - absorbed;
|
||||
|
||||
_currentShieldHP -= absorbed;
|
||||
|
||||
if (_currentShieldHP <= 0)
|
||||
{
|
||||
// 护盾破碎:多余伤害穿透
|
||||
passThrough += Mathf.Abs(_currentShieldHP);
|
||||
_currentShieldHP = 0;
|
||||
_isBroken = true;
|
||||
_brokenTimer = 0f;
|
||||
_onShieldBroken.Raise();
|
||||
}
|
||||
|
||||
_onShieldHPChanged.Raise(_currentShieldHP); // 更新 ShieldBarUI
|
||||
|
||||
return passThrough;
|
||||
}
|
||||
|
||||
/// <summary>存档点激活时调用(若配置允许)。</summary>
|
||||
public void FullRecharge()
|
||||
{
|
||||
if (!_config.FullRechargeOnSavePoint) return;
|
||||
_currentShieldHP = _config.MaxShieldHP;
|
||||
_isBroken = false;
|
||||
_brokenTimer = 0f;
|
||||
_onShieldRestored.Raise();
|
||||
}
|
||||
|
||||
/// <summary>存档加载时恢复护盾状态。由 PlayerController.LoadFromSaveData() 调用。</summary>
|
||||
public void SetShieldHP(int hp, bool isBroken)
|
||||
{
|
||||
_currentShieldHP = Mathf.Clamp(hp, 0, _config.MaxShieldHP);
|
||||
_isBroken = isBroken;
|
||||
_brokenTimer = 0f;
|
||||
_timeSinceLastHit = 0f;
|
||||
}
|
||||
|
||||
/// <summary>弹反成功时恢复护盾(P1)。</summary>
|
||||
public void OnParrySuccess()
|
||||
{
|
||||
int restore = Mathf.RoundToInt(_config.MaxShieldHP * _config.ParryRestoreRatio);
|
||||
_currentShieldHP = Mathf.Min(_config.MaxShieldHP, _currentShieldHP + restore);
|
||||
}
|
||||
|
||||
void OnDifficultyChanged(DifficultyScalerSO scaler)
|
||||
{
|
||||
// 难度变更时按比例调整护盾(可选,若 ShieldConfigSO 有难度钩子字段则使用)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. IShieldable 接口
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Player.Shield
|
||||
{
|
||||
/// <summary>
|
||||
/// 可拥有护盾的实体接口。HurtBox 持有此接口引用,在受击时优先检查护盾。
|
||||
/// </summary>
|
||||
public interface IShieldable
|
||||
{
|
||||
bool HasShield { get; }
|
||||
int CurrentShieldHP { get; }
|
||||
int MaxShieldHP { get; }
|
||||
/// <summary>拦截 incomingDamage,返回剩余穿透伤害(0 = 完全吸收)。</summary>
|
||||
int AbsorbDamage(int incomingDamage);
|
||||
void FullRecharge();
|
||||
void OnParrySuccess();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 护盾恢复机制
|
||||
|
||||
| 时机 | 行为 |
|
||||
|------|------|
|
||||
| 最后一次受击后 `RechargeDelay` 秒 | 开始按 `RechargeRate/s` 线性恢复 |
|
||||
| 护盾破碎后 `BrokenPenaltyDuration` 秒 | 破碎状态结束,恢复计时重新开始 |
|
||||
| 激活存档点 | `SavePoint.Activate()` → `ShieldComponent.FullRecharge()`(若配置为 true) |
|
||||
| 弹反成功 | `ParrySystem.OnParrySuccess` → `ShieldComponent.OnParrySuccess()` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 护盾 UI 集成
|
||||
|
||||
护盾 UI 显示在 HUD 的 HP 条上方(或并列):
|
||||
|
||||
```csharp
|
||||
// HUDController 中订阅护盾变化
|
||||
// ShieldBarUI 组件,与 HP Bar 类似设计
|
||||
public class ShieldBarUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] Image _fill;
|
||||
[SerializeField] ShieldComponent _shield;
|
||||
[SerializeField] GameObject _brokenIndicator; // 护盾破碎时显示红色图标
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] IntEventChannelSO _onShieldHPChanged; // 订阅耐久变化
|
||||
[SerializeField] VoidEventChannelSO _onShieldBroken;
|
||||
[SerializeField] VoidEventChannelSO _onShieldRestored;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
_onShieldHPChanged.OnEventRaised += RefreshFill;
|
||||
_onShieldBroken.OnEventRaised += ShowBroken;
|
||||
_onShieldRestored.OnEventRaised += HideBroken;
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
_onShieldHPChanged.OnEventRaised -= RefreshFill;
|
||||
_onShieldBroken.OnEventRaised -= ShowBroken;
|
||||
_onShieldRestored.OnEventRaised -= HideBroken;
|
||||
}
|
||||
|
||||
private void RefreshFill(int currentHP)
|
||||
{
|
||||
_fill.fillAmount = _shield.MaxShieldHP > 0
|
||||
? (float)currentHP / _shield.MaxShieldHP : 0f;
|
||||
}
|
||||
|
||||
private void ShowBroken() => _brokenIndicator.SetActive(true);
|
||||
private void HideBroken() => _brokenIndicator.SetActive(false);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 弹反集成(P1)
|
||||
|
||||
弹反成功时,`ParrySystem.HandleSuccessfulParry()` 末尾调用(见 06_CombatModule §8):
|
||||
|
||||
```csharp
|
||||
if (_controller.TryGetComponent<ShieldComponent>(out var shield))
|
||||
shield.OnParrySuccess();
|
||||
```
|
||||
|
||||
此处 `HandleSuccessfulParry()` 是 `ParrySystem` 中处理弹反成功后统一逻辑的方法,同时负责广播 `_onParrySuccess`(`ParryInfoEventChannelSO`)事件、奖励灵力、触发子弹时间。`ShieldComponent.OnParrySuccess()` 通过直接调用(而非事件订阅)接收通知,以保证执行顺序。
|
||||
|
||||
---
|
||||
|
||||
## 9. SaveData 集成
|
||||
|
||||
`PlayerSaveData` 中新增字段:
|
||||
|
||||
```csharp
|
||||
public int ShieldHP; // 当前护盾耐久(-1 = 使用最大值,即满护盾)
|
||||
public bool ShieldIsBroken; // 是否处于破碎状态
|
||||
```
|
||||
|
||||
**加载时**(`PlayerController.LoadFromSaveData`):
|
||||
```csharp
|
||||
if (saveData.ShieldHP >= 0)
|
||||
_shield.SetShieldHP(saveData.ShieldHP, saveData.ShieldIsBroken);
|
||||
else
|
||||
_shield.FullRecharge();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 事件频道
|
||||
|
||||
| 频道 SO | Payload | 发布者 | 订阅者 |
|
||||
|--------|---------|--------|--------|
|
||||
| `EVT_ShieldHPChanged` | `int`(当前耐久值) | `ShieldComponent` | `ShieldBarUI`(更新护盾条填充) |
|
||||
| `EVT_ShieldBroken` | void | `ShieldComponent` | `PlayerFeedback`(破碎音效/特效)、`HUDController`(护盾破碎 UI) |
|
||||
| `EVT_ShieldRestored` | void | `ShieldComponent` | `HUDController`(护盾恢复 UI) |
|
||||
|
||||
## Player Prefab 层级更新
|
||||
|
||||
```
|
||||
[Player]
|
||||
├── PlayerController.cs
|
||||
│ └── [SerializeField] ShieldComponent _shield ← 新增
|
||||
│
|
||||
├── [Combat]
|
||||
│ ├── HurtBox.cs
|
||||
│ │ └── [SerializeField] IShieldable _shieldable ← 由 PlayerController.Awake() 注入
|
||||
│ └── HitBox.cs
|
||||
│
|
||||
└── [Shield] ← 新增子节点
|
||||
└── ShieldComponent.cs
|
||||
```
|
||||
927
Docs/Architecture/21_LiquidPuzzleModule.md
Normal file
927
Docs/Architecture/21_LiquidPuzzleModule.md
Normal file
@@ -0,0 +1,927 @@
|
||||
# 21 · 液体与谜题模块(Liquid & Puzzle Module)
|
||||
|
||||
> **命名空间** `BaseGames.World.Liquid` / `BaseGames.Puzzle` / `BaseGames.World.Navigation` / `BaseGames.Tutorial`
|
||||
> **程序集** `BaseGames.World`(并入世界程序集)
|
||||
> **依赖** `BaseGames.Core.Events` · `BaseGames.Player`(PlayerController · FSM)· `BaseGames.World`(HazardZone · IInteractable)
|
||||
> **Design 来源** [40_LiquidSwimSystem](../Design/40_LiquidSwimSystem.md) · [35_PuzzleArchitecture](../Design/35_PuzzleArchitecture.md) · [36_NavigationHintSystem](../Design/36_NavigationHintSystem.md) · [45_TutorialSystem](../Design/45_TutorialSystem.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
### Part A — 液体与游泳
|
||||
|
||||
1. [液体系统职责](#1-液体系统职责)
|
||||
2. [LiquidType 枚举](#2-liquidtype-枚举)
|
||||
3. [LiquidPhysicsConfigSO](#3-liquidphysicsconfigso)
|
||||
4. [LiquidZone](#4-liquidzone)
|
||||
5. [SwimState(FSM 状态)](#5-swimstate-fsm-状态)
|
||||
6. [玩家进出液体流程](#6-玩家进出液体流程)
|
||||
|
||||
### Part B — 谜题架构
|
||||
|
||||
7. [谜题系统职责](#7-谜题系统职责)
|
||||
8. [核心接口](#8-核心接口)
|
||||
9. [PuzzleSwitch](#9-puzzleswitch)
|
||||
10. [PuzzleReceiver](#10-puzzlereceiver)
|
||||
11. [PuzzleWire](#11-puzzlewire)
|
||||
12. [事件频道](#12-事件频道)
|
||||
|
||||
### Part C — 导航提示与教程
|
||||
|
||||
13. [导航提示系统职责(§NavHint)](#13-导航提示系统职责-navhint)
|
||||
14. [WorldMarker](#14-worldmarker)
|
||||
15. [BreadcrumbTracker](#15-breadcrumbtracker)
|
||||
16. [教程系统职责(§Tutorial)](#16-教程系统职责-tutorial)
|
||||
17. [TutorialManager](#17-tutorialmanager)
|
||||
18. [ContextualHintTrigger](#18-contextualhinttrigger)
|
||||
|
||||
---
|
||||
|
||||
## Part A — 液体与游泳
|
||||
|
||||
---
|
||||
|
||||
## 1. 液体系统职责
|
||||
|
||||
```
|
||||
液体系统职责:
|
||||
├─ LiquidType enum → Water / Acid / Lava
|
||||
├─ LiquidPhysicsConfigSO → 浮力、水下速度、进出溅水参数
|
||||
├─ LiquidZone → 标记液态区域、触发进出事件
|
||||
└─ SwimState → PlayerController FSM 中的游泳状态
|
||||
```
|
||||
|
||||
**零耦合**:`LiquidZone` 通过 SO 事件频道广播进出事件,`PlayerController` 订阅后自行切换 FSM 状态。
|
||||
|
||||
---
|
||||
|
||||
## 2. LiquidType 枚举
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
public enum LiquidType
|
||||
{
|
||||
Water, // 可游泳(需 swim 能力)
|
||||
ShallowWater, // 浅水(水中慢走,无需游泳能力,速度 ×0.65)
|
||||
Mud, // 泥水(移动极慢,无需游泳能力,速度 ×0.50)
|
||||
Acid, // 接触即死(HazardZone 处理)
|
||||
Lava, // 接触即死(HazardZone 处理)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. LiquidPhysicsConfigSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
[CreateAssetMenu(menuName = "World/LiquidPhysicsConfig")]
|
||||
public class LiquidPhysicsConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("水下物理")]
|
||||
[Range(0f, 1f)]
|
||||
public float GravityScale = 0.3f; // 水下重力系数(越小越漂浮)
|
||||
[Range(0f, 1f)]
|
||||
public float BuoyancyForce = 0.5f; // 上浮力(每帧施加的向上力)
|
||||
public float MaxSwimSpeed = 4.0f; // 最大游泳速度 (m/s)
|
||||
public float SwimAcceleration = 8.0f; // 游泳加速度
|
||||
public float SurfaceExitSpeed = 5.0f; // 跃出水面时的冲量
|
||||
public float SinkSpeed = 2.0f; // 无游泳能力时自然下沉速度 (m/s)
|
||||
public float DiveSpeedMultiplier = 1.5f; // 主动下潜时的速度倍率
|
||||
|
||||
[Header("浅水/泥水速度缩放")]
|
||||
[Range(0.1f, 1.0f)]
|
||||
public float ShallowSpeedScale = 0.65f; // ShallowWater 类型水平移动泥幕
|
||||
[Range(0.1f, 1.0f)]
|
||||
public float MudSpeedScale = 0.50f; // Mud 类型水平移动泥幕
|
||||
|
||||
[Header("溺死计时(无游泳能力时)")]
|
||||
public float DrownTime = 3.0f; // 屏气倒计时(秒),倒计时结束则触发死亡
|
||||
|
||||
[Header("进出液体")]
|
||||
public float SplashEntryDelay = 0.05f; // 溅水特效延迟(配合动画)
|
||||
public float DragCoefficient = 3.0f; // 水下阻力系数(减缓水平移动)
|
||||
|
||||
[Header("视觉")]
|
||||
public VolumeProfile WaterVolumeProfile; // 水下后处理 Profile(可为 null)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**资产路径**:`Assets/ScriptableObjects/World/Liquid_Physics_Config.asset`
|
||||
|
||||
---
|
||||
|
||||
## 4. LiquidZone
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在液态区域根 GameObject 上。
|
||||
/// 子物件 [Surface] 的水面触发器触发溅水;[Body] 的主触发器触发进出事件。
|
||||
/// 酸液/熔岩时需同时挂载 HazardZone(InstantKill 类型)。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class LiquidZone : MonoBehaviour
|
||||
{
|
||||
[Header("液体类型")]
|
||||
[SerializeField] LiquidType _liquidType = LiquidType.Water;
|
||||
|
||||
[Header("伤害(Water 类型专用,Acid/Lava 由 HazardZone 处理)")]
|
||||
/// <summary>
|
||||
/// 未解锁 Swim 能力时,玩家在 Water 中是否持续受到溺水伤害。
|
||||
/// Acid/Lava 类型的即死效果由子节点 HazardZone.cs (InstantKill) 处理,与此字段无关。
|
||||
/// </summary>
|
||||
[SerializeField] bool _dealsDrowningDamage = false;
|
||||
[SerializeField] float _drowningDamagePerSecond = 5f; // 每秒扣减 HP
|
||||
|
||||
[Header("物理配置")]
|
||||
[SerializeField] LiquidPhysicsConfigSO _physicsConfig;
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] LiquidEventChannelSO _onPlayerEntered;
|
||||
[SerializeField] LiquidEventChannelSO _onPlayerExited;
|
||||
|
||||
[Header("视觉反馈")]
|
||||
[SerializeField] MMF_Player _splashEnterFeedback;
|
||||
[SerializeField] MMF_Player _splashExitFeedback;
|
||||
|
||||
public LiquidType Type => _liquidType;
|
||||
public LiquidPhysicsConfigSO Physics => _physicsConfig;
|
||||
|
||||
void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
_splashEnterFeedback?.PlayFeedbacks();
|
||||
_onPlayerEntered.Raise(this);
|
||||
}
|
||||
|
||||
void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
_splashExitFeedback?.PlayFeedbacks();
|
||||
_onPlayerExited.Raise(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### LiquidZone Prefab 层级
|
||||
|
||||
```
|
||||
[LiquidZone_River_01]
|
||||
├── SpriteRenderer(水体精灵,带流动 Shader)
|
||||
├── PolygonCollider2D (IsTrigger) ← 主区域触发器
|
||||
├── LiquidZone.cs
|
||||
├── [Surface]
|
||||
│ ├── BoxCollider2D (IsTrigger, 高度 ~4px)
|
||||
│ └── WaterSurfaceEffect.cs ← 溅水粒子 + 音效
|
||||
└── [Hazard](仅酸液/熔岩时存在)
|
||||
└── HazardZone.cs (InstantKill)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. SwimState(FSM 状态)
|
||||
|
||||
在 `05_PlayerModule.md` §12 状态列表中补充的第 18 个状态:
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Player.States
|
||||
{
|
||||
/// <summary>
|
||||
/// 游泳状态:玩家在液体中时使用。
|
||||
/// 需要 AbilityType.Swim 已解锁;若未解锁则自动切换到溺水/死亡流程。
|
||||
/// </summary>
|
||||
public class SwimState : PlayerStateBase
|
||||
{
|
||||
[SerializeField] LiquidPhysicsConfigSO _physics; // 由 LiquidZone 注入
|
||||
[SerializeField] ClipTransition _swimIdleClip;
|
||||
[SerializeField] ClipTransition _swimMoveClip;
|
||||
|
||||
LiquidZone _currentZone;
|
||||
float _originalGravity;
|
||||
|
||||
public void SetLiquidZone(LiquidZone zone) => _currentZone = zone;
|
||||
|
||||
public override void OnEnter()
|
||||
{
|
||||
_originalGravity = RB.gravityScale;
|
||||
RB.gravityScale = _currentZone?.Physics.GravityScale ?? 0.3f;
|
||||
Animancer.Play(_swimIdleClip);
|
||||
}
|
||||
|
||||
public override void OnExit()
|
||||
{
|
||||
RB.gravityScale = _originalGravity;
|
||||
}
|
||||
|
||||
public override void OnUpdate()
|
||||
{
|
||||
var input = Input.Move;
|
||||
if (input != Vector2.zero)
|
||||
{
|
||||
var targetVel = input * (_currentZone?.Physics.MaxSwimSpeed ?? 4f);
|
||||
RB.linearVelocity = Vector2.MoveTowards(
|
||||
RB.linearVelocity, targetVel,
|
||||
(_currentZone?.Physics.SwimAcceleration ?? 8f) * Time.deltaTime
|
||||
);
|
||||
Animancer.Play(_swimMoveClip);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 水下浮力(持续向上的微弱力)
|
||||
RB.AddForce(Vector2.up * (_currentZone?.Physics.BuoyancyForce ?? 0.5f),
|
||||
ForceMode2D.Force);
|
||||
Animancer.Play(_swimIdleClip);
|
||||
}
|
||||
|
||||
// 跳跃键 = 跃出水面
|
||||
if (Input.JumpPressed)
|
||||
{
|
||||
RB.AddForce(Vector2.up * (_currentZone?.Physics.SurfaceExitSpeed ?? 5f),
|
||||
ForceMode2D.Impulse);
|
||||
}
|
||||
|
||||
// 施加水阻
|
||||
RB.linearVelocity *= 1f - _currentZone?.Physics.DragCoefficient * Time.deltaTime ?? 0f;
|
||||
}
|
||||
|
||||
public override PlayerStateBase GetNextState()
|
||||
{
|
||||
// 离开液体区域由 PlayerController 订阅 EVT_LiquidExited 后切换
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 玩家进出液体流程
|
||||
|
||||
```
|
||||
玩家碰到 LiquidZone.PolygonCollider2D
|
||||
│
|
||||
▼
|
||||
LiquidZone.OnTriggerEnter2D
|
||||
→ EVT_LiquidEntered.Raise(liquidZone)
|
||||
│
|
||||
▼
|
||||
PlayerController(订阅 EVT_LiquidEntered)
|
||||
├─ 检查 abilities.swim == true
|
||||
│ ├─ true → swimState.SetLiquidZone(zone)
|
||||
│ │ FSM.TransitionTo(swimState)
|
||||
│ └─ false → 检查 liquidType
|
||||
│ Water → 无法游泳,自然沉底;若 zone._dealsDrowningDamage == true,
|
||||
│ 每帧通过 DamageInfo(DamageTag: Drowning)对 PlayerStats
|
||||
│ 施加 zone._drowningDamagePerSecond 伤害(忽略无敌帧)
|
||||
│ Acid/Lava → HazardZone 已处理 InstantKill(与 _dealsDrowningDamage 无关)
|
||||
│
|
||||
玩家离开 LiquidZone
|
||||
→ EVT_LiquidExited.Raise(liquidZone)
|
||||
│
|
||||
▼
|
||||
PlayerController → FSM.TransitionTo(fallState / idleState)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part B — 谜题架构
|
||||
|
||||
---
|
||||
|
||||
## 7. 谜题系统职责
|
||||
|
||||
```
|
||||
谜题架构职责:
|
||||
├─ ISwitchable → 可被切换激活/停用的物件接口
|
||||
├─ IMovable → 可被玩家推动的物件接口
|
||||
├─ IActivatable → 接受激活信号的物件接口
|
||||
├─ PuzzleSwitch → 通用开关(玩家交互/踩踏触发)
|
||||
├─ PuzzleReceiver → 接收器(门/平台/机关挂载)
|
||||
└─ PuzzleWire → 连接 Switch → Receiver,支持 AND/OR/XOR 逻辑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 核心接口
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Puzzle
|
||||
{
|
||||
/// <summary>任何可被切换激活/停用状态的谜题元素。</summary>
|
||||
public interface ISwitchable
|
||||
{
|
||||
bool IsActive { get; }
|
||||
event Action<bool> OnStateChanged;
|
||||
void ForceState(bool active); // SaveData 恢复时调用
|
||||
}
|
||||
|
||||
/// <summary>可被玩家推动的物件(需 Rigidbody2D)。</summary>
|
||||
public interface IMovable
|
||||
{
|
||||
bool CanBePushed { get; }
|
||||
void OnPushStart(Vector2 direction);
|
||||
void OnPushEnd();
|
||||
}
|
||||
|
||||
/// <summary>接受激活信号后改变自身状态的物件。</summary>
|
||||
public interface IActivatable
|
||||
{
|
||||
void Activate();
|
||||
void Deactivate();
|
||||
bool IsActivated { get; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. PuzzleSwitch
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Puzzle
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用谜题开关,支持三种触发模式。
|
||||
/// 实现 ISwitchable + IInteractable(玩家手动触发)。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class PuzzleSwitch : MonoBehaviour, ISwitchable, IInteractable
|
||||
{
|
||||
[Header("触发模式")]
|
||||
[SerializeField] SwitchTriggerMode _mode = SwitchTriggerMode.InteractOnce;
|
||||
|
||||
[Header("状态")]
|
||||
[SerializeField] bool _startsActive = false;
|
||||
[SerializeField] string _switchId; // 持久化唯一 ID(存档用,空串则不持久化)
|
||||
|
||||
[Header("视觉")]
|
||||
[SerializeField] AnimancerComponent _animancer; // 开关动画(On/Off 状态)
|
||||
[SerializeField] AnimationClip _activeClip;
|
||||
[SerializeField] AnimationClip _inactiveClip;
|
||||
[SerializeField] MMF_Player _activateFeedback;
|
||||
|
||||
bool _isActive;
|
||||
public bool IsActive => _isActive;
|
||||
public event Action<bool> OnStateChanged;
|
||||
|
||||
void Start() => _isActive = _startsActive;
|
||||
|
||||
// IInteractable
|
||||
public string InteractPrompt => _mode == SwitchTriggerMode.Hold ? "按住交互" : "交互";
|
||||
public bool CanInteract => true;
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (_mode == SwitchTriggerMode.InteractOnce && _isActive) return;
|
||||
SetState(!_isActive);
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
// ISwitchable
|
||||
public void ForceState(bool active) => SetState(active);
|
||||
|
||||
// 压板模式:OnTriggerEnter2D / OnTriggerExit2D
|
||||
void OnTriggerEnter2D(Collider2D col)
|
||||
{
|
||||
if (_mode != SwitchTriggerMode.Pressure) return;
|
||||
if (col.CompareTag("Player") || col.CompareTag("PushBox"))
|
||||
SetState(true);
|
||||
}
|
||||
|
||||
void OnTriggerExit2D(Collider2D col)
|
||||
{
|
||||
if (_mode != SwitchTriggerMode.Pressure) return;
|
||||
if (col.CompareTag("Player") || col.CompareTag("PushBox"))
|
||||
SetState(false);
|
||||
}
|
||||
|
||||
void SetState(bool active)
|
||||
{
|
||||
if (_isActive == active) return;
|
||||
_isActive = active;
|
||||
if (active) _animancer?.Play(_activeClip);
|
||||
else _animancer?.Play(_inactiveClip);
|
||||
_activateFeedback?.PlayFeedbacks();
|
||||
OnStateChanged?.Invoke(active);
|
||||
// 持久化到 WorldStateRegistry(跨场景/存档恢复开关状态)
|
||||
if (!string.IsNullOrEmpty(_switchId))
|
||||
WorldStateRegistry.Instance.SetFlag("switch_" + _switchId, active);
|
||||
}
|
||||
}
|
||||
|
||||
public enum SwitchTriggerMode
|
||||
{
|
||||
InteractOnce, // 玩家交互一次,永久激活
|
||||
InteractToggle, // 玩家交互切换开关
|
||||
Pressure, // 踩上激活,离开停用
|
||||
Hold, // 按住交互键持续激活
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. PuzzleReceiver
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Puzzle
|
||||
{
|
||||
/// <summary>
|
||||
/// 谜题接收器,由 PuzzleWire 驱动。
|
||||
/// 挂在谜题目标物件上(门/平台等),实现 IActivatable。
|
||||
/// </summary>
|
||||
public class PuzzleReceiver : MonoBehaviour, IActivatable
|
||||
{
|
||||
[SerializeField] bool _startsActivated = false;
|
||||
[SerializeField] string _receiverId; // 持久化唯一 ID(存档用,空串则不持久化)
|
||||
[SerializeField] MMF_Player _activateFeedback;
|
||||
[SerializeField] MMF_Player _deactivateFeedback;
|
||||
|
||||
bool _isActivated;
|
||||
public bool IsActivated => _isActivated;
|
||||
|
||||
void Start()
|
||||
{
|
||||
_isActivated = _startsActivated;
|
||||
if (_isActivated) Activate();
|
||||
}
|
||||
|
||||
public void Activate()
|
||||
{
|
||||
if (_isActivated) return;
|
||||
_isActivated = true;
|
||||
_activateFeedback?.PlayFeedbacks();
|
||||
OnActivate();
|
||||
if (!string.IsNullOrEmpty(_receiverId))
|
||||
WorldStateRegistry.Instance.SetFlag("receiver_" + _receiverId, true);
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
if (!_isActivated) return;
|
||||
_isActivated = false;
|
||||
_deactivateFeedback?.PlayFeedbacks();
|
||||
OnDeactivate();
|
||||
if (!string.IsNullOrEmpty(_receiverId))
|
||||
WorldStateRegistry.Instance.SetFlag("receiver_" + _receiverId, false);
|
||||
}
|
||||
|
||||
// 子类覆写具体行为(门打开、平台移动等)
|
||||
protected virtual void OnActivate() { }
|
||||
protected virtual void OnDeactivate() { }
|
||||
}
|
||||
|
||||
// 常见子类示例
|
||||
public class PuzzleDoor : PuzzleReceiver
|
||||
{
|
||||
[SerializeField] AnimancerComponent _animancer;
|
||||
[SerializeField] AnimationClip _openClip;
|
||||
[SerializeField] AnimationClip _closeClip;
|
||||
|
||||
protected override void OnActivate() => _animancer.Play(_openClip);
|
||||
protected override void OnDeactivate() => _animancer.Play(_closeClip);
|
||||
}
|
||||
public class MovingPlatform : PuzzleReceiver { /* DOTween 路径移动 */ }
|
||||
public class PuzzleSpikeTrap : PuzzleReceiver { /* 启用/禁用 HazardZone */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. PuzzleWire
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Puzzle
|
||||
{
|
||||
/// <summary>
|
||||
/// 连接一个或多个 PuzzleSwitch 到 PuzzleReceiver。
|
||||
/// 支持 AND / OR / XOR 激活逻辑。
|
||||
/// 关卡设计师在 Inspector 中配置,无需编写代码。
|
||||
/// </summary>
|
||||
public class PuzzleWire : MonoBehaviour
|
||||
{
|
||||
[Header("输入开关")]
|
||||
[SerializeField] PuzzleSwitch[] _switches;
|
||||
|
||||
[Header("激活逻辑")]
|
||||
[SerializeField] LogicType _logic = LogicType.AND;
|
||||
|
||||
[Header("目标接收器")]
|
||||
[SerializeField] PuzzleReceiver _receiver;
|
||||
|
||||
void Start()
|
||||
{
|
||||
foreach (var sw in _switches)
|
||||
sw.OnStateChanged += _ => Evaluate();
|
||||
Evaluate(); // 初始求值
|
||||
}
|
||||
|
||||
void Evaluate()
|
||||
{
|
||||
bool shouldActivate = _logic switch
|
||||
{
|
||||
LogicType.AND => System.Array.TrueForAll(_switches, s => s.IsActive),
|
||||
LogicType.OR => System.Array.Exists(_switches, s => s.IsActive),
|
||||
LogicType.XOR => _switches.Count(s => s.IsActive) % 2 == 1,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (shouldActivate) _receiver.Activate();
|
||||
else _receiver.Deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
public enum LogicType { AND, OR, XOR }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. WaterDangerState — 溺水倒计时
|
||||
|
||||
当玩家进入 `Water` 类型液体且**未解锁游泳能力**时,触发溺水危险状态:
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在 PlayerController 子节点 [WaterDanger] 上。
|
||||
/// 由 LiquidZone 的 EVT_LiquidEntered 触发,在无游泳能力时开始倒计时。
|
||||
/// </summary>
|
||||
public class WaterDangerState : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private LiquidPhysicsConfigSO _config;
|
||||
[SerializeField] private AbilityInventorySO _abilityInventory; // 检查 swim 能力
|
||||
[SerializeField] private FloatEventChannelSO _onDrownProgress; // 0~1 倒计时进度(HUD 用)
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDrowned; // 触发死亡
|
||||
|
||||
private float _drownTimer;
|
||||
private bool _isActive;
|
||||
|
||||
public void OnEnterLiquid(LiquidZone zone)
|
||||
{
|
||||
if (zone.Type != LiquidType.Water) return;
|
||||
if (_abilityInventory.HasAbility(AbilityType.Swim)) return;
|
||||
_isActive = true;
|
||||
_drownTimer = _config.DrownTime;
|
||||
}
|
||||
|
||||
public void OnExitLiquid()
|
||||
{
|
||||
_isActive = false;
|
||||
_drownTimer = _config.DrownTime;
|
||||
_onDrownProgress.Raise(0f);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!_isActive) return;
|
||||
_drownTimer -= Time.deltaTime;
|
||||
_onDrownProgress.Raise(1f - (_drownTimer / _config.DrownTime));
|
||||
|
||||
if (_drownTimer <= 0f)
|
||||
{
|
||||
_isActive = false;
|
||||
_onPlayerDrowned.Raise();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. UnderwaterPostProcessingController
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.World.Liquid
|
||||
{
|
||||
/// <summary>
|
||||
/// 控制水下全屏后处理效果(颜色滤镜、色差、暗角)。
|
||||
/// 订阅 EVT_LiquidEntered / EVT_LiquidExited 事件,启用/停用 Water Volume Profile。
|
||||
/// </summary>
|
||||
public class UnderwaterPostProcessingController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Volume _underwaterVolume; // 水下专属 Volume
|
||||
[SerializeField] private float _blendInDuration = 0.3f;
|
||||
[SerializeField] private float _blendOutDuration = 0.3f;
|
||||
|
||||
[Header("Event Channels")]
|
||||
[SerializeField] private LiquidZoneEventChannelSO _onLiquidEntered;
|
||||
[SerializeField] private VoidEventChannelSO _onLiquidExited;
|
||||
|
||||
private Coroutine _blendCoroutine;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onLiquidEntered.OnEventRaised += OnLiquidEntered;
|
||||
_onLiquidExited.OnEventRaised += OnLiquidExited;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_onLiquidEntered.OnEventRaised -= OnLiquidEntered;
|
||||
_onLiquidExited.OnEventRaised -= OnLiquidExited;
|
||||
}
|
||||
|
||||
private void OnLiquidEntered(LiquidZone zone)
|
||||
{
|
||||
if (zone.Type != LiquidType.Water) return;
|
||||
BlendVolume(1f, _blendInDuration);
|
||||
}
|
||||
|
||||
private void OnLiquidExited()
|
||||
{
|
||||
BlendVolume(0f, _blendOutDuration);
|
||||
}
|
||||
|
||||
private void BlendVolume(float target, float duration)
|
||||
{
|
||||
if (_blendCoroutine != null) StopCoroutine(_blendCoroutine);
|
||||
_blendCoroutine = StartCoroutine(BlendRoutine(target, duration));
|
||||
}
|
||||
|
||||
private IEnumerator BlendRoutine(float target, float duration)
|
||||
{
|
||||
float start = _underwaterVolume.weight;
|
||||
float elapsed = 0f;
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
_underwaterVolume.weight = Mathf.Lerp(start, target, elapsed / duration);
|
||||
yield return null;
|
||||
}
|
||||
_underwaterVolume.weight = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 事件频道
|
||||
|
||||
| 频道 SO | Payload | 发布者 | 订阅者 |
|
||||
|--------|---------|--------|--------|
|
||||
| `EVT_LiquidEntered` | `LiquidZone` | `LiquidZone` | `PlayerController`(切换 SwimState)、`WaterDangerState`、`UnderwaterPostProcessingController` |
|
||||
| `EVT_LiquidExited` | `void` | `LiquidZone` | `PlayerController`(退出 SwimState)、`WaterDangerState`、`UnderwaterPostProcessingController` |
|
||||
| `EVT_DrownProgress` | `float(0~1)` | `WaterDangerState` | `HUDController`(显示溺水进度条) |
|
||||
| `EVT_PlayerDrowned` | `void` | `WaterDangerState` | `GameManager`(触发死亡流程) |
|
||||
|
||||
> **⚠️ 谜题状态持久化说明**:PuzzleSwitch / PuzzleReceiver 使用 **直接调用** `WorldStateRegistry.Instance.SetFlag()` 记录持久状态(同 DestructibleTile 模式),而非 SO 事件频道。SO 事件频道仅用于跨模块的松耦合通知,不适用于纯持久化场景。
|
||||
|
||||
---
|
||||
|
||||
## Part C — 导航提示与教程
|
||||
|
||||
---
|
||||
|
||||
## 13. 导航提示系统职责(§NavHint)
|
||||
|
||||
```
|
||||
导航提示系统职责:
|
||||
├─ WorldMarker → 场景内的标记点,用于地图/HUD 指引
|
||||
└─ BreadcrumbTracker → 记录玩家行进路径,辅助引导迷路玩家
|
||||
```
|
||||
|
||||
**零耦合**:`WorldMarker` 通过 SO 事件频道向 `HUDController`/`MapManager` 报告标记状态;`BreadcrumbTracker` 仅写本地数据,UI 层订阅读取。
|
||||
|
||||
---
|
||||
|
||||
## 14. WorldMarker
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.World.Navigation
|
||||
{
|
||||
/// <summary>
|
||||
/// 场景内导航标记点。
|
||||
/// 可标记为目标地点、NPC 位置、兴趣点等,通过 EVT_WorldMarkerUpdated 广播给地图/HUD。
|
||||
/// </summary>
|
||||
public class WorldMarker : MonoBehaviour
|
||||
{
|
||||
[Header("标记信息")]
|
||||
[SerializeField] string _markerId; // 唯一 ID(与 MapDataSO 匹配)
|
||||
[SerializeField] WorldMarkerType _markerType; // 类型(见枚举)
|
||||
[SerializeField] string _labelKey; // 本地化显示名称 key
|
||||
|
||||
[Header("可见性")]
|
||||
[SerializeField] bool _visibleOnMap = true;
|
||||
[SerializeField] bool _visibleOnHUD = false; // 在 HUD 显示箭头指引
|
||||
|
||||
[Header("事件频道")]
|
||||
[SerializeField] WorldMarkerEventChannelSO _onMarkerActivated;
|
||||
[SerializeField] WorldMarkerEventChannelSO _onMarkerDeactivated;
|
||||
|
||||
bool _isActive = false;
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (_visibleOnMap || _visibleOnHUD)
|
||||
Activate();
|
||||
}
|
||||
|
||||
public void Activate()
|
||||
{
|
||||
_isActive = true;
|
||||
_onMarkerActivated?.Raise(this);
|
||||
}
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
_isActive = false;
|
||||
_onMarkerDeactivated?.Raise(this);
|
||||
}
|
||||
|
||||
public string MarkerId => _markerId;
|
||||
public WorldMarkerType MarkerType => _markerType;
|
||||
public string LabelKey => _labelKey;
|
||||
public bool IsActive => _isActive;
|
||||
public bool VisibleOnHUD => _visibleOnHUD;
|
||||
}
|
||||
|
||||
public enum WorldMarkerType
|
||||
{
|
||||
Objective, // 当前主线目标
|
||||
NPC, // NPC 位置
|
||||
PointOfInterest,// 兴趣点
|
||||
Exit, // 出口/传送点
|
||||
Secret, // 隐藏区域(解锁后显示)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. BreadcrumbTracker
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.World.Navigation
|
||||
{
|
||||
/// <summary>
|
||||
/// 追踪玩家最近的行进路径(面包屑)。
|
||||
/// 用于辅助迷路玩家找到回头路;数据不持久化(每次游戏重置)。
|
||||
/// </summary>
|
||||
public class BreadcrumbTracker : MonoBehaviour
|
||||
{
|
||||
[Header("追踪参数")]
|
||||
[SerializeField] float _recordInterval = 2.0f; // 每隔多少秒记录一次位置
|
||||
[SerializeField] int _maxCrumbs = 20; // 最多保留多少个历史位置
|
||||
[SerializeField] float _minMoveDistance = 1.0f; // 移动距离低于此值不记录
|
||||
|
||||
readonly Queue<Vector2> _crumbs = new();
|
||||
float _timer = 0f;
|
||||
Vector2 _lastPos;
|
||||
|
||||
public IReadOnlyCollection<Vector2> Crumbs => _crumbs;
|
||||
|
||||
void Start()
|
||||
{
|
||||
_lastPos = transform.position;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
_timer += Time.deltaTime;
|
||||
if (_timer < _recordInterval) return;
|
||||
_timer = 0f;
|
||||
|
||||
Vector2 current = transform.position;
|
||||
if (Vector2.Distance(current, _lastPos) < _minMoveDistance) return;
|
||||
|
||||
_crumbs.Enqueue(current);
|
||||
if (_crumbs.Count > _maxCrumbs)
|
||||
_crumbs.Dequeue();
|
||||
|
||||
_lastPos = current;
|
||||
}
|
||||
|
||||
/// <summary>获取最近 N 个面包屑位置(用于地图渲染)。</summary>
|
||||
public Vector2[] GetRecentCrumbs(int count)
|
||||
=> System.Linq.Enumerable.TakeLast(_crumbs, count).ToArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 教程系统职责(§Tutorial)
|
||||
|
||||
```
|
||||
教程系统职责:
|
||||
├─ TutorialManager → 追踪已完成的教程步骤,驱动提示显示/隐藏
|
||||
└─ ContextualHintTrigger → 场景中的教程触发器,条件满足时激活提示
|
||||
```
|
||||
|
||||
**显示策略**:提示只显示一次(`TutorialManager` 持久化已完成 ID),同一提示触发后不再重复显示。
|
||||
|
||||
---
|
||||
|
||||
## 17. TutorialManager
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Tutorial
|
||||
{
|
||||
/// <summary>
|
||||
/// 管理所有教程提示的显示/完成状态,挂在 Persistent 场景 [GameManagers] 下。
|
||||
/// </summary>
|
||||
public class TutorialManager : MonoBehaviour, ISaveable
|
||||
{
|
||||
[SerializeField] TutorialHintUI _hintUI; // HUD 上的提示 UI 组件
|
||||
|
||||
readonly HashSet<string> _completedHints = new();
|
||||
|
||||
public static TutorialManager Instance { get; private set; }
|
||||
|
||||
void Awake() => Instance = this;
|
||||
|
||||
/// <summary>显示提示。若已完成则跳过。</summary>
|
||||
public void ShowHint(string hintId, string localizedText, float duration = 3f)
|
||||
{
|
||||
if (_completedHints.Contains(hintId)) return;
|
||||
_hintUI.Show(localizedText, duration);
|
||||
}
|
||||
|
||||
/// <summary>标记提示为已完成,不再显示。</summary>
|
||||
public void CompleteHint(string hintId)
|
||||
{
|
||||
_completedHints.Add(hintId);
|
||||
}
|
||||
|
||||
public bool IsCompleted(string hintId) => _completedHints.Contains(hintId);
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────
|
||||
public void OnSave(SaveData data)
|
||||
{
|
||||
data.Tutorial.CompletedHintIds = new List<string>(_completedHints);
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData data)
|
||||
{
|
||||
_completedHints.Clear();
|
||||
if (data.Tutorial?.CompletedHintIds != null)
|
||||
foreach (var id in data.Tutorial.CompletedHintIds)
|
||||
_completedHints.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. ContextualHintTrigger
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Tutorial
|
||||
{
|
||||
/// <summary>
|
||||
/// 场景内的教程触发器。
|
||||
/// 玩家进入触发区域时,向 TutorialManager 请求显示对应提示。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class ContextualHintTrigger : MonoBehaviour
|
||||
{
|
||||
[Header("提示配置")]
|
||||
[SerializeField] string _hintId; // 唯一 ID,对应 TutorialManager 的完成记录
|
||||
[SerializeField] string _hintTextKey; // 本地化 key(通过 LocalizationManager 解析)
|
||||
[SerializeField] float _displayDuration = 3f;
|
||||
|
||||
[Header("触发条件(可选)")]
|
||||
// ⚠️ AbilityType 枚举(Architecture 09 §1)无 None 值;用 bool 标记是否要求能力
|
||||
[SerializeField] bool _requiresAbility = false;
|
||||
[SerializeField] AbilityType _requiredAbility;
|
||||
[SerializeField] bool _onlyOnce = true; // 只触发一次(建议保持 true)
|
||||
|
||||
void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (!other.CompareTag("Player")) return;
|
||||
|
||||
// 检查能力条件(仅当 _requiresAbility = true 时)
|
||||
if (_requiresAbility)
|
||||
{
|
||||
var stats = other.GetComponent<PlayerStats>();
|
||||
if (stats == null || !stats.HasAbility(_requiredAbility)) return;
|
||||
}
|
||||
|
||||
var text = LocalizationManager.Get(LocalizationManager.Table_UI, _hintTextKey);
|
||||
TutorialManager.Instance.ShowHint(_hintId, text, _displayDuration);
|
||||
|
||||
if (_onlyOnce)
|
||||
{
|
||||
TutorialManager.Instance.CompleteHint(_hintId);
|
||||
gameObject.SetActive(false); // 触发后禁用自身,避免重复
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
746
Docs/Architecture/22_QuestChallengeModule.md
Normal file
746
Docs/Architecture/22_QuestChallengeModule.md
Normal file
@@ -0,0 +1,746 @@
|
||||
# 22 · 任务与挑战房间模块(Quest & Challenge Module)
|
||||
|
||||
> **命名空间** `BaseGames.Quest` / `BaseGames.Challenge`
|
||||
> **程序集** `BaseGames.Quest`(`Assets/Scripts/Quest/`)
|
||||
> **依赖** `BaseGames.Core.Events` · `BaseGames.Dialogue`(InteractableNPC)· `BaseGames.World`(SaveManager · SceneLoader)· `BaseGames.Player`(PlayerStats)
|
||||
> **Design 来源** [38_QuestSystem](../Design/38_QuestSystem.md) · [39_ChallengeRoomSystem](../Design/39_ChallengeRoomSystem.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
### Part A — 任务系统
|
||||
|
||||
1. [任务系统职责](#1-任务系统职责)
|
||||
2. [QuestSO](#2-questso)
|
||||
3. [QuestObjectiveSO](#3-questobjectiveso)
|
||||
4. [RewardSO](#4-rewardso)
|
||||
5. [QuestManager](#5-questmanager)
|
||||
6. [QuestGiver](#6-questgiver)
|
||||
7. [SaveData 集成(任务)](#7-savedata-集成任务)
|
||||
|
||||
### Part B — 挑战房间
|
||||
|
||||
8. [挑战房间系统职责](#8-挑战房间系统职责)
|
||||
9. [ChallengeRoomSO](#9-challengeroomso)
|
||||
10. [ChallengeEncounterSO](#10-challengeencounterso)
|
||||
11. [BossRushSequenceSO](#11-bossrushsequenceso)
|
||||
12. [ChallengeRoomManager](#12-challengeroommanager)
|
||||
13. [ChallengeRoomTrigger](#13-challengeroomtrigger)
|
||||
14. [事件频道](#14-事件频道)
|
||||
|
||||
---
|
||||
|
||||
## Part A — 任务系统
|
||||
|
||||
---
|
||||
|
||||
## 1. 任务系统职责
|
||||
|
||||
```
|
||||
任务系统职责:
|
||||
├─ QuestSO → 任务定义(目标链/奖励/分支/前置条件)
|
||||
├─ QuestObjectiveSO → 单步目标(对话/击败/收集/到达)
|
||||
├─ RewardSO → 奖励配置(Geo/物品/好感度/能力)
|
||||
├─ QuestManager → 运行时追踪所有任务状态,事件驱动推进进度
|
||||
└─ QuestGiver → 扩展 InteractableNPC,发布/完成任务,切换对话
|
||||
```
|
||||
|
||||
**状态机**:`Unavailable → Available → Active → Completed / Failed`(只进不退)
|
||||
|
||||
**事件驱动**:`QuestManager` 只订阅事件频道(击败敌人/收集物品/到达地点),不主动轮询。
|
||||
|
||||
---
|
||||
|
||||
## 2. QuestSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Quest/Quest")]
|
||||
public class QuestSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string questId; // 唯一 ID,如 "Quest_FindMushroom"
|
||||
public string displayName;
|
||||
[TextArea(2, 6)]
|
||||
public string description;
|
||||
public Sprite icon;
|
||||
|
||||
[Header("目标链")]
|
||||
public QuestObjectiveSO[] objectives; // 按顺序完成,全部完成 = 可交完
|
||||
|
||||
[Header("前置条件")]
|
||||
public string[] prerequisiteQuestIds; // 所有前置任务 Completed 后才可接
|
||||
public int minAffinityToAccept; // NPC 好感度门槛(0 = 无限制)
|
||||
|
||||
[Header("奖励")]
|
||||
public RewardSO reward;
|
||||
|
||||
[Header("失败条件(可选)")]
|
||||
public bool canFail;
|
||||
public QuestObjectiveSO failCondition;
|
||||
|
||||
[Header("完成后续任务(分支)")]
|
||||
public QuestBranch[] branches;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class QuestBranch
|
||||
{
|
||||
public string conditionQuestId; // 若此任务已完成 → 走本分支(空 = 默认)
|
||||
public QuestSO nextQuest;
|
||||
public string npcDialogueKey; // 触发 NPC 对话 key
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. QuestObjectiveSO(多态目标体系)
|
||||
|
||||
每种目标类型使用独立的子类 SO,便于策划在 Inspector 中配置且无需修改代码即可扩展:
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务目标基类(抽象)。所有具体目标类型均继承此类。
|
||||
/// 策划通过各子类的 CreateAssetMenu 创建具体目标资产。
|
||||
/// </summary>
|
||||
public abstract class QuestObjectiveSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string objectiveId;
|
||||
[TextArea(1, 4)]
|
||||
public string displayText; // 任务日志中显示的文本
|
||||
public bool IsOptional; // 可选目标(完成加奖励但不阻塞任务)
|
||||
|
||||
/// <summary>注册监听(由 QuestManager 在任务激活时调用)。</summary>
|
||||
public abstract void RegisterListeners(IQuestObjectiveListener listener);
|
||||
/// <summary>注销监听(由 QuestManager 在任务完成/失败时调用)。</summary>
|
||||
public abstract void UnregisterListeners(IQuestObjectiveListener listener);
|
||||
/// <summary>根据当前进度判断目标是否完成。</summary>
|
||||
public abstract bool EvaluateCompletion(QuestObjectiveState state);
|
||||
}
|
||||
|
||||
// ── 具体目标类型 ──────────────────────────────────────────────
|
||||
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/TalkToNPC")]
|
||||
public class TalkToNPCObjective : QuestObjectiveSO
|
||||
{
|
||||
public string targetNpcId;
|
||||
|
||||
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
|
||||
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/Defeat")]
|
||||
public class DefeatEnemyObjective : QuestObjectiveSO
|
||||
{
|
||||
public string targetEnemyId;
|
||||
[Min(1)] public int defeatCount = 1;
|
||||
|
||||
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
|
||||
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= defeatCount;
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/Collect")]
|
||||
public class CollectItemObjective : QuestObjectiveSO
|
||||
{
|
||||
public string itemId;
|
||||
[Min(1)] public int collectCount = 1;
|
||||
|
||||
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
|
||||
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= collectCount;
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/Reach")]
|
||||
public class ReachAreaObjective : QuestObjectiveSO
|
||||
{
|
||||
public string sceneName; // 需到达的场景
|
||||
public string markerTag; // 场景内的目标标记 Tag
|
||||
|
||||
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
|
||||
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= 1;
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "Quest/Objective/UseSkill")]
|
||||
public class UseSkillObjective : QuestObjectiveSO
|
||||
{
|
||||
public AbilityType requiredAbility;
|
||||
[Min(1)] public int useCount = 1;
|
||||
|
||||
public override void RegisterListeners(IQuestObjectiveListener l) => l.RegisterListeners(this);
|
||||
public override void UnregisterListeners(IQuestObjectiveListener l) => l.UnregisterListeners(this);
|
||||
public override bool EvaluateCompletion(QuestObjectiveState s) => s.progressCount >= useCount;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**扩展方式**:继承 `QuestObjectiveSO`,实现三个抽象方法,标注 `[CreateAssetMenu]`,无需修改 `QuestManager`。
|
||||
|
||||
---
|
||||
|
||||
## 4. RewardSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Quest/Reward")]
|
||||
public class RewardSO : ScriptableObject
|
||||
{
|
||||
public int geo; // Geo 奖励
|
||||
public int soulBonus; // 灵魂槽扩展(+max)
|
||||
public string[] itemIds; // 物品/护符 ID 列表
|
||||
public int affinityBonus; // 对发布 NPC 的好感度增量
|
||||
public string unlockDialogueKey; // 解锁 NPC 新台词集合 key
|
||||
public bool unlocksAbility = false; // ⚠️ AbilityType 无 None 值,用 bool 标识是否解锁能力(架构 09 §1)
|
||||
public AbilityType unlockedAbility; // 仅当 unlocksAbility == true 时有效
|
||||
|
||||
/// <summary>将奖励应用到游戏状态(由 QuestManager.CompleteQuest 调用)。</summary>
|
||||
public void Apply(PlayerStats player)
|
||||
{
|
||||
if (geo > 0) player.AddGeo(geo);
|
||||
if (soulBonus > 0) player.ExtendSoulMax(soulBonus);
|
||||
if (unlocksAbility) // ⚠️ 替代 AbilityType.None 判断
|
||||
player.UnlockAbility(unlockedAbility);
|
||||
// 物品/护符通过 InventoryManager 发放
|
||||
foreach (var id in itemIds)
|
||||
InventoryManager.Instance.AddItem(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. QuestManager
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 运行时任务管理器,挂在 Persistent 场景 [GameManagers] 下。
|
||||
/// 通过事件频道追踪目标进度,不主动轮询。
|
||||
/// </summary>
|
||||
public class QuestManager : MonoBehaviour
|
||||
{
|
||||
// ── Inspector ────────────────────────────────────────
|
||||
[SerializeField] QuestSO[] _allQuests; // 所有任务 SO
|
||||
[SerializeField] TransformEventChannelSO _onEnemyDied; // EVT_EnemyDied → 通过 Transform.GetComponent 获取敌人类型
|
||||
[SerializeField] StringEventChannelSO _onCollectiblePickup; // EVT_CollectiblePickup(itemId)
|
||||
[SerializeField] StringEventChannelSO _onSceneLoaded; // EVT_SceneLoaded(sceneName)
|
||||
[SerializeField] StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompleted(npcId)
|
||||
|
||||
// 分拆为粒度更细的事件频道(替代旧 _onQuestStateChanged 单频道)
|
||||
[SerializeField] StringEventChannelSO _onQuestStarted; // Raise:questId
|
||||
[SerializeField] StringEventChannelSO _onQuestCompleted; // Raise:questId
|
||||
[SerializeField] StringEventChannelSO _onQuestFailed; // Raise:questId
|
||||
[SerializeField] QuestObjectiveEventChannelSO _onObjectiveUpdated; // Raise:objectiveId + progress
|
||||
|
||||
// ── Runtime State ────────────────────────────────────
|
||||
readonly Dictionary<string, QuestState> _questStates = new();
|
||||
readonly Dictionary<string, QuestObjectiveState> _objectiveStates = new(); // objectiveId → 状态
|
||||
|
||||
public static QuestManager Instance { get; private set; }
|
||||
|
||||
/// <summary>向后兼容:保留任务开始事件频道公开属性(供 QuestGiver/QuestLogUI 订阅)。</summary>
|
||||
public StringEventChannelSO OnQuestStarted => _onQuestStarted;
|
||||
public StringEventChannelSO OnQuestCompleted => _onQuestCompleted;
|
||||
|
||||
void Awake() => Instance = this;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
_onEnemyDied.OnEventRaised += HandleEnemyDefeated;
|
||||
_onCollectiblePickup.OnEventRaised += HandleItemCollected;
|
||||
_onSceneLoaded.OnEventRaised += HandleSceneLoaded;
|
||||
_onNpcDialogueCompleted.OnEventRaised += HandleNpcDialogue;
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
_onEnemyDied.OnEventRaised -= HandleEnemyDefeated;
|
||||
_onCollectiblePickup.OnEventRaised -= HandleItemCollected;
|
||||
_onSceneLoaded.OnEventRaised -= HandleSceneLoaded;
|
||||
_onNpcDialogueCompleted.OnEventRaised -= HandleNpcDialogue;
|
||||
}
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────
|
||||
|
||||
/// <summary>NPC 接受任务时调用。</summary>
|
||||
public void AcceptQuest(string questId)
|
||||
{
|
||||
if (!CanAccept(questId)) return;
|
||||
_questStates[questId] = QuestState.Active;
|
||||
_onQuestStarted.Raise(questId);
|
||||
}
|
||||
|
||||
/// <summary>NPC 完成任务时调用。</summary>
|
||||
public void CompleteQuest(string questId, PlayerStats player)
|
||||
{
|
||||
if (!IsReadyToComplete(questId)) return;
|
||||
var quest = GetQuestSO(questId);
|
||||
quest.reward?.Apply(player);
|
||||
_questStates[questId] = QuestState.Completed;
|
||||
_onQuestCompleted.Raise(questId);
|
||||
|
||||
// 解锁后续任务
|
||||
foreach (var branch in quest.branches)
|
||||
{
|
||||
if (string.IsNullOrEmpty(branch.conditionQuestId) ||
|
||||
GetState(branch.conditionQuestId) == QuestState.Completed)
|
||||
{
|
||||
if (branch.nextQuest != null)
|
||||
_questStates[branch.nextQuest.questId] = QuestState.Available;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public QuestState GetState(string questId)
|
||||
=> _questStates.TryGetValue(questId, out var s) ? s : QuestState.Unavailable;
|
||||
|
||||
public bool IsReadyToComplete(string questId)
|
||||
{
|
||||
var quest = GetQuestSO(questId);
|
||||
if (quest == null || GetState(questId) != QuestState.Active) return false;
|
||||
foreach (var obj in quest.objectives)
|
||||
{
|
||||
if (!obj.IsOptional && !IsObjectiveComplete(obj)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 私有 ─────────────────────────────────────────────
|
||||
|
||||
bool CanAccept(string questId)
|
||||
{
|
||||
if (GetState(questId) != QuestState.Available) return false;
|
||||
var quest = GetQuestSO(questId);
|
||||
foreach (var pre in quest.prerequisiteQuestIds)
|
||||
if (GetState(pre) != QuestState.Completed) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IsObjectiveComplete(QuestObjectiveSO obj)
|
||||
{
|
||||
_objectiveStates.TryGetValue(obj.objectiveId, out var s);
|
||||
s ??= new QuestObjectiveState();
|
||||
return obj.EvaluateCompletion(s); // 多态:各子类自行判断完成条件
|
||||
}
|
||||
|
||||
void HandleEnemyDefeated(Transform enemyTransform)
|
||||
{
|
||||
// 通过 Transform 获取敌人 ID(EnemyBase.EnemyId 属性)
|
||||
var enemyBase = enemyTransform.GetComponent<EnemyBase>();
|
||||
if (enemyBase == null) return;
|
||||
string enemyId = enemyBase.EnemyId;
|
||||
foreach (var (qid, state) in _questStates)
|
||||
{
|
||||
if (state != QuestState.Active) continue;
|
||||
var quest = GetQuestSO(qid);
|
||||
foreach (var obj in quest.objectives)
|
||||
{
|
||||
if (obj.type == ObjectiveType.Defeat && obj.targetEnemyId == enemyId)
|
||||
IncrementProgress(obj.objectiveId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void HandleItemCollected(string itemId) { /* 同上,匹配 Collect 目标 */ }
|
||||
void HandleNpcDialogue(string npcId) { /* 同上,匹配 TalkTo 目标 */ }
|
||||
void HandleSceneLoaded(string sceneName) { /* 同上,匹配 Reach 目标 */ }
|
||||
|
||||
void IncrementProgress(string objectiveId)
|
||||
{
|
||||
if (!_objectiveStates.TryGetValue(objectiveId, out var s))
|
||||
s = _objectiveStates[objectiveId] = new QuestObjectiveState();
|
||||
s.progressCount++;
|
||||
_onObjectiveUpdated.Raise(new QuestObjectiveEvent { ObjectiveId = objectiveId, Progress = s.progressCount });
|
||||
}
|
||||
|
||||
QuestSO GetQuestSO(string id) => System.Array.Find(_allQuests, q => q.questId == id);
|
||||
}
|
||||
|
||||
public enum QuestState { Unavailable, Available, Active, Completed, Failed }
|
||||
|
||||
/// <summary>记录单个目标的运行时进度。</summary>
|
||||
public class QuestObjectiveState
|
||||
{
|
||||
public bool completed = false;
|
||||
public int progressCount = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 实现此接口的 MonoBehaviour 可自行注册/注销目标监听,
|
||||
/// 由 QuestManager.RegisterObjectiveListeners() 自动调用。
|
||||
/// </summary>
|
||||
public interface IQuestObjectiveListener
|
||||
{
|
||||
void RegisterListeners(QuestManager manager);
|
||||
void UnregisterListeners(QuestManager manager);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. QuestGiver
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Quest
|
||||
{
|
||||
/// <summary>
|
||||
/// 继承 InteractableNPC,负责发布/完成任务并根据任务状态切换对话版本。
|
||||
/// 不依赖 [RequireComponent],直接通过继承获得 Interact 流程。
|
||||
/// </summary>
|
||||
public class QuestGiver : InteractableNPC
|
||||
{
|
||||
[Header("任务")]
|
||||
[SerializeField] QuestSO[] _offeredQuests; // 该 NPC 可提供的所有任务(按优先级排列)
|
||||
|
||||
[Header("对话版本(根据任务状态切换)")]
|
||||
[SerializeField] DialogueSequenceSO _availableDialogue; // 任务可接时
|
||||
[SerializeField] DialogueSequenceSO _activeDialogue; // 任务进行中
|
||||
[SerializeField] DialogueSequenceSO _readyDialogue; // 完成条件满足时
|
||||
[SerializeField] DialogueSequenceSO _completedDialogue; // 任务已完成后
|
||||
|
||||
// ── Interact_Internal 覆盖(在启动对话前处理任务逻辑)────────
|
||||
protected override void Interact_Internal(Transform player)
|
||||
{
|
||||
var quest = GetCurrentQuest();
|
||||
if (quest == null) return;
|
||||
|
||||
var state = QuestManager.Instance.GetState(quest.questId);
|
||||
if (state == QuestState.Available)
|
||||
QuestManager.Instance.AcceptQuest(quest.questId);
|
||||
else if (QuestManager.Instance.IsReadyToComplete(quest.questId))
|
||||
QuestManager.Instance.CompleteQuest(quest.questId,
|
||||
player.GetComponent<PlayerController>()?.Stats);
|
||||
}
|
||||
|
||||
// ── 返回与当前最高优先级任务状态匹配的对话 SO ──────────────────
|
||||
protected override DialogueSequenceSO GetCurrentDialogue()
|
||||
{
|
||||
var quest = GetCurrentQuest();
|
||||
if (quest == null) return base.GetCurrentDialogue();
|
||||
|
||||
return QuestManager.Instance.GetState(quest.questId) switch
|
||||
{
|
||||
QuestState.Available => _availableDialogue,
|
||||
QuestState.Active => QuestManager.Instance.IsReadyToComplete(quest.questId)
|
||||
? _readyDialogue : _activeDialogue,
|
||||
QuestState.Completed => _completedDialogue,
|
||||
_ => base.GetCurrentDialogue(),
|
||||
};
|
||||
}
|
||||
|
||||
// 返回当前处于 Available 或 Active 状态的第一个任务
|
||||
QuestSO GetCurrentQuest()
|
||||
{
|
||||
if (_offeredQuests == null) return null;
|
||||
foreach (var q in _offeredQuests)
|
||||
{
|
||||
var s = QuestManager.Instance.GetState(q.questId);
|
||||
if (s == QuestState.Available || s == QuestState.Active) return q;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. SaveData 集成(任务)
|
||||
|
||||
`SaveData.Quests.QuestStates`(已在 `12_SaveModule.md` 中定义)读写:
|
||||
|
||||
```csharp
|
||||
// 存档时(SaveManager.GatherSaveData)
|
||||
foreach (var (id, state) in QuestManager.Instance.QuestStates)
|
||||
saveData.Quests.QuestStates[id] = (int)state;
|
||||
|
||||
// 读档时(QuestManager.LoadFromSaveData)
|
||||
public void LoadFromSaveData(QuestSaveData data)
|
||||
{
|
||||
_questStates.Clear();
|
||||
foreach (var (id, stateInt) in data.QuestStates)
|
||||
_questStates[id] = (QuestState)stateInt;
|
||||
foreach (var (id, progress) in data.ObjectiveProgress)
|
||||
{
|
||||
if (!_objectiveStates.TryGetValue(id, out var s))
|
||||
s = _objectiveStates[id] = new QuestObjectiveState();
|
||||
s.progressCount = progress;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part B — 挑战房间
|
||||
|
||||
---
|
||||
|
||||
## 8. 挑战房间系统职责
|
||||
|
||||
```
|
||||
挑战房间系统职责:
|
||||
├─ ChallengeRoomSO → 挑战定义(波次/时限/奖励)
|
||||
├─ ChallengeEncounterSO → 单波敌人配置
|
||||
├─ BossRushSequenceSO → Boss Rush 序列数据
|
||||
├─ ChallengeRoomManager → 运行时流程管理(开始/推进/成功/失败)
|
||||
└─ ChallengeRoomTrigger → 场景组件,触发进入挑战
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. ChallengeRoomSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Challenge/ChallengeRoom")]
|
||||
public class ChallengeRoomSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public string challengeId;
|
||||
public string displayName;
|
||||
public ChallengeType challengeType;
|
||||
|
||||
[Header("波次(非 BossRush)")]
|
||||
public ChallengeEncounterSO[] encounters;
|
||||
|
||||
[Header("Boss Rush(BossRush 类型专用)")]
|
||||
public BossRushSequenceSO bossRushSequence;
|
||||
|
||||
[Header("限制条件")]
|
||||
public float timeLimit; // 0 = 无时限
|
||||
public bool requireNoHit;
|
||||
public int minComboRequired; // 0 = 无要求
|
||||
|
||||
[Header("奖励")]
|
||||
public RewardSO firstClearReward; // 首次通关奖励
|
||||
public RewardSO repeatedReward; // 重复通关奖励
|
||||
|
||||
[Header("解锁条件")]
|
||||
public string[] prerequisiteBossIds; // 需击败的 Boss ID
|
||||
}
|
||||
|
||||
public enum ChallengeType { Survival, TimeTrial, BossRush, NoHit }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. ChallengeEncounterSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Challenge/Encounter")]
|
||||
public class ChallengeEncounterSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct SpawnEntry
|
||||
{
|
||||
public string enemyAddressKey; // Addressables key
|
||||
public Transform spawnPoint;
|
||||
public int count;
|
||||
}
|
||||
|
||||
public SpawnEntry[] enemies;
|
||||
public float waveDelay; // 上波清空后等待多少秒生成本波
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. BossRushSequenceSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Challenge/BossRushSequence")]
|
||||
public class BossRushSequenceSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct BossEntry
|
||||
{
|
||||
public string bossSceneName; // Boss 所在场景(Additive 加载)
|
||||
public string bossId;
|
||||
public float hpRestoreRatio; // 击败本 Boss 后玩家恢复 HP 比例(默认 0.3)
|
||||
}
|
||||
|
||||
public BossEntry[] bosses;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. ChallengeRoomManager
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
/// <summary>
|
||||
/// 挑战房间流程管理器,挂在挑战房间场景的 [ChallengeManager] GameObject 上。
|
||||
/// 场景加载时自动启动挑战。
|
||||
/// </summary>
|
||||
public class ChallengeRoomManager : MonoBehaviour
|
||||
{
|
||||
[SerializeField] ChallengeRoomSO _challengeData;
|
||||
[SerializeField] StringEventChannelSO _onChallengeCompleted; // → EVT_ChallengeCompleted(challengeId)
|
||||
[SerializeField] StringEventChannelSO _onChallengeFailed; // → EVT_ChallengeFailed(challengeId)
|
||||
// ⚠️ PlayerController 无 Instance(Architecture 05 §2);挑战房间场景持有引用
|
||||
[SerializeField] PlayerController _player;
|
||||
|
||||
int _currentEncounterIndex;
|
||||
int _remainingEnemies;
|
||||
float _elapsedTime;
|
||||
bool _isRunning;
|
||||
bool _noHitViolated;
|
||||
|
||||
void Start() => StartChallenge();
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
_elapsedTime += Time.deltaTime;
|
||||
|
||||
// 超时失败
|
||||
if (_challengeData.timeLimit > 0 && _elapsedTime >= _challengeData.timeLimit)
|
||||
FailChallenge();
|
||||
}
|
||||
|
||||
void StartChallenge()
|
||||
{
|
||||
// 自动存档当前位置(挑战失败后读档)
|
||||
SaveManager.Instance.QuickSave();
|
||||
_isRunning = true;
|
||||
_currentEncounterIndex = 0;
|
||||
SpawnWave(_currentEncounterIndex);
|
||||
}
|
||||
|
||||
void SpawnWave(int index)
|
||||
{
|
||||
var enc = _challengeData.encounters[index];
|
||||
_remainingEnemies = 0;
|
||||
foreach (var entry in enc.enemies)
|
||||
{
|
||||
for (int i = 0; i < entry.count; i++)
|
||||
{
|
||||
_remainingEnemies++;
|
||||
// Addressables 加载并生成敌人
|
||||
Addressables.InstantiateAsync(entry.enemyAddressKey, entry.spawnPoint.position, Quaternion.identity)
|
||||
.Completed += handle =>
|
||||
{
|
||||
if (handle.Result.TryGetComponent<EnemyBase>(out var enemy))
|
||||
enemy.OnDied += OnEnemyDefeated;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OnEnemyDefeated()
|
||||
{
|
||||
_remainingEnemies--;
|
||||
if (_remainingEnemies > 0) return;
|
||||
|
||||
_currentEncounterIndex++;
|
||||
if (_currentEncounterIndex >= _challengeData.encounters.Length)
|
||||
CompleteChallenge();
|
||||
else
|
||||
StartCoroutine(DelayedNextWave(_challengeData.encounters[_currentEncounterIndex].waveDelay));
|
||||
}
|
||||
|
||||
IEnumerator DelayedNextWave(float delay)
|
||||
{
|
||||
yield return new WaitForSeconds(delay);
|
||||
SpawnWave(_currentEncounterIndex);
|
||||
}
|
||||
|
||||
void CompleteChallenge()
|
||||
{
|
||||
_isRunning = false;
|
||||
var reward = SaveManager.Instance.IsFirstClear(_challengeData.challengeId)
|
||||
? _challengeData.firstClearReward
|
||||
: _challengeData.repeatedReward;
|
||||
reward?.Apply(_player.Stats);
|
||||
_onChallengeCompleted.Raise(_challengeData.challengeId);
|
||||
}
|
||||
|
||||
void FailChallenge()
|
||||
{
|
||||
_isRunning = false;
|
||||
_onChallengeFailed.Raise(_challengeData.challengeId);
|
||||
// 自动读档回挑战入口
|
||||
SaveManager.Instance.QuickLoad();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. ChallengeRoomTrigger
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Challenge
|
||||
{
|
||||
/// <summary>
|
||||
/// 放置在挑战房间入口处,玩家交互后加载挑战场景。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class ChallengeRoomTrigger : MonoBehaviour, IInteractable
|
||||
{
|
||||
[SerializeField] ChallengeRoomSO _challengeData;
|
||||
[SerializeField] string _challengeSceneName;
|
||||
[SerializeField] SceneLoadRequestEventChannelSO _onSceneLoadRequest; // ⚠️ 通过事件频道触发加载(SceneLoader 无 Instance;架构 03 §3)
|
||||
|
||||
public string InteractPrompt => $"进入挑战:{_challengeData.displayName}";
|
||||
public bool CanInteract => IsUnlocked();
|
||||
|
||||
public void Interact(Transform player)
|
||||
{
|
||||
if (!IsUnlocked()) return;
|
||||
// ⚠️ 通过 EVT_SceneLoadRequest 频道触发(不直接调用 SceneLoader,架构 03 §3 接口为 RequestLoad)
|
||||
_onSceneLoadRequest.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = _challengeSceneName,
|
||||
EntryTransitionId = string.Empty,
|
||||
ShowLoadingScreen = false,
|
||||
IsRespawn = false,
|
||||
});
|
||||
}
|
||||
|
||||
public void OnPlayerEnterRange(Transform player) { }
|
||||
public void OnPlayerExitRange() { }
|
||||
|
||||
bool IsUnlocked()
|
||||
{
|
||||
foreach (var bossId in _challengeData.prerequisiteBossIds)
|
||||
if (!SaveManager.Instance.IsBossDefeated(bossId)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 事件频道
|
||||
|
||||
| 频道 SO | Payload | 发布者 | 订阅者 |
|
||||
|--------|---------|--------|--------|
|
||||
| `EVT_QuestStateChanged` | `(string questId, QuestState)` | `QuestManager` | `QuestGiver`(刷新对话)、`QuestLogUI`(刷新日志) |
|
||||
| `EVT_ChallengeCompleted` | `string challengeId` | `ChallengeRoomManager` | `HUDController`(结算界面)、`AchievementManager` |
|
||||
| `EVT_ChallengeFailed` | `string challengeId` | `ChallengeRoomManager` | `SaveManager`(触发读档)、`HUDController` |
|
||||
841
Docs/Architecture/23_BossSkillModule.md
Normal file
841
Docs/Architecture/23_BossSkillModule.md
Normal file
@@ -0,0 +1,841 @@
|
||||
# 23 · Boss 技能模块(Boss Skill Module)
|
||||
|
||||
> **命名空间** `BaseGames.Boss`
|
||||
> **程序集** `BaseGames.Enemy`(并入敌人程序集)
|
||||
> **依赖** `BaseGames.Core.Events` · `BaseGames.Combat`(DamageInfo · HitBox)· `BaseGames.AI`(BossOrchestrator · BehaviorDesigner)· `Kybernetik.Animancer`
|
||||
> **Design 来源** [47_BossSkillSystem](../Design/47_BossSkillSystem.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [模块职责与层级](#1-模块职责与层级)
|
||||
2. [BossSkillType 枚举](#2-bossskilltype-枚举)
|
||||
3. [VulnerabilityWindow](#3-vulnerabilitywindow)
|
||||
4. [BossSkillSO](#4-bossskillso)
|
||||
5. [AttackPatternSO](#5-attackpatternso)
|
||||
6. [SkillSequenceSO](#6-skillsequenceso)
|
||||
7. [BossSkillExecutor](#7-bossskillexecutor)
|
||||
8. [WeakPointSystem](#8-weakpointsystem)
|
||||
9. [BossOrchestrator 集成](#9-bossorchestrator-集成)
|
||||
10. [设计规则一览](#10-设计规则一览)
|
||||
11. [事件频道](#11-事件频道)
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块职责与层级
|
||||
|
||||
```
|
||||
数据层(ScriptableObject):
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ BossSkillSO │
|
||||
│ ├─ 技能元信息(类型/弱点窗口/互动标签) │
|
||||
│ └─ SkillSequenceSO[] 伤害序列 │
|
||||
│ └─ AttackPatternSO[] 单个攻击图案 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
运行时层(MonoBehaviour / UniTask):
|
||||
BossOrchestrator(AI 决策,Behavior Designer 树)
|
||||
└─ BossSkillExecutor(执行技能序列,处理 VulnerabilityWindow)
|
||||
├─ WeakPointSystem(弱点 HurtBox 激活管理)
|
||||
└─ HitBox[](输出伤害)
|
||||
|
||||
设计原则:
|
||||
① 伤害值只写在 AttackPatternSO,不在 BossSkillSO 重复
|
||||
② 每个技能必须有 ≥1 VulnerabilityWindow(后摇 ≥0.5s 或专属弱点)
|
||||
③ 技能顺序由 BossOrchestrator 决定,BossSkillExecutor 只负责执行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. BossSkillCategory 和 BossSkillType 枚举
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>高层技能分类(平衡框架用)。</summary>
|
||||
public enum BossSkillCategory
|
||||
{
|
||||
Melee, Ranged, Charge, AoE, Environmental, Summon,
|
||||
Buff, Debuff, Phase, Passive, Reactive
|
||||
}
|
||||
|
||||
/// <summary>具体技能类型(战斗设计用)。</summary>
|
||||
public enum BossSkillType
|
||||
{
|
||||
// 基础攻击
|
||||
MeleeSlash, // 近战斩击
|
||||
ChargeAttack, // 冲刺撞击
|
||||
LeapSlam, // 跳起落地震
|
||||
ProjectileVolley, // 弹幕齐射
|
||||
LaserBeam, // 激光扫射
|
||||
|
||||
// 阶段技能
|
||||
PhaseTransition, // 阶段切换(无攻击,触发动画/护盾等)
|
||||
SummonMinion, // 召唤小兵
|
||||
|
||||
// 特殊
|
||||
ArenaTrap, // 改变战斗区域地形
|
||||
SpeedBurst, // 速度爆发(数帧内免疫/高速)
|
||||
DefensiveShell, // 防御壳(需攻击弱点)
|
||||
}
|
||||
|
||||
/// <summary>互动标签:定义玩家可以对该技能执行哪些反制操作。</summary>
|
||||
[Flags]
|
||||
public enum InteractionTag
|
||||
{
|
||||
None = 0,
|
||||
Parryable = 1 << 0, // 可弹反
|
||||
PerfectParryOnly = 1 << 1, // 仅完美弹反有效
|
||||
DodgeWindow = 1 << 2, // 打开逃避窗口
|
||||
Unblockable = 1 << 3, // 无法拦截
|
||||
CanBeReflected = 1 << 4, // 弹幕可被反射
|
||||
ExposesWeakPoint = 1 << 5, // 暴露弱点
|
||||
GrantsPlayerReso = 1 << 6, // 多典:命中后给予玩家资源
|
||||
ArenaHazard = 1 << 7, // 场地危机(环境相关)
|
||||
PhaseGate = 1 << 8, // 阶段门关(必须触发才进入下一阶段)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. VulnerabilityWindow
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>弱点出现时机类型。</summary>
|
||||
public enum VulnTriggerType
|
||||
{
|
||||
OnAttackRecovery, // 攻击后摇
|
||||
OnParriedSuccess, // 弹反成功
|
||||
OnCounterSkillHit, // 计算技能命中
|
||||
OnPhaseTransition, // 阶段切换时
|
||||
OnHazardBackfire, // 场地尃8个
|
||||
OnSummonDefeated, // 召唤物被击败
|
||||
Manual, // BossSkillExecutor 手动触发
|
||||
}
|
||||
|
||||
/// <summary>弱点位置类型。</summary>
|
||||
public enum WeakPointType
|
||||
{
|
||||
FullBody, // 主体全身都是弱点
|
||||
HeadOnly, // 仅头部
|
||||
BackOnly, // 仅背部
|
||||
CoreExposed, // 核心暴露(展开中心 HurtBox)
|
||||
CustomPoint, // 自定义弱点 HurtBox
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct VulnerabilityWindow
|
||||
{
|
||||
[Tooltip("弱点触发方式")]
|
||||
public VulnTriggerType TriggerType;
|
||||
|
||||
[Tooltip("触发后延迟出现(秒)")]
|
||||
[Min(0f)]
|
||||
public float TriggerDelay;
|
||||
|
||||
[Tooltip("弱点持续时长(秒,设计约定 ≥0.5s)")]
|
||||
[Min(0.1f)]
|
||||
public float Duration;
|
||||
|
||||
[Tooltip("弱点位置类型")]
|
||||
public WeakPointType WeakPointType;
|
||||
|
||||
[Tooltip("弱点激活时 Boss 的受击乘数(1 = 正常,>1 = 额外伤害)")]
|
||||
[Min(0.1f)]
|
||||
public float DamageMultiplier;
|
||||
|
||||
[Tooltip("命中后强制驰恶")]
|
||||
public bool ForceStagger;
|
||||
|
||||
[Tooltip("驰恶时间(秒),ForceStagger=true 时生效")]
|
||||
[Min(0f)]
|
||||
public float StaggerDuration;
|
||||
|
||||
[Tooltip("弱点开启时播放的 Feedback(光效等)")]
|
||||
public MMF_Player OpenFeedback;
|
||||
|
||||
[Tooltip("弱点关闭时播放的 Feedback")]
|
||||
public MMF_Player CloseFeedback;
|
||||
|
||||
[Tooltip("弱点激活时的高亮颜色(指示玩家该攻击哪里)")]
|
||||
public Color HighlightColor;
|
||||
|
||||
// 与旧字段对应(保留为展示用)
|
||||
public bool ActivateWeakPointHurtBox => WeakPointType != WeakPointType.FullBody;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. BossSkillSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Boss/BossSkill")]
|
||||
public class BossSkillSO : ScriptableObject
|
||||
{
|
||||
[Header("元信息")]
|
||||
public string skillId;
|
||||
public string displayName;
|
||||
[TextArea(1, 4)]
|
||||
public string designNote; // 设计师注释(不进游戏)
|
||||
|
||||
[Header("技能分类")]
|
||||
public BossSkillCategory category; // 高层分类(Melee/Ranged 等)
|
||||
public BossSkillType skillType; // 具体技能类型
|
||||
|
||||
[Header("阶段可用性")]
|
||||
[Tooltip("和数据层 BossPhaseConfigSO.PhaseIndex 对应;空 = 全阶段可用")]
|
||||
public int[] availablePhaseIndices; // 空数组 = 全阶段可用
|
||||
|
||||
[Header("核心攻击动作引用")]
|
||||
[Tooltip("构成此技能的 AttackPatternSO 序列(单个技能 = 长度 1;连击/多段 = 多个)")]
|
||||
public AttackPatternSO[] attackPatterns; // 按执行顺序排列
|
||||
|
||||
[Header("弱点窗口(至少 1 个)")]
|
||||
public VulnerabilityWindow[] vulnerabilityWindows;
|
||||
|
||||
[Header("互动标签(与谜题/道具联动)")]
|
||||
public InteractionTag interactionTags; // [Flags] 枚举,替代旧 string interactionTag
|
||||
|
||||
[Header("连段(执行中的子序列)")]
|
||||
public SkillSequenceSO sequenceOnHit; // 被玩家攻击弱点时触发的序列(可空)
|
||||
public SkillSequenceSO sequenceOnMiss; // 弱点未被攻击时触发(可空)
|
||||
|
||||
[Header("玩家反制接口")]
|
||||
[Tooltip("被不同玩家行为反制时 Boss 的应激反应(见 §4.1)")]
|
||||
public PlayerCounterResponse[] counterResponses;
|
||||
|
||||
[Header("场景联动")]
|
||||
[Tooltip("此技能执行时触发的场景事件(见 §4.2)")]
|
||||
public ArenaEventTrigger[] arenaEvents;
|
||||
|
||||
[Header("Boss 资源")]
|
||||
[Tooltip("使用此技能消耗的 Boss 自身资源(见 §7)")]
|
||||
public BossResourceCost resourceCost;
|
||||
[Tooltip("使用此技能后是否积累愤怒值")]
|
||||
public bool buildsRage;
|
||||
|
||||
[Header("霸体配置")]
|
||||
[Tooltip("此技能执行期间的霸体窗口(见 54_PoiseSystem §8);None = 可被打断")]
|
||||
public PoiseWindowConfig poiseWindow;
|
||||
|
||||
[Header("动画")]
|
||||
public ClipTransition skillAnimation; // Animancer ClipTransition(技能播放动画)
|
||||
|
||||
[Header("冷却")]
|
||||
[Min(0f)]
|
||||
public float cooldown; // 秒,0 = 无冷却(由 BossOrchestrator 决策层管理)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 PlayerCounterResponse — 玩家反制接口
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
[Serializable]
|
||||
public struct PlayerCounterResponse
|
||||
{
|
||||
[Header("反制条件")]
|
||||
public CounterType counterType; // 玩家用什么行为触发反制
|
||||
public string requiredSkillId; // counterType = SpecificSkill 时填写技能 ID
|
||||
|
||||
[Header("反制效果(对 Boss)")]
|
||||
public float bossStaggerDuration; // 强制硬直时长(秒,0 = 不强制)
|
||||
public float bossDamageBonus; // 对 Boss 的额外伤害倍率(0 = 不额外)
|
||||
public bool openVulnWindow; // 是否触发 VulnerabilityWindow
|
||||
public bool interruptSkill; // 是否打断 Boss 当前技能
|
||||
|
||||
[Header("反制收益(对玩家)")]
|
||||
public int soulPowerGrant; // 给予玩家的灵力数量
|
||||
public int spiritPowerGrant; // 给予玩家的魄元数量
|
||||
public MMF_Player counterFeedback; // 成功反制的特效/音效反馈
|
||||
}
|
||||
|
||||
public enum CounterType
|
||||
{
|
||||
Parry, // 弹反成功
|
||||
PerfectParry, // 完美弹反
|
||||
DodgeThrough, // 冲刺穿越攻击(无敌帧通过)
|
||||
SpecificSkill, // 使用特定玩家技能(填 requiredSkillId)
|
||||
WeakPointHit, // 命中暴露的弱点
|
||||
HazardBackfire, // 利用场景危险伤到 Boss
|
||||
SummonKill, // 击败召唤物
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 ArenaEventTrigger — 场景联动
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
[Serializable]
|
||||
public struct ArenaEventTrigger
|
||||
{
|
||||
public string targetArenaObjectId; // 要影响的场景物件 ID(空 = 广播给所有)
|
||||
public ArenaEventType eventType;
|
||||
public float delay; // 从技能触发到场景事件的延迟(秒)
|
||||
public ArenaEventParams parameters;
|
||||
}
|
||||
|
||||
public enum ArenaEventType
|
||||
{
|
||||
DestroyPlatform, // 破坏指定平台
|
||||
ActivateHazard, // 激活陷阱(如喷火管)
|
||||
DeactivateHazard, // 关闭陷阱
|
||||
SpawnHazardArea, // 生成持续危险区域(熔岩/毒液)
|
||||
ShakeArena, // 场景震动
|
||||
ToggleLighting, // 切换场景光照
|
||||
SpawnPlatform, // 生成新平台(阶段 2 变化)
|
||||
TriggerCutscene, // 触发小型过场
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct ArenaEventParams
|
||||
{
|
||||
public float duration; // 效果持续时间(0 = 永久)
|
||||
public float intensity; // 强度(震动幅度、危险区域半径等)
|
||||
public bool revertsOnPhaseEnd; // 阶段结束时是否恢复
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct ArenaEventData
|
||||
{
|
||||
public ArenaEventType type;
|
||||
public ArenaEventParams parameters;
|
||||
public string sourceSkillId; // 来源技能 ID(供场景物件做条件判断)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 场景中可被 Boss 技能交互的对象实现此接口。
|
||||
/// Boss 通过 ArenaEventBus 广播,场景物件监听并响应。
|
||||
/// </summary>
|
||||
public interface IArenaInteractable
|
||||
{
|
||||
string ArenaObjectId { get; }
|
||||
void OnBossArenaEvent(ArenaEventData data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 BossResourceCost — Boss 资源消耗
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
[Serializable]
|
||||
public struct BossResourceCost
|
||||
{
|
||||
public string resourceId; // 对应 BossResourceConfigSO.resourceId(如 "Rage")
|
||||
public float cost; // 消耗量(0 = 不消耗资源)
|
||||
public float minRequired; // 使用此技能的最低资源要求
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "Boss/ResourceConfig")]
|
||||
public class BossResourceConfigSO : ScriptableObject
|
||||
{
|
||||
public string resourceId; // 如 "Rage" / "PhaseCharge"
|
||||
public string displayName;
|
||||
public float maxValue;
|
||||
public float startValue; // 初始值(通常 0)
|
||||
|
||||
[Header("自动变化")]
|
||||
public float passiveRate; // 每秒自动变化量(正=增长/负=衰减,0=不变)
|
||||
public float onTakeDamageGain; // 每受到 1 点伤害积累量
|
||||
public float onSkillUseGain; // 每使用一次技能积累量
|
||||
|
||||
[Header("满值效果")]
|
||||
public bool autoTriggerOnFull;
|
||||
public BossSkillSO fullTriggerSkill;
|
||||
public float resetValueAfterTrigger;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AttackPatternSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个攻击图案的数据。
|
||||
/// 存放伤害/速度等实际参数,BossSkillSO 不存参数。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Boss/AttackPattern")]
|
||||
public class AttackPatternSO : ScriptableObject
|
||||
{
|
||||
[Header("输出")]
|
||||
public DamageSourceSO DamageSource; // ⚠️ HitBox.Activate() 需要 DamageSourceSO(架构 06 §4)
|
||||
public float KnockbackAngle; // 击退角度(度)(基础击退力由 DamageSourceSO 内配置)
|
||||
|
||||
[Header("弹幕(若为弹幕类型)")]
|
||||
public AssetReferenceGameObject ProjectilePrefab;
|
||||
public int ProjectileCount = 1;
|
||||
public float SpreadAngle = 0f; // 散射角(度)
|
||||
public float ProjectileSpeed = 8f;
|
||||
|
||||
[Header("范围攻击(若为 AoE 类型)")]
|
||||
public float AoERadius;
|
||||
public Vector2 AoEOffset; // 相对于 Boss 的偏移
|
||||
|
||||
[Header("时序")]
|
||||
public float WindupDuration; // 预备动作时间
|
||||
public float ActiveDuration; // HitBox 激活时长
|
||||
public float RecoveryDuration; // 后摇时长(VulnerabilityWindow 通常在此段)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. SkillSequenceSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>
|
||||
/// 有序攻击序列(一个技能内的多段连段)。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Boss/SkillSequence")]
|
||||
public class SkillSequenceSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct SequenceStep
|
||||
{
|
||||
public AttackPatternSO pattern;
|
||||
public float delayBeforeStep; // 执行本步前等待的秒数
|
||||
}
|
||||
|
||||
public SequenceStep[] steps;
|
||||
|
||||
[Header("序列完成后的行为")]
|
||||
public bool RepeatIfPlayerInRange; // 若玩家仍在范围内则重复(Survival 关卡用)
|
||||
public float RepeatDelay;
|
||||
[Range(0, 10)]
|
||||
public int MaxRepeatCount; // 0 = 无限
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. BossSkillExecutor
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在 Boss GameObject 上。
|
||||
/// 接收 BossOrchestrator 的指令,执行指定 BossSkillSO。
|
||||
/// 管理 VulnerabilityWindow 计时和 WeakPointSystem 激活。
|
||||
/// </summary>
|
||||
public class BossSkillExecutor : MonoBehaviour
|
||||
{
|
||||
[SerializeField] HitBox[] _hitBoxes; // Boss 身上的所有 HitBox
|
||||
[SerializeField] WeakPointSystem _weakPointSystem;
|
||||
[SerializeField] AnimancerComponent _animancer;
|
||||
[SerializeField] private string _bossId; // Boss 资产唯一 ID
|
||||
[SerializeField] private BossSkillEventChannelSO _onBossSkillStarted; // 发布:技能开始
|
||||
[SerializeField] private BossSkillEventChannelSO _onBossSkillEnded; // 发布:技能结束
|
||||
// ⚠️ PlayerController 无 Instance(Architecture 05 §2);Boss 居小场景持有玩家 Transform 引用
|
||||
[SerializeField] private Transform _playerTransform; // 由 Inspector 指定玩家 Transform
|
||||
|
||||
BossSkillSO _currentSkill;
|
||||
AnimancerState _currentState;
|
||||
bool _isExecuting;
|
||||
CancellationTokenSource _cts;
|
||||
|
||||
public bool IsExecuting => _isExecuting;
|
||||
|
||||
// ── 公共 API ───────────────────────────────────────────────
|
||||
public async UniTask ExecuteSkill(BossSkillSO skill, CancellationToken ct)
|
||||
{
|
||||
if (_isExecuting) return;
|
||||
_isExecuting = true;
|
||||
_currentSkill = skill;
|
||||
_onBossSkillStarted?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
|
||||
|
||||
// 播放技能动画(Animancer ClipTransition,直接从 BossSkillSO 引用)
|
||||
_currentState = _animancer.Play(skill.skillAnimation);
|
||||
|
||||
// 执行 SkillSequenceSO(若挂载 sequenceOnMiss)
|
||||
if (skill.sequenceOnMiss != null)
|
||||
await ExecuteSequence(skill.sequenceOnMiss, ct);
|
||||
|
||||
_isExecuting = false;
|
||||
_onBossSkillEnded?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
|
||||
}
|
||||
|
||||
async UniTask ExecuteSequence(SkillSequenceSO seq, CancellationToken ct)
|
||||
{
|
||||
int repeatCount = 0;
|
||||
do
|
||||
{
|
||||
foreach (var step in seq.steps)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await UniTask.WaitForSeconds(step.delayBeforeStep, cancellationToken: ct);
|
||||
await ExecutePattern(step.pattern, ct);
|
||||
}
|
||||
repeatCount++;
|
||||
if (seq.RepeatDelay > 0)
|
||||
await UniTask.WaitForSeconds(seq.RepeatDelay, cancellationToken: ct);
|
||||
}
|
||||
while (seq.RepeatIfPlayerInRange
|
||||
&& repeatCount < seq.MaxRepeatCount
|
||||
&& IsPlayerInRange()
|
||||
&& !ct.IsCancellationRequested);
|
||||
}
|
||||
|
||||
async UniTask ExecutePattern(AttackPatternSO pattern, CancellationToken ct)
|
||||
{
|
||||
// 预备
|
||||
await UniTask.WaitForSeconds(pattern.WindupDuration, cancellationToken: ct);
|
||||
|
||||
// 激活 HitBox(⚠️ 以架构 06_CombatModule §4 为准:Activate(DamageSourceSO, Transform))
|
||||
foreach (var hb in _hitBoxes)
|
||||
hb.Activate(pattern.DamageSource, transform);
|
||||
|
||||
await UniTask.WaitForSeconds(pattern.ActiveDuration, cancellationToken: ct);
|
||||
|
||||
// 关闭 HitBox
|
||||
foreach (var hb in _hitBoxes) hb.Deactivate();
|
||||
|
||||
// 后摇(VulnerabilityWindow 由动画事件配合 VulnerabilityWindow 数据触发)
|
||||
await UniTask.WaitForSeconds(pattern.RecoveryDuration, cancellationToken: ct);
|
||||
}
|
||||
|
||||
// ── VulnerabilityWindow 激活(由 ExecuteSkill 协程驱动)─────
|
||||
// 注意:新版 VulnerabilityWindow 改用绝对时间(TriggerDelay + Duration),
|
||||
// 而非归一化时间,故弃用 Update 轮询,改为 UniTask 序列激活。
|
||||
async UniTask ActivateVulnerabilityWindows(BossSkillSO skill, CancellationToken ct)
|
||||
{
|
||||
foreach (var window in skill.vulnerabilityWindows)
|
||||
{
|
||||
// 等待触发延迟
|
||||
await UniTask.Delay(
|
||||
TimeSpan.FromSeconds(window.TriggerDelay),
|
||||
cancellationToken: ct);
|
||||
|
||||
// 激活弱点
|
||||
bool isCustom = window.ActivateWeakPointHurtBox;
|
||||
_weakPointSystem.SetActive(true, window.DamageMultiplier, isCustom);
|
||||
window.OpenFeedback?.PlayFeedbacks();
|
||||
|
||||
// 持续弱点窗口
|
||||
await UniTask.Delay(
|
||||
TimeSpan.FromSeconds(window.Duration),
|
||||
cancellationToken: ct);
|
||||
|
||||
// 关闭弱点
|
||||
_weakPointSystem.SetActive(false, 1f, isCustom);
|
||||
window.CloseFeedback?.PlayFeedbacks();
|
||||
}
|
||||
}
|
||||
|
||||
bool IsPlayerInRange() =>
|
||||
_playerTransform != null &&
|
||||
Vector2.Distance(transform.position, _playerTransform.position) < 8f;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. WeakPointSystem
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>
|
||||
/// 管理 Boss 的专属弱点 HurtBox(如核心、眼睛等)。
|
||||
/// 弱点激活期间受到的伤害会乘以 DamageMultiplier。
|
||||
/// </summary>
|
||||
public class WeakPointSystem : MonoBehaviour
|
||||
{
|
||||
[Serializable]
|
||||
public struct WeakPoint
|
||||
{
|
||||
public HurtBox hurtBox;
|
||||
public GameObject visualIndicator; // 弱点亮光/标记(可为 null)
|
||||
}
|
||||
|
||||
[SerializeField] WeakPoint[] _weakPoints;
|
||||
[SerializeField] private string _bossId; // Boss 资产唯一 ID
|
||||
[SerializeField] private StringEventChannelSO _onVulnerabilityWindowOpened; // 发布:弱点窗口开启
|
||||
|
||||
float _damageMultiplier = 1f;
|
||||
|
||||
public void SetActive(bool active, float multiplier = 1f)
|
||||
{
|
||||
_damageMultiplier = multiplier;
|
||||
foreach (var wp in _weakPoints)
|
||||
{
|
||||
wp.hurtBox.gameObject.SetActive(active);
|
||||
if (wp.visualIndicator != null)
|
||||
wp.visualIndicator.SetActive(active);
|
||||
}
|
||||
if (active) _onVulnerabilityWindowOpened?.Raise(_bossId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 弱点 HurtBox 受击时,由 BossStats 调用此方法获取最终伤害系数。
|
||||
/// </summary>
|
||||
public float GetDamageMultiplier() => _damageMultiplier;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. BossOrchestrator 集成
|
||||
|
||||
`BossOrchestrator` 是 Behavior Designer 的宿主 MonoBehaviour,持有 `BossSkillExecutor` 引用:
|
||||
|
||||
```csharp
|
||||
// BossOrchestrator 片段(供 BD 节点调用)
|
||||
public class BossOrchestrator : MonoBehaviour
|
||||
{
|
||||
[SerializeField] BossSkillSO[] _phaseOneSkills;
|
||||
[SerializeField] BossSkillSO[] _phaseTwoSkills;
|
||||
[SerializeField] BossSkillExecutor _executor;
|
||||
|
||||
int _currentPhase = 1;
|
||||
CancellationTokenSource _cts;
|
||||
|
||||
// BD Task 节点调用:ExecuteSkill(skillId)
|
||||
public async UniTask ExecuteSkillById(string skillId)
|
||||
{
|
||||
var skills = _currentPhase == 1 ? _phaseOneSkills : _phaseTwoSkills;
|
||||
var skill = System.Array.Find(skills, s => s.skillId == skillId);
|
||||
if (skill == null) return;
|
||||
|
||||
_cts?.Cancel();
|
||||
_cts = new CancellationTokenSource();
|
||||
await _executor.ExecuteSkill(skill, _cts.Token);
|
||||
}
|
||||
|
||||
public void EnterPhaseTwo()
|
||||
{
|
||||
_currentPhase = 2;
|
||||
_cts?.Cancel(); // 打断当前技能
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 设计规则一览
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 每个技能 ≥1 VulnerabilityWindow | 后摇窗口 ≥0.5s(归一化时长约 0.15~0.4) |
|
||||
| 伤害参数只在 AttackPatternSO | BossSkillSO 不存 BaseDamage 等参数 |
|
||||
| 技能冷却由 BossOrchestrator 管理 | BossSkillExecutor 只执行,不维护冷却 |
|
||||
| VulnerabilityWindow 在编辑器中校验 | 自定义 Validator Tool 检查 DurationNormalized ≥ 0.1 |
|
||||
| Boss 阶段切换打断当前技能 | `_cts.Cancel()` 终止 UniTask 序列 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 事件频道
|
||||
|
||||
| 频道 SO | Payload | 发布者 | 订阅者 |
|
||||
|--------|---------|--------|--------|
|
||||
| `EVT_BossSkillStarted` | `(string bossId, string skillId)` | `BossSkillExecutor` | `BossHUD`(显示技能名)(震动通过 `CameraStateController.Instance.TriggerImpulse()` 直接调用,不订阅事件)|
|
||||
| `EVT_BossSkillEnded` | `(string bossId, string skillId)` | `BossSkillExecutor` | `BossOrchestrator`(BD 决策推进) |
|
||||
| `EVT_BossVulnerabilityWindowOpened` | `string bossId` | `WeakPointSystem` | `PlayerFeedback`(提示音效)、`HUDController` |
|
||||
| `EVT_BossPhaseChanged` | `(string bossId, int phase)` | `BossBase.EnterPhase()` | `BossHUD`(相机切换通过 `CameraStateController.Instance` 直接调用,不订阅事件)|
|
||||
|
||||
---
|
||||
|
||||
## 12. BossSkillSequenceEditorWindow — 技能序列可视化
|
||||
|
||||
> **痛点**:`SkillSequenceSO` 包含多个 `SequenceStep[]`(每步:AttackPatternSO + delayBeforeStep),但 Inspector 中只能逐字段查看数字,策划无法直觉感受技能序列的节奏感——哪段是攻击前摇、哪段是弱点窗口、总时长是否合理,全部要靠心算。本 EditorWindow 以 **甘特图(Gantt Chart)** 方式将时间线可视化。
|
||||
|
||||
### 12.1 功能规格
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 时间轴刻度 | 横轴为时间(秒),最大显示 `SkillSequenceSO.TotalDuration`,刻度精度 0.1s |
|
||||
| 攻击阶段条 | 每个 `AttackPatternSO` 渲染为蓝色横条,长度 = `pattern.Duration` |
|
||||
| 延迟段 | `delayBeforeStep` 渲染为灰色空隙 |
|
||||
| 弱点窗口 | `VulnerabilityWindow`(`startNormalized`~`endNormalized`)渲染为绿色覆盖条 |
|
||||
| 选中高亮 | 点击任一条可选中对应 `AttackPatternSO`,Inspector 同步 Ping |
|
||||
| 验证警告 | `VulnerabilityWindow.DurationNormalized < 0.1` 时对应条变红 + Tooltip 提示 |
|
||||
| SO 拖放 | 将 `SkillSequenceSO` 资产拖入窗口即可加载 |
|
||||
|
||||
### 12.2 实现规范
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Editor/BossSkill/BossSkillSequenceWindow.cs
|
||||
// 程序集: BaseGames.Editor(Editor Only)
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BaseGames.Editor.BossSkill
|
||||
{
|
||||
public class BossSkillSequenceWindow : EditorWindow
|
||||
{
|
||||
[MenuItem("BaseGames/Tools/Boss Skill Sequence Viewer")]
|
||||
public static void Open() => GetWindow<BossSkillSequenceWindow>("技能序列查看器");
|
||||
|
||||
// ── 状态 ──────────────────────────────────────────────────────────────
|
||||
private SkillSequenceSO _target;
|
||||
private Vector2 _scroll;
|
||||
private float _zoom = 100f; // 像素/秒
|
||||
private const float TrackHeight = 28f;
|
||||
private const float LabelWidth = 140f;
|
||||
private const float HeaderHeight = 30f;
|
||||
|
||||
// 颜色
|
||||
private static readonly Color ColDelay = new(0.3f, 0.3f, 0.3f, 0.5f);
|
||||
private static readonly Color ColAttack = new(0.2f, 0.5f, 1.0f, 0.8f);
|
||||
private static readonly Color ColVulnOk = new(0.2f, 0.8f, 0.3f, 0.6f);
|
||||
private static readonly Color ColVulnWarn = new(1.0f, 0.3f, 0.3f, 0.7f);
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
DrawToolbar();
|
||||
if (_target == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("将 SkillSequenceSO 拖到此处或从 Toolbar 选择", MessageType.Info);
|
||||
HandleDragDrop();
|
||||
return;
|
||||
}
|
||||
DrawTimeline();
|
||||
}
|
||||
|
||||
private void DrawToolbar()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||||
_target = (SkillSequenceSO)EditorGUILayout.ObjectField(
|
||||
"技能序列", _target, typeof(SkillSequenceSO), false,
|
||||
GUILayout.Width(300));
|
||||
GUILayout.Label($"缩放: {_zoom:0}px/s");
|
||||
_zoom = GUILayout.HorizontalSlider(_zoom, 30f, 300f, GUILayout.Width(100));
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void DrawTimeline()
|
||||
{
|
||||
float totalSeconds = _target.TotalDuration;
|
||||
float timelineWidth = totalSeconds * _zoom + LabelWidth + 20f;
|
||||
|
||||
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||||
Rect viewRect = EditorGUILayout.GetControlRect(false,
|
||||
HeaderHeight + _target.Steps.Length * (TrackHeight + 4f),
|
||||
GUILayout.Width(timelineWidth));
|
||||
|
||||
DrawRuler(viewRect, totalSeconds);
|
||||
|
||||
float trackY = viewRect.y + HeaderHeight;
|
||||
float cursor = 0f; // 当前时间游标(秒)
|
||||
|
||||
for (int i = 0; i < _target.Steps.Length; i++)
|
||||
{
|
||||
var step = _target.Steps[i];
|
||||
Rect trackRect = new(viewRect.x, trackY + i * (TrackHeight + 4f),
|
||||
viewRect.width, TrackHeight);
|
||||
|
||||
// 标签
|
||||
Rect labelRect = new(trackRect.x, trackRect.y, LabelWidth, TrackHeight);
|
||||
GUI.Label(labelRect, step.Pattern?.name ?? "—", EditorStyles.miniLabel);
|
||||
|
||||
// 延迟段
|
||||
if (step.DelayBeforeStep > 0f)
|
||||
{
|
||||
Rect delayRect = TimeToRect(trackRect, cursor, step.DelayBeforeStep);
|
||||
EditorGUI.DrawRect(delayRect, ColDelay);
|
||||
cursor += step.DelayBeforeStep;
|
||||
}
|
||||
|
||||
// 攻击段
|
||||
float patternDur = step.Pattern?.Duration ?? 0f;
|
||||
if (patternDur > 0f)
|
||||
{
|
||||
Rect atkRect = TimeToRect(trackRect, cursor, patternDur);
|
||||
EditorGUI.DrawRect(atkRect, ColAttack);
|
||||
GUI.Label(atkRect, step.Pattern?.name ?? "", EditorStyles.centeredGreyMiniLabel);
|
||||
|
||||
// 弱点窗口覆盖层
|
||||
DrawVulnerabilityWindows(atkRect, cursor, patternDur, step.Pattern);
|
||||
cursor += patternDur;
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void DrawVulnerabilityWindows(Rect atkRect, float patternStart,
|
||||
float patternDur, AttackPatternSO pattern)
|
||||
{
|
||||
if (pattern?.VulnerabilityWindows == null) return;
|
||||
foreach (var vw in pattern.VulnerabilityWindows)
|
||||
{
|
||||
float wStart = vw.StartNormalized * patternDur;
|
||||
float wDur = (vw.EndNormalized - vw.StartNormalized) * patternDur;
|
||||
bool warn = vw.DurationNormalized < 0.1f;
|
||||
Color col = warn ? ColVulnWarn : ColVulnOk;
|
||||
|
||||
Rect wRect = TimeToRect(atkRect, patternStart + wStart, wDur);
|
||||
EditorGUI.DrawRect(wRect, col);
|
||||
if (warn)
|
||||
GUI.Label(wRect, "⚠", new GUIStyle { fontSize = 10,
|
||||
normal = { textColor = Color.white }, alignment = TextAnchor.MiddleCenter });
|
||||
}
|
||||
}
|
||||
|
||||
private Rect TimeToRect(Rect trackRect, float startSec, float durSec)
|
||||
{
|
||||
float x = trackRect.x + LabelWidth + startSec * _zoom;
|
||||
float w = Mathf.Max(2f, durSec * _zoom);
|
||||
return new Rect(x, trackRect.y + 2f, w, trackRect.height - 4f);
|
||||
}
|
||||
|
||||
private void DrawRuler(Rect viewRect, float totalSeconds)
|
||||
{
|
||||
float step = _zoom >= 80f ? 0.5f : 1f;
|
||||
for (float t = 0; t <= totalSeconds; t += step)
|
||||
{
|
||||
float x = viewRect.x + LabelWidth + t * _zoom;
|
||||
EditorGUI.DrawRect(new Rect(x, viewRect.y, 1f, HeaderHeight), Color.gray);
|
||||
GUI.Label(new Rect(x + 2f, viewRect.y, 40f, 16f), $"{t:0.0}s",
|
||||
EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDragDrop()
|
||||
{
|
||||
var evt = Event.current;
|
||||
if (evt.type == EventType.DragUpdated || evt.type == EventType.DragPerform)
|
||||
{
|
||||
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
|
||||
if (evt.type == EventType.DragPerform)
|
||||
{
|
||||
DragAndDrop.AcceptDrag();
|
||||
foreach (var obj in DragAndDrop.objectReferences)
|
||||
if (obj is SkillSequenceSO seq) { _target = seq; break; }
|
||||
}
|
||||
evt.Use();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
> **打开方式**:菜单 `BaseGames → Tools → Boss Skill Sequence Viewer`,将 `SkillSequenceSO` 资产拖入即可。时间轴缩放通过 Toolbar 滑条控制;弱点窗口 `DurationNormalized < 0.1` 时变红警告,对应设计规则(§10 Validator 等价)。
|
||||
|
||||
602
Docs/Architecture/24_AnimEventModule.md
Normal file
602
Docs/Architecture/24_AnimEventModule.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# 24 · 动画事件模块(Animation Event Module)
|
||||
|
||||
> **命名空间** `BaseGames.Animation`
|
||||
> **程序集** `BaseGames.Player` + `BaseGames.Enemy`(各自引用,共享接口)
|
||||
> **依赖** `Animancer Pro`(ClipTransition · Events API)· `BaseGames.Core.Events` · `BaseGames.Combat`(HitBox · HurtBox)
|
||||
> **Design 来源** [20_AnimationEventSystem](../Design/20_AnimationEventSystem.md)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [模块职责](#1-模块职责)
|
||||
2. [AnimationEventType 枚举](#2-animationeventtype-枚举)
|
||||
3. [AnimationEventConfigSO](#3-animationeventconfigso)
|
||||
4. [Animancer 事件注册模式](#4-animancer-事件注册模式)
|
||||
5. [PlayerAnimationEvents](#5-playeranimationevents)
|
||||
6. [EnemyAnimationEvents](#6-enemyanimationevents)
|
||||
7. [AnimationEventBinder](#7-animationeventbinder)
|
||||
8. [脚步系统](#8-脚步系统)
|
||||
9. [可取消帧窗口(Cancel Window)](#9-可取消帧窗口cancel-window)
|
||||
10. [编辑器工具:EventConfigEditor](#10-编辑器工具eventconfigeditor)
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块职责
|
||||
|
||||
```
|
||||
动画事件模块职责:
|
||||
├─ AnimationEventType → 统一事件类型枚举(替代字符串 AnimationEvent)
|
||||
├─ AnimationEventConfigSO → 每个 ClipTransition 的事件时机配置(给设计师调整)
|
||||
├─ AnimationEventBinder → 读取 Config,向 Animancer ClipTransition 注册回调
|
||||
├─ PlayerAnimationEvents → 玩家侧统一派发器(HitBox/HurtBox/音效/特效 的单一入口)
|
||||
└─ EnemyAnimationEvents → 敌人侧统一派发器
|
||||
```
|
||||
|
||||
**核心原则**:
|
||||
- **不使用** Unity 传统 `AnimationEvent`(字符串反射调用)
|
||||
- **使用** Animancer `ClipTransition.Events.SetCallback(normalizedTime, callback)` — 类型安全
|
||||
- 所有时机值由 `AnimationEventConfigSO` 管理,设计师可调整而无需修改代码
|
||||
|
||||
---
|
||||
|
||||
## 2. AnimationEventType 枚举
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
public enum AnimationEventType
|
||||
{
|
||||
// 战斗 - HitBox
|
||||
EnableHitBox, // 开启 HitBox(攻击帧开始)
|
||||
DisableHitBox, // 关闭 HitBox(攻击帧结束)
|
||||
AttackImpact, // 攻击命中反馈(音效/特效时机)
|
||||
|
||||
// 战斗 - 弹反窗口(由 ParrySystem 监听)
|
||||
EnableParryWindow, // 开启可弹反时间窗(ParrySystem.OpenWindow())
|
||||
DisableParryWindow, // 关闭可弹反时间窗(ParrySystem.CloseWindow())
|
||||
|
||||
// 玩家
|
||||
EnableIFrame, // 开启无敌帧(翻滚/受击恢复)
|
||||
DisableIFrame, // 关闭无敌帧
|
||||
Footstep, // 脚步落地(替代旧 FootstepLeft/Right,由 data 区分面)
|
||||
LandImpact, // 落地震动(落地音效/特效)
|
||||
JumpLaunch, // 起跳(跳跃音效/特效)
|
||||
EnableInteract, // 动画帧触发互动(如 NPC 握手时机)
|
||||
|
||||
// 反馈派发
|
||||
TriggerFeedback, // 触发 MMF_Player 预设(data = Feedback 名称)
|
||||
PlaySFX, // 播放音效(data = AudioEventSO Address key)
|
||||
|
||||
// 敌人
|
||||
SpawnProjectile, // 生成弹幕(替代旧 SummonProjectile)
|
||||
RoarStart, // 怒吼开始(AI 警觉)
|
||||
RoarEnd, // 怒吼结束
|
||||
PhaseTwoStart, // 二阶段开始(Boss 过渡)
|
||||
|
||||
// 通用
|
||||
CancelWindowOpen, // 可取消帧窗口开始
|
||||
CancelWindowClose, // 可取消帧窗口结束
|
||||
StateTransition, // 动画驱动状态机转移(data = 目标状态名)
|
||||
AnimationComplete, // 动画播完(一次性动画通知)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. AnimationEventConfigSO
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Animation/EventConfig")]
|
||||
public class AnimationEventConfigSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct EventEntry
|
||||
{
|
||||
public AnimationEventType eventType;
|
||||
[Range(0f, 1f)]
|
||||
public float normalizedTime; // 触发帧在整个动画中的归一化位置
|
||||
[Tooltip("附加数据(可空):如 HitBox 编号、音频 key 等")]
|
||||
public string data;
|
||||
}
|
||||
|
||||
[Header("绑定的动画片段(类型安全引用,替代旧 clipName 字符串)")]
|
||||
public AnimationClip targetClip; // 用于 GetNormalizedTime 查询
|
||||
|
||||
[Header("事件时机列表")]
|
||||
public EventEntry[] events;
|
||||
|
||||
/// <summary>按时机顺序排序,方便 Binder 批量注册。</summary>
|
||||
public IEnumerable<EventEntry> SortedEvents =>
|
||||
events.OrderBy(e => e.normalizedTime);
|
||||
|
||||
/// <summary>查询指定事件类型的触发帧(工具/编辑器用)。</summary>
|
||||
public float GetNormalizedTime(AnimationEventType eventType)
|
||||
{
|
||||
foreach (var e in events)
|
||||
if (e.eventType == eventType) return e.normalizedTime;
|
||||
return -1f; // 未找到
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Animancer 事件注册模式
|
||||
|
||||
```csharp
|
||||
// AnimationEventBinder.Bind(ClipTransition, AnimationEventConfigSO, IAnimationEventHandler)
|
||||
// 将 Config 中的事件条目全部注册到指定 ClipTransition 上。
|
||||
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
public static class AnimationEventBinder
|
||||
{
|
||||
/// <summary>
|
||||
/// 将 SO 配置的所有事件绑定到 ClipTransition。
|
||||
/// 由 PlayerAnimationEvents / EnemyAnimationEvents 在 Awake 时调用。
|
||||
/// </summary>
|
||||
public static void Bind(
|
||||
ClipTransition clip,
|
||||
AnimationEventConfigSO config,
|
||||
IAnimationEventHandler receiver)
|
||||
{
|
||||
if (config == null) return;
|
||||
foreach (var entry in config.SortedEvents)
|
||||
{
|
||||
var captured = entry; // 闭包捕获
|
||||
clip.Events.SetCallback(captured.normalizedTime, () =>
|
||||
receiver.HandleEvent(captured.eventType, captured.data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>实现此接口的 MonoBehaviour 可接收动画事件回调。</summary>
|
||||
public interface IAnimationEventHandler
|
||||
{
|
||||
void HandleEvent(AnimationEventType type, string payload);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. PlayerAnimationEvents
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在 PlayerController 上(或其 [Animation] 子节点)。
|
||||
/// 是玩家所有动画事件的唯一派发入口。
|
||||
/// </summary>
|
||||
public class PlayerAnimationEvents : MonoBehaviour, IAnimationEventHandler
|
||||
{
|
||||
[Header("战斗组件")]
|
||||
[SerializeField] HitBox[] _hitBoxes; // 玩家身上所有 HitBox
|
||||
[SerializeField] HurtBox _hurtBox;
|
||||
|
||||
[Header("能力组件")]
|
||||
[SerializeField] PlayerStats _playerStats; // 用于开关无敌帧
|
||||
[SerializeField] PlayerMovement _mover; // ⚠️ 类型为 PlayerMovement(架构 05_PlayerModule §3)
|
||||
[SerializeField] ParrySystem _parrySystem; // 弹反窗口控制(20_ShieldModule §1)
|
||||
|
||||
[Header("特效/音效")]
|
||||
[SerializeField] IFeedbackPlayer _feedback; // 通过 GetComponent 注入
|
||||
|
||||
[Header("事件配置(与 ClipTransition 一一对应)")]
|
||||
[SerializeField] EventBinding[] _bindings;
|
||||
|
||||
[Serializable]
|
||||
struct EventBinding
|
||||
{
|
||||
public ClipTransition clip;
|
||||
public AnimationEventConfigSO config;
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_feedback = GetComponentInParent<IFeedbackPlayer>();
|
||||
foreach (var b in _bindings)
|
||||
AnimationEventBinder.Bind(b.clip, b.config, this);
|
||||
}
|
||||
|
||||
public void HandleEvent(AnimationEventType type, string payload)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AnimationEventType.EnableHitBox:
|
||||
EnableHitBoxById(payload);
|
||||
break;
|
||||
|
||||
case AnimationEventType.DisableHitBox:
|
||||
DisableHitBoxById(payload);
|
||||
break;
|
||||
|
||||
case AnimationEventType.AttackImpact:
|
||||
_feedback?.PlayAttackWhoosh();
|
||||
break;
|
||||
|
||||
case AnimationEventType.EnableIFrame:
|
||||
_hurtBox.SetInvincible(true);
|
||||
break;
|
||||
|
||||
case AnimationEventType.DisableIFrame:
|
||||
_hurtBox.SetInvincible(false);
|
||||
break;
|
||||
|
||||
case AnimationEventType.Footstep:
|
||||
FootstepSystem.Play(_mover.CurrentSurface, transform.position);
|
||||
break;
|
||||
|
||||
case AnimationEventType.LandImpact:
|
||||
_feedback?.PlayLandImpact();
|
||||
break;
|
||||
|
||||
case AnimationEventType.JumpLaunch:
|
||||
_feedback?.PlayJumpLaunch();
|
||||
break;
|
||||
|
||||
case AnimationEventType.EnableParryWindow:
|
||||
_parrySystem?.OpenWindow();
|
||||
break;
|
||||
|
||||
case AnimationEventType.DisableParryWindow:
|
||||
_parrySystem?.CloseWindow();
|
||||
break;
|
||||
|
||||
case AnimationEventType.CancelWindowOpen:
|
||||
_mover.SetCancelWindowOpen(true);
|
||||
break;
|
||||
|
||||
case AnimationEventType.CancelWindowClose:
|
||||
_mover.SetCancelWindowOpen(false);
|
||||
break;
|
||||
|
||||
case AnimationEventType.TriggerFeedback:
|
||||
_feedback?.TriggerPreset(payload); // IFeedbackPlayer.TriggerPreset(见 18_VFXFeedbackModule §2)
|
||||
break;
|
||||
|
||||
case AnimationEventType.PlaySFX:
|
||||
// payload = AudioEventSO Address key(IFeedbackPlayer.PlaySFXById 内部查表后播放)
|
||||
_feedback?.PlaySFXById(payload); // IFeedbackPlayer.PlaySFXById(见 18_VFXFeedbackModule §2)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 辅助 ──────────────────────────────────────────────────
|
||||
void EnableHitBoxById(string id)
|
||||
{
|
||||
foreach (var hb in _hitBoxes)
|
||||
if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Activate(); // ⚠️ HitBox.Activate(),无参数(架构 06 §4 参数均可空)
|
||||
}
|
||||
|
||||
void DisableHitBoxById(string id)
|
||||
{
|
||||
foreach (var hb in _hitBoxes)
|
||||
if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. EnemyAnimationEvents
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在 EnemyBase 上(或其 [Animation] 子节点)。
|
||||
/// 与 PlayerAnimationEvents 结构相同,处理敌人侧事件。
|
||||
/// </summary>
|
||||
public class EnemyAnimationEvents : MonoBehaviour, IAnimationEventHandler
|
||||
{
|
||||
[SerializeField] HitBox[] _hitBoxes;
|
||||
[SerializeField] EnemyFeedback _feedback;
|
||||
[SerializeField] EnemyBase _enemy;
|
||||
|
||||
[SerializeField] EventBinding[] _bindings;
|
||||
|
||||
[Serializable]
|
||||
struct EventBinding
|
||||
{
|
||||
public ClipTransition clip;
|
||||
public AnimationEventConfigSO config;
|
||||
}
|
||||
|
||||
void Awake()
|
||||
{
|
||||
foreach (var b in _bindings)
|
||||
AnimationEventBinder.Bind(b.clip, b.config, this);
|
||||
}
|
||||
|
||||
public void HandleEvent(AnimationEventType type, string payload)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AnimationEventType.EnableHitBox:
|
||||
foreach (var hb in _hitBoxes)
|
||||
if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Activate(); // ⚠️ HitBox.Activate()(架构 06 §4)
|
||||
break;
|
||||
|
||||
case AnimationEventType.DisableHitBox:
|
||||
foreach (var hb in _hitBoxes)
|
||||
if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4)
|
||||
break;
|
||||
|
||||
case AnimationEventType.SpawnProjectile:
|
||||
_enemy.SpawnProjectile(payload);
|
||||
break;
|
||||
|
||||
case AnimationEventType.RoarStart:
|
||||
case AnimationEventType.RoarEnd:
|
||||
// 通知 AI Blackboard
|
||||
_enemy.Blackboard.SetVariableValue("IsRoaring",
|
||||
type == AnimationEventType.RoarStart);
|
||||
break;
|
||||
|
||||
case AnimationEventType.PhaseTwoStart:
|
||||
_enemy.TriggerPhaseTwo();
|
||||
break;
|
||||
|
||||
case AnimationEventType.AnimationComplete:
|
||||
_enemy.OnAnimationComplete(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. AnimationEventBinder
|
||||
|
||||
> 已在 §4 完整定义(`static Bind(ClipTransition, Config, Receiver)`)。
|
||||
|
||||
**使用示例(PlayerAnimationEvents.Awake 之外的独立绑定)**:
|
||||
|
||||
```csharp
|
||||
// 在需要运行时动态替换 Config 的场合
|
||||
AnimationEventBinder.Bind(_slashClip, _hardModeConfig, this);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 脚步系统
|
||||
|
||||
```csharp
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// 静态工具类,根据地面材质播放对应的脚步音效/特效。
|
||||
/// </summary>
|
||||
public static class FootstepSystem
|
||||
{
|
||||
static FootstepCatalogSO _catalog;
|
||||
|
||||
// 禁止使用 Resources.Load。
|
||||
// 在游戏启动时由 BootstrapLoader(或 PersistentScene)调用 InitAsync 预加载。
|
||||
public static async UniTask InitAsync()
|
||||
{
|
||||
_catalog = await Addressables
|
||||
.LoadAssetAsync<FootstepCatalogSO>(AddressKeys.SO_FootstepCatalog).Task;
|
||||
}
|
||||
|
||||
public static void Play(SurfaceType surface, Vector3 position)
|
||||
{
|
||||
var entry = _catalog.GetEntry(surface);
|
||||
if (entry == null) return;
|
||||
// 播放音效(通过 AudioManager)——使用 AudioClip 引用,API 与 11_AudioModule §2 一致
|
||||
AudioManager.Instance.PlaySFXAtPosition(entry.audioClip, position);
|
||||
// 播放粒子(通过 VFXPool)
|
||||
if (entry.dustParticlePrefab.RuntimeKeyIsValid())
|
||||
VFXPool.Instance.Play(entry.dustParticlePrefab, position);
|
||||
}
|
||||
}
|
||||
|
||||
// FootstepCatalogSO 与 FootstepEntry 见 Assets/ScriptableObjects/Audio/FootstepCatalog.asset
|
||||
[CreateAssetMenu(menuName = "Audio/FootstepCatalog")]
|
||||
public class FootstepCatalogSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct FootstepEntry
|
||||
{
|
||||
public SurfaceType surface;
|
||||
public AudioClip audioClip; // ⚠️ AudioClip 引用,非 string key(11_AudioModule §2:PlaySFXAtPosition(AudioClip, Vector2))
|
||||
public AssetReferenceGameObject dustParticlePrefab;
|
||||
}
|
||||
|
||||
[SerializeField] FootstepEntry[] _entries;
|
||||
|
||||
public FootstepEntry? GetEntry(SurfaceType surface)
|
||||
{
|
||||
foreach (var e in _entries)
|
||||
if (e.surface == surface) return e;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public enum SurfaceType { Stone, Wood, Dirt, Water, Metal, Grass }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 可取消帧窗口(Cancel Window)
|
||||
|
||||
```csharp
|
||||
// PlayerMovement.cs 中的 Cancel Window 字段(见架构 05_PlayerModule §3)
|
||||
bool _cancelWindowOpen = false;
|
||||
public bool CancelWindowOpen => _cancelWindowOpen;
|
||||
public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;
|
||||
|
||||
// 玩家 FSM:在 AttackState.OnUpdate() 末尾检查
|
||||
public override PlayerStateBase GetNextState()
|
||||
{
|
||||
// 只有在 CancelWindowOpen 时才接受新输入转换状态
|
||||
if (!_mover.CancelWindowOpen) return null;
|
||||
if (Input.AttackPressed) return _nextAttackState;
|
||||
if (Input.DashPressed) return _dashState;
|
||||
if (Input.JumpPressed) return _jumpState;
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
**流程**:
|
||||
```
|
||||
AttackClip 播放
|
||||
├─ NormalizedTime = 0.6 → AnimationEventType.CancelWindowOpen → PlayerMovement._cancelWindowOpen = true
|
||||
├─ 玩家可输入 → FSM 允许取消进入其他状态
|
||||
└─ NormalizedTime = 0.9 → AnimationEventType.CancelWindowClose → PlayerMover._cancelWindowOpen = false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 编辑器工具:EventConfigEditor
|
||||
|
||||
> **路径**:`Assets/Editor/Animation/EventConfigEditor.cs`
|
||||
> **目标用户**:动画师 / 战斗设计师(调整攻击命中帧、特效触发时机无需对照文本数值)
|
||||
|
||||
### 功能特性
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **时间轴可视化** | 按 `normalizedTime` 绘制可拖拽标记点,直观感受帧时机 |
|
||||
| **Clip 漂移检测** | Clip 长度与记录值偏差 > 5 帧时显示橙色警告,通知动画师同步确认 |
|
||||
| **Auto-detect** | 从 `AnimationClip` 自动读取 FPS/Duration,写入 `ExpectedClipLength` |
|
||||
| **事件类型着色** | 不同 `AnimationEventType` 使用不同颜色(HitBox=红, IFrame=绿, SFX=蓝)|
|
||||
| **越界保护** | `normalizedTime < 0 || > 1` 时显示红色错误行 |
|
||||
|
||||
### AnimationEventConfigSO 新增字段
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Scripts/Animation/AnimationEventConfigSO.cs(新增字段)
|
||||
[HideInInspector] public float ExpectedClipLength = -1f; // 帧数;-1=未记录;Auto-detect 写入
|
||||
```
|
||||
|
||||
### EventConfigEditor 实现
|
||||
|
||||
```csharp
|
||||
// 路径: Assets/Editor/Animation/EventConfigEditor.cs
|
||||
[CustomEditor(typeof(AnimationEventConfigSO))]
|
||||
public class EventConfigEditor : UnityEditor.Editor
|
||||
{
|
||||
private static readonly Dictionary<AnimationEventType, Color> _typeColors = new()
|
||||
{
|
||||
{ AnimationEventType.EnableHitBox, new Color(1f, 0.3f, 0.3f) }, // 红 - 攻击激活
|
||||
{ AnimationEventType.DisableHitBox, new Color(0.8f, 0.2f, 0.2f) },
|
||||
{ AnimationEventType.EnableIFrame, new Color(0.3f, 0.9f, 0.3f) }, // 绿 - 无敌帧
|
||||
{ AnimationEventType.DisableIFrame, new Color(0.2f, 0.7f, 0.2f) },
|
||||
{ AnimationEventType.EnableParryWindow, new Color(0.9f, 0.8f, 0.1f) }, // 黄 - 弹反窗口
|
||||
{ AnimationEventType.Footstep, new Color(0.7f, 0.7f, 0.7f) }, // 灰 - 音效
|
||||
{ AnimationEventType.PlaySFX, new Color(0.4f, 0.7f, 1f) }, // 蓝 - 音效
|
||||
{ AnimationEventType.TriggerFeedback, new Color(0.8f, 0.5f, 1f) }, // 紫 - Feel
|
||||
{ AnimationEventType.CancelWindowOpen, new Color(1f, 0.7f, 0.3f) }, // 橙 - 取消窗口
|
||||
};
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
var config = (AnimationEventConfigSO)target;
|
||||
var prop = serializedObject;
|
||||
prop.Update();
|
||||
|
||||
// ── Clip 引用 + Auto-detect ───────────────────────────────────────
|
||||
EditorGUILayout.PropertyField(prop.FindProperty("targetClip"));
|
||||
|
||||
if (config.targetClip != null)
|
||||
{
|
||||
float currentLen = config.targetClip.length * config.targetClip.frameRate;
|
||||
float expected = config.ExpectedClipLength;
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField($"Clip: {currentLen:F0}帧 / {config.targetClip.length:F3}s",
|
||||
EditorStyles.miniLabel);
|
||||
|
||||
if (GUILayout.Button("Auto-detect", GUILayout.Width(90)))
|
||||
{
|
||||
config.ExpectedClipLength = currentLen;
|
||||
EditorUtility.SetDirty(config);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
// 漂移检测:偏差 > 5 帧警告
|
||||
if (expected > 0 && Mathf.Abs(currentLen - expected) > 5f)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
$"⚠️ Clip 长度已变更(原 {expected:F0} 帧 → 当前 {currentLen:F0} 帧)\n" +
|
||||
"请检查各事件 normalizedTime 时机是否需要同步调整。",
|
||||
MessageType.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
// ── 时间轴可视化 ─────────────────────────────────────────────────
|
||||
EditorGUILayout.LabelField("事件时间轴", EditorStyles.boldLabel);
|
||||
|
||||
// 时间轴背景条(全宽)
|
||||
var timelineRect = GUILayoutUtility.GetRect(0, 24, GUILayout.ExpandWidth(true));
|
||||
EditorGUI.DrawRect(timelineRect, new Color(0.15f, 0.15f, 0.15f));
|
||||
|
||||
// 每个事件绘制一条竖线标记
|
||||
if (config.events != null)
|
||||
{
|
||||
foreach (var entry in config.events)
|
||||
{
|
||||
float t = Mathf.Clamp01(entry.normalizedTime);
|
||||
float x = timelineRect.x + t * timelineRect.width;
|
||||
var lineRect = new Rect(x - 1, timelineRect.y, 2, timelineRect.height);
|
||||
|
||||
Color c = _typeColors.TryGetValue(entry.eventType, out var col)
|
||||
? col : Color.white;
|
||||
EditorGUI.DrawRect(lineRect, c);
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// ── 事件列表(含越界红色标记) ──────────────────────────────────
|
||||
var eventsProp = prop.FindProperty("events");
|
||||
EditorGUILayout.PropertyField(eventsProp, new GUIContent("事件条目"), true);
|
||||
|
||||
// 遍历检测越界
|
||||
if (config.events != null)
|
||||
{
|
||||
foreach (var e in config.events)
|
||||
{
|
||||
if (e.normalizedTime < 0f || e.normalizedTime > 1f)
|
||||
EditorGUILayout.HelpBox(
|
||||
$"❌ [{e.eventType}] normalizedTime={e.normalizedTime:F3} 超出 [0, 1] 范围!",
|
||||
MessageType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
prop.ApplyModifiedProperties();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Player Prefab 层级更新
|
||||
|
||||
```
|
||||
[Player]
|
||||
├── PlayerController.cs
|
||||
├── [Animation] ← 新增子节点
|
||||
│ ├── PlayerAnimationEvents.cs
|
||||
│ │ ├── _hitBoxes: [HitBox] 引用
|
||||
│ │ ├── _hurtBox: HurtBox 引用
|
||||
│ │ └── _bindings: ClipTransition → AnimationEventConfigSO 映射
|
||||
│ └── AnimancerComponent.cs
|
||||
└── [Combat]
|
||||
├── HitBox.cs(id 字段,由 AnimationEventConfig 的 data 匹配)
|
||||
└── HurtBox.cs
|
||||
```
|
||||
108
Docs/Architecture/README.md
Normal file
108
Docs/Architecture/README.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Architecture · 代码框架设计文档集
|
||||
|
||||
> **作用层**:本文档集位于 `Docs/Architecture/`,是 `Docs/Design/`(游戏设计文档)与实际代码实现之间的桥梁层。
|
||||
> **读者**:程序员。描述"代码如何组织",不涉及具体剧情、关卡数值、叙事内容。
|
||||
> **输出物**:可直接作为实施计划(Sprint Backlog)的输入,每个模块文档对应一个或多个可独立实现的代码单元。
|
||||
|
||||
---
|
||||
|
||||
## 文档列表
|
||||
|
||||
| 编号 | 文档 | 覆盖内容 | 关联 Design 文档 |
|
||||
|------|------|---------|----------------|
|
||||
| [01](./01_ProjectStructure.md) | 项目结构与规范 | 文件夹布局、Assembly Definitions、命名规范、SO 资产路径、代码规范 | 00 |
|
||||
| [02](./02_EventSystem.md) | SO 事件系统 | 所有事件频道类型、泛型基类、发布/订阅模式、全局事件频道列表 | 00 |
|
||||
| [03](./03_CoreModule.md) | Core 核心模块 | GameManager、SceneLoader、ObjectPoolManager、SettingsManager | 00、11、43 |
|
||||
| [04](./04_InputModule.md) | 输入模块 | InputReaderSO、InputBuffer、Action Map 定义、按键重绑定 | 01、25 |
|
||||
| [05](./05_PlayerModule.md) | 玩家模块 | PlayerController、PlayerMovement、PlayerStats、PlayerCombat、FormController、FSM States | 03、05、14、21、53、54 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [13](./13_AssetPoolModule.md) | 资源与对象池 | Addressables 工作流、ObjectPoolManager、预热策略、释放规范 | 43 |
|
||||
| [14](./14_NarrativeModule.md) | 叙事模块 | DialogueManager、CutsceneManager、IInteractable NPC、EventChain | 15、18、34、50 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [24](./24_AnimEventModule.md) | 动画事件模块 | PlayerAnimationEvents、EnemyAnimationEvents、AnimEventBridge、Animancer 事件回调 | 03 |
|
||||
|
||||
---
|
||||
|
||||
## 架构全景图
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Unity 引擎层 │
|
||||
│ Addressables │ Cinemachine │ InputSystem │ UI Toolkit │ Animancer │
|
||||
│ PathBerserker2d │ Behavior Designer │ Feel │ FMOD │ Timeline │
|
||||
└───────────────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────────▼────────────────────────────────────────────┐
|
||||
│ BaseGames.Core(核心层) │
|
||||
│ GameManager │ SceneLoader │ ObjectPoolManager │ SettingsManager │
|
||||
│ SO 事件系统(BaseEventChannel<T>)│ AddressKeys │
|
||||
└───────────────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │ │
|
||||
┌───────▼───────┐ ┌────────▼──────┐ ┌─────────▼──────────┐
|
||||
│ Input 层 │ │ World 层 │ │ Combat 层 │
|
||||
│ InputReaderSO │ │ RoomTransition│ │ DamageInfo │
|
||||
│ InputBuffer │ │ SavePoint │ │ HitBox / HurtBox │
|
||||
└───────┬───────┘ │ Collectible │ │ Projectile │
|
||||
│ └───────────────┘ │ StatusEffectManager │
|
||||
│ └─────────┬───────────┘
|
||||
│ │
|
||||
┌───────▼──────────────────────────────────────▼──────────────┐
|
||||
│ Player 层 │
|
||||
│ PlayerController(协调器) │
|
||||
│ PlayerMovement │ PlayerStats │ PlayerCombat │ FormController │
|
||||
│ ParrySystem │ SkillManager │ WeaponManager │ SpringSystem │
|
||||
│ FSM States(Idle/Run/Jump/Dash/Attack/Hurt/Dead/...) │
|
||||
└───────────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────┼──────────────────────┐
|
||||
│ │ │
|
||||
┌───────▼──────┐ ┌─────────▼──────┐ ┌─────────▼──────────┐
|
||||
│ Enemy 层 │ │ Progression 层│ │ Narrative 层 │
|
||||
│ EnemyBase │ │ AbilityGate │ │ DialogueManager │
|
||||
│ AI Tasks │ │ Equipment │ │ CutsceneManager │
|
||||
│ BossPatterns│ │ SkillSO │ │ EventChain │
|
||||
└──────────────┘ │ QuestManager │ └─────────────────────┘
|
||||
└────────────────┘
|
||||
│
|
||||
┌───────────────────────────────▼──────────────────────────────────┐
|
||||
│ 上层服务层 │
|
||||
│ UIManager │ AudioManager │ MapManager │ ShopController │
|
||||
│ SaveManager │ LocalizationManager │ PlatformService │ Analytics │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模块间通信规则(三种合法方式)
|
||||
|
||||
| 方式 | 适用场景 | 示例 |
|
||||
|------|---------|------|
|
||||
| **SO 事件频道** | 跨模块异步通知 | `_onPlayerDied.Raise()` → AudioManager 响应 |
|
||||
| **接口注入** | 同 Prefab 内组件间调用 | `PlayerController` 调用 `_movement.Move()` |
|
||||
| **Inspector 序列化引用** | 同一 Prefab 层级内组件 | `[SerializeField] PlayerMovement _movement` |
|
||||
|
||||
**禁止**:`FindObjectOfType`、`GetComponent<T>` 跨 GameObject、静态单例暴露子系统引用。
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| v1.0 | 2026-04 | 初版,覆盖全部核心系统 |
|
||||
Reference in New Issue
Block a user