feat(enemy): PerceptionRecipeSO 配方资产 + SubclassSelector 模块下拉

未发现层/交战层各用 [SerializeReference]+[SubclassSelector] 下拉挑选模块,
新增模块类自动出现在下拉里,无需改枚举或已有文件。默认交战模块仍为
RushEngagement——未配能力时建图必须显式报错,而非静默产出打不出手的图。
This commit is contained in:
2026-07-29 14:46:41 +08:00
parent 04f644969b
commit 825cae7977
10 changed files with 298 additions and 0 deletions
@@ -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<PerceptionRecipeSO>();
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<PerceptionRecipeSO>();
var ex = Assert.Throws<System.InvalidOperationException>(() => so.GetOrBuildGraph());
StringAssert.Contains("RushEngagement", ex.Message);
Object.DestroyImmediate(so);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a72242c27af38274ba63c826678d375f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dae1fd2dea427e043b89785c3f824370
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using BaseGames.Enemies;
namespace BaseGames.Editor.AI
{
/// <summary>
/// [SerializeReference] + [SubclassSelector] 字段的下拉绘制器:
/// 反射收集该接口的所有非抽象 [Serializable] 实现,选中即替换实例。
/// </summary>
[CustomPropertyDrawer(typeof(SubclassSelectorAttribute))]
public sealed class SubclassSelectorDrawer : PropertyDrawer
{
static readonly Dictionary<Type, Type[]> _cache = new Dictionary<Type, Type[]>();
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<SerializedProperty> 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<Type>(); } })
.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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ee0f164717b32fb4d8e37bd6ca4d1370
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,10 @@
using UnityEngine;
namespace BaseGames.Enemies
{
/// <summary>
/// 标在 [SerializeReference] 字段上,Inspector 里给出该接口所有 [Serializable] 实现的下拉。
/// 意义:新增一个模块类,它自动出现在所有配方的下拉里,无需改枚举或任何已有文件。
/// </summary>
public sealed class SubclassSelectorAttribute : PropertyAttribute { }
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c3ac40bfbd1059347b817809c1347d6d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fc5a433d5f1395e458987409609cc01e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,32 @@
using UnityEngine;
using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>
/// 感知型 AI 配方:一个 SO 类型覆盖所有走「未发现 → 警觉 → 交战」规则的敌人。
/// 两个下拉各选一个模块即可,无需写代码。有独门机制的敌人改写 AiScript 子类。
/// </summary>
[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);
/// <summary>仅供 EditMode 测试与脚手架向导装配模块,运行时不使用。</summary>
public void SetModulesForTests(IUnawareModule unaware, IEngagementModule engagement)
{
_unaware = unaware; _engagement = engagement;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0b849b67f708c3b4f9af91a9e5f2e546
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: