diff --git a/unity/.workbuddy/memory/2026-07-07.md b/unity/.workbuddy/memory/2026-07-07.md new file mode 100644 index 0000000..f3c842c --- /dev/null +++ b/unity/.workbuddy/memory/2026-07-07.md @@ -0,0 +1,80 @@ +# 2026-07-07 + +## Unity 项目解耦架构重构(gold_dolphin/unity) + +- **架构审计结论**:定位到严重耦合信号 + - 7 个跨场景单例:GameManager/AudioManager/ScoreManager/SceneLoader(均 PersistentSingleton)、TimeController(Singleton)、EnemyManager/LightMaskSystem(static Instance) + - `GameObject.Find/FindWithTag/FindObjectsByType` 散落运行时代码;`GameHUD.Update` 仅在 `_playerHealth==null` 时**条件性** `FindWithTag("Player")` 补救(非每帧,但仍是反模式);`EnemyAI` 的 FindWithTag 仅 Start 一次 + - `GameHUD` 用 `typeof(X).GetField(..., NonPublic|Instance)` 反射读私有字段(health/rollCooldown/_lastEchoTime 等),字段改名会静默失效 + - God Class:GameManager(约295行,6职责)、HealthSystem(约216行,健康+死亡+溶解);PlayerController 仅轻度耦合(112行,RequireComponent 3系统),非 God Class + - static 事件伪总线:onGameOver/onPlayerDamaged/OnEchoReleased/onScoreChanged + - EnemyAI 直接 `GetComponent().Damage()` + HealthSystem 反向调 `GameManager.GameOver()` + - `Resources.Load` 用于音频/视频 + +- **已落地解耦基础框架** `Assets/Architecture/`(增量、零侵入、未改现有文件): + - Core/GameEvent.cs(无参事件通道+监听器)、Core/TypedGameEvents.cs(GameEvent + Int/Float/Vector3/String/GameObject 通道及监听器) + - Variables/(Float/Int/Bool/Vector3 Variable,带 OnValueChanged + ContextMenu 重置) + - RuntimeSets/RuntimeSet.cs(泛型集合 + TransformRuntimeSet + RuntimeSetRegistrar 自动注册) + - Editor/VariableDrawer.cs(Inspector 实时显示变量值) + +- **产出方案文档** `Docs/解耦架构重构方案.md`:分层架构(LO SO总线 / L1系统 / L2实体 / L3表现)、逐条 Before→After 改造映射、P0-P6 分阶段路线图。 + +- **项目约定**:无 .asmdef,所有代码在 Assembly-CSharp;GameEvent/RuntimeSet/FloatVariable 等类名无冲突,新增代码可安全编入。 + +## 同行评审修正(2 份意见,全部核实采纳) +- 文档 v1.1 已根据评审重写 `Docs/解耦架构重构方案.md`: + - 严重度下调:`GameHUD` 的 FindWithTag 实为 `_playerHealth==null` 条件回退(非每帧);`PlayerController` 从 God Class 降级为轻度耦合(112行/RequireComponent 3系统)。 + - 行号修正:`EnemyAI.Damage()` 在 `:232`(非 :230)。 + - 补遗漏点:`EnemyHealthBar` 反射读私有字段(`:50-53,73-75`)、`GameHUD` Start 的 `FindObjectOfType`、`onGameWin` 静态事件被 `GameResultScreen` 监听、`GameLostOverlay` 的 `Shader.Find` 与 `FindObjectOfType` 自检查、`MainMenuUIController` 对象名耦合、以及 `ScorePickup/SoulDrop/ScoringUIController/MainMenuUIController/GameLostOverlay` 对 `ScoreManager/SceneLoader` 的直接单例调用线。 +- **代码补强**(评审点):`TypedGameEvents.cs` 补 `StringEventListener`(初稿声称有但未实现);`RuntimeSet.cs` 补泛型 `RuntimeSetRegistrar`(强类型集合如 `EnemyRuntimeSet` 用,领域文件夹写一行具体闭包子类即可)。 + +## 续作:P1+P2 落地改造(代码侧,本次会话) + +- **P1 静态事件 → SO 事件资产(全部完成,含 onScoreSettled)** + - 发布方改写:GameManager(`onGameOver`/`onGameWin`→GameEvent)、HealthSystem(`onPlayerDamaged`→GameEvent)、EchoSystem(`OnEchoReleased`→Vector3Event)、ScoreManager(`onScoreChanged`/`onScoreSettled`→IntEvent)。 + - 订阅方改写:GameResultScreen/GameOverScreen(→`onGameOver`/`onGameWin` GameEvent.Register)、DamageFlashOverlay(→`onPlayerDamagedEvent`.Register)、ScoreDisplay(→`scoreChangedEvent` IntEvent.Register)、EnemyAI(→`echoReleasedEvent` Vector3Event.Register)。 + - API:GameEvent 同时支持 Inspector 监听器(GameEventListener) 与代码监听器 `Register(System.Action)`;GameEvent 用 `Register(Action)`。 +- **P2a GameHUD**:接 PlayerHealth IntVariable(OnValueChanged 驱动 UpdateLifeIcons)+ scoreChangedEvent;技能 CD 改读 PlayerController.RollCooldown / EchoSystem.BellCooldown 公共只读属性(替代反射)。 +- **P2b EnemyHealthBar 重要正确性修正**:原方案让所有 HealthSystem 写同一个全局 IntVariable,会导致多敌人共享同一份血量(严重 bug)。改为 EnemyHealthBar 引用**同物体** HealthSystem(RequireComponent 保证),订阅其 `OnHealthChanged` 事件刷新;HealthSystem 的全局 `healthVar`/`maxHealthVar` 写入**仅限 `isPlayer`**。HealthSystem 新增 `CurrentHealth`/`MaxHealth` 只读属性 + `OnHealthChanged` 事件。 +- **P2d GameManager**:DisableAllEnemyAI 改用 Enemies(TransformRuntimeSet) 遍历禁用,替代 `FindObjectsOfType()`;EnemyManager 改 SerializeField 引用。保留 `FindObjectsOfType` 兜底分支(enemiesSet 未接入时),接入敌人 Prefab 的 RuntimeSetRegistrar 后可删。 +- **P5 Shader.Find ×5 处理**:GameLostOverlay(水波纹→`waterRippleShader` 字段)、GroundBuilder/CliffWallBuilder(AbyssEdgeGlow→`edgeGlowShader` 字段);EchoSystem 已有 `ringShader` 字段;GroundClipTool.cs:19 为 Editor 脚本(运行时不进包,保留但建议改字段)。 +- **变量跨 Play Mode 状态泄漏(用户重点反馈)**:VariableRegistry + `IVariable.ResetToDefault()` + `[RuntimeInitializeOnLoadMethod(BeforeSceneLoad)]` 在每次进 Play Mode 统一重置;AssetBootstrap 生成变量时同时写 `_value`/`_defaultValue`。 +- **UIBuilder.cs**:BuildResultScreen 时自动 LoadAssetAtPath 连线 OnGameOver/OnGameWin 资产,使编辑器搭建的 UI 直接可用(需先运行 `Architecture > Bootstrap Core Assets`)。 +- **仍需手动/后续步骤(Unity 编辑器内)**: + 1. 运行菜单 `Architecture > Bootstrap Core Assets` 生成全部 SO 资产。 + 2. 各 UI/系统组件 Inspector 拖入对应事件/变量资产(GameResultScreen/GameOverScreen/DamageFlashOverlay/ScoreDisplay/EnemyAI/GameHUD 等)。 + 3. 敌人 Prefab 挂 RuntimeSetRegistrar(Enemies)、Player Prefab 挂 RuntimeSetRegistrar(Players),使运行时集合被填充;之后删 GameManager 的 FindObjectsOfType 兜底分支。 + 4. P2e 剩余:MainMenuUIController 对象名耦合 + GameLostOverlay.cs:85 FindObjectOfType 自检查;EnemyAI.cs:112 Start 一次性 FindWithTag(待 Players 集合接入后移除)。 +- **验证**:全仓 grep 确认 `HealthSystem.onPlayerDamaged`/`ScoreManager.onScore*`/`EchoSystem.OnEchoReleased`/`GameManager.onGameOver/onGameWin` 已无代码引用(仅注释),新 API 表面(OnHealthChanged/CurrentHealth/RollCooldown/BellCooldown/IVariable)均已落地。 + +## 计划/方案文档同步(用户 4 条反馈) +- Addressables 过度:P5 改为仅 SerializeField 引用(AudioClip/VideoClip),不引入 Addressables。 +- 变量重置策略:计划 P0/P1 明确 VariableRegistry + _defaultValue 方案。 +- Shader.Find 5 处:P5 清单补全。 +- onScoreSettled:P1 一并迁移。 +- 两份文档(`plans/quantum-pulse-turing.md`、`Docs/解耦架构重构方案.md`)均已更新至反映上述反馈。 + +## 编译错误修复(用户反馈 4 个 CS 错误,已全部修正) +- `GameManager.cs:55/64`:CS0120 —— `GameOver()`/`Win()` 是 static 方法,却直接访问实例字段 `onGameOverEvent`/`onGameWinEvent`。改为 `Instance.onGameOverEvent?.Raise()` / `Instance.onGameWinEvent?.Raise()`(方法开头已有 `if (Instance == null) return;` 保护,安全)。 +- `GameHUD.cs:82/102`:CS0123 —— `IntVariable.OnValueChanged` 是 `Action`,但 `UpdateLifeIcons()` 是无参方法,委托签名不匹配。改为 `UpdateLifeIcons(int value)`(内部 `currentHealth = value`),并把初始直接调用 `UpdateLifeIcons()` → `UpdateLifeIcons(playerHealthVar.Value)`。 +- **经验教训(可复用)**:① 把 static 事件总线改成 SO 事件字段后,原 `GameManager.GameOver()/Win()` 这类 static 入口方法必须改走 `Instance.字段`(或把字段也变 static,但 SO 资产不应是 static);② 订阅 `Variable.OnValueChanged`(`Action`)时,回调方法必须带对应 `T` 参数,否则 CS0123。已提醒用户编译后按接线手册拖资产。 +- `VariableDrawer.cs`:CS0246 —— `Architecture.Editor` 命名空间下的 Editor 脚本引用 `FloatVariable/IntVariable/BoolVariable/Vector3Variable`(位于 `Architecture.Variables`)时漏 `using Architecture.Variables;`。**规则**:Architecture 下的 Editor 脚本都需显式 `using Architecture.Variables;` 与 `using Architecture.Core;`。`AssetBootstrap.cs` 已带齐,无需改。 +- `UIBuilder.cs`(位于 `Assets/Editor/`,非 Architecture/Editor):CS0246 —— 引用 `GameEvent`(`Architecture.Core`)漏 `using Architecture.Core;`,已在文件头补上。已全仓 grep 排雷:其余引用架构类型的外部文件(DamageFlashOverlay/GameHUD/GameOverScreen/GameResultScreen/EnemyAI/EchoSystem/HealthSystem/GameManager/ScoreDisplay/ScoreManager)均已在改写时带齐 using,无其它漏网。`TransformRuntimeSet` 与 `GameEvent` 同处 `Architecture.Core` 命名空间。 + +## 第二轮 Review 意见处理(2 份,全部落地) +- **关键时序修复**:`VariableRegistry.Register()` 内立即 `variable.ResetToDefault()`,解决 `RuntimeInitializeOnLoadMethod(BeforeSceneLoad)` 在 SO 尚未 OnEnable 注册前执行 `ResetAll`(_variables 为空)导致重置无效、跨局状态残留的问题。`ResetAll` 保留作兜底。 +- **HealthSystem 去单例直调**:玩家死亡改为 `onPlayerDiedEvent?.Raise()`,不再 `GameManager.GameOver()`;`GameManager` 订阅 `OnPlayerDied` → `GameOver()`。新增双方 `onPlayerDiedEvent` 字段(同指 `OnPlayerDied` 资产)。 +- **GameLostOverlay 解耦**:删除静态 `Show()` / `_instance` / `FindObjectOfType`,改为订阅 `OnGameOver` 事件(`OnEnable` 注册 → `Activate`)。`GameManager.DeathTransition` 不再直接调用。 +- **空响应清理**:`GameResultScreen` 删除空 `OnGameOver` 方法+字段+订阅(只负责胜利);`GameOverScreen` 全仓无引用,已删除 `.cs`+`.meta`;`UIBuilder` 同步移除 `onGameOverEvent` 接线(否则 `FindProperty` 返回 null 抛 NRE)。 +- **顺手修**:`GameEvent.Raise()` 改为 Inspector 监听(`_listeners`)先于代码监听(`_codeListeners`);`SpiritLanternSystem` 加 `public float Cooldown`,GameHUD 灵灯 CD 删最后一处反射;GameHUD 移除 `Update` 逐帧延迟订阅 ScoreManager(改为 Start 无条件注册);ScoreDisplay 初始值走事件不再访问 `ScoreManager.Instance`。 +- **静态校验通过**:grep 确认无 `GameOverScreen`/`GameLostOverlay.Show`/`_instance`/`typeof(SpiritLanternSystem)` 残留;`onPlayerDiedEvent`(HealthSystem 发布/GameManager 订阅)、`onGameOverEvent`(GameManager 发布/GameLostOverlay 订阅) 一致。 +- **新增接线要求**:HealthSystem+GameManager 的 `On Player Died Event`→`OnPlayerDied`;GameLostOverlay(场景物体) `On Game Over Event`→`OnGameOver`;DamageFlashOverlay 需场景预置已接线实例;GameResultScreen 仅 `On Game Win Event`。旧场景若残留 GameOverScreen 组件需手动移除。 + +## Play Mode 回归(用户实测,03:15) +- ✅ 通过:魂灵数(ScoreChanged)、生命图标(PlayerHealth)、玩家死亡过场(OnPlayerDied→OnGameOver+Enemies 集合)、VariableRegistry 重置(退出再进血量=5,无跨局泄漏)。 +- ❌ 失败两项(已定位,均为 SO 事件资产接线缺口,非代码 bug): + 1. **OnPlayerDamaged 受击泛红**:HealthSystem.Damage 第87行 `onPlayerDamagedEvent?.Raise()` 逻辑正确(同机制 OnPlayerDied 可通),断链原因为 HealthSystem 或/且 DamageFlashOverlay 的 `On Player Damaged Event` 字段为 null。 + 2. **EchoReleased 摇铃引敌**:EchoSystem.StartEcho 第220行 `echoReleasedEvent?.Raise(p)` + EnemyAI.OnEnable `Register(OnBell)` 均正确,断链原因为 EchoSystem 或/且 敌人预制体 EnemyAI 的 `Echo Released Event` 字段为 null(极可能玩家预制体 EchoSystem 未接)。 +- **诊断加固(已落地)**:给 HealthSystem / EchoSystem / EnemyAI / DamageFlashOverlay 的事件字段加 `OnValidate()` 编辑器实时告警(Inspector 黄色三角+控制台),并在 Raise 处加一次性空引用 `Debug.LogWarning`;另把 GameHUD 中「自动 new 一个 event=null 的 DamageFlashOverlay」改为仅告警不创建,避免掩盖未接线问题。 +- **结论**:代码链路正确,失败=事件字段未接。用户需在编辑器看 OnValidate 警告锁定漏接组件,重拖 `OnPlayerDamaged`/`EchoReleased` 资产(注意 EnemyAI 要接**预制体资产**而非场景临时实例)。 + diff --git a/unity/Assets/2.5D Engine/Enemy.prefab b/unity/Assets/2.5D Engine/Enemy.prefab index 7841bf5..1452016 100644 --- a/unity/Assets/2.5D Engine/Enemy.prefab +++ b/unity/Assets/2.5D Engine/Enemy.prefab @@ -347,6 +347,7 @@ MonoBehaviour: maxSightDistance: 4 loseSightTime: 3 listenRange: 15 + echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2} bellArriveDistance: 1 bellChaseTimeout: 10 bellPatrolTime: 5 @@ -385,9 +386,16 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3} m_Name: m_EditorClassIdentifier: + onPlayerDamagedEvent: {fileID: 0} + onPlayerDiedEvent: {fileID: 0} + healthVar: {fileID: 0} + maxHealthVar: {fileID: 0} maxHealth: 3 deathEffect: {fileID: 0} isPlayer: 0 + dissolveMaterial: {fileID: 0} + fadeDuration: 2 + damageInvincibleDuration: 2 --- !u!114 &1893771889199554109 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/unity/Assets/2.5D Engine/Player.prefab b/unity/Assets/2.5D Engine/Player.prefab index c3603a6..7d431dc 100644 --- a/unity/Assets/2.5D Engine/Player.prefab +++ b/unity/Assets/2.5D Engine/Player.prefab @@ -355,6 +355,10 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3} m_Name: m_EditorClassIdentifier: + onPlayerDamagedEvent: {fileID: 11400000, guid: 1690413250c5d464eb84008d45cb88cc, type: 2} + onPlayerDiedEvent: {fileID: 11400000, guid: 8eee0c771c042c44a88f48756f3717b5, type: 2} + healthVar: {fileID: 11400000, guid: 6952ca1b8005ae541bf172dd398d4cc7, type: 2} + maxHealthVar: {fileID: 11400000, guid: 6952ca1b8005ae541bf172dd398d4cc7, type: 2} maxHealth: 5 deathEffect: {fileID: 4806121257990350900, guid: f19c76183b5e22e44a73655dc18f1a92, type: 3} isPlayer: 1 @@ -430,6 +434,7 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 0a44142f1063e1d4dbab7b851d31d258, type: 3} m_Name: m_EditorClassIdentifier: + echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2} echoKey: 101 expandSpeed: 15 maxRadius: 30 @@ -441,6 +446,8 @@ MonoBehaviour: ringVisualWidth: 2.5 ringYOffset: 0.1 ringFadeTime: 3 + ringExpandSpeed: 15 + ringShader: {fileID: 4800000, guid: 67f78fa2aa44cba4cadd869535881366, type: 3} cooldown: 3 --- !u!1001 &1156712605891213377 PrefabInstance: diff --git a/unity/Assets/2.5D Engine/Scripts/HealthSystem.cs b/unity/Assets/2.5D Engine/Scripts/HealthSystem.cs index 0da9a3b..43a4418 100644 --- a/unity/Assets/2.5D Engine/Scripts/HealthSystem.cs +++ b/unity/Assets/2.5D Engine/Scripts/HealthSystem.cs @@ -2,13 +2,23 @@ using UnityEngine; using System; using System.Collections; using GameFramework; +using Architecture.Core; +using Architecture.Variables; + namespace IndianOceanAssets.Engine2_5D { // Handles health, damage, and death for entities public class HealthSystem : MonoBehaviour { - /// 玩家受伤时触发(用于受击特效等) - public static Action onPlayerDamaged; + [Header("SO 事件通道(替代 static onPlayerDamaged 事件)")] + [SerializeField] private GameEvent onPlayerDamagedEvent; + + [Header("SO 事件通道(玩家死亡,替代直接调用 GameManager.GameOver)")] + [SerializeField] private GameEvent onPlayerDiedEvent; + + [Header("SO 变量(供 EnemyHealthBar / HUD 订阅,替代反射读取私有字段)")] + [SerializeField] private IntVariable healthVar; + [SerializeField] private IntVariable maxHealthVar; [Range(1, 100)] [SerializeField] @@ -31,6 +41,7 @@ namespace IndianOceanAssets.Engine2_5D private bool _isDead = false; // 防止重复触发死亡 private bool _isInvincible = false; // 无敌状态 private float _invincibleTimer = 0f; // 无敌剩余时间(<=0 表示永久无敌) + private bool _warnedDamaged = false; // 防止 onPlayerDamagedEvent 空引用告警刷屏 [Header("受伤无敌")] [Tooltip("玩家受伤后的无敌时间(秒)")] @@ -42,13 +53,33 @@ namespace IndianOceanAssets.Engine2_5D /// 是否处于无敌状态(只读) public bool IsInvincible => _isInvincible; + /// 当前血量(只读) + public int CurrentHealth => health; + /// 最大血量(只读) public int MaxHealth => maxHealth; + /// 血量变化事件(current, max)。敌人血条等本地订阅者用于刷新显示,替代反射读取私有字段。 + public event System.Action OnHealthChanged; + + // 编辑器实时校验:事件字段未接线时给出黄色警告三角 + 控制台告警 + private void OnValidate() + { + if (isPlayer && onPlayerDamagedEvent == null) + Debug.LogWarning($"[HealthSystem] 玩家 HealthSystem 的 On Player Damaged Event 未接线({gameObject.name})。受击泛红不会出现。", this); + if (isPlayer && onPlayerDiedEvent == null) + Debug.LogWarning($"[HealthSystem] 玩家 HealthSystem 的 On Player Died Event 未接线({gameObject.name})。玩家死亡不会触发过场。", this); + } + // Initializes health private void Start() { health = maxHealth; + if (isPlayer) + { + if (maxHealthVar != null) maxHealthVar.Value = maxHealth; + if (healthVar != null) healthVar.Value = health; + } } // Applies damage and checks for death @@ -58,10 +89,20 @@ namespace IndianOceanAssets.Engine2_5D if (_isInvincible) return; // 无敌状态,免疫伤害 health -= damageAmount; + if (isPlayer && healthVar != null) healthVar.Value = health; + OnHealthChanged?.Invoke(health, maxHealth); // 玩家受伤时触发事件(用于受击泛红特效) - if (isPlayer && onPlayerDamaged != null) - onPlayerDamaged.Invoke(); + if (isPlayer) + { + if (onPlayerDamagedEvent != null) + onPlayerDamagedEvent.Raise(); + else if (!_warnedDamaged) + { + Debug.LogWarning("[HealthSystem] onPlayerDamagedEvent 未接线!受击泛红特效不会触发。请在玩家 HealthSystem 的 On Player Damaged Event 字段拖入 OnPlayerDamaged 资产。", this); + _warnedDamaged = true; + } + } // 播放受伤音效 if (isPlayer && AudioManager.Instance != null) @@ -78,8 +119,8 @@ namespace IndianOceanAssets.Engine2_5D if (isPlayer) { - // 玩家死亡:触发过场动画,不立即销毁(由 GameManager 处理) - GameManager.GameOver(); + // 玩家死亡:通过 OnPlayerDied SO 事件通知 GameManager(解耦,不再直接调用单例) + onPlayerDiedEvent?.Raise(); } else { @@ -213,4 +254,4 @@ namespace IndianOceanAssets.Engine2_5D Destroy(gameObject); } } -} \ No newline at end of file +} diff --git a/unity/Assets/2.5D Engine/Scripts/Player.cs b/unity/Assets/2.5D Engine/Scripts/Player.cs index 943a6c5..42deb07 100644 --- a/unity/Assets/2.5D Engine/Scripts/Player.cs +++ b/unity/Assets/2.5D Engine/Scripts/Player.cs @@ -21,6 +21,10 @@ namespace IndianOceanAssets.Engine2_5D private float lastRollTime; // Timestamp of last roll private bool isRolling; // Is player currently rolling? + /// 冲刺冷却剩余/总时长(供 HUD 显示,替代反射读取私有字段)。 + public (float remaining, float total) RollCooldown + => (Mathf.Max(0f, (lastRollTime + rollCooldown) - Time.time), rollCooldown); + [SerializeField] private KeyCode rollKeyCode; // Key to trigger roll // Enum to switch between sword or projectile attack types diff --git a/unity/Assets/39678.jpg.meta b/unity/Assets/39678.jpg.meta new file mode 100644 index 0000000..f57c442 --- /dev/null +++ b/unity/Assets/39678.jpg.meta @@ -0,0 +1,133 @@ +fileFormatVersion: 2 +guid: CXkZ4S2lVHyohUFSvtGvZhelAh5YZD1t21cOxId/XueCt0dvcSw2LVI= +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + webStreaming: 0 + priorityLevel: 0 + uploadedMode: 2 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 3 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + maxPlaceholderSize: 32 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 3 + buildTarget: Standalone + maxTextureSize: 2048 + maxPlaceholderSize: 32 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 3 + buildTarget: Server + maxTextureSize: 2048 + maxPlaceholderSize: 32 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture.meta b/unity/Assets/Architecture.meta new file mode 100644 index 0000000..8567e4f --- /dev/null +++ b/unity/Assets/Architecture.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: WSxO4H+lVy/+Blf5pfhG4hmfSru5p5O0gX9h1Xbssr3FMpnAolEEZqQ= +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets.meta b/unity/Assets/Architecture/Assets.meta new file mode 100644 index 0000000..59b7488 --- /dev/null +++ b/unity/Assets/Architecture/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: DXhN53n7Wn6X4mq4BzmVGcYyf4qgGr+dz9mS8D/RSs2VcTccKveruRE= +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/EchoReleased.asset b/unity/Assets/Architecture/Assets/EchoReleased.asset new file mode 100644 index 0000000..a019fa6 --- /dev/null +++ b/unity/Assets/Architecture/Assets/EchoReleased.asset @@ -0,0 +1,14 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 0} + m_Name: EchoReleased + m_EditorClassIdentifier: Assembly-CSharp:Architecture.Core:Vector3Event diff --git a/unity/Assets/Architecture/Assets/EchoReleased.asset.meta b/unity/Assets/Architecture/Assets/EchoReleased.asset.meta new file mode 100644 index 0000000..0f8437d --- /dev/null +++ b/unity/Assets/Architecture/Assets/EchoReleased.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: DCwXsH6lBn1xFo36tyRsjs+h4O+VaxEW/j5EUKPXE9wXY5c7QNmWpTw= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/Enemies.asset b/unity/Assets/Architecture/Assets/Enemies.asset new file mode 100644 index 0000000..cdaeaf2 --- /dev/null +++ b/unity/Assets/Architecture/Assets/Enemies.asset @@ -0,0 +1,15 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 0} + m_Name: Enemies + m_EditorClassIdentifier: Assembly-CSharp:Architecture.Core:TransformRuntimeSet + Items: [] diff --git a/unity/Assets/Architecture/Assets/Enemies.asset.meta b/unity/Assets/Architecture/Assets/Enemies.asset.meta new file mode 100644 index 0000000..6826322 --- /dev/null +++ b/unity/Assets/Architecture/Assets/Enemies.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: WXNNtH6sVHk/AtKjh3pZHW8Y0Ud+sV/2q1BsD/usbR4CLijRwhOQiIk= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/OnGameOver.asset b/unity/Assets/Architecture/Assets/OnGameOver.asset new file mode 100644 index 0000000..a651979 --- /dev/null +++ b/unity/Assets/Architecture/Assets/OnGameOver.asset @@ -0,0 +1,14 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0d892c58b6fddaa4cb129dc82a1599bf, type: 3} + m_Name: OnGameOver + m_EditorClassIdentifier: diff --git a/unity/Assets/Architecture/Assets/OnGameOver.asset.meta b/unity/Assets/Architecture/Assets/OnGameOver.asset.meta new file mode 100644 index 0000000..7dfe654 --- /dev/null +++ b/unity/Assets/Architecture/Assets/OnGameOver.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: CChMtHz5UioVBUTAdiP6msXTWBHbXvrlL/lrziGncpCasO91pyWEhyk= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/OnGameWin.asset b/unity/Assets/Architecture/Assets/OnGameWin.asset new file mode 100644 index 0000000..4d8b890 --- /dev/null +++ b/unity/Assets/Architecture/Assets/OnGameWin.asset @@ -0,0 +1,14 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0d892c58b6fddaa4cb129dc82a1599bf, type: 3} + m_Name: OnGameWin + m_EditorClassIdentifier: diff --git a/unity/Assets/Architecture/Assets/OnGameWin.asset.meta b/unity/Assets/Architecture/Assets/OnGameWin.asset.meta new file mode 100644 index 0000000..80524ce --- /dev/null +++ b/unity/Assets/Architecture/Assets/OnGameWin.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: XH5L5HukAH7cHD+m8JFQuv4+Mj+RudGvKdJlEfvVp4JP/PFYo2XNvkY= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/OnPlayerDamaged.asset b/unity/Assets/Architecture/Assets/OnPlayerDamaged.asset new file mode 100644 index 0000000..5fe69dd --- /dev/null +++ b/unity/Assets/Architecture/Assets/OnPlayerDamaged.asset @@ -0,0 +1,14 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0d892c58b6fddaa4cb129dc82a1599bf, type: 3} + m_Name: OnPlayerDamaged + m_EditorClassIdentifier: diff --git a/unity/Assets/Architecture/Assets/OnPlayerDamaged.asset.meta b/unity/Assets/Architecture/Assets/OnPlayerDamaged.asset.meta new file mode 100644 index 0000000..d61c54a --- /dev/null +++ b/unity/Assets/Architecture/Assets/OnPlayerDamaged.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: DnwWtS6sUHnS++rBiFqf1RewyFloSSTeXLbE2N2AYG05n2Ydap4Fwj0= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/OnPlayerDied.asset b/unity/Assets/Architecture/Assets/OnPlayerDied.asset new file mode 100644 index 0000000..ed9f218 --- /dev/null +++ b/unity/Assets/Architecture/Assets/OnPlayerDied.asset @@ -0,0 +1,14 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0d892c58b6fddaa4cb129dc82a1599bf, type: 3} + m_Name: OnPlayerDied + m_EditorClassIdentifier: diff --git a/unity/Assets/Architecture/Assets/OnPlayerDied.asset.meta b/unity/Assets/Architecture/Assets/OnPlayerDied.asset.meta new file mode 100644 index 0000000..9217c3c --- /dev/null +++ b/unity/Assets/Architecture/Assets/OnPlayerDied.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: By9K4Cr+VHyn1VhN1u6wwvxVZTtp7QakSbea38aO5PM+1kQ2/WvqpTk= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/PlayerHealth.asset b/unity/Assets/Architecture/Assets/PlayerHealth.asset new file mode 100644 index 0000000..4be7219 --- /dev/null +++ b/unity/Assets/Architecture/Assets/PlayerHealth.asset @@ -0,0 +1,16 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d031a592f871059438eecfab967281c5, type: 3} + m_Name: PlayerHealth + m_EditorClassIdentifier: + _value: 5 + _defaultValue: 5 diff --git a/unity/Assets/Architecture/Assets/PlayerHealth.asset.meta b/unity/Assets/Architecture/Assets/PlayerHealth.asset.meta new file mode 100644 index 0000000..6910b97 --- /dev/null +++ b/unity/Assets/Architecture/Assets/PlayerHealth.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: CXMat3n8Uine4euE0m+/PP+65iSUYO/kdbR4DpS1kOnmtBMCoGxpDXU= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/Players.asset b/unity/Assets/Architecture/Assets/Players.asset new file mode 100644 index 0000000..b976f3a --- /dev/null +++ b/unity/Assets/Architecture/Assets/Players.asset @@ -0,0 +1,15 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 0} + m_Name: Players + m_EditorClassIdentifier: Assembly-CSharp:Architecture.Core:TransformRuntimeSet + Items: [] diff --git a/unity/Assets/Architecture/Assets/Players.asset.meta b/unity/Assets/Architecture/Assets/Players.asset.meta new file mode 100644 index 0000000..8645c47 --- /dev/null +++ b/unity/Assets/Architecture/Assets/Players.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: DXkfsH+oAC3hF+c5rKlzwRKUa/umXiwsRPwwZnPWvvBN2Q0hWBBPanE= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/Score.asset b/unity/Assets/Architecture/Assets/Score.asset new file mode 100644 index 0000000..9436499 --- /dev/null +++ b/unity/Assets/Architecture/Assets/Score.asset @@ -0,0 +1,16 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d031a592f871059438eecfab967281c5, type: 3} + m_Name: Score + m_EditorClassIdentifier: + _value: 0 + _defaultValue: 0 diff --git a/unity/Assets/Architecture/Assets/Score.asset.meta b/unity/Assets/Architecture/Assets/Score.asset.meta new file mode 100644 index 0000000..c692e2e --- /dev/null +++ b/unity/Assets/Architecture/Assets/Score.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: WnIXtyivW3to970CZlNapENiq9hQEjUh+OqHFQyk+JfklXrmuCSE5fk= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/ScoreChanged.asset b/unity/Assets/Architecture/Assets/ScoreChanged.asset new file mode 100644 index 0000000..e7149cf --- /dev/null +++ b/unity/Assets/Architecture/Assets/ScoreChanged.asset @@ -0,0 +1,14 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 0} + m_Name: ScoreChanged + m_EditorClassIdentifier: Assembly-CSharp:Architecture.Core:IntEvent diff --git a/unity/Assets/Architecture/Assets/ScoreChanged.asset.meta b/unity/Assets/Architecture/Assets/ScoreChanged.asset.meta new file mode 100644 index 0000000..fefa64f --- /dev/null +++ b/unity/Assets/Architecture/Assets/ScoreChanged.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: WXNNvCuoVXP90sjsVOaNxagClFTPQl2e6lNPzXvzsn7Deww9UFDHgQ4= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Assets/ScoreSettled.asset b/unity/Assets/Architecture/Assets/ScoreSettled.asset new file mode 100644 index 0000000..347659d --- /dev/null +++ b/unity/Assets/Architecture/Assets/ScoreSettled.asset @@ -0,0 +1,14 @@ +%YAML 1.1 +%TAG !u! tag:yousandi.cn,2023: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 0} + m_Name: ScoreSettled + m_EditorClassIdentifier: Assembly-CSharp:Architecture.Core:IntEvent diff --git a/unity/Assets/Architecture/Assets/ScoreSettled.asset.meta b/unity/Assets/Architecture/Assets/ScoreSettled.asset.meta new file mode 100644 index 0000000..851f381 --- /dev/null +++ b/unity/Assets/Architecture/Assets/ScoreSettled.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: Wi9KtSqvUiiCMcmSH6Ragy/X4YLegcpMnbruBTQrei7BTu3lyGrSIoc= +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Core.meta b/unity/Assets/Architecture/Core.meta new file mode 100644 index 0000000..e289155 --- /dev/null +++ b/unity/Assets/Architecture/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: CHwdtCmvUy1YMyYqJ0W+W9h32R9zeWPvveDocc+XzRh7umlTkIVFsPU= +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Core/GameEvent.cs b/unity/Assets/Architecture/Core/GameEvent.cs new file mode 100644 index 0000000..a3d7290 --- /dev/null +++ b/unity/Assets/Architecture/Core/GameEvent.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Events; + +namespace Architecture.Core +{ + /// + /// 无参事件通道(ScriptableObject 资产)。 + /// 系统 / UI 在 Inspector 中拖入同一个资产实例即可实现解耦通信, + /// 彻底替代 static Action 事件与单例方法调用。 + /// 注意:资产实例是项目级资源,跨场景天然存活,无需 DontDestroyOnLoad。 + /// + [CreateAssetMenu(menuName = "Architecture/Events/Game Event", fileName = "NewGameEvent")] + public class GameEvent : ScriptableObject + { + private readonly List _listeners = new List(); + private event System.Action _codeListeners; + + public void Raise() + { + // Inspector 监听器(设计师配置的 UnityEvent 响应)优先于代码监听器触发, + // 保证表现层(UI 动画/音效)在逻辑层之前响应事件。 + for (int i = _listeners.Count - 1; i >= 0; i--) + _listeners[i].OnEventRaised(); + _codeListeners?.Invoke(); + } + + /// Inspector 监听器(GameEventListener 组件)注册。 + public void RegisterListener(GameEventListener listener) + { + if (!_listeners.Contains(listener)) _listeners.Add(listener); + } + + public void UnregisterListener(GameEventListener listener) + { + if (_listeners.Contains(listener)) _listeners.Remove(listener); + } + + /// 代码监听器(Action)注册,便于 MonoBehaviour 在 OnEnable 中订阅, + /// 是替换 static Action 事件的主要入口。 + public void Register(System.Action listener) + { + if (listener != null) _codeListeners += listener; + } + + public void Unregister(System.Action listener) + { + if (listener != null) _codeListeners -= listener; + } + } + + /// + /// 无参事件监听器(设计师友好,纯 Inspector 配置)。 + /// 监听某个 GameEvent,触发时调用绑定的 UnityEvent 响应。 + /// + public class GameEventListener : MonoBehaviour + { + [SerializeField] private GameEvent _event; + [SerializeField] private UnityEvent _response; + + private void OnEnable() => _event?.RegisterListener(this); + private void OnDisable() => _event?.UnregisterListener(this); + + public void OnEventRaised() => _response?.Invoke(); + } +} diff --git a/unity/Assets/UI/GameOverScreen.cs.meta b/unity/Assets/Architecture/Core/GameEvent.cs.meta similarity index 76% rename from unity/Assets/UI/GameOverScreen.cs.meta rename to unity/Assets/Architecture/Core/GameEvent.cs.meta index ced777a..0b15ac4 100644 --- a/unity/Assets/UI/GameOverScreen.cs.meta +++ b/unity/Assets/Architecture/Core/GameEvent.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: CClOsC78Vi9/cIog8UeuUzecgGTFlu5AQ5iyvdkeMcyG1n6ns9R5ds8= +guid: Dy4XvCj+VnPPG8II9z1gbnWGNyb39F6pQKS/CfIEjQntIaaEOm2XcP4= MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/unity/Assets/Architecture/Core/TypedGameEvents.cs b/unity/Assets/Architecture/Core/TypedGameEvents.cs new file mode 100644 index 0000000..f678c15 --- /dev/null +++ b/unity/Assets/Architecture/Core/TypedGameEvents.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Events; + +namespace Architecture.Core +{ + /// + /// 带载荷的泛型事件通道。用于在调用方之间传递数据,而无需 static 事件或单例。 + /// 具体类型(IntEvent / FloatEvent 等)可在 Inspector 右键 Create。 + /// 系统内部订阅用 event.Register(Action<T>);设计师驱动用对应 *EventListener 组件。 + /// + public abstract class GameEvent : ScriptableObject + { + private readonly List> _listeners = new List>(); + + public void Raise(T payload) + { + for (int i = _listeners.Count - 1; i >= 0; i--) + _listeners[i]?.Invoke(payload); + } + + public void Register(Action listener) + { + if (!_listeners.Contains(listener)) _listeners.Add(listener); + } + + public void Unregister(Action listener) + { + if (_listeners.Contains(listener)) _listeners.Remove(listener); + } + } + + [CreateAssetMenu(menuName = "Architecture/Events/Int Event", fileName = "NewIntEvent")] + public class IntEvent : GameEvent { } + + [CreateAssetMenu(menuName = "Architecture/Events/Float Event", fileName = "NewFloatEvent")] + public class FloatEvent : GameEvent { } + + [CreateAssetMenu(menuName = "Architecture/Events/Vector3 Event", fileName = "NewVector3Event")] + public class Vector3Event : GameEvent { } + + [CreateAssetMenu(menuName = "Architecture/Events/String Event", fileName = "NewStringEvent")] + public class StringEvent : GameEvent { } + + [CreateAssetMenu(menuName = "Architecture/Events/GameObject Event", fileName = "NewGameObjectEvent")] + public class GameObjectEvent : GameEvent { } + + // ===== 带载荷的监听器组件(Inspector 驱动)===== + + public class IntEventListener : MonoBehaviour + { + [SerializeField] private IntEvent _event; + [SerializeField] private UnityEvent _response; + private void OnEnable() => _event?.Register(Respond); + private void OnDisable() => _event?.Unregister(Respond); + private void Respond(int v) => _response?.Invoke(v); + } + + public class FloatEventListener : MonoBehaviour + { + [SerializeField] private FloatEvent _event; + [SerializeField] private UnityEvent _response; + private void OnEnable() => _event?.Register(Respond); + private void OnDisable() => _event?.Unregister(Respond); + private void Respond(float v) => _response?.Invoke(v); + } + + public class Vector3EventListener : MonoBehaviour + { + [SerializeField] private Vector3Event _event; + [SerializeField] private UnityEvent _response; + private void OnEnable() => _event?.Register(Respond); + private void OnDisable() => _event?.Unregister(Respond); + private void Respond(Vector3 v) => _response?.Invoke(v); + } + + public class GameObjectEventListener : MonoBehaviour + { + [SerializeField] private GameObjectEvent _event; + [SerializeField] private UnityEvent _response; + private void OnEnable() => _event?.Register(Respond); + private void OnDisable() => _event?.Unregister(Respond); + private void Respond(GameObject v) => _response?.Invoke(v); + } + + /// + /// 字符串事件监听器组件。补齐 Int/Float/Vector3/GameObject 之后遗漏的 String 通道监听器, + /// 使策划能在 Inspector 里为 StringEvent 连线响应(例如传递场景名、提示文本)。 + /// + public class StringEventListener : MonoBehaviour + { + [SerializeField] private StringEvent _event; + [SerializeField] private UnityEvent _response; + private void OnEnable() => _event?.Register(Respond); + private void OnDisable() => _event?.Unregister(Respond); + private void Respond(string v) => _response?.Invoke(v); + } +} diff --git a/unity/Assets/Architecture/Core/TypedGameEvents.cs.meta b/unity/Assets/Architecture/Core/TypedGameEvents.cs.meta new file mode 100644 index 0000000..8798283 --- /dev/null +++ b/unity/Assets/Architecture/Core/TypedGameEvents.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: Wn0Y43n4BSqQmCBSQfcVumlArfA4ONJNN2iHuAFNyRze987Ocx2MGyY= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Editor.meta b/unity/Assets/Architecture/Editor.meta new file mode 100644 index 0000000..7521ffb --- /dev/null +++ b/unity/Assets/Architecture/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: B31O5CulAS1WrdnBvd8elA4AXddHMwLYIMjEJbxVqMOjBnaYzy4S8LU= +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Editor/AssetBootstrap.cs b/unity/Assets/Architecture/Editor/AssetBootstrap.cs new file mode 100644 index 0000000..d9046e9 --- /dev/null +++ b/unity/Assets/Architecture/Editor/AssetBootstrap.cs @@ -0,0 +1,72 @@ +using UnityEngine; +using UnityEditor; +using Architecture.Core; +using Architecture.Variables; + +namespace Architecture.Editor +{ + /// + /// 一键生成 P1/P2 所需的 SO 通信资产(变量 / 事件 / 运行时集合)。 + /// 运行方式:Unity 顶部菜单 Architecture > Bootstrap Core Assets。 + /// 生成的资产位于 Assets/Architecture/Assets/,可安全删改、重新生成(已存在则跳过)。 + /// + /// 注意:本脚本仅生成"空壳资产",具体系统需在 Inspector 中将对应字段拖入这些资产实例, + /// 才能建立解耦链路(详见 Docs/解耦架构重构方案.md)。 + /// + public static class AssetBootstrap + { + private const string Root = "Assets/Architecture/Assets"; + + [MenuItem("Architecture/Bootstrap Core Assets")] + public static void Bootstrap() + { + if (!AssetDatabase.IsValidFolder(Root)) + AssetDatabase.CreateFolder("Assets/Architecture", "Assets"); + + // 共享变量(同时设置 _value 与 _defaultValue,避免跨 Play Mode 状态泄漏) + CreateInt("PlayerHealth", 5); + CreateInt("Score", 0); + + // 无参事件通道 + Create("OnPlayerDied"); + Create("OnGameOver"); + Create("OnGameWin"); + Create("OnPlayerDamaged"); + + // 带载荷事件通道 + Create("EchoReleased"); + Create("ScoreChanged"); + Create("ScoreSettled"); + + // 运行时集合(当前场景实体注册表) + Create("Players"); + Create("Enemies"); + + AssetDatabase.SaveAssets(); + Debug.Log($"[AssetBootstrap] 已生成核心 SO 资产到 {Root}"); + } + + private static T Create(string name) where T : ScriptableObject + { + var path = $"{Root}/{name}.asset"; + var existing = AssetDatabase.LoadAssetAtPath(path); + if (existing != null) + { + Debug.Log($"[AssetBootstrap] 已存在,跳过:{path}"); + return existing; + } + var asset = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(asset, path); + return asset; + } + + private static void CreateInt(string name, int value) + { + var asset = Create(name); + var so = new SerializedObject(asset); + so.FindProperty("_value").intValue = value; + so.FindProperty("_defaultValue").intValue = value; + so.ApplyModifiedProperties(); + } + } +} diff --git a/unity/Assets/Architecture/Editor/AssetBootstrap.cs.meta b/unity/Assets/Architecture/Editor/AssetBootstrap.cs.meta new file mode 100644 index 0000000..fead06b --- /dev/null +++ b/unity/Assets/Architecture/Editor/AssetBootstrap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: Dn4fvSL+Bi5qpJQPKTiLAFU3fgR+KLt4w0Ql57XB14ROp4pNfqn9n84= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Editor/VariableDrawer.cs b/unity/Assets/Architecture/Editor/VariableDrawer.cs new file mode 100644 index 0000000..9af6d02 --- /dev/null +++ b/unity/Assets/Architecture/Editor/VariableDrawer.cs @@ -0,0 +1,43 @@ +using UnityEditor; +using UnityEngine; +using Architecture.Variables; + +namespace Architecture.Editor +{ + /// + /// 变量资产的 Inspector 绘制器:在字段右侧实时显示当前值(含 Play 模式运行时值), + /// 设计师无需打开脚本即可看到数值,降低沟通成本。 + /// + [CustomPropertyDrawer(typeof(FloatVariable))] + [CustomPropertyDrawer(typeof(IntVariable))] + [CustomPropertyDrawer(typeof(BoolVariable))] + [CustomPropertyDrawer(typeof(Vector3Variable))] + public class VariableDrawer : PropertyDrawer + { + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) + { + EditorGUI.BeginProperty(position, label, property); + var target = property.objectReferenceValue as ScriptableObject; + if (target != null) + { + var so = new SerializedObject(target); + so.Update(); + var valueProp = so.FindProperty("_value"); + Rect objRect = new Rect(position.x, position.y, position.width * 0.58f, position.height); + Rect valRect = new Rect(position.x + position.width * 0.60f, position.y, position.width * 0.40f, position.height); + EditorGUI.ObjectField(objRect, property, GUIContent.none); + if (valueProp != null) + { + EditorGUI.BeginDisabledGroup(true); + EditorGUI.PropertyField(valRect, valueProp, GUIContent.none); + EditorGUI.EndDisabledGroup(); + } + } + else + { + EditorGUI.ObjectField(position, property, label); + } + EditorGUI.EndProperty(); + } + } +} diff --git a/unity/Assets/Architecture/Editor/VariableDrawer.cs.meta b/unity/Assets/Architecture/Editor/VariableDrawer.cs.meta new file mode 100644 index 0000000..cb2b6f0 --- /dev/null +++ b/unity/Assets/Architecture/Editor/VariableDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: BnMa4CioUihgI9MvBm9J06Z6TNiVXXGlVT3vne3vk2CEiePGAFaObuc= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/RuntimeSets.meta b/unity/Assets/Architecture/RuntimeSets.meta new file mode 100644 index 0000000..4939e77 --- /dev/null +++ b/unity/Assets/Architecture/RuntimeSets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: DXkasC+sU3ihXHypaAzLi0kYmkhZiiQpNsOF3Y4Ksr+Tt5M8BpbIw1w= +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/RuntimeSets/RuntimeSet.cs b/unity/Assets/Architecture/RuntimeSets/RuntimeSet.cs new file mode 100644 index 0000000..c914259 --- /dev/null +++ b/unity/Assets/Architecture/RuntimeSets/RuntimeSet.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Architecture.Core +{ + /// + /// 运行时集合:替代 FindObjectsOfType / 单例列表,零单例开销地跟踪场景实体。 + /// 实体在 OnEnable 注册、OnDisable 注销(见下方 RuntimeSetRegistrar)。 + /// 资产实例是项目级资源,跨场景存活,可安全被任何系统读取。 + /// + public abstract class RuntimeSet : ScriptableObject + { + public List Items = new List(); + + public void Add(T item) + { + if (item != null && !Items.Contains(item)) Items.Add(item); + } + + public void Remove(T item) + { + if (item != null && Items.Contains(item)) Items.Remove(item); + } + + public int Count => Items.Count; + public T this[int i] => Items[i]; + } + + /// + /// 通用 Transform 集合,作为"当前场景实体"的轻量注册表(玩家、敌人等)。 + /// 领域专用集合(如 EnemyRuntimeSet : RuntimeSet<EnemyAI>)放在对应领域文件夹, + /// 继承本类即可,无需改动核心。 + /// + [CreateAssetMenu(menuName = "Architecture/Runtime Sets/Transform Set", fileName = "NewTransformSet")] + public class TransformRuntimeSet : RuntimeSet { } + + /// + /// 挂载到任意预制体,使其自动注册进指定 RuntimeSet。 + /// 这样系统只需遍历集合,永不需要 GameObject.Find / FindObjectsOfType。 + /// + public class RuntimeSetRegistrar : MonoBehaviour + { + [SerializeField] private TransformRuntimeSet _set; + + private void OnEnable() + { + if (_set != null) _set.Add(transform); + } + + private void OnDisable() + { + if (_set != null) _set.Remove(transform); + } + } + + /// + /// 泛型注册器:领域专用强类型集合(如 EnemyRuntimeSet : RuntimeSet<EnemyAI>)使用。 + /// 自动 GetComponent<T>() 并加入对应集合,避免用 Transform 集合再手动转型。 + /// 因 Unity 对泛型 MonoBehaviour 需具体闭包类型才能挂载,领域文件夹内写一行具体子类即可: + /// public class EnemySetRegistrar : RuntimeSetRegistrar<EnemyAI> { } + /// + public class RuntimeSetRegistrar : MonoBehaviour where T : Component + { + [SerializeField] private RuntimeSet _set; + + private void OnEnable() + { + if (_set != null) _set.Add(GetComponent()); + } + + private void OnDisable() + { + if (_set != null) _set.Remove(GetComponent()); + } + } +} diff --git a/unity/Assets/Architecture/RuntimeSets/RuntimeSet.cs.meta b/unity/Assets/Architecture/RuntimeSets/RuntimeSet.cs.meta new file mode 100644 index 0000000..169c2f7 --- /dev/null +++ b/unity/Assets/Architecture/RuntimeSets/RuntimeSet.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: By8XsSukWyonwLQZ/3RNaE8FAk+70HcGhhnyFRI8oY/tcLIntpkDvH4= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Variables.meta b/unity/Assets/Architecture/Variables.meta new file mode 100644 index 0000000..299f418 --- /dev/null +++ b/unity/Assets/Architecture/Variables.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: CXMW4CP8BnnBCuaEto3WYyr2C3RLoOC5Jd5fjwZGKcsuUekgm8W5KFY= +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Variables/BoolVariable.cs b/unity/Assets/Architecture/Variables/BoolVariable.cs new file mode 100644 index 0000000..c0d144b --- /dev/null +++ b/unity/Assets/Architecture/Variables/BoolVariable.cs @@ -0,0 +1,49 @@ +using UnityEngine; +using System; + +namespace Architecture.Variables +{ + /// + /// 共享布尔变量(ScriptableObject 资产)。常用于暂停、开关等状态。 + /// + [CreateAssetMenu(menuName = "Architecture/Variables/Bool", fileName = "NewBoolVariable")] + public class BoolVariable : ScriptableObject, Architecture.IVariable + { + [SerializeField] private bool _value; + + [Tooltip("进入 Play Mode 时的初始值,由 VariableRegistry 在每次启动游戏时自动恢复,防止跨局状态泄漏")] + [SerializeField] private bool _defaultValue; + + public bool Value + { + get => _value; + set + { + if (_value == value) return; + _value = value; + OnValueChanged?.Invoke(value); + } + } + + public event Action OnValueChanged; + + public void SetValue(bool value) => Value = value; + + /// 将运行时值重置为 _defaultValue(每次进入 Play Mode 时由 VariableRegistry 自动调用)。 + public void ResetToDefault() + { + _value = _defaultValue; + OnValueChanged?.Invoke(_value); + } + + [ContextMenu("Reset To Default")] + public void Reset() => ResetToDefault(); + + private void OnEnable() => VariableRegistry.Register(this); + private void OnDisable() + { + VariableRegistry.Unregister(this); + OnValueChanged = null; + } + } +} diff --git a/unity/Assets/Architecture/Variables/BoolVariable.cs.meta b/unity/Assets/Architecture/Variables/BoolVariable.cs.meta new file mode 100644 index 0000000..2e36254 --- /dev/null +++ b/unity/Assets/Architecture/Variables/BoolVariable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: DXsdsCquB3geiEyhIkY2aFLJpbGuThlr6ky9pgS1Nnrno7aw3ZWk4Uc= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Variables/FloatVariable.cs b/unity/Assets/Architecture/Variables/FloatVariable.cs new file mode 100644 index 0000000..77703c8 --- /dev/null +++ b/unity/Assets/Architecture/Variables/FloatVariable.cs @@ -0,0 +1,52 @@ +using UnityEngine; +using System; + +namespace Architecture.Variables +{ + /// + /// 共享浮点变量(ScriptableObject 资产)。 + /// 任何系统可读取 / 写入,并由 OnValueChanged 事件驱动 UI 刷新, + /// 替代 MonoBehaviour 字段跨系统传递与逐帧反射读取。 + /// + [CreateAssetMenu(menuName = "Architecture/Variables/Float", fileName = "NewFloatVariable")] + public class FloatVariable : ScriptableObject, Architecture.IVariable + { + [SerializeField] private float _value; + + [Tooltip("进入 Play Mode 时的初始值,由 VariableRegistry 在每次启动游戏时自动恢复,防止跨局状态泄漏")] + [SerializeField] private float _defaultValue; + + public float Value + { + get => _value; + set + { + if (Mathf.Approximately(_value, value)) return; + _value = value; + OnValueChanged?.Invoke(value); + } + } + + public event Action OnValueChanged; + + public void SetValue(float value) => Value = value; + public void ApplyChange(float amount) => Value += amount; + + /// 将运行时值重置为 _defaultValue(每次进入 Play Mode 时由 VariableRegistry 自动调用)。 + public void ResetToDefault() + { + _value = _defaultValue; + OnValueChanged?.Invoke(_value); + } + + [ContextMenu("Reset To Default")] + public void Reset() => ResetToDefault(); + + private void OnEnable() => VariableRegistry.Register(this); + private void OnDisable() + { + VariableRegistry.Unregister(this); + OnValueChanged = null; + } + } +} diff --git a/unity/Assets/Architecture/Variables/FloatVariable.cs.meta b/unity/Assets/Architecture/Variables/FloatVariable.cs.meta new file mode 100644 index 0000000..d3a7c07 --- /dev/null +++ b/unity/Assets/Architecture/Variables/FloatVariable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: DntJsS6uBnP85uXqLRZji/q5FaD10/2h7b+TePWWB+tWhBvOfF7TELg= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Variables/IntVariable.cs b/unity/Assets/Architecture/Variables/IntVariable.cs new file mode 100644 index 0000000..af8dfcd --- /dev/null +++ b/unity/Assets/Architecture/Variables/IntVariable.cs @@ -0,0 +1,50 @@ +using UnityEngine; +using System; + +namespace Architecture.Variables +{ + /// + /// 共享整数变量(ScriptableObject 资产)。常用于血量、得分、魂灵数等。 + /// + [CreateAssetMenu(menuName = "Architecture/Variables/Int", fileName = "NewIntVariable")] + public class IntVariable : ScriptableObject, Architecture.IVariable + { + [SerializeField] private int _value; + + [Tooltip("进入 Play Mode 时的初始值,由 VariableRegistry 在每次启动游戏时自动恢复,防止跨局状态泄漏")] + [SerializeField] private int _defaultValue; + + public int Value + { + get => _value; + set + { + if (_value == value) return; + _value = value; + OnValueChanged?.Invoke(value); + } + } + + public event Action OnValueChanged; + + public void SetValue(int value) => Value = value; + public void ApplyChange(int amount) => Value += amount; + + /// 将运行时值重置为 _defaultValue(每次进入 Play Mode 时由 VariableRegistry 自动调用)。 + public void ResetToDefault() + { + _value = _defaultValue; + OnValueChanged?.Invoke(_value); + } + + [ContextMenu("Reset To Default")] + public void Reset() => ResetToDefault(); + + private void OnEnable() => VariableRegistry.Register(this); + private void OnDisable() + { + VariableRegistry.Unregister(this); + OnValueChanged = null; + } + } +} diff --git a/unity/Assets/Architecture/Variables/IntVariable.cs.meta b/unity/Assets/Architecture/Variables/IntVariable.cs.meta new file mode 100644 index 0000000..2612181 --- /dev/null +++ b/unity/Assets/Architecture/Variables/IntVariable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: W3octHuoWnnZAVrTuCOazcbeHxKYqo5abD3dZ/KJEBmkydHqGDyV9qE= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Variables/VariableRegistry.cs b/unity/Assets/Architecture/Variables/VariableRegistry.cs new file mode 100644 index 0000000..226d5f2 --- /dev/null +++ b/unity/Assets/Architecture/Variables/VariableRegistry.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Architecture +{ + /// + /// 共享变量统一接口:所有 Variable 资产实现它,注册表才能统一重置。 + /// + public interface IVariable + { + void ResetToDefault(); + } + + /// + /// 共享变量注册表 —— 运行时状态防泄漏的核心。 + /// + /// 问题:ScriptableObject 资产在 Editor 下跨 Play Mode 域重载会持久化其序列化值。 + /// 例如 PlayerHealth.Value 在游玩中变为 0 后退出 Play Mode,若残留为 0, + /// 下次进入 Play Mode 可能直接触发死亡(或分数残留、开关状态错乱)。 + /// + /// 解决:每个 Variable 在 OnEnable 时自动注册;每次进入 Play Mode(域加载前) + /// 由 [RuntimeInitializeOnLoadMethod] 统一调用 ResetToDefault(), + /// 将运行时值恢复到设计器设置的 _defaultValue,杜绝跨局状态泄漏。 + /// + /// 注意:RuntimeInitializeLoadType.BeforeSceneLoad 在整个 Play Mode 生命周期内只触发一次, + /// 因此同一局内跨场景加载(SceneManager.LoadScene)不会重置变量,分数等跨场景共享状态可正常保留。 + /// + public static class VariableRegistry + { + private static readonly HashSet _variables = new HashSet(); + + public static void Register(IVariable variable) + { + _variables.Add(variable); + // 注册即重置:解决 [RuntimeInitializeOnLoadMethod(BeforeSceneLoad)] 在 SO 资产 + // 尚未 OnEnable 注册前就执行 ResetAll(此时 _variables 为空)导致重置无效的问题。 + // 无论 ResetAll 何时触发,变量在「上线」瞬间都会被恢复到 _defaultValue, + // 杜绝退出 Play Mode 后 PlayerHealth 残留 0、再次进入直接触发死亡的跨局泄漏。 + variable.ResetToDefault(); + } + public static void Unregister(IVariable variable) => _variables.Remove(variable); + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + public static void ResetAll() + { + foreach (var v in _variables) + v.ResetToDefault(); + } + } +} diff --git a/unity/Assets/Architecture/Variables/VariableRegistry.cs.meta b/unity/Assets/Architecture/Variables/VariableRegistry.cs.meta new file mode 100644 index 0000000..4d1742b --- /dev/null +++ b/unity/Assets/Architecture/Variables/VariableRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: DHMdty+tWygUNpW3ZTnsgECvyUsf7TZS/Bowuhd8nTjV1gGiSvKO6z4= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Architecture/Variables/Vector3Variable.cs b/unity/Assets/Architecture/Variables/Vector3Variable.cs new file mode 100644 index 0000000..96fd1e1 --- /dev/null +++ b/unity/Assets/Architecture/Variables/Vector3Variable.cs @@ -0,0 +1,48 @@ +using UnityEngine; +using System; + +namespace Architecture.Variables +{ + /// + /// 共享 Vector3 变量(ScriptableObject 资产)。常用于位置、回声中心等。 + /// + [CreateAssetMenu(menuName = "Architecture/Variables/Vector3", fileName = "NewVector3Variable")] + public class Vector3Variable : ScriptableObject, Architecture.IVariable + { + [SerializeField] private Vector3 _value; + + [Tooltip("进入 Play Mode 时的初始值,由 VariableRegistry 在每次启动游戏时自动恢复,防止跨局状态泄漏")] + [SerializeField] private Vector3 _defaultValue; + + public Vector3 Value + { + get => _value; + set + { + _value = value; + OnValueChanged?.Invoke(value); + } + } + + public event Action OnValueChanged; + + public void SetValue(Vector3 value) => Value = value; + + /// 将运行时值重置为 _defaultValue(每次进入 Play Mode 时由 VariableRegistry 自动调用)。 + public void ResetToDefault() + { + _value = _defaultValue; + OnValueChanged?.Invoke(_value); + } + + [ContextMenu("Reset To Default")] + public void Reset() => ResetToDefault(); + + private void OnEnable() => VariableRegistry.Register(this); + private void OnDisable() + { + VariableRegistry.Unregister(this); + OnValueChanged = null; + } + } +} diff --git a/unity/Assets/Architecture/Variables/Vector3Variable.cs.meta b/unity/Assets/Architecture/Variables/Vector3Variable.cs.meta new file mode 100644 index 0000000..958c412 --- /dev/null +++ b/unity/Assets/Architecture/Variables/Vector3Variable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: BnIa4SyuVyiKWphMPOJ0zNHa5e8eoX06FNossjB+7+Ma8SaSRkVd14E= +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/unity/Assets/Editor/UIBuilder.cs b/unity/Assets/Editor/UIBuilder.cs index 1c69990..1f25173 100644 --- a/unity/Assets/Editor/UIBuilder.cs +++ b/unity/Assets/Editor/UIBuilder.cs @@ -3,6 +3,7 @@ using UnityEditor; using UnityEngine.UI; using TMPro; using GameFramework; +using Architecture.Core; /// /// 一键搭建游戏 UI:HUD Canvas + 游戏结果 Canvas + 主菜单标题。 @@ -300,6 +301,13 @@ public static class UIBuilder so.FindProperty("loseConfirmButton").objectReferenceValue = loseBtn.GetComponent public class GameManager : PersistentSingleton { - /// 游戏结束(失败)时触发。 - public static Action onGameOver; + [Header("SO 事件通道(替代 static Action 事件,Inspector 拖入对应资产)")] + [SerializeField] private GameEvent onGameOverEvent; + [SerializeField] private GameEvent onGameWinEvent; - /// 游戏胜利时触发。 - public static Action onGameWin; + [Header("SO 事件通道(玩家死亡事件,替代 HealthSystem 直接调用 GameOver)")] + [SerializeField] private GameEvent onPlayerDiedEvent; + + [Header("运行时集合(替代 FindObjectsOfType,需敌人 Prefab 挂 RuntimeSetRegistrar 指向该集合)")] + [SerializeField] private TransformRuntimeSet enemiesSet; + [SerializeField] private EnemyManager enemyManagerRef; + + private void OnEnable() + { + if (onPlayerDiedEvent != null) + onPlayerDiedEvent.Register(OnPlayerDied); + } + + private void OnDisable() + { + if (onPlayerDiedEvent != null) + onPlayerDiedEvent.Unregister(OnPlayerDied); + } + + /// 玩家死亡事件回调:触发失败流程(设置状态 + 过场 + 广播 OnGameOver)。 + private void OnPlayerDied() => GameOver(); public static GameState GameState { @@ -49,7 +70,7 @@ namespace GameFramework { if (Instance == null) return; GameState = GameState.GameOver; - onGameOver?.Invoke(); + Instance.onGameOverEvent?.Raise(); Instance.StartCoroutine(Instance.DeathTransition()); } @@ -58,7 +79,7 @@ namespace GameFramework { if (Instance == null) return; GameState = GameState.Victory; - onGameWin?.Invoke(); + Instance.onGameWinEvent?.Raise(); Instance.StartCoroutine(Instance.VictoryTransition()); } @@ -141,17 +162,32 @@ namespace GameFramework /// private void DisableAllEnemyAI() { - // 禁用所有 EnemyAI - var enemyAIs = FindObjectsOfType(); - foreach (var ai in enemyAIs) - ai.enabled = false; + int disabled = 0; + + // 优先用 Enemies 运行时集合(敌人 Prefab 需挂 RuntimeSetRegistrar 并指向该集合, + // 在 OnEnable/OnDisable 时自动注册/注销自身 Transform) + if (enemiesSet != null) + { + foreach (var t in enemiesSet.Items) + { + if (t == null) continue; + var ai = t.GetComponent(); + if (ai != null) { ai.enabled = false; disabled++; } + } + } + else + { + // 过渡期兜底:尚未接入 Enemies 集合时回退(Prefab 接入 RuntimeSetRegistrar 后可删除此分支) + var enemyAIs = FindObjectsOfType(); + foreach (var ai in enemyAIs) ai.enabled = false; + disabled = enemyAIs.Length; + } // 禁用 EnemyManager(停止生成/管理逻辑) - var enemyManager = FindObjectOfType(); - if (enemyManager != null) - enemyManager.enabled = false; + if (enemyManagerRef != null) + enemyManagerRef.enabled = false; - Debug.Log($"[GameManager] 已禁用 {enemyAIs.Length} 个 EnemyAI + EnemyManager"); + Debug.Log($"[GameManager] 已禁用 {disabled} 个 EnemyAI + EnemyManager"); } /// @@ -230,8 +266,7 @@ namespace GameFramework yield return null; - // 在全黑之上显示失败叠加层("你已迷失……" + 主界面按钮) - GameLostOverlay.Show(); + // 失败叠加层("你已迷失……")由 GameLostOverlay 订阅 OnGameOver 事件自显示,此处不再直接调用。 // 等待一帧让 UI 渲染 yield return null; diff --git a/unity/Assets/UI/GameOverScreen.cs b/unity/Assets/UI/GameOverScreen.cs deleted file mode 100644 index 56d5f6c..0000000 --- a/unity/Assets/UI/GameOverScreen.cs +++ /dev/null @@ -1,75 +0,0 @@ -using UnityEngine; -using UnityEngine.UI; - -namespace GameFramework -{ - /// - /// 游戏结束画面。 - /// 监听 GameManager.onGameOver 事件,显示结束 Canvas 并暂停游戏。 - /// 玩家按确认键或点击按钮后跳转到排行榜场景。 - /// - public class GameOverScreen : MonoBehaviour - { - [Header("Canvas")] - [SerializeField] Canvas gameOverCanvas; - - [Header("Button")] - [SerializeField] Button confirmButton; - - [Header("Audio")] - [SerializeField] AudioData gameOverSFX; - - [Header("Input")] - [SerializeField] KeyCode confirmKey = KeyCode.Return; - - void OnEnable() - { - GameManager.onGameOver += ShowGameOver; - } - - void OnDisable() - { - GameManager.onGameOver -= ShowGameOver; - } - - void Start() - { - // 初始隐藏 - if (gameOverCanvas != null) - gameOverCanvas.enabled = false; - - if (confirmButton != null) - confirmButton.onClick.AddListener(OnConfirmClick); - } - - void ShowGameOver() - { - // 失败时由 GameLostOverlay 接管 UI,这里不再显示旧 Canvas - // 暂停和鼠标由 GameManager + GameLostOverlay 统一处理 - return; - } - - void Update() - { - // 游戏结束画面可见时,按确认键跳转 - if (gameOverCanvas != null && gameOverCanvas.enabled) - { - if (Input.GetKeyDown(confirmKey) || Input.GetKeyDown(KeyCode.Space)) - { - OnConfirmClick(); - } - } - } - - void OnConfirmClick() - { - // 先解除暂停 - if (TimeController.Instance != null) - TimeController.Instance.Unpause(); - - // 跳转到排行榜场景 - if (SceneLoader.Instance != null) - SceneLoader.Instance.LoadScoringScene(); - } - } -} diff --git a/unity/Assets/UI/GameResultScreen.cs b/unity/Assets/UI/GameResultScreen.cs index 9b197cd..feeee0c 100644 --- a/unity/Assets/UI/GameResultScreen.cs +++ b/unity/Assets/UI/GameResultScreen.cs @@ -1,6 +1,7 @@ using UnityEngine; using UnityEngine.UI; using UnityEngine.SceneManagement; +using Architecture.Core; namespace GameFramework { @@ -10,9 +11,9 @@ namespace GameFramework public enum GameResult { Win, Lose } /// - /// 游戏结果界面(支持胜利/失败)。 - /// 监听 GameManager.onGameOver 和 onGameWin 事件, - /// 显示对应面板并暂停游戏。确认后跳转到排行榜场景。 + /// 游戏结果界面(仅负责「胜利」结算)。 + /// 监听 OnGameWin 事件,显示胜利面板并暂停游戏,确认后跳转到排行榜场景。 + /// 「失败」由 GameLostOverlay 接管(订阅 OnGameOver),本屏不处理失败,避免空响应。 /// public class GameResultScreen : MonoBehaviour { @@ -38,6 +39,9 @@ namespace GameFramework [Header("Input")] [SerializeField] private KeyCode confirmKey = KeyCode.Return; + [Header("SO 事件通道(胜利事件;失败由 GameLostOverlay 接管)")] + [SerializeField] private GameEvent onGameWinEvent; + private bool _isVisible; /// @@ -59,20 +63,14 @@ namespace GameFramework void OnEnable() { - GameManager.onGameOver += OnGameOver; - GameManager.onGameWin += OnGameWin; + onGameWinEvent?.Register(OnGameWin); } void OnDisable() { - GameManager.onGameOver -= OnGameOver; - GameManager.onGameWin -= OnGameWin; + onGameWinEvent?.Unregister(OnGameWin); } - void OnGameOver() - { - // 失败时由 GameLostOverlay 接管,不再显示旧失败面板 - } void OnGameWin() => ShowResult(GameResult.Win); void Start() diff --git a/unity/Assets/UI/Materials/WaterRippleFade.mat b/unity/Assets/UI/Materials/WaterRippleFade.mat index 12bd595..b0dcea3 100644 --- a/unity/Assets/UI/Materials/WaterRippleFade.mat +++ b/unity/Assets/UI/Materials/WaterRippleFade.mat @@ -59,10 +59,10 @@ Material: - _DecalMeshDepthBias: 0 - _DecalMeshViewBias: 0 - _DrawOrder: 0 - - _FadeAlpha: 0.96674436 + - _FadeAlpha: 1 - _RippleAmplitude: 0.0412 - _RippleFrequency: 10.5 - - _RippleIntensity: 0.64074624 + - _RippleIntensity: -0 - _RippleSpeed: 3 - _Shininess: 0.2 - _Stencil: 0 diff --git a/unity/Assets/UI/ScoreDisplay.cs b/unity/Assets/UI/ScoreDisplay.cs index 55a7ce5..f6917e6 100644 --- a/unity/Assets/UI/ScoreDisplay.cs +++ b/unity/Assets/UI/ScoreDisplay.cs @@ -1,5 +1,6 @@ using UnityEngine; using UnityEngine.UI; +using Architecture.Core; namespace GameFramework { @@ -13,24 +14,25 @@ namespace GameFramework [SerializeField] Text scoreText; [SerializeField] string format = "{0}"; + [Header("SO 事件通道(替代 static ScoreManager.onScoreChanged 事件)")] + [SerializeField] private IntEvent scoreChangedEvent; + void OnEnable() { - ScoreManager.onScoreChanged += UpdateText; + scoreChangedEvent?.Register(UpdateText); } void OnDisable() { - ScoreManager.onScoreChanged -= UpdateText; + scoreChangedEvent?.Unregister(UpdateText); } void Start() { if (scoreText == null) scoreText = GetComponent(); - if (ScoreManager.Instance != null) - UpdateText(ScoreManager.Instance.CurrentScore); - else - UpdateText(0); + // 初始值走事件:订阅后首个 ScoreChanged 会刷新显示;分数初始即为 0,先显示 0。 + UpdateText(0); } void UpdateText(int score) diff --git a/unity/Assets/UI/ScoreManager.cs b/unity/Assets/UI/ScoreManager.cs index 4281949..f6241fa 100644 --- a/unity/Assets/UI/ScoreManager.cs +++ b/unity/Assets/UI/ScoreManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Generic; using UnityEngine; +using Architecture.Core; namespace GameFramework { @@ -32,7 +33,7 @@ namespace GameFramework /// /// 得分管理器(持久单例)。 - /// 维护当前局得分,通过事件通知 UI,并管理 Top-10 排行榜存档。 + /// 维护当前局得分,通过 SO 事件通道通知 UI,并管理 Top-10 排行榜存档。 /// public class ScoreManager : PersistentSingleton { @@ -42,10 +43,9 @@ namespace GameFramework // 当前局得分 int currentScore = 0; - // 当得分变化时触发,参数为最新得分(动画过程中的中间值也会触发) - public static event Action onScoreChanged; - // 当得分完成最终增加时触发,参数为最终得分 - public static event Action onScoreSettled; + [Header("SO 事件通道(替代 static onScoreChanged / onScoreSettled 事件,Inspector 拖入对应资产)")] + [SerializeField] private IntEvent scoreChangedEvent; + [SerializeField] private IntEvent scoreSettledEvent; /// 当前得分(只读)。 public int CurrentScore => currentScore; @@ -75,8 +75,8 @@ namespace GameFramework public void SetScore(int value) { currentScore = Mathf.Max(0, value); - onScoreChanged?.Invoke(currentScore); - onScoreSettled?.Invoke(currentScore); + scoreChangedEvent?.Raise(currentScore); + scoreSettledEvent?.Raise(currentScore); } /// @@ -85,8 +85,8 @@ namespace GameFramework public void ResetScore() { currentScore = 0; - onScoreChanged?.Invoke(0); - onScoreSettled?.Invoke(0); + scoreChangedEvent?.Raise(0); + scoreSettledEvent?.Raise(0); } IEnumerator ScoreCountUpCoroutine(int from, int to) @@ -100,12 +100,12 @@ namespace GameFramework elapsed += Time.unscaledDeltaTime; float t = Mathf.Clamp01(elapsed / duration); int display = Mathf.RoundToInt(Mathf.Lerp(from, to, t)); - onScoreChanged?.Invoke(display); + scoreChangedEvent?.Raise(display); yield return null; } - onScoreChanged?.Invoke(to); - onScoreSettled?.Invoke(to); + scoreChangedEvent?.Raise(to); + scoreSettledEvent?.Raise(to); } #region 排行榜 diff --git a/unity/Assets/eco/EchoSystem.cs b/unity/Assets/eco/EchoSystem.cs index 3cfed30..fc02665 100644 --- a/unity/Assets/eco/EchoSystem.cs +++ b/unity/Assets/eco/EchoSystem.cs @@ -1,6 +1,7 @@ using System; using UnityEngine; using GameFramework; +using Architecture.Core; namespace IndianOceanAssets.Engine2_5D { @@ -20,11 +21,8 @@ namespace IndianOceanAssets.Engine2_5D /// public class EchoSystem : MonoBehaviour { - /// - /// 按 E 释放回声(摇铃)时触发,参数为释放时的玩家世界位置。 - /// 敌人聆听系统等可订阅此事件。 - /// - public static event Action OnEchoReleased; + [Header("SO 事件通道(替代 static OnEchoReleased 事件,参数为释放时玩家世界位置)")] + [SerializeField] private Vector3Event echoReleasedEvent; [Header("按键")] [SerializeField] private KeyCode echoKey = KeyCode.E; @@ -101,6 +99,7 @@ namespace IndianOceanAssets.Engine2_5D private float _lastEchoTime = -999f; private float _ringAlpha = 0f; private float _ringRadius = 0f; + private bool _warnedEcho = false; // 防止 echoReleasedEvent 空引用告警刷屏 private void Start() { @@ -219,7 +218,13 @@ namespace IndianOceanAssets.Engine2_5D } // 通知订阅者(敌人聆听等) - OnEchoReleased?.Invoke(p); + if (echoReleasedEvent != null) + echoReleasedEvent.Raise(p); + else if (!_warnedEcho) + { + Debug.LogWarning("[EchoSystem] echoReleasedEvent 未接线!敌人不会响应摇铃。请在 EchoSystem 的 Echo Released Event 字段拖入 EchoReleased 资产。", this); + _warnedEcho = true; + } } private void UpdateExpanding() @@ -309,6 +314,16 @@ namespace IndianOceanAssets.Engine2_5D public bool IsActive => _state != State.Idle; + /// 摇铃冷却剩余/总时长(供 HUD 显示,替代反射读取私有字段)。 + public (float remaining, float total) BellCooldown + => (Mathf.Max(0f, (_lastEchoTime + cooldown) - Time.time), cooldown); + + private void OnValidate() + { + if (echoReleasedEvent == null) + Debug.LogWarning($"[EchoSystem] Echo Released Event 未接线({gameObject.name})。按 E 摇铃时敌人不会响应。", this); + } + // ===== 编辑器可视化 ===== private void OnDrawGizmosSelected() { diff --git a/unity/Assets/eco/SpiritLanternSystem.cs b/unity/Assets/eco/SpiritLanternSystem.cs index 0ce5d75..42da0c2 100644 --- a/unity/Assets/eco/SpiritLanternSystem.cs +++ b/unity/Assets/eco/SpiritLanternSystem.cs @@ -42,6 +42,9 @@ namespace IndianOceanAssets.Engine2_5D /// 冷却剩余时间(供 UI 使用) public float CooldownRemaining => Mathf.Max(0f, (_lastPlaceTime + cooldown) - Time.time); + /// 冷却总时长(供 UI 计算 CD 比例,替代反射读取私有字段) + public float Cooldown => cooldown; + private void Start() { _remainingLanterns = maxLanterns; diff --git a/unity/Assets/enemy/EnemyAI.cs b/unity/Assets/enemy/EnemyAI.cs index 592040a..4111920 100644 --- a/unity/Assets/enemy/EnemyAI.cs +++ b/unity/Assets/enemy/EnemyAI.cs @@ -1,4 +1,5 @@ using UnityEngine; +using Architecture.Core; namespace IndianOceanAssets.Engine2_5D { @@ -46,6 +47,9 @@ namespace IndianOceanAssets.Engine2_5D [Tooltip("聆听范围:主角在此范围内按E摇铃时,敌人会朝铃铛位置移动")] [SerializeField] private float listenRange = 15f; + [Header("SO 事件通道(替代 static EchoSystem.OnEchoReleased 事件)")] + [SerializeField] private Vector3Event echoReleasedEvent; + [Header("铃铛追击")] [Tooltip("到达铃铛位置后的容差距离")] [SerializeField] private float bellArriveDistance = 1f; @@ -105,6 +109,12 @@ namespace IndianOceanAssets.Engine2_5D private HealthSystem _playerHealth; // 玩家血量引用 private Camera _mainCam; // 主相机引用(用于Billboard) + private void OnValidate() + { + if (echoReleasedEvent == null) + Debug.LogWarning($"[EnemyAI] Echo Released Event 未接线({gameObject.name})。该敌人不会响应摇铃。", this); + } + private void Start() { if (playerTarget == null) @@ -127,12 +137,12 @@ namespace IndianOceanAssets.Engine2_5D private void OnEnable() { - EchoSystem.OnEchoReleased += OnBell; + echoReleasedEvent?.Register(OnBell); } private void OnDisable() { - EchoSystem.OnEchoReleased -= OnBell; + echoReleasedEvent?.Unregister(OnBell); } private void LateUpdate() diff --git a/unity/Assets/enemy/EnemyHealthBar.cs b/unity/Assets/enemy/EnemyHealthBar.cs index 99c3ac8..6af38c5 100644 --- a/unity/Assets/enemy/EnemyHealthBar.cs +++ b/unity/Assets/enemy/EnemyHealthBar.cs @@ -1,11 +1,16 @@ using UnityEngine; +using IndianOceanAssets.Engine2_5D; namespace IndianOceanAssets.Engine2_5D { /// /// 敌人头顶血条 —— 挂在敌人 Prefab 上。 - /// 血条可视化物体(BG、Fill)已在 Prefab 中预建, - /// 运行时直接引用,无需动态创建。 + /// 血条可视化物体(BG、Fill)已在 Prefab 中预建,运行时直接引用,无需动态创建。 + /// + /// 解耦改造:直接引用同物体上的 HealthSystem(RequireComponent 保证存在), + /// 订阅其 OnHealthChanged 事件刷新,彻底移除对私有字段的反射读取。 + /// 注意:不使用"全局共享 IntVariable"承载敌人血量——否则所有敌人血条会显示同一份血量, + /// 正确的解法是"每个敌人读自己的 HealthSystem"(同物体组件引用,非 Find / 反射 / 跨对象)。 /// [RequireComponent(typeof(HealthSystem))] public class EnemyHealthBar : MonoBehaviour @@ -33,52 +38,44 @@ namespace IndianOceanAssets.Engine2_5D [Tooltip("满血时是否隐藏血条")] [SerializeField] private bool hideWhenFull = false; - private HealthSystem _healthSystem; - private int _lastHealth = -1; - private int _maxHealth; + // 同物体上的 HealthSystem(RequireComponent 保证存在;非 Find / 反射 / 跨对象引用) + private HealthSystem _healthSource; private void Start() { - _healthSystem = GetComponent(); - if (_healthSystem == null) + if (fillRenderer == null || bgRenderer == null) { enabled = false; return; } - // 获取 maxHealth(通过反射读取私有字段) - var field = typeof(HealthSystem).GetField("maxHealth", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (field != null) - _maxHealth = (int)field.GetValue(_healthSystem); + _healthSource = GetComponent(); + if (_healthSource != null) + { + _healthSource.OnHealthChanged += UpdateBar; + UpdateBar(_healthSource.CurrentHealth, _healthSource.MaxHealth); + } else - _maxHealth = 3; - - UpdateBar(); + { + UpdateBar(1, 1); + } } - private void LateUpdate() + private void OnDestroy() { - UpdateBar(); + if (_healthSource != null) + _healthSource.OnHealthChanged -= UpdateBar; } /// - /// 根据当前血量更新血条显示。 + /// 根据当前血量更新血条显示(事件驱动,无反射、无每帧 LateUpdate)。 /// - private void UpdateBar() + private void UpdateBar(int currentHealth, int maxHealth) { - if (_healthSystem == null || fillRenderer == null || bgRenderer == null) return; + if (fillRenderer == null || bgRenderer == null) return; - // 获取当前血量(反射) - var healthField = typeof(HealthSystem).GetField("health", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - int currentHealth = healthField != null ? (int)healthField.GetValue(_healthSystem) : _maxHealth; - - // 血量没变化则跳过(性能优化) - if (currentHealth == _lastHealth) return; - _lastHealth = currentHealth; - - float ratio = Mathf.Clamp01((float)currentHealth / _maxHealth); + if (maxHealth <= 0) maxHealth = 1; + float ratio = Mathf.Clamp01((float)currentHealth / maxHealth); // 更新填充条缩放(居中对齐) fillRenderer.transform.localScale = new Vector3(barWidth * ratio, barHeight, 1f); diff --git a/unity/Assets/enemy/Prefabs/Enemy1.prefab b/unity/Assets/enemy/Prefabs/Enemy1.prefab index 3cce9bc..abbfe23 100644 --- a/unity/Assets/enemy/Prefabs/Enemy1.prefab +++ b/unity/Assets/enemy/Prefabs/Enemy1.prefab @@ -439,6 +439,7 @@ MonoBehaviour: maxSightDistance: 4 loseSightTime: 3 listenRange: 15 + echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2} bellArriveDistance: 1 bellChaseTimeout: 10 bellPatrolTime: 5 @@ -477,11 +478,16 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3} m_Name: m_EditorClassIdentifier: + onPlayerDamagedEvent: {fileID: 0} + onPlayerDiedEvent: {fileID: 0} + healthVar: {fileID: 0} + maxHealthVar: {fileID: 0} maxHealth: 3 deathEffect: {fileID: 0} isPlayer: 0 dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2} fadeDuration: 2 + damageInvincibleDuration: 2 --- !u!114 &1893771889199554109 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/unity/Assets/enemy/Prefabs/Enemy2.prefab b/unity/Assets/enemy/Prefabs/Enemy2.prefab index e58052f..dc6e424 100644 --- a/unity/Assets/enemy/Prefabs/Enemy2.prefab +++ b/unity/Assets/enemy/Prefabs/Enemy2.prefab @@ -351,6 +351,7 @@ MonoBehaviour: maxSightDistance: 4 loseSightTime: 3 listenRange: 15 + echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2} bellArriveDistance: 1 bellChaseTimeout: 10 bellPatrolTime: 5 @@ -389,11 +390,16 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3} m_Name: m_EditorClassIdentifier: + onPlayerDamagedEvent: {fileID: 0} + onPlayerDiedEvent: {fileID: 0} + healthVar: {fileID: 0} + maxHealthVar: {fileID: 0} maxHealth: 3 deathEffect: {fileID: 0} isPlayer: 0 dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2} fadeDuration: 2 + damageInvincibleDuration: 2 --- !u!114 &1893771889199554109 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/unity/Assets/enemy/Prefabs/Enemy3.prefab b/unity/Assets/enemy/Prefabs/Enemy3.prefab index beaf197..053aeff 100644 --- a/unity/Assets/enemy/Prefabs/Enemy3.prefab +++ b/unity/Assets/enemy/Prefabs/Enemy3.prefab @@ -351,6 +351,7 @@ MonoBehaviour: maxSightDistance: 4 loseSightTime: 3 listenRange: 15 + echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2} bellArriveDistance: 1 bellChaseTimeout: 10 bellPatrolTime: 5 @@ -389,11 +390,16 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3} m_Name: m_EditorClassIdentifier: + onPlayerDamagedEvent: {fileID: 0} + onPlayerDiedEvent: {fileID: 0} + healthVar: {fileID: 0} + maxHealthVar: {fileID: 0} maxHealth: 3 deathEffect: {fileID: 0} isPlayer: 0 dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2} fadeDuration: 2 + damageInvincibleDuration: 2 --- !u!114 &1893771889199554109 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/unity/Assets/enemy/Prefabs/Enemy4.prefab b/unity/Assets/enemy/Prefabs/Enemy4.prefab index 0fb5685..b2e1093 100644 --- a/unity/Assets/enemy/Prefabs/Enemy4.prefab +++ b/unity/Assets/enemy/Prefabs/Enemy4.prefab @@ -439,6 +439,7 @@ MonoBehaviour: maxSightDistance: 4 loseSightTime: 3 listenRange: 15 + echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2} bellArriveDistance: 1 bellChaseTimeout: 10 bellPatrolTime: 5 @@ -477,11 +478,16 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3} m_Name: m_EditorClassIdentifier: + onPlayerDamagedEvent: {fileID: 0} + onPlayerDiedEvent: {fileID: 0} + healthVar: {fileID: 0} + maxHealthVar: {fileID: 0} maxHealth: 3 deathEffect: {fileID: 0} isPlayer: 0 dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2} fadeDuration: 2 + damageInvincibleDuration: 2 --- !u!114 &1893771889199554109 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/unity/Assets/enemy/Prefabs/Enemy5.prefab b/unity/Assets/enemy/Prefabs/Enemy5.prefab index 73e8c3f..43a2c9f 100644 --- a/unity/Assets/enemy/Prefabs/Enemy5.prefab +++ b/unity/Assets/enemy/Prefabs/Enemy5.prefab @@ -351,6 +351,7 @@ MonoBehaviour: maxSightDistance: 4 loseSightTime: 3 listenRange: 15 + echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2} bellArriveDistance: 1 bellChaseTimeout: 10 bellPatrolTime: 5 @@ -389,11 +390,16 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3} m_Name: m_EditorClassIdentifier: + onPlayerDamagedEvent: {fileID: 0} + onPlayerDiedEvent: {fileID: 0} + healthVar: {fileID: 0} + maxHealthVar: {fileID: 0} maxHealth: 3 deathEffect: {fileID: 0} isPlayer: 0 dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2} fadeDuration: 2 + damageInvincibleDuration: 2 --- !u!114 &1893771889199554109 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/unity/Docs/解耦架构重构方案.md b/unity/Docs/解耦架构重构方案.md new file mode 100644 index 0000000..8263f24 --- /dev/null +++ b/unity/Docs/解耦架构重构方案.md @@ -0,0 +1,264 @@ +# 解耦架构重构方案(Unity Architecture Refactoring Plan) + +> 适用范围:`gold_dolphin/unity` 项目全局 +> 设计准则:ScriptableObject 优先、单一职责、零 `GameObject.Find`、无静态单例调用方 +> 配套代码:`Assets/Architecture/`(已落地,增量、零侵入,未改动任何现有文件) +> 版本:v1.1 —— 已根据同行评审(2 份意见)修正严重度、行号与遗漏点,见 §1.3 + +--- + +## 0. 执行摘要 + +当前项目**已经出现典型的耦合腐烂信号**:7 个跨场景单例、`GameObject.Find/FindWithTag/FindObjectsByType` 散落在运行时代码中(含 `GameHUD.Update` 里 `_playerHealth == null` 时的**条件性** `FindWithTag` 补救)、UI 通过**反射读取私有字段**来刷新(`GameHUD` 与 `EnemyHealthBar` 两处)、God Class(`GameManager`/`HealthSystem`)身兼数职、系统间通过 `static Action` 事件 + 单例方法互相调用。 + +这套结构在小规模时跑得动,但已直接导致你正在经历的维护困难: +- 场景重载后引用丢失(`GameHUD` 在 `Update` 中 `_playerHealth == null` 时才 `FindWithTag` 补救——属于条件性回退,并非每帧都查,但本质仍是反模式) +- 改一个类名/字段名就引发连锁编译或**静默运行时失效**(反射那几处) +- 新增系统必须知道"谁是谁"(单例类名、Player 标签),无法并行开发 + +**目标架构**:以 ScriptableObject 为"通信总线",所有共享状态放进 Variable 资产、所有跨系统消息走 Event 通道、所有实体集合用 RuntimeSet 跟踪。MonoBehaviour 只做"执行者",且**永不直接引用彼此的实现类**——它们只认 SO 资产引用。 + +--- + +## 1. 现状诊断(Architecture Audit) + +### 1.1 反模式清单(含证据) + +| 反模式 | 位置 | 具体表现 | +|---|---|---| +| **跨场景单例** | `GameManager : PersistentSingleton`、`AudioManager`、`ScoreManager`、`SceneLoader`、`TimeController`;`EnemyManager`/`LightMaskSystem` 用 `static Instance` | 调用方写死 `XxxManager.Instance`,系统间强耦合,且 `DontDestroyOnLoad` 引发跨场景引用生命周期问题 | +| **GameObject.Find / FindWithTag / FindObjectsByType** | `GameManager.cs:81,145,150,165`、`GameHUD.cs:49,76,104-115`、`EnemyAI.cs:112`、`MainMenuUIController.cs:55,61,66,143`、`GameLostOverlay.cs:85,273`、`BoundaryWallGenerator.cs:171`、`SceneInteraction.cs:49`、`SpawnPointGenerator.cs:263,293` | 依赖场景层级、对象名、标签。**`GameHUD.Update`(`GameHUD.cs:104`)仅在 `_playerHealth == null` 时才 `FindWithTag("Player")` 补救——条件性回退,非每帧**;`GameHUD.cs:76` 在 `Start` 里 `FindObjectOfType()` 未发现则动态 `new GameObject` 创建,增加 `Start` 复杂度;`EnemyAI.cs:112` 的 `FindWithTag` **仅在 `Start` 执行一次**并缓存 `_playerHealth`,与 `GameHUD` 的高频补救需区分;`MainMenuUIController.cs:55` 的 `FindObjectsByType`、`:61/66` 的 `GameObject.Find("SettingsPanel"/"CreditsPanel")`、`FindButton()` 内 `GameObject.Find(name)` 属主菜单场景的对象名耦合 | +| **反射读取私有字段** | `GameHUD.cs:150-152`(health)、`170-176`(rollCooldown/lastRollTime)、`183-189`(cooldown/_lastEchoTime)、`197-199`(lantern cooldown);`EnemyHealthBar.cs:50-53`(读 maxHealth)、`73-75`(读 health) | 用 `typeof(X).GetField("xxx", NonPublic\|Instance)` 读私有字段;字段一改名即**静默失效**。`GameHUD`、`EnemyHealthBar` 均在每帧更新(`EnemyHealthBar` 在 `LateUpdate`)里读 `health`/`maxHealth`,既有性能开销,又对字段改名极度脆弱(初稿仅列 GameHUD,EnemyHealthBar 已补) | +| **God Class** | `GameManager.cs`(约 295 行,6 项职责:状态/过场/敌人控制/分数/场景/输入分发)、`HealthSystem.cs`(约 216 行,健康+受伤+无敌+死亡+溶解动画+粒子) | 一处改动牵动全局,难以测试 | +| **轻度耦合(非 God Class)** | `PlayerController`(`Player.cs`,约 112 行,仅移动+翻滚;`RequireComponent` 为 **3 个** 服务系统:HealthSystem/SwordAttack、ProjectileShooter、SpiritLanternSystem) | 体量可控,主要问题是 `RequireComponent` 把子系统的实现类硬绑到玩家预制体上;可在 P4 轻量剥离,不必列为重度重构对象(初稿误归为 God Class,已降级) | +| **static 事件伪总线** | `GameManager.onGameOver`/`onGameWin`(`GameManager.cs:20` 声明、`:61` 触发)、`HealthSystem.onPlayerDamaged`、`EchoSystem.OnEchoReleased`、`ScoreManager.onScoreChanged/onScoreSettled` | 全局无类型资产、不可在 Inspector 配置、跨场景订阅清理脆弱(`GameHUD` 在 Update 里二次订阅补洞);`onGameWin` 被 `GameResultScreen.cs:63,69` 监听(初稿漏列) | +| **直接跨实体耦合** | `EnemyAI.cs:116` 缓存 `_playerHealth = player.GetComponent()`,`:232` 直接 `_playerHealth.Damage()` | 敌人紧耦合玩家实现类;玩家死亡时 `HealthSystem.cs:82` 又反向调 `GameManager.GameOver()` —— 双向硬依赖(注:初稿误写为 `:230`,实际调用在 `:232`) | +| **直接单例调用(覆盖面比初稿更广)** | `ScorePickup.cs:39`、`SoulDrop.cs:110` 直接 `ScoreManager.Instance.AddScore(...)`;`ScoringUIController.cs` 同时依赖 `ScoreManager.Instance`/`AudioManager.Instance`/`SceneLoader.Instance`;`MainMenuUIController.cs:206,216` 调 `SceneLoader.Instance.LoadGameplayScene()`;`GameLostOverlay.cs:246` 调 `SceneLoader.Instance.LoadMainMenuScene()` | 调用方写死单例类名,跨系统强耦合;`SoulDrop.cs` 甚至在无 `ScoreManager` 时 `new GameObject` 自建——单例缺失的运行时补洞,比显式依赖更危险 | +| **Resources.Load** | `AudioManager.cs:121`、`StoryPVPlayer.cs:141` | 资源路径硬编码,无法做分包/热更,构建裁剪风险 | +| **Shader.Find(同类硬编码)** | `GameLostOverlay.cs:358` `Shader.Find("GameFramework/UI/WaterRippleFade")` | 与 `Resources.Load` 同属"字符串硬编码资源路径",构建裁剪 / 资源改名即失效,归入 P5 一并治理;`GameLostOverlay.cs:85` 还用 `FindObjectOfType()` 做单体自检查 | + +### 1.2 耦合地图(谁通过什么找谁) + +``` +[EnemyAI] --FindWithTag("Player")【仅 Start 一次 :112】--> [Player] +[EnemyAI] --GetComponent().Damage()【:232】--> [Player.HealthSystem] +[GameHUD] --FindWithTag【_playerHealth==null 时 :104】+ 反射--> [Player] 各子系统 +[GameHUD] --FindObjectOfType :76 + 动态 new GameObject--> [DamageFlashOverlay] +[GameHUD] --ScoreManager.Instance + onScoreChanged--> [ScoreManager] +[EnemyHealthBar] --反射读 HealthSystem 私有字段 maxHealth/health :50/73--> [HealthSystem] +[GameManager] --FindObjectsOfType()--> 所有敌人 +[GameManager] --FindWithTag + GetComponent 逐个禁用--> [Player] 子组件 +[HealthSystem] --GameManager.GameOver()--> [GameManager] +[HealthSystem/Echo/Player/Lantern] --AudioManager.Instance.PlayX()--> [AudioManager] +[ScorePickup]/[SoulDrop] --ScoreManager.Instance.AddScore()--> [ScoreManager] +[ScoringUIController] --ScoreManager.Instance / AudioManager.Instance / SceneLoader.Instance--> [三服务] +[MainMenuUIController] --SceneLoader.Instance.LoadGameplayScene() :206/216--> [SceneLoader] +[MainMenuUIController] --GameObject.Find / FindObjectsByType【对象名耦合】--> [主菜单场景物体] +[GameLostOverlay] --SceneLoader.Instance.LoadMainMenuScene() :246--> [SceneLoader] +[GameLostOverlay] --Shader.Find :358 + FindObjectOfType 自检查 :85--> [Shader / 自身] +[GameResultScreen] --onGameWin / onGameOver 静态事件--> [GameManager] +[各系统] --static event--> 订阅方(无资产、无可见性) +``` + +**根因**:所有系统都把"另一个具体类/具体场景物体"当成了通信媒介,而不是把"消息"和"状态"抽象成独立资产。 + +### 1.3 评审补充(peer-review 修正记录) + +本方案经 2 份同行评审,以下为已采纳的修正,供后续读者对照: +- **严重度下调**:初稿将 `GameHUD` 的 `FindWithTag` 描述为"每帧"。经核实为 `_playerHealth == null` 时的**条件性回退**(`GameHUD.cs:104-115`),已下调严重度;但本质仍是反模式。 +- **降级 `PlayerController`**:从 God Class 降级为"轻度耦合"(约 112 行、仅移动+翻滚、`RequireComponent` 为 3 个服务系统,非初稿所述的 4 个)。 +- **行号修正**:`EnemyAI` 的 `.Damage()` 调用在 `:232`(非初稿写的 `:230`);其 `FindWithTag` 仅在 `Start` 一次(`:112`),与 `GameHUD` 需区分。 +- **补充遗漏点**:`EnemyHealthBar` 反射读私有字段;`GameHUD` `Start` 里 `FindObjectOfType`;`onGameWin` 静态事件被 `GameResultScreen` 监听;`GameLostOverlay` 的 `Shader.Find`;`MainMenuUIController` 的对象名耦合;`ScorePickup`/`SoulDrop`/`ScoringUIController`/`MainMenuUIController`/`GameLostOverlay` 对 `ScoreManager`/`SceneLoader` 的直接单例调用线。 +- **配套代码据评审补强**:`TypedGameEvents` 已增加 `StringEventListener`(初稿声称有但未实现);`RuntimeSet` 已增加泛型 `RuntimeSetRegistrar`(强类型集合用),详见 §2.3。 + +--- + +## 2. 目标架构(Target Architecture) + +### 2.1 分层 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ L0 SO 通信总线(项目级资产,跨场景天然存活,无 MonoBehaviour) │ +│ · Event 通道:GameEvent / IntEvent / Vector3Event ... │ +│ · Variable 资产:FloatVariable / IntVariable / BoolVariable │ +│ · RuntimeSet:玩家集 / 敌人集 / 灵灯集 │ +└───────────────┬───────────────────────────┬─────────────────┘ + │ 监听/触发(Inspector 引用) │ 注册/读取 + ┌───────▼────────┐ ┌───────▼────────┐ + │ L1 系统服务 │ │ L2 实体 │ + │ (MonoBehaviour) │ │ Player/Enemy/ │ + │ Audio/Scene/Time │ │ Lantern │ + │ Spawn/Transition │ │ │ + └───────┬────────┘ └───────┬────────┘ + │ 触发事件 │ 触发事件 + └─────────────┬──────────────┘ + ┌───────▼────────┐ + │ L3 表现层 │ + │ HUD / Overlay │ + │ 只读 Variable + │ + │ 监听 Event │ + └─────────────────┘ +``` + +**关键变化**:L1/L2/L3 之间**没有任何直接的类引用**。它们只通过 L0 的 SO 资产通信。谁触发了什么、谁在监听,全部在 Inspector 里可见、可配、可审计。 + +### 2.2 四条铁律 + +1. **调用方不碰单例**:需要某个服务时,不是 `AudioManager.Instance.PlayX()`,而是 `audioEvent.Raise(clip)`。服务类自己订阅该 Event 资产。 +2. **生产代码零 `GameObject.Find`**:找实体走 `RuntimeSet`(遍历集合);找玩家走 `PlayerRuntimeSet`(玩家生成时注册)。 +3. **共享状态进 Variable**:血量/得分/魂灵数/冷却剩余 全是 SO 资产,UI 订阅 `OnValueChanged`,不再反射、不再逐帧 Find。 +4. **一个 MonoBehaviour 一件事**:`GameManager` 拆成状态机 + 过场 + 敌人控制;`HealthSystem` 拆成健康逻辑 + 死亡表现;`PlayerController` 仅剥离 `RequireComponent` 对子系统实现类的硬绑。 + +### 2.3 已落地的基础原语(`Assets/Architecture/`) + +| 文件 | 作用 | +|---|---| +| `Core/GameEvent.cs` | 无参事件通道 + `GameEventListener`(Inspector 配置响应) | +| `Core/TypedGameEvents.cs` | 泛型 `GameEvent` + `Int/Float/Vector3/String/GameObject` 五类通道;监听器组件 `Int/Float/Vector3/GameObject/StringEventListener`(初稿遗漏的 `StringEventListener` 已补) | +| `Variables/FloatVariable.cs` `IntVariable.cs` `BoolVariable.cs` `Vector3Variable.cs` | 共享变量资产,带 `OnValueChanged` 事件与 `ContextMenu` 重置 | +| `RuntimeSets/RuntimeSet.cs` | 泛型 `RuntimeSet` + `TransformRuntimeSet` + `RuntimeSetRegistrar`(Transform 集合自动注册)+ 泛型 `RuntimeSetRegistrar`(强类型集合如 `EnemyRuntimeSet` 用,领域文件夹写一行具体子类即可) | +| `Editor/VariableDrawer.cs` | 变量资产在 Inspector 实时显示当前值(含 Play 模式) | + +配套资产(在 Unity 里右键 Create)示例: +`Assets/ScriptableObjects/Events/OnPlayerDamaged.asset`、`.../Variables/PlayerHealth.asset`、`.../RuntimeSets/Enemies.asset`。 + +> **关于 `RuntimeSetRegistrar` 的限制说明(评审点)**:初版仅 `RuntimeSetRegistrar` 支持 `TransformRuntimeSet`。若 P2 建立强类型集合(如 `EnemyRuntimeSet : RuntimeSet`),应使用泛型 `RuntimeSetRegistrar` 的闭包子类: +> ```csharp +> // 放在 enemy 文件夹,领域专用 +> public class EnemySetRegistrar : RuntimeSetRegistrar { } +> ``` +> 这样敌人生成时自动 `GetComponent()` 并加入集合,无需退回 Transform 集合再手动转型。 + +--- + +## 3. 逐条改造映射(Before → After) + +### 3.1 玩家引用:`FindWithTag("Player")` → `PlayerRuntimeSet` + +```csharp +// ❌ 现有 +var player = GameObject.FindWithTag("Player"); +_playerHealth = player.GetComponent(); + +// ✅ 新增 PlayerRuntimeSet : RuntimeSet(放在 enemy/或 player 文件夹) +// 玩家预制体挂 RuntimeSetRegistrar 并指向该 Set +// 任意系统: +public class EnemyAI : MonoBehaviour +{ + [SerializeField] private TransformRuntimeSet _players; // 拖入 Player Set 资产 + private Transform _player; + private void Awake() => _player = _players.Items.Count > 0 ? _players.Items[0] : null; +} +``` +> 敌人不再假设"场景里一定有个带 Player 标签的物体",也无需每帧 Find。若用强类型集合(如 `EnemyRuntimeSet : RuntimeSet`),挂 `RuntimeSetRegistrar` 的闭包子类即可,无需退回 Transform 集合再转型。 + +### 3.2 敌人受伤:紧耦合 `HealthSystem.Damage()` → 事件 / 引用集 + +`EnemyAI` 仍可直接持有玩家 `HealthSystem` 引用(在注册时通过 Set 取得),但**不建议跨实体直接调用**。更干净的做法:伤害走"玩家健康"由玩家自己管理,敌人只负责"发起攻击意图"——例如提升一个 `FloatVariable` 或触发 `DamageEvent`。本方案第一步先解决查找问题,第二步再抽伤害意图。 + +### 3.3 玩家血量显示:反射 → `IntVariable` + 订阅 + +```csharp +// ❌ 现有:GameHUD.Update 里 typeof(HealthSystem).GetField("health", NonPublic|Instance) +// ✅ HealthSystem 写入 SO 变量;GameHUD 订阅 +[SerializeField] private IntVariable _playerHealth; // 拖入 PlayerHealth.asset +private void OnEnable() { _playerHealth.OnValueChanged += UpdateLifeIcons; UpdateLifeIcons(_playerHealth.Value); } +private void OnDisable() => _playerHealth.OnValueChanged -= UpdateLifeIcons; +// UpdateLifeIcons 只读 _playerHealth.Value,零反射、零 Find +``` +> `EnemyHealthBar`(`EnemyHealthBar.cs:50-53,73-75`)同理:让 `HealthSystem` 暴露 `maxHealth`/`health` 的 `IntVariable`,血条订阅刷新,彻底消灭两处反射。 + +### 3.4 游戏结束:static `onGameOver` → `GameOverEvent` 资产 + +```csharp +// ❌ HealthSystem.cs:82 → GameManager.GameOver() (反向硬依赖 + 单例) +// ✅ HealthSystem 只管健康;血量归零时触发事件资产 +[SerializeField] private GameEvent _onPlayerDied; +// 在 health<=0 且 isPlayer 时:_onPlayerDied.Raise(); +// GameOverTransition 组件监听该 Event → 播放过场 → 再 Raise LoadSceneEvent +``` +`GameManager` 拆为:`GameStateController`(持有 `GameState` 枚举变量)、`GameOverTransition`、`VictoryTransition`、`EnemyPauseOnGameOver`(监听事件,遍历 `EnemyRuntimeSet` 禁用,替代 `FindObjectsOfType`)。`onGameWin` 同样改为 `VictoryEvent` 资产,`GameResultScreen` 改为监听该资产事件。 + +### 3.5 音频:`AudioManager.Instance.PlayX()` → `AudioEvent` 通道 + +```csharp +// ❌ 各系统:AudioManager.Instance.PlaySFXFromResources("SFX/Bell", 0.9f); +// ✅ 定义 AudioEvent : GameEvent 资产;系统 Raise;AudioManager 订阅并播放 +[SerializeField] private AudioEvent _bellSfx; +// 播放处:_bellSfx.Raise(bellClip); // clip 用 SO 引用或 Addressables,不再 Resources.Load +``` +`AudioManager` 的 MonoBehaviour 保留(它需要持有 AudioSource、跑播放逻辑),但**外部不再通过单例调它**——它只是某个 Event 资产的订阅者。 + +### 3.6 分数:`ScoreManager.onScoreChanged` → `Score IntVariable` + +分数本质是"共享值",用 `IntVariable` 比事件更自然。HUD 订阅 `OnValueChanged` 刷新;排行榜逻辑留在 `ScoreManager`(改为写该 Variable)。`ScorePickup`/`SoulDrop` 不再 `ScoreManager.Instance.AddScore(...)`,改为对 `Score IntVariable` `ApplyChange(...)`;缺失时也不会运行时 `new GameObject` 自建。 + +### 3.7 场景切换 / 暂停:`SceneLoader.Load()` / `TimeController.Pause()` → Event 资产 + +UI 按钮只 `loadSceneEvent.Raise("Gameplay")` / `pauseEvent.Raise()`;`SceneLoader`、`TimeController` 退化为事件订阅者(仍保留 MonoBehaviour 负责协程与 `Time.timeScale`)。场景名放进 SO 配置(`SceneList` 资产),消除 `"Gameplay"/"Scoring"` 魔法字符串。`MainMenuUIController`/`GameLostOverlay`/`ScoringUIController` 的 `SceneLoader.Instance` 调用全部改走事件通道。 + +### 3.8 回声:`EchoSystem.OnEchoReleased` → `Vector3Event` 资产 + +`EnemyAI.OnBell` 改为订阅 `EchoEvent`(带位置载荷)的 `Register(Action)`,彻底解耦对 `EchoSystem` 类的依赖。 + +### 3.9 着色器引用:`Shader.Find` → 资产引用(P5) + +`Shader.Find` 与 `Resources.Load` 同类,归入 P5。实际项目中共 **5 处**,应于 Inspector 中将 shader 作为 `Shader` 字段拖入(构建管线可静态识别,避免 shader stripping 将其剔除导致运行时 fallback 成粉色错误着色器): +- `GameLostOverlay.cs:358` — `Shader.Find("GameFramework/UI/WaterRippleFade")` +- `EchoSystem.cs:140` — `Shader.Find("IndianOcean/EchoRing")`(**已落地 `ringShader` 序列化字段**) +- `GroundBuilder.cs:180` — `Shader.Find("IndianOcean/AbyssEdgeGlow")` +- `CliffWallBuilder.cs:108` — `Shader.Find("IndianOcean/AbyssEdgeGlow")` +- `GroundClipTool.cs:19` — `Shader.Find("Custom/SpriteWithGroundClip")`(**Editor 脚本,运行时不进包,可保留,但建议同样改字段引用**) + +### 3.10 变量跨局重置(VariableRegistry,P0/P1 必做) + +SO 变量(IntVariable/FloatVariable 等)在 Editor 下是**跨 Play Mode 域重载持久化**的。若 Play Mode 退出时 `PlayerHealth.Value` 残留 0,下次启动会直接触发死亡——这是 SO 架构常见坑。 +已在 `Assets/Architecture/Variables/VariableRegistry.cs` 解决: +- 变量实现 `IVariable` 接口,含 `ResetToDefault()`; +- `VariableRegistry` 在 `[RuntimeInitializeOnLoadMethod(BeforeSceneLoad)]` 中于每次进入 Play Mode 时统一 `ResetToDefault()`; +- `AssetBootstrap` 生成变量时同时写入 `_value` 与 `_defaultValue`,保证重置目标正确。 +(注:该回调在跨场景加载时只触发一次,故分数等跨场景共享状态可保留。) + +--- + +## 4. 分阶段迁移路线图(Phased Roadmap) + +每个阶段独立、可回滚、可编译,不要求一次性大改。 + +| 阶段 | 目标 | 关键动作 | 风险 | 验证 | +|---|---|---|---|---| +| **P0 地基** | 引入通信总线 | 已落地 `Assets/Architecture/`,在 Unity 创建首批 Event/Variable/RuntimeSet 资产 | 极低(增量) | 编译通过,无现有代码改动 | +| **P1 事件去静态化** | 消灭 `static Action` 事件 | 把 `onGameOver/onGameWin/onPlayerDamaged/onScoreChanged/onScoreSettled/OnEchoReleased` 改为对应 SO 事件资产,1:1 替换订阅 | 低 | 各触发点行为不变 | +| **P2 去掉 Find** | 消灭 `GameObject.Find/FindWithTag/FindObjectsOfType` | 建 `PlayerRuntimeSet`/`EnemyRuntimeSet`/`LanternRuntimeSet`,预制体挂 `RuntimeSetRegistrar`(或泛型闭包子类);改写 `GameHUD`/`EnemyAI`/`GameManager`/`MainMenuUIController`/`GameLostOverlay`/`EnemyHealthBar` 的查找与反射 | 中 | Play 模式跑通;`GameHUD` 不再在 Update 里 Find;`EnemyHealthBar` 反射消除 | +| **P3 去单例调用方** | 调用方不再 `XxxManager.Instance` | `AudioManager/SceneLoader/TimeController/ScoreManager` 改为事件订阅者;调用方改 `Raise`;`ScorePickup`/`SoulDrop`/`ScoringUIController` 改走 Variable/Event | 中 | 全功能回归测试 | +| **P4 拆分 God Class** | 单一职责 | `GameManager`→状态机+过场+敌人控制;`HealthSystem`→健康+死亡表现;`PlayerController` 仅轻度重构(剥离 `RequireComponent` 对子系统实现类的硬绑,改由 SO 引用/事件驱动,非重度重写) | 中高 | 单元/手动测试每个拆分组件 | +| **P5 资源与配置** | 去 `Resources.Load`/`Shader.Find` 与魔法串 | 音频/视频→**SerializeField 直接引用(AudioClip/VideoClip 拖到 SO/组件)**,**不引入 Addressables**(项目体量下工程复杂度过高);`Shader.Find` **共 5 处**→序列化 `Shader` 字段引用(GameLostOverlay:358 / EchoSystem:140 已有 ringShader / GroundBuilder:180 / CliffWallBuilder:108 / GroundClipTool:19 为 Editor 脚本);场景名/标签进 SO 配置 | 中 | 构建后资源不缺失 | +| **P6 工具与守门** | 防回归 | `VariableDrawer`(已落地);加构建期校验脚本(扫描生产代码 `GameObject.Find` 报错);设计师 SO 配置文档 | 低 | CI / 构建时报错拦截 | + +**建议节奏**:P0→P1→P2 可在一个迭代内完成(收益最大、风险最低);P3/P4 按系统逐个推进;P5/P6 与功能开发并行。 + +--- + +## 5. 设计师赋能(Designer Empowerment) + +- 所有 SO 均已 `[CreateAssetMenu]`,策划/美术右键即可创建事件、变量、集合,**无需写代码**。 +- `VariableDrawer` 让 Inspector 实时显示变量当前值(含 Play 模式),数值调试不再开脚本。 +- 事件连线在 Inspector 可见:谁监听 `OnPlayerDamaged`、谁触发 `LoadSceneEvent` 一目了然,便于排查"为什么没反应"。 +- 建议建立 `Assets/ScriptableObjects/{Events,Variables,RuntimeSets}` 目录规约,按领域分子文件夹。 + +--- + +## 6. 风险与回滚 + +- 所有新增代码位于 `Assets/Architecture/`,**未触碰任何现有文件**,可整体删除回滚。 +- 迁移过程保持"旧接口可用、新接口并行",每个阶段独立验证,避免大爆炸式重写。 +- 事件通道为引用语义:误删资产会在 Inspector 显示缺失引用(编译期可查),不会静默失效——比反射/单例安全得多。 + +--- + +## 7. 下一步 + +1. 在 Unity 中创建首批资产:`PlayerHealth`(Int)、`Score`(Int)、`OnPlayerDied`(GameEvent)、`Victory`(GameEvent)、`Echo`(Vector3Event)、`Enemies`(EnemyRuntimeSet)、`Players`(TransformRuntimeSet)。 +2. 选一个最小系统(建议从 `GameHUD` 接 `PlayerHealth` 变量 + 消灭 `EnemyHealthBar` 反射开始)验证 P1+P2 链路。 +3. 跑通后按路线图逐阶段推进;需要我直接改造某个具体系统时,指认文件即可。 diff --git a/unity/Docs/解耦重构Review清单.md b/unity/Docs/解耦重构Review清单.md new file mode 100644 index 0000000..ac57990 --- /dev/null +++ b/unity/Docs/解耦重构Review清单.md @@ -0,0 +1,200 @@ +# 解耦架构重构 — Review 清单 + +> 范围:P0 地基 + P1 事件去静态化 + P2a/P2b 去反射/Find + P5(Shader.Find 部分) +> 状态:代码已全部落地(静态校验通过),**尚未在 Unity 内编译/接线/回归**。 +> 用法:逐项勾选。每条都标注了「文件 / 风险等级 / 需核对的点」。 + +--- + +## 0. 改动总览 + +| 类别 | 文件数 | 说明 | +|------|--------|------| +| 新增架构框架 | 7 | `Assets/Architecture/` 下:Core / RuntimeSets / Variables / Editor | +| 修改发布方 | 4 | GameManager / HealthSystem / ScoreManager / EchoSystem | +| 修改订阅方 | 5 | GameResultScreen / GameOverScreen / DamageFlashOverlay / ScoreDisplay / EnemyAI | +| 去反射/Find 重写 | 3 | GameHUD / EnemyHealthBar / Player(属性) | +| Shader.Find 修复 | 3 | GameLostOverlay / GroundBuilder / CliffWallBuilder | +| 编辑器工具 | 1 | UIBuilder(自动连线事件资产) | +| 文档 | 2 | `plans/quantum-pulse-turing.md` + `Docs/解耦架构重构方案.md` | + +--- + +## 1. 新增架构框架(设计正确性 · 需重点 review) + +**文件**:`Architecture/Core/GameEvent.cs`、`TypedGameEvents.cs`、`RuntimeSets/RuntimeSet.cs`、`Variables/VariableRegistry.cs` + `FloatVariable/IntVariable/BoolVariable/Vector3Variable.cs`、`Editor/VariableDrawer.cs`、`Editor/AssetBootstrap.cs` + +- [ ] **GameEvent 双通道**:`Raise()` 先触发代码监听器(`_codeListeners`),再触发 Inspector 监听器(`_listeners`)。顺序是否符合预期?(当前:代码监听优先) +- [ ] **代码监听器泄漏**:`Register(Action)` / `Unregister(Action)` 未在 `OnDisable` 对称注销时,组件销毁后事件仍持有引用 → 悬空调用。已要求订阅方在 `OnDisable` 注销,请核对第 3 节。 +- [ ] **VariableRegistry 重置时机**:`[RuntimeInitializeOnLoadMethod(BeforeSceneLoad)]` 在每次进入 Play Mode 触发 `ResetAll()`。确认该回调在 Editor「停止→再播放」时确实重新执行(域重置后)。 +- [ ] **`_defaultValue` 机制**:`ResetToDefault()` 把 `_value` 恢复为 `_defaultValue`。`AssetBootstrap` 生成时已同时写 `_value=_defaultValue`;但**策划手工 `CreateAssetMenu` 新建变量时若只改 `_value` 没改 `_defaultValue`,Reset 会回到 0/初始值**——需在约定里提醒。 +- [ ] **Reset 触发刷新**:`ResetToDefault()` 会 `OnValueChanged?.Invoke(_value)`,可能令 UI 在启动瞬间刷新一次——确认无副作用。 +- [ ] **RuntimeSetRegistrar 空集合**:若 Prefab 未挂 `RuntimeSetRegistrar` 或未拖 `Enemies` 集合,`GameManager.DisableAllEnemyAI` 走 `FindObjectsOfType` 兜底分支(见第 6 节)。 + +--- + +## 2. 发布方改造(static 事件 → SO 字段) + +- [ ] **GameManager**(`:51-66`):`GameOver()`/`Win()` 仍是 `static`,已改 `Instance.onGameOverEvent?.Raise()`,且方法开头 `if (Instance == null) return;` 保护。✓ 已修 CS0120。 +- [ ] **HealthSystem**(`:62-84`): + - `OnHealthChanged?.Invoke(health, maxHealth)` 在**每次 `Damage`** 触发(`:80`)——敌人血条据此刷新。 + - **仅 `isPlayer` 时**写 `healthVar`/`maxHealthVar` + 触发 `onPlayerDamagedEvent`(`:65-69, 79, 83`)。⚠️ 敌人路径不写全局变量(关键正确性)。 +- [ ] **ScoreManager**(`:47-48`):`onScoreChanged` / `onScoreSettled` 两个静态事件**均已迁移**到 `scoreChangedEvent` / `scoreSettledEvent`;`SetScore`/`ResetScore`/`ScoreCountUp` 全部改 `Raise(...)`。 +- [ ] **EchoSystem**(`:25`):`OnEchoReleased` → `echoReleasedEvent`;新增公共只读属性 `BellCooldown`(供 HUD),`ringShader` 已为序列化字段。 + +--- + +## 3. 订阅方改造(Register 替代 `+=` static) + +- [ ] **GameResultScreen**(`:43-44, OnEnable/OnDisable`):`onGameOverEvent.Register(...)` / `onGameWinEvent.Register(...)`,OnDisable 反注册。 +- [ ] **GameOverScreen**(`:27`):`onGameOverEvent.Register(ShowGameOver)`,OnDisable 反注册。 +- [ ] **DamageFlashOverlay**(`:4 using, :32`):`onPlayerDamagedEvent.Register(OnPlayerDamaged)`,OnDestroy 反注册。 +- [ ] **ScoreDisplay**(`:18, OnEnable/OnDisable`):`scoreChangedEvent.Register(UpdateText)`。 +- [ ] **EnemyAI**(`:51, OnEnable/OnDisable`):`echoReleasedEvent.Register(OnBell)`,OnDisable 反注册。 +- [ ] **生命周期对称**:所有订阅在 `OnEnable`/`Start` 注册、`OnDisable`/`OnDestroy` 注销,避免重复订阅或组件销毁后悬空调用。 + +--- + +## 4. 去反射 / 去 Find(关键正确性 · 重点 review) + +- [ ] **EnemyHealthBar 重写(最重要)**:改为订阅**同物体** `HealthSystem.OnHealthChanged(health, maxHealth)`,**不再读全局 `PlayerHealth`**。修复了原方案「所有敌人共享同一份血量变量」的严重 bug。 + - ⚠️ **接线约束**:敌人 Prefab 上的 `HealthSystem` 其 `healthVar`/`maxHealthVar`/`onPlayerDamagedEvent` 三个 SO 字段**必须留空**,否则敌人受伤会改写 `PlayerHealth`、污染玩家血条与 HUD。 +- [ ] **GameHUD 去反射**: + - 生命图标:`playerHealthVar.OnValueChanged += UpdateLifeIcons(int)`(已修 CS0123 签名)。✓ + - 技能 CD:读 `PlayerController.RollCooldown` / `EchoSystem.BellCooldown` 公共属性。✓ + - ⚠️ **残留 1 处反射**(`:181-183`):灵灯最大 CD 仍 `typeof(SpiritLanternSystem).GetField("cooldown", NonPublic|Instance)`,属 P2b 未清完。建议给 `SpiritLanternSystem` 加 `public float Cooldown => cooldown;` 后删此反射。 + - **残留 Find**:`Start` 中仍有一次性 `FindWithTag("Player")`(`:55`)和 `FindObjectOfType()`(`:87`)——一次性可接受,非逐帧;若想彻底解耦可改场景引用(留待 P2e)。 +- [ ] **Player.cs**:新增 `public (float remaining, float total) RollCooldown` 只读属性,供 HUD 替代反射。✓ + +--- + +## 5. Shader.Find → 序列化字段(P5 反馈项) + +- [ ] **GameLostOverlay**(`:waterRippleShader`):原 `Shader.Find("GameFramework/UI/WaterRippleFade")` 改为 `[SerializeField] Shader waterRippleShader`,Inspector 需拖入 `WaterRippleFade.shader`。 +- [ ] **GroundBuilder**(`:edgeGlowMaterial`):原 `Shader.Find("IndianOcean/AbyssEdgeGlow")` 改为序列化 `Shader` 字段,需拖 `AbyssEdgeGlow.shader`。 +- [ ] **CliffWallBuilder**(`:edgeGlowMaterial`):同上,需拖 `AbyssEdgeGlow.shader`。 +- [ ] **EchoSystem**:`ringShader` 字段已存在(确认 Inspector 已拖 `EchoRing.shader`)。 +- [ ] **GroundClipTool.cs:19**:**Editor 脚本**,`Shader.Find` 运行时不进包,暂保留;建议后续也改为序列化字段以统一。 + +--- + +## 6. 编辑器工具 + +- [ ] **UIBuilder.cs**(`:305-307`):`BuildResultScreen` 时 `LoadAssetAtPath(".../OnGameOver.asset")` 自动连线。⚠️ **前提是先运行 `Architecture > Bootstrap Core Assets` 生成资产**,否则两格为 `null`,需手动拖。 +- [ ] **AssetBootstrap.cs**:菜单项 `Architecture/Bootstrap Core Assets`,生成 11 个 SO 资产到 `Assets/Architecture/Assets/`,已存在则跳过。✓ 已修 CS0246(补 `using Architecture.Core;`)。 +- [ ] **自动创建的 DamageFlashOverlay**(GameHUD.Start 兜底,`:87-91`):该自动实例的 `onPlayerDamagedEvent` 为 `null` → 受击不闪红。建议场景**预置**一个手动接好线的 `DamageFlashOverlay`,HUD 检测到就不自动建。 + +--- + +## 7. Unity 编辑器内必做(手动 · 代码改完 ≠ 能跑) + +- [ ] 运行菜单 `Architecture > Bootstrap Core Assets` 生成全部 SO 资产。 +- [ ] GameManager:`On Game Over Event`/`On Game Win Event` → 对应资产;`Enemies Set` → `Enemies`;`Enemy Manager Ref` → 场景 EnemyManager(可空)。 +- [ ] **玩家** HealthSystem:`On Player Damaged Event`→`OnPlayerDamaged`、`Health Var`/`Max Health Var`→`PlayerHealth`。 +- [ ] **敌人** HealthSystem:三个 SO 字段**留空**。 +- [ ] EchoSystem:`Echo Released Event`→`EchoReleased`、`Ring Shader`→`EchoRing.shader`。 +- [ ] ScoreManager:`Score Changed Event`→`ScoreChanged`、`Score Settled Event`→`ScoreSettled`(**最易漏**)。 +- [ ] GameHUD:`Score Changed Event`→`ScoreChanged`、`Player Health Var`→`PlayerHealth`。 +- [ ] DamageFlashOverlay:`On Player Damaged Event`→`OnPlayerDamaged`。 +- [ ] ScoreDisplay / GameOverScreen / GameResultScreen:`Score Changed Event` / `On Game Over Event` / `On Game Win Event` 对应资产(若用 UIBuilder 搭建则 GameResultScreen 已自动连)。 +- [ ] EnemyAI:`Echo Released Event`→`EchoReleased`。 +- [ ] 敌人 Prefab 挂 `RuntimeSetRegistrar`(Set→`Enemies`);Player Prefab 挂 `RuntimeSetRegistrar`(Set→`Players`,当前无代码消费,可延后)。 + +--- + +## 8. 编译 / 运行时验证 + +- [ ] **编译全绿**:已修 `GameManager` CS0120、`GameHUD` CS0123、`VariableDrawer` CS0246、`UIBuilder` CS0246;全仓排雷确认其余引用架构类型的文件已带齐 `using`。 +- [ ] **Play Mode 回归**: + - [ ] 左上角魂灵数随吃魂变化 → `ScoreChanged` 链路通。 + - [ ] 左下角生命图标随受伤减少 → `PlayerHealth` 链路通。 + - [ ] 受伤时屏幕红边闪 → `OnPlayerDamaged` + DamageFlashOverlay。 + - [ ] 按 E 摇铃,附近敌人朝铃铛移动 → `EchoReleased` 链路通。 + - [ ] 玩家死亡 → 失败过场 + 敌人 AI 被禁用 → `OnGameOver` + `Enemies` 集合。 +- [ ] **状态泄漏验证**:退出 Play Mode 再进一次,血量应从 5 开始(`VariableRegistry` 重置生效,无跨局泄漏)。 + +--- + +## 9. 已知风险 & 遗留 TODO + +| 项 | 位置 | 说明 | 阶段 | +|----|------|------|------| +| MainMenuUIController 对象名耦合 | `FindButton`/`GameObject.Find` by name | 改 Inspector 引用或 SO 配置 | P2e | +| EnemyAI.cs:112 一次性 FindWithTag | Start 中 | 待 Players 集合接入后移除 | P2e | +| 单例调用方 | ScorePickup / SoulDrop 等 | 改走 SO 事件/变量 | P3 | +| God Class 拆分 | PlayerController 等 | 按 SRP 拆组件 | P4 | +| 构建期守门脚本 | 新 Editor 校验 | 静态扫描 GameObject.Find / 静态单例引用 | P6 | + +> 已本轮解决:灵灯 CD 反射(改为 `SpiritLanternSystem.Cooldown` 属性)、GameLostOverlay 直调(改订阅 OnGameOver)、GameOverScreen 空响应(已删除)、DamageFlashOverlay 空接线(OnEnable 警告 + 场景预置)。 + +--- + +## 10. 回滚说明 + +- 所有新增代码位于 `Assets/Architecture/`,与现有游戏代码零耦合,可整体删除回滚。 +- 对现有文件的修改(发布/订阅方、去反射、Shader.Find)为就地改写;如需回滚,请用 Git 版本对比 `Assets/` 下被改文件。 +- 生成的 SO 资产位于 `Assets/Architecture/Assets/`,可安全删改、重新 `Bootstrap`。 + +--- + +## 11. 第二轮 Review 意见处理记录 + +针对两份 review 意见(严重项 + 顺手修项),已全部落地。改动文件清单: + +### 严重(需修复) + +| # | 意见 | 处理 | 文件 | +|---|------|------|------| +| 1 | VariableRegistry.ResetAll 时序:BeforeSceneLoad 时 SO 尚未注册导致重置无效 | `Register()` 内立即调用 `variable.ResetToDefault()`,变量「上线」即重置;保留 `ResetAll` 作兜底 | `Architecture/Variables/VariableRegistry.cs` | +| 2 | HealthSystem 直接调 `GameManager.GameOver()` | 改为 Raise `OnPlayerDied` SO 事件;`GameManager` 订阅并触发 `GameOver()` | `HealthSystem.cs` / `GameManager.cs` | +| 3 | GameLostOverlay 被 `GameManager` 直接调用 | 改为订阅 `OnGameOver` 事件自激活;删除静态 `Show()` 与 `_instance`/`FindObjectOfType` | `GameLostOverlay.cs` / `GameManager.cs` | +| 4 | GameResultScreen / GameOverScreen 空响应 | `GameResultScreen` 删除空 `OnGameOver`(只负责胜利);`GameOverScreen` 无引用、已删除 | `GameResultScreen.cs`(删字段/订阅/空方法)、`GameOverScreen.cs`(删除)、`UIBuilder.cs`(移除 onGameOver 接线防 NRE) | + +### 顺手修(建议项) + +| # | 意见 | 处理 | 文件 | +|---|------|------|------| +| 5 | GameEvent.Raise 顺序:Inspector 优先于代码 | `Raise()` 先触发 `_listeners`(Inspector/UnityEvent),再 `_codeListeners` | `Architecture/Core/GameEvent.cs` | +| 6 | 灵灯反射残留 | `SpiritLanternSystem` 加 `public float Cooldown`;GameHUD 灵灯 CD 改读该属性,删最后一处反射 | `SpiritLanternSystem.cs` / `GameHUD.cs` | +| 7 | GameHUD 延迟订阅逻辑 | 移除 `Update` 中逐帧 `ScoreManager.Instance` 延迟订阅,改为 `Start` 无条件注册事件 | `GameHUD.cs` | +| 8 | ScoreDisplay 初始值直接访问 `ScoreManager.Instance` | 初始值改走事件(显示 0,首个 `ScoreChanged` 刷新),不再访问单例 | `ScoreDisplay.cs` | + +### 新增接线要求(Unity 编辑器内) + +- `HealthSystem`(玩家):新增 `On Player Died Event` → `OnPlayerDied` 资产。 +- `GameManager`:新增 `On Player Died Event` → `OnPlayerDied` 资产。 +- `GameLostOverlay`(Gameplay 场景物体):新增 `On Game Over Event` → `OnGameOver` 资产。 +- `DamageFlashOverlay`:**场景预置已接线实例**(拖 `On Player Damaged Event` → `OnPlayerDamaged`),否则受击不闪红(已加空接线警告)。 +- `GameResultScreen`:仅 `On Game Win Event` → `OnGameWin`(不再有 `On Game Over Event` 字段)。 +- `GameOverScreen` 已从项目删除:若旧场景/预制体仍挂该组件,请在 Unity 中移除该组件(避免 Missing Script)。 + +--- + +## 12. Play Mode 回归(用户实测)与诊断加固 + +### 回归结果(03:15) +| 链路 | 结果 | +|------|------| +| 魂灵数 ← ScoreChanged | ✅ | +| 生命图标 ← PlayerHealth | ✅ | +| 受击泛红 ← OnPlayerDamaged + DamageFlashOverlay | ❌ **接线缺口** | +| 摇铃引敌 ← EchoReleased | ❌ **接线缺口** | +| 玩家死亡过场 ← OnPlayerDied→OnGameOver + Enemies 集合 | ✅ | +| 退出再进血量=5(VariableRegistry 重置) | ✅ | + +### 根因(两处失败均为 SO 事件资产未接,非代码 bug) +- **OnPlayerDamaged**:`HealthSystem.Damage` 第87行 `onPlayerDamagedEvent?.Raise()` 逻辑正确(同机制 OnPlayerDied 已通),断链在 HealthSystem 或/且 DamageFlashOverlay 的 `On Player Damaged Event` 字段为 `null`。 +- **EchoReleased**:`EchoSystem.StartEcho` 第220行 `echoReleasedEvent?.Raise(p)` 与 `EnemyAI.OnEnable` `Register(OnBell)` 均正确(Vector3Event 签名匹配),断链在 EchoSystem 或/且 **敌人预制体** EnemyAI 的 `Echo Released Event` 字段为 `null`(最可能是玩家预制体的 EchoSystem 未接)。 + +### 诊断加固(已落地,无需再改代码) +- `HealthSystem` / `EchoSystem` / `EnemyAI` / `DamageFlashOverlay` 的事件字段加 `OnValidate()`:**编辑器内即刻在 Inspector 显示黄色警告三角 + 控制台告警**,精确锁定漏接组件。 +- Raise 处加一次性空引用 `Debug.LogWarning`(运行时不刷屏)。 +- `GameHUD` 不再自动 new 一个 event=null 的 DamageFlashOverlay,改为仅告警,避免掩盖「未接线」。 + +### 用户需做的接线修复(看 OnValidate 警告最准) +1. 玩家预制体 `HealthSystem`:`On Player Damaged Event` → `OnPlayerDamaged`。 +2. 场景中的 `DamageFlashOverlay`:`On Player Damaged Event` → `OnPlayerDamaged`(若场景没有该物体,先放一个)。 +3. 玩家预制体 `EchoSystem`:`Echo Released Event` → `EchoReleased`。 +4. **敌人预制体**(Project 里的 prefab,不是场景临时实例):`EnemyAI.Echo Released Event` → `EchoReleased`。 +5. 重新编译 + 进 Play Mode,先确认控制台无上述 `[HealthSystem]/[EchoSystem]/[EnemyAI]/[DamageFlashOverlay]` 黄色警告,再测受击与摇铃。 + diff --git a/unity/UserSettings/EditorUserSettings.asset b/unity/UserSettings/EditorUserSettings.asset index 3f9c886..0e02cd4 100644 --- a/unity/UserSettings/EditorUserSettings.asset +++ b/unity/UserSettings/EditorUserSettings.asset @@ -30,10 +30,10 @@ EditorUserSettings: value: 5a5757560101590a5d0c0e24427b5d44434e4c7a7b7a23677f2b4565b7b5353a flags: 0 RecentlyUsedSceneGuid-8: - value: 0209025f575750595c57092149765d444e4e49727e7c7068757b1c65b6e36c3b + value: 5a55515156575a0b0f56592346260f444f16197c7e7f24697d2a4a32b1b0353e flags: 0 RecentlyUsedSceneGuid-9: - value: 5a55515156575a0b0f56592346260f444f16197c7e7f24697d2a4a32b1b0353e + value: 0209025f575750595c57092149765d444e4e49727e7c7068757b1c65b6e36c3b flags: 0 UnityEditor.ShaderGraph.Blackboard: value: 18135939215a0a5004000b0e15254b524c030a3f2964643d120d1230e9e93a3fd6e826abbd2e2d293c4ead313b08042de6030a0afa240c0d020be94c4ba75e435d8715fa32c70d15d11612dacc11fee5d3c5d1fe9ab1bf968e93e2ffcbc3e7e2f0b3ffe0e8b0be9af8ffaeffff8e85dd8390e3949c8899daa7