From 825cae79776fdc16119b51e2b0c674787698a419 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Wed, 29 Jul 2026 14:46:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(enemy):=20PerceptionRecipeSO=20=E9=85=8D?= =?UTF-8?q?=E6=96=B9=E8=B5=84=E4=BA=A7=20+=20SubclassSelector=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E4=B8=8B=E6=8B=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未发现层/交战层各用 [SerializeReference]+[SubclassSelector] 下拉挑选模块, 新增模块类自动出现在下拉里,无需改枚举或已有文件。默认交战模块仍为 RushEngagement——未配能力时建图必须显式报错,而非静默产出打不出手的图。 --- .../EditMode/AI/PerceptionRecipeSoTests.cs | 84 +++++++++++++ .../AI/PerceptionRecipeSoTests.cs.meta | 11 ++ Assets/_Game/Scripts/Editor/AI.meta | 8 ++ .../Editor/AI/SubclassSelectorDrawer.cs | 112 ++++++++++++++++++ .../Editor/AI/SubclassSelectorDrawer.cs.meta | 11 ++ .../Modules/SubclassSelectorAttribute.cs | 10 ++ .../Modules/SubclassSelectorAttribute.cs.meta | 11 ++ .../Scripts/Enemies/AIBrain/Recipes.meta | 8 ++ .../AIBrain/Recipes/PerceptionRecipeSO.cs | 32 +++++ .../Recipes/PerceptionRecipeSO.cs.meta | 11 ++ 10 files changed, 298 insertions(+) create mode 100644 Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs create mode 100644 Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs.meta create mode 100644 Assets/_Game/Scripts/Editor/AI.meta create mode 100644 Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs create mode 100644 Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs create mode 100644 Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs.meta create mode 100644 Assets/_Game/Scripts/Enemies/AIBrain/Recipes.meta create mode 100644 Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs create mode 100644 Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs.meta diff --git a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs new file mode 100644 index 00000000..be6dce39 --- /dev/null +++ b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs @@ -0,0 +1,84 @@ +using NUnit.Framework; +using UnityEngine; +using BaseGames.AI; +using BaseGames.Enemies; + +namespace BaseGames.Tests.EditMode.AI +{ + public class PerceptionRecipeSoTests + { + sealed class Ctx : IAiContext, IEnemyActor + { + public FakeSensor S = new FakeSensor(); + public FakeLocomotion L = new FakeLocomotion(); + public FakeCombat C = new FakeCombat(); + public FakeVitals V = new FakeVitals(); + public Blackboard BB = new Blackboard(); + public ISensor Sensor => S; + public IEnemyLocomotion Locomotion => L; + public ICombatant Combat => C; + public IActorVitals Vitals => V; + public Blackboard Blackboard => BB; + public bool HasAlertState => true; + } + + static PerceptionRecipeSO MakeE001Recipe() + { + var so = ScriptableObject.CreateInstance(); + so.SetModulesForTests( + new DisguiseThenPatrol(), + new RushEngagement("e001_chase", RushExit.Committed)); + return so; + } + + [Test] + public void Graph_IsCachedAcrossCalls_Flyweight() + { + var so = MakeE001Recipe(); + Assert.AreSame(so.GetOrBuildGraph(), so.GetOrBuildGraph()); + Object.DestroyImmediate(so); + } + + [Test] + public void Graph_MatchesEquivalentHandWrittenAssembly() + { + var so = MakeE001Recipe(); + var fromRecipe = so.GetOrBuildGraph(); + + var b = new BrainBuilder(); + PerceptionSkeleton.Add(b, + new DisguiseThenPatrol(), + new RushEngagement("e001_chase", RushExit.Committed)); + var handWritten = b.Build(); + + Assert.AreEqual(handWritten.EntryState, fromRecipe.EntryState); + Object.DestroyImmediate(so); + } + + [Test] + public void Graph_BehavesLikeE001() + { + var so = MakeE001Recipe(); + var ctx = new Ctx(); + var rt = new AiRuntime(so.GetOrBuildGraph(), ctx); + Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName); + ctx.S.Chase = true; + rt.Tick(0.1f); + Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName); + Object.DestroyImmediate(so); + } + + [Test] + public void UnconfiguredRecipe_ThrowsClearError_NotSilentlyInert() + { + // 新建的空配方默认用 RushEngagement 但尚未配能力。建图时必须**显式报错**, + // 而不是静默产出一张"永远不会出手"的图——第 6 条:暴露问题,不掩盖。 + // 注:策划建完资产到配好模块之间没有任何东西会调 GetOrBuildGraph(), + // 它只在 EnemyAiBrain.Start() 运行时被调,所以这里报错不影响编辑期体验。 + var so = ScriptableObject.CreateInstance(); + var ex = Assert.Throws(() => so.GetOrBuildGraph()); + StringAssert.Contains("RushEngagement", ex.Message); + Object.DestroyImmediate(so); + } + } +} diff --git a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs.meta b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs.meta new file mode 100644 index 00000000..6ef03310 --- /dev/null +++ b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a72242c27af38274ba63c826678d375f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/AI.meta b/Assets/_Game/Scripts/Editor/AI.meta new file mode 100644 index 00000000..2fbb300b --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dae1fd2dea427e043b89785c3f824370 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs b/Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs new file mode 100644 index 00000000..15cda9d2 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using BaseGames.Enemies; + +namespace BaseGames.Editor.AI +{ + /// + /// [SerializeReference] + [SubclassSelector] 字段的下拉绘制器: + /// 反射收集该接口的所有非抽象 [Serializable] 实现,选中即替换实例。 + /// + [CustomPropertyDrawer(typeof(SubclassSelectorAttribute))] + public sealed class SubclassSelectorDrawer : PropertyDrawer + { + static readonly Dictionary _cache = new Dictionary(); + + public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) + { + if (property.propertyType != SerializedPropertyType.ManagedReference) + { + EditorGUI.PropertyField(position, property, label, true); + return; + } + + var baseType = GetManagedReferenceFieldType(property); + var options = GetImplementations(baseType); + + var line = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); + string currentName = ShortName(property.managedReferenceFullTypename); + int currentIndex = Array.FindIndex(options, t => t.Name == currentName); + + var labels = options.Select(t => new GUIContent(t.Name)).ToArray(); + EditorGUI.LabelField(line, label); + int picked = EditorGUI.Popup( + new Rect(line.x + EditorGUIUtility.labelWidth + 2f, line.y, + line.width - EditorGUIUtility.labelWidth - 2f, line.height), + currentIndex, labels); + + if (picked >= 0 && picked != currentIndex) + { + property.managedReferenceValue = Activator.CreateInstance(options[picked]); + property.serializedObject.ApplyModifiedProperties(); + } + + EditorGUI.indentLevel++; + float y = position.y + EditorGUIUtility.singleLineHeight + 2f; + foreach (var child in Children(property)) + { + float h = EditorGUI.GetPropertyHeight(child, true); + EditorGUI.PropertyField(new Rect(position.x, y, position.width, h), child, true); + y += h + 2f; + } + EditorGUI.indentLevel--; + } + + public override float GetPropertyHeight(SerializedProperty property, GUIContent label) + { + float h = EditorGUIUtility.singleLineHeight + 2f; + if (property.propertyType == SerializedPropertyType.ManagedReference) + foreach (var child in Children(property)) + h += EditorGUI.GetPropertyHeight(child, true) + 2f; + return h; + } + + static IEnumerable Children(SerializedProperty property) + { + var it = property.Copy(); + var end = it.GetEndProperty(); + if (!it.NextVisible(true)) yield break; + while (!SerializedProperty.EqualContents(it, end)) + { + yield return it.Copy(); + if (!it.NextVisible(false)) break; + } + } + + static string ShortName(string managedReferenceFullTypename) + { + if (string.IsNullOrEmpty(managedReferenceFullTypename)) return null; + int dot = managedReferenceFullTypename.LastIndexOf('.'); + return dot >= 0 ? managedReferenceFullTypename.Substring(dot + 1) + : managedReferenceFullTypename.Split(' ').Last(); + } + + static Type GetManagedReferenceFieldType(SerializedProperty property) + { + // 形如 "Assembly TypeFullName" + string full = property.managedReferenceFieldTypename; + var parts = full.Split(' '); + if (parts.Length != 2) return typeof(object); + return AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == parts[0])?.GetType(parts[1]) ?? typeof(object); + } + + static Type[] GetImplementations(Type baseType) + { + if (_cache.TryGetValue(baseType, out var cached)) return cached; + var found = AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty(); } }) + .Where(t => t != null && !t.IsAbstract && !t.IsInterface + && baseType.IsAssignableFrom(t) + && t.IsDefined(typeof(SerializableAttribute), false) + && t.GetConstructor(Type.EmptyTypes) != null) + .OrderBy(t => t.Name) + .ToArray(); + _cache[baseType] = found; + return found; + } + } +} diff --git a/Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs.meta b/Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs.meta new file mode 100644 index 00000000..b2866c09 --- /dev/null +++ b/Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ee0f164717b32fb4d8e37bd6ca4d1370 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs new file mode 100644 index 00000000..7476cb29 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs @@ -0,0 +1,10 @@ +using UnityEngine; + +namespace BaseGames.Enemies +{ + /// + /// 标在 [SerializeReference] 字段上,Inspector 里给出该接口所有 [Serializable] 实现的下拉。 + /// 意义:新增一个模块类,它自动出现在所有配方的下拉里,无需改枚举或任何已有文件。 + /// + public sealed class SubclassSelectorAttribute : PropertyAttribute { } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs.meta new file mode 100644 index 00000000..1437e75e --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/SubclassSelectorAttribute.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c3ac40bfbd1059347b817809c1347d6d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Recipes.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Recipes.meta new file mode 100644 index 00000000..16c0227d --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Recipes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fc5a433d5f1395e458987409609cc01e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs new file mode 100644 index 00000000..1f91684d --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs @@ -0,0 +1,32 @@ +using UnityEngine; +using BaseGames.AI; + +namespace BaseGames.Enemies +{ + /// + /// 感知型 AI 配方:一个 SO 类型覆盖所有走「未发现 → 警觉 → 交战」规则的敌人。 + /// 两个下拉各选一个模块即可,无需写代码。有独门机制的敌人改写 AiScript 子类。 + /// + [CreateAssetMenu(menuName = "BaseGames/AI/感知型 AI 配方", fileName = "ENM_")] + public sealed class PerceptionRecipeSO : AiRecipeSO + { + [Tooltip("未发现层:玩家尚未被发现时的行为形状")] + [SerializeReference, SubclassSelector] IUnawareModule _unaware = new SinglePost(); + + [Tooltip("交战层:发现玩家之后怎么打")] + [SerializeReference, SubclassSelector] IEngagementModule _engagement = new RushEngagement(); + + // 无「死亡层」字段:死亡归物理层,骨架自己声明 Death 终态。 + // PerformDeath 先 ForceState(Dead) 再发 Died 信号,此后 IsControllable 永久为假, + // 死亡链里的条件边永不被求值——所以死亡不能做成可插拔层。 + + protected override void Build(BrainBuilder b) + => PerceptionSkeleton.Add(b, _unaware, _engagement); + + /// 仅供 EditMode 测试与脚手架向导装配模块,运行时不使用。 + public void SetModulesForTests(IUnawareModule unaware, IEngagementModule engagement) + { + _unaware = unaware; _engagement = engagement; + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs.meta new file mode 100644 index 00000000..ab50fec8 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b849b67f708c3b4f9af91a9e5f2e546 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: