Compare commits
5
Commits
5178b73637
...
9aaa41e420
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9aaa41e420 | ||
|
|
d61f10ee80 | ||
|
|
bb577f01a8 | ||
|
|
bf7899f91a | ||
|
|
1e3c060c8d |
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f007001a22b3d24dae350342c4d19c8
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f46310a8b0a8f04a92993c37c713243
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d719ed2e2c87eae4e8dd520e2df659c1
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee3a420017f129443896310d9fab256b
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 995cf2d9b4a41f840b3a41712e9b3bc0
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2c20a7b4db3cd0a4a99bcf6218f92860
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -4,6 +4,7 @@
|
||||
"references": [
|
||||
"BaseGames.Combat.StatusEffects",
|
||||
"BaseGames.Combat",
|
||||
"BaseGames.Parry",
|
||||
"BaseGames.Core",
|
||||
"BaseGames.AI",
|
||||
"BaseGames.Enemies",
|
||||
@@ -11,6 +12,7 @@
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Core.Save",
|
||||
"BaseGames.EventChain",
|
||||
"BaseGames.Editor",
|
||||
"UnityEngine.TestRunner",
|
||||
"UnityEditor.TestRunner"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Parry;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 弹反反制接线:玩家弹反成功时,发起这次攻击的敌人必须硬直。
|
||||
///
|
||||
/// EnemyBase.ReceiveParry 早就存在且形态正确(强制 Stagger + 打断能力 + 定时恢复),
|
||||
/// 但一直零调用者——弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用。
|
||||
/// 硬直时长的权威也早就存在:ParryConfigSO.StaggerDuration,注释写着
|
||||
/// 「被弹反敌人的受击硬直时长」,同样零消费者。本次是把这两端接上,不新造语义。
|
||||
/// </summary>
|
||||
public class ParryCounterWiringTests
|
||||
{
|
||||
/// <summary>攻击方探针:记录是否被通知、以及拿到的硬直时长。</summary>
|
||||
private sealed class SpyAttacker : MonoBehaviour, IParryable
|
||||
{
|
||||
public int Calls;
|
||||
public float LastDuration = -1f;
|
||||
|
||||
public void ReceiveParry(float staggerDuration)
|
||||
{
|
||||
Calls++;
|
||||
LastDuration = staggerDuration;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>受击方:HurtBox.Awake 要在父级找到 IDamageable,否则流水线首步就返回。</summary>
|
||||
private sealed class FakeVictim : MonoBehaviour, IDamageable
|
||||
{
|
||||
public bool IsAlive => true;
|
||||
public bool IsInvincible => false;
|
||||
public int Defense => 0;
|
||||
public void TakeDamage(DamageInfo info) { }
|
||||
}
|
||||
|
||||
private GameObject _victim;
|
||||
private GameObject _attacker;
|
||||
private ParryConfigSO _config;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => LogAssert.ignoreFailingMessages = true;
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// ParrySystem 的完美弹反会改 Time.timeScale 后 yield,编辑模式下协程不会恢复。
|
||||
// 本用例已用配置避开那条路径,这里再兜一道,避免任何意外把编辑器留在慢放状态。
|
||||
Time.timeScale = 1f;
|
||||
|
||||
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
|
||||
// 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。
|
||||
if (_victim != null) Object.DestroyImmediate(_victim);
|
||||
if (_attacker != null) Object.DestroyImmediate(_attacker);
|
||||
if (_config != null) Object.DestroyImmediate(_config);
|
||||
_victim = null; _attacker = null; _config = null;
|
||||
LogAssert.ignoreFailingMessages = false;
|
||||
}
|
||||
|
||||
// 反射只用来「喂配置」与「跑真实的 Awake」,被测逻辑全程走生产代码。
|
||||
// 与 BossPhaseAbilityGateTests 里给私有序列化字段喂数据是同一手法。
|
||||
private static void SetPrivateField(object target, string field, object value)
|
||||
=> target.GetType()
|
||||
.GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.SetValue(target, value);
|
||||
|
||||
private static void InvokeAwake(object target)
|
||||
=> target.GetType()
|
||||
.GetMethod("Awake", BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Invoke(target, null);
|
||||
|
||||
/// <summary>建一个「弹反窗口已开启」的受击方。</summary>
|
||||
private HurtBox MakeParryingVictim()
|
||||
{
|
||||
_config = ScriptableObject.CreateInstance<ParryConfigSO>();
|
||||
// 完美弹反会 StartCoroutine 改 timeScale;窗口刚开时 elapsed==0 必然判定为完美。
|
||||
// 阈值设为负数即可让 IsInPerfectWindow 恒假,把用例限制在普通弹反这条路径上。
|
||||
_config.PerfectParryThreshold = -1f;
|
||||
_config.StaggerDuration = 1.25f; // 与默认值不同,才能证明读的是配置而非常量
|
||||
|
||||
_victim = new GameObject("victim");
|
||||
_victim.AddComponent<BoxCollider2D>(); // HurtBox 的 RequireComponent 指向抽象 Collider2D
|
||||
_victim.AddComponent<FakeVictim>();
|
||||
var hurtBox = _victim.AddComponent<HurtBox>();
|
||||
Assert.IsNotNull(hurtBox, "HurtBox 需要先有具体 Collider2D 才能挂载");
|
||||
InvokeAwake(hurtBox); // 解析 _owner 并建 HurtBoxOwnerGuard
|
||||
|
||||
var parry = _victim.AddComponent<ParrySystem>();
|
||||
SetPrivateField(parry, "_config", _config);
|
||||
hurtBox.SetParrySystem(parry);
|
||||
parry.OpenParryWindow(); // 生产 API:动画事件走的就是这个
|
||||
Assert.IsTrue(parry.IsParrying, "前提:弹反窗口必须已开启,否则测不到弹反分支");
|
||||
|
||||
return hurtBox;
|
||||
}
|
||||
|
||||
private static DamageInfo ParryableHit(IParryable attacker)
|
||||
=> new DamageInfo.Builder()
|
||||
.SetRaw(10)
|
||||
.SetFlags(DamageFlags.CanBeParried)
|
||||
.SetAttacker(attacker)
|
||||
.Build();
|
||||
|
||||
[Test]
|
||||
public void ParrySuccess_StaggersTheAttacker_WithConfiguredDuration()
|
||||
{
|
||||
var hurtBox = MakeParryingVictim();
|
||||
_attacker = new GameObject("attacker");
|
||||
var spy = _attacker.AddComponent<SpyAttacker>();
|
||||
|
||||
hurtBox.ReceiveDamage(ParryableHit(spy));
|
||||
|
||||
Assert.AreEqual(1, spy.Calls,
|
||||
"弹反成功必须反制发起这次攻击的那一方——这正是此前缺失的那根线");
|
||||
Assert.AreEqual(_config.StaggerDuration, spy.LastDuration,
|
||||
"硬直时长要取 ParryConfigSO.StaggerDuration,不得另立常量");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParrySuccess_WithoutAttacker_DoesNotThrow()
|
||||
{
|
||||
// 陷阱 / 环境伤害等旁路没有攻击者,弹反它们不该炸。
|
||||
var hurtBox = MakeParryingVictim();
|
||||
|
||||
Assert.DoesNotThrow(() => hurtBox.ReceiveDamage(ParryableHit(null)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EnemyBase_IsParryable_AndParryForcesStagger()
|
||||
{
|
||||
// 接收端:EnemyBase 必须能被 Combat 层当作 IParryable 拿到
|
||||
// (Combat 不能反向依赖 Enemies,所以接缝只能是接口)。
|
||||
_attacker = new GameObject("enemy");
|
||||
var enemy = _attacker.AddComponent<EnemyBase>();
|
||||
|
||||
Assert.IsInstanceOf<IParryable>(enemy, "EnemyBase 应实现 IParryable");
|
||||
|
||||
((IParryable)enemy).ReceiveParry(0.5f);
|
||||
|
||||
Assert.AreEqual(EnemyStateType.Stagger, enemy.CurrentState,
|
||||
"被弹反应强制进入 Stagger——期间 IsControllable 为假,AI 决策自动让位");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9056219a9fc88404e8384fea384d6945
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,119 @@
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Pool;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 对象池必须真正通知 <see cref="IPoolable"/>。
|
||||
///
|
||||
/// IPoolable 的接口文档写着「由 PooledObject 子类或同 GameObject 上的其他 MonoBehaviour 实现,
|
||||
/// 并在 PooledObject.OnSpawn/OnDespawn 中手动驱动」,但 PooledObject.OnSpawn/OnDespawn 是空的
|
||||
/// public virtual、全库无任何子类,GlobalObjectPool 也从不查找 IPoolable 组件——
|
||||
/// 于是这份契约从未生效。
|
||||
///
|
||||
/// 具体后果:EnemyBase(全库唯一的 IPoolable 实现者)死亡时 PerformDeath 会 ForceState(Dead)
|
||||
/// 并禁用全部碰撞体,然后归池;除 EnemyRespawner 手动补调 OnSpawn 外,其余取用方
|
||||
/// (EnemySpawnerOnEvent / ChaoFengBoss 召唤 / RangedEnemy)拿到的复用敌人仍停在 Dead 态、
|
||||
/// 碰撞体全关——一个不可交互也不会动的幽灵。
|
||||
/// </summary>
|
||||
public class PooledObjectForwardingTests
|
||||
{
|
||||
/// <summary>记录通知次数与「被通知时对象是否还活跃」的探针。</summary>
|
||||
private sealed class SpyPoolable : MonoBehaviour, IPoolable
|
||||
{
|
||||
public int SpawnCalls;
|
||||
public int DespawnCalls;
|
||||
public bool WasActiveOnDespawn;
|
||||
|
||||
public void OnSpawn() => SpawnCalls++;
|
||||
public void OnDespawn()
|
||||
{
|
||||
DespawnCalls++;
|
||||
WasActiveOnDespawn = gameObject.activeSelf;
|
||||
}
|
||||
}
|
||||
|
||||
private GameObject _host;
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
|
||||
// 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。
|
||||
if (_host != null) Object.DestroyImmediate(_host);
|
||||
_host = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按生产路径建对象:两条创建路径(预热 EnqueueNew 与按需 Instantiate)
|
||||
/// 都在首次 Spawn 前调 Setup,所以 Setup 是可靠的解析时机。
|
||||
/// </summary>
|
||||
private (PooledObject po, SpyPoolable spy) MakePooled()
|
||||
{
|
||||
_host = new GameObject("pooled");
|
||||
var po = _host.AddComponent<PooledObject>();
|
||||
var spy = _host.AddComponent<SpyPoolable>();
|
||||
po.Setup("TEST_Key", null);
|
||||
return (po, spy);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnSpawn_NotifiesPoolableOnSameGameObject()
|
||||
{
|
||||
var (po, spy) = MakePooled();
|
||||
|
||||
po.OnSpawn();
|
||||
|
||||
Assert.AreEqual(1, spy.SpawnCalls,
|
||||
"池取出对象时必须通知 IPoolable,否则复用的敌人带着上一条命的死亡状态出场");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnDespawn_NotifiesPoolableOnSameGameObject()
|
||||
{
|
||||
var (po, spy) = MakePooled();
|
||||
|
||||
po.OnDespawn();
|
||||
|
||||
Assert.AreEqual(1, spy.DespawnCalls,
|
||||
"归池时必须通知 IPoolable,否则 EnemyBase.OnDespawn 里的清理(_nav.Stop)永远不执行");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Notification_IsScopedToOwnGameObject_NotChildren()
|
||||
{
|
||||
// IPoolable 的接口文档限定为「同 GameObject 上的其他 MonoBehaviour」。
|
||||
// 不向下扫子物体:嵌套的可池化对象会因此被两个 PooledObject 各通知一次。
|
||||
var (po, _) = MakePooled();
|
||||
var childGo = new GameObject("child");
|
||||
childGo.transform.SetParent(_host.transform);
|
||||
var childSpy = childGo.AddComponent<SpyPoolable>();
|
||||
|
||||
po.OnSpawn();
|
||||
po.OnDespawn();
|
||||
|
||||
Assert.AreEqual(0, childSpy.SpawnCalls, "子物体上的 IPoolable 不在本组件职责内");
|
||||
Assert.AreEqual(0, childSpy.DespawnCalls, "子物体上的 IPoolable 不在本组件职责内");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Despawn_NotifiesBeforeDeactivating()
|
||||
{
|
||||
// 清理要在对象还活跃时跑(可用协程、可读组件状态),
|
||||
// 且 PooledObject.ForceReturnToPool 本来就是「先 OnDespawn 再 SetActive(false)」。
|
||||
// GlobalObjectPool.Despawn 顺序相反,两条归池路径给出的契约必须一致。
|
||||
var (po, spy) = MakePooled();
|
||||
var pool = new GameObject("pool").AddComponent<GlobalObjectPool>();
|
||||
|
||||
pool.Despawn("TEST_Key", po);
|
||||
|
||||
Assert.AreEqual(1, spy.DespawnCalls);
|
||||
Assert.IsTrue(spy.WasActiveOnDespawn,
|
||||
"OnDespawn 必须在 SetActive(false) 之前调用,否则清理跑在一个已停用的对象上");
|
||||
Assert.IsFalse(po.gameObject.activeSelf, "通知之后仍然要停用对象");
|
||||
|
||||
Object.DestroyImmediate(pool.gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f2987c38d1c03d74db0a93f908048e3a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b6d8451861294544a83ef2430a0932fe
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using BaseGames.Editor.Modules;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.EditorTools
|
||||
{
|
||||
/// <summary>
|
||||
/// 能力总览表格的取数与排序。UI 构建不可测,所以把「一行长什么样」「怎么排序」
|
||||
/// 「问题从哪来」抽成纯函数放这里——表格真正会出错的也正是这三处。
|
||||
/// </summary>
|
||||
public class EnemyAbilityOverviewTests
|
||||
{
|
||||
private readonly List<EnemyAbilitySO> _created = new List<EnemyAbilitySO>();
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
|
||||
// 清理必须放在 TearDown,否则失败一次就往内存里漏一个 SO。
|
||||
foreach (var so in _created) if (so != null) Object.DestroyImmediate(so);
|
||||
_created.Clear();
|
||||
}
|
||||
|
||||
private EnemyAbilitySO MakeAbility(
|
||||
string id, AbilityCategory category = AbilityCategory.None,
|
||||
float range = 1f, float weight = 1f, float cooldown = 1f,
|
||||
int priority = 0, string exclusion = "")
|
||||
{
|
||||
var so = ScriptableObject.CreateInstance<EnemyAbilitySO>();
|
||||
so.abilityId = id;
|
||||
so.category = category;
|
||||
so.rangeRadius = range;
|
||||
so.weight = weight;
|
||||
so.cooldown = cooldown;
|
||||
so.priority = priority;
|
||||
so.exclusionGroup = exclusion;
|
||||
_created.Add(so);
|
||||
return so;
|
||||
}
|
||||
|
||||
// ── 归属提取 ─────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void OwnerFromPath_TakesEnemyFolderName()
|
||||
{
|
||||
// 能力资产按 AssetFolderSpec 落在 Data/Enemies/{敌人}/Abilities/ 下,
|
||||
// 归属列靠路径推导——表格要横向对比,必须能一眼看出这招是谁的。
|
||||
Assert.AreEqual("ChaoFeng", EnemyAbilityOverview.OwnerFromPath(
|
||||
"Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Intro.asset"));
|
||||
Assert.AreEqual("E004", EnemyAbilityOverview.OwnerFromPath(
|
||||
"Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Bite.asset"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OwnerFromPath_OffSpecLayout_ReturnsPlaceholder()
|
||||
{
|
||||
// 放错目录的资产不该让整张表崩掉,但也不能假装它有归属——显示占位符,
|
||||
// 让"这个资产没按规范放"本身在表里可见。
|
||||
Assert.AreEqual(EnemyAbilityOverview.UnknownOwner,
|
||||
EnemyAbilityOverview.OwnerFromPath("Assets/Random/ABL_Stray.asset"));
|
||||
Assert.AreEqual(EnemyAbilityOverview.UnknownOwner,
|
||||
EnemyAbilityOverview.OwnerFromPath(""));
|
||||
}
|
||||
|
||||
// ── 行取数 ───────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void BuildRow_CarriesTunableFields()
|
||||
{
|
||||
var so = MakeAbility("blink_strike", AbilityCategory.Attack,
|
||||
range: 3.5f, weight: 2f, cooldown: 4.25f,
|
||||
priority: 7, exclusion: "melee");
|
||||
|
||||
var row = EnemyAbilityOverview.BuildRow(
|
||||
so, "Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Blink.asset");
|
||||
|
||||
Assert.AreEqual("E004", row.Owner);
|
||||
Assert.AreEqual("blink_strike", row.Id);
|
||||
Assert.AreEqual(AbilityCategory.Attack, row.Category);
|
||||
Assert.AreEqual(3.5f, row.Range);
|
||||
Assert.AreEqual(2f, row.Weight);
|
||||
Assert.AreEqual(4.25f, row.Cooldown);
|
||||
Assert.AreEqual(7, row.Priority);
|
||||
Assert.AreEqual("melee", row.Exclusion);
|
||||
Assert.AreSame(so, row.Asset, "行必须持有资产引用,点选才能跳到它");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildRow_SurfacesIssue_FromAssetsOwnValidate()
|
||||
{
|
||||
// 问题判定不在表格里另写一份规则,而是调资产自己的 Validate()。
|
||||
// 这条断言守住"规则只有一处"——否则 SO 侧改了规则,表格会继续按旧规则报。
|
||||
var broken = MakeAbility("bad_attack", AbilityCategory.Attack, range: 0f);
|
||||
var ok = MakeAbility("good_attack", AbilityCategory.Attack, range: 2f);
|
||||
|
||||
var brokenRow = EnemyAbilityOverview.BuildRow(broken, "Assets/x/ABL_A.asset");
|
||||
var okRow = EnemyAbilityOverview.BuildRow(ok, "Assets/x/ABL_B.asset");
|
||||
|
||||
Assert.IsTrue(brokenRow.HasIssue,
|
||||
"Attack 类但射程为 0 的招永远选不中,表格必须标出来");
|
||||
Assert.IsFalse(string.IsNullOrEmpty(brokenRow.Issue), "要带上原因文本,不能只给个红点");
|
||||
Assert.IsFalse(okRow.HasIssue);
|
||||
}
|
||||
|
||||
// ── 排序 ─────────────────────────────────────────────────────────
|
||||
|
||||
[Test]
|
||||
public void Sort_ByCooldown_BothDirections()
|
||||
{
|
||||
var rows = new List<AbilityOverviewRow>
|
||||
{
|
||||
EnemyAbilityOverview.BuildRow(MakeAbility("b", cooldown: 5f), "Assets/x/B.asset"),
|
||||
EnemyAbilityOverview.BuildRow(MakeAbility("a", cooldown: 1f), "Assets/x/A.asset"),
|
||||
EnemyAbilityOverview.BuildRow(MakeAbility("c", cooldown: 3f), "Assets/x/C.asset"),
|
||||
};
|
||||
|
||||
var asc = EnemyAbilityOverview.Sort(rows, AbilityOverviewColumn.Cooldown, ascending: true);
|
||||
CollectionAssert.AreEqual(new[] { "a", "c", "b" }, asc.Select(r => r.Id).ToArray());
|
||||
|
||||
var desc = EnemyAbilityOverview.Sort(rows, AbilityOverviewColumn.Cooldown, ascending: false);
|
||||
CollectionAssert.AreEqual(new[] { "b", "c", "a" }, desc.Select(r => r.Id).ToArray());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Sort_ByOwnerThenId_IsStableAcrossEnemies()
|
||||
{
|
||||
// 默认视图按归属分组才有对比意义:同一敌人的招要挨在一起,组内按 id 稳定排列。
|
||||
var rows = new List<AbilityOverviewRow>
|
||||
{
|
||||
EnemyAbilityOverview.BuildRow(MakeAbility("bite"), "Assets/_Game/Data/Enemies/E005/Abilities/ABL_1.asset"),
|
||||
EnemyAbilityOverview.BuildRow(MakeAbility("acid"), "Assets/_Game/Data/Enemies/E004/Abilities/ABL_2.asset"),
|
||||
EnemyAbilityOverview.BuildRow(MakeAbility("appear"),"Assets/_Game/Data/Enemies/E004/Abilities/ABL_3.asset"),
|
||||
};
|
||||
|
||||
var sorted = EnemyAbilityOverview.Sort(rows, AbilityOverviewColumn.Owner, ascending: true);
|
||||
|
||||
CollectionAssert.AreEqual(
|
||||
new[] { "acid", "appear", "bite" },
|
||||
sorted.Select(r => r.Id).ToArray(),
|
||||
"同归属内按 id 排,跨归属按归属名排");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d191f982c5f0394192ac0f2a7d1172a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -12,6 +12,20 @@ namespace BaseGames.Combat
|
||||
void TakeDamage(DamageInfo info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 攻击被弹反时的反制承受方(攻击发起者实现,如 EnemyBase)。
|
||||
///
|
||||
/// 存在的理由是程序集方向:Combat 不能反向依赖 Enemies,而弹反成功的判定点
|
||||
/// (<see cref="HurtBox.ReceiveDamage"/>)是唯一同时握有弹反结果与
|
||||
/// <see cref="DamageInfo"/> 的地方,所以攻击者只能以接口形态经 DamageInfo 传进来。
|
||||
/// 与 <see cref="IDamageable"/> 同款做法。
|
||||
/// </summary>
|
||||
public interface IParryable
|
||||
{
|
||||
/// <summary>本方发起的攻击被弹反:进入硬直 <paramref name="staggerDuration"/> 秒。</summary>
|
||||
void ReceiveParry(float staggerDuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可持有霸体的实体接口。HurtBox 在 ReceiveDamage 中做等级比较。
|
||||
/// </summary>
|
||||
|
||||
@@ -46,6 +46,13 @@ namespace BaseGames.Combat
|
||||
/// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。
|
||||
/// </summary>
|
||||
[System.NonSerialized] public Projectile SourceProjectile;
|
||||
/// <summary>
|
||||
/// 发起这次攻击的一方(近战由 HitBox 在 Activate 时按宿主解析;无实现者时为 null)。
|
||||
/// 用于弹反成功时调用 ReceiveParry 让攻击者硬直——与 <see cref="SourceProjectile"/> 同构:
|
||||
/// 都是"攻击从哪来"的引用,供弹反分支反向作用于来源。
|
||||
/// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。
|
||||
/// </summary>
|
||||
[System.NonSerialized] public IParryable Attacker;
|
||||
|
||||
// ── Builder ──────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
@@ -68,6 +75,7 @@ namespace BaseGames.Combat
|
||||
private Vector2 _sourcePosition;
|
||||
private int _sourceLayer;
|
||||
private Projectile _sourceProjectile;
|
||||
private IParryable _attacker;
|
||||
|
||||
public Builder() { }
|
||||
|
||||
@@ -86,6 +94,7 @@ namespace BaseGames.Combat
|
||||
public Builder SetSourcePos(Vector2 v) { _sourcePosition = v; return this; }
|
||||
public Builder SetLayer(int v) { _sourceLayer = v; return this; }
|
||||
public Builder SetProjectile(Projectile v) { _sourceProjectile = v; return this; }
|
||||
public Builder SetAttacker(IParryable v) { _attacker = v; return this; }
|
||||
|
||||
public DamageInfo Build() => new DamageInfo
|
||||
{
|
||||
@@ -105,6 +114,7 @@ namespace BaseGames.Combat
|
||||
SourcePosition = _sourcePosition,
|
||||
SourceLayer = _sourceLayer,
|
||||
SourceProjectile = _sourceProjectile,
|
||||
Attacker = _attacker,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,7 +128,8 @@ namespace BaseGames.Combat
|
||||
Vector2 knockbackDir = default,
|
||||
Vector2 sourcePos = default,
|
||||
int sourceLayer = 0,
|
||||
Projectile sourceProjectile = null)
|
||||
Projectile sourceProjectile = null,
|
||||
IParryable attacker = null)
|
||||
{
|
||||
int baseAmt = Mathf.RoundToInt(so.BaseDamage * so.DamageMultiplier);
|
||||
return new DamageInfo
|
||||
@@ -139,6 +150,7 @@ namespace BaseGames.Combat
|
||||
SourcePosition = sourcePos,
|
||||
SourceLayer = sourceLayer,
|
||||
SourceProjectile = sourceProjectile,
|
||||
Attacker = attacker,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ namespace BaseGames.Combat
|
||||
// 宿主投射物缓存(Activate 时填入,DamageInfo.SourceProjectile 写入用)
|
||||
private Projectile _ownerProjectile;
|
||||
|
||||
// 宿主的弹反反制承受方(Activate 时填入,DamageInfo.Attacker 写入用)。
|
||||
// 弹反成功时由 HurtBox 反向调用它的 ReceiveParry。
|
||||
private IParryable _ownerParryable;
|
||||
|
||||
/// <summary>
|
||||
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
|
||||
/// ⚠️ 不存在 Activate(float duration) 重载。
|
||||
@@ -106,6 +110,9 @@ namespace BaseGames.Combat
|
||||
_attackerTransform = attacker ?? transform;
|
||||
_isActive = true;
|
||||
_ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>();
|
||||
// 攻击者的弹反反制承受方;与 _ownerRigidbody 同源解析,供 DamageInfo.Attacker 写入。
|
||||
// 放 Activate 而非 Awake:attacker 可由调用方传入,宿主要到这时才确定。
|
||||
_ownerParryable = _attackerTransform.GetComponentInParent<IParryable>();
|
||||
foreach (var col in _directColliders) col.enabled = true;
|
||||
foreach (var proxy in _proxies) proxy.SetEnabled(true);
|
||||
// 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标)
|
||||
@@ -265,7 +272,8 @@ namespace BaseGames.Combat
|
||||
knockDir,
|
||||
_attackerTransform.position,
|
||||
_attackerTransform.gameObject.layer,
|
||||
_ownerProjectile);
|
||||
_ownerProjectile,
|
||||
_ownerParryable);
|
||||
info.HitActivationId = _currentActivationId;
|
||||
|
||||
// hitPoint:优先使用触发命中的碰撞体中心在目标表面的最近点;
|
||||
|
||||
@@ -103,6 +103,9 @@ namespace BaseGames.Combat
|
||||
// 若攻击来源是投射物,按弹反者阵营反射:
|
||||
// 玩家弹反翻转阵营 Layer 与伤害目标层;敌人弹反仅反转方向
|
||||
info.SourceProjectile?.ReflectBy(transform);
|
||||
// 近战来源:让发起这次攻击的一方硬直。时长取弹反配置里的权威值,
|
||||
// 不在此另立常量。攻击者为空(陷阱 / 环境伤害等旁路)时自然跳过。
|
||||
info.Attacker?.ReceiveParry(_parrySystem.StaggerDuration);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +154,10 @@ namespace BaseGames.Core.Pool
|
||||
aliveRef.Remove(po.AliveNode);
|
||||
po.AliveNode = null;
|
||||
}
|
||||
po.gameObject.SetActive(false);
|
||||
// 先通知再停用:清理要跑在还活跃的对象上(可用协程、可读组件状态)。
|
||||
// 与 PooledObject.ForceReturnToPool 的顺序一致——两条归池路径必须给出同一份契约。
|
||||
po.OnDespawn();
|
||||
po.gameObject.SetActive(false);
|
||||
|
||||
int maxCount = _maxCounts.GetValueOrDefault(key, 0);
|
||||
int queueSize = _pools.TryGetValue(key, out var queue) ? queue.Count : 0;
|
||||
|
||||
@@ -24,14 +24,37 @@ namespace BaseGames.Core.Pool
|
||||
// 组件缓存(避免反复 GetComponent)
|
||||
private readonly Dictionary<Type, Component> _componentCache = new();
|
||||
|
||||
// 本物体上的 IPoolable 实现者。首次使用时解析一次并缓存——池化本就是为省开销,
|
||||
// 不能每次 Spawn/Despawn 都遍历一遍组件。惰性解析而非依赖 Setup:
|
||||
// 结果与解析时机无关,也就不存在"忘了调 Setup 就静默不通知"的失败模式。
|
||||
private IPoolable[] _poolables;
|
||||
private IPoolable[] Poolables => _poolables ??= GetComponents<IPoolable>();
|
||||
|
||||
public void Setup(string key, GlobalObjectPool pool)
|
||||
{
|
||||
AddressKey = key;
|
||||
_pool = pool;
|
||||
}
|
||||
|
||||
public virtual void OnSpawn() { }
|
||||
public virtual void OnDespawn(){ }
|
||||
/// <summary>
|
||||
/// 由池在取出对象后调用,转发给本物体上的所有 <see cref="IPoolable"/>。
|
||||
/// 子类覆盖时必须调 base,否则 IPoolable 实现者收不到通知。
|
||||
/// </summary>
|
||||
public virtual void OnSpawn()
|
||||
{
|
||||
var ps = Poolables;
|
||||
for (int i = 0; i < ps.Length; i++) ps[i].OnSpawn();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 由池在归还对象时调用(对象仍处于活跃状态),转发给本物体上的所有 <see cref="IPoolable"/>。
|
||||
/// 子类覆盖时必须调 base。
|
||||
/// </summary>
|
||||
public virtual void OnDespawn()
|
||||
{
|
||||
var ps = Poolables;
|
||||
for (int i = 0; i < ps.Length; i++) ps[i].OnDespawn();
|
||||
}
|
||||
|
||||
// ── 归还 API ──────────────────────────────────────────────────────
|
||||
/// <summary>立即归还到对象池。</summary>
|
||||
@@ -67,8 +90,10 @@ namespace BaseGames.Core.Pool
|
||||
|
||||
/// <summary>
|
||||
/// 可选接口:若池化对象需要在 Spawn/Despawn 时执行额外逻辑,
|
||||
/// 由 PooledObject 子类或同 GameObject 上的其他 MonoBehaviour 实现,
|
||||
/// 并在 PooledObject.OnSpawn/OnDespawn 中手动驱动。
|
||||
/// 由 PooledObject 子类或**同 GameObject 上**的其他 MonoBehaviour 实现。
|
||||
/// <see cref="PooledObject.OnSpawn"/> / <see cref="PooledObject.OnDespawn"/> 会自动转发,
|
||||
/// 调用方无需手动驱动。
|
||||
/// 只扫本物体、不向下扫子物体:嵌套的可池化对象会因此被两个 PooledObject 各通知一次。
|
||||
/// </summary>
|
||||
public interface IPoolable
|
||||
{
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 敌人能力模块 —— 全部 <see cref="EnemyAbilitySO"/> 的对比表格。
|
||||
///
|
||||
/// 本模块刻意不做成「列表 + 详情」的标准形态:能力资产按敌人分散在
|
||||
/// Data/Enemies/{敌人}/Abilities/ 下,逐个点开看不出问题;真正需要的是横向对比
|
||||
/// —— 同一敌人的招之间射程/权重/冷却是否成比例、有没有 Attack 类却射程为 0 的死招。
|
||||
/// 所以表格本身就是导航:点行即选中资产,Inspector 出现在表格下方。
|
||||
///
|
||||
/// 新建不在此模块内做:能力有类型化子类(EnemyAbilitySO 的各子类),且必须落到
|
||||
/// 对应敌人的目录,这两件事由角色向导保证(CLAUDE.md 第 2 条:不裸建)。
|
||||
/// </summary>
|
||||
public class EnemyAbilityModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
public string ModuleId => "enemyability";
|
||||
public string DisplayName => "敌人能力";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 35; // 紧随「敌人」(30)
|
||||
|
||||
private readonly List<AbilityOverviewRow> _rows = new List<AbilityOverviewRow>();
|
||||
|
||||
private AbilityOverviewColumn _sortColumn = AbilityOverviewColumn.Owner;
|
||||
private bool _sortAscending = true;
|
||||
private bool _onlyIssues;
|
||||
private bool _onlyAttack;
|
||||
|
||||
private EnemyAbilitySO _selected;
|
||||
private Label _summaryLabel;
|
||||
private VisualElement _tableHost; // 表格容器:过滤/排序变化时只重建它
|
||||
private Action<UnityEngine.Object> _onSelected;
|
||||
|
||||
// ── IDataModule ──────────────────────────────────────────────────
|
||||
|
||||
public void Initialize() => Reload();
|
||||
|
||||
public void OnActivated()
|
||||
{
|
||||
Reload();
|
||||
RebuildTable();
|
||||
UpdateSummary();
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
_onSelected = onSelected;
|
||||
|
||||
_summaryLabel = new Label();
|
||||
_summaryLabel.style.fontSize = 10;
|
||||
_summaryLabel.style.opacity = 0.7f;
|
||||
_summaryLabel.style.paddingLeft = 10;
|
||||
_summaryLabel.style.paddingTop = 8;
|
||||
_summaryLabel.style.whiteSpace = WhiteSpace.Normal;
|
||||
container.Add(_summaryLabel);
|
||||
UpdateSummary();
|
||||
|
||||
var hint = new Label("能力资产按敌人分目录存放,本页把它们并到一张表里横向对比。\n点表格中的行即可选中并编辑该资产。");
|
||||
hint.style.fontSize = 10;
|
||||
hint.style.opacity = 0.45f;
|
||||
hint.style.whiteSpace = WhiteSpace.Normal;
|
||||
hint.style.paddingLeft = 10;
|
||||
hint.style.paddingRight = 8;
|
||||
hint.style.paddingTop = 6;
|
||||
container.Add(hint);
|
||||
|
||||
var refreshBtn = new Button(() => { Reload(); RebuildTable(); UpdateSummary(); }) { text = "刷新" };
|
||||
refreshBtn.style.marginTop = 10;
|
||||
refreshBtn.style.marginLeft = 8;
|
||||
refreshBtn.style.marginRight = 8;
|
||||
container.Add(refreshBtn);
|
||||
|
||||
// 新建走向导:能力有类型化子类且必须落到对应敌人目录,裸建两者都保证不了
|
||||
var wizardBtn = new Button(OpenCharacterWizard) { text = "用角色向导新建…" };
|
||||
wizardBtn.style.marginTop = 4;
|
||||
wizardBtn.style.marginLeft = 8;
|
||||
wizardBtn.style.marginRight = 8;
|
||||
container.Add(wizardBtn);
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as EnemyAbilitySO;
|
||||
|
||||
// 过滤
|
||||
var filterRow = new VisualElement();
|
||||
filterRow.style.flexDirection = FlexDirection.Row;
|
||||
filterRow.style.flexWrap = Wrap.Wrap;
|
||||
filterRow.style.paddingLeft = 6;
|
||||
filterRow.style.paddingTop = 6;
|
||||
filterRow.style.paddingBottom = 4;
|
||||
filterRow.Add(DataHubEditorKit.MakeFilterChip("仅有问题",
|
||||
v => { _onlyIssues = v; RebuildTable(); UpdateSummary(); }));
|
||||
filterRow.Add(DataHubEditorKit.MakeFilterChip("仅 Attack 类",
|
||||
v => { _onlyAttack = v; RebuildTable(); UpdateSummary(); }));
|
||||
container.Add(filterRow);
|
||||
|
||||
_tableHost = new VisualElement();
|
||||
container.Add(_tableHost);
|
||||
RebuildTable();
|
||||
|
||||
if (_selected == null) return;
|
||||
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
container.Add(new InspectorElement(_selected));
|
||||
}
|
||||
|
||||
// ── 表格 ─────────────────────────────────────────────────────────
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
_rows.Clear();
|
||||
foreach (var so in AssetOperations.FindAll<EnemyAbilitySO>())
|
||||
_rows.Add(EnemyAbilityOverview.BuildRow(so, AssetDatabase.GetAssetPath(so)));
|
||||
}
|
||||
|
||||
private IEnumerable<AbilityOverviewRow> VisibleRows()
|
||||
{
|
||||
var rows = _rows.AsEnumerable();
|
||||
if (_onlyIssues) rows = rows.Where(r => r.HasIssue);
|
||||
if (_onlyAttack) rows = rows.Where(r => r.Category == AbilityCategory.Attack);
|
||||
return EnemyAbilityOverview.Sort(rows.ToList(), _sortColumn, _sortAscending);
|
||||
}
|
||||
|
||||
private void RebuildTable()
|
||||
{
|
||||
if (_tableHost == null) return;
|
||||
_tableHost.Clear();
|
||||
_tableHost.Add(BuildHeaderRow());
|
||||
foreach (var row in VisibleRows())
|
||||
_tableHost.Add(BuildDataRow(row));
|
||||
}
|
||||
|
||||
// 列宽(px);归属与 id 用弹性宽度,数值列固定以便纵向对齐比较
|
||||
private const int WIssue = 18, WOwner = 90, WId = 150, WCat = 70,
|
||||
WNum = 58, WExcl = 90;
|
||||
|
||||
private VisualElement BuildHeaderRow()
|
||||
{
|
||||
var header = new VisualElement();
|
||||
header.style.flexDirection = FlexDirection.Row;
|
||||
header.style.alignItems = Align.Center;
|
||||
header.style.paddingLeft = 8;
|
||||
header.style.paddingRight = 8;
|
||||
header.style.paddingBottom = 3;
|
||||
header.style.borderBottomWidth = 1;
|
||||
header.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.35f));
|
||||
|
||||
header.Add(FixedLabel("", WIssue, bold: true));
|
||||
header.Add(SortButton("归属", AbilityOverviewColumn.Owner, WOwner));
|
||||
header.Add(SortButton("能力 Id", AbilityOverviewColumn.Id, WId));
|
||||
header.Add(SortButton("类别", AbilityOverviewColumn.Category, WCat));
|
||||
header.Add(SortButton("射程", AbilityOverviewColumn.Range, WNum));
|
||||
header.Add(SortButton("权重", AbilityOverviewColumn.Weight, WNum));
|
||||
header.Add(SortButton("冷却", AbilityOverviewColumn.Cooldown, WNum));
|
||||
header.Add(SortButton("优先级", AbilityOverviewColumn.Priority, WNum));
|
||||
header.Add(SortButton("互斥组", AbilityOverviewColumn.Exclusion, WExcl));
|
||||
return header;
|
||||
}
|
||||
|
||||
private Button SortButton(string text, AbilityOverviewColumn column, int width)
|
||||
{
|
||||
string arrow = _sortColumn == column ? (_sortAscending ? " ▲" : " ▼") : "";
|
||||
var btn = new Button(() =>
|
||||
{
|
||||
if (_sortColumn == column) _sortAscending = !_sortAscending;
|
||||
else { _sortColumn = column; _sortAscending = true; }
|
||||
RebuildTable();
|
||||
})
|
||||
{ text = text + arrow };
|
||||
|
||||
btn.style.width = width;
|
||||
btn.style.flexShrink = 0;
|
||||
btn.style.fontSize = 10;
|
||||
btn.style.marginLeft = 0;
|
||||
btn.style.marginRight = 0;
|
||||
btn.style.paddingLeft = 2;
|
||||
btn.style.paddingRight = 2;
|
||||
btn.style.backgroundColor = StyleKeyword.None;
|
||||
btn.style.borderTopWidth = btn.style.borderBottomWidth = 0;
|
||||
btn.style.borderLeftWidth = btn.style.borderRightWidth = 0;
|
||||
btn.style.unityTextAlign = TextAnchor.MiddleLeft;
|
||||
return btn;
|
||||
}
|
||||
|
||||
private VisualElement BuildDataRow(AbilityOverviewRow r)
|
||||
{
|
||||
bool isSelected = _selected != null && r.Asset == _selected;
|
||||
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingTop = 2;
|
||||
row.style.paddingBottom = 2;
|
||||
row.style.paddingLeft = 8;
|
||||
row.style.paddingRight = 8;
|
||||
row.style.backgroundColor = isSelected
|
||||
? new StyleColor(new Color(0.25f, 0.5f, 1f, 0.2f))
|
||||
: StyleKeyword.None;
|
||||
|
||||
var issue = FixedLabel(r.HasIssue ? "⚠" : "", WIssue);
|
||||
if (r.HasIssue)
|
||||
{
|
||||
issue.style.color = new StyleColor(new Color(1f, 0.45f, 0.2f));
|
||||
issue.tooltip = r.Issue; // 悬停看原因,不占列宽
|
||||
}
|
||||
row.Add(issue);
|
||||
|
||||
row.Add(FixedLabel(r.Owner, WOwner));
|
||||
row.Add(FixedLabel(string.IsNullOrEmpty(r.Id) ? "(未命名)" : r.Id, WId));
|
||||
row.Add(FixedLabel(r.Category.ToString(), WCat));
|
||||
row.Add(FixedLabel(Num(r.Range), WNum));
|
||||
row.Add(FixedLabel(Num(r.Weight), WNum));
|
||||
row.Add(FixedLabel(Num(r.Cooldown), WNum));
|
||||
row.Add(FixedLabel(r.Priority.ToString(), WNum));
|
||||
row.Add(FixedLabel(string.IsNullOrEmpty(r.Exclusion) ? "-" : r.Exclusion, WExcl));
|
||||
|
||||
row.RegisterCallback<MouseDownEvent>(_ =>
|
||||
{
|
||||
_selected = r.Asset;
|
||||
_onSelected?.Invoke(r.Asset);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
private static string Num(float v) => v.ToString("0.##");
|
||||
|
||||
private static Label FixedLabel(string text, int width, bool bold = false)
|
||||
{
|
||||
var lbl = new Label(text);
|
||||
lbl.style.width = width;
|
||||
lbl.style.flexShrink = 0;
|
||||
lbl.style.fontSize = 11;
|
||||
if (bold) lbl.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
return lbl;
|
||||
}
|
||||
|
||||
private void UpdateSummary()
|
||||
{
|
||||
if (_summaryLabel == null) return;
|
||||
int total = _rows.Count;
|
||||
int issues = _rows.Count(r => r.HasIssue);
|
||||
int shown = VisibleRows().Count();
|
||||
_summaryLabel.text = issues > 0
|
||||
? $"共 {total} 个能力 · 有问题 {issues} 个 · 当前显示 {shown}"
|
||||
: $"共 {total} 个能力 · 无问题 · 当前显示 {shown}";
|
||||
}
|
||||
|
||||
private static void OpenCharacterWizard()
|
||||
{
|
||||
// 与脚手架保持单一入口:能力的类型化子类与目标目录都由向导保证
|
||||
var type = typeof(EnemyAbilityModule).Assembly.GetType("BaseGames.Editor.CharacterWizardWindow");
|
||||
if (type != null) { EditorWindow.GetWindow(type).Show(); return; }
|
||||
EditorUtility.DisplayDialog("未找到角色向导",
|
||||
"没有找到 CharacterWizardWindow。请从菜单打开角色向导创建能力资产。", "确定");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e88e43dbf88ab84a99a5469669ceb0d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>总览表格的可排序列。</summary>
|
||||
public enum AbilityOverviewColumn
|
||||
{
|
||||
Owner, Id, Category, Range, Weight, Cooldown, Priority, Exclusion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 总览表格的一行。只读快照,刷新时整体重建——能力资产改动频率低,
|
||||
/// 不值得为增量更新引入订阅。
|
||||
/// </summary>
|
||||
public readonly struct AbilityOverviewRow
|
||||
{
|
||||
public EnemyAbilitySO Asset { get; }
|
||||
/// <summary>归属敌人(由资产路径推导;不在规范目录下时为 <see cref="EnemyAbilityOverview.UnknownOwner"/>)。</summary>
|
||||
public string Owner { get; }
|
||||
public string Id { get; }
|
||||
public AbilityCategory Category { get; }
|
||||
public float Range { get; }
|
||||
public float Weight { get; }
|
||||
public float Cooldown { get; }
|
||||
public int Priority { get; }
|
||||
public string Exclusion { get; }
|
||||
/// <summary>资产自校验报出的首条问题;无问题为 null。</summary>
|
||||
public string Issue { get; }
|
||||
|
||||
public bool HasIssue => !string.IsNullOrEmpty(Issue);
|
||||
|
||||
public AbilityOverviewRow(
|
||||
EnemyAbilitySO asset, string owner, string id, AbilityCategory category,
|
||||
float range, float weight, float cooldown, int priority, string exclusion, string issue)
|
||||
{
|
||||
Asset = asset; Owner = owner; Id = id; Category = category;
|
||||
Range = range; Weight = weight; Cooldown = cooldown;
|
||||
Priority = priority; Exclusion = exclusion; Issue = issue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 能力总览表格的取数与排序(纯函数,不碰 UI)。
|
||||
/// 抽出来的理由:UIElements 构建不可单测,而表格真正会出错的是
|
||||
/// 「归属怎么推导」「问题从哪来」「排序对不对」这三处——它们都是纯逻辑。
|
||||
/// </summary>
|
||||
public static class EnemyAbilityOverview
|
||||
{
|
||||
/// <summary>资产不在 Data/Enemies/{敌人}/ 下时的归属占位符。</summary>
|
||||
public const string UnknownOwner = "—";
|
||||
|
||||
private const string EnemiesSegment = "Enemies";
|
||||
|
||||
/// <summary>
|
||||
/// 从资产路径推导归属敌人:取 Enemies 段之后的那一级目录名。
|
||||
/// 按 AssetFolderSpec,能力资产落在 Data/Enemies/{敌人}/Abilities/ 下。
|
||||
/// 不符合该布局时返回占位符,而不是猜一个名字——放错目录这件事本身
|
||||
/// 应该在表里看得见。
|
||||
/// </summary>
|
||||
public static string OwnerFromPath(string assetPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assetPath)) return UnknownOwner;
|
||||
|
||||
var parts = assetPath.Split('/');
|
||||
for (int i = 0; i < parts.Length - 1; i++)
|
||||
if (parts[i] == EnemiesSegment)
|
||||
return string.IsNullOrEmpty(parts[i + 1]) ? UnknownOwner : parts[i + 1];
|
||||
|
||||
return UnknownOwner;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把一个能力资产摊成一行。问题一列直接取资产自己的 <see cref="IValidatable.Validate"/>,
|
||||
/// 不在这里另写一份判定规则——否则 SO 侧改了规则,表格会继续按旧规则报。
|
||||
/// </summary>
|
||||
public static AbilityOverviewRow BuildRow(EnemyAbilitySO asset, string assetPath)
|
||||
{
|
||||
string issue = null;
|
||||
foreach (var r in asset.Validate()) { issue = r.Message; break; }
|
||||
|
||||
return new AbilityOverviewRow(
|
||||
asset,
|
||||
OwnerFromPath(assetPath),
|
||||
asset.abilityId,
|
||||
asset.category,
|
||||
asset.rangeRadius,
|
||||
asset.weight,
|
||||
asset.cooldown,
|
||||
asset.priority,
|
||||
asset.exclusionGroup,
|
||||
issue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按列排序。次序键固定为 abilityId:同值行的相对位置才不会每次刷新都跳动,
|
||||
/// 也让「按归属排」自然变成「同一敌人的招挨在一起、组内按 id」。
|
||||
/// </summary>
|
||||
public static List<AbilityOverviewRow> Sort(
|
||||
IReadOnlyList<AbilityOverviewRow> rows, AbilityOverviewColumn column, bool ascending = true)
|
||||
{
|
||||
IOrderedEnumerable<AbilityOverviewRow> ordered = column switch
|
||||
{
|
||||
AbilityOverviewColumn.Owner => ByText(rows, r => r.Owner, ascending),
|
||||
AbilityOverviewColumn.Id => ByText(rows, r => r.Id, ascending),
|
||||
AbilityOverviewColumn.Exclusion => ByText(rows, r => r.Exclusion, ascending),
|
||||
AbilityOverviewColumn.Category => By(rows, r => (int)r.Category, ascending),
|
||||
AbilityOverviewColumn.Range => By(rows, r => r.Range, ascending),
|
||||
AbilityOverviewColumn.Weight => By(rows, r => r.Weight, ascending),
|
||||
AbilityOverviewColumn.Cooldown => By(rows, r => r.Cooldown, ascending),
|
||||
AbilityOverviewColumn.Priority => By(rows, r => r.Priority, ascending),
|
||||
_ => ByText(rows, r => r.Id, ascending),
|
||||
};
|
||||
|
||||
return ordered.ThenBy(r => r.Id, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static IOrderedEnumerable<AbilityOverviewRow> By<TKey>(
|
||||
IEnumerable<AbilityOverviewRow> src, Func<AbilityOverviewRow, TKey> key, bool ascending)
|
||||
=> ascending ? src.OrderBy(key) : src.OrderByDescending(key);
|
||||
|
||||
// 文本列固定用序数比较:排序结果不随编辑器区域设置变化
|
||||
private static IOrderedEnumerable<AbilityOverviewRow> ByText(
|
||||
IEnumerable<AbilityOverviewRow> src, Func<AbilityOverviewRow, string> key, bool ascending)
|
||||
=> ascending
|
||||
? src.OrderBy(key, StringComparer.Ordinal)
|
||||
: src.OrderByDescending(key, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e8a611379a854f4fb285f91fad810a1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -16,7 +16,7 @@ namespace BaseGames.Enemies
|
||||
/// ⚠️ _nav 字段类型为 IEnemyNavigator(在 BaseGames.Enemies.Navigation 中实现具体类)。
|
||||
/// 实现 IPoolable:配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。
|
||||
/// </summary>
|
||||
public class EnemyBase : MonoBehaviour, IDamageable, IPoolable
|
||||
public class EnemyBase : MonoBehaviour, IDamageable, IPoolable, IParryable
|
||||
{
|
||||
[Header("标识")]
|
||||
[SerializeField] private string _enemyId; // 任务系统 / Boss 进程追踪用,如 "Enemy_SpiderGuard"
|
||||
@@ -429,8 +429,8 @@ namespace BaseGames.Enemies
|
||||
/// <summary>
|
||||
/// 被弹反时调用:强制进入 Stagger 并在 staggerDuration 秒后恢复。
|
||||
/// Stagger 期间 IsControllable 为 false,AI 决策自动让位——不需要额外的 AI 信号。
|
||||
/// ⚠️ 目前**零调用者**:弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用,
|
||||
/// 接线是一件独立任务(DamageInfo 加 attacker,或弹反成功时回调发起攻击的 HitBox)。
|
||||
/// IParryable 实现:由 HurtBox 在弹反成功时经 DamageInfo.Attacker 调用,
|
||||
/// 时长取 ParryConfigSO.StaggerDuration。
|
||||
/// </summary>
|
||||
public virtual void ReceiveParry(float staggerDuration = 0.5f)
|
||||
{
|
||||
|
||||
@@ -127,12 +127,14 @@ namespace BaseGames.Enemies
|
||||
private void SpawnEnemy()
|
||||
{
|
||||
GameObject go = null;
|
||||
bool fromPool = false;
|
||||
|
||||
// 优先:对象池
|
||||
if (!string.IsNullOrEmpty(_poolKey))
|
||||
{
|
||||
var pool = ServiceLocator.GetOrDefault<IObjectPoolService>();
|
||||
go = pool?.Spawn(_poolKey, transform.position, transform.rotation);
|
||||
fromPool = go != null;
|
||||
}
|
||||
|
||||
// 兜底:直接实例化
|
||||
@@ -152,8 +154,10 @@ namespace BaseGames.Enemies
|
||||
}
|
||||
|
||||
_activeEnemy = enemy;
|
||||
// 确保对象池复用路径也能正确重置运行时状态
|
||||
_activeEnemy.OnSpawn();
|
||||
// 池化路径的重置由 PooledObject.OnSpawn 转发 IPoolable 完成,这里不能再调一次:
|
||||
// OnSpawn 末尾会 Spawned?.Invoke(),重复触发即重复执行出生能力。
|
||||
// 兜底实例化路径没有池,需要在这里补一次。
|
||||
if (!fromPool) _activeEnemy.OnSpawn();
|
||||
_activeEnemy.OnDied += OnEnemyDied;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ namespace BaseGames.Parry
|
||||
/// <summary>启用/禁用弹反输入(玩家能力解锁前设为 false)。</summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>被本方弹反的攻击者应硬直多久(秒)。由 HurtBox 在弹反成功时读取。</summary>
|
||||
public float StaggerDuration => _config.StaggerDuration;
|
||||
|
||||
// ── C# 事件 ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user