feat(enemy): PerceptionRecipeSO 配方资产 + SubclassSelector 模块下拉
未发现层/交战层各用 [SerializeReference]+[SubclassSelector] 下拉挑选模块, 新增模块类自动出现在下拉里,无需改枚举或已有文件。默认交战模块仍为 RushEngagement——未配能力时建图必须显式报错,而非静默产出打不出手的图。
This commit is contained in:
@@ -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:
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user