This commit is contained in:
2026-07-07 03:34:56 +08:00
parent e7891d7e00
commit ecb20cf370
81 changed files with 2108 additions and 293 deletions
+80
View File
@@ -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 ClassGameManager(约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.csGameEvent<T> + Int/Float/Vector3/String/GameObject 通道及监听器)
- Variables/Float/Int/Bool/Vector3 Variable,带 OnValueChanged + ContextMenu 重置)
- RuntimeSets/RuntimeSet.cs(泛型集合 + TransformRuntimeSet + RuntimeSetRegistrar 自动注册)
- Editor/VariableDrawer.csInspector 实时显示变量值)
- **产出方案文档** `Docs/解耦架构重构方案.md`:分层架构(LO SO总线 / L1系统 / L2实体 / L3表现)、逐条 Before→After 改造映射、P0-P6 分阶段路线图。
- **项目约定**:无 .asmdef,所有代码在 Assembly-CSharpGameEvent/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)。
- APIGameEvent 同时支持 Inspector 监听器(GameEventListener) 与代码监听器 `Register(System.Action)`GameEvent<T> 用 `Register(Action<T>)`
- **P2a GameHUD**:接 PlayerHealth IntVariableOnValueChanged 驱动 UpdateLifeIcons+ scoreChangedEvent;技能 CD 改读 PlayerController.RollCooldown / EchoSystem.BellCooldown 公共只读属性(替代反射)。
- **P2b EnemyHealthBar 重要正确性修正**:原方案让所有 HealthSystem 写同一个全局 IntVariable,会导致多敌人共享同一份血量(严重 bug)。改为 EnemyHealthBar 引用**同物体** HealthSystemRequireComponent 保证),订阅其 `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 清单补全。
- onScoreSettledP1 一并迁移。
- 两份文档(`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 要接**预制体资产**而非场景临时实例)。
+8
View File
@@ -347,6 +347,7 @@ MonoBehaviour:
maxSightDistance: 4
loseSightTime: 3
listenRange: 15
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
bellArriveDistance: 1
bellChaseTimeout: 10
bellPatrolTime: 5
@@ -385,9 +386,16 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
m_Name:
m_EditorClassIdentifier:
onPlayerDamagedEvent: {fileID: 0}
onPlayerDiedEvent: {fileID: 0}
healthVar: {fileID: 0}
maxHealthVar: {fileID: 0}
maxHealth: 3
deathEffect: {fileID: 0}
isPlayer: 0
dissolveMaterial: {fileID: 0}
fadeDuration: 2
damageInvincibleDuration: 2
--- !u!114 &1893771889199554109
MonoBehaviour:
m_ObjectHideFlags: 0
+7
View File
@@ -355,6 +355,10 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
m_Name:
m_EditorClassIdentifier:
onPlayerDamagedEvent: {fileID: 11400000, guid: 1690413250c5d464eb84008d45cb88cc, type: 2}
onPlayerDiedEvent: {fileID: 11400000, guid: 8eee0c771c042c44a88f48756f3717b5, type: 2}
healthVar: {fileID: 11400000, guid: 6952ca1b8005ae541bf172dd398d4cc7, type: 2}
maxHealthVar: {fileID: 11400000, guid: 6952ca1b8005ae541bf172dd398d4cc7, type: 2}
maxHealth: 5
deathEffect: {fileID: 4806121257990350900, guid: f19c76183b5e22e44a73655dc18f1a92, type: 3}
isPlayer: 1
@@ -430,6 +434,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 0a44142f1063e1d4dbab7b851d31d258, type: 3}
m_Name:
m_EditorClassIdentifier:
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
echoKey: 101
expandSpeed: 15
maxRadius: 30
@@ -441,6 +446,8 @@ MonoBehaviour:
ringVisualWidth: 2.5
ringYOffset: 0.1
ringFadeTime: 3
ringExpandSpeed: 15
ringShader: {fileID: 4800000, guid: 67f78fa2aa44cba4cadd869535881366, type: 3}
cooldown: 3
--- !u!1001 &1156712605891213377
PrefabInstance:
@@ -2,13 +2,23 @@ using UnityEngine;
using System;
using System.Collections;
using GameFramework;
using Architecture.Core;
using Architecture.Variables;
namespace IndianOceanAssets.Engine2_5D
{
// Handles health, damage, and death for entities
public class HealthSystem : MonoBehaviour
{
/// <summary>玩家受伤时触发(用于受击特效等)</summary>
public static Action onPlayerDamaged;
[Header("SO 事件通道(替代 static onPlayerDamaged 事件)")]
[SerializeField] private GameEvent onPlayerDamagedEvent;
[Header("SO 事件通道(玩家死亡,替代直接调用 GameManager.GameOver")]
[SerializeField] private GameEvent onPlayerDiedEvent;
[Header("SO 变量(供 EnemyHealthBar / HUD 订阅,替代反射读取私有字段)")]
[SerializeField] private IntVariable healthVar;
[SerializeField] private IntVariable maxHealthVar;
[Range(1, 100)]
[SerializeField]
@@ -31,6 +41,7 @@ namespace IndianOceanAssets.Engine2_5D
private bool _isDead = false; // 防止重复触发死亡
private bool _isInvincible = false; // 无敌状态
private float _invincibleTimer = 0f; // 无敌剩余时间(<=0 表示永久无敌)
private bool _warnedDamaged = false; // 防止 onPlayerDamagedEvent 空引用告警刷屏
[Header("受伤无敌")]
[Tooltip("玩家受伤后的无敌时间(秒)")]
@@ -42,13 +53,33 @@ namespace IndianOceanAssets.Engine2_5D
/// <summary>是否处于无敌状态(只读)</summary>
public bool IsInvincible => _isInvincible;
/// <summary>当前血量(只读)</summary>
public int CurrentHealth => health;
/// <summary>最大血量(只读)</summary>
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
private void Start()
{
health = maxHealth;
if (isPlayer)
{
if (maxHealthVar != null) maxHealthVar.Value = maxHealth;
if (healthVar != null) healthVar.Value = health;
}
}
// Applies damage and checks for death
@@ -58,10 +89,20 @@ namespace IndianOceanAssets.Engine2_5D
if (_isInvincible) return; // 无敌状态,免疫伤害
health -= damageAmount;
if (isPlayer && healthVar != null) healthVar.Value = health;
OnHealthChanged?.Invoke(health, maxHealth);
// 玩家受伤时触发事件(用于受击泛红特效)
if (isPlayer && onPlayerDamaged != null)
onPlayerDamaged.Invoke();
if (isPlayer)
{
if (onPlayerDamagedEvent != null)
onPlayerDamagedEvent.Raise();
else if (!_warnedDamaged)
{
Debug.LogWarning("[HealthSystem] onPlayerDamagedEvent 未接线!受击泛红特效不会触发。请在玩家 HealthSystem 的 On Player Damaged Event 字段拖入 OnPlayerDamaged 资产。", this);
_warnedDamaged = true;
}
}
// 播放受伤音效
if (isPlayer && AudioManager.Instance != null)
@@ -78,8 +119,8 @@ namespace IndianOceanAssets.Engine2_5D
if (isPlayer)
{
// 玩家死亡:触发过场动画,不立即销毁(由 GameManager 处理
GameManager.GameOver();
// 玩家死亡:通过 OnPlayerDied SO 事件通知 GameManager(解耦,不再直接调用单例
onPlayerDiedEvent?.Raise();
}
else
{
@@ -213,4 +254,4 @@ namespace IndianOceanAssets.Engine2_5D
Destroy(gameObject);
}
}
}
}
@@ -21,6 +21,10 @@ namespace IndianOceanAssets.Engine2_5D
private float lastRollTime; // Timestamp of last roll
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
// Enum to switch between sword or projectile attack types
+133
View File
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: WSxO4H+lVy/+Blf5pfhG4hmfSru5p5O0gX9h1Xbssr3FMpnAolEEZqQ=
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -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:
+8
View File
@@ -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,5 +1,5 @@
fileFormatVersion: 2
guid: CClOsC78Vi9/cIog8UeuUzecgGTFlu5AQ5iyvdkeMcyG1n6ns9R5ds8=
guid: Dy4XvCj+VnPPG8II9z1gbnWGNyb39F6pQKS/CfIEjQntIaaEOm2XcP4=
MonoImporter:
externalObjects: {}
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&lt;T&gt;);设计师驱动用对应 *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:
+8
View File
@@ -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&lt;EnemyAI&gt;)放在对应领域文件夹,
/// 继承本类即可,无需改动核心。
/// </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&lt;EnemyAI&gt;</c>)使用。
/// 自动 <c>GetComponent&lt;T&gt;()</c> 并加入对应集合,避免用 Transform 集合再手动转型。
/// 因 Unity 对泛型 MonoBehaviour 需具体闭包类型才能挂载,领域文件夹内写一行具体子类即可:
/// <code>public class EnemySetRegistrar : RuntimeSetRegistrar&lt;EnemyAI&gt; { }</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:
+8
View File
@@ -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:
+8
View File
@@ -3,6 +3,7 @@ using UnityEditor;
using UnityEngine.UI;
using TMPro;
using GameFramework;
using Architecture.Core;
/// <summary>
/// 一键搭建游戏 UIHUD Canvas + 游戏结果 Canvas + 主菜单标题。
@@ -300,6 +301,13 @@ public static class UIBuilder
so.FindProperty("loseConfirmButton").objectReferenceValue = loseBtn.GetComponent<Button>();
so.FindProperty("loseTitleText").objectReferenceValue = loseTitle.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();
Debug.Log("[UIBuilder] ResultScreen Canvas 搭建完成");
@@ -42,6 +42,9 @@ namespace IndianOceanAssets.Engine2_5D
[Tooltip("边缘发光材质(使用 AbyssEdgeGlow shader)。留空则自动创建。")]
[SerializeField] private Material edgeGlowMaterial;
[Tooltip("边缘发光着色器(留空则尝试 Shader.Find 兜底;建议拖入 Assets/Light/shaders/AbyssEdgeGlow.shader 以避免构建裁剪)")]
[SerializeField] private Shader edgeGlowShader;
private const string GeneratedName = "Generated_CliffWalls";
[ContextMenu("Build Cliff Walls")]
@@ -105,7 +108,7 @@ namespace IndianOceanAssets.Engine2_5D
else
{
// 尝试自动创建 AbyssEdgeGlow 材质
Shader edgeShader = Shader.Find("IndianOcean/AbyssEdgeGlow");
Shader edgeShader = edgeGlowShader != null ? edgeGlowShader : Shader.Find("IndianOcean/AbyssEdgeGlow");
if (edgeShader != null)
{
Material mat = new Material(edgeShader);
+4 -1
View File
@@ -54,6 +54,9 @@ namespace IndianOceanAssets.Engine2_5D
[Tooltip("边缘发光材质(使用 AbyssEdgeGlow shader)。留空则自动创建。")]
[SerializeField] Material edgeGlowMaterial;
[Tooltip("边缘发光着色器(留空则尝试 Shader.Find 兜底;建议拖入 Assets/Light/shaders/AbyssEdgeGlow.shader 以避免构建裁剪)")]
[SerializeField] private Shader edgeGlowShader;
[Header("优化")]
[Tooltip("Douglas-Peucker 简化容差(世界单位)。0 = 不简化。\n" +
"建议 0.05~0.2,减少顶点数同时保持轮廓形状。")]
@@ -177,7 +180,7 @@ namespace IndianOceanAssets.Engine2_5D
}
else
{
Shader glowShader = Shader.Find("IndianOcean/AbyssEdgeGlow");
Shader glowShader = edgeGlowShader != null ? edgeGlowShader : Shader.Find("IndianOcean/AbyssEdgeGlow");
if (glowShader != null)
{
Material mat = new Material(glowShader);
+67
View File
@@ -3778,11 +3778,13 @@ MonoBehaviour:
mainMenuButton: {fileID: 549601800}
mainMenuButtonImage: {fileID: 549601797}
lostSFX: {fileID: 0}
onGameOverEvent: {fileID: 11400000, guid: 7bc1fd1afe8d2774fb5cc757ef12895b, type: 2}
titleFadeDuration: 1.5
ripplePositionAmplitude: 8
rippleScaleAmplitude: 0.08
rippleFrequency: 15
rippleMaterial: {fileID: 2100000, guid: 2d4ef2f4a2f0eb2479ece51f60be054d, type: 2}
waterRippleShader: {fileID: 0}
buttonFadeDelay: 2
buttonFadeDuration: 0.5
--- !u!4 &73055215
@@ -7504,6 +7506,8 @@ MonoBehaviour:
skillLanternIcon: {fileID: 1435658950}
skillLanternCD: {fileID: 2099612955}
pauseButton: {fileID: 0}
scoreChangedEvent: {fileID: 11400000, guid: f9b9156804ea63d44a675e3485b43f88, type: 2}
playerHealthVar: {fileID: 11400000, guid: 6952ca1b8005ae541bf172dd398d4cc7, type: 2}
--- !u!114 &160213092
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -25269,6 +25273,55 @@ BoxCollider:
serializedVersion: 3
m_Size: {x: 0.8, y: 3, z: 0.41948986}
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
PrefabInstance:
m_ObjectHideFlags: 0
@@ -67656,6 +67709,11 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 0bc49c6d42cb88a4c981d949a1cd87cd, type: 3}
m_Name:
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
lightShrinkDuration: 2
lostFadeOutDuration: 1
@@ -101243,6 +101301,14 @@ PrefabInstance:
serializedVersion: 3
m_TransformParent: {fileID: 0}
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}
propertyPath: m_LocalPosition.x
value: 8.063
@@ -101334,3 +101400,4 @@ SceneRoots:
- {fileID: 1115832006}
- {fileID: 2106106797}
- {fileID: 2084288743}
- {fileID: 535625616}
+11
View File
@@ -931,6 +931,11 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 0bc49c6d42cb88a4c981d949a1cd87cd, type: 3}
m_Name:
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
lightShrinkDuration: 2
lostFadeOutDuration: 1
@@ -1173,6 +1178,8 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 3e98896ae36c7a54ba83c4e9eb532752, type: 3}
m_Name:
m_EditorClassIdentifier:
scoreChangedEvent: {fileID: 11400000, guid: f9b9156804ea63d44a675e3485b43f88, type: 2}
scoreSettledEvent: {fileID: 11400000, guid: eee0021c0c646f3469a86bef9c590cff, type: 2}
defaultPlayerName: Player
--- !u!4 &646400229
Transform:
@@ -4723,6 +4730,8 @@ MonoBehaviour:
skillLanternIcon: {fileID: 0}
skillLanternCD: {fileID: 0}
pauseButton: {fileID: 0}
scoreChangedEvent: {fileID: 0}
playerHealthVar: {fileID: 0}
--- !u!114 &1852443089
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -4947,6 +4956,8 @@ MonoBehaviour:
skillLanternIcon: {fileID: 1675213501}
skillLanternCD: {fileID: 1498112937}
pauseButton: {fileID: 90840731}
scoreChangedEvent: {fileID: 0}
playerHealthVar: {fileID: 0}
--- !u!114 &1890235558
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -268,6 +268,10 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
m_Name:
m_EditorClassIdentifier:
onPlayerDamagedEvent: {fileID: 11400000, guid: 1690413250c5d464eb84008d45cb88cc, type: 2}
onPlayerDiedEvent: {fileID: 11400000, guid: 8eee0c771c042c44a88f48756f3717b5, type: 2}
healthVar: {fileID: 11400000, guid: 6952ca1b8005ae541bf172dd398d4cc7, type: 2}
maxHealthVar: {fileID: 11400000, guid: 6952ca1b8005ae541bf172dd398d4cc7, type: 2}
maxHealth: 5
deathEffect: {fileID: 4806121257990350900, guid: f19c76183b5e22e44a73655dc18f1a92, type: 3}
isPlayer: 1
@@ -343,6 +347,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 0a44142f1063e1d4dbab7b851d31d258, type: 3}
m_Name:
m_EditorClassIdentifier:
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
echoKey: 101
expandSpeed: 15
maxRadius: 30
@@ -355,6 +360,7 @@ MonoBehaviour:
ringYOffset: 0.1
ringFadeTime: 2
ringExpandSpeed: 18
ringShader: {fileID: 4800000, guid: 67f78fa2aa44cba4cadd869535881366, type: 3}
cooldown: 10
--- !u!114 &4208162987965796937
MonoBehaviour:
+24 -6
View File
@@ -1,6 +1,7 @@
using UnityEngine;
using UnityEngine.UI;
using IndianOceanAssets.Engine2_5D;
using Architecture.Core;
namespace GameFramework
{
@@ -27,15 +28,32 @@ namespace GameFramework
private float _currentAlpha;
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()
{
CreateOverlay();
HealthSystem.onPlayerDamaged += OnPlayerDamaged;
}
private void OnDestroy()
{
HealthSystem.onPlayerDamaged -= OnPlayerDamaged;
}
private void Update()
+37 -75
View File
@@ -2,12 +2,14 @@ using UnityEngine;
using UnityEngine.UI;
using TMPro;
using IndianOceanAssets.Engine2_5D;
using Architecture.Core;
using Architecture.Variables;
namespace GameFramework
{
/// <summary>
/// 游戏内 HUD —— 魂灵计数 + 生命图标 + 技能CD。
///
///
/// 布局:
/// - 左上角:收集到的魂灵(icon + 数字)
/// - 左下角:5个生命图标
@@ -36,12 +38,14 @@ namespace GameFramework
[Header("暂停")]
[SerializeField] private Button pauseButton;
[Header("SO 事件通道 / 变量")]
[SerializeField] private IntEvent scoreChangedEvent;
[SerializeField] private IntVariable playerHealthVar;
private HealthSystem _playerHealth;
private SpiritLanternSystem _lanternSystem;
private EchoSystem _echoSystem;
private PlayerController _playerController;
private int _maxHealth = 5;
private bool _subscribedScoreEvent = false;
private int _lastSoulCount = -1;
void Start()
@@ -62,59 +66,34 @@ namespace GameFramework
if (soulIcon != null)
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;
_subscribedScoreEvent = true;
UpdateSoulCount(ScoreManager.Instance.CurrentScore);
playerHealthVar.OnValueChanged += UpdateLifeIcons;
UpdateLifeIcons(playerHealthVar.Value);
}
UpdateLifeIcons();
// 自动创建受击泛红特效(如果场景中没有)
// 受击泛红特效:场景需预置已接线的 DamageFlashOverlay 实例(不再自动创建,
// 否则会生成一个事件为 null 的实例,既无效果又掩盖「未接线」问题)
if (FindObjectOfType<DamageFlashOverlay>() == null)
{
var overlayObj = new GameObject("DamageFlashOverlay");
overlayObj.AddComponent<DamageFlashOverlay>();
}
Debug.LogWarning("[GameHUD] 场景中未找到 DamageFlashOverlay,受击泛红特效不会显示。请在场景中放置一个、并把它 On Player Damaged Event 字段接上 OnPlayerDamaged 资产。", this);
}
void OnDestroy()
{
if (_subscribedScoreEvent)
{
ScoreManager.onScoreChanged -= UpdateSoulCount;
_subscribedScoreEvent = false;
}
scoreChangedEvent?.Unregister(UpdateSoulCount);
if (playerHealthVar != null)
playerHealthVar.OnValueChanged -= UpdateLifeIcons;
}
void Update()
{
// 延迟订阅 ScoreManager(跨场景后 ScoreManager 可能才创建)
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();
// 事件已在 Start 中无条件订阅,无需逐帧延迟订阅(避免反模式)。
UpdateSkillCooldowns();
}
@@ -140,17 +119,14 @@ namespace GameFramework
/// <summary>
/// 更新生命图标(左下角)。
/// 根据当前血量显示/隐藏对应图标
/// 直接读取 PlayerHealth 共享变量,由 OnValueChanged 事件驱动,无反射、无逐帧 Find
/// </summary>
private void UpdateLifeIcons()
private void UpdateLifeIcons(int value)
{
if (_playerHealth == null || lifeIcons == null || lifeIcons.Length == 0) return;
// 获取当前血量
var healthField = typeof(HealthSystem).GetField("health",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
int currentHealth = healthField != null ? (int)healthField.GetValue(_playerHealth) : _maxHealth;
if (lifeIcons == null || lifeIcons.Length == 0) return;
if (playerHealthVar == null) return;
int currentHealth = value;
for (int i = 0; i < lifeIcons.Length; i++)
{
if (lifeIcons[i] != null)
@@ -160,43 +136,29 @@ namespace GameFramework
/// <summary>
/// 更新技能CD遮罩(右下角)。
/// 使用 Image.fillAmount 实现圆形CD效果
/// 通过各系统的公共只读属性读取冷却状态,替代反射读取私有字段
/// </summary>
private void UpdateSkillCooldowns()
{
// 冲刺 CD(从 PlayerController 读取 rollCooldown + lastRollTime
// 冲刺 CD(从 PlayerController.RollCooldown 读取
if (_playerController != null)
{
var cdField = typeof(PlayerController).GetField("rollCooldown",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
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);
var cd = _playerController.RollCooldown;
UpdateSkillCD(skillSprintCD, skillSprintIcon, cd.remaining, cd.total);
}
// 摇铃 CD(从 EchoSystem 读取 cooldown + _lastEchoTime
// 摇铃 CD(从 EchoSystem.BellCooldown 读取
if (_echoSystem != null)
{
var cdField = typeof(EchoSystem).GetField("cooldown",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
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);
var cd = _echoSystem.BellCooldown;
UpdateSkillCD(skillBellCD, skillBellIcon, cd.remaining, cd.total);
}
// 灵灯 CDSpiritLanternSystem 读取
// 灵灯 CDSpiritLanternSystem 已暴露公共 Cooldown / CooldownRemaining,无反射
if (_lanternSystem != null)
{
float remaining = _lanternSystem.CooldownRemaining;
var cdField = typeof(SpiritLanternSystem).GetField("cooldown",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
float maxCD = cdField != null ? (float)cdField.GetValue(_lanternSystem) : 3f;
float maxCD = _lanternSystem.Cooldown;
UpdateSkillCD(skillLanternCD, skillLanternIcon, remaining, maxCD);
}
}
+25 -40
View File
@@ -2,6 +2,7 @@ using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using Architecture.Core;
namespace GameFramework
{
@@ -12,14 +13,12 @@ namespace GameFramework
/// 1. 在 Gameplay 场景里创建一个空物体,挂上本脚本
/// 2. 运行一次游戏(或点 Editor 按钮),脚本会自动构建子 UI
/// 3. 停止运行后,子 UI 留在场景中,可自由编辑样式/位置/精灵
/// 4. 运行时由 GameManager 调用 GameLostOverlay.Show() 激活
/// 4. 运行时订阅 OnGameOver 事件自激活(不再由 GameManager 直接调用)
///
/// 所有 UI 元素均为 [SerializeField],可在 Inspector 中替换。
/// </summary>
public class GameLostOverlay : MonoBehaviour
{
private static GameLostOverlay _instance;
// ====== Canvas ======
[Header("Canvas(留空则自动创建)")]
[SerializeField] private Canvas lostCanvas;
@@ -43,6 +42,9 @@ namespace GameFramework
[Header("音效")]
[SerializeField] private AudioData lostSFX;
[Header("SO 事件通道(替代 GameManager 直接调用 Show,订阅 OnGameOver 自激活)")]
[SerializeField] private GameEvent onGameOverEvent;
[Header("标题渐现动效")]
[Tooltip("标题渐现总时长(秒)")]
[SerializeField] private float titleFadeDuration = 1.5f;
@@ -54,6 +56,9 @@ namespace GameFramework
[SerializeField] private float rippleFrequency = 15f;
[Tooltip("水波纹材质(留空则自动从 Shader 创建)")]
[SerializeField] private Material rippleMaterial;
[Tooltip("水波纹着色器(留空则尝试 Shader.Find 兜底;建议拖入 Assets/UI/shaders/WaterRippleFade.shader 以避免构建裁剪导致运行时找不到)")]
[SerializeField] private Shader waterRippleShader;
[Tooltip("主界面按钮延迟出现时间(秒)")]
[SerializeField] private float buttonFadeDelay = 2f;
[Tooltip("按钮渐现时长(秒)")]
@@ -65,46 +70,11 @@ namespace GameFramework
private Material _rippleMaterial;
// ====================================================================
// 静态入口
// ====================================================================
/// <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();
}
// ====================================================================
// 生命周期
// 生命周期(激活由 OnGameOver 事件驱动,见 OnEnable
// ====================================================================
void Awake()
{
_instance = this;
// 如果 Canvas 还没赋值,尝试从自身获取
if (lostCanvas == null)
lostCanvas = GetComponent<Canvas>();
@@ -114,6 +84,21 @@ namespace GameFramework
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()
{
if (!_isVisible) return;
@@ -355,7 +340,7 @@ namespace GameFramework
}
else
{
var shader = Shader.Find("GameFramework/UI/WaterRippleFade");
var shader = waterRippleShader != null ? waterRippleShader : Shader.Find("GameFramework/UI/WaterRippleFade");
if (shader == null)
{
Debug.LogWarning("[GameLostOverlay] 找不到 WaterRippleFade Shader!请确认 Assets/UI/shaders/WaterRippleFade.shader 存在且无编译错误。回退到抖动动效。");
+51 -16
View File
@@ -4,6 +4,7 @@ using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using IndianOceanAssets.Engine2_5D;
using Architecture.Core;
namespace GameFramework
{
@@ -13,11 +14,31 @@ namespace GameFramework
/// </summary>
public class GameManager : PersistentSingleton<GameManager>
{
/// <summary>游戏结束(失败)时触发。</summary>
public static Action onGameOver;
[Header("SO 事件通道(替代 static Action 事件,Inspector 拖入对应资产)")]
[SerializeField] private GameEvent onGameOverEvent;
[SerializeField] private GameEvent onGameWinEvent;
/// <summary>游戏胜利时触发。</summary>
public static Action onGameWin;
[Header("SO 事件通道(玩家死亡事件,替代 HealthSystem 直接调用 GameOver")]
[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
{
@@ -49,7 +70,7 @@ namespace GameFramework
{
if (Instance == null) return;
GameState = GameState.GameOver;
onGameOver?.Invoke();
Instance.onGameOverEvent?.Raise();
Instance.StartCoroutine(Instance.DeathTransition());
}
@@ -58,7 +79,7 @@ namespace GameFramework
{
if (Instance == null) return;
GameState = GameState.Victory;
onGameWin?.Invoke();
Instance.onGameWinEvent?.Raise();
Instance.StartCoroutine(Instance.VictoryTransition());
}
@@ -141,17 +162,32 @@ namespace GameFramework
/// </summary>
private void DisableAllEnemyAI()
{
// 禁用所有 EnemyAI
var enemyAIs = FindObjectsOfType<IndianOceanAssets.Engine2_5D.EnemyAI>();
foreach (var ai in enemyAIs)
ai.enabled = false;
int disabled = 0;
// 优先用 Enemies 运行时集合(敌人 Prefab 需挂 RuntimeSetRegistrar 并指向该集合,
// 在 OnEnable/OnDisable 时自动注册/注销自身 Transform)
if (enemiesSet != null)
{
foreach (var t in enemiesSet.Items)
{
if (t == null) continue;
var ai = t.GetComponent<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(停止生成/管理逻辑)
var enemyManager = FindObjectOfType<IndianOceanAssets.Engine2_5D.EnemyManager>();
if (enemyManager != null)
enemyManager.enabled = false;
if (enemyManagerRef != null)
enemyManagerRef.enabled = false;
Debug.Log($"[GameManager] 已禁用 {enemyAIs.Length} 个 EnemyAI + EnemyManager");
Debug.Log($"[GameManager] 已禁用 {disabled} 个 EnemyAI + EnemyManager");
}
/// <summary>
@@ -230,8 +266,7 @@ namespace GameFramework
yield return null;
// 在全黑之上显示失败叠加层("你已迷失……" + 主界面按钮)
GameLostOverlay.Show();
// 失败叠加层("你已迷失……")由 GameLostOverlay 订阅 OnGameOver 事件自显示,此处不再直接调用。
// 等待一帧让 UI 渲染
yield return null;
-75
View File
@@ -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();
}
}
}
+9 -11
View File
@@ -1,6 +1,7 @@
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using Architecture.Core;
namespace GameFramework
{
@@ -10,9 +11,9 @@ namespace GameFramework
public enum GameResult { Win, Lose }
/// <summary>
/// 游戏结果界面(支持胜利/失败)。
/// 监听 GameManager.onGameOver 和 onGameWin 事件,
/// 显示对应面板并暂停游戏。确认后跳转到排行榜场景
/// 游戏结果界面(仅负责「胜利」结算)。
/// 监听 OnGameWin 事件,显示胜利面板并暂停游戏,确认后跳转到排行榜场景。
/// 「失败」由 GameLostOverlay 接管(订阅 OnGameOver),本屏不处理失败,避免空响应
/// </summary>
public class GameResultScreen : MonoBehaviour
{
@@ -38,6 +39,9 @@ namespace GameFramework
[Header("Input")]
[SerializeField] private KeyCode confirmKey = KeyCode.Return;
[Header("SO 事件通道(胜利事件;失败由 GameLostOverlay 接管)")]
[SerializeField] private GameEvent onGameWinEvent;
private bool _isVisible;
/// <summary>
@@ -59,20 +63,14 @@ namespace GameFramework
void OnEnable()
{
GameManager.onGameOver += OnGameOver;
GameManager.onGameWin += OnGameWin;
onGameWinEvent?.Register(OnGameWin);
}
void OnDisable()
{
GameManager.onGameOver -= OnGameOver;
GameManager.onGameWin -= OnGameWin;
onGameWinEvent?.Unregister(OnGameWin);
}
void OnGameOver()
{
// 失败时由 GameLostOverlay 接管,不再显示旧失败面板
}
void OnGameWin() => ShowResult(GameResult.Win);
void Start()
@@ -59,10 +59,10 @@ Material:
- _DecalMeshDepthBias: 0
- _DecalMeshViewBias: 0
- _DrawOrder: 0
- _FadeAlpha: 0.96674436
- _FadeAlpha: 1
- _RippleAmplitude: 0.0412
- _RippleFrequency: 10.5
- _RippleIntensity: 0.64074624
- _RippleIntensity: -0
- _RippleSpeed: 3
- _Shininess: 0.2
- _Stencil: 0
+8 -6
View File
@@ -1,5 +1,6 @@
using UnityEngine;
using UnityEngine.UI;
using Architecture.Core;
namespace GameFramework
{
@@ -13,24 +14,25 @@ namespace GameFramework
[SerializeField] Text scoreText;
[SerializeField] string format = "{0}";
[Header("SO 事件通道(替代 static ScoreManager.onScoreChanged 事件)")]
[SerializeField] private IntEvent scoreChangedEvent;
void OnEnable()
{
ScoreManager.onScoreChanged += UpdateText;
scoreChangedEvent?.Register(UpdateText);
}
void OnDisable()
{
ScoreManager.onScoreChanged -= UpdateText;
scoreChangedEvent?.Unregister(UpdateText);
}
void Start()
{
if (scoreText == null)
scoreText = GetComponent<Text>();
if (ScoreManager.Instance != null)
UpdateText(ScoreManager.Instance.CurrentScore);
else
UpdateText(0);
// 初始值走事件:订阅后首个 ScoreChanged 会刷新显示;分数初始即为 0,先显示 0。
UpdateText(0);
}
void UpdateText(int score)
+12 -12
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Architecture.Core;
namespace GameFramework
{
@@ -32,7 +33,7 @@ namespace GameFramework
/// <summary>
/// 得分管理器(持久单例)。
/// 维护当前局得分,通过事件通知 UI,并管理 Top-10 排行榜存档。
/// 维护当前局得分,通过 SO 事件通道通知 UI,并管理 Top-10 排行榜存档。
/// </summary>
public class ScoreManager : PersistentSingleton<ScoreManager>
{
@@ -42,10 +43,9 @@ namespace GameFramework
// 当前局得分
int currentScore = 0;
// 当得分变化时触发,参数为最新得分(动画过程中的中间值也会触发)
public static event Action<int> onScoreChanged;
// 当得分完成最终增加时触发,参数为最终得分
public static event Action<int> onScoreSettled;
[Header("SO 事件通道(替代 static onScoreChanged / onScoreSettled 事件,Inspector 拖入对应资产)")]
[SerializeField] private IntEvent scoreChangedEvent;
[SerializeField] private IntEvent scoreSettledEvent;
/// <summary>当前得分(只读)。</summary>
public int CurrentScore => currentScore;
@@ -75,8 +75,8 @@ namespace GameFramework
public void SetScore(int value)
{
currentScore = Mathf.Max(0, value);
onScoreChanged?.Invoke(currentScore);
onScoreSettled?.Invoke(currentScore);
scoreChangedEvent?.Raise(currentScore);
scoreSettledEvent?.Raise(currentScore);
}
/// <summary>
@@ -85,8 +85,8 @@ namespace GameFramework
public void ResetScore()
{
currentScore = 0;
onScoreChanged?.Invoke(0);
onScoreSettled?.Invoke(0);
scoreChangedEvent?.Raise(0);
scoreSettledEvent?.Raise(0);
}
IEnumerator ScoreCountUpCoroutine(int from, int to)
@@ -100,12 +100,12 @@ namespace GameFramework
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / duration);
int display = Mathf.RoundToInt(Mathf.Lerp(from, to, t));
onScoreChanged?.Invoke(display);
scoreChangedEvent?.Raise(display);
yield return null;
}
onScoreChanged?.Invoke(to);
onScoreSettled?.Invoke(to);
scoreChangedEvent?.Raise(to);
scoreSettledEvent?.Raise(to);
}
#region
+21 -6
View File
@@ -1,6 +1,7 @@
using System;
using UnityEngine;
using GameFramework;
using Architecture.Core;
namespace IndianOceanAssets.Engine2_5D
{
@@ -20,11 +21,8 @@ namespace IndianOceanAssets.Engine2_5D
/// </summary>
public class EchoSystem : MonoBehaviour
{
/// <summary>
/// 按 E 释放回声(摇铃)时触发,参数为释放时的玩家世界位置。
/// 敌人聆听系统等可订阅此事件。
/// </summary>
public static event Action<Vector3> OnEchoReleased;
[Header("SO 事件通道(替代 static OnEchoReleased 事件,参数为释放时玩家世界位置)")]
[SerializeField] private Vector3Event echoReleasedEvent;
[Header("按键")]
[SerializeField] private KeyCode echoKey = KeyCode.E;
@@ -101,6 +99,7 @@ namespace IndianOceanAssets.Engine2_5D
private float _lastEchoTime = -999f;
private float _ringAlpha = 0f;
private float _ringRadius = 0f;
private bool _warnedEcho = false; // 防止 echoReleasedEvent 空引用告警刷屏
private void Start()
{
@@ -219,7 +218,13 @@ namespace IndianOceanAssets.Engine2_5D
}
// 通知订阅者(敌人聆听等)
OnEchoReleased?.Invoke(p);
if (echoReleasedEvent != null)
echoReleasedEvent.Raise(p);
else if (!_warnedEcho)
{
Debug.LogWarning("[EchoSystem] echoReleasedEvent 未接线!敌人不会响应摇铃。请在 EchoSystem 的 Echo Released Event 字段拖入 EchoReleased 资产。", this);
_warnedEcho = true;
}
}
private void UpdateExpanding()
@@ -309,6 +314,16 @@ namespace IndianOceanAssets.Engine2_5D
public bool IsActive => _state != State.Idle;
/// <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()
{
+3
View File
@@ -42,6 +42,9 @@ namespace IndianOceanAssets.Engine2_5D
/// <summary>冷却剩余时间(供 UI 使用)</summary>
public float CooldownRemaining => Mathf.Max(0f, (_lastPlaceTime + cooldown) - Time.time);
/// <summary>冷却总时长(供 UI 计算 CD 比例,替代反射读取私有字段)</summary>
public float Cooldown => cooldown;
private void Start()
{
_remainingLanterns = maxLanterns;
+12 -2
View File
@@ -1,4 +1,5 @@
using UnityEngine;
using Architecture.Core;
namespace IndianOceanAssets.Engine2_5D
{
@@ -46,6 +47,9 @@ namespace IndianOceanAssets.Engine2_5D
[Tooltip("聆听范围:主角在此范围内按E摇铃时,敌人会朝铃铛位置移动")]
[SerializeField] private float listenRange = 15f;
[Header("SO 事件通道(替代 static EchoSystem.OnEchoReleased 事件)")]
[SerializeField] private Vector3Event echoReleasedEvent;
[Header("铃铛追击")]
[Tooltip("到达铃铛位置后的容差距离")]
[SerializeField] private float bellArriveDistance = 1f;
@@ -105,6 +109,12 @@ namespace IndianOceanAssets.Engine2_5D
private HealthSystem _playerHealth; // 玩家血量引用
private Camera _mainCam; // 主相机引用(用于Billboard
private void OnValidate()
{
if (echoReleasedEvent == null)
Debug.LogWarning($"[EnemyAI] Echo Released Event 未接线({gameObject.name})。该敌人不会响应摇铃。", this);
}
private void Start()
{
if (playerTarget == null)
@@ -127,12 +137,12 @@ namespace IndianOceanAssets.Engine2_5D
private void OnEnable()
{
EchoSystem.OnEchoReleased += OnBell;
echoReleasedEvent?.Register(OnBell);
}
private void OnDisable()
{
EchoSystem.OnEchoReleased -= OnBell;
echoReleasedEvent?.Unregister(OnBell);
}
private void LateUpdate()
+27 -30
View File
@@ -1,11 +1,16 @@
using UnityEngine;
using IndianOceanAssets.Engine2_5D;
namespace IndianOceanAssets.Engine2_5D
{
/// <summary>
/// 敌人头顶血条 —— 挂在敌人 Prefab 上。
/// 血条可视化物体(BG、Fill)已在 Prefab 中预建,
/// 运行时直接引用,无需动态创建。
/// 血条可视化物体(BG、Fill)已在 Prefab 中预建,运行时直接引用,无需动态创建。
///
/// 解耦改造:直接引用同物体上的 HealthSystemRequireComponent 保证存在),
/// 订阅其 OnHealthChanged 事件刷新,彻底移除对私有字段的反射读取。
/// 注意:不使用"全局共享 IntVariable"承载敌人血量——否则所有敌人血条会显示同一份血量,
/// 正确的解法是"每个敌人读自己的 HealthSystem"(同物体组件引用,非 Find / 反射 / 跨对象)。
/// </summary>
[RequireComponent(typeof(HealthSystem))]
public class EnemyHealthBar : MonoBehaviour
@@ -33,52 +38,44 @@ namespace IndianOceanAssets.Engine2_5D
[Tooltip("满血时是否隐藏血条")]
[SerializeField] private bool hideWhenFull = false;
private HealthSystem _healthSystem;
private int _lastHealth = -1;
private int _maxHealth;
// 同物体上的 HealthSystemRequireComponent 保证存在;非 Find / 反射 / 跨对象引用)
private HealthSystem _healthSource;
private void Start()
{
_healthSystem = GetComponent<HealthSystem>();
if (_healthSystem == null)
if (fillRenderer == null || bgRenderer == null)
{
enabled = false;
return;
}
// 获取 maxHealth(通过反射读取私有字段)
var field = typeof(HealthSystem).GetField("maxHealth",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (field != null)
_maxHealth = (int)field.GetValue(_healthSystem);
_healthSource = GetComponent<HealthSystem>();
if (_healthSource != null)
{
_healthSource.OnHealthChanged += UpdateBar;
UpdateBar(_healthSource.CurrentHealth, _healthSource.MaxHealth);
}
else
_maxHealth = 3;
UpdateBar();
{
UpdateBar(1, 1);
}
}
private void LateUpdate()
private void OnDestroy()
{
UpdateBar();
if (_healthSource != null)
_healthSource.OnHealthChanged -= UpdateBar;
}
/// <summary>
/// 根据当前血量更新血条显示。
/// 根据当前血量更新血条显示(事件驱动,无反射、无每帧 LateUpdate)
/// </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;
// 获取当前血量(反射)
var healthField = typeof(HealthSystem).GetField("health",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
int currentHealth = healthField != null ? (int)healthField.GetValue(_healthSystem) : _maxHealth;
// 血量没变化则跳过(性能优化)
if (currentHealth == _lastHealth) return;
_lastHealth = currentHealth;
float ratio = Mathf.Clamp01((float)currentHealth / _maxHealth);
if (maxHealth <= 0) maxHealth = 1;
float ratio = Mathf.Clamp01((float)currentHealth / maxHealth);
// 更新填充条缩放(居中对齐)
fillRenderer.transform.localScale = new Vector3(barWidth * ratio, barHeight, 1f);
+6
View File
@@ -439,6 +439,7 @@ MonoBehaviour:
maxSightDistance: 4
loseSightTime: 3
listenRange: 15
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
bellArriveDistance: 1
bellChaseTimeout: 10
bellPatrolTime: 5
@@ -477,11 +478,16 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
m_Name:
m_EditorClassIdentifier:
onPlayerDamagedEvent: {fileID: 0}
onPlayerDiedEvent: {fileID: 0}
healthVar: {fileID: 0}
maxHealthVar: {fileID: 0}
maxHealth: 3
deathEffect: {fileID: 0}
isPlayer: 0
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
fadeDuration: 2
damageInvincibleDuration: 2
--- !u!114 &1893771889199554109
MonoBehaviour:
m_ObjectHideFlags: 0
+6
View File
@@ -351,6 +351,7 @@ MonoBehaviour:
maxSightDistance: 4
loseSightTime: 3
listenRange: 15
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
bellArriveDistance: 1
bellChaseTimeout: 10
bellPatrolTime: 5
@@ -389,11 +390,16 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
m_Name:
m_EditorClassIdentifier:
onPlayerDamagedEvent: {fileID: 0}
onPlayerDiedEvent: {fileID: 0}
healthVar: {fileID: 0}
maxHealthVar: {fileID: 0}
maxHealth: 3
deathEffect: {fileID: 0}
isPlayer: 0
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
fadeDuration: 2
damageInvincibleDuration: 2
--- !u!114 &1893771889199554109
MonoBehaviour:
m_ObjectHideFlags: 0
+6
View File
@@ -351,6 +351,7 @@ MonoBehaviour:
maxSightDistance: 4
loseSightTime: 3
listenRange: 15
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
bellArriveDistance: 1
bellChaseTimeout: 10
bellPatrolTime: 5
@@ -389,11 +390,16 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
m_Name:
m_EditorClassIdentifier:
onPlayerDamagedEvent: {fileID: 0}
onPlayerDiedEvent: {fileID: 0}
healthVar: {fileID: 0}
maxHealthVar: {fileID: 0}
maxHealth: 3
deathEffect: {fileID: 0}
isPlayer: 0
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
fadeDuration: 2
damageInvincibleDuration: 2
--- !u!114 &1893771889199554109
MonoBehaviour:
m_ObjectHideFlags: 0
+6
View File
@@ -439,6 +439,7 @@ MonoBehaviour:
maxSightDistance: 4
loseSightTime: 3
listenRange: 15
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
bellArriveDistance: 1
bellChaseTimeout: 10
bellPatrolTime: 5
@@ -477,11 +478,16 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
m_Name:
m_EditorClassIdentifier:
onPlayerDamagedEvent: {fileID: 0}
onPlayerDiedEvent: {fileID: 0}
healthVar: {fileID: 0}
maxHealthVar: {fileID: 0}
maxHealth: 3
deathEffect: {fileID: 0}
isPlayer: 0
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
fadeDuration: 2
damageInvincibleDuration: 2
--- !u!114 &1893771889199554109
MonoBehaviour:
m_ObjectHideFlags: 0
+6
View File
@@ -351,6 +351,7 @@ MonoBehaviour:
maxSightDistance: 4
loseSightTime: 3
listenRange: 15
echoReleasedEvent: {fileID: 11400000, guid: 3f85d8e63d6b82342ba94a4b413c21fa, type: 2}
bellArriveDistance: 1
bellChaseTimeout: 10
bellPatrolTime: 5
@@ -389,11 +390,16 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f9e36f6d2e0ea0541bad29e784e0841b, type: 3}
m_Name:
m_EditorClassIdentifier:
onPlayerDamagedEvent: {fileID: 0}
onPlayerDiedEvent: {fileID: 0}
healthVar: {fileID: 0}
maxHealthVar: {fileID: 0}
maxHealth: 3
deathEffect: {fileID: 0}
isPlayer: 0
dissolveMaterial: {fileID: 2100000, guid: a2207e01903058a4198862e6a7fb73c7, type: 2}
fadeDuration: 2
damageInvincibleDuration: 2
--- !u!114 &1893771889199554109
MonoBehaviour:
m_ObjectHideFlags: 0
+264
View File
@@ -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`,既有性能开销,又对字段改名极度脆弱(初稿仅列 GameHUDEnemyHealthBar 已补) |
| **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> 资产;系统 RaiseAudioManager 订阅并播放
[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 变量跨局重置(VariableRegistryP0/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. 跑通后按路线图逐阶段推进;需要我直接改造某个具体系统时,指认文件即可。
+200
View File
@@ -0,0 +1,200 @@
# 解耦架构重构 — Review 清单
> 范围:P0 地基 + P1 事件去静态化 + P2a/P2b 去反射/Find + P5Shader.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 集合 | ✅ |
| 退出再进血量=5VariableRegistry 重置) | ✅ |
### 根因(两处失败均为 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]` 黄色警告,再测受击与摇铃。
+2 -2
View File
@@ -30,10 +30,10 @@ EditorUserSettings:
value: 5a5757560101590a5d0c0e24427b5d44434e4c7a7b7a23677f2b4565b7b5353a
flags: 0
RecentlyUsedSceneGuid-8:
value: 0209025f575750595c57092149765d444e4e49727e7c7068757b1c65b6e36c3b
value: 5a55515156575a0b0f56592346260f444f16197c7e7f24697d2a4a32b1b0353e
flags: 0
RecentlyUsedSceneGuid-9:
value: 5a55515156575a0b0f56592346260f444f16197c7e7f24697d2a4a32b1b0353e
value: 0209025f575750595c57092149765d444e4e49727e7c7068757b1c65b6e36c3b
flags: 0
UnityEditor.ShaderGraph.Blackboard:
value: 18135939215a0a5004000b0e15254b524c030a3f2964643d120d1230e9e93a3fd6e826abbd2e2d293c4ead313b08042de6030a0afa240c0d020be94c4ba75e435d8715fa32c70d15d11612dacc11fee5d3c5d1fe9ab1bf968e93e2ffcbc3e7e2f0b3ffe0e8b0be9af8ffaeffff8e85dd8390e3949c8899daa7