Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc084d5c1e | ||
|
|
ecb20cf370 |
@@ -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<HealthSystem>().Damage()` + HealthSystem 反向调 `GameManager.GameOver()`
|
||||||
|
- `Resources.Load` 用于音频/视频
|
||||||
|
|
||||||
|
- **已落地解耦基础框架** `Assets/Architecture/`(增量、零侵入、未改现有文件):
|
||||||
|
- Core/GameEvent.cs(无参事件通道+监听器)、Core/TypedGameEvents.cs(GameEvent<T> + 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<DamageFlashOverlay>`、`onGameWin` 静态事件被 `GameResultScreen` 监听、`GameLostOverlay` 的 `Shader.Find` 与 `FindObjectOfType` 自检查、`MainMenuUIController` 对象名耦合、以及 `ScorePickup/SoulDrop/ScoringUIController/MainMenuUIController/GameLostOverlay` 对 `ScoreManager/SceneLoader` 的直接单例调用线。
|
||||||
|
- **代码补强**(评审点):`TypedGameEvents.cs` 补 `StringEventListener`(初稿声称有但未实现);`RuntimeSet.cs` 补泛型 `RuntimeSetRegistrar<T>`(强类型集合如 `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<T> 用 `Register(Action<T>)`。
|
||||||
|
- **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<EnemyAI>()`;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<int>`,但 `UpdateLifeIcons()` 是无参方法,委托签名不匹配。改为 `UpdateLifeIcons(int value)`(内部 `currentHealth = value`),并把初始直接调用 `UpdateLifeIcons()` → `UpdateLifeIcons(playerHealthVar.Value)`。
|
||||||
|
- **经验教训(可复用)**:① 把 static 事件总线改成 SO 事件字段后,原 `GameManager.GameOver()/Win()` 这类 static 入口方法必须改走 `Instance.字段`(或把字段也变 static,但 SO 资产不应是 static);② 订阅 `Variable.OnValueChanged`(`Action<T>`)时,回调方法必须带对应 `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 要接**预制体资产**而非场景临时实例)。
|
||||||
|
|
||||||
@@ -347,6 +347,7 @@ MonoBehaviour:
|
|||||||
maxSightDistance: 4
|
maxSightDistance: 4
|
||||||
loseSightTime: 3
|
loseSightTime: 3
|
||||||
listenRange: 15
|
listenRange: 15
|
||||||
|
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
|
||||||
bellArriveDistance: 1
|
bellArriveDistance: 1
|
||||||
bellChaseTimeout: 10
|
bellChaseTimeout: 10
|
||||||
bellPatrolTime: 5
|
bellPatrolTime: 5
|
||||||
@@ -385,9 +386,16 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
onPlayerDamagedEvent: {fileID: 0}
|
||||||
|
onPlayerDiedEvent: {fileID: 0}
|
||||||
|
healthVar: {fileID: 0}
|
||||||
|
maxHealthVar: {fileID: 0}
|
||||||
maxHealth: 3
|
maxHealth: 3
|
||||||
deathEffect: {fileID: 0}
|
deathEffect: {fileID: 0}
|
||||||
isPlayer: 0
|
isPlayer: 0
|
||||||
|
dissolveMaterial: {fileID: 0}
|
||||||
|
fadeDuration: 2
|
||||||
|
damageInvincibleDuration: 2
|
||||||
--- !u!114 &1893771889199554109
|
--- !u!114 &1893771889199554109
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -355,6 +355,10 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
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
|
maxHealth: 5
|
||||||
deathEffect: {fileID: 4806121257990350900, guid: f19c76183b5e22e44a73655dc18f1a92, type: 3}
|
deathEffect: {fileID: 4806121257990350900, guid: f19c76183b5e22e44a73655dc18f1a92, type: 3}
|
||||||
isPlayer: 1
|
isPlayer: 1
|
||||||
@@ -430,6 +434,7 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: 0a44142f1063e1d4dbab7b851d31d258, type: 3}
|
m_Script: {fileID: 11500000, guid: 0a44142f1063e1d4dbab7b851d31d258, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
|
||||||
echoKey: 101
|
echoKey: 101
|
||||||
expandSpeed: 15
|
expandSpeed: 15
|
||||||
maxRadius: 30
|
maxRadius: 30
|
||||||
@@ -441,6 +446,8 @@ MonoBehaviour:
|
|||||||
ringVisualWidth: 2.5
|
ringVisualWidth: 2.5
|
||||||
ringYOffset: 0.1
|
ringYOffset: 0.1
|
||||||
ringFadeTime: 3
|
ringFadeTime: 3
|
||||||
|
ringExpandSpeed: 15
|
||||||
|
ringShader: {fileID: 4800000, guid: 67f78fa2aa44cba4cadd869535881366, type: 3}
|
||||||
cooldown: 3
|
cooldown: 3
|
||||||
--- !u!1001 &1156712605891213377
|
--- !u!1001 &1156712605891213377
|
||||||
PrefabInstance:
|
PrefabInstance:
|
||||||
|
|||||||
@@ -2,13 +2,23 @@ using UnityEngine;
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using GameFramework;
|
using GameFramework;
|
||||||
|
using Architecture.Core;
|
||||||
|
using Architecture.Variables;
|
||||||
|
|
||||||
namespace IndianOceanAssets.Engine2_5D
|
namespace IndianOceanAssets.Engine2_5D
|
||||||
{
|
{
|
||||||
// Handles health, damage, and death for entities
|
// Handles health, damage, and death for entities
|
||||||
public class HealthSystem : MonoBehaviour
|
public class HealthSystem : MonoBehaviour
|
||||||
{
|
{
|
||||||
/// <summary>玩家受伤时触发(用于受击特效等)</summary>
|
[Header("SO 事件通道(替代 static onPlayerDamaged 事件)")]
|
||||||
public static Action 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)]
|
[Range(1, 100)]
|
||||||
[SerializeField]
|
[SerializeField]
|
||||||
@@ -31,6 +41,7 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
private bool _isDead = false; // 防止重复触发死亡
|
private bool _isDead = false; // 防止重复触发死亡
|
||||||
private bool _isInvincible = false; // 无敌状态
|
private bool _isInvincible = false; // 无敌状态
|
||||||
private float _invincibleTimer = 0f; // 无敌剩余时间(<=0 表示永久无敌)
|
private float _invincibleTimer = 0f; // 无敌剩余时间(<=0 表示永久无敌)
|
||||||
|
private bool _warnedDamaged = false; // 防止 onPlayerDamagedEvent 空引用告警刷屏
|
||||||
|
|
||||||
[Header("受伤无敌")]
|
[Header("受伤无敌")]
|
||||||
[Tooltip("玩家受伤后的无敌时间(秒)")]
|
[Tooltip("玩家受伤后的无敌时间(秒)")]
|
||||||
@@ -42,13 +53,33 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
/// <summary>是否处于无敌状态(只读)</summary>
|
/// <summary>是否处于无敌状态(只读)</summary>
|
||||||
public bool IsInvincible => _isInvincible;
|
public bool IsInvincible => _isInvincible;
|
||||||
|
|
||||||
|
/// <summary>当前血量(只读)</summary>
|
||||||
|
public int CurrentHealth => health;
|
||||||
|
|
||||||
/// <summary>最大血量(只读)</summary>
|
/// <summary>最大血量(只读)</summary>
|
||||||
public int MaxHealth => maxHealth;
|
public int MaxHealth => maxHealth;
|
||||||
|
|
||||||
|
/// <summary>血量变化事件(current, max)。敌人血条等本地订阅者用于刷新显示,替代反射读取私有字段。</summary>
|
||||||
|
public event System.Action<int, int> 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
|
// Initializes health
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
health = maxHealth;
|
health = maxHealth;
|
||||||
|
if (isPlayer)
|
||||||
|
{
|
||||||
|
if (maxHealthVar != null) maxHealthVar.Value = maxHealth;
|
||||||
|
if (healthVar != null) healthVar.Value = health;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies damage and checks for death
|
// Applies damage and checks for death
|
||||||
@@ -58,10 +89,20 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
if (_isInvincible) return; // 无敌状态,免疫伤害
|
if (_isInvincible) return; // 无敌状态,免疫伤害
|
||||||
|
|
||||||
health -= damageAmount;
|
health -= damageAmount;
|
||||||
|
if (isPlayer && healthVar != null) healthVar.Value = health;
|
||||||
|
OnHealthChanged?.Invoke(health, maxHealth);
|
||||||
|
|
||||||
// 玩家受伤时触发事件(用于受击泛红特效)
|
// 玩家受伤时触发事件(用于受击泛红特效)
|
||||||
if (isPlayer && onPlayerDamaged != null)
|
if (isPlayer)
|
||||||
onPlayerDamaged.Invoke();
|
{
|
||||||
|
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)
|
if (isPlayer && AudioManager.Instance != null)
|
||||||
@@ -78,8 +119,8 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
|
|
||||||
if (isPlayer)
|
if (isPlayer)
|
||||||
{
|
{
|
||||||
// 玩家死亡:触发过场动画,不立即销毁(由 GameManager 处理)
|
// 玩家死亡:通过 OnPlayerDied SO 事件通知 GameManager(解耦,不再直接调用单例)
|
||||||
GameManager.GameOver();
|
onPlayerDiedEvent?.Raise();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -213,4 +254,4 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
Destroy(gameObject);
|
Destroy(gameObject);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
private float lastRollTime; // Timestamp of last roll
|
private float lastRollTime; // Timestamp of last roll
|
||||||
private bool isRolling; // Is player currently rolling?
|
private bool isRolling; // Is player currently rolling?
|
||||||
|
|
||||||
|
/// <summary>冲刺冷却剩余/总时长(供 HUD 显示,替代反射读取私有字段)。</summary>
|
||||||
|
public (float remaining, float total) RollCooldown
|
||||||
|
=> (Mathf.Max(0f, (lastRollTime + rollCooldown) - Time.time), rollCooldown);
|
||||||
|
|
||||||
[SerializeField] private KeyCode rollKeyCode; // Key to trigger roll
|
[SerializeField] private KeyCode rollKeyCode; // Key to trigger roll
|
||||||
|
|
||||||
// Enum to switch between sword or projectile attack types
|
// Enum to switch between sword or projectile attack types
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: WSxO4H+lVy/+Blf5pfhG4hmfSru5p5O0gX9h1Xbssr3FMpnAolEEZqQ=
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: DXhN53n7Wn6X4mq4BzmVGcYyf4qgGr+dz9mS8D/RSs2VcTccKveruRE=
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: DCwXsH6lBn1xFo36tyRsjs+h4O+VaxEW/j5EUKPXE9wXY5c7QNmWpTw=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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: []
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: WXNNtH6sVHk/AtKjh3pZHW8Y0Ud+sV/2q1BsD/usbR4CLijRwhOQiIk=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: CChMtHz5UioVBUTAdiP6msXTWBHbXvrlL/lrziGncpCasO91pyWEhyk=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: XH5L5HukAH7cHD+m8JFQuv4+Mj+RudGvKdJlEfvVp4JP/PFYo2XNvkY=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: DnwWtS6sUHnS++rBiFqf1RewyFloSSTeXLbE2N2AYG05n2Ydap4Fwj0=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: By9K4Cr+VHyn1VhN1u6wwvxVZTtp7QakSbea38aO5PM+1kQ2/WvqpTk=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: CXMat3n8Uine4euE0m+/PP+65iSUYO/kdbR4DpS1kOnmtBMCoGxpDXU=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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: []
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: DXkfsH+oAC3hF+c5rKlzwRKUa/umXiwsRPwwZnPWvvBN2Q0hWBBPanE=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: WnIXtyivW3to970CZlNapENiq9hQEjUh+OqHFQyk+JfklXrmuCSE5fk=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: WXNNvCuoVXP90sjsVOaNxagClFTPQl2e6lNPzXvzsn7Deww9UFDHgQ4=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: Wi9KtSqvUiiCMcmSH6Ragy/X4YLegcpMnbruBTQrei7BTu3lyGrSIoc=
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: CHwdtCmvUy1YMyYqJ0W+W9h32R9zeWPvveDocc+XzRh7umlTkIVFsPU=
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Events;
|
||||||
|
|
||||||
|
namespace Architecture.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 无参事件通道(ScriptableObject 资产)。
|
||||||
|
/// 系统 / UI 在 Inspector 中拖入同一个资产实例即可实现解耦通信,
|
||||||
|
/// 彻底替代 static Action 事件与单例方法调用。
|
||||||
|
/// 注意:资产实例是项目级资源,跨场景天然存活,无需 DontDestroyOnLoad。
|
||||||
|
/// </summary>
|
||||||
|
[CreateAssetMenu(menuName = "Architecture/Events/Game Event", fileName = "NewGameEvent")]
|
||||||
|
public class GameEvent : ScriptableObject
|
||||||
|
{
|
||||||
|
private readonly List<GameEventListener> _listeners = new List<GameEventListener>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Inspector 监听器(GameEventListener 组件)注册。</summary>
|
||||||
|
public void RegisterListener(GameEventListener listener)
|
||||||
|
{
|
||||||
|
if (!_listeners.Contains(listener)) _listeners.Add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UnregisterListener(GameEventListener listener)
|
||||||
|
{
|
||||||
|
if (_listeners.Contains(listener)) _listeners.Remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>代码监听器(Action)注册,便于 MonoBehaviour 在 OnEnable 中订阅,
|
||||||
|
/// 是替换 static Action 事件的主要入口。</summary>
|
||||||
|
public void Register(System.Action listener)
|
||||||
|
{
|
||||||
|
if (listener != null) _codeListeners += listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unregister(System.Action listener)
|
||||||
|
{
|
||||||
|
if (listener != null) _codeListeners -= listener;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 无参事件监听器(设计师友好,纯 Inspector 配置)。
|
||||||
|
/// 监听某个 GameEvent,触发时调用绑定的 UnityEvent 响应。
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
fileFormatVersion: 2
|
fileFormatVersion: 2
|
||||||
guid: CClOsC78Vi9/cIog8UeuUzecgGTFlu5AQ5iyvdkeMcyG1n6ns9R5ds8=
|
guid: Dy4XvCj+VnPPG8II9z1gbnWGNyb39F6pQKS/CfIEjQntIaaEOm2XcP4=
|
||||||
MonoImporter:
|
MonoImporter:
|
||||||
externalObjects: {}
|
externalObjects: {}
|
||||||
serializedVersion: 2
|
serializedVersion: 2
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.Events;
|
||||||
|
|
||||||
|
namespace Architecture.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 带载荷的泛型事件通道。用于在调用方之间传递数据,而无需 static 事件或单例。
|
||||||
|
/// 具体类型(IntEvent / FloatEvent 等)可在 Inspector 右键 Create。
|
||||||
|
/// 系统内部订阅用 event.Register(Action<T>);设计师驱动用对应 *EventListener 组件。
|
||||||
|
/// </summary>
|
||||||
|
public abstract class GameEvent<T> : ScriptableObject
|
||||||
|
{
|
||||||
|
private readonly List<Action<T>> _listeners = new List<Action<T>>();
|
||||||
|
|
||||||
|
public void Raise(T payload)
|
||||||
|
{
|
||||||
|
for (int i = _listeners.Count - 1; i >= 0; i--)
|
||||||
|
_listeners[i]?.Invoke(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Register(Action<T> listener)
|
||||||
|
{
|
||||||
|
if (!_listeners.Contains(listener)) _listeners.Add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unregister(Action<T> listener)
|
||||||
|
{
|
||||||
|
if (_listeners.Contains(listener)) _listeners.Remove(listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[CreateAssetMenu(menuName = "Architecture/Events/Int Event", fileName = "NewIntEvent")]
|
||||||
|
public class IntEvent : GameEvent<int> { }
|
||||||
|
|
||||||
|
[CreateAssetMenu(menuName = "Architecture/Events/Float Event", fileName = "NewFloatEvent")]
|
||||||
|
public class FloatEvent : GameEvent<float> { }
|
||||||
|
|
||||||
|
[CreateAssetMenu(menuName = "Architecture/Events/Vector3 Event", fileName = "NewVector3Event")]
|
||||||
|
public class Vector3Event : GameEvent<Vector3> { }
|
||||||
|
|
||||||
|
[CreateAssetMenu(menuName = "Architecture/Events/String Event", fileName = "NewStringEvent")]
|
||||||
|
public class StringEvent : GameEvent<string> { }
|
||||||
|
|
||||||
|
[CreateAssetMenu(menuName = "Architecture/Events/GameObject Event", fileName = "NewGameObjectEvent")]
|
||||||
|
public class GameObjectEvent : GameEvent<GameObject> { }
|
||||||
|
|
||||||
|
// ===== 带载荷的监听器组件(Inspector 驱动)=====
|
||||||
|
|
||||||
|
public class IntEventListener : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private IntEvent _event;
|
||||||
|
[SerializeField] private UnityEvent<int> _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<float> _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<Vector3> _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<GameObject> _response;
|
||||||
|
private void OnEnable() => _event?.Register(Respond);
|
||||||
|
private void OnDisable() => _event?.Unregister(Respond);
|
||||||
|
private void Respond(GameObject v) => _response?.Invoke(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 字符串事件监听器组件。补齐 Int/Float/Vector3/GameObject 之后遗漏的 String 通道监听器,
|
||||||
|
/// 使策划能在 Inspector 里为 StringEvent 连线响应(例如传递场景名、提示文本)。
|
||||||
|
/// </summary>
|
||||||
|
public class StringEventListener : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private StringEvent _event;
|
||||||
|
[SerializeField] private UnityEvent<string> _response;
|
||||||
|
private void OnEnable() => _event?.Register(Respond);
|
||||||
|
private void OnDisable() => _event?.Unregister(Respond);
|
||||||
|
private void Respond(string v) => _response?.Invoke(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: Wn0Y43n4BSqQmCBSQfcVumlArfA4ONJNN2iHuAFNyRze987Ocx2MGyY=
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: B31O5CulAS1WrdnBvd8elA4AXddHMwLYIMjEJbxVqMOjBnaYzy4S8LU=
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEditor;
|
||||||
|
using Architecture.Core;
|
||||||
|
using Architecture.Variables;
|
||||||
|
|
||||||
|
namespace Architecture.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 一键生成 P1/P2 所需的 SO 通信资产(变量 / 事件 / 运行时集合)。
|
||||||
|
/// 运行方式:Unity 顶部菜单 Architecture > Bootstrap Core Assets。
|
||||||
|
/// 生成的资产位于 Assets/Architecture/Assets/,可安全删改、重新生成(已存在则跳过)。
|
||||||
|
///
|
||||||
|
/// 注意:本脚本仅生成"空壳资产",具体系统需在 Inspector 中将对应字段拖入这些资产实例,
|
||||||
|
/// 才能建立解耦链路(详见 Docs/解耦架构重构方案.md)。
|
||||||
|
/// </summary>
|
||||||
|
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<GameEvent>("OnPlayerDied");
|
||||||
|
Create<GameEvent>("OnGameOver");
|
||||||
|
Create<GameEvent>("OnGameWin");
|
||||||
|
Create<GameEvent>("OnPlayerDamaged");
|
||||||
|
|
||||||
|
// 带载荷事件通道
|
||||||
|
Create<Vector3Event>("EchoReleased");
|
||||||
|
Create<IntEvent>("ScoreChanged");
|
||||||
|
Create<IntEvent>("ScoreSettled");
|
||||||
|
|
||||||
|
// 运行时集合(当前场景实体注册表)
|
||||||
|
Create<TransformRuntimeSet>("Players");
|
||||||
|
Create<TransformRuntimeSet>("Enemies");
|
||||||
|
|
||||||
|
AssetDatabase.SaveAssets();
|
||||||
|
Debug.Log($"[AssetBootstrap] 已生成核心 SO 资产到 {Root}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Create<T>(string name) where T : ScriptableObject
|
||||||
|
{
|
||||||
|
var path = $"{Root}/{name}.asset";
|
||||||
|
var existing = AssetDatabase.LoadAssetAtPath<T>(path);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
Debug.Log($"[AssetBootstrap] 已存在,跳过:{path}");
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
var asset = ScriptableObject.CreateInstance<T>();
|
||||||
|
AssetDatabase.CreateAsset(asset, path);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateInt(string name, int value)
|
||||||
|
{
|
||||||
|
var asset = Create<IntVariable>(name);
|
||||||
|
var so = new SerializedObject(asset);
|
||||||
|
so.FindProperty("_value").intValue = value;
|
||||||
|
so.FindProperty("_defaultValue").intValue = value;
|
||||||
|
so.ApplyModifiedProperties();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: Dn4fvSL+Bi5qpJQPKTiLAFU3fgR+KLt4w0Ql57XB14ROp4pNfqn9n84=
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
using Architecture.Variables;
|
||||||
|
|
||||||
|
namespace Architecture.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 变量资产的 Inspector 绘制器:在字段右侧实时显示当前值(含 Play 模式运行时值),
|
||||||
|
/// 设计师无需打开脚本即可看到数值,降低沟通成本。
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: BnMa4CioUihgI9MvBm9J06Z6TNiVXXGlVT3vne3vk2CEiePGAFaObuc=
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: DXkasC+sU3ihXHypaAzLi0kYmkhZiiQpNsOF3Y4Ksr+Tt5M8BpbIw1w=
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Architecture.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 运行时集合:替代 FindObjectsOfType / 单例列表,零单例开销地跟踪场景实体。
|
||||||
|
/// 实体在 OnEnable 注册、OnDisable 注销(见下方 RuntimeSetRegistrar)。
|
||||||
|
/// 资产实例是项目级资源,跨场景存活,可安全被任何系统读取。
|
||||||
|
/// </summary>
|
||||||
|
public abstract class RuntimeSet<T> : ScriptableObject
|
||||||
|
{
|
||||||
|
public List<T> Items = new List<T>();
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 通用 Transform 集合,作为"当前场景实体"的轻量注册表(玩家、敌人等)。
|
||||||
|
/// 领域专用集合(如 EnemyRuntimeSet : RuntimeSet<EnemyAI>)放在对应领域文件夹,
|
||||||
|
/// 继承本类即可,无需改动核心。
|
||||||
|
/// </summary>
|
||||||
|
[CreateAssetMenu(menuName = "Architecture/Runtime Sets/Transform Set", fileName = "NewTransformSet")]
|
||||||
|
public class TransformRuntimeSet : RuntimeSet<Transform> { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 挂载到任意预制体,使其自动注册进指定 RuntimeSet。
|
||||||
|
/// 这样系统只需遍历集合,永不需要 GameObject.Find / FindObjectsOfType。
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 泛型注册器:领域专用强类型集合(如 <c>EnemyRuntimeSet : RuntimeSet<EnemyAI></c>)使用。
|
||||||
|
/// 自动 <c>GetComponent<T>()</c> 并加入对应集合,避免用 Transform 集合再手动转型。
|
||||||
|
/// 因 Unity 对泛型 MonoBehaviour 需具体闭包类型才能挂载,领域文件夹内写一行具体子类即可:
|
||||||
|
/// <code>public class EnemySetRegistrar : RuntimeSetRegistrar<EnemyAI> { }</code>
|
||||||
|
/// </summary>
|
||||||
|
public class RuntimeSetRegistrar<T> : MonoBehaviour where T : Component
|
||||||
|
{
|
||||||
|
[SerializeField] private RuntimeSet<T> _set;
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
if (_set != null) _set.Add(GetComponent<T>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
if (_set != null) _set.Remove(GetComponent<T>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: By8XsSukWyonwLQZ/3RNaE8FAk+70HcGhhnyFRI8oY/tcLIntpkDvH4=
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: CXMW4CP8BnnBCuaEto3WYyr2C3RLoOC5Jd5fjwZGKcsuUekgm8W5KFY=
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Architecture.Variables
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 共享布尔变量(ScriptableObject 资产)。常用于暂停、开关等状态。
|
||||||
|
/// </summary>
|
||||||
|
[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<bool> OnValueChanged;
|
||||||
|
|
||||||
|
public void SetValue(bool value) => Value = value;
|
||||||
|
|
||||||
|
/// <summary>将运行时值重置为 _defaultValue(每次进入 Play Mode 时由 VariableRegistry 自动调用)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: DXsdsCquB3geiEyhIkY2aFLJpbGuThlr6ky9pgS1Nnrno7aw3ZWk4Uc=
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Architecture.Variables
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 共享浮点变量(ScriptableObject 资产)。
|
||||||
|
/// 任何系统可读取 / 写入,并由 OnValueChanged 事件驱动 UI 刷新,
|
||||||
|
/// 替代 MonoBehaviour 字段跨系统传递与逐帧反射读取。
|
||||||
|
/// </summary>
|
||||||
|
[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<float> OnValueChanged;
|
||||||
|
|
||||||
|
public void SetValue(float value) => Value = value;
|
||||||
|
public void ApplyChange(float amount) => Value += amount;
|
||||||
|
|
||||||
|
/// <summary>将运行时值重置为 _defaultValue(每次进入 Play Mode 时由 VariableRegistry 自动调用)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Architecture.Variables
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 共享整数变量(ScriptableObject 资产)。常用于血量、得分、魂灵数等。
|
||||||
|
/// </summary>
|
||||||
|
[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<int> OnValueChanged;
|
||||||
|
|
||||||
|
public void SetValue(int value) => Value = value;
|
||||||
|
public void ApplyChange(int amount) => Value += amount;
|
||||||
|
|
||||||
|
/// <summary>将运行时值重置为 _defaultValue(每次进入 Play Mode 时由 VariableRegistry 自动调用)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: W3octHuoWnnZAVrTuCOazcbeHxKYqo5abD3dZ/KJEBmkydHqGDyV9qE=
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Architecture
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 共享变量统一接口:所有 Variable 资产实现它,注册表才能统一重置。
|
||||||
|
/// </summary>
|
||||||
|
public interface IVariable
|
||||||
|
{
|
||||||
|
void ResetToDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 共享变量注册表 —— 运行时状态防泄漏的核心。
|
||||||
|
///
|
||||||
|
/// <b>问题</b>:ScriptableObject 资产在 Editor 下跨 Play Mode 域重载会持久化其序列化值。
|
||||||
|
/// 例如 PlayerHealth.Value 在游玩中变为 0 后退出 Play Mode,若残留为 0,
|
||||||
|
/// 下次进入 Play Mode 可能直接触发死亡(或分数残留、开关状态错乱)。
|
||||||
|
///
|
||||||
|
/// <b>解决</b>:每个 Variable 在 OnEnable 时自动注册;每次进入 Play Mode(域加载前)
|
||||||
|
/// 由 [RuntimeInitializeOnLoadMethod] 统一调用 ResetToDefault(),
|
||||||
|
/// 将运行时值恢复到设计器设置的 _defaultValue,杜绝跨局状态泄漏。
|
||||||
|
///
|
||||||
|
/// 注意:RuntimeInitializeLoadType.BeforeSceneLoad 在整个 Play Mode 生命周期内只触发一次,
|
||||||
|
/// 因此同一局内跨场景加载(SceneManager.LoadScene)不会重置变量,分数等跨场景共享状态可正常保留。
|
||||||
|
/// </summary>
|
||||||
|
public static class VariableRegistry
|
||||||
|
{
|
||||||
|
private static readonly HashSet<IVariable> _variables = new HashSet<IVariable>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: DHMdty+tWygUNpW3ZTnsgECvyUsf7TZS/Bowuhd8nTjV1gGiSvKO6z4=
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Architecture.Variables
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 共享 Vector3 变量(ScriptableObject 资产)。常用于位置、回声中心等。
|
||||||
|
/// </summary>
|
||||||
|
[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<Vector3> OnValueChanged;
|
||||||
|
|
||||||
|
public void SetValue(Vector3 value) => Value = value;
|
||||||
|
|
||||||
|
/// <summary>将运行时值重置为 _defaultValue(每次进入 Play Mode 时由 VariableRegistry 自动调用)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: BnIa4SyuVyiKWphMPOJ0zNHa5e8eoX06FNossjB+7+Ma8SaSRkVd14E=
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -3,6 +3,7 @@ using UnityEditor;
|
|||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
using GameFramework;
|
using GameFramework;
|
||||||
|
using Architecture.Core;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 一键搭建游戏 UI:HUD Canvas + 游戏结果 Canvas + 主菜单标题。
|
/// 一键搭建游戏 UI:HUD Canvas + 游戏结果 Canvas + 主菜单标题。
|
||||||
@@ -300,6 +301,13 @@ public static class UIBuilder
|
|||||||
so.FindProperty("loseConfirmButton").objectReferenceValue = loseBtn.GetComponent<Button>();
|
so.FindProperty("loseConfirmButton").objectReferenceValue = loseBtn.GetComponent<Button>();
|
||||||
so.FindProperty("loseTitleText").objectReferenceValue = loseTitle.GetComponent<Text>();
|
so.FindProperty("loseTitleText").objectReferenceValue = loseTitle.GetComponent<Text>();
|
||||||
so.FindProperty("loseScoreText").objectReferenceValue = loseScore.GetComponent<Text>();
|
so.FindProperty("loseScoreText").objectReferenceValue = loseScore.GetComponent<Text>();
|
||||||
|
|
||||||
|
// 连接 SO 事件通道(与 GameManager 发布方使用同一资产,建立解耦监听)
|
||||||
|
// 注意:GameResultScreen 现已明确只负责「胜利」,不再订阅 OnGameOver(失败由 GameLostOverlay 接管)。
|
||||||
|
var onGameWin = AssetDatabase.LoadAssetAtPath<GameEvent>("Assets/Architecture/Assets/OnGameWin.asset");
|
||||||
|
if (onGameWin != null) so.FindProperty("onGameWinEvent").objectReferenceValue = onGameWin;
|
||||||
|
else Debug.LogWarning("[UIBuilder] 未找到 OnGameWin 资产,请先运行 Architecture > Bootstrap Core Assets");
|
||||||
|
|
||||||
so.ApplyModifiedProperties();
|
so.ApplyModifiedProperties();
|
||||||
|
|
||||||
Debug.Log("[UIBuilder] ResultScreen Canvas 搭建完成");
|
Debug.Log("[UIBuilder] ResultScreen Canvas 搭建完成");
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
[Tooltip("边缘发光材质(使用 AbyssEdgeGlow shader)。留空则自动创建。")]
|
[Tooltip("边缘发光材质(使用 AbyssEdgeGlow shader)。留空则自动创建。")]
|
||||||
[SerializeField] private Material edgeGlowMaterial;
|
[SerializeField] private Material edgeGlowMaterial;
|
||||||
|
|
||||||
|
[Tooltip("边缘发光着色器(留空则尝试 Shader.Find 兜底;建议拖入 Assets/Light/shaders/AbyssEdgeGlow.shader 以避免构建裁剪)")]
|
||||||
|
[SerializeField] private Shader edgeGlowShader;
|
||||||
|
|
||||||
private const string GeneratedName = "Generated_CliffWalls";
|
private const string GeneratedName = "Generated_CliffWalls";
|
||||||
|
|
||||||
[ContextMenu("Build Cliff Walls")]
|
[ContextMenu("Build Cliff Walls")]
|
||||||
@@ -105,7 +108,7 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// 尝试自动创建 AbyssEdgeGlow 材质
|
// 尝试自动创建 AbyssEdgeGlow 材质
|
||||||
Shader edgeShader = Shader.Find("IndianOcean/AbyssEdgeGlow");
|
Shader edgeShader = edgeGlowShader != null ? edgeGlowShader : Shader.Find("IndianOcean/AbyssEdgeGlow");
|
||||||
if (edgeShader != null)
|
if (edgeShader != null)
|
||||||
{
|
{
|
||||||
Material mat = new Material(edgeShader);
|
Material mat = new Material(edgeShader);
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
[Tooltip("边缘发光材质(使用 AbyssEdgeGlow shader)。留空则自动创建。")]
|
[Tooltip("边缘发光材质(使用 AbyssEdgeGlow shader)。留空则自动创建。")]
|
||||||
[SerializeField] Material edgeGlowMaterial;
|
[SerializeField] Material edgeGlowMaterial;
|
||||||
|
|
||||||
|
[Tooltip("边缘发光着色器(留空则尝试 Shader.Find 兜底;建议拖入 Assets/Light/shaders/AbyssEdgeGlow.shader 以避免构建裁剪)")]
|
||||||
|
[SerializeField] private Shader edgeGlowShader;
|
||||||
|
|
||||||
[Header("优化")]
|
[Header("优化")]
|
||||||
[Tooltip("Douglas-Peucker 简化容差(世界单位)。0 = 不简化。\n" +
|
[Tooltip("Douglas-Peucker 简化容差(世界单位)。0 = 不简化。\n" +
|
||||||
"建议 0.05~0.2,减少顶点数同时保持轮廓形状。")]
|
"建议 0.05~0.2,减少顶点数同时保持轮廓形状。")]
|
||||||
@@ -177,7 +180,7 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Shader glowShader = Shader.Find("IndianOcean/AbyssEdgeGlow");
|
Shader glowShader = edgeGlowShader != null ? edgeGlowShader : Shader.Find("IndianOcean/AbyssEdgeGlow");
|
||||||
if (glowShader != null)
|
if (glowShader != null)
|
||||||
{
|
{
|
||||||
Material mat = new Material(glowShader);
|
Material mat = new Material(glowShader);
|
||||||
|
|||||||
@@ -3778,11 +3778,13 @@ MonoBehaviour:
|
|||||||
mainMenuButton: {fileID: 549601800}
|
mainMenuButton: {fileID: 549601800}
|
||||||
mainMenuButtonImage: {fileID: 549601797}
|
mainMenuButtonImage: {fileID: 549601797}
|
||||||
lostSFX: {fileID: 0}
|
lostSFX: {fileID: 0}
|
||||||
|
onGameOverEvent: {fileID: 11400000, guid: 7bc1fd1afe8d2774fb5cc757ef12895b, type: 2}
|
||||||
titleFadeDuration: 1.5
|
titleFadeDuration: 1.5
|
||||||
ripplePositionAmplitude: 8
|
ripplePositionAmplitude: 8
|
||||||
rippleScaleAmplitude: 0.08
|
rippleScaleAmplitude: 0.08
|
||||||
rippleFrequency: 15
|
rippleFrequency: 15
|
||||||
rippleMaterial: {fileID: 2100000, guid: 2d4ef2f4a2f0eb2479ece51f60be054d, type: 2}
|
rippleMaterial: {fileID: 2100000, guid: 2d4ef2f4a2f0eb2479ece51f60be054d, type: 2}
|
||||||
|
waterRippleShader: {fileID: 0}
|
||||||
buttonFadeDelay: 2
|
buttonFadeDelay: 2
|
||||||
buttonFadeDuration: 0.5
|
buttonFadeDuration: 0.5
|
||||||
--- !u!4 &73055215
|
--- !u!4 &73055215
|
||||||
@@ -7504,6 +7506,8 @@ MonoBehaviour:
|
|||||||
skillLanternIcon: {fileID: 1435658950}
|
skillLanternIcon: {fileID: 1435658950}
|
||||||
skillLanternCD: {fileID: 2099612955}
|
skillLanternCD: {fileID: 2099612955}
|
||||||
pauseButton: {fileID: 0}
|
pauseButton: {fileID: 0}
|
||||||
|
scoreChangedEvent: {fileID: 11400000, guid: f9b9156804ea63d44a675e3485b43f88, type: 2}
|
||||||
|
playerHealthVar: {fileID: 11400000, guid: 6952ca1b8005ae541bf172dd398d4cc7, type: 2}
|
||||||
--- !u!114 &160213092
|
--- !u!114 &160213092
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -25269,6 +25273,55 @@ BoxCollider:
|
|||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 0.8, y: 3, z: 0.41948986}
|
m_Size: {x: 0.8, y: 3, z: 0.41948986}
|
||||||
m_Center: {x: 0, y: 0, z: 0}
|
m_Center: {x: 0, y: 0, z: 0}
|
||||||
|
--- !u!1 &535625614
|
||||||
|
GameObject:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
serializedVersion: 7
|
||||||
|
m_Component:
|
||||||
|
- component: {fileID: 535625616}
|
||||||
|
- component: {fileID: 535625615}
|
||||||
|
m_Layer: 0
|
||||||
|
m_HasEditorInfo: 1
|
||||||
|
m_Name: DamageFlashOverlay
|
||||||
|
m_TagString: Untagged
|
||||||
|
m_Icon: {fileID: 0}
|
||||||
|
m_NavMeshLayer: 0
|
||||||
|
m_StaticEditorFlags: 0
|
||||||
|
m_IsActive: 1
|
||||||
|
--- !u!114 &535625615
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 535625614}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 356da6cdceb3f0144b7782901b81314d, type: 3}
|
||||||
|
m_Name:
|
||||||
|
m_EditorClassIdentifier:
|
||||||
|
flashAlpha: 0.8
|
||||||
|
fadeDuration: 0.4
|
||||||
|
innerRadius: 0.8
|
||||||
|
onPlayerDamagedEvent: {fileID: 11400000, guid: 1690413250c5d464eb84008d45cb88cc, type: 2}
|
||||||
|
--- !u!4 &535625616
|
||||||
|
Transform:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 535625614}
|
||||||
|
serializedVersion: 2
|
||||||
|
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||||
|
m_LocalPosition: {x: 424.2601, y: 195.52283, z: 80.32833}
|
||||||
|
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||||
|
m_ConstrainProportionsScale: 0
|
||||||
|
m_Children: []
|
||||||
|
m_Father: {fileID: 0}
|
||||||
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||||
--- !u!1001 &539669134
|
--- !u!1001 &539669134
|
||||||
PrefabInstance:
|
PrefabInstance:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -67656,6 +67709,11 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: 0bc49c6d42cb88a4c981d949a1cd87cd, type: 3}
|
m_Script: {fileID: 11500000, guid: 0bc49c6d42cb88a4c981d949a1cd87cd, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
onGameOverEvent: {fileID: 11400000, guid: 7bc1fd1afe8d2774fb5cc757ef12895b, type: 2}
|
||||||
|
onGameWinEvent: {fileID: 11400000, guid: c4daa9c5c7532f3409b33e98e40fec55, type: 2}
|
||||||
|
onPlayerDiedEvent: {fileID: 11400000, guid: 8eee0c771c042c44a88f48756f3717b5, type: 2}
|
||||||
|
enemiesSet: {fileID: 11400000, guid: f9b1d1725469e0c40a31cf48dbc6439e, type: 2}
|
||||||
|
enemyManagerRef: {fileID: 340526138}
|
||||||
gameState: 0
|
gameState: 0
|
||||||
lightShrinkDuration: 2
|
lightShrinkDuration: 2
|
||||||
lostFadeOutDuration: 1
|
lostFadeOutDuration: 1
|
||||||
@@ -101243,6 +101301,14 @@ PrefabInstance:
|
|||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_TransformParent: {fileID: 0}
|
m_TransformParent: {fileID: 0}
|
||||||
m_Modifications:
|
m_Modifications:
|
||||||
|
- target: {fileID: 230515964846317157, guid: a60578acb930546489d4f5b6d598bce8, type: 3}
|
||||||
|
propertyPath: ringShader
|
||||||
|
value:
|
||||||
|
objectReference: {fileID: 4800000, guid: 67f78fa2aa44cba4cadd869535881366, type: 3}
|
||||||
|
- target: {fileID: 230515964846317157, guid: a60578acb930546489d4f5b6d598bce8, type: 3}
|
||||||
|
propertyPath: echoReleasedEvent
|
||||||
|
value:
|
||||||
|
objectReference: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
|
||||||
- target: {fileID: 4224307220934746970, guid: a60578acb930546489d4f5b6d598bce8, type: 3}
|
- target: {fileID: 4224307220934746970, guid: a60578acb930546489d4f5b6d598bce8, type: 3}
|
||||||
propertyPath: m_LocalPosition.x
|
propertyPath: m_LocalPosition.x
|
||||||
value: 8.063
|
value: 8.063
|
||||||
@@ -101334,3 +101400,4 @@ SceneRoots:
|
|||||||
- {fileID: 1115832006}
|
- {fileID: 1115832006}
|
||||||
- {fileID: 2106106797}
|
- {fileID: 2106106797}
|
||||||
- {fileID: 2084288743}
|
- {fileID: 2084288743}
|
||||||
|
- {fileID: 535625616}
|
||||||
|
|||||||
@@ -931,6 +931,11 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: 0bc49c6d42cb88a4c981d949a1cd87cd, type: 3}
|
m_Script: {fileID: 11500000, guid: 0bc49c6d42cb88a4c981d949a1cd87cd, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
onGameOverEvent: {fileID: 11400000, guid: 7bc1fd1afe8d2774fb5cc757ef12895b, type: 2}
|
||||||
|
onGameWinEvent: {fileID: 11400000, guid: c4daa9c5c7532f3409b33e98e40fec55, type: 2}
|
||||||
|
onPlayerDiedEvent: {fileID: 11400000, guid: 8eee0c771c042c44a88f48756f3717b5, type: 2}
|
||||||
|
enemiesSet: {fileID: 11400000, guid: f9b1d1725469e0c40a31cf48dbc6439e, type: 2}
|
||||||
|
enemyManagerRef: {fileID: 0}
|
||||||
gameState: 0
|
gameState: 0
|
||||||
lightShrinkDuration: 2
|
lightShrinkDuration: 2
|
||||||
lostFadeOutDuration: 1
|
lostFadeOutDuration: 1
|
||||||
@@ -1173,6 +1178,8 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: 3e98896ae36c7a54ba83c4e9eb532752, type: 3}
|
m_Script: {fileID: 11500000, guid: 3e98896ae36c7a54ba83c4e9eb532752, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
scoreChangedEvent: {fileID: 11400000, guid: f9b9156804ea63d44a675e3485b43f88, type: 2}
|
||||||
|
scoreSettledEvent: {fileID: 11400000, guid: eee0021c0c646f3469a86bef9c590cff, type: 2}
|
||||||
defaultPlayerName: Player
|
defaultPlayerName: Player
|
||||||
--- !u!4 &646400229
|
--- !u!4 &646400229
|
||||||
Transform:
|
Transform:
|
||||||
@@ -4723,6 +4730,8 @@ MonoBehaviour:
|
|||||||
skillLanternIcon: {fileID: 0}
|
skillLanternIcon: {fileID: 0}
|
||||||
skillLanternCD: {fileID: 0}
|
skillLanternCD: {fileID: 0}
|
||||||
pauseButton: {fileID: 0}
|
pauseButton: {fileID: 0}
|
||||||
|
scoreChangedEvent: {fileID: 0}
|
||||||
|
playerHealthVar: {fileID: 0}
|
||||||
--- !u!114 &1852443089
|
--- !u!114 &1852443089
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -4947,6 +4956,8 @@ MonoBehaviour:
|
|||||||
skillLanternIcon: {fileID: 1675213501}
|
skillLanternIcon: {fileID: 1675213501}
|
||||||
skillLanternCD: {fileID: 1498112937}
|
skillLanternCD: {fileID: 1498112937}
|
||||||
pauseButton: {fileID: 90840731}
|
pauseButton: {fileID: 90840731}
|
||||||
|
scoreChangedEvent: {fileID: 0}
|
||||||
|
playerHealthVar: {fileID: 0}
|
||||||
--- !u!114 &1890235558
|
--- !u!114 &1890235558
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -268,6 +268,10 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
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
|
maxHealth: 5
|
||||||
deathEffect: {fileID: 4806121257990350900, guid: f19c76183b5e22e44a73655dc18f1a92, type: 3}
|
deathEffect: {fileID: 4806121257990350900, guid: f19c76183b5e22e44a73655dc18f1a92, type: 3}
|
||||||
isPlayer: 1
|
isPlayer: 1
|
||||||
@@ -343,6 +347,7 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: 0a44142f1063e1d4dbab7b851d31d258, type: 3}
|
m_Script: {fileID: 11500000, guid: 0a44142f1063e1d4dbab7b851d31d258, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
|
||||||
echoKey: 101
|
echoKey: 101
|
||||||
expandSpeed: 15
|
expandSpeed: 15
|
||||||
maxRadius: 30
|
maxRadius: 30
|
||||||
@@ -355,6 +360,7 @@ MonoBehaviour:
|
|||||||
ringYOffset: 0.1
|
ringYOffset: 0.1
|
||||||
ringFadeTime: 2
|
ringFadeTime: 2
|
||||||
ringExpandSpeed: 18
|
ringExpandSpeed: 18
|
||||||
|
ringShader: {fileID: 4800000, guid: 67f78fa2aa44cba4cadd869535881366, type: 3}
|
||||||
cooldown: 10
|
cooldown: 10
|
||||||
--- !u!114 &4208162987965796937
|
--- !u!114 &4208162987965796937
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using IndianOceanAssets.Engine2_5D;
|
using IndianOceanAssets.Engine2_5D;
|
||||||
|
using Architecture.Core;
|
||||||
|
|
||||||
namespace GameFramework
|
namespace GameFramework
|
||||||
{
|
{
|
||||||
@@ -27,15 +28,32 @@ namespace GameFramework
|
|||||||
private float _currentAlpha;
|
private float _currentAlpha;
|
||||||
private bool _isFlashing;
|
private bool _isFlashing;
|
||||||
|
|
||||||
|
[Header("SO 事件通道(替代 static HealthSystem.onPlayerDamaged 事件)")]
|
||||||
|
[SerializeField] private GameEvent onPlayerDamagedEvent;
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
if (onPlayerDamagedEvent == null)
|
||||||
|
Debug.LogWarning("[DamageFlashOverlay] onPlayerDamagedEvent 未接线!请在场景中预置已接线的 DamageFlashOverlay 实例,否则受击不闪红。", this);
|
||||||
|
else
|
||||||
|
onPlayerDamagedEvent.Register(OnPlayerDamaged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
if (onPlayerDamagedEvent != null)
|
||||||
|
onPlayerDamagedEvent.Unregister(OnPlayerDamaged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
if (onPlayerDamagedEvent == null)
|
||||||
|
Debug.LogWarning($"[DamageFlashOverlay] On Player Damaged Event 未接线({gameObject.name})。受击泛红不会出现。", this);
|
||||||
|
}
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
CreateOverlay();
|
CreateOverlay();
|
||||||
HealthSystem.onPlayerDamaged += OnPlayerDamaged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDestroy()
|
|
||||||
{
|
|
||||||
HealthSystem.onPlayerDamaged -= OnPlayerDamaged;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
|
|||||||
+37
-75
@@ -2,12 +2,14 @@ using UnityEngine;
|
|||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using TMPro;
|
using TMPro;
|
||||||
using IndianOceanAssets.Engine2_5D;
|
using IndianOceanAssets.Engine2_5D;
|
||||||
|
using Architecture.Core;
|
||||||
|
using Architecture.Variables;
|
||||||
|
|
||||||
namespace GameFramework
|
namespace GameFramework
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 游戏内 HUD —— 魂灵计数 + 生命图标 + 技能CD。
|
/// 游戏内 HUD —— 魂灵计数 + 生命图标 + 技能CD。
|
||||||
///
|
///
|
||||||
/// 布局:
|
/// 布局:
|
||||||
/// - 左上角:收集到的魂灵(icon + 数字)
|
/// - 左上角:收集到的魂灵(icon + 数字)
|
||||||
/// - 左下角:5个生命图标
|
/// - 左下角:5个生命图标
|
||||||
@@ -36,12 +38,14 @@ namespace GameFramework
|
|||||||
[Header("暂停")]
|
[Header("暂停")]
|
||||||
[SerializeField] private Button pauseButton;
|
[SerializeField] private Button pauseButton;
|
||||||
|
|
||||||
|
[Header("SO 事件通道 / 变量")]
|
||||||
|
[SerializeField] private IntEvent scoreChangedEvent;
|
||||||
|
[SerializeField] private IntVariable playerHealthVar;
|
||||||
|
|
||||||
private HealthSystem _playerHealth;
|
private HealthSystem _playerHealth;
|
||||||
private SpiritLanternSystem _lanternSystem;
|
private SpiritLanternSystem _lanternSystem;
|
||||||
private EchoSystem _echoSystem;
|
private EchoSystem _echoSystem;
|
||||||
private PlayerController _playerController;
|
private PlayerController _playerController;
|
||||||
private int _maxHealth = 5;
|
|
||||||
private bool _subscribedScoreEvent = false;
|
|
||||||
private int _lastSoulCount = -1;
|
private int _lastSoulCount = -1;
|
||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
@@ -62,59 +66,34 @@ namespace GameFramework
|
|||||||
if (soulIcon != null)
|
if (soulIcon != null)
|
||||||
SoulIconRect = soulIcon.rectTransform;
|
SoulIconRect = soulIcon.rectTransform;
|
||||||
|
|
||||||
// 订阅分数事件(魂灵计数复用分数系统)
|
// 订阅分数事件(魂灵计数复用分数系统):无条件注册,事件资产始终存在;
|
||||||
if (ScoreManager.Instance != null)
|
// 初始值若 ScoreManager 已就绪则取实时分,否则先显示 0,首个 ScoreChanged 会刷新。
|
||||||
|
scoreChangedEvent?.Register(UpdateSoulCount);
|
||||||
|
UpdateSoulCount(ScoreManager.Instance != null ? ScoreManager.Instance.CurrentScore : 0);
|
||||||
|
|
||||||
|
// 订阅玩家血量变量(替代反射读取私有字段 + Update 条件 Find 回退)
|
||||||
|
if (playerHealthVar != null)
|
||||||
{
|
{
|
||||||
ScoreManager.onScoreChanged += UpdateSoulCount;
|
playerHealthVar.OnValueChanged += UpdateLifeIcons;
|
||||||
_subscribedScoreEvent = true;
|
UpdateLifeIcons(playerHealthVar.Value);
|
||||||
UpdateSoulCount(ScoreManager.Instance.CurrentScore);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateLifeIcons();
|
// 受击泛红特效:场景需预置已接线的 DamageFlashOverlay 实例(不再自动创建,
|
||||||
|
// 否则会生成一个事件为 null 的实例,既无效果又掩盖「未接线」问题)
|
||||||
// 自动创建受击泛红特效(如果场景中没有)
|
|
||||||
if (FindObjectOfType<DamageFlashOverlay>() == null)
|
if (FindObjectOfType<DamageFlashOverlay>() == null)
|
||||||
{
|
Debug.LogWarning("[GameHUD] 场景中未找到 DamageFlashOverlay,受击泛红特效不会显示。请在场景中放置一个、并把它 On Player Damaged Event 字段接上 OnPlayerDamaged 资产。", this);
|
||||||
var overlayObj = new GameObject("DamageFlashOverlay");
|
|
||||||
overlayObj.AddComponent<DamageFlashOverlay>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnDestroy()
|
void OnDestroy()
|
||||||
{
|
{
|
||||||
if (_subscribedScoreEvent)
|
scoreChangedEvent?.Unregister(UpdateSoulCount);
|
||||||
{
|
if (playerHealthVar != null)
|
||||||
ScoreManager.onScoreChanged -= UpdateSoulCount;
|
playerHealthVar.OnValueChanged -= UpdateLifeIcons;
|
||||||
_subscribedScoreEvent = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Update()
|
void Update()
|
||||||
{
|
{
|
||||||
// 延迟订阅 ScoreManager(跨场景后 ScoreManager 可能才创建)
|
// 事件已在 Start 中无条件订阅,无需逐帧延迟订阅(避免反模式)。
|
||||||
if (!_subscribedScoreEvent && ScoreManager.Instance != null)
|
|
||||||
{
|
|
||||||
ScoreManager.onScoreChanged += UpdateSoulCount;
|
|
||||||
_subscribedScoreEvent = true;
|
|
||||||
UpdateSoulCount(ScoreManager.Instance.CurrentScore);
|
|
||||||
Debug.Log("[GameHUD] 延迟订阅 ScoreManager.onScoreChanged 成功");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 延迟查找玩家(GameSpawnManager 可能比 HUD 晚创建玩家)
|
|
||||||
if (_playerHealth == null)
|
|
||||||
{
|
|
||||||
var player = GameObject.FindWithTag("Player");
|
|
||||||
if (player != null)
|
|
||||||
{
|
|
||||||
_playerHealth = player.GetComponent<HealthSystem>();
|
|
||||||
_lanternSystem = player.GetComponent<SpiritLanternSystem>();
|
|
||||||
_echoSystem = player.GetComponent<EchoSystem>();
|
|
||||||
_playerController = player.GetComponent<PlayerController>();
|
|
||||||
Debug.Log("[GameHUD] 延迟找到玩家,已绑定组件");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateLifeIcons();
|
|
||||||
UpdateSkillCooldowns();
|
UpdateSkillCooldowns();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,17 +119,14 @@ namespace GameFramework
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 更新生命图标(左下角)。
|
/// 更新生命图标(左下角)。
|
||||||
/// 根据当前血量显示/隐藏对应图标。
|
/// 直接读取 PlayerHealth 共享变量,由 OnValueChanged 事件驱动,无反射、无逐帧 Find。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void UpdateLifeIcons()
|
private void UpdateLifeIcons(int value)
|
||||||
{
|
{
|
||||||
if (_playerHealth == null || lifeIcons == null || lifeIcons.Length == 0) return;
|
if (lifeIcons == null || lifeIcons.Length == 0) return;
|
||||||
|
if (playerHealthVar == null) return;
|
||||||
// 获取当前血量
|
|
||||||
var healthField = typeof(HealthSystem).GetField("health",
|
|
||||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
||||||
int currentHealth = healthField != null ? (int)healthField.GetValue(_playerHealth) : _maxHealth;
|
|
||||||
|
|
||||||
|
int currentHealth = value;
|
||||||
for (int i = 0; i < lifeIcons.Length; i++)
|
for (int i = 0; i < lifeIcons.Length; i++)
|
||||||
{
|
{
|
||||||
if (lifeIcons[i] != null)
|
if (lifeIcons[i] != null)
|
||||||
@@ -160,43 +136,29 @@ namespace GameFramework
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 更新技能CD遮罩(右下角)。
|
/// 更新技能CD遮罩(右下角)。
|
||||||
/// 使用 Image.fillAmount 实现圆形CD效果。
|
/// 通过各系统的公共只读属性读取冷却状态,替代反射读取私有字段。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void UpdateSkillCooldowns()
|
private void UpdateSkillCooldowns()
|
||||||
{
|
{
|
||||||
// 冲刺 CD(从 PlayerController 读取 rollCooldown + lastRollTime)
|
// 冲刺 CD(从 PlayerController.RollCooldown 读取)
|
||||||
if (_playerController != null)
|
if (_playerController != null)
|
||||||
{
|
{
|
||||||
var cdField = typeof(PlayerController).GetField("rollCooldown",
|
var cd = _playerController.RollCooldown;
|
||||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
UpdateSkillCD(skillSprintCD, skillSprintIcon, cd.remaining, cd.total);
|
||||||
var lastField = typeof(PlayerController).GetField("lastRollTime",
|
|
||||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
||||||
float sprintCD = cdField != null ? (float)cdField.GetValue(_playerController) : 1f;
|
|
||||||
float lastRoll = lastField != null ? (float)lastField.GetValue(_playerController) : -999f;
|
|
||||||
float remaining = Mathf.Max(0f, (lastRoll + sprintCD) - Time.time);
|
|
||||||
UpdateSkillCD(skillSprintCD, skillSprintIcon, remaining, sprintCD);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 摇铃 CD(从 EchoSystem 读取 cooldown + _lastEchoTime)
|
// 摇铃 CD(从 EchoSystem.BellCooldown 读取)
|
||||||
if (_echoSystem != null)
|
if (_echoSystem != null)
|
||||||
{
|
{
|
||||||
var cdField = typeof(EchoSystem).GetField("cooldown",
|
var cd = _echoSystem.BellCooldown;
|
||||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
UpdateSkillCD(skillBellCD, skillBellIcon, cd.remaining, cd.total);
|
||||||
var lastField = typeof(EchoSystem).GetField("_lastEchoTime",
|
|
||||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
||||||
float bellCD = cdField != null ? (float)cdField.GetValue(_echoSystem) : 2f;
|
|
||||||
float lastTime = lastField != null ? (float)lastField.GetValue(_echoSystem) : -999f;
|
|
||||||
float remaining = Mathf.Max(0f, (lastTime + bellCD) - Time.time);
|
|
||||||
UpdateSkillCD(skillBellCD, skillBellIcon, remaining, bellCD);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 灵灯 CD(从 SpiritLanternSystem 读取)
|
// 灵灯 CD(SpiritLanternSystem 已暴露公共 Cooldown / CooldownRemaining,无反射)
|
||||||
if (_lanternSystem != null)
|
if (_lanternSystem != null)
|
||||||
{
|
{
|
||||||
float remaining = _lanternSystem.CooldownRemaining;
|
float remaining = _lanternSystem.CooldownRemaining;
|
||||||
var cdField = typeof(SpiritLanternSystem).GetField("cooldown",
|
float maxCD = _lanternSystem.Cooldown;
|
||||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
|
||||||
float maxCD = cdField != null ? (float)cdField.GetValue(_lanternSystem) : 3f;
|
|
||||||
UpdateSkillCD(skillLanternCD, skillLanternIcon, remaining, maxCD);
|
UpdateSkillCD(skillLanternCD, skillLanternIcon, remaining, maxCD);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections;
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using UnityEngine.SceneManagement;
|
using UnityEngine.SceneManagement;
|
||||||
|
using Architecture.Core;
|
||||||
|
|
||||||
namespace GameFramework
|
namespace GameFramework
|
||||||
{
|
{
|
||||||
@@ -12,14 +13,12 @@ namespace GameFramework
|
|||||||
/// 1. 在 Gameplay 场景里创建一个空物体,挂上本脚本
|
/// 1. 在 Gameplay 场景里创建一个空物体,挂上本脚本
|
||||||
/// 2. 运行一次游戏(或点 Editor 按钮),脚本会自动构建子 UI
|
/// 2. 运行一次游戏(或点 Editor 按钮),脚本会自动构建子 UI
|
||||||
/// 3. 停止运行后,子 UI 留在场景中,可自由编辑样式/位置/精灵
|
/// 3. 停止运行后,子 UI 留在场景中,可自由编辑样式/位置/精灵
|
||||||
/// 4. 运行时由 GameManager 调用 GameLostOverlay.Show() 激活
|
/// 4. 运行时订阅 OnGameOver 事件自激活(不再由 GameManager 直接调用)
|
||||||
///
|
///
|
||||||
/// 所有 UI 元素均为 [SerializeField],可在 Inspector 中替换。
|
/// 所有 UI 元素均为 [SerializeField],可在 Inspector 中替换。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GameLostOverlay : MonoBehaviour
|
public class GameLostOverlay : MonoBehaviour
|
||||||
{
|
{
|
||||||
private static GameLostOverlay _instance;
|
|
||||||
|
|
||||||
// ====== Canvas ======
|
// ====== Canvas ======
|
||||||
[Header("Canvas(留空则自动创建)")]
|
[Header("Canvas(留空则自动创建)")]
|
||||||
[SerializeField] private Canvas lostCanvas;
|
[SerializeField] private Canvas lostCanvas;
|
||||||
@@ -43,6 +42,9 @@ namespace GameFramework
|
|||||||
[Header("音效")]
|
[Header("音效")]
|
||||||
[SerializeField] private AudioData lostSFX;
|
[SerializeField] private AudioData lostSFX;
|
||||||
|
|
||||||
|
[Header("SO 事件通道(替代 GameManager 直接调用 Show,订阅 OnGameOver 自激活)")]
|
||||||
|
[SerializeField] private GameEvent onGameOverEvent;
|
||||||
|
|
||||||
[Header("标题渐现动效")]
|
[Header("标题渐现动效")]
|
||||||
[Tooltip("标题渐现总时长(秒)")]
|
[Tooltip("标题渐现总时长(秒)")]
|
||||||
[SerializeField] private float titleFadeDuration = 1.5f;
|
[SerializeField] private float titleFadeDuration = 1.5f;
|
||||||
@@ -54,6 +56,9 @@ namespace GameFramework
|
|||||||
[SerializeField] private float rippleFrequency = 15f;
|
[SerializeField] private float rippleFrequency = 15f;
|
||||||
[Tooltip("水波纹材质(留空则自动从 Shader 创建)")]
|
[Tooltip("水波纹材质(留空则自动从 Shader 创建)")]
|
||||||
[SerializeField] private Material rippleMaterial;
|
[SerializeField] private Material rippleMaterial;
|
||||||
|
|
||||||
|
[Tooltip("水波纹着色器(留空则尝试 Shader.Find 兜底;建议拖入 Assets/UI/shaders/WaterRippleFade.shader 以避免构建裁剪导致运行时找不到)")]
|
||||||
|
[SerializeField] private Shader waterRippleShader;
|
||||||
[Tooltip("主界面按钮延迟出现时间(秒)")]
|
[Tooltip("主界面按钮延迟出现时间(秒)")]
|
||||||
[SerializeField] private float buttonFadeDelay = 2f;
|
[SerializeField] private float buttonFadeDelay = 2f;
|
||||||
[Tooltip("按钮渐现时长(秒)")]
|
[Tooltip("按钮渐现时长(秒)")]
|
||||||
@@ -65,46 +70,11 @@ namespace GameFramework
|
|||||||
private Material _rippleMaterial;
|
private Material _rippleMaterial;
|
||||||
|
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
// 静态入口
|
// 生命周期(激活由 OnGameOver 事件驱动,见 OnEnable)
|
||||||
// ====================================================================
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 显示失败叠加层。优先使用场景中已有的实例,
|
|
||||||
/// 没有则动态创建一个。
|
|
||||||
/// </summary>
|
|
||||||
public static void Show()
|
|
||||||
{
|
|
||||||
// 场景中已有(隐藏的)实例?
|
|
||||||
if (_instance != null)
|
|
||||||
{
|
|
||||||
_instance.Activate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试在场景中找到
|
|
||||||
var found = FindObjectOfType<GameLostOverlay>();
|
|
||||||
if (found != null)
|
|
||||||
{
|
|
||||||
_instance = found;
|
|
||||||
found.Activate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 都没有 → 动态创建
|
|
||||||
var obj = new GameObject("GameLostOverlay");
|
|
||||||
var overlay = obj.AddComponent<GameLostOverlay>();
|
|
||||||
overlay.BuildAll();
|
|
||||||
overlay.Activate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================================================================
|
|
||||||
// 生命周期
|
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
|
|
||||||
void Awake()
|
void Awake()
|
||||||
{
|
{
|
||||||
_instance = this;
|
|
||||||
|
|
||||||
// 如果 Canvas 还没赋值,尝试从自身获取
|
// 如果 Canvas 还没赋值,尝试从自身获取
|
||||||
if (lostCanvas == null)
|
if (lostCanvas == null)
|
||||||
lostCanvas = GetComponent<Canvas>();
|
lostCanvas = GetComponent<Canvas>();
|
||||||
@@ -114,6 +84,21 @@ namespace GameFramework
|
|||||||
lostCanvas.enabled = false;
|
lostCanvas.enabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void OnEnable()
|
||||||
|
{
|
||||||
|
if (onGameOverEvent != null)
|
||||||
|
onGameOverEvent.Register(OnGameOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnDisable()
|
||||||
|
{
|
||||||
|
if (onGameOverEvent != null)
|
||||||
|
onGameOverEvent.Unregister(OnGameOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>收到 OnGameOver 事件后激活失败叠加层(替代 GameManager 直接调用 Show)。</summary>
|
||||||
|
private void OnGameOver() => Activate();
|
||||||
|
|
||||||
void Update()
|
void Update()
|
||||||
{
|
{
|
||||||
if (!_isVisible) return;
|
if (!_isVisible) return;
|
||||||
@@ -355,7 +340,7 @@ namespace GameFramework
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var shader = Shader.Find("GameFramework/UI/WaterRippleFade");
|
var shader = waterRippleShader != null ? waterRippleShader : Shader.Find("GameFramework/UI/WaterRippleFade");
|
||||||
if (shader == null)
|
if (shader == null)
|
||||||
{
|
{
|
||||||
Debug.LogWarning("[GameLostOverlay] 找不到 WaterRippleFade Shader!请确认 Assets/UI/shaders/WaterRippleFade.shader 存在且无编译错误。回退到抖动动效。");
|
Debug.LogWarning("[GameLostOverlay] 找不到 WaterRippleFade Shader!请确认 Assets/UI/shaders/WaterRippleFade.shader 存在且无编译错误。回退到抖动动效。");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using UnityEngine;
|
|||||||
using UnityEngine.SceneManagement;
|
using UnityEngine.SceneManagement;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using IndianOceanAssets.Engine2_5D;
|
using IndianOceanAssets.Engine2_5D;
|
||||||
|
using Architecture.Core;
|
||||||
|
|
||||||
namespace GameFramework
|
namespace GameFramework
|
||||||
{
|
{
|
||||||
@@ -13,11 +14,31 @@ namespace GameFramework
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class GameManager : PersistentSingleton<GameManager>
|
public class GameManager : PersistentSingleton<GameManager>
|
||||||
{
|
{
|
||||||
/// <summary>游戏结束(失败)时触发。</summary>
|
[Header("SO 事件通道(替代 static Action 事件,Inspector 拖入对应资产)")]
|
||||||
public static Action onGameOver;
|
[SerializeField] private GameEvent onGameOverEvent;
|
||||||
|
[SerializeField] private GameEvent onGameWinEvent;
|
||||||
|
|
||||||
/// <summary>游戏胜利时触发。</summary>
|
[Header("SO 事件通道(玩家死亡事件,替代 HealthSystem 直接调用 GameOver)")]
|
||||||
public static Action onGameWin;
|
[SerializeField] private GameEvent onPlayerDiedEvent;
|
||||||
|
|
||||||
|
[Header("运行时集合(替代 FindObjectsOfType<EnemyAI>,需敌人 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>玩家死亡事件回调:触发失败流程(设置状态 + 过场 + 广播 OnGameOver)。</summary>
|
||||||
|
private void OnPlayerDied() => GameOver();
|
||||||
|
|
||||||
public static GameState GameState
|
public static GameState GameState
|
||||||
{
|
{
|
||||||
@@ -49,7 +70,7 @@ namespace GameFramework
|
|||||||
{
|
{
|
||||||
if (Instance == null) return;
|
if (Instance == null) return;
|
||||||
GameState = GameState.GameOver;
|
GameState = GameState.GameOver;
|
||||||
onGameOver?.Invoke();
|
Instance.onGameOverEvent?.Raise();
|
||||||
Instance.StartCoroutine(Instance.DeathTransition());
|
Instance.StartCoroutine(Instance.DeathTransition());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +79,7 @@ namespace GameFramework
|
|||||||
{
|
{
|
||||||
if (Instance == null) return;
|
if (Instance == null) return;
|
||||||
GameState = GameState.Victory;
|
GameState = GameState.Victory;
|
||||||
onGameWin?.Invoke();
|
Instance.onGameWinEvent?.Raise();
|
||||||
Instance.StartCoroutine(Instance.VictoryTransition());
|
Instance.StartCoroutine(Instance.VictoryTransition());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,17 +162,32 @@ namespace GameFramework
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void DisableAllEnemyAI()
|
private void DisableAllEnemyAI()
|
||||||
{
|
{
|
||||||
// 禁用所有 EnemyAI
|
int disabled = 0;
|
||||||
var enemyAIs = FindObjectsOfType<IndianOceanAssets.Engine2_5D.EnemyAI>();
|
|
||||||
foreach (var ai in enemyAIs)
|
// 优先用 Enemies 运行时集合(敌人 Prefab 需挂 RuntimeSetRegistrar 并指向该集合,
|
||||||
ai.enabled = false;
|
// 在 OnEnable/OnDisable 时自动注册/注销自身 Transform)
|
||||||
|
if (enemiesSet != null)
|
||||||
|
{
|
||||||
|
foreach (var t in enemiesSet.Items)
|
||||||
|
{
|
||||||
|
if (t == null) continue;
|
||||||
|
var ai = t.GetComponent<IndianOceanAssets.Engine2_5D.EnemyAI>();
|
||||||
|
if (ai != null) { ai.enabled = false; disabled++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 过渡期兜底:尚未接入 Enemies 集合时回退(Prefab 接入 RuntimeSetRegistrar 后可删除此分支)
|
||||||
|
var enemyAIs = FindObjectsOfType<IndianOceanAssets.Engine2_5D.EnemyAI>();
|
||||||
|
foreach (var ai in enemyAIs) ai.enabled = false;
|
||||||
|
disabled = enemyAIs.Length;
|
||||||
|
}
|
||||||
|
|
||||||
// 禁用 EnemyManager(停止生成/管理逻辑)
|
// 禁用 EnemyManager(停止生成/管理逻辑)
|
||||||
var enemyManager = FindObjectOfType<IndianOceanAssets.Engine2_5D.EnemyManager>();
|
if (enemyManagerRef != null)
|
||||||
if (enemyManager != null)
|
enemyManagerRef.enabled = false;
|
||||||
enemyManager.enabled = false;
|
|
||||||
|
|
||||||
Debug.Log($"[GameManager] 已禁用 {enemyAIs.Length} 个 EnemyAI + EnemyManager");
|
Debug.Log($"[GameManager] 已禁用 {disabled} 个 EnemyAI + EnemyManager");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -230,8 +266,7 @@ namespace GameFramework
|
|||||||
|
|
||||||
yield return null;
|
yield return null;
|
||||||
|
|
||||||
// 在全黑之上显示失败叠加层("你已迷失……" + 主界面按钮)
|
// 失败叠加层("你已迷失……")由 GameLostOverlay 订阅 OnGameOver 事件自显示,此处不再直接调用。
|
||||||
GameLostOverlay.Show();
|
|
||||||
|
|
||||||
// 等待一帧让 UI 渲染
|
// 等待一帧让 UI 渲染
|
||||||
yield return null;
|
yield return null;
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
using UnityEngine;
|
|
||||||
using UnityEngine.UI;
|
|
||||||
|
|
||||||
namespace GameFramework
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 游戏结束画面。
|
|
||||||
/// 监听 GameManager.onGameOver 事件,显示结束 Canvas 并暂停游戏。
|
|
||||||
/// 玩家按确认键或点击按钮后跳转到排行榜场景。
|
|
||||||
/// </summary>
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
using UnityEngine.SceneManagement;
|
using UnityEngine.SceneManagement;
|
||||||
|
using Architecture.Core;
|
||||||
|
|
||||||
namespace GameFramework
|
namespace GameFramework
|
||||||
{
|
{
|
||||||
@@ -10,9 +11,9 @@ namespace GameFramework
|
|||||||
public enum GameResult { Win, Lose }
|
public enum GameResult { Win, Lose }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 游戏结果界面(支持胜利/失败)。
|
/// 游戏结果界面(仅负责「胜利」结算)。
|
||||||
/// 监听 GameManager.onGameOver 和 onGameWin 事件,
|
/// 监听 OnGameWin 事件,显示胜利面板并暂停游戏,确认后跳转到排行榜场景。
|
||||||
/// 显示对应面板并暂停游戏。确认后跳转到排行榜场景。
|
/// 「失败」由 GameLostOverlay 接管(订阅 OnGameOver),本屏不处理失败,避免空响应。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class GameResultScreen : MonoBehaviour
|
public class GameResultScreen : MonoBehaviour
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,9 @@ namespace GameFramework
|
|||||||
[Header("Input")]
|
[Header("Input")]
|
||||||
[SerializeField] private KeyCode confirmKey = KeyCode.Return;
|
[SerializeField] private KeyCode confirmKey = KeyCode.Return;
|
||||||
|
|
||||||
|
[Header("SO 事件通道(胜利事件;失败由 GameLostOverlay 接管)")]
|
||||||
|
[SerializeField] private GameEvent onGameWinEvent;
|
||||||
|
|
||||||
private bool _isVisible;
|
private bool _isVisible;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -59,20 +63,14 @@ namespace GameFramework
|
|||||||
|
|
||||||
void OnEnable()
|
void OnEnable()
|
||||||
{
|
{
|
||||||
GameManager.onGameOver += OnGameOver;
|
onGameWinEvent?.Register(OnGameWin);
|
||||||
GameManager.onGameWin += OnGameWin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnDisable()
|
void OnDisable()
|
||||||
{
|
{
|
||||||
GameManager.onGameOver -= OnGameOver;
|
onGameWinEvent?.Unregister(OnGameWin);
|
||||||
GameManager.onGameWin -= OnGameWin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnGameOver()
|
|
||||||
{
|
|
||||||
// 失败时由 GameLostOverlay 接管,不再显示旧失败面板
|
|
||||||
}
|
|
||||||
void OnGameWin() => ShowResult(GameResult.Win);
|
void OnGameWin() => ShowResult(GameResult.Win);
|
||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
|
|||||||
@@ -59,10 +59,10 @@ Material:
|
|||||||
- _DecalMeshDepthBias: 0
|
- _DecalMeshDepthBias: 0
|
||||||
- _DecalMeshViewBias: 0
|
- _DecalMeshViewBias: 0
|
||||||
- _DrawOrder: 0
|
- _DrawOrder: 0
|
||||||
- _FadeAlpha: 0.96674436
|
- _FadeAlpha: 1
|
||||||
- _RippleAmplitude: 0.0412
|
- _RippleAmplitude: 0.0412
|
||||||
- _RippleFrequency: 10.5
|
- _RippleFrequency: 10.5
|
||||||
- _RippleIntensity: 0.64074624
|
- _RippleIntensity: -0
|
||||||
- _RippleSpeed: 3
|
- _RippleSpeed: 3
|
||||||
- _Shininess: 0.2
|
- _Shininess: 0.2
|
||||||
- _Stencil: 0
|
- _Stencil: 0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.UI;
|
using UnityEngine.UI;
|
||||||
|
using Architecture.Core;
|
||||||
|
|
||||||
namespace GameFramework
|
namespace GameFramework
|
||||||
{
|
{
|
||||||
@@ -13,24 +14,25 @@ namespace GameFramework
|
|||||||
[SerializeField] Text scoreText;
|
[SerializeField] Text scoreText;
|
||||||
[SerializeField] string format = "{0}";
|
[SerializeField] string format = "{0}";
|
||||||
|
|
||||||
|
[Header("SO 事件通道(替代 static ScoreManager.onScoreChanged 事件)")]
|
||||||
|
[SerializeField] private IntEvent scoreChangedEvent;
|
||||||
|
|
||||||
void OnEnable()
|
void OnEnable()
|
||||||
{
|
{
|
||||||
ScoreManager.onScoreChanged += UpdateText;
|
scoreChangedEvent?.Register(UpdateText);
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnDisable()
|
void OnDisable()
|
||||||
{
|
{
|
||||||
ScoreManager.onScoreChanged -= UpdateText;
|
scoreChangedEvent?.Unregister(UpdateText);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Start()
|
void Start()
|
||||||
{
|
{
|
||||||
if (scoreText == null)
|
if (scoreText == null)
|
||||||
scoreText = GetComponent<Text>();
|
scoreText = GetComponent<Text>();
|
||||||
if (ScoreManager.Instance != null)
|
// 初始值走事件:订阅后首个 ScoreChanged 会刷新显示;分数初始即为 0,先显示 0。
|
||||||
UpdateText(ScoreManager.Instance.CurrentScore);
|
UpdateText(0);
|
||||||
else
|
|
||||||
UpdateText(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdateText(int score)
|
void UpdateText(int score)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using Architecture.Core;
|
||||||
|
|
||||||
namespace GameFramework
|
namespace GameFramework
|
||||||
{
|
{
|
||||||
@@ -32,7 +33,7 @@ namespace GameFramework
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 得分管理器(持久单例)。
|
/// 得分管理器(持久单例)。
|
||||||
/// 维护当前局得分,通过事件通知 UI,并管理 Top-10 排行榜存档。
|
/// 维护当前局得分,通过 SO 事件通道通知 UI,并管理 Top-10 排行榜存档。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ScoreManager : PersistentSingleton<ScoreManager>
|
public class ScoreManager : PersistentSingleton<ScoreManager>
|
||||||
{
|
{
|
||||||
@@ -42,10 +43,9 @@ namespace GameFramework
|
|||||||
// 当前局得分
|
// 当前局得分
|
||||||
int currentScore = 0;
|
int currentScore = 0;
|
||||||
|
|
||||||
// 当得分变化时触发,参数为最新得分(动画过程中的中间值也会触发)
|
[Header("SO 事件通道(替代 static onScoreChanged / onScoreSettled 事件,Inspector 拖入对应资产)")]
|
||||||
public static event Action<int> onScoreChanged;
|
[SerializeField] private IntEvent scoreChangedEvent;
|
||||||
// 当得分完成最终增加时触发,参数为最终得分
|
[SerializeField] private IntEvent scoreSettledEvent;
|
||||||
public static event Action<int> onScoreSettled;
|
|
||||||
|
|
||||||
/// <summary>当前得分(只读)。</summary>
|
/// <summary>当前得分(只读)。</summary>
|
||||||
public int CurrentScore => currentScore;
|
public int CurrentScore => currentScore;
|
||||||
@@ -75,8 +75,8 @@ namespace GameFramework
|
|||||||
public void SetScore(int value)
|
public void SetScore(int value)
|
||||||
{
|
{
|
||||||
currentScore = Mathf.Max(0, value);
|
currentScore = Mathf.Max(0, value);
|
||||||
onScoreChanged?.Invoke(currentScore);
|
scoreChangedEvent?.Raise(currentScore);
|
||||||
onScoreSettled?.Invoke(currentScore);
|
scoreSettledEvent?.Raise(currentScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -85,8 +85,8 @@ namespace GameFramework
|
|||||||
public void ResetScore()
|
public void ResetScore()
|
||||||
{
|
{
|
||||||
currentScore = 0;
|
currentScore = 0;
|
||||||
onScoreChanged?.Invoke(0);
|
scoreChangedEvent?.Raise(0);
|
||||||
onScoreSettled?.Invoke(0);
|
scoreSettledEvent?.Raise(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerator ScoreCountUpCoroutine(int from, int to)
|
IEnumerator ScoreCountUpCoroutine(int from, int to)
|
||||||
@@ -100,12 +100,12 @@ namespace GameFramework
|
|||||||
elapsed += Time.unscaledDeltaTime;
|
elapsed += Time.unscaledDeltaTime;
|
||||||
float t = Mathf.Clamp01(elapsed / duration);
|
float t = Mathf.Clamp01(elapsed / duration);
|
||||||
int display = Mathf.RoundToInt(Mathf.Lerp(from, to, t));
|
int display = Mathf.RoundToInt(Mathf.Lerp(from, to, t));
|
||||||
onScoreChanged?.Invoke(display);
|
scoreChangedEvent?.Raise(display);
|
||||||
yield return null;
|
yield return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onScoreChanged?.Invoke(to);
|
scoreChangedEvent?.Raise(to);
|
||||||
onScoreSettled?.Invoke(to);
|
scoreSettledEvent?.Raise(to);
|
||||||
}
|
}
|
||||||
|
|
||||||
#region 排行榜
|
#region 排行榜
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using GameFramework;
|
using GameFramework;
|
||||||
|
using Architecture.Core;
|
||||||
|
|
||||||
namespace IndianOceanAssets.Engine2_5D
|
namespace IndianOceanAssets.Engine2_5D
|
||||||
{
|
{
|
||||||
@@ -20,11 +21,8 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class EchoSystem : MonoBehaviour
|
public class EchoSystem : MonoBehaviour
|
||||||
{
|
{
|
||||||
/// <summary>
|
[Header("SO 事件通道(替代 static OnEchoReleased 事件,参数为释放时玩家世界位置)")]
|
||||||
/// 按 E 释放回声(摇铃)时触发,参数为释放时的玩家世界位置。
|
[SerializeField] private Vector3Event echoReleasedEvent;
|
||||||
/// 敌人聆听系统等可订阅此事件。
|
|
||||||
/// </summary>
|
|
||||||
public static event Action<Vector3> OnEchoReleased;
|
|
||||||
|
|
||||||
[Header("按键")]
|
[Header("按键")]
|
||||||
[SerializeField] private KeyCode echoKey = KeyCode.E;
|
[SerializeField] private KeyCode echoKey = KeyCode.E;
|
||||||
@@ -101,6 +99,7 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
private float _lastEchoTime = -999f;
|
private float _lastEchoTime = -999f;
|
||||||
private float _ringAlpha = 0f;
|
private float _ringAlpha = 0f;
|
||||||
private float _ringRadius = 0f;
|
private float _ringRadius = 0f;
|
||||||
|
private bool _warnedEcho = false; // 防止 echoReleasedEvent 空引用告警刷屏
|
||||||
|
|
||||||
private void Start()
|
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()
|
private void UpdateExpanding()
|
||||||
@@ -309,6 +314,16 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
|
|
||||||
public bool IsActive => _state != State.Idle;
|
public bool IsActive => _state != State.Idle;
|
||||||
|
|
||||||
|
/// <summary>摇铃冷却剩余/总时长(供 HUD 显示,替代反射读取私有字段)。</summary>
|
||||||
|
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()
|
private void OnDrawGizmosSelected()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
/// <summary>冷却剩余时间(供 UI 使用)</summary>
|
/// <summary>冷却剩余时间(供 UI 使用)</summary>
|
||||||
public float CooldownRemaining => Mathf.Max(0f, (_lastPlaceTime + cooldown) - Time.time);
|
public float CooldownRemaining => Mathf.Max(0f, (_lastPlaceTime + cooldown) - Time.time);
|
||||||
|
|
||||||
|
/// <summary>冷却总时长(供 UI 计算 CD 比例,替代反射读取私有字段)</summary>
|
||||||
|
public float Cooldown => cooldown;
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
_remainingLanterns = maxLanterns;
|
_remainingLanterns = maxLanterns;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using Architecture.Core;
|
||||||
|
|
||||||
namespace IndianOceanAssets.Engine2_5D
|
namespace IndianOceanAssets.Engine2_5D
|
||||||
{
|
{
|
||||||
@@ -46,6 +47,9 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
[Tooltip("聆听范围:主角在此范围内按E摇铃时,敌人会朝铃铛位置移动")]
|
[Tooltip("聆听范围:主角在此范围内按E摇铃时,敌人会朝铃铛位置移动")]
|
||||||
[SerializeField] private float listenRange = 15f;
|
[SerializeField] private float listenRange = 15f;
|
||||||
|
|
||||||
|
[Header("SO 事件通道(替代 static EchoSystem.OnEchoReleased 事件)")]
|
||||||
|
[SerializeField] private Vector3Event echoReleasedEvent;
|
||||||
|
|
||||||
[Header("铃铛追击")]
|
[Header("铃铛追击")]
|
||||||
[Tooltip("到达铃铛位置后的容差距离")]
|
[Tooltip("到达铃铛位置后的容差距离")]
|
||||||
[SerializeField] private float bellArriveDistance = 1f;
|
[SerializeField] private float bellArriveDistance = 1f;
|
||||||
@@ -105,6 +109,12 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
private HealthSystem _playerHealth; // 玩家血量引用
|
private HealthSystem _playerHealth; // 玩家血量引用
|
||||||
private Camera _mainCam; // 主相机引用(用于Billboard)
|
private Camera _mainCam; // 主相机引用(用于Billboard)
|
||||||
|
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
if (echoReleasedEvent == null)
|
||||||
|
Debug.LogWarning($"[EnemyAI] Echo Released Event 未接线({gameObject.name})。该敌人不会响应摇铃。", this);
|
||||||
|
}
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
if (playerTarget == null)
|
if (playerTarget == null)
|
||||||
@@ -127,12 +137,12 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
|
|
||||||
private void OnEnable()
|
private void OnEnable()
|
||||||
{
|
{
|
||||||
EchoSystem.OnEchoReleased += OnBell;
|
echoReleasedEvent?.Register(OnBell);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDisable()
|
private void OnDisable()
|
||||||
{
|
{
|
||||||
EchoSystem.OnEchoReleased -= OnBell;
|
echoReleasedEvent?.Unregister(OnBell);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LateUpdate()
|
private void LateUpdate()
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
using IndianOceanAssets.Engine2_5D;
|
||||||
|
|
||||||
namespace IndianOceanAssets.Engine2_5D
|
namespace IndianOceanAssets.Engine2_5D
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 敌人头顶血条 —— 挂在敌人 Prefab 上。
|
/// 敌人头顶血条 —— 挂在敌人 Prefab 上。
|
||||||
/// 血条可视化物体(BG、Fill)已在 Prefab 中预建,
|
/// 血条可视化物体(BG、Fill)已在 Prefab 中预建,运行时直接引用,无需动态创建。
|
||||||
/// 运行时直接引用,无需动态创建。
|
///
|
||||||
|
/// 解耦改造:直接引用同物体上的 HealthSystem(RequireComponent 保证存在),
|
||||||
|
/// 订阅其 OnHealthChanged 事件刷新,彻底移除对私有字段的反射读取。
|
||||||
|
/// 注意:不使用"全局共享 IntVariable"承载敌人血量——否则所有敌人血条会显示同一份血量,
|
||||||
|
/// 正确的解法是"每个敌人读自己的 HealthSystem"(同物体组件引用,非 Find / 反射 / 跨对象)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[RequireComponent(typeof(HealthSystem))]
|
[RequireComponent(typeof(HealthSystem))]
|
||||||
public class EnemyHealthBar : MonoBehaviour
|
public class EnemyHealthBar : MonoBehaviour
|
||||||
@@ -33,52 +38,44 @@ namespace IndianOceanAssets.Engine2_5D
|
|||||||
[Tooltip("满血时是否隐藏血条")]
|
[Tooltip("满血时是否隐藏血条")]
|
||||||
[SerializeField] private bool hideWhenFull = false;
|
[SerializeField] private bool hideWhenFull = false;
|
||||||
|
|
||||||
private HealthSystem _healthSystem;
|
// 同物体上的 HealthSystem(RequireComponent 保证存在;非 Find / 反射 / 跨对象引用)
|
||||||
private int _lastHealth = -1;
|
private HealthSystem _healthSource;
|
||||||
private int _maxHealth;
|
|
||||||
|
|
||||||
private void Start()
|
private void Start()
|
||||||
{
|
{
|
||||||
_healthSystem = GetComponent<HealthSystem>();
|
if (fillRenderer == null || bgRenderer == null)
|
||||||
if (_healthSystem == null)
|
|
||||||
{
|
{
|
||||||
enabled = false;
|
enabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 maxHealth(通过反射读取私有字段)
|
_healthSource = GetComponent<HealthSystem>();
|
||||||
var field = typeof(HealthSystem).GetField("maxHealth",
|
if (_healthSource != null)
|
||||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
{
|
||||||
if (field != null)
|
_healthSource.OnHealthChanged += UpdateBar;
|
||||||
_maxHealth = (int)field.GetValue(_healthSystem);
|
UpdateBar(_healthSource.CurrentHealth, _healthSource.MaxHealth);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
_maxHealth = 3;
|
{
|
||||||
|
UpdateBar(1, 1);
|
||||||
UpdateBar();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LateUpdate()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
UpdateBar();
|
if (_healthSource != null)
|
||||||
|
_healthSource.OnHealthChanged -= UpdateBar;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据当前血量更新血条显示。
|
/// 根据当前血量更新血条显示(事件驱动,无反射、无每帧 LateUpdate)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void UpdateBar()
|
private void UpdateBar(int currentHealth, int maxHealth)
|
||||||
{
|
{
|
||||||
if (_healthSystem == null || fillRenderer == null || bgRenderer == null) return;
|
if (fillRenderer == null || bgRenderer == null) return;
|
||||||
|
|
||||||
// 获取当前血量(反射)
|
if (maxHealth <= 0) maxHealth = 1;
|
||||||
var healthField = typeof(HealthSystem).GetField("health",
|
float ratio = Mathf.Clamp01((float)currentHealth / maxHealth);
|
||||||
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);
|
|
||||||
|
|
||||||
// 更新填充条缩放(居中对齐)
|
// 更新填充条缩放(居中对齐)
|
||||||
fillRenderer.transform.localScale = new Vector3(barWidth * ratio, barHeight, 1f);
|
fillRenderer.transform.localScale = new Vector3(barWidth * ratio, barHeight, 1f);
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ MonoBehaviour:
|
|||||||
maxSightDistance: 4
|
maxSightDistance: 4
|
||||||
loseSightTime: 3
|
loseSightTime: 3
|
||||||
listenRange: 15
|
listenRange: 15
|
||||||
|
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
|
||||||
bellArriveDistance: 1
|
bellArriveDistance: 1
|
||||||
bellChaseTimeout: 10
|
bellChaseTimeout: 10
|
||||||
bellPatrolTime: 5
|
bellPatrolTime: 5
|
||||||
@@ -477,11 +478,16 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
onPlayerDamagedEvent: {fileID: 0}
|
||||||
|
onPlayerDiedEvent: {fileID: 0}
|
||||||
|
healthVar: {fileID: 0}
|
||||||
|
maxHealthVar: {fileID: 0}
|
||||||
maxHealth: 3
|
maxHealth: 3
|
||||||
deathEffect: {fileID: 0}
|
deathEffect: {fileID: 0}
|
||||||
isPlayer: 0
|
isPlayer: 0
|
||||||
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
||||||
fadeDuration: 2
|
fadeDuration: 2
|
||||||
|
damageInvincibleDuration: 2
|
||||||
--- !u!114 &1893771889199554109
|
--- !u!114 &1893771889199554109
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ MonoBehaviour:
|
|||||||
maxSightDistance: 4
|
maxSightDistance: 4
|
||||||
loseSightTime: 3
|
loseSightTime: 3
|
||||||
listenRange: 15
|
listenRange: 15
|
||||||
|
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
|
||||||
bellArriveDistance: 1
|
bellArriveDistance: 1
|
||||||
bellChaseTimeout: 10
|
bellChaseTimeout: 10
|
||||||
bellPatrolTime: 5
|
bellPatrolTime: 5
|
||||||
@@ -389,11 +390,16 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
onPlayerDamagedEvent: {fileID: 0}
|
||||||
|
onPlayerDiedEvent: {fileID: 0}
|
||||||
|
healthVar: {fileID: 0}
|
||||||
|
maxHealthVar: {fileID: 0}
|
||||||
maxHealth: 3
|
maxHealth: 3
|
||||||
deathEffect: {fileID: 0}
|
deathEffect: {fileID: 0}
|
||||||
isPlayer: 0
|
isPlayer: 0
|
||||||
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
||||||
fadeDuration: 2
|
fadeDuration: 2
|
||||||
|
damageInvincibleDuration: 2
|
||||||
--- !u!114 &1893771889199554109
|
--- !u!114 &1893771889199554109
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ MonoBehaviour:
|
|||||||
maxSightDistance: 4
|
maxSightDistance: 4
|
||||||
loseSightTime: 3
|
loseSightTime: 3
|
||||||
listenRange: 15
|
listenRange: 15
|
||||||
|
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
|
||||||
bellArriveDistance: 1
|
bellArriveDistance: 1
|
||||||
bellChaseTimeout: 10
|
bellChaseTimeout: 10
|
||||||
bellPatrolTime: 5
|
bellPatrolTime: 5
|
||||||
@@ -389,11 +390,16 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
onPlayerDamagedEvent: {fileID: 0}
|
||||||
|
onPlayerDiedEvent: {fileID: 0}
|
||||||
|
healthVar: {fileID: 0}
|
||||||
|
maxHealthVar: {fileID: 0}
|
||||||
maxHealth: 3
|
maxHealth: 3
|
||||||
deathEffect: {fileID: 0}
|
deathEffect: {fileID: 0}
|
||||||
isPlayer: 0
|
isPlayer: 0
|
||||||
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
||||||
fadeDuration: 2
|
fadeDuration: 2
|
||||||
|
damageInvincibleDuration: 2
|
||||||
--- !u!114 &1893771889199554109
|
--- !u!114 &1893771889199554109
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ MonoBehaviour:
|
|||||||
maxSightDistance: 4
|
maxSightDistance: 4
|
||||||
loseSightTime: 3
|
loseSightTime: 3
|
||||||
listenRange: 15
|
listenRange: 15
|
||||||
|
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
|
||||||
bellArriveDistance: 1
|
bellArriveDistance: 1
|
||||||
bellChaseTimeout: 10
|
bellChaseTimeout: 10
|
||||||
bellPatrolTime: 5
|
bellPatrolTime: 5
|
||||||
@@ -477,11 +478,16 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
onPlayerDamagedEvent: {fileID: 0}
|
||||||
|
onPlayerDiedEvent: {fileID: 0}
|
||||||
|
healthVar: {fileID: 0}
|
||||||
|
maxHealthVar: {fileID: 0}
|
||||||
maxHealth: 3
|
maxHealth: 3
|
||||||
deathEffect: {fileID: 0}
|
deathEffect: {fileID: 0}
|
||||||
isPlayer: 0
|
isPlayer: 0
|
||||||
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
||||||
fadeDuration: 2
|
fadeDuration: 2
|
||||||
|
damageInvincibleDuration: 2
|
||||||
--- !u!114 &1893771889199554109
|
--- !u!114 &1893771889199554109
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ MonoBehaviour:
|
|||||||
maxSightDistance: 4
|
maxSightDistance: 4
|
||||||
loseSightTime: 3
|
loseSightTime: 3
|
||||||
listenRange: 15
|
listenRange: 15
|
||||||
|
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
|
||||||
bellArriveDistance: 1
|
bellArriveDistance: 1
|
||||||
bellChaseTimeout: 10
|
bellChaseTimeout: 10
|
||||||
bellPatrolTime: 5
|
bellPatrolTime: 5
|
||||||
@@ -389,11 +390,16 @@ MonoBehaviour:
|
|||||||
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
|
||||||
m_Name:
|
m_Name:
|
||||||
m_EditorClassIdentifier:
|
m_EditorClassIdentifier:
|
||||||
|
onPlayerDamagedEvent: {fileID: 0}
|
||||||
|
onPlayerDiedEvent: {fileID: 0}
|
||||||
|
healthVar: {fileID: 0}
|
||||||
|
maxHealthVar: {fileID: 0}
|
||||||
maxHealth: 3
|
maxHealth: 3
|
||||||
deathEffect: {fileID: 0}
|
deathEffect: {fileID: 0}
|
||||||
isPlayer: 0
|
isPlayer: 0
|
||||||
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
|
||||||
fadeDuration: 2
|
fadeDuration: 2
|
||||||
|
damageInvincibleDuration: 2
|
||||||
--- !u!114 &1893771889199554109
|
--- !u!114 &1893771889199554109
|
||||||
MonoBehaviour:
|
MonoBehaviour:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Unity 项目解耦架构重构 — 整体计划(v1.1)
|
||||||
|
|
||||||
|
> 配套详细诊断与改造映射见 `Docs/解耦架构重构方案.md`(已据同行评审修正至 v1.1)
|
||||||
|
> 当前状态:**P0 地基 + P1 事件去静态化 + P2 大部分(去 Find/反射/Shader.Find)已落地**,P2d 运行时集合接入、P3/P4/P5/P6 待推进
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
以 **ScriptableObject 为通信总线**,消除当前耦合腐烂信号:
|
||||||
|
- 7 个跨场景单例(`GameManager`/`AudioManager`/`ScoreManager`/`SceneLoader`/`TimeController`/`EnemyManager`/`LightMaskSystem`)
|
||||||
|
- `GameObject.Find/FindWithTag/FindObjectsByType` 散落运行时(含 `GameHUD` 条件性 `FindWithTag` 补救、`EnemyAI` Start 一次 `FindWithTag`、`MainMenuUIController` 对象名耦合)
|
||||||
|
- UI 反射读私有字段(`GameHUD`、`EnemyHealthBar`)
|
||||||
|
- God Class(`GameManager`/`HealthSystem`);`PlayerController` 仅轻度耦合
|
||||||
|
- `static Action` 事件伪总线(`onGameOver/onGameWin/onPlayerDamaged/OnEchoReleased/onScoreChanged`)
|
||||||
|
- `Resources.Load` / `Shader.Find` 字符串硬编码资源路径
|
||||||
|
|
||||||
|
**结果形态**:所有共享状态进 Variable 资产、跨系统消息走 Event 通道、实体集合走 RuntimeSet。MonoBehaviour 只认 SO 资产引用,彼此不再直连。
|
||||||
|
|
||||||
|
## 2. 目标架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
L0 SO 通信总线(项目级资产,跨场景存活,无 MonoBehaviour)
|
||||||
|
· Event 通道:GameEvent / IntEvent / Vector3Event / StringEvent / GameObjectEvent
|
||||||
|
· Variable 资产:FloatVariable / IntVariable / BoolVariable / Vector3Variable
|
||||||
|
· RuntimeSet<T>:玩家集 / 敌人集 / 灵灯集
|
||||||
|
↓ 监听/触发(Inspector 引用) ↓ 注册/读取
|
||||||
|
L1 系统服务(Audio/Scene/Time/Spawn) L2 实体(Player/Enemy/Lantern)
|
||||||
|
↓ 触发事件
|
||||||
|
L3 表现层(HUD/Overlay,只读 Variable + 监听 Event)
|
||||||
|
```
|
||||||
|
层间**零直接类引用**,谁触发/监听全在 Inspector 可见可配。
|
||||||
|
|
||||||
|
## 3. 四条铁律
|
||||||
|
|
||||||
|
1. **调用方不碰单例** → 改 `XxxEvent.Raise()`,服务类自己订阅
|
||||||
|
2. **生产代码零 `GameObject.Find`** → 找实体走 `RuntimeSet`
|
||||||
|
3. **共享状态进 Variable** → UI 订阅 `OnValueChanged`,不再反射/逐帧 Find
|
||||||
|
4. **一个 MonoBehaviour 一件事** → 拆分 God Class
|
||||||
|
|
||||||
|
## 4. 已落地地基(P0,增量零侵入)
|
||||||
|
|
||||||
|
`Assets/Architecture/`(**未改动任何现有文件**,可整体回滚):
|
||||||
|
- `Core/GameEvent.cs` — 无参事件通道 + `GameEventListener`
|
||||||
|
- `Core/TypedGameEvents.cs` — `GameEvent<T>` + Int/Float/Vector3/String/GameObject 通道及监听器(含补全的 `StringEventListener`)
|
||||||
|
- `Variables/` — Float/Int/Bool/Vector3 Variable,带 `OnValueChanged` + `ContextMenu` 重置
|
||||||
|
- `RuntimeSets/RuntimeSet.cs` — `RuntimeSet<T>` + `TransformRuntimeSet` + `RuntimeSetRegistrar` + 泛型 `RuntimeSetRegistrar<T>`
|
||||||
|
- `Editor/VariableDrawer.cs` — Inspector 实时显示变量值
|
||||||
|
- `Variables/VariableRegistry.cs` — **运行时状态泄漏防护**:`IVariable` 接口 + `VariableRegistry`,在每次进入 Play Mode 时(`[RuntimeInitializeOnLoadMethod(BeforeSceneLoad)]`)统一 `ResetToDefault()`,防止 SO 变量跨局持久化(如 PlayerHealth 残留 0 直接触发死亡)
|
||||||
|
- `Editor/AssetBootstrap.cs` — 一键生成核心 SO 资产(菜单 `Architecture > Bootstrap Core Assets`),已存在则跳过;变量同时写入 `_value` 与 `_defaultValue`,从源头杜绝跨 Play Mode 泄漏
|
||||||
|
|
||||||
|
## 5. 分阶段路线图
|
||||||
|
|
||||||
|
| 阶段 | 目标 | 关键动作 | 风险 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P0 地基**(已完成) | 引入通信总线 | 落地 `Assets/Architecture/` + 创建首批资产 | 极低 |
|
||||||
|
| **P1 事件去静态化** | 消灭 `static Action` 事件 | `onGameOver/onGameWin/onPlayerDamaged/onScoreChanged/onScoreSettled/OnEchoReleased` → SO 事件资产,1:1 替换订阅 | 低 |
|
||||||
|
| **P2 去掉 Find** | 消灭 Find / 反射 | 建 `PlayerRuntimeSet`/`EnemyRuntimeSet`/`LanternRuntimeSet`,预制体挂 Registrar;改写 `GameHUD`/`EnemyAI`/`GameManager`/`MainMenuUIController`/`GameLostOverlay`/`EnemyHealthBar` | 中 |
|
||||||
|
| **P3 去单例调用方** | 调用方不再 `XxxManager.Instance` | `Audio/Scene/Time/Score` 改事件订阅者;`ScorePickup`/`SoulDrop`/`ScoringUIController` 改走 Variable/Event | 中 |
|
||||||
|
| **P4 拆分 God Class** | 单一职责 | `GameManager`→状态机+过场+敌人控制;`HealthSystem`→健康+死亡表现;`PlayerController` 轻量剥离 `RequireComponent` | 中高 |
|
||||||
|
| **P5 资源与配置** | 去 `Resources.Load`/`Shader.Find`/魔法串 | 音频/视频→**SerializeField 直接引用(AudioClip/VideoClip 拖到 SO/组件)**,**不引入 Addressables**(此项目体量下 Addressables 工程复杂度过高,需装包/建分组/异步加载);`Shader.Find`→资产引用;场景名/标签→SO 配置 | 中 |
|
||||||
|
| **P6 工具与守门** | 防回归 | `VariableDrawer`(已完成)+ 构建期校验脚本(扫描生产代码 `GameObject.Find`)+ 设计师文档 | 低 |
|
||||||
|
|
||||||
|
建议节奏:**P0→P1→P2 一个迭代内完成**(收益最大、风险最低);P3/P4 按系统逐个推进;P5/P6 并行。
|
||||||
|
|
||||||
|
## 6. 关键待改文件清单(按阶段)
|
||||||
|
|
||||||
|
- **P1**:`GameManager.cs`(去 `onGameOver`/`onGameWin` static)、`HealthSystem.cs`、`EchoSystem.cs`、`ScoreManager.cs`(去 `onScoreChanged` **+ `onScoreSettled` 两个 static**,二者需一并迁移)
|
||||||
|
- **P2**:`GameHUD.cs`(`:104` 条件 `FindWithTag` + `:150-199` 反射)、`EnemyHealthBar.cs`(`:50-53,73-75` 反射)、`EnemyAI.cs`(`:112` FindWithTag + `:232` Damage)、`GameManager.cs`(`FindObjectsOfType<EnemyAI>`)、`MainMenuUIController.cs`(`:55,61,66,143` 对象名耦合)、`GameLostOverlay.cs`(`:85` 自查找);并创建 `PlayerRuntimeSet`/`EnemyRuntimeSet`/`LanternRuntimeSet` 资产
|
||||||
|
- **P3**:`AudioManager`/`SceneLoader`/`TimeController`/`ScoreManager` 改订阅者;`ScorePickup.cs:39`/`SoulDrop.cs:110`/`ScoringUIController.cs`/`MainMenuUIController.cs:206,216`/`GameLostOverlay.cs:246` 改 `Raise`
|
||||||
|
- **P4**:`GameManager.cs` 拆分;`HealthSystem.cs` 拆分;`Player.cs` 轻量剥离 `RequireComponent`
|
||||||
|
- **P5**:`AudioManager.cs:121`/`StoryPVPlayer.cs:141` 的 `Resources.Load` → SO 资产引用;`Shader.Find` **共 5 处** → 序列化 `Shader` 字段引用:`GameLostOverlay.cs:358`(WaterRippleFade)、`EchoSystem.cs:140`(EchoRing,已有 `ringShader` 字段)、`GroundBuilder.cs:180`(AbyssEdgeGlow)、`CliffWallBuilder.cs:108`(AbyssEdgeGlow)、`GroundClipTool.cs:19`(SpriteWithGroundClip,**Editor 脚本,运行时不进包,可保留 Shader.Find,但建议同样改字段引用**));场景名魔法串 → `SceneList` SO
|
||||||
|
- **P6**:构建期校验脚本(扫描 `GameObject.Find`)+ 设计师 SO 配置文档
|
||||||
|
|
||||||
|
## 7. 本迭代建议起点(P1+P2 验证链路)
|
||||||
|
|
||||||
|
1. 运行菜单 `Architecture > Bootstrap Core Assets` 一键生成首批资产(`PlayerHealth`/`Score` IntVariable、`OnGameOver`/`OnGameWin`/`OnPlayerDied`/`OnPlayerDamaged` GameEvent、`EchoReleased`/`ScoreChanged`/`ScoreSettled` 带载荷事件、`Players`/`Enemies` RuntimeSet);已存在则跳过
|
||||||
|
2. 从最小系统切入:把 `GameHUD` 接到 `PlayerHealth` 变量(消灭逐帧条件 Find 回退)+ 消除 `EnemyHealthBar` 反射(改用 `maxHealth`/`health` 的 IntVariable 订阅)
|
||||||
|
3. 跑通后按路线图逐阶段推进;具体系统改造指名文件即可
|
||||||
|
|
||||||
|
## 8. 风险与回滚
|
||||||
|
|
||||||
|
- 架构核心位于 `Assets/Architecture/`,可整体删除回滚;P1/P2 对既有文件的改造均为"加 SO 字段 + 改订阅方式",保留原逻辑,可逐文件回退
|
||||||
|
- 迁移保持"旧接口可用、新接口并行",每阶段独立验证,避免大爆炸重写
|
||||||
|
- 事件通道为引用语义:误删资产会在 Inspector 显示缺失引用(编译期可查),不会静默失效——比反射/单例安全
|
||||||
@@ -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<DamageFlashOverlay>()` 未发现则动态 `new GameObject` 创建,增加 `Start` 复杂度;`EnemyAI.cs:112` 的 `FindWithTag` **仅在 `Start` 执行一次**并缓存 `_playerHealth`,与 `GameHUD` 的高频补救需区分;`MainMenuUIController.cs:55` 的 `FindObjectsByType<StoryPVPlayer>`、`: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<HealthSystem>()`,`: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<GameLostOverlay>()` 做单体自检查 |
|
||||||
|
|
||||||
|
### 1.2 耦合地图(谁通过什么找谁)
|
||||||
|
|
||||||
|
```
|
||||||
|
[EnemyAI] --FindWithTag("Player")【仅 Start 一次 :112】--> [Player]
|
||||||
|
[EnemyAI] --GetComponent<HealthSystem>().Damage()【:232】--> [Player.HealthSystem]
|
||||||
|
[GameHUD] --FindWithTag【_playerHealth==null 时 :104】+ 反射--> [Player] 各子系统
|
||||||
|
[GameHUD] --FindObjectOfType<DamageFlashOverlay> :76 + 动态 new GameObject--> [DamageFlashOverlay]
|
||||||
|
[GameHUD] --ScoreManager.Instance + onScoreChanged--> [ScoreManager]
|
||||||
|
[EnemyHealthBar] --反射读 HealthSystem 私有字段 maxHealth/health :50/73--> [HealthSystem]
|
||||||
|
[GameManager] --FindObjectsOfType<EnemyAI>()--> 所有敌人
|
||||||
|
[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<DamageFlashOverlay>`;`onGameWin` 静态事件被 `GameResultScreen` 监听;`GameLostOverlay` 的 `Shader.Find`;`MainMenuUIController` 的对象名耦合;`ScorePickup`/`SoulDrop`/`ScoringUIController`/`MainMenuUIController`/`GameLostOverlay` 对 `ScoreManager`/`SceneLoader` 的直接单例调用线。
|
||||||
|
- **配套代码据评审补强**:`TypedGameEvents` 已增加 `StringEventListener`(初稿声称有但未实现);`RuntimeSet` 已增加泛型 `RuntimeSetRegistrar<T>`(强类型集合用),详见 §2.3。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目标架构(Target Architecture)
|
||||||
|
|
||||||
|
### 2.1 分层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ L0 SO 通信总线(项目级资产,跨场景天然存活,无 MonoBehaviour) │
|
||||||
|
│ · Event 通道:GameEvent / IntEvent / Vector3Event ... │
|
||||||
|
│ · Variable 资产:FloatVariable / IntVariable / BoolVariable │
|
||||||
|
│ · RuntimeSet<T>:玩家集 / 敌人集 / 灵灯集 │
|
||||||
|
└───────────────┬───────────────────────────┬─────────────────┘
|
||||||
|
│ 监听/触发(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<T>` + `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<T>` + `TransformRuntimeSet` + `RuntimeSetRegistrar`(Transform 集合自动注册)+ 泛型 `RuntimeSetRegistrar<T>`(强类型集合如 `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<EnemyAI>`),应使用泛型 `RuntimeSetRegistrar<EnemyAI>` 的闭包子类:
|
||||||
|
> ```csharp
|
||||||
|
> // 放在 enemy 文件夹,领域专用
|
||||||
|
> public class EnemySetRegistrar : RuntimeSetRegistrar<EnemyAI> { }
|
||||||
|
> ```
|
||||||
|
> 这样敌人生成时自动 `GetComponent<EnemyAI>()` 并加入集合,无需退回 Transform 集合再手动转型。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 逐条改造映射(Before → After)
|
||||||
|
|
||||||
|
### 3.1 玩家引用:`FindWithTag("Player")` → `PlayerRuntimeSet`
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ❌ 现有
|
||||||
|
var player = GameObject.FindWithTag("Player");
|
||||||
|
_playerHealth = player.GetComponent<HealthSystem>();
|
||||||
|
|
||||||
|
// ✅ 新增 PlayerRuntimeSet : RuntimeSet<Transform>(放在 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<EnemyAI>`),挂 `RuntimeSetRegistrar<EnemyAI>` 的闭包子类即可,无需退回 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<EnemyAI>`)。`onGameWin` 同样改为 `VictoryEvent` 资产,`GameResultScreen` 改为监听该资产事件。
|
||||||
|
|
||||||
|
### 3.5 音频:`AudioManager.Instance.PlayX()` → `AudioEvent` 通道
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ❌ 各系统:AudioManager.Instance.PlaySFXFromResources("SFX/Bell", 0.9f);
|
||||||
|
// ✅ 定义 AudioEvent : GameEvent<AudioClip> 资产;系统 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<Vector3>)`,彻底解耦对 `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. 跑通后按路线图逐阶段推进;需要我直接改造某个具体系统时,指认文件即可。
|
||||||
@@ -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<DamageFlashOverlay>()`(`: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<GameEvent>(".../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]` 黄色警告,再测受击与摇铃。
|
||||||
|
|
||||||
@@ -30,10 +30,10 @@ EditorUserSettings:
|
|||||||
value: 5a5757560101590a5d0c0e24427b5d44434e4c7a7b7a23677f2b4565b7b5353a
|
value: 5a5757560101590a5d0c0e24427b5d44434e4c7a7b7a23677f2b4565b7b5353a
|
||||||
flags: 0
|
flags: 0
|
||||||
RecentlyUsedSceneGuid-8:
|
RecentlyUsedSceneGuid-8:
|
||||||
value: 0209025f575750595c57092149765d444e4e49727e7c7068757b1c65b6e36c3b
|
value: 5a55515156575a0b0f56592346260f444f16197c7e7f24697d2a4a32b1b0353e
|
||||||
flags: 0
|
flags: 0
|
||||||
RecentlyUsedSceneGuid-9:
|
RecentlyUsedSceneGuid-9:
|
||||||
value: 5a55515156575a0b0f56592346260f444f16197c7e7f24697d2a4a32b1b0353e
|
value: 0209025f575750595c57092149765d444e4e49727e7c7068757b1c65b6e36c3b
|
||||||
flags: 0
|
flags: 0
|
||||||
UnityEditor.ShaderGraph.Blackboard:
|
UnityEditor.ShaderGraph.Blackboard:
|
||||||
value: 18135939215a0a5004000b0e15254b524c030a3f2964643d120d1230e9e93a3fd6e826abbd2e2d293c4ead313b08042de6030a0afa240c0d020be94c4ba75e435d8715fa32c70d15d11612dacc11fee5d3c5d1fe9ab1bf968e93e2ffcbc3e7e2f0b3ffe0e8b0be9af8ffaeffff8e85dd8390e3949c8899daa7
|
value: 18135939215a0a5004000b0e15254b524c030a3f2964643d120d1230e9e93a3fd6e826abbd2e2d293c4ead313b08042de6030a0afa240c0d020be94c4ba75e435d8715fa32c70d15d11612dacc11fee5d3c5d1fe9ab1bf968e93e2ffcbc3e7e2f0b3ffe0e8b0be9af8ffaeffff8e85dd8390e3949c8899daa7
|
||||||
|
|||||||
Reference in New Issue
Block a user