chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View 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 | ✅ | 合并入 PlayerMovementGroundDetectionConfigSO 已定义 |
| 25 | InputRebindingUI | 04_InputModule §6 | ✅ | RebindPanel/ConflictDetector/RebindPersistence 已定义 |
| 26 | WallMechanicsSystem | 05_PlayerModule §3 | ✅ | 合并入 PlayerMovementWallMechanicsConfigSO 已定义 |
| 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/NullPlatformServiceServiceLocator 模式)已定义 |
| 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 | ✅ | WeaponSOper-attack ClipTransition + DamageSourceSO + WeaponVFXConfig/ WeaponManagerFormSO 事件驱动 + 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 / ColorBlindFilterURP 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、ParryableProjectileHurtBox 护盾检查步骤 | ✅ 已应用 |
| [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 改为纯数据 SOFormSO.defaultWeaponWeaponOverrideEffect§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-basedIEventChainStep/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 事件桥接SignalEmitterClipPlayableAsset/ITimelineClipAsset+ SignalEmitterBehaviourPlayableBehaviour含 _fired 防重复机制)及使用场景示例 | ✅ 已应用 |
| [16_SupportingModules.md](16_SupportingModules.md) | **§1.1** LanguageManagerSOSO 单例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 HardAbilityGateAbilityGate 子类_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 DialogueUIShowLine/SkipTyping/Hide + StringBuilder 打字机零分配实现§6 InteractableNPC 重编号并添加 `Interact_Internal`/`GetCurrentDialogue` 虚方法;新增 §7 NarrativeNPCDialogueVersion[]CheckConditions 条件对话)+ DialogueVersion 结构体;新增 §8 WorldStateRegistrySO 形式HashSet 存储SetFlag/HasFlag/LoadFromSave/GetAllFlagsEventChain/EventChainManager 等段落重编号为 §9-12CutsceneTrigger 修正:改用 `[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.3ShieldComponent 实现同步(移除 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 §3QuestManager.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.CoreObjectPoolManager` 改为 `BaseGames.CoreGlobalObjectPool`,对齐 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` 调用 | ✅ 已应用 |

View File

@@ -0,0 +1,473 @@
# 01 · 项目结构与规范
> **作用**定义文件夹布局、Assembly Definition 清单、命名规范、ScriptableObject 资产路径、代码风格约束。
> **所有程序员必读**,开始任何模块开发前先阅读本文档。
---
## 目录
1. [文件夹布局](#1-文件夹布局)
2. [Assembly Definitionsasmdef](#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.AIBehavior 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.EditorEditor 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.EditorEditor 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` | BGMFMOD 包) | 按需加载 |
---
## 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
│ └── CameraConfinerPolygonCollider2D
└── [Lighting]
└── GlobalLight2D
```
### Persistent 场景组织
```
Scene: Persistent
├── GameManager ← DontDestroyOnLoad 协调器
├── AudioManager ← FMOD 封装
├── ObjectPoolManager ← 对象池
├── SettingsManager ← 设置管理
└── InputReader ← InputReaderSO持久化
```

View 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 §1enum 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`01 进度) | `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]` 直接引用 SORegistry 仅供动态 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 调用 SubscribeOnDisable 调用 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();
// ── 只写 OnEnableOnDisable 统一 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` 包裹,零运行时开销 |

View 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; // 玩家出生点 IDRoomTransition 与之匹配)
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 BGMAudioMixer 快照)
← [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 场景 AwakeExecutionOrder -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>
/// 查找服务。未注册时返回 defaultT为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);
}
```

View 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_PauseRequestedGameManager 订阅)
// 在首次访问时懒初始化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` 本身是 ScriptableObjectAsset不需要 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);
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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.PlayerWorld.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); // 传入玩家自身 TransformIInteractable 内部通过 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非 IsFlagSetSetFlag(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;
// ── IInteractableInteractKey 模式)─────────────────────────────
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 模式 ───────────────────────────────
// PlayerBodyOnTriggerEnter2DCollider 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>() 检测
// GroundDiveStatePlayerFSM在每帧对角色脚下 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 挂载 Rigidbody2DLayer = "PhantomBody"
// 本组件的 ColliderTrigger对 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();
}
}
```
**典型谜题**
```
场景设置:
PhantomInteractablePressurePlate 型)── _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` |

File diff suppressed because it is too large Load Diff

View 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. [DialogueBoxHUD 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; // 01
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 不直接依赖 BossBaseUI → Combat 逆向耐合)
// BossBase 在运行时实现此接口BossOrchestrator 配置到 BossHPBar._provider 中
public interface IBossHPProvider
{
string BossId { get; } // Boss 前缀 ID
string BossNameKey { get; } // 本地化 Key
float HPRatio { get; } // 01 实时 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` |

View 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. [AudioEventSOSFX 集成)](#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 dB1.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 Pool4~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&lt;IAudioService&gt;() 访问音频服务。
/// 保留此属性仅为历史层兴范过渡期兼容,将在下一个大版本移除。
/// </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);
// 轮转返回下一个 AudioSourcePlayOneShot 下无需检查 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
// FeelMMF_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
// 挂载到地面碰撞体所在 GameObjectTilemap 图层 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 所在 GameObjectLiquidZone 调用 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 随速度变化)|

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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 = itemIdvalue = 已购次数
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` 即可,无需改运行时代码。

File diff suppressed because it is too large Load Diff

View 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 30Boss 战激活)
│ │ ├── CinemachineCamera_lookaheadTime=0, _orthographicSize 由 ConfigSO 驱动)
│ │ ├── CinemachineConfiner2D
│ │ └── CinemachineImpulseListener
│ ├── VCam_Death ← Priority 50死亡时激活慢速 ZoomIn
│ │ └── CinemachineCamera
│ └── VCam_Cutscene ← Priority 40CutsceneManager 注册后激活)
│ └── 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
}
}
```
### RoomVisibleAreaEditorEditor 脚本)
```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" +
"= 参考分辨率 / PPU320×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
}
}
```
### CameraTriggerZoneEditorEditor 脚本)
```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 ──→ CameraTriggerZoneCollider 尺寸手动管理,无 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。

View 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>
/// 反馈执行器的抽象接口。
/// GameLogicPlayerCombat / 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-forgetUniTask 自动回池)。
/// </summary>
/// <param name="maxLifetime">
/// &gt; 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 查找表LUTShader 实现,不换 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=0Boss 战开始时 blend 到 1.0
├── Volume_Death Priority=20 Weight=0玩家死亡时 blend 到 1.0
└── Volume_Victory Priority=10 Weight=0Boss 击败时 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 受此层光照 |
| 57 | _预留_ | 未来扩展 |
**规则**:地形 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` | 伤害数字弹出 | 浮动文字动画 |

View 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; // SteelSoulHP 归零直接清档
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;
// 默认初始化为 NormalSaveData 加载后由 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
```

View 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_DamageDealtAnalyticsManager
_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 和 IDamageablePlayerStats之间担当拦截层。
/// </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
```

View 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. [SwimStateFSM 状态)](#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] 的主触发器触发进出事件。
/// 酸液/熔岩时需同时挂载 HazardZoneInstantKill 类型)。
/// </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. SwimStateFSM 状态)
`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
│ 每帧通过 DamageInfoDamageTag: 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); // 触发后禁用自身,避免重复
}
}
}
}
```

View 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_CollectiblePickupitemId
[SerializeField] StringEventChannelSO _onSceneLoaded; // EVT_SceneLoadedsceneName
[SerializeField] StringEventChannelSO _onNpcDialogueCompleted; // EVT_NpcDialogueCompletednpcId
// 分拆为粒度更细的事件频道(替代旧 _onQuestStateChanged 单频道)
[SerializeField] StringEventChannelSO _onQuestStarted; // RaisequestId
[SerializeField] StringEventChannelSO _onQuestCompleted; // RaisequestId
[SerializeField] StringEventChannelSO _onQuestFailed; // RaisequestId
[SerializeField] QuestObjectiveEventChannelSO _onObjectiveUpdated; // RaiseobjectiveId + 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 获取敌人 IDEnemyBase.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 RushBossRush 类型专用)")]
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_ChallengeCompletedchallengeId
[SerializeField] StringEventChannelSO _onChallengeFailed; // → EVT_ChallengeFailedchallengeId
// ⚠️ PlayerController 无 InstanceArchitecture 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` |

View 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
BossOrchestratorAI 决策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 §8None = 可被打断")]
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 无 InstanceArchitecture 05 §2Boss 居小场景持有玩家 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.EditorEditor 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 等价)。

View 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 keyIFeedbackPlayer.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 key11_AudioModule §2PlaySFXAtPosition(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.csid 字段,由 AnimationEventConfig 的 data 匹配)
└── HurtBox.cs
```

108
Docs/Architecture/README.md Normal file
View 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 schemaC# 完整结构、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 StatesIdle/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 | 初版,覆盖全部核心系统 |