merge: Boss AI 双轨统一 — 决策层与执行层一并并入小怪轨
核心洞察:Boss 侧那层"富编排"从未真正存在——全项目 0 个 AttackPatternSO/ SkillSequenceSO 资产、5 个 BossSkillSO 全空、BossSkillExecutor 跑一遍纯空转、 UseBossSkillWeighted 零调用者、Boss 预制体上没有 EnemyAiBrain。唯一的 Boss(嘲风) 实际用的就是动画事件驱动,即小怪轨的做法。所以"按复杂度分层"的前提不成立, 双轨只是让机制原语出现平行实现并已开始漂移。 - 决策归 AI 图:Boss 走 [AiDefinition] AiScript 手写图(不建 Boss 骨架/配方—— Boss 间差异远大于小怪,骨架会退化成开关面板) - 阶段 = 换招池:BossPhaseAbilityGate 按阶段启停能力组件,不在 SO 上写阶段字段 - 招走 EnemyAbilitySO + EnemyAttackSO;判定/生成/音效/无敌帧全挂动画事件 - Boss 专属只剩 IBossControl facet(非 Boss 访问即抛)+ 几个旁挂 MonoBehaviour - 删旧轨 9 个脚本与 5 个空能力资产;两条脚手架创建链路改挂新轨 - 选招器补防重复折扣(WeightedRandomAntiRepeat),取代旧轨同名逻辑 顺带修掉三个波及全体敌人的缺陷: - EnemyHurtState/EnemyStaggerState 在无对应动画 Clip 时守卫式早退却丢掉恢复安排, 敌人被打一次即永久失能(美术未接入时影响大部分敌人) - 池化复活带着上一条命的能力冷却:OnSpawn 那行 InterruptAll 的两道 IsRunning 门 让它恒为空转,注释所称的"重置冷却"从未发生 - BrainBuilder.Build() 只做正向校验,已声明却无出边的非终态会让敌人永久卡住且 零报错;现加"非终态必须有出口"校验,部分图另走 BuildPartial() 净变化:139 文件,+3808/-4211;删 10 个脚本、加 14 个。 验证:编译 0 错;EditMode 269/269。 未做:进 Play 实跑嘲风战的人工验收(嘲风动画 Clip 仍缺,卡美术资源)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
"Bash(*)",
|
||||
"mcp__unity__unity_*",
|
||||
"PowerShell(Get-Command *)",
|
||||
"PowerShell(*)"
|
||||
"PowerShell(*)",
|
||||
"WebSearch"
|
||||
],
|
||||
"dangerouslySkipPermissions": true
|
||||
},
|
||||
|
||||
@@ -11,7 +11,9 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
b.Entry("A");
|
||||
b.State("A").To("B").When(c => c.Sensor.SeesPlayer(), "SeesPlayer");
|
||||
b.State("B");
|
||||
// 回边不可省:本夹具经 GetOrBuildGraph 走生产的 Build(),
|
||||
// 它会校验「非终态必须有出口」——夹具也得是一张能自洽运转的图
|
||||
b.State("B").To("A").When(c => !c.Sensor.SeesPlayer(), "LostPlayer");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
/// BrainGraph 框架健壮性/边界测试:构图校验、转换优先级、全局转换、
|
||||
/// 事件与条件优先、队列信号、自转换、trace 上限、Blackboard、dt 传递。
|
||||
/// 与 AiRuntimeTests(主干行为)互补,覆盖易回归的边角。
|
||||
///
|
||||
/// 本文件的夹具多为「A → 桩汇态」的最小图,汇态刻意不挂出边,
|
||||
/// 因此用 BuildPartial() 构建——它跳过「非终态必须有出口」校验,
|
||||
/// 其余校验照旧。断言 Build() 抛异常的用例仍用 Build()。
|
||||
/// </summary>
|
||||
public class AiFrameworkTests
|
||||
{
|
||||
@@ -66,7 +70,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
.To("B").When(c => true, "first")
|
||||
.To("C").When(c => true, "second"); // 两者都真,先声明的 B 胜
|
||||
b.State("B"); b.State("C");
|
||||
var rt = new AiRuntime(b.Build(), new FakeAiContext());
|
||||
var rt = new AiRuntime(b.BuildPartial(), new FakeAiContext());
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("B", rt.CurrentStateName);
|
||||
}
|
||||
@@ -80,7 +84,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.Global().To("Panic").When(c => c.Vitals.HpBelow(0.3f), "lowHp");
|
||||
var ctx = new FakeAiContext();
|
||||
ctx.V.Hp = 0.1f;
|
||||
var rt = new AiRuntime(b.Build(), ctx);
|
||||
var rt = new AiRuntime(b.BuildPartial(), ctx);
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("Panic", rt.CurrentStateName);
|
||||
}
|
||||
@@ -93,7 +97,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.State("A").To("B").When(c => true, "local");
|
||||
b.State("B"); b.State("G");
|
||||
b.Global().To("G").When(c => true, "global"); // 全局先评估
|
||||
var rt = new AiRuntime(b.Build(), new FakeAiContext());
|
||||
var rt = new AiRuntime(b.BuildPartial(), new FakeAiContext());
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("G", rt.CurrentStateName);
|
||||
}
|
||||
@@ -109,7 +113,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
.To("ByCond").When(c => true, "cond") // 条件恒真
|
||||
.To("ByEvent").OnEvent(AiSignal.Died); // 事件优先(Tick 第1步)
|
||||
b.State("ByCond"); b.State("ByEvent");
|
||||
var rt = new AiRuntime(b.Build(), new FakeAiContext());
|
||||
var rt = new AiRuntime(b.BuildPartial(), new FakeAiContext());
|
||||
rt.Send(AiSignal.Died);
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("ByEvent", rt.CurrentStateName);
|
||||
@@ -124,7 +128,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.State("B");
|
||||
var ctx = new FakeAiContext();
|
||||
ctx.S.Sees = true;
|
||||
var rt = new AiRuntime(b.Build(), ctx);
|
||||
var rt = new AiRuntime(b.BuildPartial(), ctx);
|
||||
rt.Send(AiSignal.Died); // A 无 Died 转换 → 被消费掉,不阻塞条件评估
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("B", rt.CurrentStateName);
|
||||
@@ -141,7 +145,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.State("A").To("B").OnEvent(AiSignal.Died);
|
||||
b.State("B").OnEnter(c => c.Blackboard.Set("enters", c.Blackboard.Get<int>("enters") + 1));
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(b.Build(), ctx);
|
||||
var rt = new AiRuntime(b.BuildPartial(), ctx);
|
||||
rt.Send(AiSignal.Died);
|
||||
rt.Send(AiSignal.Died);
|
||||
rt.Tick(0.1f);
|
||||
@@ -162,7 +166,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
.OnEnter(c => c.Blackboard.Set("enters", c.Blackboard.Get<int>("enters") + 1))
|
||||
.To("A").When(c => true, "self"); // 自转换
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(b.Build(), ctx); // 构造进入 → enters=1
|
||||
var rt = new AiRuntime(b.BuildPartial(), ctx); // 构造进入 → enters=1
|
||||
rt.Tick(0.1f); // 自转换应 no-op,不重跑 OnEnter
|
||||
Assert.AreEqual(1, ctx.BB.Get<int>("enters"));
|
||||
Assert.AreEqual("A", rt.CurrentStateName);
|
||||
@@ -178,7 +182,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
.To("B").When(c => true, "go");
|
||||
b.State("B");
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(b.Build(), ctx);
|
||||
var rt = new AiRuntime(b.BuildPartial(), ctx);
|
||||
rt.Tick(0.1f); // 转换发生 → 当帧不应执行 A 的 OnTick
|
||||
Assert.AreEqual("B", rt.CurrentStateName);
|
||||
Assert.IsFalse(ctx.BB.Has("ticked"));
|
||||
@@ -191,7 +195,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.Entry("A");
|
||||
b.State("A").Tick((c, dt) => c.Blackboard.Set("dt", dt)); // 无转换
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(b.Build(), ctx);
|
||||
var rt = new AiRuntime(b.BuildPartial(), ctx);
|
||||
rt.Tick(0.25f);
|
||||
Assert.AreEqual(0.25f, ctx.BB.Get<float>("dt"), 1e-5f);
|
||||
}
|
||||
@@ -221,7 +225,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.Global().To("Dead").OnEvent(AiSignal.Died);
|
||||
var ctx = new FakeAiContext();
|
||||
ctx.V.Controllable = false; // 受击中(若无事件本会挂起)
|
||||
var rt = new AiRuntime(b.Build(), ctx);
|
||||
var rt = new AiRuntime(b.BuildPartial(), ctx);
|
||||
rt.Send(AiSignal.Died);
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("Dead", rt.CurrentStateName);
|
||||
@@ -236,7 +240,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.State("A").To("B").When(c => false, "t1"); // 第一次声明
|
||||
b.State("A").To("C").When(c => true, "t2"); // 再次 State("A") 应取同一状态,累加转换
|
||||
b.State("B"); b.State("C");
|
||||
var graph = b.Build();
|
||||
var graph = b.BuildPartial();
|
||||
Assert.AreEqual(2, graph.GetState("A").Transitions.Count);
|
||||
var rt = new AiRuntime(graph, new FakeAiContext());
|
||||
rt.Tick(0.1f); // t1 假、t2 真 → C
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
.To("Search").When(c => c.Sensor.LostFor(2f), "LostFor(2s)");
|
||||
b.State("Search")
|
||||
.To("Patrol").After(3f);
|
||||
b.State("Dead");
|
||||
b.State("Dead").Terminal();
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.Entry("Start");
|
||||
b.State("Start").OnEnter(c => c.Locomotion.SetMode(LocomotionMode.Idle));
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(b.Build(), ctx);
|
||||
var rt = new AiRuntime(b.BuildPartial(), ctx); // 只验 OnEnter 的单态夹具,不是完整图
|
||||
CollectionAssert.Contains(ctx.L.Calls, "SetMode:Idle");
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.State("Idle").To("Flee").OnEvent(AiSignal.Died);
|
||||
b.State("Flee");
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(b.Build(), ctx);
|
||||
var rt = new AiRuntime(b.BuildPartial(), ctx); // Flee 是夹具桩,不建完整图
|
||||
rt.Send(AiSignal.Died);
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual("Flee", rt.CurrentStateName);
|
||||
@@ -180,7 +180,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
var b = new BrainBuilder();
|
||||
b.Entry("Dead");
|
||||
int enterCount = 0;
|
||||
b.State("Dead").OnEnter(c => enterCount++);
|
||||
b.State("Dead").OnEnter(c => enterCount++).Terminal();
|
||||
b.Global().To("Dead").OnEvent(AiSignal.Died);
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(b.Build(), ctx);
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
BuildCount++;
|
||||
b.Entry("A");
|
||||
b.State("A").To("B").When(c => c.Sensor.SeesPlayer(), "SeesPlayer");
|
||||
b.State("B");
|
||||
// 回边不可省:GetOrBuildGraph 走生产的 Build(),会校验「非终态必须有出口」
|
||||
b.State("B").To("A").When(c => !c.Sensor.SeesPlayer(), "LostPlayer");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
var b = new BrainBuilder();
|
||||
b.Entry("S");
|
||||
build(b);
|
||||
return new AiRuntime(b.Build(), ctx);
|
||||
// 片段单独建图:被验的态就是唯一的态,没有出边是这类夹具的常态
|
||||
return new AiRuntime(b.BuildPartial(), ctx);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
AiStateFragments.Locomotion(b, Rest, LocomotionMode.Patrol);
|
||||
m.Build(b, Rest);
|
||||
b.Entry(m.EntryState);
|
||||
return new AiRuntime(b.Build(), ctx);
|
||||
// 单独跑交战模块:Rest 的升级边由骨架挂,这里缺它是预期的部分图
|
||||
return new AiRuntime(b.BuildPartial(), ctx);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using NUnit.Framework;
|
||||
using BaseGames.AI;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// Build() 必须挡住「非终态却没有出口」的状态。
|
||||
///
|
||||
/// 既有校验只做正向——边指向的态是否已声明(RequireState 的同款关切);
|
||||
/// 反向从不检查。一个已声明却没有任何出边的非终态会让敌人永久停在那里,
|
||||
/// 且全程零报错,与 RequireState 要防的静默失败是同一类,只是方向相反。
|
||||
/// </summary>
|
||||
public class BrainBuilderDeadEndTests
|
||||
{
|
||||
[Test]
|
||||
public void Build_NonTerminalStateWithNoOutgoing_Throws()
|
||||
{
|
||||
var b = new BrainBuilder();
|
||||
b.Entry("A");
|
||||
b.DeclareState("A").To("B").When(c => true, "go");
|
||||
b.DeclareState("B"); // 忘了挂出边
|
||||
|
||||
var ex = Assert.Throws<System.InvalidOperationException>(() => b.Build());
|
||||
StringAssert.Contains("B", ex.Message, "报错必须点名是哪个态,否则大图里无从查起");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_TerminalState_IsAllowedToHaveNoOutgoing()
|
||||
{
|
||||
var b = new BrainBuilder();
|
||||
b.Entry("A");
|
||||
b.DeclareState("A").To("Dead").When(c => true, "die");
|
||||
b.DeclareState("Dead").Terminal();
|
||||
|
||||
Assert.DoesNotThrow(() => b.Build());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_StateWithOnlySelfTransition_Throws()
|
||||
{
|
||||
// 自转换在 AiRuntime.Switch 里是 no-op(Target == 当前态直接 return false),
|
||||
// 出不去——等同于没有出边,不能算作出口。
|
||||
var b = new BrainBuilder();
|
||||
b.Entry("A");
|
||||
b.DeclareState("A").To("B").When(c => true, "go");
|
||||
b.DeclareState("B").To("B").When(c => true, "self");
|
||||
|
||||
var ex = Assert.Throws<System.InvalidOperationException>(() => b.Build());
|
||||
StringAssert.Contains("B", ex.Message);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Build_GlobalTransition_DoesNotCountAsOutgoing()
|
||||
{
|
||||
// 全局事件边要外部推信号才触发,不是自主出口;把它算作出口,
|
||||
// 等于让「只有死了才出得去」的死角通过校验——那正是要暴露的东西。
|
||||
var b = new BrainBuilder();
|
||||
b.Entry("A");
|
||||
b.DeclareState("A").To("B").When(c => true, "go");
|
||||
b.DeclareState("B");
|
||||
b.DeclareState("Dead").Terminal();
|
||||
b.Global().To("Dead").OnEvent(AiSignal.Died);
|
||||
|
||||
Assert.Throws<System.InvalidOperationException>(() => b.Build());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AiStateFragments_Terminal_MarksStateAsTerminal()
|
||||
{
|
||||
// 生产入口:骨架与 Boss 图都经这个片段声明死亡终态,
|
||||
// 它必须自带标记,否则每个图都要额外记得手动标一次。
|
||||
var b = new BrainBuilder();
|
||||
b.Entry("A");
|
||||
b.DeclareState("A").To("Death").When(c => true, "die");
|
||||
AiStateFragments.Terminal(b, "Death");
|
||||
|
||||
Assert.DoesNotThrow(() => b.Build());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d47145d394333184eb3ff822e3c4aa4d
|
||||
guid: 282b4a740e6e4f84ca26c5dbed5aee91
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -14,7 +14,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
.To("Chase").When(c => c.Sensor.SeesPlayer(), "SeesPlayer");
|
||||
b.State("Chase")
|
||||
.To("Patrol").When(c => c.Sensor.LostFor(2f), "LostFor(2s)");
|
||||
b.State("Dead");
|
||||
b.State("Dead").Terminal();
|
||||
return b.Build();
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.Entry("A");
|
||||
b.State("A").To("B").When(c => true);
|
||||
b.State("B");
|
||||
var g = b.Build();
|
||||
var g = b.BuildPartial(); // B 是桩汇态,本例只验标签回退
|
||||
Assert.AreEqual("cond", g.GetState("A").Transitions[0].Label);
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
b.Entry("A");
|
||||
b.DeclareState("A").To("B").When(c => true, "go");
|
||||
b.DeclareState("B");
|
||||
Assert.DoesNotThrow(() => b.Build());
|
||||
Assert.DoesNotThrow(() => b.BuildPartial()); // 只验 builder 可用,B 是桩汇态
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using NUnit.Framework;
|
||||
using BaseGames.AI;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 嘲风定制图的状态序列断言。
|
||||
///
|
||||
/// 注意 <see cref="AiRuntime.Send"/> 只入队信号,事件转换在下一次 Tick 的第一步出队处理,
|
||||
/// 因此每个 Send 之后都必须跟一次 Tick 才会真正换态(不是"发了就到")。
|
||||
/// </summary>
|
||||
public class ChaoFengAiTests
|
||||
{
|
||||
static AiRuntime Run(FakeAiContext ctx)
|
||||
=> new AiRuntime(new ChaoFengAi().GetOrBuildGraph(), ctx);
|
||||
|
||||
static FakeAiContext Ctx()
|
||||
{
|
||||
var c = new FakeAiContext();
|
||||
c.S.Chase = true; // 竞技场内玩家恒在追逐区
|
||||
c.S.Vision = true;
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>推进到地面阶段:开战 → 入场演出播完 → 地面逼近态。</summary>
|
||||
static AiRuntime RunToGround(FakeAiContext ctx)
|
||||
{
|
||||
var rt = Run(ctx);
|
||||
rt.Send(AiSignal.Engaged);
|
||||
rt.Tick(0.1f); // 事件出队 → Intro
|
||||
ctx.C.Running = null; // 入场演出播完
|
||||
rt.Tick(0.1f); // introDone → Ground
|
||||
return rt;
|
||||
}
|
||||
|
||||
/// <summary>再推进到空中阶段:血量过半 → 阶段过渡 → 过渡结束。</summary>
|
||||
static void AdvanceToAirPhase(FakeAiContext ctx, AiRuntime rt)
|
||||
{
|
||||
ctx.V.Hp = 0.4f;
|
||||
rt.Tick(0.1f); // hp<50% → PhaseTx
|
||||
ctx.B.Transitioning = false; // 过渡结束
|
||||
rt.Tick(0.1f); // txDone → Air
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StartsInWait_UntilEngaged()
|
||||
{
|
||||
var ctx = Ctx();
|
||||
var rt = Run(ctx);
|
||||
|
||||
Assert.AreEqual(ChaoFengAi.Wait, rt.CurrentStateName);
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Wait, rt.CurrentStateName, "未开战前不应自行进入战斗");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Engaged_EntersIntro_ThenGround()
|
||||
{
|
||||
var ctx = Ctx();
|
||||
var rt = Run(ctx);
|
||||
|
||||
rt.Send(AiSignal.Engaged);
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Intro, rt.CurrentStateName);
|
||||
CollectionAssert.Contains(ctx.C.Used, ChaoFengAi.IntroAbility);
|
||||
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Intro, rt.CurrentStateName, "入场演出未播完不应进战");
|
||||
|
||||
ctx.C.Running = null; // 入场演出播完
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ground_ApproachAttackLoop()
|
||||
{
|
||||
var ctx = Ctx();
|
||||
var rt = RunToGround(ctx);
|
||||
Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName);
|
||||
|
||||
ctx.C.Eligible = true;
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.GroundAttack, rt.CurrentStateName);
|
||||
CollectionAssert.Contains(ctx.C.Used, "best");
|
||||
|
||||
ctx.C.Running = null; // 招式打完
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ground_DoesNotDisengage_WhenPlayerLeavesAllZones()
|
||||
{
|
||||
// Boss 不脱战:逼近态的 rest 指回自身,脱离感知区也留在地面阶段。
|
||||
var ctx = Ctx();
|
||||
var rt = RunToGround(ctx);
|
||||
|
||||
ctx.S.Chase = false; ctx.S.Vision = false;
|
||||
for (int i = 0; i < 5; i++) rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Ground, rt.CurrentStateName, "Boss 不应脱战");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HpBelowHalf_TransitionsToAirPhase()
|
||||
{
|
||||
var ctx = Ctx();
|
||||
var rt = RunToGround(ctx);
|
||||
|
||||
ctx.V.Hp = 0.4f;
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.PhaseTx, rt.CurrentStateName);
|
||||
Assert.AreEqual(1, ctx.B.BeginCallCount, "阶段过渡只应发起一次");
|
||||
Assert.AreEqual(1, ctx.B.LastTargetPhase);
|
||||
Assert.Greater(ctx.B.LastDuration, 0f, "过渡期必须有正的无敌时长");
|
||||
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.PhaseTx, rt.CurrentStateName, "过渡期间不得提前转出");
|
||||
Assert.AreEqual(1, ctx.B.BeginCallCount, "过渡期间不得重复发起");
|
||||
|
||||
ctx.B.Transitioning = false; // 过渡结束
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName,
|
||||
"过渡结束必须有出边转出——过渡态无出边会把 Boss 永久锁死且零报错");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Air_HoverAttackLoop()
|
||||
{
|
||||
var ctx = Ctx();
|
||||
var rt = RunToGround(ctx);
|
||||
AdvanceToAirPhase(ctx, rt);
|
||||
Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
|
||||
|
||||
ctx.C.Eligible = true;
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.AirAttack, rt.CurrentStateName);
|
||||
CollectionAssert.Contains(ctx.C.Used, "best");
|
||||
|
||||
ctx.C.Running = null;
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Died_GoesToDeathFromAnyState()
|
||||
{
|
||||
var ctx = Ctx();
|
||||
var rt = RunToGround(ctx);
|
||||
|
||||
rt.Send(AiSignal.Died);
|
||||
rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Death, rt.CurrentStateName);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AirPhase_DoesNotFallBackToGround()
|
||||
{
|
||||
// 阶段单向推进:进了空中阶段就不再回地面阶段(招池已换)
|
||||
var ctx = Ctx();
|
||||
var rt = RunToGround(ctx);
|
||||
AdvanceToAirPhase(ctx, rt);
|
||||
|
||||
ctx.V.Hp = 0.9f; // 即使血量回升
|
||||
for (int i = 0; i < 5; i++) rt.Tick(0.1f);
|
||||
Assert.AreEqual(ChaoFengAi.Air, rt.CurrentStateName);
|
||||
Assert.AreEqual(1, ctx.B.BeginCallCount, "不应再次发起阶段过渡");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 81f89b6e2f8f2774ab7cedbe45dcb810
|
||||
guid: ca3207eb8b4a0ca4f9e57bf3c12aa95c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.AI;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
@@ -44,17 +45,21 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
public bool HpBelow(float ratio) => Hp < ratio;
|
||||
}
|
||||
|
||||
public sealed class FakeAiContext : IAiContext
|
||||
public sealed class FakeAiContext : 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 FakeBossControl B = new FakeBossControl();
|
||||
public bool HasAlert = true;
|
||||
public ISensor Sensor => S;
|
||||
public IEnemyLocomotion Locomotion => L;
|
||||
public ICombatant Combat => C;
|
||||
public IActorVitals Vitals => V;
|
||||
public Blackboard Blackboard => BB;
|
||||
public IBossControl Boss => B;
|
||||
public bool HasAlertState => HasAlert;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
public sealed class FakeBossControl : IBossControl
|
||||
{
|
||||
public int Phase;
|
||||
public bool Transitioning;
|
||||
public bool Full;
|
||||
|
||||
// 记录最近一次过渡请求,供断言"图确实发起了过渡"
|
||||
public int LastTargetPhase = -1;
|
||||
public float LastDuration = -1f;
|
||||
public int BeginCallCount;
|
||||
|
||||
// 锚点:测试按下标直给坐标;距离由 SelfPosition 与该下标锚点算出(不忽略 index)
|
||||
public UnityEngine.Vector2[] Anchors = new UnityEngine.Vector2[0];
|
||||
public UnityEngine.Vector2 SelfPosition;
|
||||
|
||||
public int CurrentPhase => Phase;
|
||||
public bool IsPhaseTransitioning => Transitioning;
|
||||
public bool ResourceFull => Full;
|
||||
|
||||
public void BeginPhaseTransition(int targetPhase, float invincibleDuration)
|
||||
{
|
||||
LastTargetPhase = targetPhase;
|
||||
LastDuration = invincibleDuration;
|
||||
BeginCallCount++;
|
||||
Transitioning = true; // 由测试手工置回 false 模拟过渡结束
|
||||
}
|
||||
|
||||
public UnityEngine.Vector2 AnchorAt(int index) => Anchors[index];
|
||||
public float DistanceToAnchor(int index)
|
||||
=> UnityEngine.Vector2.Distance(SelfPosition, Anchors[index]);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f0d0425e529293e469da3762fe3bf8f0
|
||||
guid: dc93ec3a62b58c24da5b420bdeeed029
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -7,21 +7,6 @@ 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>();
|
||||
@@ -59,7 +44,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
public void Graph_BehavesLikeE001()
|
||||
{
|
||||
var so = MakeE001Recipe();
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = new AiRuntime(so.GetOrBuildGraph(), ctx);
|
||||
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
|
||||
ctx.S.Chase = true;
|
||||
|
||||
@@ -7,24 +7,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
{
|
||||
public class PerceptionSkeletonTests
|
||||
{
|
||||
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 bool HasAlert = true;
|
||||
|
||||
public ISensor Sensor => S;
|
||||
public IEnemyLocomotion Locomotion => L;
|
||||
public ICombatant Combat => C;
|
||||
public IActorVitals Vitals => V;
|
||||
public Blackboard Blackboard => BB;
|
||||
public bool HasAlertState => HasAlert;
|
||||
}
|
||||
|
||||
static AiRuntime Build(Ctx ctx, IUnawareModule unaware = null, IEngagementModule engagement = null)
|
||||
static AiRuntime Build(FakeAiContext ctx, IUnawareModule unaware = null, IEngagementModule engagement = null)
|
||||
{
|
||||
var b = new BrainBuilder();
|
||||
PerceptionSkeleton.Add(b,
|
||||
@@ -38,7 +21,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Unaware_ToEngagement_WhenInChaseZone()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx);
|
||||
ctx.S.Chase = true;
|
||||
rt.Tick(0.1f);
|
||||
@@ -48,7 +31,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Unaware_ToAlert_WhenInVision_AndHasAlert()
|
||||
{
|
||||
var ctx = new Ctx { HasAlert = true };
|
||||
var ctx = new FakeAiContext { HasAlert = true };
|
||||
var rt = Build(ctx);
|
||||
ctx.S.Vision = true;
|
||||
rt.Tick(0.1f);
|
||||
@@ -58,7 +41,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Unaware_StaysUnaware_WhenInVision_ButNoAlertState()
|
||||
{
|
||||
var ctx = new Ctx { HasAlert = false };
|
||||
var ctx = new FakeAiContext { HasAlert = false };
|
||||
var rt = Build(ctx);
|
||||
ctx.S.Vision = true;
|
||||
rt.Tick(0.1f);
|
||||
@@ -68,7 +51,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void ChaseZone_BeatsAlert_WhenBothActive()
|
||||
{
|
||||
var ctx = new Ctx { HasAlert = true };
|
||||
var ctx = new FakeAiContext { HasAlert = true };
|
||||
var rt = Build(ctx);
|
||||
ctx.S.Chase = true; ctx.S.Vision = true;
|
||||
rt.Tick(0.1f);
|
||||
@@ -79,7 +62,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void UpgradeEdge_BeatsUnawareInternalDwellEdge()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx, new AlternatingIdlePatrol(idleDwell: 1f, patrolDwell: 1f));
|
||||
Assert.AreEqual(AlternatingIdlePatrol.Idle, rt.CurrentStateName);
|
||||
ctx.S.Chase = true;
|
||||
@@ -92,7 +75,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Alert_ToEngagement_WhenEnteringChaseZone()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx);
|
||||
ctx.S.Vision = true; rt.Tick(0.1f);
|
||||
Assert.AreEqual(PerceptionSkeleton.Alert, rt.CurrentStateName);
|
||||
@@ -103,7 +86,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Alert_ToRest_WhenLeavingVision()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx);
|
||||
ctx.S.Vision = true; rt.Tick(0.1f);
|
||||
ctx.S.Vision = false; rt.Tick(0.1f);
|
||||
@@ -113,7 +96,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Alert_FacesLastKnown()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
ctx.S.Last = new UnityEngine.Vector2(7f, 2f);
|
||||
var rt = Build(ctx);
|
||||
ctx.S.Vision = true; rt.Tick(0.1f);
|
||||
@@ -125,7 +108,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void AfterEngagement_GoesToRest_NeverBackToAlert()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx);
|
||||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||||
@@ -138,7 +121,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Died_TransitionsToDeath_FromAnyState()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx);
|
||||
ctx.S.Chase = true; rt.Tick(0.1f);
|
||||
Assert.AreEqual(RushEngagement.Rush, rt.CurrentStateName);
|
||||
@@ -152,7 +135,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Died_WorksEvenWhenNotControllable()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx);
|
||||
ctx.V.Controllable = false; // 已是 Dead 物理态
|
||||
rt.Send(AiSignal.Died);
|
||||
@@ -163,7 +146,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Death_IsTerminal_NoConditionEdgesEvaluatedAfterwards()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx);
|
||||
rt.Send(AiSignal.Died);
|
||||
rt.Tick(0.1f);
|
||||
@@ -222,7 +205,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
[Test]
|
||||
public void Skeleton_WithApproachAttack_EscalatesAndDisengages()
|
||||
{
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx, new SinglePost(LocomotionMode.Patrol), new ApproachAttackEngagement());
|
||||
Assert.AreEqual(SinglePost.Post, rt.CurrentStateName);
|
||||
|
||||
@@ -244,7 +227,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
public void Skeleton_WithDisguiseThenPatrol_DisengagesToPatrol_NotDisguise()
|
||||
{
|
||||
// 唯一 Entry != Rest 的未发现层:出生伪装静止,脱战只回巡逻,伪装态从此不可达。
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var rt = Build(ctx, new DisguiseThenPatrol());
|
||||
Assert.AreEqual(DisguiseThenPatrol.Disguise, rt.CurrentStateName);
|
||||
|
||||
@@ -265,7 +248,7 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
public void EntryCanBeOverridden_ForEnemiesWithPrefixStates()
|
||||
{
|
||||
// 骨架注释承诺的出路:掉落链 / 出场链这类前置态,调 Add 之后再覆盖 Entry。
|
||||
var ctx = new Ctx();
|
||||
var ctx = new FakeAiContext();
|
||||
var b = new BrainBuilder();
|
||||
PerceptionSkeleton.Add(b, new SinglePost(LocomotionMode.Patrol),
|
||||
new RushEngagement("rush", RushExit.OnLostTarget));
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
AiStateFragments.Locomotion(b, Rest, LocomotionMode.Patrol);
|
||||
m.Build(b, Rest);
|
||||
b.Entry(m.EntryState);
|
||||
return new AiRuntime(b.Build(), ctx);
|
||||
// 单独跑交战模块:Rest 的升级边由骨架挂,这里缺它是预期的部分图
|
||||
return new AiRuntime(b.BuildPartial(), ctx);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace BaseGames.Tests.EditMode.AI
|
||||
m.Declare(b);
|
||||
m.Link(b);
|
||||
b.Entry(m.Entry);
|
||||
return new AiRuntime(b.Build(), ctx);
|
||||
// 不挂骨架升级边,未发现层的态本就没有出口——正是部分图
|
||||
return new AiRuntime(b.BuildPartial(), ctx);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6d8f5f23ee1dde046b1a7361ac1b6386
|
||||
guid: e314cb3fe82112a409b7632389cdb35c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
@@ -0,0 +1,77 @@
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using BaseGames.Combat;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.Combat
|
||||
{
|
||||
public class HurtBoxMultiplierTests
|
||||
{
|
||||
/// <summary>测试期建出的宿主对象;在 TearDown 统一销毁,断言失败也不会残留到编辑器场景。</summary>
|
||||
private GameObject _host;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => LogAssert.ignoreFailingMessages = true;
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
|
||||
// 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。
|
||||
if (_host != null) Object.DestroyImmediate(_host);
|
||||
_host = null;
|
||||
LogAssert.ignoreFailingMessages = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HurtBox 标了 [RequireComponent(typeof(Collider2D))],而 Collider2D 是抽象类,
|
||||
/// Unity 无法自动补齐——裸 AddComponent<HurtBox>() 会返回真 null。
|
||||
/// 必须先挂一个具体碰撞体。
|
||||
/// </summary>
|
||||
private HurtBox CreateHurtBox()
|
||||
{
|
||||
_host = new GameObject("hurtbox");
|
||||
_host.AddComponent<BoxCollider2D>();
|
||||
var hb = _host.AddComponent<HurtBox>();
|
||||
Assert.IsNotNull(hb, "HurtBox 需要先有具体 Collider2D 才能挂载");
|
||||
return hb;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DefaultMultiplier_IsOne()
|
||||
{
|
||||
var hb = CreateHurtBox();
|
||||
Assert.AreEqual(1f, hb.DamageMultiplier);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SetDamageMultiplier_ClampsToNonNegative()
|
||||
{
|
||||
var hb = CreateHurtBox();
|
||||
|
||||
hb.SetDamageMultiplier(2.5f);
|
||||
Assert.AreEqual(2.5f, hb.DamageMultiplier);
|
||||
|
||||
hb.SetDamageMultiplier(-3f);
|
||||
Assert.AreEqual(0f, hb.DamageMultiplier, "负倍率无意义,钳到 0");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyMultiplier_ScalesRawAmount_AndFloorsAtOne()
|
||||
{
|
||||
var hb = CreateHurtBox();
|
||||
|
||||
hb.SetDamageMultiplier(2f);
|
||||
Assert.AreEqual(20, hb.ApplyDamageMultiplier(10));
|
||||
|
||||
hb.SetDamageMultiplier(0f);
|
||||
Assert.AreEqual(1, hb.ApplyDamageMultiplier(10), "倍率 0 也至少造成 1 点,与防御减免同规则");
|
||||
|
||||
hb.SetDamageMultiplier(1f);
|
||||
Assert.AreEqual(10, hb.ApplyDamageMultiplier(10), "倍率 1 时数值原样通过");
|
||||
// 倍率 1 必须是"原样通过",而不是"乘完再钳最低 1":10 在两种实现下都得 10,
|
||||
// 只有 0 能把二者区分开。这条断言守住"默认倍率不改变任何既有伤害数值"。
|
||||
Assert.AreEqual(0, hb.ApplyDamageMultiplier(0), "倍率 1 时原样通过,不引入最低 1 的钳制");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8230eab2acba8c24499b2d20df81adb7
|
||||
guid: faac4f58895b7714b97a548a16181462
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Collections;
|
||||
using System.Reflection;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.Enemies
|
||||
{
|
||||
public class BossPhaseAbilityGateTests
|
||||
{
|
||||
/// <summary>最小可实例化能力:不播动画、不碰 HitBox,只为验证启停与 CanUse。</summary>
|
||||
private sealed class StubAbility : EnemyAbilityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 编辑模式下 Unity 不会在 AddComponent 时调用 Awake,_enemy 会留空导致 CanUse 恒假。
|
||||
/// 这里手动跑一次真实的 Awake(而非反射直写字段),让依赖解析走生产代码路径。
|
||||
/// </summary>
|
||||
public void RunAwake() => Awake();
|
||||
|
||||
protected override IEnumerator ExecuteCoroutine() { yield break; }
|
||||
}
|
||||
|
||||
/// <summary>测试期建出的宿主对象;在 TearDown 统一销毁,断言失败也不会残留到编辑器场景。</summary>
|
||||
private GameObject _host;
|
||||
|
||||
// RunAwake 时 EnemyAbilityBase 找不到 AnimancerComponent 会告警,与本测试无关。
|
||||
[SetUp]
|
||||
public void SetUp() => LogAssert.ignoreFailingMessages = true;
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
|
||||
// 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。
|
||||
if (_host != null) Object.DestroyImmediate(_host);
|
||||
_host = null;
|
||||
LogAssert.ignoreFailingMessages = false;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DisabledAbility_IsNotUsable()
|
||||
{
|
||||
// 必须先挂 EnemyBase:否则 _enemy==null 会让 CanUse 恒假,
|
||||
// 这条断言在修复前也会"通过",成为一个永远不会失败的测试。
|
||||
_host = new GameObject("ability-host");
|
||||
_host.AddComponent<EnemyBase>(); // 裸 EnemyBase 的 _currentState=Controlled → IsAlive 为真
|
||||
var ab = _host.AddComponent<StubAbility>();
|
||||
ab.RunAwake(); // 编辑模式不自动跑 Awake,手动解析 _enemy
|
||||
|
||||
Assert.IsTrue(ab.CanUse,
|
||||
"前提:启用且宿主存活时应可用——否则下一条断言分不清是 enabled 还是别的门在起作用");
|
||||
|
||||
ab.enabled = false;
|
||||
Assert.IsFalse(ab.CanUse, "禁用的能力组件不可用——否则选招器会选中一个启不动招的招");
|
||||
}
|
||||
|
||||
// 阶段门的 _entries 是私有序列化字段——与放置脚手架同样用反射写入。
|
||||
// 反射只用来喂入这份配置数据,ApplyPhase 的判定逻辑走的仍是生产代码。
|
||||
private static void SetEntries(BossPhaseAbilityGate gate,
|
||||
params (EnemyAbilityBase ab, int[] phases)[] items)
|
||||
{
|
||||
var arr = new BossPhaseAbilityGate.PhaseEntry[items.Length];
|
||||
for (int i = 0; i < items.Length; i++)
|
||||
arr[i] = new BossPhaseAbilityGate.PhaseEntry { ability = items[i].ab, phases = items[i].phases };
|
||||
typeof(BossPhaseAbilityGate)
|
||||
.GetField("_entries", BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.SetValue(gate, arr);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyPhase_EnablesOnlyAbilitiesAllowedInThatPhase()
|
||||
{
|
||||
_host = new GameObject("boss");
|
||||
var gate = _host.AddComponent<BossPhaseAbilityGate>();
|
||||
var p0 = _host.AddComponent<StubAbility>();
|
||||
// EnemyAbilityBase 是 [DisallowMultipleComponent],第二个能力必须另起子节点
|
||||
// (挂在 _host 下,TearDown 会连带销毁)
|
||||
var p1Go = new GameObject("p1"); p1Go.transform.SetParent(_host.transform);
|
||||
var p1 = p1Go.AddComponent<StubAbility>();
|
||||
|
||||
SetEntries(gate, (p0, new[] { 0 }), (p1, new[] { 1 }));
|
||||
|
||||
gate.ApplyPhase(0);
|
||||
Assert.IsTrue (p0.enabled, "阶段 0 的招应在阶段 0 启用");
|
||||
Assert.IsFalse(p1.enabled, "阶段 1 的招不应在阶段 0 启用");
|
||||
|
||||
gate.ApplyPhase(1);
|
||||
Assert.IsFalse(p0.enabled, "换阶段后旧池的招应被禁用");
|
||||
Assert.IsTrue (p1.enabled, "换阶段后新池的招应被启用");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyPhase_EmptyPhaseArrayMeansAllPhases()
|
||||
{
|
||||
_host = new GameObject("boss");
|
||||
var gate = _host.AddComponent<BossPhaseAbilityGate>();
|
||||
var any = _host.AddComponent<StubAbility>();
|
||||
|
||||
SetEntries(gate, (any, new int[0]));
|
||||
|
||||
gate.ApplyPhase(0);
|
||||
Assert.IsTrue(any.enabled, "空阶段数组 = 全阶段可用");
|
||||
gate.ApplyPhase(7);
|
||||
Assert.IsTrue(any.enabled, "空阶段数组在任意阶段都应保持启用");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ApplyPhase_UnlistedAbilityIsUntouched()
|
||||
{
|
||||
_host = new GameObject("boss");
|
||||
var gate = _host.AddComponent<BossPhaseAbilityGate>();
|
||||
var listed = _host.AddComponent<StubAbility>();
|
||||
var otherGo = new GameObject("unlisted"); otherGo.transform.SetParent(_host.transform);
|
||||
var unlisted = otherGo.AddComponent<StubAbility>();
|
||||
|
||||
SetEntries(gate, (listed, new[] { 1 }));
|
||||
|
||||
gate.ApplyPhase(0);
|
||||
Assert.IsFalse(listed.enabled, "登记的能力应被阶段门管控");
|
||||
Assert.IsTrue (unlisted.enabled, "未登记的能力不受阶段门影响");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cc78469b00f228648844d2e34c13f80c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Collections;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 池化敌人复活后不得带着上一条命的能力冷却。
|
||||
///
|
||||
/// EnemyBase.OnSpawn 里 InterruptAll 那一行的注释写着「重置能力冷却」,实际三处都不成立:
|
||||
/// EnemyAbilityRegistry.InterruptAll 与 EnemyAbilityBase.Interrupt 各有一道 IsRunning 门,
|
||||
/// 出生时无一在跑,循环体一次都不执行;即便执行到,末行也是**设置**半冷却而非清零。
|
||||
/// 冷却基于绝对 Time.time,于是上一条命的剩余冷却原样活到下一条命。
|
||||
/// </summary>
|
||||
public class EnemyAbilityCooldownResetTests
|
||||
{
|
||||
/// <summary>最小可实例化能力:不播动画、不碰 HitBox,只为验证冷却的跨池化生命周期。</summary>
|
||||
private sealed class StubAbility : EnemyAbilityBase
|
||||
{
|
||||
/// <summary>编辑模式下 AddComponent 不触发 Awake,手动跑一次真实的依赖解析路径。</summary>
|
||||
public void RunAwake() => Awake();
|
||||
|
||||
/// <summary>等价于 Inspector 对 [SerializeField] _config 的赋值(子类可见,非反射伪造)。</summary>
|
||||
public void SetConfig(EnemyAbilitySO cfg) => _config = cfg;
|
||||
|
||||
protected override IEnumerator ExecuteCoroutine() { yield break; }
|
||||
}
|
||||
|
||||
private GameObject _host;
|
||||
private EnemyAbilitySO _config;
|
||||
|
||||
// RunAwake 时找不到 AnimancerComponent 会告警,与本测试无关。
|
||||
[SetUp]
|
||||
public void SetUp() => LogAssert.ignoreFailingMessages = true;
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
|
||||
// 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。
|
||||
if (_host != null) Object.DestroyImmediate(_host);
|
||||
if (_config != null) Object.DestroyImmediate(_config);
|
||||
_host = null;
|
||||
_config = null;
|
||||
LogAssert.ignoreFailingMessages = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 建一个能力已进入冷却的敌人。冷却全程由生产代码写入:
|
||||
/// Execute() 启真实协程(编辑模式下跑到首个 yield 即挂起,_isRunning 留 true),
|
||||
/// 再由 Interrupt() 经真实路径写 _cooldownEndTime。
|
||||
/// 不反射直写冷却字段——那样测到的是伪造状态,而非真实时序产出的状态。
|
||||
/// </summary>
|
||||
private (EnemyBase enemy, StubAbility ability) MakeEnemyWithAbilityOnCooldown()
|
||||
{
|
||||
_host = new GameObject("pooled-enemy");
|
||||
var enemy = _host.AddComponent<EnemyBase>(); // 裸 EnemyBase 的 _currentState=Controlled → IsAlive 为真
|
||||
var ab = _host.AddComponent<StubAbility>();
|
||||
|
||||
_config = ScriptableObject.CreateInstance<EnemyAbilitySO>();
|
||||
_config.abilityId = "stub_ability";
|
||||
_config.cooldown = 10f; // 远长于单次用例耗时,排除"跑着跑着自然冷却完"的假绿
|
||||
ab.SetConfig(_config);
|
||||
ab.RunAwake();
|
||||
|
||||
// EnemyBase.Awake 里的生产写法,编辑模式下手动跑一次
|
||||
enemy.Abilities.CollectFrom(_host);
|
||||
|
||||
Assert.IsTrue(ab.Execute(), "前提:能力应能启动,否则下面写不进冷却");
|
||||
ab.Interrupt(InterruptReason.ExternalRequest);
|
||||
Assert.IsTrue(ab.IsOnCooldown, "前提:本用例覆盖的是带冷却复活,此刻必须真的在冷却中");
|
||||
|
||||
return (enemy, ab);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnSpawn_ClearsAbilityCooldown()
|
||||
{
|
||||
var (enemy, ability) = MakeEnemyWithAbilityOnCooldown();
|
||||
|
||||
enemy.OnSpawn();
|
||||
|
||||
Assert.AreEqual(0f, ability.CooldownRemaining,
|
||||
"池化复活的敌人不得带着上一条命的剩余冷却——否则新生的敌人有数秒出不了招");
|
||||
Assert.IsFalse(ability.IsOnCooldown);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b8c4bfbd21220fd429f7f845fee03cff
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -104,5 +104,152 @@ namespace BaseGames.Tests.EditMode.Enemies
|
||||
for (int i = 0; i < 50; i++)
|
||||
Assert.AreSame(ok, Sel(bad, ok).Select(true, true, AttackSelectionMode.WeightedRandom));
|
||||
}
|
||||
|
||||
// ── WeightedRandomAntiRepeat ─────────────────────────────────────────
|
||||
|
||||
static EnemyAttackSelector SelAntiRepeat(float factor, params IAttackCandidate[] cs)
|
||||
=> new EnemyAttackSelector(new List<IAttackCandidate>(cs), factor);
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_ZeroFactor_NeverPicksSameTwiceWhenAlternativeExists()
|
||||
{
|
||||
var a = new FakeCandidate { WeightV = 1f };
|
||||
var b = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0f, a, b);
|
||||
|
||||
var prev = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
Assert.AreNotSame(prev, cur, "系数为 0 时不应连续选中同一招");
|
||||
prev = cur;
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_ZeroFactor_SingleCandidate_StillReturnsIt()
|
||||
{
|
||||
// 只有一招时折扣会把权重压到 0,WeightedPick 返回 -1;
|
||||
// 必须退化为 Priority 路径把它选出来,否则敌人永远不出手。
|
||||
var only = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0f, only);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
Assert.AreSame(only, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_DiscountsPreviousPick_ButDoesNotBanIt()
|
||||
{
|
||||
// 系数 0.5:a 被选过后权重折半,但仍有机会再被选中。
|
||||
var a = new FakeCandidate { WeightV = 1f };
|
||||
var b = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0.5f, a, b);
|
||||
|
||||
s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
int repeats = 0;
|
||||
var prev = s.LastPicked;
|
||||
for (int i = 0; i < 400; i++)
|
||||
{
|
||||
var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
if (ReferenceEquals(cur, prev)) repeats++;
|
||||
prev = cur;
|
||||
}
|
||||
// 统计断言(无固定随机种子):系数 0.5 → 每次重复概率 0.5/1.5≈1/3,
|
||||
// 400 次期望≈133,标准差≈9.4;下面的边界约 ±5σ,正确实现下几乎不可能红。
|
||||
const string msg = "系数 0.5 → 每次重复概率 0.5/1.5≈1/3,400 次期望≈133(±5σ 约 [90,180])";
|
||||
Assert.Greater(repeats, 90, "折扣不应等于禁用。" + msg);
|
||||
Assert.Less (repeats, 180, "重复率明显高于预期,折扣可能未生效。" + msg);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_IneligiblePrevious_DoesNotBlockOthers()
|
||||
{
|
||||
// 用系数 0.5 而非 0:若实现把折扣错误地施加在「资格归零」之前
|
||||
// (c.Weight * factor 而非 w * factor),冷却中的上一招会拿到 0.5 的权重而可能被选中。
|
||||
// 系数为 0 时那个 bug 也会算出 0,测试将永远通过、失去意义。
|
||||
var a = new FakeCandidate { WeightV = 1f };
|
||||
var b = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0.5f, a, b);
|
||||
|
||||
var first = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
((FakeCandidate)first).CanUseV = false; // 上一招进冷却
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var second = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
Assert.IsNotNull(second);
|
||||
Assert.AreNotSame(first, second, "冷却中的招不得因防重复折扣路径被选中");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResetRepeatMemory_RestoresUndiscountedWeights()
|
||||
{
|
||||
// 确定性用例:a 权重 1 / 优先级 0,b 权重 0 / 优先级 5。
|
||||
// 选中 a 后若不清记忆,a 被折扣到 0 → 全零 → 走 Priority 退化路径选出 b。
|
||||
// 清了记忆则 a 恢复权重 1,仍应选 a。因此该用例精确地在重置失效时变红。
|
||||
var a = new FakeCandidate { WeightV = 1f, PriorityV = 0 };
|
||||
var b = new FakeCandidate { WeightV = 0f, PriorityV = 5 };
|
||||
var s = SelAntiRepeat(0f, a, b);
|
||||
|
||||
Assert.AreSame(a, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat));
|
||||
s.ResetRepeatMemory();
|
||||
Assert.AreSame(a, s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat),
|
||||
"清除防重复记忆后应恢复未折扣的权重分布");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_FactorOne_AppliesNoPenalty()
|
||||
{
|
||||
// 系数 1.0 是 [Range] 的上界,语义为「不惩罚」:重复率应回到约 50%
|
||||
var a = new FakeCandidate { WeightV = 1f };
|
||||
var b = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(1f, a, b);
|
||||
|
||||
s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
int repeats = 0;
|
||||
var prev = s.LastPicked;
|
||||
for (int i = 0; i < 400; i++)
|
||||
{
|
||||
var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
if (ReferenceEquals(cur, prev)) repeats++;
|
||||
prev = cur;
|
||||
}
|
||||
Assert.Greater(repeats, 150,
|
||||
"系数 1.0 应无惩罚:重复概率≈50%,400 次期望≈200(σ≈10,150 约为 -5σ)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AntiRepeat_ThreeCandidates_NoImmediateRepeat_AndAllReachable()
|
||||
{
|
||||
// 三候选:既验证折扣循环的下标没写错,也验证除上一招外其余候选都仍可达
|
||||
var a = new FakeCandidate { WeightV = 1f };
|
||||
var b = new FakeCandidate { WeightV = 1f };
|
||||
var c = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0f, a, b, c);
|
||||
|
||||
var seen = new HashSet<IAttackCandidate>();
|
||||
var prev = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
seen.Add(prev);
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var cur = s.Select(true, true, AttackSelectionMode.WeightedRandomAntiRepeat);
|
||||
Assert.AreNotSame(prev, cur, "系数为 0 时不应连续选中同一招");
|
||||
seen.Add(cur);
|
||||
prev = cur;
|
||||
}
|
||||
Assert.AreEqual(3, seen.Count, "三个等权候选应都能被选到");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlainWeighted_DoesNotApplyAntiRepeat()
|
||||
{
|
||||
// WeightedRandom 模式下唯一候选恒被选中(回归:折扣不得泄漏到旧模式)
|
||||
var only = new FakeCandidate { WeightV = 1f };
|
||||
var s = SelAntiRepeat(0f, only);
|
||||
for (int i = 0; i < 10; i++)
|
||||
Assert.AreSame(only, s.Select(true, true, AttackSelectionMode.WeightedRandom));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.Enemies
|
||||
{
|
||||
public class EnemyBrainContextBossFacetTests
|
||||
{
|
||||
// 本项目关闭了 Domain/Scene Reload,EditMode 测试跑在当前打开的场景上:
|
||||
// 建出来的物体必须在 TearDown 里销毁,断言失败时才不会把物体永久留在场景里。
|
||||
GameObject _go;
|
||||
|
||||
// EnemyBase.Awake 会对缺失的碰撞体/引用报错,这些与本测试无关。
|
||||
[SetUp]
|
||||
public void SetUp() => LogAssert.ignoreFailingMessages = true;
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
LogAssert.ignoreFailingMessages = false;
|
||||
if (_go != null) UnityEngine.Object.DestroyImmediate(_go);
|
||||
_go = null;
|
||||
}
|
||||
|
||||
T MakeEnemy<T>(string name) where T : EnemyBase
|
||||
{
|
||||
_go = new GameObject(name);
|
||||
return _go.AddComponent<T>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NonBoss_AccessingBossFacet_Throws()
|
||||
{
|
||||
var enemy = MakeEnemy<EnemyBase>("plain-enemy");
|
||||
var ctx = new EnemyBrainContext(enemy);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => { var _ = ctx.Boss; });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Boss_AccessingBossFacet_ReturnsTheBoss()
|
||||
{
|
||||
var boss = MakeEnemy<BossBase>("boss");
|
||||
var ctx = new EnemyBrainContext(boss);
|
||||
|
||||
Assert.AreSame(boss, ctx.Boss);
|
||||
Assert.AreEqual(0, ctx.Boss.CurrentPhase);
|
||||
Assert.IsFalse(ctx.Boss.IsPhaseTransitioning);
|
||||
// 未挂 BossResource:图查询资源满值属配置错误,必须抛而不是静默返回 false
|
||||
Assert.Throws<InvalidOperationException>(() => { var _ = ctx.Boss.ResourceFull; });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90d0535e362d3b9469d2c91a70c4c8ca
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,107 @@
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Enemies.States;
|
||||
|
||||
namespace BaseGames.Tests.EditMode.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 受击类状态(Hurt / Stagger / KnockUp)在**没有对应动画 Clip** 时,
|
||||
/// 必须仍然安排回到 Controlled——否则敌人被打一次就永久停在该态,
|
||||
/// IsControllable 恒假、AI 永久停摆。
|
||||
///
|
||||
/// 这是本项目「禁止下游兜底掩盖问题」的反面:早退本身不是兜底,
|
||||
/// 但早退**同时丢掉了恢复安排**,把「动画没配」这个可恢复的缺失
|
||||
/// 变成了「敌人永久失能」这个不可恢复的故障。
|
||||
/// </summary>
|
||||
public class EnemyHitStateRecoveryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 记录 ScheduleStateRecovery 调用而不真正启协程——EditMode 下没有播放循环,
|
||||
/// 协程不会推进,直接断言"是否安排了恢复"才是这个缺陷的可测形态。
|
||||
/// </summary>
|
||||
private sealed class RecordingEnemy : EnemyBase
|
||||
{
|
||||
public int Calls;
|
||||
public EnemyStateType LastFromState;
|
||||
public float LastDelay;
|
||||
|
||||
public override void ScheduleStateRecovery(EnemyStateType fromState, float delay)
|
||||
{
|
||||
Calls++;
|
||||
LastFromState = fromState;
|
||||
LastDelay = delay;
|
||||
}
|
||||
}
|
||||
|
||||
private GameObject _host;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => LogAssert.ignoreFailingMessages = true;
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
|
||||
// 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。
|
||||
if (_host != null) Object.DestroyImmediate(_host);
|
||||
_host = null;
|
||||
LogAssert.ignoreFailingMessages = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EditMode 下 AddComponent 不触发 Awake,动画引用保持为空——
|
||||
/// 这正好等价于"美术未接入"的生产场景,无需再造假配置。
|
||||
/// 前提用 AnimConfig 断言(而非 Animancer):后者会把 Animancer 程序集
|
||||
/// 拖进测试程序集的引用,而 AnimConfig 为空已足以让三个状态都走无动画分支。
|
||||
/// </summary>
|
||||
private RecordingEnemy MakeEnemyWithoutAnimation()
|
||||
{
|
||||
_host = new GameObject("enemy-without-anim");
|
||||
var e = _host.AddComponent<RecordingEnemy>();
|
||||
Assert.IsNull(e.AnimConfig, "前提:本用例要覆盖的是无动画路径");
|
||||
return e;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HurtState_WithoutClip_SchedulesRecovery()
|
||||
{
|
||||
var enemy = MakeEnemyWithoutAnimation();
|
||||
|
||||
new EnemyHurtState().Enter(enemy);
|
||||
|
||||
Assert.AreEqual(1, enemy.Calls,
|
||||
"无受击动画时必须安排回到 Controlled,否则敌人被打一次即永久失能");
|
||||
Assert.AreEqual(EnemyStateType.Hurt, enemy.LastFromState,
|
||||
"恢复必须限定为从 Hurt 态出发,避免覆盖其后可能已切换的状态");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StaggerState_WithoutClip_SchedulesRecovery()
|
||||
{
|
||||
var enemy = MakeEnemyWithoutAnimation();
|
||||
|
||||
new EnemyStaggerState().Enter(enemy);
|
||||
|
||||
Assert.AreEqual(1, enemy.Calls,
|
||||
"无僵直动画时必须安排回到 Controlled——霸体被破也是 TakeDamage 的常规分支");
|
||||
Assert.AreEqual(EnemyStateType.Stagger, enemy.LastFromState);
|
||||
Assert.Greater(enemy.LastDelay, 0f,
|
||||
"僵直是给玩家的惩罚窗口,时长必须为正,否则等同于没有僵直");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void KnockUpState_WithoutClip_StillSchedulesRecovery()
|
||||
{
|
||||
// 回归保护:KnockUp 本来就有 else 分支,本次修复不得破坏它
|
||||
var enemy = MakeEnemyWithoutAnimation();
|
||||
|
||||
new EnemyKnockUpState().Enter(enemy);
|
||||
|
||||
Assert.AreEqual(1, enemy.Calls);
|
||||
Assert.AreEqual(EnemyStateType.KnockUp, enemy.LastFromState);
|
||||
Assert.Greater(enemy.LastDelay, 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df7e877126c6c344f8cbf5fe74c4b324
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -9,32 +9,27 @@ MonoBehaviour:
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: de92221c7c3fb4a42a7cd122a8f97632, type: 3}
|
||||
m_Script: {fileID: 11500000, guid: fc81345eb5294d643b4242eac215c5fc, type: 3}
|
||||
m_Name: ABL_ChaoFeng_Boomerang
|
||||
m_EditorClassIdentifier:
|
||||
skillId: boomerang
|
||||
displayName: Boomerang
|
||||
designNote:
|
||||
category: 0
|
||||
skillType: 0
|
||||
availablePhaseIndices: 00000000
|
||||
attackPatterns: []
|
||||
vulnerabilityWindows: []
|
||||
interactionTags: 0
|
||||
sequenceOnHit: {fileID: 0}
|
||||
sequenceOnMiss: {fileID: 0}
|
||||
counterResponses: []
|
||||
arenaEvents: []
|
||||
resourceCost:
|
||||
resourceId:
|
||||
cost: 0
|
||||
minRequired: 0
|
||||
buildsRage: 0
|
||||
poiseWindow:
|
||||
Level: 0
|
||||
NormalizedStart: 0
|
||||
NormalizedEnd: 0
|
||||
skillAnimation:
|
||||
abilityId: boomerang
|
||||
designNote: "\u9636\u6BB5 0 \u62DB\u5F0F\uFF1B\u9636\u6BB5\u53EF\u7528\u6027\u7531
|
||||
BossPhaseAbilityGate \u914D\u7F6E"
|
||||
attackSequence: []
|
||||
cooldown: 4
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 1
|
||||
weight: 1
|
||||
rangeRadius: 9
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
clip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
_Events:
|
||||
@@ -43,8 +38,6 @@ MonoBehaviour:
|
||||
_Names: []
|
||||
_Clip: {fileID: 0}
|
||||
_NormalizedStartTime: NaN
|
||||
cooldown: 0
|
||||
weight: 1
|
||||
references:
|
||||
version: 2
|
||||
RefIds: []
|
||||
|
||||
@@ -9,42 +9,23 @@ MonoBehaviour:
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: de92221c7c3fb4a42a7cd122a8f97632, type: 3}
|
||||
m_Script: {fileID: 11500000, guid: 9050afa76362dff469c64fbb48c9ff8d, type: 3}
|
||||
m_Name: ABL_ChaoFeng_FanCombo
|
||||
m_EditorClassIdentifier:
|
||||
skillId: fan_combo
|
||||
displayName: FanCombo
|
||||
designNote:
|
||||
category: 0
|
||||
skillType: 0
|
||||
availablePhaseIndices: 00000000
|
||||
attackPatterns: []
|
||||
vulnerabilityWindows: []
|
||||
interactionTags: 0
|
||||
sequenceOnHit: {fileID: 0}
|
||||
sequenceOnMiss: {fileID: 0}
|
||||
counterResponses: []
|
||||
arenaEvents: []
|
||||
resourceCost:
|
||||
resourceId:
|
||||
cost: 0
|
||||
minRequired: 0
|
||||
buildsRage: 0
|
||||
poiseWindow:
|
||||
Level: 0
|
||||
NormalizedStart: 0
|
||||
NormalizedEnd: 0
|
||||
skillAnimation:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
_Events:
|
||||
_NormalizedTimes: []
|
||||
_Callbacks: []
|
||||
_Names: []
|
||||
_Clip: {fileID: 0}
|
||||
_NormalizedStartTime: NaN
|
||||
cooldown: 0
|
||||
abilityId: fan_combo
|
||||
designNote: "\u9636\u6BB5 0 \u62DB\u5F0F\uFF1B\u9636\u6BB5\u53EF\u7528\u6027\u7531
|
||||
BossPhaseAbilityGate \u914D\u7F6E"
|
||||
attackSequence: []
|
||||
cooldown: 2.5
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 1
|
||||
weight: 1.5
|
||||
references:
|
||||
version: 2
|
||||
RefIds: []
|
||||
rangeRadius: 2.5
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !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: fc81345eb5294d643b4242eac215c5fc, type: 3}
|
||||
m_Name: ABL_ChaoFeng_Intro
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: chaofeng_intro
|
||||
designNote: "\u5165\u573A\u6F14\u51FA\uFF0C\u4E0D\u8FDB\u9009\u62DB\u6C60"
|
||||
attackSequence: []
|
||||
cooldown: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 0
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
clip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
_Events:
|
||||
_NormalizedTimes: []
|
||||
_Callbacks: []
|
||||
_Names: []
|
||||
_Clip: {fileID: 0}
|
||||
_NormalizedStartTime: NaN
|
||||
references:
|
||||
version: 2
|
||||
RefIds: []
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e2f5df884c7f624da572ab81162da4d
|
||||
guid: eddeb2d9e64934d4eab9acf5efd8902e
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
@@ -9,32 +9,27 @@ MonoBehaviour:
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: de92221c7c3fb4a42a7cd122a8f97632, type: 3}
|
||||
m_Script: {fileID: 11500000, guid: fc81345eb5294d643b4242eac215c5fc, type: 3}
|
||||
m_Name: ABL_ChaoFeng_TornadoLarge
|
||||
m_EditorClassIdentifier:
|
||||
skillId: tornado_large
|
||||
displayName: TornadoLarge
|
||||
designNote:
|
||||
category: 0
|
||||
skillType: 0
|
||||
availablePhaseIndices: 00000000
|
||||
attackPatterns: []
|
||||
vulnerabilityWindows: []
|
||||
interactionTags: 0
|
||||
sequenceOnHit: {fileID: 0}
|
||||
sequenceOnMiss: {fileID: 0}
|
||||
counterResponses: []
|
||||
arenaEvents: []
|
||||
resourceCost:
|
||||
resourceId:
|
||||
cost: 0
|
||||
minRequired: 0
|
||||
buildsRage: 0
|
||||
poiseWindow:
|
||||
Level: 0
|
||||
NormalizedStart: 0
|
||||
NormalizedEnd: 0
|
||||
skillAnimation:
|
||||
abilityId: tornado_large
|
||||
designNote: "\u9636\u6BB5 0 \u62DB\u5F0F\uFF1B\u9636\u6BB5\u53EF\u7528\u6027\u7531
|
||||
BossPhaseAbilityGate \u914D\u7F6E"
|
||||
attackSequence: []
|
||||
cooldown: 6
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 1
|
||||
weight: 0.8
|
||||
rangeRadius: 12
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
clip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
_Events:
|
||||
@@ -43,8 +38,6 @@ MonoBehaviour:
|
||||
_Names: []
|
||||
_Clip: {fileID: 0}
|
||||
_NormalizedStartTime: NaN
|
||||
cooldown: 0
|
||||
weight: 0.8
|
||||
references:
|
||||
version: 2
|
||||
RefIds: []
|
||||
|
||||
@@ -9,32 +9,27 @@ MonoBehaviour:
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: de92221c7c3fb4a42a7cd122a8f97632, type: 3}
|
||||
m_Script: {fileID: 11500000, guid: fc81345eb5294d643b4242eac215c5fc, type: 3}
|
||||
m_Name: ABL_ChaoFeng_TornadoSmall
|
||||
m_EditorClassIdentifier:
|
||||
skillId: tornado_small
|
||||
displayName: TornadoSmall
|
||||
designNote:
|
||||
category: 0
|
||||
skillType: 0
|
||||
availablePhaseIndices: 00000000
|
||||
attackPatterns: []
|
||||
vulnerabilityWindows: []
|
||||
interactionTags: 0
|
||||
sequenceOnHit: {fileID: 0}
|
||||
sequenceOnMiss: {fileID: 0}
|
||||
counterResponses: []
|
||||
arenaEvents: []
|
||||
resourceCost:
|
||||
resourceId:
|
||||
cost: 0
|
||||
minRequired: 0
|
||||
buildsRage: 0
|
||||
poiseWindow:
|
||||
Level: 0
|
||||
NormalizedStart: 0
|
||||
NormalizedEnd: 0
|
||||
skillAnimation:
|
||||
abilityId: tornado_small
|
||||
designNote: "\u9636\u6BB5 0 \u62DB\u5F0F\uFF1B\u9636\u6BB5\u53EF\u7528\u6027\u7531
|
||||
BossPhaseAbilityGate \u914D\u7F6E"
|
||||
attackSequence: []
|
||||
cooldown: 3.5
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 1
|
||||
weight: 1.2
|
||||
rangeRadius: 8
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
clip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
_Events:
|
||||
@@ -43,8 +38,6 @@ MonoBehaviour:
|
||||
_Names: []
|
||||
_Clip: {fileID: 0}
|
||||
_NormalizedStartTime: NaN
|
||||
cooldown: 0
|
||||
weight: 1.2
|
||||
references:
|
||||
version: 2
|
||||
RefIds: []
|
||||
|
||||
@@ -9,32 +9,27 @@ MonoBehaviour:
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: de92221c7c3fb4a42a7cd122a8f97632, type: 3}
|
||||
m_Script: {fileID: 11500000, guid: fc81345eb5294d643b4242eac215c5fc, type: 3}
|
||||
m_Name: ABL_ChaoFeng_WindStone
|
||||
m_EditorClassIdentifier:
|
||||
skillId: wind_stone
|
||||
displayName: WindStone
|
||||
designNote:
|
||||
category: 0
|
||||
skillType: 0
|
||||
availablePhaseIndices: 01000000
|
||||
attackPatterns: []
|
||||
vulnerabilityWindows: []
|
||||
interactionTags: 0
|
||||
sequenceOnHit: {fileID: 0}
|
||||
sequenceOnMiss: {fileID: 0}
|
||||
counterResponses: []
|
||||
arenaEvents: []
|
||||
resourceCost:
|
||||
resourceId:
|
||||
cost: 0
|
||||
minRequired: 0
|
||||
buildsRage: 0
|
||||
poiseWindow:
|
||||
Level: 0
|
||||
NormalizedStart: 0
|
||||
NormalizedEnd: 0
|
||||
skillAnimation:
|
||||
abilityId: wind_stone
|
||||
designNote: "\u9636\u6BB5 1 \u62DB\u5F0F\uFF1B\u9636\u6BB5\u53EF\u7528\u6027\u7531
|
||||
BossPhaseAbilityGate \u914D\u7F6E"
|
||||
attackSequence: []
|
||||
cooldown: 3
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 0
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 1
|
||||
weight: 1
|
||||
rangeRadius: 12
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
clip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
_Events:
|
||||
@@ -43,8 +38,6 @@ MonoBehaviour:
|
||||
_Names: []
|
||||
_Clip: {fileID: 0}
|
||||
_NormalizedStartTime: NaN
|
||||
cooldown: 0
|
||||
weight: 1
|
||||
references:
|
||||
version: 2
|
||||
RefIds: []
|
||||
|
||||
@@ -13,18 +13,21 @@ MonoBehaviour:
|
||||
m_Name: ABL_E001_Chase
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e001_chase
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
startClip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
@@ -53,6 +56,7 @@ MonoBehaviour:
|
||||
_Clip: {fileID: 7400000, guid: 9f0fea347ba7eb347b0f71b423832808, type: 2}
|
||||
_NormalizedStartTime: NaN
|
||||
dashSpeed: 9
|
||||
maxDashDuration: 2
|
||||
references:
|
||||
version: 2
|
||||
RefIds: []
|
||||
|
||||
@@ -13,18 +13,21 @@ MonoBehaviour:
|
||||
m_Name: ABL_E002_CeilingStrike
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e002_ceiling_strike
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
strikeClip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
|
||||
@@ -13,18 +13,21 @@ MonoBehaviour:
|
||||
m_Name: ABL_E003_Fall
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e003_fall
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
fallLoopClip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
|
||||
@@ -13,11 +13,10 @@ MonoBehaviour:
|
||||
m_Name: ABL_E004_Acid
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e004_acid
|
||||
designNote:
|
||||
attackSequence:
|
||||
- {fileID: 11400000, guid: b0bce0e3cbc007842a69b60d6cf12792, type: 2}
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
@@ -26,3 +25,7 @@ MonoBehaviour:
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
|
||||
@@ -13,18 +13,21 @@ MonoBehaviour:
|
||||
m_Name: ABL_E004_Appear
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e004_appear
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
appearClip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
|
||||
@@ -13,10 +13,9 @@ MonoBehaviour:
|
||||
m_Name: ABL_E004_Bite
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e004_bite
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
@@ -25,3 +24,7 @@ MonoBehaviour:
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
|
||||
@@ -13,18 +13,21 @@ MonoBehaviour:
|
||||
m_Name: ABL_E004_Flip
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e004_flip
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
faceClip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
|
||||
@@ -13,18 +13,21 @@ MonoBehaviour:
|
||||
m_Name: ABL_E004_HeadSlam
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e004_headslam
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
startClip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
|
||||
@@ -13,12 +13,11 @@ MonoBehaviour:
|
||||
m_Name: ABL_E005_Acid
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e005_acid
|
||||
designNote:
|
||||
attackSequence:
|
||||
- {fileID: 11400000, guid: cabdf15fd8ee5c8449c6236751ada4f5, type: 2}
|
||||
- {fileID: 11400000, guid: 800b72bef8e5be446aa56ec4993ba21d, type: 2}
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
@@ -27,3 +26,7 @@ MonoBehaviour:
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
|
||||
@@ -13,10 +13,9 @@ MonoBehaviour:
|
||||
m_Name: ABL_E005_Bite
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e005_bite
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
@@ -25,3 +24,7 @@ MonoBehaviour:
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
|
||||
@@ -13,18 +13,21 @@ MonoBehaviour:
|
||||
m_Name: ABL_E006_Chase
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e006_chase
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
preferredMaxRange: 5
|
||||
requiresLineOfSight: 1
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
startClip:
|
||||
_FadeDuration: 0.25
|
||||
_Speed: 1
|
||||
@@ -53,6 +56,7 @@ MonoBehaviour:
|
||||
_Clip: {fileID: 0}
|
||||
_NormalizedStartTime: NaN
|
||||
dashSpeed: 0
|
||||
maxDashDuration: 2
|
||||
references:
|
||||
version: 2
|
||||
RefIds: []
|
||||
|
||||
@@ -13,10 +13,9 @@ MonoBehaviour:
|
||||
m_Name: ABL_E006_Leap
|
||||
m_EditorClassIdentifier:
|
||||
abilityId: e006_leap
|
||||
designNote:
|
||||
attackSequence: []
|
||||
cooldown: 1.5
|
||||
telegraphVfxKey:
|
||||
telegraphDuration: 0
|
||||
interruptOnHurt: 1
|
||||
interruptOnStagger: 1
|
||||
preferredMinRange: 0
|
||||
@@ -25,3 +24,7 @@ MonoBehaviour:
|
||||
requiresGrounded: 1
|
||||
exclusionGroup:
|
||||
priority: 0
|
||||
category: 0
|
||||
weight: 1
|
||||
rangeRadius: 0
|
||||
rangeOffset: {x: 0, y: 0}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !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: 8230eab2acba8c24499b2d20df81adb7, type: 3}
|
||||
m_Name: EVT_BossSkill
|
||||
m_EditorClassIdentifier:
|
||||
description:
|
||||
@@ -1,5 +1,58 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &979165801027136474
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 5181544129696273283}
|
||||
- component: {fileID: 7940098084422673156}
|
||||
m_Layer: 0
|
||||
m_Name: MeleeAttackAbility_FanCombo
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &5181544129696273283
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 979165801027136474}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 5296480593323786894}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &7940098084422673156
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 979165801027136474}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 971ba82e05d87234e8b944760542e47c, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_config: {fileID: 11400000, guid: b13d174edfd74654188f1cd08f072123, type: 2}
|
||||
_hitBoxSlots:
|
||||
- slotName: fan_1
|
||||
hitBox: {fileID: 230484937522067432}
|
||||
- slotName: fan_2
|
||||
hitBox: {fileID: 9170136654108653383}
|
||||
- slotName: fan_3
|
||||
hitBox: {fileID: 4630646362173111049}
|
||||
_faceTargetOnStart: 1
|
||||
--- !u!1 &1231266844344956596
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -62,6 +115,51 @@ Transform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2025611111464161772}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &1825922247716397416
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 8959989091743334252}
|
||||
- component: {fileID: 8219329344573822582}
|
||||
m_Layer: 0
|
||||
m_Name: PlayClipAbility_Intro
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &8959989091743334252
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1825922247716397416}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 5296480593323786894}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &8219329344573822582
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1825922247716397416}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a26fca0fa72894a4da1a5a58ee023154, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_config: {fileID: 11400000, guid: eddeb2d9e64934d4eab9acf5efd8902e, type: 2}
|
||||
--- !u!1 &2290525692157171072
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -191,7 +289,7 @@ Transform:
|
||||
m_GameObject: {fileID: 2904988631977355437}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalPosition: {x: 0, y: 1, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
@@ -285,7 +383,7 @@ SpriteRenderer:
|
||||
m_WasSpriteAssigned: 1
|
||||
m_MaskInteraction: 0
|
||||
m_SpriteSortPoint: 0
|
||||
--- !u!1 &3015807690815610513
|
||||
--- !u!1 &2916559493138038639
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
@@ -293,99 +391,88 @@ GameObject:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 1409576167468823365}
|
||||
- component: {fileID: 2458103135947848963}
|
||||
- component: {fileID: 2450883169310398879}
|
||||
m_Layer: 25
|
||||
m_Name: Phase1_Tornado_HitBox
|
||||
- component: {fileID: 7149986575129040968}
|
||||
- component: {fileID: 209352783170023499}
|
||||
m_Layer: 0
|
||||
m_Name: PlayClipAbility_Boomerang
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 0
|
||||
--- !u!4 &1409576167468823365
|
||||
m_IsActive: 1
|
||||
--- !u!4 &7149986575129040968
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3015807690815610513}
|
||||
m_GameObject: {fileID: 2916559493138038639}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2025611111464161772}
|
||||
m_Father: {fileID: 5296480593323786894}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!61 &2458103135947848963
|
||||
BoxCollider2D:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3015807690815610513}
|
||||
m_Enabled: 1
|
||||
m_Density: 1
|
||||
m_Material: {fileID: 0}
|
||||
m_IncludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_ExcludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_LayerOverridePriority: 0
|
||||
m_ForceSendLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ForceReceiveLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ContactCaptureLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_CallbackLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
oldSize: {x: 0, y: 0}
|
||||
newSize: {x: 0, y: 0}
|
||||
adaptiveTilingThreshold: 0
|
||||
drawMode: 0
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 0.6, y: 1.2}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!114 &2450883169310398879
|
||||
--- !u!114 &209352783170023499
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3015807690815610513}
|
||||
m_GameObject: {fileID: 2916559493138038639}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a655e2461396a8348a32a13144438e8e, type: 3}
|
||||
m_Script: {fileID: 11500000, guid: a26fca0fa72894a4da1a5a58ee023154, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_defaultSource: {fileID: 11400000, guid: caae9c7600281fe4e8d8637fa3fd2ca1, type: 2}
|
||||
_hitCooldown: 0.1
|
||||
_hitMode: 0
|
||||
_hitInterval: 0.5
|
||||
_targetLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
_id:
|
||||
_rivalHitBoxMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
_config: {fileID: 11400000, guid: 6076c1b736f69af4c9048d9d4e7ec768, type: 2}
|
||||
--- !u!1 &3224375973558534567
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 6052463430739264899}
|
||||
- component: {fileID: 1436318798284677237}
|
||||
m_Layer: 0
|
||||
m_Name: PlayClipAbility_TornadoLarge
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &6052463430739264899
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3224375973558534567}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 5296480593323786894}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &1436318798284677237
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3224375973558534567}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a26fca0fa72894a4da1a5a58ee023154, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_config: {fileID: 11400000, guid: 27037bf4ed7188741ba55bb82fdd8fa9, type: 2}
|
||||
--- !u!1 &3277627412355666927
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -487,6 +574,43 @@ MonoBehaviour:
|
||||
_rivalHitBoxMask:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
--- !u!1 &3801371647583863605
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 5296480593323786894}
|
||||
m_Layer: 0
|
||||
m_Name: Abilities
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &5296480593323786894
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 3801371647583863605}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 8959989091743334252}
|
||||
- {fileID: 5181544129696273283}
|
||||
- {fileID: 7149986575129040968}
|
||||
- {fileID: 3558980470815650670}
|
||||
- {fileID: 6052463430739264899}
|
||||
- {fileID: 4717475142090746934}
|
||||
m_Father: {fileID: 2025611111464161772}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &4177669747969163254
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -634,7 +758,10 @@ GameObject:
|
||||
- component: {fileID: 973594085864951384}
|
||||
- component: {fileID: 5341485012012430190}
|
||||
- component: {fileID: 8088437629491012474}
|
||||
- component: {fileID: 3437191888218430966}
|
||||
- component: {fileID: 3133205082234065514}
|
||||
- component: {fileID: 3942835667027582048}
|
||||
- component: {fileID: 4739322141714049461}
|
||||
- component: {fileID: 5585087268776340831}
|
||||
- component: {fileID: 1810872916174435854}
|
||||
- component: {fileID: 1201243240150589714}
|
||||
- component: {fileID: 6858366591425580670}
|
||||
@@ -654,20 +781,20 @@ Transform:
|
||||
m_GameObject: {fileID: 7781161515165213226}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 1968.4287, y: 77.1997, z: 0}
|
||||
m_LocalPosition: {x: 98, y: 21, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 5517492589801030229}
|
||||
- {fileID: 6397871098464242200}
|
||||
- {fileID: 80973138644072513}
|
||||
- {fileID: 376149298811787739}
|
||||
- {fileID: 8176707102135037433}
|
||||
- {fileID: 8814706017035826473}
|
||||
- {fileID: 1409576167468823365}
|
||||
- {fileID: 7580995191264333933}
|
||||
- {fileID: 2397879757105861778}
|
||||
- {fileID: 4107279870065028942}
|
||||
- {fileID: 80973138644072513}
|
||||
- {fileID: 5296480593323786894}
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!50 &6158877404411547497
|
||||
@@ -729,7 +856,7 @@ BoxCollider2D:
|
||||
m_IsTrigger: 0
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Offset: {x: 0, y: 1}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
@@ -763,7 +890,7 @@ MonoBehaviour:
|
||||
_animancer: {fileID: 8300109994792221770}
|
||||
_feedback: {fileID: 5341485012012430190}
|
||||
_hurtBox: {fileID: 6579043664862402528}
|
||||
_bodyCollider: {fileID: 0}
|
||||
_bodyCollider: {fileID: 4474032822853512122}
|
||||
_patrolZone: {fileID: 0}
|
||||
_onEnemyDied: {fileID: 11400000, guid: def849e2c5ec8204eae6b083b02307aa, type: 2}
|
||||
_onPlayerSpawned: {fileID: 11400000, guid: 7e2c7e614f6627b449a244ab44443adf, type: 2}
|
||||
@@ -775,6 +902,8 @@ MonoBehaviour:
|
||||
_onBossPhaseChanged: {fileID: 11400000, guid: 9f49e575a92a7fb43af755ba1840abd2, type: 2}
|
||||
_skillExecutor: {fileID: 0}
|
||||
_bossResource: {fileID: 0}
|
||||
_phaseGate: {fileID: 5585087268776340831}
|
||||
_arenaAnchors: {fileID: 0}
|
||||
_onParrySuccess: {fileID: 0}
|
||||
_floatController: {fileID: 1810872916174435854}
|
||||
_knockdownCounter: {fileID: 1201243240150589714}
|
||||
@@ -908,14 +1037,13 @@ MonoBehaviour:
|
||||
_dbg_IsWallAhead: 0
|
||||
_dbg_IsLedgeAhead: 0
|
||||
_dbg_IsTurning: 0
|
||||
_dbg_NavDriving: 0
|
||||
_dbg_Input_MoveDir: 0
|
||||
_dbg_Input_MoveSpeed: 0
|
||||
_dbg_Input_WantStop: 0
|
||||
_dbg_Input_WantFace: 0
|
||||
_dbg_Input_FaceTargetPos: {x: 0, y: 0}
|
||||
_dbg_Input_FaceDir: 0
|
||||
--- !u!114 &3437191888218430966
|
||||
--- !u!114 &3133205082234065514
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
@@ -924,27 +1052,69 @@ MonoBehaviour:
|
||||
m_GameObject: {fileID: 7781161515165213226}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 4dfa1c525eaca5640b3cfe945626a466, type: 3}
|
||||
m_Script: {fileID: 11500000, guid: 8caa8e247c70b7445820efe80d2da0ec, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_hitBoxes:
|
||||
- {fileID: 230484937522067432}
|
||||
- {fileID: 9170136654108653383}
|
||||
- {fileID: 4630646362173111049}
|
||||
- {fileID: 2450883169310398879}
|
||||
_weakPointSystem: {fileID: 0}
|
||||
_animancer: {fileID: 8300109994792221770}
|
||||
_bossId: ChaoFeng
|
||||
_onBossSkillStarted: {fileID: 0}
|
||||
_onBossSkillEnded: {fileID: 0}
|
||||
_playerTransform: {fileID: 0}
|
||||
_skills:
|
||||
- {fileID: 11400000, guid: 6076c1b736f69af4c9048d9d4e7ec768, type: 2}
|
||||
- {fileID: 11400000, guid: b13d174edfd74654188f1cd08f072123, type: 2}
|
||||
- {fileID: 11400000, guid: 7cb2926dd5b97e64b9e37f07124ae307, type: 2}
|
||||
- {fileID: 11400000, guid: 27037bf4ed7188741ba55bb82fdd8fa9, type: 2}
|
||||
- {fileID: 11400000, guid: 02b79b9dc903c824786ed3cc3c3e225e, type: 2}
|
||||
_repeatRangeCheck: 8
|
||||
_stoppingDistance: 0.2
|
||||
_wanderMinRange: 1.5
|
||||
_wanderMaxRange: 5
|
||||
_stallWindowSeconds: 0.4
|
||||
_stallMinProgress: 0.05
|
||||
--- !u!114 &3942835667027582048
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7781161515165213226}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 9201463979893884288d3645a607eb90, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_patrolStrategy: 0
|
||||
_wanderPauseMin: 0.5
|
||||
_wanderPauseMax: 1.5
|
||||
_waypoints: []
|
||||
_pingPong: 0
|
||||
_waypointArriveRadius: 0.4
|
||||
--- !u!114 &4739322141714049461
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7781161515165213226}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: d92d657c6bbdfe246a7333e04f79f8f7, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_recipe: {fileID: 0}
|
||||
_definitionId: ChaoFeng
|
||||
--- !u!114 &5585087268776340831
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7781161515165213226}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a9f265c84eb5db34b8fd06472949ee83, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_entries:
|
||||
- ability: {fileID: 7940098084422673156}
|
||||
phases: 00000000
|
||||
- ability: {fileID: 209352783170023499}
|
||||
phases: 00000000
|
||||
- ability: {fileID: 536854426800190861}
|
||||
phases: 00000000
|
||||
- ability: {fileID: 1436318798284677237}
|
||||
phases: 00000000
|
||||
- ability: {fileID: 9004908790283913037}
|
||||
phases: 01000000
|
||||
--- !u!114 &1810872916174435854
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -1115,6 +1285,96 @@ MonoBehaviour:
|
||||
obstructLayer:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
--- !u!1 &7806369876961327953
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 3558980470815650670}
|
||||
- component: {fileID: 536854426800190861}
|
||||
m_Layer: 0
|
||||
m_Name: PlayClipAbility_TornadoSmall
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &3558980470815650670
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7806369876961327953}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 5296480593323786894}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &536854426800190861
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7806369876961327953}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a26fca0fa72894a4da1a5a58ee023154, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_config: {fileID: 11400000, guid: 7cb2926dd5b97e64b9e37f07124ae307, type: 2}
|
||||
--- !u!1 &7888626137513785884
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 4717475142090746934}
|
||||
- component: {fileID: 9004908790283913037}
|
||||
m_Layer: 0
|
||||
m_Name: PlayClipAbility_WindStone
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!4 &4717475142090746934
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7888626137513785884}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 5296480593323786894}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!114 &9004908790283913037
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7888626137513785884}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a26fca0fa72894a4da1a5a58ee023154, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_config: {fileID: 11400000, guid: 02b79b9dc903c824786ed3cc3c3e225e, type: 2}
|
||||
--- !u!1 &8109938981782931710
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -1124,7 +1384,7 @@ GameObject:
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 6397871098464242200}
|
||||
- component: {fileID: 5510659119725211148}
|
||||
- component: {fileID: 5509146531381767278}
|
||||
- component: {fileID: 6579043664862402528}
|
||||
m_Layer: 27
|
||||
m_Name: HurtBox
|
||||
@@ -1148,8 +1408,8 @@ Transform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2025611111464161772}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!70 &5510659119725211148
|
||||
CapsuleCollider2D:
|
||||
--- !u!61 &5509146531381767278
|
||||
BoxCollider2D:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
@@ -1180,9 +1440,19 @@ CapsuleCollider2D:
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Size: {x: 1.1, y: 1.9}
|
||||
m_Direction: 0
|
||||
m_Offset: {x: 0, y: 1}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
oldSize: {x: 0, y: 0}
|
||||
newSize: {x: 0, y: 0}
|
||||
adaptiveTilingThreshold: 0
|
||||
drawMode: 0
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 1.2, y: 2}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!114 &6579043664862402528
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -1195,7 +1465,7 @@ MonoBehaviour:
|
||||
m_Script: {fileID: 11500000, guid: d7b7a233d7f70aa4f86b473412b826de, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_onDamageDealt: {fileID: 0}
|
||||
_onDamageDealt: {fileID: 11400000, guid: eebd58cfef3527940949a8a7655a343b, type: 2}
|
||||
_onHitConfirmed: {fileID: 11400000, guid: a67d56f5124e0db4f98f326c74be8091, type: 2}
|
||||
--- !u!1 &8920059952006723162
|
||||
GameObject:
|
||||
@@ -1206,7 +1476,7 @@ GameObject:
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 80973138644072513}
|
||||
- component: {fileID: 6125666153035542197}
|
||||
- component: {fileID: 4535625604443592544}
|
||||
- component: {fileID: 4960688227238377219}
|
||||
- component: {fileID: 3072716489886911545}
|
||||
m_Layer: 25
|
||||
@@ -1231,8 +1501,8 @@ Transform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2025611111464161772}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!70 &6125666153035542197
|
||||
CapsuleCollider2D:
|
||||
--- !u!61 &4535625604443592544
|
||||
BoxCollider2D:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
@@ -1263,9 +1533,19 @@ CapsuleCollider2D:
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Size: {x: 1.1, y: 1.9}
|
||||
m_Direction: 0
|
||||
m_Offset: {x: 0, y: 1}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
oldSize: {x: 0, y: 0}
|
||||
newSize: {x: 0, y: 0}
|
||||
adaptiveTilingThreshold: 0
|
||||
drawMode: 0
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 1.2, y: 2}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!114 &4960688227238377219
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
|
||||
@@ -7280,6 +7280,103 @@ MonoBehaviour:
|
||||
BarrelClipping: 0.25
|
||||
Anamorphism: 0
|
||||
BlendHint: 0
|
||||
--- !u!1 &378930082
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 378930085}
|
||||
- component: {fileID: 378930084}
|
||||
- component: {fileID: 378930083}
|
||||
m_Layer: 24
|
||||
m_Name: BossFightTrigger_ChaoFeng
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &378930083
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 378930082}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bd8a9d00a149c124a9bd90ffba663a32, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_bossBrain: {fileID: 2029340551999249091}
|
||||
_onBossFightStarted: {fileID: 11400000, guid: 6ac21fc2929c8ce4b9f56df680ad122b, type: 2}
|
||||
_onBossFightToggled: {fileID: 11400000, guid: f367dc6d9a2848241b54799ce37f7288, type: 2}
|
||||
_bossId: ChaoFeng
|
||||
_playerLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 512
|
||||
--- !u!61 &378930084
|
||||
BoxCollider2D:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 378930082}
|
||||
m_Enabled: 1
|
||||
m_Density: 1
|
||||
m_Material: {fileID: 0}
|
||||
m_IncludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_ExcludeLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 0
|
||||
m_LayerOverridePriority: 0
|
||||
m_ForceSendLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ForceReceiveLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_ContactCaptureLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_CallbackLayers:
|
||||
serializedVersion: 2
|
||||
m_Bits: 4294967295
|
||||
m_IsTrigger: 1
|
||||
m_UsedByEffector: 0
|
||||
m_UsedByComposite: 0
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_SpriteTilingProperty:
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
pivot: {x: 0, y: 0}
|
||||
oldSize: {x: 0, y: 0}
|
||||
newSize: {x: 0, y: 0}
|
||||
adaptiveTilingThreshold: 0
|
||||
drawMode: 0
|
||||
adaptiveTiling: 0
|
||||
m_AutoTiling: 0
|
||||
serializedVersion: 2
|
||||
m_Size: {x: 4, y: 6}
|
||||
m_EdgeRadius: 0
|
||||
--- !u!4 &378930085
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 378930082}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 87, y: 21, z: 0}
|
||||
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!4 &379870868 stripped
|
||||
Transform:
|
||||
m_CorrespondingSourceObject: {fileID: 8020630769765462792, guid: dadbcefa02b3d0f4ba27af93ff088166, type: 3}
|
||||
@@ -215493,11 +215590,11 @@ PrefabInstance:
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 8020630769765462792, guid: dadbcefa02b3d0f4ba27af93ff088166, type: 3}
|
||||
propertyPath: m_LocalPosition.x
|
||||
value: -10.91
|
||||
value: 79.8
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 8020630769765462792, guid: dadbcefa02b3d0f4ba27af93ff088166, type: 3}
|
||||
propertyPath: m_LocalPosition.y
|
||||
value: 7.63
|
||||
value: 24.6
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 8020630769765462792, guid: dadbcefa02b3d0f4ba27af93ff088166, type: 3}
|
||||
propertyPath: m_LocalPosition.z
|
||||
@@ -217899,6 +217996,74 @@ Transform:
|
||||
- {fileID: 505494684}
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1001 &2029340551999249090
|
||||
PrefabInstance:
|
||||
m_ObjectHideFlags: 0
|
||||
serializedVersion: 2
|
||||
m_Modification:
|
||||
serializedVersion: 3
|
||||
m_TransformParent: {fileID: 0}
|
||||
m_Modifications:
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalPosition.x
|
||||
value: 94.9
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalPosition.y
|
||||
value: 23.81
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalPosition.z
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalRotation.w
|
||||
value: 1
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalRotation.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalRotation.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalRotation.z
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.x
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.y
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 2025611111464161772, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_LocalEulerAnglesHint.z
|
||||
value: 0
|
||||
objectReference: {fileID: 0}
|
||||
- target: {fileID: 7781161515165213226, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
propertyPath: m_Name
|
||||
value: ENM_ChaoFeng
|
||||
objectReference: {fileID: 0}
|
||||
m_RemovedComponents: []
|
||||
m_RemovedGameObjects: []
|
||||
m_AddedGameObjects: []
|
||||
m_AddedComponents: []
|
||||
m_SourcePrefab: {fileID: 100100000, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
--- !u!114 &2029340551999249091 stripped
|
||||
MonoBehaviour:
|
||||
m_CorrespondingSourceObject: {fileID: 4739322141714049461, guid: e55e94346ed15ce40bc0ae5aa7771ea6, type: 3}
|
||||
m_PrefabInstance: {fileID: 2029340551999249090}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: d92d657c6bbdfe246a7333e04f79f8f7, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
--- !u!1660057539 &9223372036854775807
|
||||
SceneRoots:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -217912,3 +218077,5 @@ SceneRoots:
|
||||
- {fileID: 960547555}
|
||||
- {fileID: 445140214}
|
||||
- {fileID: 310446327}
|
||||
- {fileID: 2029340551999249090}
|
||||
- {fileID: 378930085}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
namespace BaseGames.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。
|
||||
/// 外部 → 决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。
|
||||
/// 事件边不受 IsControllable 让位门阻挡。
|
||||
///
|
||||
/// 只保留真实存在发送方与消费方的信号。受击 / 硬直 / 击飞 / 弹反**不在此列**——
|
||||
/// 它们由敌人物理状态机(EnemyStateType)处理,决策层经 IActorVitals.IsControllable
|
||||
/// 让位门自动挂起。在这里重复定义会诱导写出与物理层打架的第二套受击逻辑。
|
||||
///
|
||||
/// 新增信号的判据:已经有确定的发送方与消费方,否则不要预留。
|
||||
/// 新增信号的判据:已经有确定的发送方与消费方,否则不要预留——无发送方的占位枚举只会变成死值。
|
||||
/// </summary>
|
||||
public enum AiSignal
|
||||
{
|
||||
/// <summary>敌人已死亡:由 EnemyBase 的死亡流程发出,让决策停止。</summary>
|
||||
Died,
|
||||
|
||||
/// <summary>开战:由竞技场开战触发器发出,让 Boss 从静置态进入入场演出。</summary>
|
||||
Engaged,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ namespace BaseGames.AI
|
||||
public Action<IAiContext> OnExit { get; internal set; }
|
||||
public IReadOnlyList<Transition> Transitions => _transitions;
|
||||
|
||||
/// <summary>
|
||||
/// 是否为终态:进入后不再离开是设计意图,而非漏挂出边。
|
||||
/// 由 <see cref="BrainBuilder.StateBuilder.Terminal"/> 标记,供 Build() 的死角校验放行。
|
||||
/// </summary>
|
||||
public bool IsTerminal { get; internal set; }
|
||||
|
||||
internal void AddTransition(Transition t) => _transitions.Add(t);
|
||||
|
||||
public AiState(string name) => Name = name;
|
||||
|
||||
@@ -59,7 +59,22 @@ namespace BaseGames.AI
|
||||
|
||||
internal void AddGlobal(Transition t) => _globals.Add(t);
|
||||
|
||||
public AiGraph Build()
|
||||
/// <summary>
|
||||
/// 构建**完整**图。除了引用完整性,还校验「非终态必须有出口」。
|
||||
/// 生产的两个装配点(AiScript.GetOrBuildGraph / AiRecipeSO)都走这里,
|
||||
/// 将来新增的入口默认即受保护——这正是校验放在 Build() 而非各入口的理由。
|
||||
/// </summary>
|
||||
public AiGraph Build() => BuildInternal(checkDeadEnds: true);
|
||||
|
||||
/// <summary>
|
||||
/// 构建**部分**图:跳过「非终态必须有出口」校验,其余校验照旧。
|
||||
/// 单个模块(IUnawareModule / IEngagementModule)按设计只声明自己的态,
|
||||
/// 升级边由 PerceptionSkeleton 事后挂上——单独构建时的死角是正常中间态,不是缺陷。
|
||||
/// 仅用于隔离验证单个模块或片段;装配真实敌人图一律用 <see cref="Build"/>。
|
||||
/// </summary>
|
||||
public AiGraph BuildPartial() => BuildInternal(checkDeadEnds: false);
|
||||
|
||||
AiGraph BuildInternal(bool checkDeadEnds)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_entry))
|
||||
throw new InvalidOperationException("BrainBuilder: 未设置 Entry 状态。");
|
||||
@@ -76,9 +91,35 @@ namespace BaseGames.AI
|
||||
throw new InvalidOperationException(
|
||||
$"BrainBuilder: 全局转换指向未声明状态 '{t.Target}'。");
|
||||
|
||||
if (checkDeadEnds)
|
||||
{
|
||||
foreach (var s in _states.Values)
|
||||
if (!s.IsTerminal && !HasEscape(s))
|
||||
throw new InvalidOperationException(
|
||||
$"BrainBuilder: 状态 '{s.Name}' 不是终态,却没有任何指向其他状态的出边。" +
|
||||
"进入后会永久停在这里,且全程零报错。请给它挂出边;" +
|
||||
"若「进去就不出来」本就是设计意图(如死亡态)," +
|
||||
"请用 AiStateFragments.Terminal() 声明,或对已有 builder 调 .Terminal() 标记;" +
|
||||
"若这是隔离测试单个模块的部分图,请改用 BuildPartial()。");
|
||||
}
|
||||
|
||||
return new AiGraph(_entry, _states, _globals);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 该状态是否有真正的出口。两点刻意的判定:
|
||||
/// 1. 自转换不算——AiRuntime.Switch 对 Target == 当前态直接 return false,出不去;
|
||||
/// 2. 全局转换不算——全局事件边要外部推信号才触发,不是自主出口。把它算作出口,
|
||||
/// 等于让「只有死了才出得去」的死角通过校验,而那正是本校验要暴露的东西。
|
||||
/// </summary>
|
||||
static bool HasEscape(AiState s)
|
||||
{
|
||||
var ts = s.Transitions;
|
||||
for (int i = 0; i < ts.Count; i++)
|
||||
if (ts[i].Target != s.Name) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public sealed class StateBuilder
|
||||
{
|
||||
readonly AiState _state;
|
||||
@@ -89,6 +130,13 @@ namespace BaseGames.AI
|
||||
public StateBuilder Tick(Action<IAiContext, float> fn) { _state.OnTick = fn; return this; }
|
||||
public StateBuilder OnExit(Action<IAiContext> fn) { _state.OnExit = fn; return this; }
|
||||
|
||||
/// <summary>
|
||||
/// 标记为终态:声明"进入后不再离开"是设计意图。
|
||||
/// Build() 会对非终态且无出口的态抛异常,本标记是唯一的豁免方式——
|
||||
/// 用它而不是随手挂一条永假的边,意图才留在代码里。
|
||||
/// </summary>
|
||||
public StateBuilder Terminal() { _state.IsTerminal = true; return this; }
|
||||
|
||||
public TransitionBuilder To(string target) => new TransitionBuilder(this, _state, target);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,5 +11,11 @@ namespace BaseGames.AI
|
||||
ICombatant Combat { get; }
|
||||
IActorVitals Vitals { get; }
|
||||
Blackboard Blackboard { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Boss 专属决策面。**非 Boss 敌人访问本属性会抛 InvalidOperationException**——
|
||||
/// 小怪图误用 Boss 边必须立刻暴露,不返回空对象把问题盖住。
|
||||
/// </summary>
|
||||
IBossControl Boss { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss 专属决策面。挂在 <see cref="IAiContext"/> 上供 Boss 图读取阶段状态、发起阶段过渡。
|
||||
/// 只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。
|
||||
/// 非 Boss 敌人访问 <see cref="IAiContext.Boss"/> 会抛异常——这是有意的显式失败。
|
||||
/// </summary>
|
||||
public interface IBossControl
|
||||
{
|
||||
/// <summary>当前阶段索引(出生为 0)。</summary>
|
||||
int CurrentPhase { get; }
|
||||
|
||||
/// <summary>是否处于阶段过渡(无敌 + 过渡演出)期间。</summary>
|
||||
bool IsPhaseTransitioning { get; }
|
||||
|
||||
/// <summary>Boss 资源是否已满。未挂资源组件时抛异常——图问了资源却没配组件是配置错误。</summary>
|
||||
bool ResourceFull { get; }
|
||||
|
||||
/// <summary>发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。
|
||||
/// 过渡进行中重复调用会被忽略并告警——应在 OnEnter 调用一次,不要放在 Tick。</summary>
|
||||
void BeginPhaseTransition(int targetPhase, float invincibleDuration);
|
||||
|
||||
/// <summary>第 index 个竞技场锚点的世界坐标。未挂锚点组件 / 下标越界即抛。</summary>
|
||||
Vector2 AnchorAt(int index);
|
||||
|
||||
/// <summary>Boss 当前位置到第 index 个锚点的距离。未挂锚点组件 / 下标越界即抛。</summary>
|
||||
float DistanceToAnchor(int index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 20ae59ebfd68f04488a369b1c1911040
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using Animancer;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Feedback;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Animation
|
||||
@@ -9,10 +10,10 @@ namespace BaseGames.Animation
|
||||
/// <summary>
|
||||
/// 敌人动画事件接收器(架构 §AnimationModule)。
|
||||
/// 挂载于敌人 Prefab 根节点。负责将 Animancer 动画时间点回调路由到
|
||||
/// HitBox 激活、弹幕生成、嘶吼状态、二阶段切换等系统。
|
||||
/// HitBox 激活、弹幕生成、嘶吼状态、二阶段切换、无敌帧、反馈等系统。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// 1. 在 Inspector 中填充 _hitBoxes、_enemy。
|
||||
/// 1. 在 Inspector 中填充 _hitBoxes、_hurtBox、_enemy。
|
||||
/// 2. 将每条 ClipTransition + AnimationEventConfigSO 配对添加到 _bindings。
|
||||
/// 3. Awake 中 AnimationEventBinder.Bind 自动完成注入。
|
||||
/// </summary>
|
||||
@@ -30,16 +31,23 @@ namespace BaseGames.Animation
|
||||
|
||||
[Header("子系统引用")]
|
||||
[SerializeField] private HitBox[] _hitBoxes;
|
||||
[SerializeField] private HurtBox _hurtBox;
|
||||
[SerializeField] private EnemyBase _enemy;
|
||||
|
||||
[Header("事件绑定(每个 Clip 对应一个配置资产)")]
|
||||
[SerializeField] private EventBinding[] _bindings;
|
||||
|
||||
private IFeedbackPlayer _feedback;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_enemy == null)
|
||||
_enemy = GetComponentInParent<EnemyBase>();
|
||||
|
||||
// IFeedbackPlayer 由父层或同层实现(EnemyFeedback / NullFeedbackPlayer),与玩家侧同写法
|
||||
_feedback = GetComponentInParent<IFeedbackPlayer>()
|
||||
?? NullFeedbackPlayer.Instance;
|
||||
|
||||
foreach (var b in _bindings)
|
||||
AnimationEventBinder.Bind(b.clip, b.config, this);
|
||||
}
|
||||
@@ -59,6 +67,16 @@ namespace BaseGames.Animation
|
||||
SetHitBoxActive(payload, false);
|
||||
break;
|
||||
|
||||
// ── 无敌帧 ────────────────────────────────────────────────
|
||||
// 时长由动画时间轴上两个事件的间距表达,不在代码里写常量。
|
||||
case AnimationEventType.EnableIFrame:
|
||||
_hurtBox?.SetInvincible(true);
|
||||
break;
|
||||
|
||||
case AnimationEventType.DisableIFrame:
|
||||
_hurtBox?.SetInvincible(false);
|
||||
break;
|
||||
|
||||
// ── 弹幕 / 技能 ───────────────────────────────────────────
|
||||
case AnimationEventType.SpawnProjectile:
|
||||
_enemy?.SpawnProjectile(payload);
|
||||
@@ -78,6 +96,18 @@ namespace BaseGames.Animation
|
||||
_enemy?.TriggerPhaseTwo();
|
||||
break;
|
||||
|
||||
// ── 通用反馈 ──────────────────────────────────────────────
|
||||
// 音效预警:在起手帧播一段可辨识的音,让玩家提前缩小招式可能性。
|
||||
case AnimationEventType.TriggerFeedback:
|
||||
if (!string.IsNullOrEmpty(payload))
|
||||
_feedback.TriggerPreset(payload);
|
||||
break;
|
||||
|
||||
case AnimationEventType.PlaySFX:
|
||||
if (!string.IsNullOrEmpty(payload))
|
||||
_feedback.PlaySFXById(payload);
|
||||
break;
|
||||
|
||||
// ── 状态机钩子 ────────────────────────────────────────────
|
||||
case AnimationEventType.AnimationComplete:
|
||||
_enemy?.OnAnimationComplete(payload);
|
||||
|
||||
@@ -4,7 +4,7 @@ using BaseGames.Parry;
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 受击盒组件。实现完整 8 步伤害流水线(架构 06_CombatModule §5)。
|
||||
/// 受击盒组件。实现完整 9 步伤害流水线(架构 06_CombatModule §5)。
|
||||
/// 挂载在角色根节点或指定子节点上,Collider2D 需设 IsTrigger = true,
|
||||
/// Layer = PlayerHurtBox 或 EnemyHurtBox。
|
||||
/// </summary>
|
||||
@@ -33,6 +33,28 @@ namespace BaseGames.Combat
|
||||
public void SetPoiseSource(IPoiseSource src) => _poiseSource = src;
|
||||
public void SetInvincible(bool value) => _isHurtBoxInvincible = value;
|
||||
public void SetActive(bool value) => _isActive = value;
|
||||
|
||||
// 本 HurtBox 专属的受击倍率(弱点部位 > 1;默认 1 = 无影响)。
|
||||
// 由能力在弱点窗口期间开关,语义与"这个部位更脆弱"一致。
|
||||
private float _damageMultiplier = 1f;
|
||||
|
||||
/// <summary>本 HurtBox 当前的受击伤害倍率。</summary>
|
||||
public float DamageMultiplier => _damageMultiplier;
|
||||
|
||||
/// <summary>设置受击倍率(弱点窗口开合时调用)。负值钳到 0。</summary>
|
||||
public void SetDamageMultiplier(float value)
|
||||
=> _damageMultiplier = UnityEngine.Mathf.Max(0f, value);
|
||||
|
||||
/// <summary>
|
||||
/// 按本 HurtBox 的倍率缩放伤害。最低 1 点,与防御减免同规则
|
||||
/// (倍率为 0 也不该让攻击完全无效——那属于无敌帧的语义,不是弱点倍率的)。
|
||||
/// 倍率为 1 时原样返回,保证默认状态下伤害数值与未引入倍率前完全一致。
|
||||
/// </summary>
|
||||
public int ApplyDamageMultiplier(int raw)
|
||||
=> _damageMultiplier == 1f
|
||||
? raw
|
||||
: UnityEngine.Mathf.Max(1, UnityEngine.Mathf.RoundToInt(raw * _damageMultiplier));
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// 付给编辑器的只读属性——避免反射并限制编辑器与运行时字段名耐合性。
|
||||
public object EditorOwner => _owner;
|
||||
@@ -109,15 +131,18 @@ namespace BaseGames.Combat
|
||||
info.Amount = passThrough;
|
||||
}
|
||||
|
||||
// 5. 计算 FinalDamage(防御减免,最低 1)
|
||||
// 5. 本 HurtBox 受击倍率(弱点部位;默认 1 时无影响)
|
||||
info.Amount = ApplyDamageMultiplier(info.Amount);
|
||||
|
||||
// 6. 计算 FinalDamage(防御减免,最低 1)
|
||||
int finalDamage = UnityEngine.Mathf.Max(1, info.Amount - _owner.Defense);
|
||||
info.Amount = finalDamage;
|
||||
info.FinalDamage = finalDamage;
|
||||
|
||||
// 6. 调用 _owner.TakeDamage
|
||||
// 7. 调用 _owner.TakeDamage
|
||||
_owner.TakeDamage(info);
|
||||
|
||||
// 7. 全局广播
|
||||
// 8. 全局广播
|
||||
_onDamageDealt?.Raise(info);
|
||||
_onHitConfirmed?.Raise(new HitInfo
|
||||
{
|
||||
@@ -125,7 +150,7 @@ namespace BaseGames.Combat
|
||||
HitPoint = resolvedHitPoint,
|
||||
});
|
||||
|
||||
// 8. 状态效果触发(DoT — Fire / Poison)
|
||||
// 9. 状态效果触发(DoT — Fire / Poison)
|
||||
// _statusEffectable 已在 Awake 中缓存,无需每次受击调用 GetComponent
|
||||
_statusEffectable?.ApplyStatusEffect(info.Type);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss 技能事件负载。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct BossSkillEvent
|
||||
{
|
||||
public string BossId;
|
||||
public string SkillId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boss 阶段切换事件负载。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/Events/BossSkill")]
|
||||
public class BossSkillEventChannelSO : BaseEventChannelSO<BossSkillEvent> { }
|
||||
}
|
||||
@@ -5,7 +5,6 @@ using UnityEditor;
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Boss;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
@@ -76,8 +75,7 @@ namespace BaseGames.Editor
|
||||
// 动态内容区(类型切换时重建)
|
||||
private VisualElement _enemyContentArea;
|
||||
|
||||
// Boss 命名字段
|
||||
private string _bossId = "NewBoss"; // kept for legacy SkillSequenceSO queries if any
|
||||
// 命名字段
|
||||
private string _enemyId = "E001"; // kept for legacy status calls if any
|
||||
private string _playerId = "Player";
|
||||
|
||||
@@ -330,11 +328,12 @@ namespace BaseGames.Editor
|
||||
var factory = MakeActionGroup();
|
||||
factory.Add(MakeFactoryButton("ENM_ChaoFeng_Stats.asset", () => { CreateChaoFengStatsSO(); RefreshSOStatus(); }));
|
||||
factory.Add(MakeFactoryButton("ENM_ChaoFeng_AnimConfig.asset",() => { CreateChaoFengAnimConfigSO(); RefreshSOStatus(); }));
|
||||
foreach (var (skName, skId, skPhases, skWeight) in ChaoFengSkillDefs)
|
||||
foreach (var (abName, abId, soType, r, cd, w, ph, g) in ChaoFengAbilityDefs)
|
||||
{
|
||||
string cName = skName; string cId = skId; int[] cPhases = skPhases; float cWeight = skWeight;
|
||||
string cName = abName; string cId = abId; System.Type cType = soType;
|
||||
float cR = r, cCd = cd, cW = w; int cPh = ph; bool cG = g;
|
||||
factory.Add(MakeFactoryButton($"ABL_ChaoFeng_{cName}.asset",
|
||||
() => { CreateChaoFengSkillSO(cName, cId, cPhases, cWeight); RefreshSOStatus(); }));
|
||||
() => { CreateChaoFengAbilitySO(cName, cId, cType, cR, cCd, cW, cPh, cG); RefreshSOStatus(); }));
|
||||
}
|
||||
root.Add(factory);
|
||||
|
||||
@@ -345,7 +344,9 @@ namespace BaseGames.Editor
|
||||
|
||||
root.Add(MakeSeparator());
|
||||
root.Add(MakeSectionHeader("▶ 场景搭建"));
|
||||
root.Add(MakeHelpBox("放置嘲风完整组件树(ChaoFengBoss + 浮空控制器 + 击倒计数 + Phase1 HitBox × 4 + 炮口 × 3)。"));
|
||||
root.Add(MakeHelpBox("放置嘲风完整组件树(ChaoFengBoss + 浮空控制器 + 击倒计数 + " +
|
||||
"EnemyAiBrain + BossPhaseAbilityGate + 6 个能力组件 + " +
|
||||
"Phase1 HitBox × 4 + 炮口 × 3)。"));
|
||||
|
||||
var bossSpriteField = new UnityEditor.UIElements.ObjectField("默认外观 Sprite(碰撞体尺寸依据,可留空)")
|
||||
{ objectType = typeof(Sprite), allowSceneObjects = false, value = _defaultSprite };
|
||||
@@ -362,9 +363,8 @@ namespace BaseGames.Editor
|
||||
root.Add(MakeSectionHeader("▶ 专项编辑器"));
|
||||
|
||||
var jumpGroup = MakeActionGroup();
|
||||
jumpGroup.Add(MakeJumpButton("Boss 技能序列查看器", BossSkillSequenceWindow.OpenWindow));
|
||||
jumpGroup.Add(MakeJumpButton("武器 HitBox 向导", WeaponHitBoxWizard.Open));
|
||||
jumpGroup.Add(MakeJumpButton("Data Hub(Boss技能)", DataHubWindow.Open));
|
||||
jumpGroup.Add(MakeJumpButton("Data Hub(敌人 / 能力)", DataHubWindow.Open));
|
||||
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
|
||||
root.Add(jumpGroup);
|
||||
|
||||
@@ -689,41 +689,77 @@ namespace BaseGames.Editor
|
||||
EditorScaffoldUtils.CreateSOAsset<EnemyAnimationConfigSO>(dir, "ENM_ChaoFeng_AnimConfig");
|
||||
}
|
||||
|
||||
private static void CreateChaoFengSkillSO(string skillName, string skillId, int[] phaseIndices, float weight)
|
||||
private static void CreateChaoFengAbilitySO(
|
||||
string abilityName, string abilityId, System.Type soType,
|
||||
float rangeRadius, float cooldown, float weight, int phase, bool requiresGrounded)
|
||||
{
|
||||
string dir = "Assets/_Game/Data/Enemies/ChaoFeng/Abilities";
|
||||
string name = $"ABL_ChaoFeng_{skillName}";
|
||||
var so = EditorScaffoldUtils.CreateSOAsset<BossSkillSO>(dir, name);
|
||||
if (so != null)
|
||||
const string dir = "Assets/_Game/Data/Enemies/ChaoFeng/Abilities";
|
||||
string name = $"ABL_ChaoFeng_{abilityName}";
|
||||
|
||||
var so = EditorScaffoldUtils.CreateSOAsset(soType, dir, name) as EnemyAbilitySO;
|
||||
if (so == null) return; // 已存在则不覆盖
|
||||
|
||||
so.abilityId = abilityId;
|
||||
so.designNote = phase < 0
|
||||
? "入场演出,不进选招池"
|
||||
: $"阶段 {phase} 招式;阶段可用性由 BossPhaseAbilityGate 配置";
|
||||
so.cooldown = cooldown;
|
||||
// 空中阶段的招必须放开这一项:选招器把 requiresGrounded 当硬门,
|
||||
// Boss 浮空时 IsGrounded 恒为 false,留着默认 true 会让该招永远选不中。
|
||||
so.requiresGrounded = requiresGrounded;
|
||||
|
||||
if (phase < 0)
|
||||
{
|
||||
so.skillId = skillId;
|
||||
so.displayName = skillName;
|
||||
so.availablePhaseIndices = phaseIndices;
|
||||
so.weight = weight;
|
||||
EditorUtility.SetDirty(so);
|
||||
AssetDatabase.SaveAssets();
|
||||
so.category = AbilityCategory.None;
|
||||
so.rangeRadius = 0f;
|
||||
so.weight = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
so.category = AbilityCategory.Attack;
|
||||
so.rangeRadius = rangeRadius; // 必须 > 0,否则选招器永远选不中
|
||||
so.weight = weight;
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(so);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
/// <summary>嘲风技能集(计划):Phase0 四技能加权随机 + Phase1 风石。</summary>
|
||||
private static readonly (string name, string id, int[] phases, float weight)[] ChaoFengSkillDefs =
|
||||
/// <summary>
|
||||
/// 嘲风能力集。阶段可用性不在 SO 上——由 BossPhaseAbilityGate 承载
|
||||
/// (见放置工具 PlaceChaoFeng),这里只记录供说明文本使用。
|
||||
/// rangeRadius 必须 > 0:category==Attack 且射程为 0 的招永远够不着玩家,
|
||||
/// EnemyAbilitySO.Validate 与 EnemyBase.BuildAttackSelector 都会显式报错。
|
||||
/// grounded 是选招器的硬门:阶段 1 是空中阶段(Boss 浮空,IsGrounded 恒 false),
|
||||
/// 该阶段的招必须为 false,否则永远选不中,空中阶段会站着不打。
|
||||
/// </summary>
|
||||
private static readonly (string name, string id, System.Type soType,
|
||||
float rangeRadius, float cooldown, float weight, int phase, bool grounded)[]
|
||||
ChaoFengAbilityDefs =
|
||||
{
|
||||
("Boomerang", "boomerang", new[] { 0 }, 1.0f),
|
||||
("FanCombo", "fan_combo", new[] { 0 }, 1.5f),
|
||||
("TornadoSmall", "tornado_small", new[] { 0 }, 1.2f),
|
||||
("TornadoLarge", "tornado_large", new[] { 0 }, 0.8f),
|
||||
("WindStone", "wind_stone", new[] { 1 }, 1.0f),
|
||||
("Intro", "chaofeng_intro", typeof(PlayClipAbilitySO), 0f, 0f, 0f, -1, true),
|
||||
("Boomerang", "boomerang", typeof(PlayClipAbilitySO), 9f, 4.0f, 1.0f, 0, true),
|
||||
("FanCombo", "fan_combo", typeof(EnemyAbilitySO), 2.5f, 2.5f, 1.5f, 0, true),
|
||||
("TornadoSmall", "tornado_small", typeof(PlayClipAbilitySO), 8f, 3.5f, 1.2f, 0, true),
|
||||
("TornadoLarge", "tornado_large", typeof(PlayClipAbilitySO), 12f, 6.0f, 0.8f, 0, true),
|
||||
("WindStone", "wind_stone", typeof(PlayClipAbilitySO), 12f, 3.0f, 1.0f, 1, false),
|
||||
};
|
||||
|
||||
private static void CreateAllChaoFengSOs()
|
||||
{
|
||||
CreateChaoFengStatsSO();
|
||||
CreateChaoFengAnimConfigSO();
|
||||
foreach (var (n, id, phases, weight) in ChaoFengSkillDefs)
|
||||
CreateChaoFengSkillSO(n, id, phases, weight);
|
||||
foreach (var (n, id, t, r, cd, w, ph, g) in ChaoFengAbilityDefs)
|
||||
CreateChaoFengAbilitySO(n, id, t, r, cd, w, ph, g);
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorUtility.DisplayDialog("创建完成",
|
||||
"全部嘲风 SO 已创建(已存在的跳过)。\n放置到场景后检查 BossSkillExecutor._skills 绑定。", "确定");
|
||||
"全部嘲风 SO 已创建(已存在的跳过)。\n" +
|
||||
"接下来:\n" +
|
||||
"1. 用「放置嘲风到场景并绑定 SO」生成组件树;\n" +
|
||||
"2. 检查 EnemyAiBrain._definitionId = ChaoFeng;\n" +
|
||||
"3. 检查各能力组件的 _config 已绑定对应 ABL_ 资产;\n" +
|
||||
"4. 在 BossPhaseAbilityGate 上确认阶段表(地面四招=阶段0,风石=阶段1)。",
|
||||
"确定");
|
||||
}
|
||||
|
||||
// ── 场景搭建(已移至 RefreshEnemyTabContent 内的内联按钮) ─────────────
|
||||
@@ -794,10 +830,16 @@ namespace BaseGames.Editor
|
||||
("ENM_ChaoFeng_Stats", FindAtPath<EnemyStatsSO>($"{dir}/ENM_ChaoFeng_Stats.asset")),
|
||||
("ENM_ChaoFeng_AnimConfig",FindAtPath<EnemyAnimationConfigSO>($"{dir}/ENM_ChaoFeng_AnimConfig.asset")),
|
||||
};
|
||||
foreach (var (skName, _, _, _) in ChaoFengSkillDefs)
|
||||
checks.Add(($"ABL_ChaoFeng_{skName}", FindAtPath<BossSkillSO>($"{ablDir}/ABL_ChaoFeng_{skName}.asset")));
|
||||
foreach (var (abName, _, _, _, _, _, _, _) in ChaoFengAbilityDefs)
|
||||
checks.Add(($"ABL_ChaoFeng_{abName}",
|
||||
FindAtPath<EnemyAbilitySO>($"{ablDir}/ABL_ChaoFeng_{abName}.asset")));
|
||||
|
||||
// AI 图是代码定义的(定制路径),没有资产可查——用注册表确认它被反射收集到了
|
||||
bool hasAiGraph = BaseGames.AI.AiDefinitionRegistry.Has("ChaoFeng");
|
||||
_bossStatusPanel.Add(MakeStatusGrid(checks.ToArray()));
|
||||
_bossStatusPanel.Add(new Label(
|
||||
hasAiGraph ? "✔ AI 图 ChaoFengAi 已注册(定制路径 _definitionId = ChaoFeng)"
|
||||
: "✘ AI 图 ChaoFengAi 未注册——检查 [AiDefinition(\"ChaoFeng\")] 特性"));
|
||||
}
|
||||
|
||||
// ── 辅助:状态格 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using BaseGames.Boss;
|
||||
|
||||
namespace BaseGames.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss 技能序列甘特图可视化窗口(架构 23_BossSkillModule §12)。
|
||||
/// 菜单:BaseGames/Tools/Boss Skill Sequence Viewer
|
||||
///
|
||||
/// 功能:
|
||||
/// - 拖放 BossSkillSO 或 SkillSequenceSO 资产加载
|
||||
/// - 甘特图:Windup(黄色)→ Active(红色)→ Recovery(灰色)各阶段时序条
|
||||
/// - VulnerabilityWindow 绿色覆盖层(TriggerDelay 偏移 + Duration 宽度)
|
||||
/// - DurationNormalized < 0.1 时阶段条变红警告
|
||||
/// - 点击阶段条高亮对应 AttackPatternSO(EditorGUIUtility.PingObject)
|
||||
/// </summary>
|
||||
public class BossSkillSequenceWindow : EditorWindow
|
||||
{
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
private BossSkillSO _loadedSkill;
|
||||
private SkillSequenceSO _loadedSequence;
|
||||
private Vector2 _scrollPos;
|
||||
|
||||
// ── Layout ─────────────────────────────────────────────────────────
|
||||
private const float HeaderH = 24f;
|
||||
private const float RowH = 28f;
|
||||
private const float LabelW = 180f;
|
||||
private const float MinBarWidth = 6f;
|
||||
// 时间轴宽度随窗口宽度动态调整,最小 300px
|
||||
private float TimelineW => Mathf.Max(300f, position.width - LabelW - 30f);
|
||||
|
||||
// ── Colors ─────────────────────────────────────────────────────────
|
||||
private static readonly Color ColWindup = new Color(0.95f, 0.80f, 0.10f, 0.85f);
|
||||
private static readonly Color ColActive = new Color(0.90f, 0.20f, 0.15f, 0.85f);
|
||||
private static readonly Color ColRecovery = new Color(0.50f, 0.50f, 0.55f, 0.70f);
|
||||
private static readonly Color ColVuln = new Color(0.10f, 0.90f, 0.30f, 0.45f);
|
||||
private static readonly Color ColDelay = new Color(0.25f, 0.25f, 0.30f, 0.50f);
|
||||
private static readonly Color ColWarn = new Color(0.95f, 0.10f, 0.10f, 0.85f);
|
||||
|
||||
[MenuItem("BaseGames/Data/Boss Skill Sequence", priority = 110)]
|
||||
public static void OpenWindow()
|
||||
{
|
||||
var win = GetWindow<BossSkillSequenceWindow>("Boss Skill Sequence");
|
||||
win.minSize = new Vector2(900, 400);
|
||||
win.Show();
|
||||
}
|
||||
|
||||
// ── GUI ────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
DrawToolbar();
|
||||
|
||||
if (_loadedSkill == null && _loadedSequence == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"将 BossSkillSO 或 SkillSequenceSO 资产拖放到此处,或使用上方字段加载。",
|
||||
MessageType.Info);
|
||||
HandleDragDrop();
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
|
||||
|
||||
if (_loadedSkill != null)
|
||||
DrawSkillTimeline(_loadedSkill);
|
||||
else if (_loadedSequence != null)
|
||||
DrawSequenceTimeline(_loadedSequence);
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
// ── Toolbar ───────────────────────────────────────────────────────
|
||||
|
||||
private void DrawToolbar()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||||
|
||||
EditorGUILayout.LabelField("技能:", GUILayout.Width(36));
|
||||
var newSkill = (BossSkillSO)EditorGUILayout.ObjectField(
|
||||
_loadedSkill, typeof(BossSkillSO), false, GUILayout.Width(200));
|
||||
if (newSkill != _loadedSkill)
|
||||
{
|
||||
_loadedSkill = newSkill;
|
||||
_loadedSequence = null;
|
||||
}
|
||||
|
||||
GUILayout.Space(12);
|
||||
EditorGUILayout.LabelField("序列:", GUILayout.Width(36));
|
||||
var newSeq = (SkillSequenceSO)EditorGUILayout.ObjectField(
|
||||
_loadedSequence, typeof(SkillSequenceSO), false, GUILayout.Width(200));
|
||||
if (newSeq != _loadedSequence)
|
||||
{
|
||||
_loadedSequence = newSeq;
|
||||
_loadedSkill = null;
|
||||
}
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("清除", EditorStyles.toolbarButton, GUILayout.Width(50)))
|
||||
{
|
||||
_loadedSkill = null;
|
||||
_loadedSequence = null;
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
// ── BossSkillSO 时间轴 ────────────────────────────────────────────
|
||||
|
||||
private void DrawSkillTimeline(BossSkillSO skill)
|
||||
{
|
||||
EditorGUILayout.LabelField($"技能:{skill.displayName} [{skill.skillId}]",
|
||||
EditorStyles.boldLabel);
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
if (skill.attackPatterns == null || skill.attackPatterns.Length == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("此技能没有 AttackPattern。", MessageType.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算总时长
|
||||
float totalDuration = 0f;
|
||||
foreach (var p in skill.attackPatterns)
|
||||
if (p != null) totalDuration += p.WindupDuration + p.ActiveDuration + p.RecoveryDuration;
|
||||
|
||||
if (totalDuration <= 0f) totalDuration = 1f;
|
||||
|
||||
DrawTimelineHeader(totalDuration);
|
||||
|
||||
float cursor = 0f;
|
||||
for (int i = 0; i < skill.attackPatterns.Length; i++)
|
||||
{
|
||||
var pattern = skill.attackPatterns[i];
|
||||
if (pattern == null) continue;
|
||||
DrawPatternRow($"[{i}] {pattern.name}", pattern, ref cursor, totalDuration);
|
||||
}
|
||||
|
||||
// 绘制 VulnerabilityWindows
|
||||
if (skill.vulnerabilityWindows != null && skill.vulnerabilityWindows.Length > 0)
|
||||
{
|
||||
EditorGUILayout.Space(4);
|
||||
EditorGUILayout.LabelField("弱点窗口(Vulnerability Windows)", EditorStyles.miniBoldLabel);
|
||||
foreach (var vw in skill.vulnerabilityWindows)
|
||||
DrawVulnWindowRow(vw, totalDuration);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SkillSequenceSO 时间轴 ────────────────────────────────────────
|
||||
|
||||
private void DrawSequenceTimeline(SkillSequenceSO sequence)
|
||||
{
|
||||
EditorGUILayout.LabelField($"序列:{sequence.name}", EditorStyles.boldLabel);
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
if (sequence.steps == null || sequence.steps.Length == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("此序列没有步骤。", MessageType.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算总时长
|
||||
float totalDuration = 0f;
|
||||
foreach (var step in sequence.steps)
|
||||
{
|
||||
totalDuration += step.delayBeforeStep;
|
||||
if (step.pattern != null)
|
||||
totalDuration += step.pattern.WindupDuration + step.pattern.ActiveDuration + step.pattern.RecoveryDuration;
|
||||
}
|
||||
if (totalDuration <= 0f) totalDuration = 1f;
|
||||
|
||||
DrawTimelineHeader(totalDuration);
|
||||
|
||||
float cursor = 0f;
|
||||
for (int i = 0; i < sequence.steps.Length; i++)
|
||||
{
|
||||
var step = sequence.steps[i];
|
||||
|
||||
// 延迟条
|
||||
if (step.delayBeforeStep > 0f)
|
||||
{
|
||||
DrawBar($"延迟 {step.delayBeforeStep:F2}s", cursor, step.delayBeforeStep,
|
||||
totalDuration, ColDelay, null);
|
||||
cursor += step.delayBeforeStep;
|
||||
}
|
||||
|
||||
if (step.pattern != null)
|
||||
DrawPatternRow($"[{i}] {step.pattern.name}", step.pattern, ref cursor, totalDuration);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 共用绘制方法 ──────────────────────────────────────────────────
|
||||
|
||||
private void DrawTimelineHeader(float totalDuration)
|
||||
{
|
||||
Rect headerRect = EditorGUILayout.GetControlRect(false, HeaderH);
|
||||
headerRect.x += LabelW;
|
||||
headerRect.width -= LabelW;
|
||||
|
||||
EditorGUI.DrawRect(headerRect, new Color(0.18f, 0.18f, 0.20f));
|
||||
|
||||
// 刻度线(每 0.5s 一条)
|
||||
float step = 0.5f;
|
||||
for (float t = 0; t <= totalDuration + 0.001f; t += step)
|
||||
{
|
||||
float x = headerRect.x + (t / totalDuration) * headerRect.width;
|
||||
EditorGUI.DrawRect(new Rect(x, headerRect.y, 1f, HeaderH * 0.6f), Color.gray);
|
||||
EditorGUI.LabelField(new Rect(x + 2f, headerRect.y, 40f, HeaderH),
|
||||
$"{t:F1}s", new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = Color.gray } });
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPatternRow(string label, AttackPatternSO pattern, ref float cursor, float totalDuration)
|
||||
{
|
||||
float windupDur = pattern.WindupDuration;
|
||||
float activeDur = pattern.ActiveDuration;
|
||||
float recoveryDur = pattern.RecoveryDuration;
|
||||
|
||||
float rowStart = cursor;
|
||||
EditorGUILayout.BeginHorizontal(GUILayout.Height(RowH));
|
||||
|
||||
// 标签 + Ping
|
||||
if (GUILayout.Button(label, EditorStyles.miniLabel, GUILayout.Width(LabelW), GUILayout.Height(RowH)))
|
||||
EditorGUIUtility.PingObject(pattern);
|
||||
|
||||
Rect timelineRect = EditorGUILayout.GetControlRect(false, RowH,
|
||||
GUILayout.Width(TimelineW));
|
||||
|
||||
// Windup
|
||||
if (windupDur > 0f)
|
||||
DrawBarInRect(timelineRect, cursor, windupDur, totalDuration,
|
||||
windupDur / (windupDur + activeDur + recoveryDur) < 0.1f ? ColWarn : ColWindup);
|
||||
cursor += windupDur;
|
||||
|
||||
// Active
|
||||
if (activeDur > 0f)
|
||||
DrawBarInRect(timelineRect, cursor, activeDur, totalDuration,
|
||||
activeDur / (windupDur + activeDur + recoveryDur) < 0.1f ? ColWarn : ColActive);
|
||||
cursor += activeDur;
|
||||
|
||||
// Recovery
|
||||
if (recoveryDur > 0f)
|
||||
DrawBarInRect(timelineRect, cursor, recoveryDur, totalDuration,
|
||||
recoveryDur / (windupDur + activeDur + recoveryDur) < 0.1f ? ColWarn : ColRecovery);
|
||||
cursor += recoveryDur;
|
||||
|
||||
_ = rowStart; // suppress unused warning
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void DrawVulnWindowRow(VulnerabilityWindow vw, float totalDuration)
|
||||
{
|
||||
string label = $"弱点:{vw.TriggerType} +{vw.TriggerDelay:F2}s / {vw.Duration:F2}s";
|
||||
DrawBar(label, vw.TriggerDelay, vw.Duration, totalDuration, ColVuln, null);
|
||||
}
|
||||
|
||||
private void DrawBar(string label, float start, float duration, float totalDuration,
|
||||
Color color, AttackPatternSO pingTarget)
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal(GUILayout.Height(RowH));
|
||||
|
||||
if (GUILayout.Button(label, EditorStyles.miniLabel, GUILayout.Width(LabelW), GUILayout.Height(RowH)))
|
||||
{
|
||||
if (pingTarget != null) EditorGUIUtility.PingObject(pingTarget);
|
||||
}
|
||||
|
||||
Rect timelineRect = EditorGUILayout.GetControlRect(false, RowH, GUILayout.Width(TimelineW));
|
||||
DrawBarInRect(timelineRect, start, duration, totalDuration, color);
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private static void DrawBarInRect(Rect timeline, float start, float duration,
|
||||
float totalDuration, Color color)
|
||||
{
|
||||
float x = timeline.x + (start / totalDuration) * timeline.width;
|
||||
float w = Mathf.Max(MinBarWidth, (duration / totalDuration) * timeline.width);
|
||||
EditorGUI.DrawRect(new Rect(x, timeline.y + 2f, w, timeline.height - 4f), color);
|
||||
}
|
||||
|
||||
// ── Drag & Drop ───────────────────────────────────────────────────
|
||||
|
||||
private void HandleDragDrop()
|
||||
{
|
||||
var evt = Event.current;
|
||||
if (evt.type != EventType.DragUpdated && evt.type != EventType.DragPerform) return;
|
||||
|
||||
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
|
||||
|
||||
if (evt.type == EventType.DragPerform)
|
||||
{
|
||||
DragAndDrop.AcceptDrag();
|
||||
foreach (var obj in DragAndDrop.objectReferences)
|
||||
{
|
||||
if (obj is BossSkillSO skill) { _loadedSkill = skill; _loadedSequence = null; break; }
|
||||
if (obj is SkillSequenceSO seq) { _loadedSequence = seq; _loadedSkill = null; break; }
|
||||
}
|
||||
Repaint();
|
||||
}
|
||||
evt.Use();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,6 @@ namespace BaseGames.Editor
|
||||
CreateAsset<VoidEventChannelSO> ("Combat", "EVT_ShieldRestored");
|
||||
|
||||
// ── Boss ──────────────────────────────────────────────────────────
|
||||
CreateAsset<BossSkillEventChannelSO> ("Boss", "EVT_BossSkill");
|
||||
CreateAsset<BossPhaseEventChannelSO> ("Boss", "EVT_BossPhase");
|
||||
CreateAsset<BossPhaseEventChannelSO> ("Boss", "EVT_BossPhaseChanged");
|
||||
CreateAsset<StringEventChannelSO> ("Boss", "EVT_BossDefeated");
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.Boss;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub Boss技能模块 —— Tab 切换管理 BossSkillSO 和 SkillSequenceSO。
|
||||
/// </summary>
|
||||
public class BossSkillModule : IDataModule, IDataModuleOrdered
|
||||
{
|
||||
private const string SkillFolder = "Assets/_Game/Data/Boss/Skills";
|
||||
private const string SeqFolder = "Assets/_Game/Data/Boss/Sequences";
|
||||
|
||||
public string ModuleId => "boss";
|
||||
public string DisplayName => "Boss技能";
|
||||
public string IconName => null;
|
||||
public int DisplayOrder => 50;
|
||||
|
||||
private int _activeTab = 0;
|
||||
|
||||
private SoListPane<BossSkillSO> _skillPane;
|
||||
private SoListPane<SkillSequenceSO> _seqPane;
|
||||
private Action<UnityEngine.Object> _onSelected;
|
||||
|
||||
private DetailHeader _header;
|
||||
private BossSkillSO _selectedSkill;
|
||||
private SkillSequenceSO _selectedSeq;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_skillPane = new SoListPane<BossSkillSO>(
|
||||
SkillFolder, "ABL_Boss_",
|
||||
s => s.category.ToString());
|
||||
_skillPane.SelectionChanged = s => { _selectedSkill = s; _onSelected?.Invoke(s); };
|
||||
|
||||
_seqPane = new SoListPane<SkillSequenceSO>(SeqFolder, "ABL_Seq_");
|
||||
_seqPane.SelectionChanged = s => { _selectedSeq = s; _onSelected?.Invoke(s); };
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
_onSelected = onSelected;
|
||||
container.style.flexDirection = FlexDirection.Column;
|
||||
|
||||
// Tab bar
|
||||
var tabBar = new VisualElement();
|
||||
tabBar.style.flexDirection = FlexDirection.Row;
|
||||
tabBar.style.borderBottomWidth = 1;
|
||||
tabBar.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.3f));
|
||||
container.Add(tabBar);
|
||||
|
||||
var btnSkill = BuildTabBtn("技能 (Skill)", 0, tabBar);
|
||||
var btnSeq = BuildTabBtn("序列 (Seq)", 1, tabBar);
|
||||
|
||||
var listArea = new VisualElement();
|
||||
listArea.style.flexGrow = 1;
|
||||
container.Add(listArea);
|
||||
|
||||
ShowTab(0, listArea, new[] { btnSkill, btnSeq });
|
||||
btnSkill.clicked += () => ShowTab(0, listArea, new[] { btnSkill, btnSeq });
|
||||
btnSeq.clicked += () => ShowTab(1, listArea, new[] { btnSkill, btnSeq });
|
||||
|
||||
_skillPane.Refresh();
|
||||
_seqPane.Refresh();
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_header = new DetailHeader();
|
||||
_header.SetAsset(selected);
|
||||
_header.RenameRequested += name => OnRenameRequested(selected, name);
|
||||
container.Add(_header);
|
||||
|
||||
if (selected == null) return;
|
||||
|
||||
if (selected is BossSkillSO skill)
|
||||
{
|
||||
container.Add(BuildSkillCard(skill));
|
||||
container.Add(BuildActionBar(skill, SkillFolder, _skillPane));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
var insp = new InspectorElement(skill); container.Add(insp);
|
||||
}
|
||||
else if (selected is SkillSequenceSO seq)
|
||||
{
|
||||
container.Add(BuildSeqCard(seq));
|
||||
container.Add(BuildActionBar(seq, SeqFolder, _seqPane));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
var insp = new InspectorElement(seq); container.Add(insp);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnActivated()
|
||||
{
|
||||
_skillPane?.Refresh();
|
||||
_seqPane?.Refresh();
|
||||
}
|
||||
|
||||
// ── 内部 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Button BuildTabBtn(string text, int tabIdx, VisualElement bar)
|
||||
{
|
||||
var btn = new Button { text = text };
|
||||
btn.style.flexGrow = 1;
|
||||
btn.style.paddingTop = 5;
|
||||
btn.style.paddingBottom = 5;
|
||||
btn.style.borderTopLeftRadius = 0;
|
||||
btn.style.borderTopRightRadius = 0;
|
||||
btn.style.borderBottomLeftRadius = 0;
|
||||
btn.style.borderBottomRightRadius = 0;
|
||||
btn.style.borderLeftWidth = 0;
|
||||
btn.style.borderRightWidth = 0;
|
||||
btn.style.borderTopWidth = 0;
|
||||
btn.style.borderBottomWidth = 0;
|
||||
btn.style.backgroundColor = new StyleColor(Color.clear);
|
||||
btn.userData = tabIdx;
|
||||
bar.Add(btn);
|
||||
return btn;
|
||||
}
|
||||
|
||||
private void ShowTab(int tab, VisualElement area, Button[] tabBtns)
|
||||
{
|
||||
_activeTab = tab;
|
||||
area.Clear();
|
||||
|
||||
for (int i = 0; i < tabBtns.Length; i++)
|
||||
{
|
||||
if (i == tab)
|
||||
{
|
||||
tabBtns[i].style.borderBottomWidth = 2;
|
||||
tabBtns[i].style.borderBottomColor = new StyleColor(new Color(0.4f, 0.65f, 1f, 1f));
|
||||
tabBtns[i].style.opacity = 1f;
|
||||
}
|
||||
else
|
||||
{
|
||||
tabBtns[i].style.borderBottomWidth = 0;
|
||||
tabBtns[i].style.opacity = 0.65f;
|
||||
}
|
||||
}
|
||||
|
||||
if (tab == 0) { _skillPane.style.flexGrow = 1; area.Add(_skillPane); }
|
||||
else { _seqPane.style.flexGrow = 1; area.Add(_seqPane); }
|
||||
}
|
||||
|
||||
private void OnRenameRequested(UnityEngine.Object asset, string newName)
|
||||
{
|
||||
var (ok, err) = AssetOperations.Rename(asset, newName);
|
||||
if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定");
|
||||
else
|
||||
{
|
||||
_header.SetAsset(asset);
|
||||
if (_activeTab == 0) _skillPane.Invalidate();
|
||||
else _seqPane.Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private static VisualElement BuildSkillCard(BossSkillSO s)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
SkillModule.AddChip(card, "分类", s.category.ToString());
|
||||
SkillModule.AddChip(card, "类型", s.skillType.ToString());
|
||||
SkillModule.AddChip(card, "模式数", (s.attackPatterns?.Length ?? 0).ToString());
|
||||
SkillModule.AddChip(card, "弱点窗口", (s.vulnerabilityWindows?.Length ?? 0).ToString());
|
||||
if (!string.IsNullOrEmpty(s.skillId))
|
||||
SkillModule.AddChip(card, "ID", s.skillId);
|
||||
return card;
|
||||
}
|
||||
|
||||
private static VisualElement BuildSeqCard(SkillSequenceSO s)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
SkillModule.AddChip(card, "步骤数", (s.steps?.Length ?? 0).ToString());
|
||||
SkillModule.AddChip(card, "循环", s.RepeatIfPlayerInRange ? "是" : "否");
|
||||
SkillModule.AddChip(card, "最大循环次数", s.MaxRepeatCount.ToString());
|
||||
return card;
|
||||
}
|
||||
|
||||
private VisualElement BuildActionBar<T>(T asset, string folder, SoListPane<T> pane)
|
||||
where T : ScriptableObject
|
||||
{
|
||||
var bar = SkillModule.MakeActionBar();
|
||||
new Button(() => { EditorGUIUtility.PingObject(asset); Selection.activeObject = asset; })
|
||||
{ text = "定位" }.AlsoAddTo(bar);
|
||||
new Button(() =>
|
||||
{
|
||||
var c = AssetOperations.Clone(asset, folder);
|
||||
if (c != null) pane.Refresh(c);
|
||||
}) { text = "克隆..." }.AlsoAddTo(bar);
|
||||
var del = new Button(() =>
|
||||
{
|
||||
if (AssetOperations.Delete(asset)) pane.Refresh(null);
|
||||
}) { text = "删除" };
|
||||
del.style.borderLeftColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
del.style.borderRightColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
del.style.borderTopColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
del.style.borderBottomColor = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
del.style.borderLeftWidth = 1;
|
||||
del.style.borderRightWidth = 1;
|
||||
del.style.borderTopWidth = 1;
|
||||
del.style.borderBottomWidth = 1;
|
||||
del.style.marginLeft = 8;
|
||||
del.AlsoAddTo(bar);
|
||||
return bar;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Animancer;
|
||||
using BaseGames.Boss;
|
||||
using BaseGames.Camera;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Combat.StatusEffects;
|
||||
@@ -303,6 +302,7 @@ namespace BaseGames.Editor
|
||||
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
|
||||
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
|
||||
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
|
||||
GetOrAddComponent<EnemyLocomotion>(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地
|
||||
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
|
||||
|
||||
var (hurtBox, bodyContact, contactHitBox) =
|
||||
@@ -402,7 +402,6 @@ namespace BaseGames.Editor
|
||||
|
||||
SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report);
|
||||
report.Add("填写 _bossId。");
|
||||
report.Add("★ 挂载 BossSkillExecutor 并指定 BossSkillSO 列表(技能执行层)。");
|
||||
report.Add("★ AI(BrainGraph):用菜单 BaseGames/AI/Enemy AI Recipe Wizard 建一个配方资产," +
|
||||
"在 Inspector 选两层模块(未发现/交战),再挂 EnemyAiBrain 组件并把 _recipe 指向该资产。" +
|
||||
"有独门机制的敌人才写 [AiDefinition] 的 AiScript 子类并改用 _definitionId。");
|
||||
@@ -447,6 +446,7 @@ namespace BaseGames.Editor
|
||||
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
|
||||
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
|
||||
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
|
||||
GetOrAddComponent<EnemyLocomotion>(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地
|
||||
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
|
||||
|
||||
var (hurtBox, bodyContact, contactHitBox) =
|
||||
@@ -495,9 +495,7 @@ namespace BaseGames.Editor
|
||||
|
||||
SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report);
|
||||
|
||||
GetOrAddComponent<EnemyLocomotion>(go);
|
||||
|
||||
// BrainGraph AI:挂载决策组件并绑定 AI 配方资产(取代旧的 Opsive BehaviorTree)
|
||||
// BrainGraph AI:挂载决策组件并绑定 AI 配方资产
|
||||
var brain = GetOrAddComponent<EnemyAiBrain>(go);
|
||||
var recipe = AssetDatabase.LoadAssetAtPath<PerceptionRecipeSO>(
|
||||
"Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset");
|
||||
@@ -552,6 +550,7 @@ namespace BaseGames.Editor
|
||||
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
|
||||
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
|
||||
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
|
||||
GetOrAddComponent<EnemyLocomotion>(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地
|
||||
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
|
||||
|
||||
// HurtBox + ContactDamageZone(身体接触伤害常驻开启;HurtBox 悬挂期外关闭见下)
|
||||
@@ -653,6 +652,7 @@ namespace BaseGames.Editor
|
||||
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
|
||||
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
|
||||
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
|
||||
GetOrAddComponent<EnemyLocomotion>(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地
|
||||
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
|
||||
|
||||
var (hurtBox, bodyContact, contactHitBox) =
|
||||
@@ -743,6 +743,7 @@ namespace BaseGames.Editor
|
||||
EnemyFeedback feedback = GetOrAddComponent<EnemyFeedback>(go);
|
||||
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
|
||||
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
|
||||
GetOrAddComponent<EnemyLocomotion>(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地
|
||||
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
|
||||
|
||||
var (hurtBox, bodyContact, contactHitBox) =
|
||||
@@ -873,6 +874,7 @@ namespace BaseGames.Editor
|
||||
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
|
||||
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
|
||||
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
|
||||
GetOrAddComponent<EnemyLocomotion>(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地
|
||||
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
|
||||
|
||||
var (hurtBox, bodyContact, contactHitBox) =
|
||||
@@ -984,6 +986,7 @@ namespace BaseGames.Editor
|
||||
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
|
||||
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
|
||||
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
|
||||
GetOrAddComponent<EnemyLocomotion>(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地
|
||||
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
|
||||
|
||||
var (hurtBox, bodyContact, contactHitBox) =
|
||||
@@ -1086,7 +1089,12 @@ namespace BaseGames.Editor
|
||||
EnemyFeedback feedback = GetOrAddComponent<EnemyFeedback>(go);
|
||||
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
|
||||
GetOrAddComponent<BaseGames.Enemies.Navigation.GroundNavigator>(go); // 地面导航(直接移动,无寻路图)
|
||||
BossSkillExecutor skillExec = GetOrAddComponent<BossSkillExecutor>(go);
|
||||
// 移动执行器:AI 图的移动意图(Idle/Face/Approach)全靠它落地。
|
||||
// 少了它 EnemyBase.Awake 就报「未找到 EnemyLocomotion」,且 Locomotion 为 null,
|
||||
// 建图时 AiStateFragments.ApplyLocomotion 会 NRE,AiRuntime 构造失败 → Boss 完全不动。
|
||||
GetOrAddComponent<EnemyLocomotion>(go);
|
||||
EnemyAiBrain brain = GetOrAddComponent<EnemyAiBrain>(go);
|
||||
BossPhaseAbilityGate phaseGate = GetOrAddComponent<BossPhaseAbilityGate>(go);
|
||||
ChaoFengFloatController floatCtrl = GetOrAddComponent<ChaoFengFloatController>(go);
|
||||
ChaoFengKnockdownCounter knockdown = GetOrAddComponent<ChaoFengKnockdownCounter>(go);
|
||||
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
|
||||
@@ -1095,22 +1103,39 @@ namespace BaseGames.Editor
|
||||
var (hurtBox, bodyContact, contactHitBox) =
|
||||
SetupHurtAndContactBoxes(go, size, report);
|
||||
|
||||
// Phase1 攻击 HitBox(默认禁用;技能执行时由 BossSkillExecutor 开关)。
|
||||
// 计划:挥扇三连 FanCombo ×3 + 龙卷接触 Tornado。
|
||||
// 近战攻击 HitBox(默认禁用;由 MeleeAttackAbility 按 EnemyAttackSO.hitBoxSlot 归一化时机开关)。
|
||||
// 只有挥扇三连需要 Boss 自身的判定;回旋扇 / 龙卷 / 风石都是弹体招,
|
||||
// 判定随 SpawnProjectile 动画事件生成的弹体走,Boss 身上不留对应 HitBox
|
||||
// (留一个无人引用的 HitBox 会让作者误以为该招判定挂在 Boss 身上)。
|
||||
HitBox fan1 = CreateDisabledHitBox(go.transform, "Phase1_FanCombo_HitBox_1", "EnemyHitBox",
|
||||
true, report, size: new Vector2(1.0f, 0.5f));
|
||||
HitBox fan2 = CreateDisabledHitBox(go.transform, "Phase1_FanCombo_HitBox_2", "EnemyHitBox",
|
||||
true, report, size: new Vector2(1.0f, 0.5f));
|
||||
HitBox fan3 = CreateDisabledHitBox(go.transform, "Phase1_FanCombo_HitBox_3", "EnemyHitBox",
|
||||
true, report, size: new Vector2(1.2f, 0.6f));
|
||||
HitBox tornadoHB = CreateDisabledHitBox(go.transform, "Phase1_Tornado_HitBox", "EnemyHitBox",
|
||||
true, report, size: new Vector2(0.6f, 1.2f));
|
||||
|
||||
// 弹体发射点(Phase1 回旋扇 / 龙卷;Phase2 风石)
|
||||
Transform boomerangMuzzleT = GetOrCreateChild(go.transform, "BoomerangMuzzle");
|
||||
Transform tornadoMuzzleT = GetOrCreateChild(go.transform, "TornadoMuzzle");
|
||||
Transform windStoneMuzzleT = GetOrCreateChild(go.transform, "WindStoneMuzzle");
|
||||
|
||||
// 能力集:入场演出 + 五个战斗招,一招一个子物体。
|
||||
// EnemyAbilityBase 带 [DisallowMultipleComponent],同一物体放不下四个 PlayClipAbility;
|
||||
// EnemyAbilityRegistry.CollectFrom 用 GetComponentsInChildren 递归收集,子物体照常注册。
|
||||
Transform abilitiesT = GetOrCreateChild(go.transform, "Abilities");
|
||||
var abIntro = GetOrAddComponent<PlayClipAbility>(
|
||||
GetOrCreateChild(abilitiesT, "PlayClipAbility_Intro").gameObject);
|
||||
var abFan = GetOrAddComponent<MeleeAttackAbility>(
|
||||
GetOrCreateChild(abilitiesT, "MeleeAttackAbility_FanCombo").gameObject);
|
||||
var abBoomer = GetOrAddComponent<PlayClipAbility>(
|
||||
GetOrCreateChild(abilitiesT, "PlayClipAbility_Boomerang").gameObject);
|
||||
var abTornadoS = GetOrAddComponent<PlayClipAbility>(
|
||||
GetOrCreateChild(abilitiesT, "PlayClipAbility_TornadoSmall").gameObject);
|
||||
var abTornadoL = GetOrAddComponent<PlayClipAbility>(
|
||||
GetOrCreateChild(abilitiesT, "PlayClipAbility_TornadoLarge").gameObject);
|
||||
var abWind = GetOrAddComponent<PlayClipAbility>(
|
||||
GetOrCreateChild(abilitiesT, "PlayClipAbility_WindStone").gameObject);
|
||||
|
||||
// SOs — assign first so OnValidate doesn't warn during wiring
|
||||
AssignAsset(bossBase, "_statsSO", report, false, "ENM_ChaoFeng_Stats");
|
||||
AssignAsset(bossBase, "_animConfig", report, false, "ENM_ChaoFeng_AnimConfig");
|
||||
@@ -1122,7 +1147,6 @@ namespace BaseGames.Editor
|
||||
AssignReference(bossBase, "_feedback", feedback, report);
|
||||
AssignReference(bossBase, "_hurtBox", hurtBox, report);
|
||||
AssignReference(bossBase, "_bodyCollider", body, report); // 身体几何唯一权威(EnemyBase.Body)
|
||||
AssignReference(skillExec, "_animancer", animancer, report);
|
||||
|
||||
// 浮空 / 击落 / 弹体发射点接线(计划)
|
||||
AssignReference(bossBase, "_floatController", floatCtrl, report);
|
||||
@@ -1130,6 +1154,7 @@ namespace BaseGames.Editor
|
||||
AssignReference(bossBase, "_boomerangMuzzle", boomerangMuzzleT, report);
|
||||
AssignReference(bossBase, "_tornadoMuzzle", tornadoMuzzleT, report);
|
||||
AssignReference(bossBase, "_windStoneMuzzle", windStoneMuzzleT, report);
|
||||
AssignReference(bossBase, "_phaseGate", phaseGate, report);
|
||||
AssignReference(floatCtrl, "_rb", rb, report);
|
||||
AssignReference(knockdown, "_boss", bossBase, report);
|
||||
AssignReference(knockdown, "_floatCtrl", floatCtrl, report);
|
||||
@@ -1158,37 +1183,60 @@ namespace BaseGames.Editor
|
||||
report);
|
||||
AssignLayerMask(movement, "_wallMask", new[] { "Platform", "Wall" }, report); // 撞墙判定层(策划可调)
|
||||
|
||||
// 收集 BossSkillSO 并赋给执行器(计划技能集)
|
||||
var skillAssets = new System.Collections.Generic.List<Object>();
|
||||
foreach (var n in new[] { "ABL_ChaoFeng_Boomerang", "ABL_ChaoFeng_FanCombo",
|
||||
"ABL_ChaoFeng_TornadoSmall", "ABL_ChaoFeng_TornadoLarge",
|
||||
"ABL_ChaoFeng_WindStone" })
|
||||
{
|
||||
Object sk = FindFirstAsset(n);
|
||||
if (sk != null) skillAssets.Add(sk);
|
||||
else report.Add($"未找到 BossSkillSO:{n},请先一键创建 ChaoFeng SO 后再重新运行此放置操作。");
|
||||
}
|
||||
if (skillAssets.Count > 0)
|
||||
AssignObjectArray(skillExec, "_skills", skillAssets.ToArray(), report);
|
||||
// 决策层:Boss 走定制路径(AiScript),不用配方
|
||||
AssignString(brain, "_definitionId", "ChaoFeng", report);
|
||||
|
||||
AssignString(skillExec, "_bossId", "ChaoFeng", report);
|
||||
AssignObjectArray(skillExec, "_hitBoxes", new Object[] { fan1, fan2, fan3, tornadoHB }, report);
|
||||
// 能力 SO 绑定(一招一组件)
|
||||
var abilityWiring = new (Object comp, string assetName)[]
|
||||
{
|
||||
(abIntro, "ABL_ChaoFeng_Intro"),
|
||||
(abFan, "ABL_ChaoFeng_FanCombo"),
|
||||
(abBoomer, "ABL_ChaoFeng_Boomerang"),
|
||||
(abTornadoS, "ABL_ChaoFeng_TornadoSmall"),
|
||||
(abTornadoL, "ABL_ChaoFeng_TornadoLarge"),
|
||||
(abWind, "ABL_ChaoFeng_WindStone"),
|
||||
};
|
||||
foreach (var (comp, assetName) in abilityWiring)
|
||||
{
|
||||
Object so = FindFirstAsset(assetName);
|
||||
if (so != null) AssignReference(comp, "_config", so, report);
|
||||
else report.Add($"未找到 EnemyAbilitySO:{assetName}," +
|
||||
"请先在角色向导 Boss 页「一键创建全部 ChaoFeng SO」后重新运行本放置操作。");
|
||||
}
|
||||
|
||||
// 近战三段 HitBox 走命名槽位(EnemyAttackSO.hitBoxSlot 按名索引)
|
||||
AssignMeleeHitBoxSlots(abFan, new[] { ("fan_1", fan1), ("fan_2", fan2), ("fan_3", fan3) }, report);
|
||||
|
||||
// 阶段招池:地面四招 = 阶段 0,风石 = 阶段 1;
|
||||
// 入场演出不登记(category=None,本就不进选招池,且需全阶段可用)
|
||||
AssignPhaseGate(phaseGate, new (EnemyAbilityBase ability, int[] phases)[]
|
||||
{
|
||||
(abFan, new[] { 0 }),
|
||||
(abBoomer, new[] { 0 }),
|
||||
(abTornadoS, new[] { 0 }),
|
||||
(abTornadoL, new[] { 0 }),
|
||||
(abWind, new[] { 1 }),
|
||||
}, report);
|
||||
|
||||
Object dmgSrc = FindFirstAsset("CMB_DS_BossBody", "CMB_DS_EnemyBody");
|
||||
if (dmgSrc != null)
|
||||
{
|
||||
foreach (var hb in new[] { fan1, fan2, fan3, tornadoHB, contactHitBox })
|
||||
foreach (var hb in new[] { fan1, fan2, fan3, contactHitBox })
|
||||
if (hb != null) AssignReference(hb, "_defaultSource", dmgSrc, report);
|
||||
}
|
||||
|
||||
SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "sight" }, report);
|
||||
|
||||
report.Add("★ FanCombo 三段 HitBox 与 Tornado HitBox 已挂入 BossSkillExecutor._hitBoxes。");
|
||||
report.Add("★ 将 BoomerangMuzzle / TornadoMuzzle / WindStoneMuzzle 拖入对应 BossSkillSO 的发射点字段(如有)。");
|
||||
report.Add("★ 回旋扇收招/阶段过渡/击败演出等动画 Clip 待美术接入后在 ChaoFengBoss Inspector 指定。");
|
||||
report.Add("★ AI(BrainGraph):用菜单 BaseGames/AI/Enemy AI Recipe Wizard 建一个配方资产," +
|
||||
"在 Inspector 选两层模块(未发现/交战),再挂 EnemyAiBrain 组件并把 _recipe 指向该资产" +
|
||||
"(技能执行仍由 BossSkillExecutor 负责)。有独门机制的敌人才写 [AiDefinition] 的 AiScript 子类并改用 _definitionId。");
|
||||
report.Add("★ FanCombo 三段 HitBox 已绑为命名槽位 fan_1 / fan_2 / fan_3," +
|
||||
"在各 EATK_ 资产的 hitBoxSlot 里填对应名字。");
|
||||
report.Add("★ 回旋扇 / 龙卷 / 风石的生成由技能 clip 上的 SpawnProjectile 动画事件驱动," +
|
||||
"payload 填 boomerang / tornado_small / tornado_large / wind_stone;" +
|
||||
"炮口引用留在 ChaoFengBoss 上,无需再拖到 SO。");
|
||||
report.Add("★ 回旋扇收招 / 阶段过渡 / 击败演出等动画 Clip 待美术接入后在 ChaoFengBoss Inspector 指定。");
|
||||
report.Add("★ AI:Boss 走定制路径,EnemyAiBrain._definitionId 已设为 ChaoFeng(对应 ChaoFengAi)。" +
|
||||
"小怪才用配方(_recipe)。");
|
||||
report.Add("★ 开战触发:Boss 房间入口需另放一个带 BossFightTrigger 的触发区," +
|
||||
"指向本 Boss 的 EnemyAiBrain 并填 bossId=ChaoFeng,否则 Boss 不会开战。");
|
||||
|
||||
Undo.CollapseUndoOperations(undoGroup);
|
||||
Selection.activeGameObject = go;
|
||||
@@ -1196,6 +1244,75 @@ namespace BaseGames.Editor
|
||||
MarkDirtyAndLog("Boss 嘲风 (ChaoFeng)", go, report);
|
||||
}
|
||||
|
||||
[MenuItem("BaseGames/Scene/Place/Boss 开战触发区 (嘲风)", priority = 118)]
|
||||
public static void PlaceChaoFengFightTrigger() => PlaceBossFightTrigger("ChaoFeng");
|
||||
|
||||
/// <summary>
|
||||
/// 放置 Boss 开战触发区:玩家进入即广播开战事件并让 Boss 决策层进战。
|
||||
/// 没有它,Boss 会一直停在静置态——开战事件频道此前全项目零发送方。
|
||||
/// </summary>
|
||||
/// <param name="bossId">
|
||||
/// Boss id。既写进触发器的 <c>_bossId</c>(BGM / 血条按它定位 Boss),
|
||||
/// 也用来在活动场景里定位目标 Boss——按 <c>EnemyAiBrain._definitionId</c> 匹配。
|
||||
/// </param>
|
||||
public static void PlaceBossFightTrigger(string bossId)
|
||||
{
|
||||
var report = new List<string>();
|
||||
int undoGroup = Undo.GetCurrentGroup();
|
||||
Undo.SetCurrentGroupName("Place Boss Fight Trigger");
|
||||
|
||||
GameObject go = new GameObject($"BossFightTrigger_{bossId}");
|
||||
Undo.RegisterCreatedObjectUndo(go, "Place Boss Fight Trigger");
|
||||
go.transform.position = GetDropPosition();
|
||||
SetLayer(go, "TriggerZone", report);
|
||||
|
||||
// 碰撞体必须先于 BossFightTrigger 挂上:该组件标了 [RequireComponent(typeof(Collider2D))],
|
||||
// 而 Collider2D 是抽象类,Unity 补不出来,AddComponent 会返回真正的 null。
|
||||
BoxCollider2D col = GetOrAddComponent<BoxCollider2D>(go);
|
||||
col.isTrigger = true;
|
||||
col.size = new Vector2(4f, 6f); // 门口尺寸占位,按竞技场入口在 Inspector 调整
|
||||
|
||||
BossFightTrigger trigger = GetOrAddComponent<BossFightTrigger>(go);
|
||||
|
||||
AssignString(trigger, "_bossId", bossId, report);
|
||||
AssignLayerMask(trigger, "_playerLayers", "Player", report);
|
||||
AssignAsset(trigger, "_onBossFightStarted", report, true, "EVT_BossFightStarted");
|
||||
// 开关频道:资产名叫 EVT_BossFightEnded,但实际是「战斗中 true/false」的开关,
|
||||
// BGMController 与 BossHPBar 绑的也是它。名字与用途不符是既存问题,此处沿用同一资产。
|
||||
AssignAsset(trigger, "_onBossFightToggled", report, true, "EVT_BossFightEnded");
|
||||
|
||||
// 目标 Boss:按 EnemyAiBrain._definitionId 在活动场景里精确匹配,不做名字模糊猜测。
|
||||
// 匹配不到(或匹配到多个)一律如实报告,不塞一个凑数的引用——
|
||||
// 漏绑时 BossFightTrigger.Awake 也会再报一次并停用自己。
|
||||
var matches = new List<EnemyAiBrain>();
|
||||
foreach (var root in UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().GetRootGameObjects())
|
||||
foreach (var b in root.GetComponentsInChildren<EnemyAiBrain>(true))
|
||||
if (new SerializedObject(b).FindProperty("_definitionId")?.stringValue == bossId)
|
||||
matches.Add(b);
|
||||
|
||||
if (matches.Count == 1)
|
||||
{
|
||||
AssignReference(trigger, "_bossBrain", matches[0], report);
|
||||
report.Add($"[OK] _bossBrain → {matches[0].name}(_definitionId={bossId})");
|
||||
}
|
||||
else if (matches.Count == 0)
|
||||
{
|
||||
report.Add($"✗ 活动场景里找不到 _definitionId={bossId} 的 EnemyAiBrain," +
|
||||
"_bossBrain 未绑定。请先放置该 Boss,再重跑本操作。");
|
||||
}
|
||||
else
|
||||
{
|
||||
report.Add($"✗ 活动场景里有 {matches.Count} 个 _definitionId={bossId} 的 EnemyAiBrain," +
|
||||
"无法确定目标,_bossBrain 未绑定。请先清理重复的 Boss。");
|
||||
}
|
||||
|
||||
report.Add("★ 把触发区拖到竞技场入口并按门口尺寸调整 BoxCollider2D。只触发一次。");
|
||||
|
||||
Undo.CollapseUndoOperations(undoGroup);
|
||||
Selection.activeGameObject = go;
|
||||
MarkDirtyAndLog($"Boss 开战触发区 ({bossId})", go, report);
|
||||
}
|
||||
|
||||
// ══ 敌人放置辅助方法 ═══════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
@@ -2448,6 +2565,37 @@ namespace BaseGames.Editor
|
||||
report?.Add($"[OK] MeleeAttackAbility._hitBoxSlots 已配置 {slots.Length} 个槽位。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 BossPhaseAbilityGate._entries 赋值(struct 数组 {ability, phases})。
|
||||
/// phases 为空数组 = 该能力全阶段可用。
|
||||
/// </summary>
|
||||
private static void AssignPhaseGate(BossPhaseAbilityGate gate,
|
||||
(EnemyAbilityBase ability, int[] phases)[] entries,
|
||||
List<string> report)
|
||||
{
|
||||
var so = new SerializedObject(gate);
|
||||
var prop = so.FindProperty("_entries");
|
||||
if (prop == null || !prop.isArray)
|
||||
{
|
||||
report?.Add("[WARN] BossPhaseAbilityGate._entries 属性未找到,阶段招池未写入,请检查字段名。");
|
||||
return;
|
||||
}
|
||||
prop.arraySize = entries.Length;
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
{
|
||||
var elem = prop.GetArrayElementAtIndex(i);
|
||||
elem.FindPropertyRelative("ability").objectReferenceValue = entries[i].ability;
|
||||
|
||||
var phasesProp = elem.FindPropertyRelative("phases");
|
||||
int[] phases = entries[i].phases ?? System.Array.Empty<int>();
|
||||
phasesProp.arraySize = phases.Length;
|
||||
for (int p = 0; p < phases.Length; p++)
|
||||
phasesProp.GetArrayElementAtIndex(p).intValue = phases[p];
|
||||
}
|
||||
so.ApplyModifiedPropertiesWithoutUndo();
|
||||
report?.Add($"[OK] BossPhaseAbilityGate._entries 已写入 {entries.Length} 条阶段招池。");
|
||||
}
|
||||
|
||||
private static Object FindFirstAsset(params string[] candidates)
|
||||
{
|
||||
foreach (string candidate in candidates)
|
||||
|
||||
@@ -18,8 +18,6 @@ namespace BaseGames.Editor
|
||||
{
|
||||
{ "WeaponSO", ("WPN_", "WPN_{ID},例:WPN_SkyBlade") },
|
||||
{ "FormSkillSO", ("SKL_", "SKL_{Name},例:SKL_SoulBlade") },
|
||||
{ "BossSkillSO", ("SKL_", "SKL_{Name},例:SKL_BossRage") },
|
||||
{ "SkillSequenceSO", ("SKL_", "SKL_Seq_{Name},例:SKL_Seq_RageCombo") },
|
||||
{ "EnemyStatsSO", ("ENM_", "ENM_E{ID}_Stats,例:ENM_E001_Stats") },
|
||||
{ "LootTableSO", ("ENM_", "ENM_E{ID}_Loot,例:ENM_E001_Loot") },
|
||||
{ "FormConfigSO", ("PLY_", "PLY_{FormID},例:PLY_Player01") },
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8bc3529e552a34a45998814c7cd056e6
|
||||
AssemblyDefinitionImporter:
|
||||
guid: d0934d78d9f1fe34aa6c0429a0822df8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
@@ -0,0 +1,80 @@
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 嘲风的 AI 图(定制路径:Boss 数量少、彼此差异大,手写图而非配方)。
|
||||
///
|
||||
/// 阶段 0(地面):逼近 ↔ 选招。地面招由 BossPhaseAbilityGate 在阶段 0 放行。
|
||||
/// 阶段 1(空中):悬停 ↔ 选招。空中招由阶段门在阶段 1 放行,地面招被禁用。
|
||||
///
|
||||
/// 招式的射程 / 冷却 / 权重全归各能力自己的 EnemyAbilitySO;本图只决定
|
||||
/// 「什么时候该逼近、什么时候该出手、什么时候该换阶段」。
|
||||
/// 死亡不在本图:EnemyBase 的死亡流程发 Died 事件,全局边把决策停在 Death 终态。
|
||||
///
|
||||
/// 三条刻意的形状,改图时别顺手抹掉:
|
||||
/// 1. Boss 不脱战——逼近态的 rest 指回自身,脱离感知区也不回静置态。
|
||||
/// 2. 阶段单向推进——空中阶段没有回地面阶段的边,血量回升也不退阶段(招池已换)。
|
||||
/// 3. 起手就打完——攻击态不挂脱战边,前摇中玩家跑开仍完整打完。
|
||||
/// </summary>
|
||||
[AiDefinition("ChaoFeng")]
|
||||
public sealed class ChaoFengAi : AiScript
|
||||
{
|
||||
public const string Wait = "Wait";
|
||||
public const string Intro = "Intro";
|
||||
public const string Ground = "Ground";
|
||||
public const string GroundAttack = "GroundAttack";
|
||||
public const string PhaseTx = "PhaseTransition";
|
||||
public const string Air = "Air";
|
||||
public const string AirAttack = "AirAttack";
|
||||
public const string Death = "Death";
|
||||
|
||||
/// <summary>入场演出能力 id(对应入场能力资产的 abilityId)。</summary>
|
||||
public const string IntroAbility = "chaofeng_intro";
|
||||
|
||||
/// <summary>空中阶段的血量阈值。</summary>
|
||||
private const float AirPhaseHpRatio = 0.5f;
|
||||
|
||||
/// <summary>阶段过渡的目标阶段索引。</summary>
|
||||
private const int AirPhaseIndex = 1;
|
||||
|
||||
/// <summary>阶段过渡无敌时长,须 ≥ 浮空上升时长 + 缓冲。</summary>
|
||||
private const float PhaseTxInvincible = 2f;
|
||||
|
||||
protected override void Build(BrainBuilder b)
|
||||
{
|
||||
b.Entry(Wait);
|
||||
|
||||
// 竞技场触发前静置:不巡逻、不警觉(Boss 不搜敌,等玩家进场)
|
||||
AiStateFragments.Locomotion(b, Wait, LocomotionMode.Idle)
|
||||
.To(Intro).OnEvent(AiSignal.Engaged);
|
||||
|
||||
// 入场演出:一次性能力,播完进战
|
||||
AiStateFragments.AbilityOnce(b, Intro, IntroAbility)
|
||||
.To(Ground).When(x => !x.Combat.IsAbilityRunning(IntroAbility), "introDone");
|
||||
|
||||
// 阶段 0(地面):逼近 ↔ 选招。rest 指回自身——Boss 不脱战。
|
||||
BossFragments.ApproachAttack(b, Ground, GroundAttack, rest: Ground)
|
||||
.To(PhaseTx).When(x => x.Vitals.HpBelow(AirPhaseHpRatio), "hp<50%");
|
||||
|
||||
// 阶段过渡:无敌 + 浮空演出由 Boss 侧的阶段过渡回调承担。
|
||||
// PhaseTransition 片段刻意不带出边——这条 txDone 边就是它的出口,删了会把 Boss 永久锁死。
|
||||
BossFragments.PhaseTransition(b, PhaseTx, AirPhaseIndex, PhaseTxInvincible)
|
||||
.To(Air).When(x => !x.Boss.IsPhaseTransitioning, "txDone");
|
||||
|
||||
// 阶段 1(空中):悬停朝向玩家 ↔ 选招。刻意不挂回地面阶段的边(阶段单向推进)。
|
||||
AiStateFragments.Locomotion(b, Air, LocomotionMode.Face)
|
||||
.To(AirAttack).When(x => x.Combat.HasEligibleAttack(), "attackInRange");
|
||||
|
||||
// 与地面攻击态同语义:起手就打完,不挂脱战边
|
||||
b.DeclareState(AirAttack)
|
||||
.OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
|
||||
.OnExit (x => x.Combat.InterruptAbilities())
|
||||
.To(Air).When(x => !x.Combat.IsAbilityRunning(), "attackDone");
|
||||
|
||||
// 死亡终态:演出走物理层的击败序列,图上只需停止决策
|
||||
AiStateFragments.Terminal(b, Death);
|
||||
b.Global().To(Death).OnEvent(AiSignal.Died);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9df917389cefced4692c7e56ed9bd6e3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -5,7 +5,7 @@ namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 把 BrainGraph 决策层挂到敌人上:按 id 取共享 AiGraph、构造 AiRuntime、逐帧推进。
|
||||
/// 取代旧的行为树组件。
|
||||
/// 是敌人唯一的决策层组件(小怪与 Boss 共用)。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
[RequireComponent(typeof(EnemyBase))]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using BaseGames.AI;
|
||||
|
||||
@@ -10,11 +11,16 @@ namespace BaseGames.Enemies
|
||||
public sealed class EnemyBrainContext : IAiContext, ISensor, ICombatant, IActorVitals, IEnemyActor
|
||||
{
|
||||
readonly EnemyBase _enemy;
|
||||
readonly BossBase _boss;
|
||||
readonly Blackboard _blackboard = new Blackboard();
|
||||
Vector2 _lastKnown;
|
||||
float _lostTimer;
|
||||
|
||||
public EnemyBrainContext(EnemyBase enemy) { _enemy = enemy; }
|
||||
public EnemyBrainContext(EnemyBase enemy)
|
||||
{
|
||||
_enemy = enemy;
|
||||
_boss = enemy as BossBase; // 非 Boss 为 null,Boss facet 访问时显式抛
|
||||
}
|
||||
|
||||
// ---- IAiContext ----
|
||||
public ISensor Sensor => this;
|
||||
@@ -23,6 +29,14 @@ namespace BaseGames.Enemies
|
||||
public IActorVitals Vitals => this;
|
||||
public Blackboard Blackboard => _blackboard;
|
||||
|
||||
/// <summary>
|
||||
/// Boss 专属决策面。非 Boss 敌人访问即抛——小怪图误挂了 Boss 边必须立刻暴露,
|
||||
/// 不返回空对象让错误静默通过(CLAUDE.md 第 6 条)。
|
||||
/// </summary>
|
||||
public IBossControl Boss => _boss ?? throw new InvalidOperationException(
|
||||
$"[EnemyBrainContext] '{_enemy.name}' 不是 Boss(未挂 BossBase 或其子类)," +
|
||||
"但它的 AI 图访问了 IAiContext.Boss。请检查该敌人是否误用了 Boss 专属的图或图片段。");
|
||||
|
||||
/// <summary>每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时(供需要的敌人用)。</summary>
|
||||
public void Refresh(float dt)
|
||||
{
|
||||
@@ -53,10 +67,26 @@ namespace BaseGames.Enemies
|
||||
public Vector2 LastKnown => _lastKnown;
|
||||
|
||||
// ---- ICombatant ----
|
||||
/// <summary>
|
||||
/// 执行指定 id 的能力。解析不到该 id 一律显式报错——
|
||||
/// 建图期 <c>AiStateFragments.Ability/AbilityOnce</c> 已挡掉空 id,
|
||||
/// 所以运行期解析不到只可能是漏挂能力组件或 SO 上的 abilityId 写错,没有合法情形。
|
||||
/// 静默返回 false 会让「能力态进入后什么都不做、IsAbilityRunning 恒假、
|
||||
/// 完成条件立刻成立」表现为"演出被跳过"却零报错(CLAUDE.md 第 6 条)。
|
||||
/// 用 LogError 而非抛异常:这是每帧路径,抛异常会打断整个 AiRuntime.Tick,
|
||||
/// 让一个漏配的敌人拖垮整场战斗。
|
||||
/// </summary>
|
||||
public bool UseAbility(string abilityId)
|
||||
{
|
||||
var a = _enemy.Abilities?.Get(abilityId);
|
||||
return a != null && a.Execute();
|
||||
if (a == null)
|
||||
{
|
||||
Debug.LogError($"[EnemyBrainContext] '{_enemy.name}' 的 AI 图请求能力 " +
|
||||
$"'{abilityId}',但该敌人身上没有这个能力组件。" +
|
||||
"请检查能力组件是否挂载、其 EnemyAbilitySO 的 abilityId 是否一致。", _enemy);
|
||||
return false;
|
||||
}
|
||||
return a.Execute();
|
||||
}
|
||||
public bool IsAbilityRunning(string abilityId = null)
|
||||
{
|
||||
@@ -95,6 +125,9 @@ namespace BaseGames.Enemies
|
||||
var mode = _enemy.StatsSO != null
|
||||
? _enemy.StatsSO.attackSelectionMode
|
||||
: Abilities.AttackSelectionMode.WeightedRandom;
|
||||
// 折扣系数与 mode 同步每次刷新,使播放模式下调参立即生效(不必重进场景)
|
||||
if (_enemy.StatsSO != null)
|
||||
sel.AntiRepeatFactor = _enemy.StatsSO.attackAntiRepeatFactor;
|
||||
bool grounded = _enemy.Movement != null && _enemy.Movement.IsGrounded;
|
||||
var pick = sel.Select(_enemy.IsPlayerVisible(), grounded, mode);
|
||||
return pick != null && pick.Execute();
|
||||
|
||||
@@ -46,9 +46,10 @@ namespace BaseGames.Enemies
|
||||
/// <summary>
|
||||
/// 无行为的终态。不需要在这里停移动——转入本态时,上一个态的 OnExit 已经收尾。
|
||||
/// 用于"死亡演出交给物理状态机(EnemyBase.PerformDeath)"的敌人。
|
||||
/// 自带终态标记,因此能通过 Build() 的"非终态必须有出口"校验。
|
||||
/// </summary>
|
||||
public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state)
|
||||
=> b.DeclareState(state);
|
||||
=> b.DeclareState(state).Terminal();
|
||||
|
||||
static void ApplyLocomotion(IAiContext x, LocomotionMode mode)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using BaseGames.AI;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss 图的共享建态片段。与 <see cref="AiStateFragments"/> 同级——是静态建态原语,
|
||||
/// 不是可插拔模块:Boss 图手写,重复的形状抽成片段即可,不需要 SO 下拉与多态序列化。
|
||||
/// </summary>
|
||||
public static class BossFragments
|
||||
{
|
||||
/// <summary>
|
||||
/// 阶段过渡态:进入时发起过渡(无敌 + 演出由 BossBase 负责),过渡结束后由调用方挂出边。
|
||||
/// 只发起一次——放 OnEnter 而非 Tick,因为 BossBase.BeginPhaseTransition 对重入会告警。
|
||||
/// </summary>
|
||||
public static BrainBuilder.StateBuilder PhaseTransition(
|
||||
BrainBuilder b, string state, int targetPhase, float invincibleDuration)
|
||||
{
|
||||
if (targetPhase < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(targetPhase),
|
||||
$"BossFragments.PhaseTransition: 状态 '{state}' 的目标阶段不能为负。");
|
||||
if (invincibleDuration <= 0f)
|
||||
throw new ArgumentOutOfRangeException(nameof(invincibleDuration),
|
||||
$"BossFragments.PhaseTransition: 状态 '{state}' 的无敌时长必须 > 0——" +
|
||||
"为 0 意味着过渡演出期间可被打断,这不是阶段过渡的语义。");
|
||||
|
||||
return b.DeclareState(state)
|
||||
.OnEnter(x =>
|
||||
{
|
||||
x.Locomotion.Stop();
|
||||
x.Combat.InterruptAbilities();
|
||||
x.Boss.BeginPhaseTransition(targetPhase, invincibleDuration);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一组「逼近 ↔ 到射程选招攻击」。转发到 ApproachAttackEngagement 的共享实现,
|
||||
/// 使模块路径与 Boss 图路径永远是同一份行为。
|
||||
/// 返回逼近态的 builder,调用方可继续挂阶段过渡等出边。
|
||||
/// </summary>
|
||||
public static BrainBuilder.StateBuilder ApproachAttack(
|
||||
BrainBuilder b, string approach, string attack, string rest)
|
||||
=> ApproachAttackEngagement.Declare(b, approach, attack, rest);
|
||||
|
||||
/// <summary>
|
||||
/// 移动到第 index 个竞技场锚点(<see cref="BossArenaAnchors"/>),到位后由调用方挂出边。
|
||||
/// 坐标经 IBossControl 取得——声明层的 lambda 不得闭包捕获具体实例。
|
||||
/// </summary>
|
||||
public static BrainBuilder.StateBuilder MoveToAnchor(BrainBuilder b, string state, int index)
|
||||
{
|
||||
if (index < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(index),
|
||||
$"BossFragments.MoveToAnchor: 状态 '{state}' 的锚点下标不能为负。");
|
||||
|
||||
return b.DeclareState(state)
|
||||
.OnEnter(x => x.Locomotion.MoveTo(x.Boss.AnchorAt(index)))
|
||||
.OnExit (x => x.Locomotion.Stop());
|
||||
}
|
||||
|
||||
/// <summary>共享条件:已到达第 index 个锚点(容差内)。</summary>
|
||||
public static Func<IAiContext, bool> AtAnchor(int index, float tolerance = 0.2f)
|
||||
=> x => x.Boss.DistanceToAnchor(index) <= tolerance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f04cf21bca8c61418a9ce9e88f6ba91
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+16
-5
@@ -22,8 +22,17 @@ namespace BaseGames.Enemies
|
||||
public string EngageLabel => "InChaseZone";
|
||||
|
||||
public void Build(BrainBuilder b, string rest)
|
||||
=> Declare(b, Approach, Attack, rest);
|
||||
|
||||
/// <summary>
|
||||
/// 建一组「逼近 ↔ 到射程选招攻击」。态名参数化,供本模块与 Boss 图共用同一份实现
|
||||
/// (Boss 可能有多个阶段各一组,如地面组与空中组)。
|
||||
/// 返回逼近态的 builder,调用方可在其上继续挂自己的出边(如阶段过渡)。
|
||||
/// </summary>
|
||||
internal static BrainBuilder.StateBuilder Declare(
|
||||
BrainBuilder b, string approach, string attack, string rest)
|
||||
{
|
||||
b.DeclareState(Approach)
|
||||
var approachBuilder = b.DeclareState(approach)
|
||||
.OnEnter(x => x.Locomotion.Pursue(x.Sensor.LastKnown))
|
||||
.Tick (x => x.Locomotion.Pursue(x.Sensor.LastKnown))
|
||||
.OnExit(x => x.Locomotion.Stop())
|
||||
@@ -31,16 +40,18 @@ namespace BaseGames.Enemies
|
||||
// (EnemyAttackSelector.Eligible 的 InAttackRange 门),故"够得着"不会在
|
||||
// "已脱离全部感知"之后仍成立,两条件不会相争。若将来出现射程大于追击区的招式,
|
||||
// 那属于射程/感知区配置错误,由 AI 配方校验器报出,不要靠调整此处边序绕开。
|
||||
.To(Attack).When(x => x.Combat.HasEligibleAttack(), "attackInRange")
|
||||
.To(attack).When(x => x.Combat.HasEligibleAttack(), "attackInRange")
|
||||
.To(rest) .When(AiStateFragments.LostAllZones, "leftAllZones");
|
||||
|
||||
// 攻击一旦起手就打完:刻意不挂 leftAllZones 边。
|
||||
// 玩家在前摇中跑出感知区时招式仍完整打完,之后经 Approach 自然脱战——
|
||||
// 玩家在前摇中跑出感知区时招式仍完整打完,之后经逼近态自然脱战——
|
||||
// 只多一帧,且消除了"起手到一半凭空收招"的观感缺陷。
|
||||
b.DeclareState(Attack)
|
||||
b.DeclareState(attack)
|
||||
.OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
|
||||
.OnExit (x => x.Combat.InterruptAbilities())
|
||||
.To(Approach).When(x => !x.Combat.IsAbilityRunning(), "attackDone");
|
||||
.To(approach).When(x => !x.Combat.IsAbilityRunning(), "attackDone");
|
||||
|
||||
return approachBuilder;
|
||||
}
|
||||
|
||||
// 本模块无自身配置:招式的射程/冷却/权重全归敌人身上的 EnemyAttackSelector,
|
||||
|
||||
@@ -2,11 +2,12 @@ namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 能力执行的阶段枚举(架构 07_EnemyModule §8)。
|
||||
/// 用于 BD/Animator/UI 查询能力当前在哪个阶段(telegraph/出招/恢复)。
|
||||
/// 用于 AI 决策层 / 动画 / UI 查询能力当前在哪个阶段(预警 / 出招 / 恢复)。
|
||||
/// </summary>
|
||||
public enum AbilityRunState
|
||||
{
|
||||
Idle,
|
||||
/// <summary>预警:由能力自身在协程里显式置位(如 BlinkStrikeAbility 的现身闪光)。</summary>
|
||||
Telegraph,
|
||||
Windup,
|
||||
Active,
|
||||
@@ -14,7 +15,7 @@ namespace BaseGames.Enemies.Abilities
|
||||
Interrupted
|
||||
}
|
||||
|
||||
/// <summary>能力中断原因。供 BD 判断是否需要重新调度。</summary>
|
||||
/// <summary>能力中断原因。供 AI 决策层判断是否需要重新调度。</summary>
|
||||
public enum InterruptReason
|
||||
{
|
||||
ExternalRequest,
|
||||
|
||||
@@ -3,7 +3,8 @@ namespace BaseGames.Enemies.Abilities
|
||||
/// <summary>多攻击选招策略。</summary>
|
||||
public enum AttackSelectionMode
|
||||
{
|
||||
WeightedRandom, // 合格集内按 weight 加权随机
|
||||
Priority // 合格集内取 priority 最高(并列取首个)
|
||||
WeightedRandom, // 合格集内按 weight 加权随机
|
||||
Priority, // 合格集内取 priority 最高(并列取首个)
|
||||
WeightedRandomAntiRepeat, // 同 WeightedRandom,但对上一次选中的招施加权重折扣,压低连续重复
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using Animancer;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Pool;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
@@ -37,11 +35,16 @@ namespace BaseGames.Enemies.Abilities
|
||||
public float CooldownRemaining => Mathf.Max(0f, _cooldownEndTime - Time.time);
|
||||
public bool IsOnCooldown => CooldownRemaining > 0f;
|
||||
|
||||
/// <summary>能力被外部中断时触发(BD Task / 状态机订阅用)。</summary>
|
||||
/// <summary>能力被外部中断时触发(AI 决策层 / 状态机订阅用)。</summary>
|
||||
public event System.Action<InterruptReason> Interrupted;
|
||||
|
||||
/// <summary>BD 任务统一查询入口:当前是否可用(冷却完毕且未执行中)。</summary>
|
||||
public virtual bool CanUse => !_isRunning && !IsOnCooldown && _enemy != null && _enemy.IsAlive;
|
||||
/// <summary>
|
||||
/// 统一可用性查询:组件启用 + 冷却完毕 + 未执行中 + 宿主存活。
|
||||
/// enabled 这一维供阶段门使用——被阶段禁用的能力不得进入选招候选,
|
||||
/// 否则选招器会选中它然后 StartCoroutine 在禁用组件上必然失败。
|
||||
/// </summary>
|
||||
public virtual bool CanUse => enabled && !_isRunning && !IsOnCooldown
|
||||
&& _enemy != null && _enemy.IsAlive;
|
||||
|
||||
// ── IAttackCandidate(供 EnemyAttackSelector 选招)──────────────────
|
||||
public bool RequiresLineOfSight => _config != null && _config.requiresLineOfSight;
|
||||
@@ -109,13 +112,9 @@ namespace BaseGames.Enemies.Abilities
|
||||
private IEnumerator RunInternal()
|
||||
{
|
||||
_isRunning = true;
|
||||
Phase = AbilityRunState.Telegraph;
|
||||
Phase = AbilityRunState.Windup;
|
||||
try
|
||||
{
|
||||
if (_config != null && _config.telegraphDuration > 0f)
|
||||
yield return TelegraphRoutine();
|
||||
|
||||
Phase = AbilityRunState.Windup;
|
||||
yield return ExecuteCoroutine();
|
||||
Phase = AbilityRunState.Recovery;
|
||||
}
|
||||
@@ -132,17 +131,6 @@ namespace BaseGames.Enemies.Abilities
|
||||
/// <summary>子类实现:能力主体。可分多段、含 HitBox 激活/弹幕生成/物理推进等。</summary>
|
||||
protected abstract IEnumerator ExecuteCoroutine();
|
||||
|
||||
/// <summary>预警阶段(默认生成 VFX 后等待 telegraphDuration)。子类可重写。</summary>
|
||||
protected virtual IEnumerator TelegraphRoutine()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_config.telegraphVfxKey))
|
||||
{
|
||||
var pool = ServiceLocator.GetOrDefault<IObjectPoolService>();
|
||||
pool?.Spawn(_config.telegraphVfxKey, _transform.position, Quaternion.identity);
|
||||
}
|
||||
yield return EnemyAbilityWaits.Get(_config.telegraphDuration);
|
||||
}
|
||||
|
||||
/// <summary>能力结束钩子(被中断或正常结束都会调用)。</summary>
|
||||
protected virtual void OnAbilityEnded() { }
|
||||
|
||||
@@ -167,6 +155,15 @@ namespace BaseGames.Enemies.Abilities
|
||||
|
||||
protected virtual void OnInterrupted(InterruptReason reason) { }
|
||||
|
||||
/// <summary>
|
||||
/// 把冷却清回出生态,使能力立刻可用。
|
||||
/// 供对象池复活(<see cref="EnemyBase.OnSpawn"/>)调用:冷却以绝对 Time.time 记时,
|
||||
/// 不显式清零就会原样活过 despawn/spawn,让新生的敌人带着上一条命的剩余冷却。
|
||||
/// 注意不能靠 <see cref="Interrupt"/> 代劳——它有 _isRunning 门(出生时无一在跑),
|
||||
/// 且其语义是"中断后计半程冷却",是写入冷却而非清除。
|
||||
/// </summary>
|
||||
public void ResetCooldown() => _cooldownEndTime = -1f;
|
||||
|
||||
/// <summary>子类辅助:朝向目标(写入输入信号,下一 FixedUpdate 由 EnemyMovement 消费)。</summary>
|
||||
protected void FaceTarget(Transform target)
|
||||
{
|
||||
|
||||
@@ -61,6 +61,17 @@ namespace BaseGames.Enemies.Abilities
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空所有能力的冷却。对象池复活时调用。
|
||||
/// 与 <see cref="InterruptAll"/> 的区别是**不看 IsRunning**——出生时没有能力在跑,
|
||||
/// 那道门会让中断路径一次都不执行,正是冷却跨命残留的成因。
|
||||
/// </summary>
|
||||
public void ResetAllCooldowns()
|
||||
{
|
||||
for (int i = 0; i < _all.Count; i++)
|
||||
if (_all[i] != null) _all[i].ResetCooldown();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 中断指定互斥组内所有正在执行的能力。
|
||||
/// 由 <see cref="EnemyAbilityBase"/> 在 Execute 开始时调用,确保同组互斥。
|
||||
|
||||
@@ -6,27 +6,28 @@ namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 能力配置包(架构 07_EnemyModule §8.2)。
|
||||
/// 一个能力 = 一组攻击段 + 公共参数(冷却/预警/中断规则)。
|
||||
/// 一个能力 = 一组攻击段 + 公共参数(冷却/中断规则)。
|
||||
/// 预警不在此配置:预警的载体是动画 clip 本身(姿态在动画里,音效与特效挂动画事件)。
|
||||
/// 由对应 EnemyAbilityBase 子类组件读取并执行。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Ability", fileName = "EAB_")]
|
||||
[CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Ability", fileName = "ABL_")]
|
||||
public class EnemyAbilitySO : ScriptableObject, IValidatable
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("BD 任务通过此 Id 调用能力(如 \"melee_combo\" / \"blink_strike\")")]
|
||||
[Tooltip("能力唯一 id(全小写英文 + 下划线,如 \"melee_combo\" / \"blink_strike\")。" +
|
||||
"AI 图与选招器通过它引用本能力")]
|
||||
public string abilityId = "ability_id";
|
||||
|
||||
[TextArea(1, 4)]
|
||||
[Tooltip("设计备注(仅供编辑器参考,不影响运行)")]
|
||||
public string designNote;
|
||||
|
||||
[Header("攻击序列")]
|
||||
public EnemyAttackSO[] attackSequence;
|
||||
|
||||
[Header("冷却(秒,从能力执行结束开始计)")]
|
||||
[Min(0f)] public float cooldown = 1.5f;
|
||||
|
||||
[Header("预警(Telegraph)")]
|
||||
[Tooltip("预警 VFX key(IObjectPoolService.Spawn 的池 key),为空则跳过")]
|
||||
public string telegraphVfxKey = "";
|
||||
[Min(0f)] public float telegraphDuration = 0f;
|
||||
|
||||
[Header("中断规则")]
|
||||
[Tooltip("受击时是否打断能力(false=能力具霸体)")]
|
||||
public bool interruptOnHurt = true;
|
||||
|
||||
@@ -9,18 +9,41 @@ namespace BaseGames.Enemies.Abilities
|
||||
/// </summary>
|
||||
public sealed class EnemyAttackSelector
|
||||
{
|
||||
/// <summary>防重复折扣系数的默认值。EnemyStatsSO 字段初值与 EnemyBase 的兜底分支共用此常量。</summary>
|
||||
public const float DefaultAntiRepeatFactor = 0.3f;
|
||||
|
||||
private readonly List<IAttackCandidate> _candidates;
|
||||
// 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC
|
||||
private readonly List<float> _weightBuf;
|
||||
// 防重复折扣系数:0 = 有替代时绝不连续重复,1 = 不惩罚
|
||||
private float _antiRepeatFactor;
|
||||
|
||||
public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates)
|
||||
private IAttackCandidate _lastPicked;
|
||||
|
||||
public EnemyAttackSelector(IEnumerable<IAttackCandidate> candidates,
|
||||
float antiRepeatFactor = DefaultAntiRepeatFactor)
|
||||
{
|
||||
_candidates = new List<IAttackCandidate>(candidates);
|
||||
_weightBuf = new List<float>(_candidates.Count);
|
||||
_candidates = new List<IAttackCandidate>(candidates);
|
||||
_weightBuf = new List<float>(_candidates.Count);
|
||||
AntiRepeatFactor = antiRepeatFactor; // 经属性赋值,夹紧逻辑只有一处
|
||||
}
|
||||
|
||||
public int Count => _candidates.Count;
|
||||
|
||||
/// <summary>防重复折扣系数。与 attackSelectionMode 一样每次选招前由调用方刷新,
|
||||
/// 使策划在播放模式下调参能立即生效。</summary>
|
||||
public float AntiRepeatFactor
|
||||
{
|
||||
get => _antiRepeatFactor;
|
||||
set => _antiRepeatFactor = Mathf.Clamp01(value);
|
||||
}
|
||||
|
||||
/// <summary>上一次实际选中的招(防重复折扣的作用对象;供测试与调试面板读取)。</summary>
|
||||
public IAttackCandidate LastPicked => _lastPicked;
|
||||
|
||||
/// <summary>对象池复用 / 阶段切换时清除防重复记忆。</summary>
|
||||
public void ResetRepeatMemory() => _lastPicked = null;
|
||||
|
||||
private static bool Eligible(IAttackCandidate c, bool hasLOS, bool grounded)
|
||||
=> c != null && c.CanUse && c.InAttackRange()
|
||||
&& (!c.RequiresLineOfSight || hasLOS)
|
||||
@@ -34,9 +57,24 @@ namespace BaseGames.Enemies.Abilities
|
||||
}
|
||||
|
||||
public IAttackCandidate Select(bool hasLOS, bool grounded, AttackSelectionMode mode)
|
||||
=> mode == AttackSelectionMode.Priority
|
||||
? SelectByPriority(hasLOS, grounded)
|
||||
: SelectByWeight(hasLOS, grounded);
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case AttackSelectionMode.Priority:
|
||||
return Remember(SelectByPriority(hasLOS, grounded));
|
||||
case AttackSelectionMode.WeightedRandomAntiRepeat:
|
||||
return Remember(SelectByWeight(hasLOS, grounded, antiRepeat: true));
|
||||
default:
|
||||
return Remember(SelectByWeight(hasLOS, grounded, antiRepeat: false));
|
||||
}
|
||||
}
|
||||
|
||||
// 只在真选中了东西时更新记忆:选空时保留上一招,避免"空一帧就清掉防重复"。
|
||||
private IAttackCandidate Remember(IAttackCandidate pick)
|
||||
{
|
||||
if (pick != null) _lastPicked = pick;
|
||||
return pick;
|
||||
}
|
||||
|
||||
private IAttackCandidate SelectByPriority(bool hasLOS, bool grounded)
|
||||
{
|
||||
@@ -50,20 +88,25 @@ namespace BaseGames.Enemies.Abilities
|
||||
return best;
|
||||
}
|
||||
|
||||
private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded)
|
||||
private IAttackCandidate SelectByWeight(bool hasLOS, bool grounded, bool antiRepeat)
|
||||
{
|
||||
// 有效权重:不合格候选填 0(由共享原语保证零权重项绝不被选中)。缓冲区复用 → 选招零 GC。
|
||||
_weightBuf.Clear();
|
||||
for (int i = 0; i < _candidates.Count; i++)
|
||||
{
|
||||
var c = _candidates[i];
|
||||
_weightBuf.Add(Eligible(c, hasLOS, grounded) ? Mathf.Max(0f, c.Weight) : 0f);
|
||||
float w = Eligible(c, hasLOS, grounded) ? Mathf.Max(0f, c.Weight) : 0f;
|
||||
// 防重复:上一招权重打折。折扣可把权重压到 0——此时若它是唯一候选,
|
||||
// 下面的 Priority 退化路径会把它选出来,不会出现"敌人永远不出手"。
|
||||
if (antiRepeat && w > 0f && ReferenceEquals(c, _lastPicked))
|
||||
w *= _antiRepeatFactor;
|
||||
_weightBuf.Add(w);
|
||||
}
|
||||
|
||||
int idx = BaseGames.Core.WeightedPick.Index(_weightBuf);
|
||||
if (idx >= 0) return _candidates[idx];
|
||||
|
||||
// 无正权重(合格候选权重全 0)→ 退化为按 Priority 选(并列取首个)
|
||||
// 无正权重(合格候选权重全 0,或全被防重复折扣压平)→ 退化为按 Priority 选(并列取首个)
|
||||
return SelectByPriority(hasLOS, grounded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace BaseGames.Enemies.Abilities
|
||||
/// </summary>
|
||||
public interface IAttackCandidate
|
||||
{
|
||||
bool CanUse { get; } // 未冷却 + 未运行 + 存活
|
||||
bool CanUse { get; } // 组件启用 + 未冷却 + 未运行 + 存活
|
||||
bool RequiresLineOfSight { get; } // 是否需要视线(由敌人级 LOS 提供)
|
||||
bool RequiresGrounded { get; } // 是否需要着地
|
||||
float Weight { get; } // WeightedRandom 权重
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace BaseGames.Enemies.Behaviors
|
||||
/// <summary>
|
||||
/// 死亡前摇无敌演出(零代码替代每敌人专属的 <c>Die()</c> 重写)。
|
||||
/// <para>
|
||||
/// <see cref="EnemyBase.Die"/> 会委托本组件:停行为树 / 停移动、停用受击框,播放前摇动画并等待,
|
||||
/// <see cref="EnemyBase.Die"/> 会委托本组件:通知决策层终止 / 停移动、停用受击框,播放前摇动画并等待,
|
||||
/// 期间敌人处于无敌(<see cref="EnemyBase.IsInvincible"/>),演出结束后回调真正的死亡清理。
|
||||
/// 前摇动画上可放置 SpawnProjectile 动画事件,配合 <see cref="EnemySpawnerOnEvent"/> 在演出中生成小怪。
|
||||
/// </para>
|
||||
@@ -26,8 +26,9 @@ namespace BaseGames.Enemies.Behaviors
|
||||
[Header("演出期间")]
|
||||
[Tooltip("演出期间停用的受击框(防止演出中被打断或二次受伤);对象池复用时 OnSpawn 自动恢复")]
|
||||
[SerializeField] private HurtBox[] _hurtBoxesToDisable;
|
||||
[Tooltip("演出开始时停止行为树(防止 BT 继续 Tick 覆盖演出动画)")]
|
||||
[SerializeField] private bool _stopBehaviorTree = true;
|
||||
[UnityEngine.Serialization.FormerlySerializedAs("_stopBehaviorTree")]
|
||||
[Tooltip("死亡演出开始时通知决策层终止,防止 AI 继续覆盖演出")]
|
||||
[SerializeField] private bool _stopDecisionLayer = true;
|
||||
[Tooltip("演出开始时停止移动")]
|
||||
[SerializeField] private bool _stopMovement = true;
|
||||
|
||||
@@ -60,7 +61,7 @@ namespace BaseGames.Enemies.Behaviors
|
||||
|
||||
private IEnumerator Sequence(Action onComplete)
|
||||
{
|
||||
if (_stopBehaviorTree) _enemy?.StopBehaviorTree();
|
||||
if (_stopDecisionLayer) _enemy?.NotifyDecisionStop();
|
||||
if (_stopMovement) _enemy?.StopMovement();
|
||||
|
||||
SetHurtBoxesEnabled(false);
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using BaseGames.Combat;
|
||||
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个攻击图案的数据。伤害参数只写在此处,BossSkillSO 不存参数。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Boss/AttackPattern")]
|
||||
public class AttackPatternSO : ScriptableObject
|
||||
{
|
||||
[Header("输出")]
|
||||
public DamageSourceSO DamageSource;
|
||||
public float KnockbackAngle;
|
||||
|
||||
[Header("弹幕(若为弹幕类型)")]
|
||||
public AssetReferenceGameObject ProjectilePrefab;
|
||||
public int ProjectileCount = 1;
|
||||
public float SpreadAngle = 0f;
|
||||
public float ProjectileSpeed = 8f;
|
||||
|
||||
[Header("范围攻击(若为 AoE 类型)")]
|
||||
public float AoERadius;
|
||||
public Vector2 AoEOffset;
|
||||
|
||||
[Header("时序")]
|
||||
[Min(0f)] public float WindupDuration;
|
||||
[Min(0f)] public float ActiveDuration;
|
||||
[Min(0f)] public float RecoveryDuration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 竞技场锚点集合:Boss 定点走位(跳到悬空平台、传送到房间固定角落)的坐标来源。
|
||||
/// 挂在 Boss 上;锚点物体本身放在场景里(随房间布局走,不进 SO)。
|
||||
///
|
||||
/// AI 图不直接持有本组件——坐标经 <see cref="BaseGames.AI.IBossControl.AnchorAt"/> 取得
|
||||
/// (图的 lambda 不得闭包捕获实例)。BossBase 在 Awake 解析本组件并转发。
|
||||
/// </summary>
|
||||
public sealed class BossArenaAnchors : MonoBehaviour
|
||||
{
|
||||
[Tooltip("锚点物体(顺序即下标,AI 图按下标引用)")]
|
||||
[SerializeField] private Transform[] _anchors;
|
||||
|
||||
public int Count => _anchors != null ? _anchors.Length : 0;
|
||||
|
||||
/// <summary>取第 index 个锚点坐标。下标越界或漏配即抛——不静默回退到自身位置。</summary>
|
||||
public Vector2 At(int index)
|
||||
{
|
||||
if (_anchors == null || index < 0 || index >= _anchors.Length)
|
||||
throw new System.IndexOutOfRangeException(
|
||||
$"[BossArenaAnchors] {name} 请求锚点下标 {index},但只配了 {Count} 个。" +
|
||||
"请在 Inspector 补齐锚点,或修正 AI 图里的下标。");
|
||||
var t = _anchors[index];
|
||||
if (t == null)
|
||||
throw new System.InvalidOperationException(
|
||||
$"[BossArenaAnchors] {name} 第 {index} 个锚点为空引用。请在 Inspector 指定。");
|
||||
return t.position;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnValidate()
|
||||
{
|
||||
if (_anchors == null) return;
|
||||
for (int i = 0; i < _anchors.Length; i++)
|
||||
if (_anchors[i] == null)
|
||||
Debug.LogError($"[BossArenaAnchors] {name} 第 {i} 个锚点未指定。", this);
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (_anchors == null) return;
|
||||
Gizmos.color = new Color(1f, 0.8f, 0.2f, 0.9f);
|
||||
for (int i = 0; i < _anchors.Length; i++)
|
||||
{
|
||||
if (_anchors[i] == null) continue;
|
||||
Gizmos.DrawWireSphere(_anchors[i].position, 0.35f);
|
||||
UnityEditor.Handles.Label(_anchors[i].position, $"anchor {i}");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2e82425fc5d93141b283d4b30a67a9b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,63 +1,79 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Boss;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Parry;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss 敌人基类。扩展 <see cref="EnemyBase"/> 以支持多阶段切换、技能执行与战斗结束广播。
|
||||
/// 具体 Boss 继承此类并重写 <see cref="EnterPhase"/>。
|
||||
/// Boss 敌人基类。扩展 <see cref="EnemyBase"/> 以支持多阶段切换与战斗结束广播。
|
||||
/// 招式的选取与执行走小怪同轨(<see cref="EnemyAttackSelector"/> + 能力组件),
|
||||
/// 本类不再持有独立的技能执行器。具体 Boss 继承此类并重写 <see cref="EnterPhase"/>。
|
||||
/// </summary>
|
||||
public class BossBase : EnemyBase
|
||||
public class BossBase : EnemyBase, BaseGames.AI.IBossControl
|
||||
{
|
||||
[Header("Boss 配置")]
|
||||
[SerializeField] private string _bossId;
|
||||
[SerializeField] private BoolEventChannelSO _onBossFightEnded;
|
||||
[SerializeField] private BossPhaseEventChannelSO _onBossPhaseChanged;
|
||||
|
||||
[Header("技能执行器")]
|
||||
[SerializeField] private BossSkillExecutor _skillExecutor;
|
||||
|
||||
[Header("资源组件(可选)")]
|
||||
[SerializeField] private BossResource _bossResource;
|
||||
|
||||
[Header("玩家反制事件(可选)")]
|
||||
[Tooltip("订阅此频道以响应玩家弹反成功事件")]
|
||||
[SerializeField] private ParryInfoEventChannelSO _onParrySuccess;
|
||||
[Header("阶段招池门(可选)")]
|
||||
[Tooltip("按阶段启用 / 禁用能力组件;未挂载则所有能力全阶段可用")]
|
||||
[SerializeField] private BossPhaseAbilityGate _phaseGate;
|
||||
|
||||
[Header("竞技场锚点(可选)")]
|
||||
[Tooltip("定点走位用的锚点集合;AI 图未用到锚点片段时可留空")]
|
||||
[SerializeField] private BossArenaAnchors _arenaAnchors;
|
||||
|
||||
public string BossId => _bossId;
|
||||
|
||||
/// <summary>当前是否有 Boss 技能正在执行(BD_UseBossSkill 轮询此值)。</summary>
|
||||
public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting;
|
||||
|
||||
protected int _currentPhase = 0;
|
||||
/// <summary>当前 Boss 阶段索引(BD Task 可直接查询)。</summary>
|
||||
/// <summary>当前 Boss 阶段索引。IBossControl 实现。</summary>
|
||||
public int CurrentPhase => _currentPhase;
|
||||
private Coroutine _counterStaggerCoroutine;
|
||||
|
||||
// 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配
|
||||
private readonly List<BossSkillSO> _weightedCandidates = new(8);
|
||||
private readonly List<float> _candidateWeights = new(8);
|
||||
/// <summary>Boss 资源是否已满。IBossControl 实现。
|
||||
/// 未挂 BossResource 即抛——AI 图问了资源却没配资源组件是配置错误,必须暴露。</summary>
|
||||
public bool ResourceFull => _bossResource != null
|
||||
? _bossResource.IsFull
|
||||
: throw new System.InvalidOperationException(
|
||||
$"[BossBase] '{name}' 的 AI 图查询了资源满值,但未挂 BossResource 组件。" +
|
||||
"请挂上该组件,或从 AI 图里移除资源相关的边。");
|
||||
|
||||
// 单元素缓冲数组,供 ApplyCounterResponse 缓存当前技能,避免 new[] 分配
|
||||
private readonly BossSkillSO[] _singleSkillBuf = new BossSkillSO[1];
|
||||
/// <summary>第 index 个竞技场锚点坐标。IBossControl 实现。未挂锚点组件即抛——
|
||||
/// AI 图用了锚点片段却没配锚点是配置错误,必须暴露而非回退到自身位置。</summary>
|
||||
public Vector2 AnchorAt(int index) => RequireAnchors().At(index);
|
||||
|
||||
/// <summary>当前位置到第 index 个锚点的距离。IBossControl 实现。</summary>
|
||||
public float DistanceToAnchor(int index)
|
||||
=> Vector2.Distance(transform.position, RequireAnchors().At(index));
|
||||
|
||||
private BossArenaAnchors RequireAnchors()
|
||||
=> _arenaAnchors != null ? _arenaAnchors : throw new System.InvalidOperationException(
|
||||
$"[BossBase] '{name}' 的 AI 图使用了竞技场锚点,但未挂 BossArenaAnchors 组件。" +
|
||||
"请挂上该组件并配好锚点,或从 AI 图里移除锚点片段。");
|
||||
|
||||
protected override void Awake()
|
||||
{
|
||||
base.Awake();
|
||||
// includeInactive:true 确保禁用状态的子组件也能被发现(如分阶段按需启用的执行器)
|
||||
if (_skillExecutor == null) _skillExecutor = GetComponentInChildren<BossSkillExecutor>(true);
|
||||
// includeInactive:true 确保禁用状态的子组件也能被发现(如阶段门按阶段停用的组件)
|
||||
if (_bossResource == null) _bossResource = GetComponentInChildren<BossResource>(true);
|
||||
if (_phaseGate == null) _phaseGate = GetComponentInChildren<BossPhaseAbilityGate>(true);
|
||||
if (_arenaAnchors == null) _arenaAnchors = GetComponentInChildren<BossArenaAnchors>(true);
|
||||
}
|
||||
|
||||
protected override void OnEnable()
|
||||
// 初始阶段的招池必须在第一次选招前就位。
|
||||
// ApplyPhase 此前只在 EnterPhase(阶段切换)与 OnSpawn(对象池复用)里调用,
|
||||
// 而直接摆在场景里的 Boss 两条路径都不走——开局时后续阶段的招式仍是 enabled,
|
||||
// 会混进阶段 0 的候选池。放在 Start:此时各能力组件的 Awake 均已执行完毕。
|
||||
protected override void Start()
|
||||
{
|
||||
base.OnEnable();
|
||||
_onParrySuccess?.Subscribe(HandleParrySuccess).AddTo(_subs);
|
||||
base.Start();
|
||||
_phaseGate?.ApplyPhase(_currentPhase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -65,99 +81,6 @@ namespace BaseGames.Enemies
|
||||
/// </summary>
|
||||
public override bool IsInvincible => IsPhaseTransitioning || base.IsInvincible;
|
||||
|
||||
/// <summary>
|
||||
/// 上一次成功执行的技能 ID。<see cref="UseBossSkillWeighted"/> 对其施加权重惩罚,防止相同技能连续重复。
|
||||
/// </summary>
|
||||
public string LastUsedSkillId { get; private set; }
|
||||
|
||||
// ── 技能执行(BD Task 调用入口)─────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 通过技能 ID 执行 Boss 技能。
|
||||
/// 若技能未找到、执行器忙或冷却中则返回 false,否则返回 true。
|
||||
/// </summary>
|
||||
public bool UseBossSkill(string skillId)
|
||||
{
|
||||
if (_skillExecutor == null || string.IsNullOrEmpty(skillId)) return false;
|
||||
if (IsPhaseTransitioning) return false;
|
||||
var skill = _skillExecutor.FindSkill(skillId);
|
||||
if (skill == null)
|
||||
{
|
||||
Debug.LogWarning($"[BossBase] 未找到技能 '{skillId}'(Boss: {_bossId})", this);
|
||||
return false;
|
||||
}
|
||||
if (!_skillExecutor.CanUseSkill(skillId))
|
||||
return false;
|
||||
if (!CheckResourceCost(skill))
|
||||
return false;
|
||||
|
||||
_skillExecutor.ExecuteSkill(skill);
|
||||
_bossResource?.OnBossUseSkill();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在当前阶段可用且冷却就绪的技能中,按 <see cref="BossSkillSO.weight"/> 加权随机选择一个并执行。
|
||||
/// 若上一次已使用某技能,则对该技能施加 0.3× 权重惩罚,降低连续重复的概率。
|
||||
/// 若无可用技能或执行器忙则返回 false。
|
||||
/// </summary>
|
||||
public bool UseBossSkillWeighted()
|
||||
{
|
||||
if (_skillExecutor == null || _skillExecutor.IsExecuting) return false;
|
||||
if (IsPhaseTransitioning) return false;
|
||||
|
||||
var skills = _skillExecutor.Skills;
|
||||
if (skills == null || skills.Length == 0) return false;
|
||||
|
||||
// 筛选:在当前阶段可用 + 冷却就绪 + weight > 0
|
||||
_weightedCandidates.Clear();
|
||||
_candidateWeights.Clear();
|
||||
foreach (var s in skills)
|
||||
{
|
||||
if (s == null || s.weight <= 0f) continue;
|
||||
if (!_skillExecutor.CanUseSkill(s.skillId)) continue;
|
||||
if (!IsSkillAvailableInPhase(s)) continue;
|
||||
|
||||
_weightedCandidates.Add(s);
|
||||
// 防重复:上一个技能权重打折
|
||||
_candidateWeights.Add(s.skillId == LastUsedSkillId ? s.weight * 0.3f : s.weight);
|
||||
}
|
||||
|
||||
// 加权随机抽取(共享原语:无正权重/无候选时返回 -1)
|
||||
int idx = BaseGames.Core.WeightedPick.Index(_candidateWeights);
|
||||
if (idx < 0) return false;
|
||||
BossSkillSO selected = _weightedCandidates[idx];
|
||||
|
||||
if (!CheckResourceCost(selected)) return false;
|
||||
|
||||
_skillExecutor.ExecuteSkill(selected);
|
||||
LastUsedSkillId = selected.skillId;
|
||||
_bossResource?.OnBossUseSkill();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>检查技能的 availablePhaseIndices 是否包含当前阶段(空数组 = 全阶段可用)。</summary>
|
||||
private bool IsSkillAvailableInPhase(BossSkillSO skill)
|
||||
{
|
||||
if (skill.availablePhaseIndices == null || skill.availablePhaseIndices.Length == 0)
|
||||
return true;
|
||||
foreach (int p in skill.availablePhaseIndices)
|
||||
if (p == _currentPhase) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查 Boss 资源是否满足技能的 minRequired 门槛。
|
||||
/// 未配置资源组件或 minRequired <= 0 时视为通过。
|
||||
/// </summary>
|
||||
private bool CheckResourceCost(BossSkillSO skill)
|
||||
{
|
||||
if (_bossResource == null) return true;
|
||||
float min = skill.resourceCost.minRequired;
|
||||
if (min <= 0f) return true;
|
||||
return _bossResource.CurrentValue >= min;
|
||||
}
|
||||
|
||||
// ── 阶段 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>当前是否处于阶段过渡(无敌帧 + 过渡演出)期间。</summary>
|
||||
@@ -166,16 +89,19 @@ namespace BaseGames.Enemies
|
||||
private Coroutine _phaseTransitionCoroutine;
|
||||
|
||||
/// <summary>
|
||||
/// 进入指定阶段。自动打断当前执行中的技能,广播 <see cref="BossPhaseEvent"/> 供 UI / 音乐系统响应。
|
||||
/// 进入指定阶段。自动打断在跑的招,广播 <see cref="BossPhaseEvent"/> 供 UI / 音乐系统响应。
|
||||
/// 子类可重写以添加额外过渡逻辑(动画、无敌帧等)。
|
||||
/// </summary>
|
||||
public virtual void EnterPhase(int phase)
|
||||
{
|
||||
// 阶段切换必须先打断正在执行的技能,确保原子性
|
||||
_skillExecutor?.InterruptCurrentSkill();
|
||||
// 阶段切换必须先打断在跑的招,确保原子性
|
||||
Abilities.InterruptAll(InterruptReason.ExternalRequest);
|
||||
|
||||
_currentPhase = phase;
|
||||
LastUsedSkillId = null; // 新阶段重置权重惩罚,防止跨阶段漂移
|
||||
// 新阶段换招池,上一阶段的防重复记忆不应跨阶段影响选招
|
||||
AttackSelector?.ResetRepeatMemory();
|
||||
// 阶段 = 换招池:先换池,再广播阶段事件,保证订阅方看到的是新池
|
||||
_phaseGate?.ApplyPhase(phase);
|
||||
_onBossPhaseChanged?.Raise(new BossPhaseEvent
|
||||
{
|
||||
BossId = _bossId,
|
||||
@@ -185,17 +111,17 @@ namespace BaseGames.Enemies
|
||||
|
||||
/// <summary>
|
||||
/// 启动阶段过渡演出:无敌帧 + 可选定格时间,结束后自动调用 <see cref="EnterPhase"/>。
|
||||
/// BD_BossPhaseTransition 检查 <see cref="IsPhaseTransitioning"/> 来等待过渡完成。
|
||||
/// 决策层检查 <see cref="IsPhaseTransitioning"/> 来等待过渡完成。
|
||||
/// </summary>
|
||||
/// <param name="targetPhase">过渡目标阶段索引。</param>
|
||||
/// <param name="invincibleDuration">无敌帧持续时间(秒)。</param>
|
||||
public void BeginPhaseTransition(int targetPhase, float invincibleDuration = 1.5f)
|
||||
public void BeginPhaseTransition(int targetPhase, float invincibleDuration)
|
||||
{
|
||||
if (IsPhaseTransitioning)
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"[BossBase] '{_bossId}' 已在阶段过渡中(当前阶段 {_currentPhase})," +
|
||||
$"忽略跳转至阶段 {targetPhase} 的请求。请检查行为树逻辑是否重复触发阶段切换。",
|
||||
$"忽略跳转至阶段 {targetPhase} 的请求。请检查决策层逻辑是否重复触发阶段切换。",
|
||||
this);
|
||||
return;
|
||||
}
|
||||
@@ -208,8 +134,8 @@ namespace BaseGames.Enemies
|
||||
IsPhaseTransitioning = true;
|
||||
OnBeginPhaseTransition(targetPhase);
|
||||
|
||||
// 打断技能 + 停止移动
|
||||
_skillExecutor?.InterruptCurrentSkill();
|
||||
// 打断在跑的招 + 停止移动
|
||||
Abilities.InterruptAll(InterruptReason.ExternalRequest);
|
||||
StopMovement();
|
||||
|
||||
// 无敌帧期间接受的伤害由 IsInvincible 属性屏蔽(子类重写 IsInvincible 或在此处理)
|
||||
@@ -264,96 +190,11 @@ namespace BaseGames.Enemies
|
||||
_onBossFightEnded?.Raise(true);
|
||||
}
|
||||
|
||||
// ── 玩家反制响应 ──────────────────────────────────────────────────────
|
||||
|
||||
private void HandleParrySuccess(ParryInfo info)
|
||||
{
|
||||
if (!IsAlive) return;
|
||||
var counterType = info.IsPerfect ? CounterType.PerfectParry : CounterType.Parry;
|
||||
ApplyCounterResponse(counterType, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 counterType 查找当前技能(或所有技能)的 PlayerCounterResponse 并应用效果。
|
||||
/// 可由外部系统(闪避穿越、弱点命中等)直接调用。
|
||||
/// </summary>
|
||||
public void ApplyCounterResponse(CounterType counterType, string requiredSkillId)
|
||||
{
|
||||
if (_skillExecutor == null) return;
|
||||
|
||||
// 优先检查当前正在执行的技能的反制规则
|
||||
BossSkillSO activeSkill = _skillExecutor.IsExecuting
|
||||
? _skillExecutor.FindCurrentSkill()
|
||||
: null;
|
||||
|
||||
BossSkillSO[] candidates;
|
||||
if (activeSkill != null)
|
||||
{
|
||||
_singleSkillBuf[0] = activeSkill;
|
||||
candidates = _singleSkillBuf;
|
||||
}
|
||||
else
|
||||
{
|
||||
candidates = _skillExecutor.Skills;
|
||||
}
|
||||
|
||||
if (candidates == null) return;
|
||||
|
||||
foreach (var skill in candidates)
|
||||
{
|
||||
if (skill?.counterResponses == null) continue;
|
||||
foreach (var resp in skill.counterResponses)
|
||||
{
|
||||
if (resp.counterType != counterType) continue;
|
||||
if (!string.IsNullOrEmpty(resp.requiredSkillId) &&
|
||||
!string.IsNullOrEmpty(requiredSkillId) &&
|
||||
resp.requiredSkillId != requiredSkillId)
|
||||
continue;
|
||||
|
||||
ExecuteCounterEffect(resp);
|
||||
return; // 每次反制只触发第一条匹配规则
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteCounterEffect(in PlayerCounterResponse resp)
|
||||
{
|
||||
if (resp.interruptSkill)
|
||||
_skillExecutor?.InterruptCurrentSkill();
|
||||
|
||||
if (resp.bossStaggerDuration > 0f)
|
||||
{
|
||||
if (_counterStaggerCoroutine != null)
|
||||
StopCoroutine(_counterStaggerCoroutine);
|
||||
_counterStaggerCoroutine = StartCoroutine(CounterStaggerCoroutine(resp.bossStaggerDuration));
|
||||
}
|
||||
|
||||
if (resp.openVulnWindow)
|
||||
{
|
||||
float duration = Mathf.Max(resp.bossStaggerDuration, 1f);
|
||||
float multiplier = 1f + resp.bossDamageBonus;
|
||||
_skillExecutor?.OpenVulnerabilityWindow(duration, multiplier);
|
||||
}
|
||||
|
||||
resp.counterFeedback?.Play();
|
||||
}
|
||||
|
||||
private IEnumerator CounterStaggerCoroutine(float duration)
|
||||
{
|
||||
ForceState(EnemyStateType.Stagger);
|
||||
// 时长固定且较短,直接 new WFY 即可;若需优化可接入 WFS 缓存
|
||||
yield return new WaitForSeconds(duration);
|
||||
if (IsAlive && CurrentState == EnemyStateType.Stagger)
|
||||
ForceState(EnemyStateType.Controlled);
|
||||
_counterStaggerCoroutine = null;
|
||||
}
|
||||
|
||||
public override void OnSpawn()
|
||||
{
|
||||
base.OnSpawn();
|
||||
LastUsedSkillId = null;
|
||||
_currentPhase = 0;
|
||||
_skillExecutor?.ResetAllCooldowns();
|
||||
_currentPhase = 0;
|
||||
_phaseGate?.ApplyPhase(0); // 对象池复用:回到阶段 0 的启用集
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.AI;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss 开战触发:玩家进入竞技场触发区时,广播开战事件并让 Boss 的决策层从静置态进战。
|
||||
/// 此前开战事件频道全项目零发送方,Boss 永远不会开战。
|
||||
///
|
||||
/// 放在 Boss 房间入口的触发区上(Collider2D + isTrigger)。只触发一次。
|
||||
/// 漏配一律在 Awake 显式报错——静默不触发会表现为"Boss 站着不动",极难定位。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public sealed class BossFightTrigger : MonoBehaviour
|
||||
{
|
||||
[Header("目标 Boss")]
|
||||
[Tooltip("要唤醒的 Boss 的决策层组件")]
|
||||
[SerializeField] private EnemyAiBrain _bossBrain;
|
||||
|
||||
[Header("事件频道")]
|
||||
[Tooltip("EVT_BossFightStarted — payload 为 bossId,驱动全局状态机 / 后处理 / HUD")]
|
||||
[SerializeField] private StringEventChannelSO _onBossFightStarted;
|
||||
[Tooltip("EVT_BossFightEnded — 布尔开关频道,true 表示开始,驱动 Boss 血条与 BGM 切换")]
|
||||
[SerializeField] private BoolEventChannelSO _onBossFightToggled;
|
||||
|
||||
[Header("标识")]
|
||||
[Tooltip("Boss id,与 Boss 身上配置的 bossId 一致")]
|
||||
[SerializeField] private string _bossId;
|
||||
|
||||
[Header("触发条件")]
|
||||
[Tooltip("玩家所在的层")]
|
||||
[SerializeField] private LayerMask _playerLayers;
|
||||
|
||||
private bool _fired;
|
||||
|
||||
// 漏配处理沿用 EnemyAiBrain 的形态:逐条点名报错 + 停用组件。
|
||||
// 不做「缺谁就跳过谁」的部分执行——半开的战斗(有 BGM 没血条、Boss 不动)
|
||||
// 比彻底不开更难查,问题必须停在配置这一层。
|
||||
private void Awake()
|
||||
{
|
||||
bool valid = true;
|
||||
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (!col.isTrigger)
|
||||
{
|
||||
Debug.LogError($"[BossFightTrigger] {name} 的 Collider2D 未勾选 Is Trigger,无法触发开战。", this);
|
||||
valid = false;
|
||||
}
|
||||
if (_bossBrain == null)
|
||||
{
|
||||
Debug.LogError($"[BossFightTrigger] {name} 未指定目标 Boss 的 EnemyAiBrain,开战不会生效。", this);
|
||||
valid = false;
|
||||
}
|
||||
if (string.IsNullOrEmpty(_bossId))
|
||||
{
|
||||
Debug.LogError($"[BossFightTrigger] {name} 未填 bossId,BGM / 血条无法定位该 Boss。", this);
|
||||
valid = false;
|
||||
}
|
||||
if (_onBossFightStarted == null)
|
||||
{
|
||||
Debug.LogError($"[BossFightTrigger] {name} 未绑定开战事件频道,全局状态机 / 后处理不会响应开战。", this);
|
||||
valid = false;
|
||||
}
|
||||
if (_onBossFightToggled == null)
|
||||
{
|
||||
Debug.LogError($"[BossFightTrigger] {name} 未绑定开战开关频道,Boss 血条与战斗 BGM 不会切换。", this);
|
||||
valid = false;
|
||||
}
|
||||
// 掩码为空 = 任何层都过不了下面的判定,触发区形同虚设。
|
||||
if (_playerLayers.value == 0)
|
||||
{
|
||||
Debug.LogError($"[BossFightTrigger] {name} 的玩家层掩码为空,触发区永远不会被触发。", this);
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (!valid) enabled = false;
|
||||
}
|
||||
|
||||
// 能走到这里说明 Awake 的配置校验全过了,故不再判空——
|
||||
// 判空只会把已经报过错的漏配再盖一层。
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
if (_fired) return;
|
||||
if ((_playerLayers.value & (1 << other.gameObject.layer)) == 0) return;
|
||||
|
||||
_fired = true;
|
||||
_onBossFightStarted.Raise(_bossId);
|
||||
_onBossFightToggled.Raise(true);
|
||||
_bossBrain.Send(AiSignal.Engaged);
|
||||
}
|
||||
|
||||
/// <summary>供存档回载 / 重试时复位(Boss 未被击败则重新可触发)。</summary>
|
||||
public void ResetTrigger() => _fired = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd8a9d00a149c124a9bd90ffba663a32
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Enemies
|
||||
{
|
||||
/// <summary>
|
||||
/// 阶段 = 换招池:按当前阶段启用 / 禁用能力组件。
|
||||
/// 被禁用的能力其 <see cref="EnemyAbilityBase.CanUse"/> 为 false,
|
||||
/// 因此自动从 <see cref="EnemyAttackSelector"/> 的候选里消失——阶段门不需要碰选招器。
|
||||
///
|
||||
/// 由 <see cref="BossBase.EnterPhase"/> 与 <see cref="BossBase.OnSpawn"/> 直接调用
|
||||
/// (而非订阅阶段频道),保证与阶段切换严格同序。
|
||||
/// </summary>
|
||||
public sealed class BossPhaseAbilityGate : MonoBehaviour
|
||||
{
|
||||
[Serializable]
|
||||
public struct PhaseEntry
|
||||
{
|
||||
[Tooltip("受阶段管控的能力组件")]
|
||||
public EnemyAbilityBase ability;
|
||||
[Tooltip("该能力可用的阶段索引;空数组 = 全阶段可用")]
|
||||
public int[] phases;
|
||||
}
|
||||
|
||||
[Tooltip("阶段可用性表。未登记的能力不受本组件影响(保持其自身启用状态)")]
|
||||
[SerializeField] private PhaseEntry[] _entries;
|
||||
|
||||
/// <summary>把指定阶段的启用集应用到所有登记的能力。</summary>
|
||||
public void ApplyPhase(int phase)
|
||||
{
|
||||
if (_entries == null) return;
|
||||
for (int i = 0; i < _entries.Length; i++)
|
||||
{
|
||||
var e = _entries[i];
|
||||
if (e.ability == null) continue;
|
||||
e.ability.enabled = IsAllowed(e.phases, phase);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowed(int[] phases, int phase)
|
||||
{
|
||||
if (phases == null || phases.Length == 0) return true; // 空 = 全阶段
|
||||
for (int i = 0; i < phases.Length; i++)
|
||||
if (phases[i] == phase) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// 漏配显式报错,不静默跳过(CLAUDE.md 第 6 条)
|
||||
private void OnValidate()
|
||||
{
|
||||
if (_entries == null) return;
|
||||
for (int i = 0; i < _entries.Length; i++)
|
||||
if (_entries[i].ability == null)
|
||||
Debug.LogError(
|
||||
$"[BossPhaseAbilityGate] {name} 第 {i} 项未指定能力组件,该行不会生效。", this);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a9f265c84eb5db34b8fd06472949ee83
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -9,7 +9,7 @@ namespace BaseGames.Boss
|
||||
/// - 每帧以 <see cref="BossResourceConfigSO.passiveRate"/> 自动积累或消耗。
|
||||
/// - 受击时增加 <see cref="BossResourceConfigSO.onTakeDamageGain"/>。
|
||||
/// - 技能使用时增加 <see cref="BossResourceConfigSO.onSkillUseGain"/>。
|
||||
/// - 满值时若 <see cref="BossResourceConfigSO.autoTriggerOnFull"/>,自动让 BossBase 执行配置的技能。
|
||||
/// - 满值时 <see cref="IsFull"/> 为真,由 AI 图的条件边决定放什么招(决策不在本组件)。
|
||||
/// </summary>
|
||||
public sealed class BossResource : MonoBehaviour
|
||||
{
|
||||
@@ -17,7 +17,6 @@ namespace BaseGames.Boss
|
||||
[SerializeField] private BossBase _boss;
|
||||
|
||||
private float _currentValue;
|
||||
private bool _fullTriggered;
|
||||
|
||||
/// <summary>当前资源值(0 ~ config.maxValue)。</summary>
|
||||
public float CurrentValue => _currentValue;
|
||||
@@ -26,6 +25,10 @@ namespace BaseGames.Boss
|
||||
public float NormalizedValue => _config != null && _config.maxValue > 0f
|
||||
? _currentValue / _config.maxValue : 0f;
|
||||
|
||||
/// <summary>资源是否已达上限(供 AI 图条件边判定"可放大招")。</summary>
|
||||
public bool IsFull => _config != null && _config.maxValue > 0f
|
||||
&& _currentValue >= _config.maxValue;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_boss == null) _boss = GetComponentInParent<BossBase>();
|
||||
@@ -55,14 +58,14 @@ namespace BaseGames.Boss
|
||||
AddValue(_config.onTakeDamageGain);
|
||||
}
|
||||
|
||||
/// <summary>Boss 使用技能时调用。由 BossBase.UseBossSkill 触发。</summary>
|
||||
/// <summary>Boss 使用技能时调用。由使用了资源的能力触发(Boss 的招在自己的 EnemyAbilityBase 里调用)。</summary>
|
||||
public void OnBossUseSkill()
|
||||
{
|
||||
if (_config == null) return;
|
||||
AddValue(_config.onSkillUseGain);
|
||||
}
|
||||
|
||||
/// <summary>直接设置资源值(外部强制赋值,跳过满值触发)。</summary>
|
||||
/// <summary>直接设置资源值(外部强制赋值)。</summary>
|
||||
public void SetValue(float value)
|
||||
{
|
||||
_currentValue = Mathf.Clamp(value, 0f, _config != null ? _config.maxValue : float.MaxValue);
|
||||
@@ -72,33 +75,7 @@ namespace BaseGames.Boss
|
||||
|
||||
private void AddValue(float delta)
|
||||
{
|
||||
float prev = _currentValue;
|
||||
_currentValue = Mathf.Clamp(_currentValue + delta, 0f, _config.maxValue);
|
||||
|
||||
// 满值触发(从未满→满时只触发一次)
|
||||
if (_config.autoTriggerOnFull &&
|
||||
_currentValue >= _config.maxValue &&
|
||||
prev < _config.maxValue &&
|
||||
!_fullTriggered)
|
||||
{
|
||||
_fullTriggered = true;
|
||||
OnReachFull();
|
||||
}
|
||||
|
||||
if (_currentValue < _config.maxValue)
|
||||
_fullTriggered = false;
|
||||
}
|
||||
|
||||
private void OnReachFull()
|
||||
{
|
||||
if (_config.fullTriggerSkill == null || _boss == null) return;
|
||||
|
||||
_boss.UseBossSkill(_config.fullTriggerSkill.skillId);
|
||||
|
||||
if (_config.resetValueAfterTrigger > 0f)
|
||||
_currentValue = _config.resetValueAfterTrigger;
|
||||
else
|
||||
_currentValue = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ namespace BaseGames.Boss
|
||||
/// <summary>
|
||||
/// Boss 自身资源(如愤怒值)的配置 ScriptableObject。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 资源满值不再由本组件自动放招——那是绕过决策层直接执行,违反「AI 只决策、能力负责实现」。
|
||||
/// 现在的形态是:本组件只维护数值,AI 图挂一条 When(x => x.Boss.ResourceFull) 边进专属招态。
|
||||
/// </remarks>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Boss/ResourceConfig")]
|
||||
public class BossResourceConfigSO : ScriptableObject
|
||||
{
|
||||
@@ -17,10 +21,5 @@ namespace BaseGames.Boss
|
||||
public float passiveRate;
|
||||
public float onTakeDamageGain;
|
||||
public float onSkillUseGain;
|
||||
|
||||
[Header("满值效果")]
|
||||
public bool autoTriggerOnFull;
|
||||
public BossSkillSO fullTriggerSkill;
|
||||
public float resetValueAfterTrigger;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Animancer;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂在 Boss GameObject 上,接收 BossOrchestrator 的指令执行指定 BossSkillSO。
|
||||
/// 管理 VulnerabilityWindow 计时和 WeakPointSystem 激活。
|
||||
/// </summary>
|
||||
public class BossSkillExecutor : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private HitBox[] _hitBoxes;
|
||||
[SerializeField] private WeakPointSystem _weakPointSystem;
|
||||
[SerializeField] private AnimancerComponent _animancer;
|
||||
[SerializeField] private string _bossId;
|
||||
[SerializeField] private BossSkillEventChannelSO _onBossSkillStarted;
|
||||
[SerializeField] private BossSkillEventChannelSO _onBossSkillEnded;
|
||||
/// <remarks>PlayerController 无 Instance(架构 05 §2),由 Inspector 指定。</remarks>
|
||||
[SerializeField] private Transform _playerTransform;
|
||||
|
||||
[SerializeField] private BossSkillSO[] _skills;
|
||||
|
||||
[Header("技能重复检测范围")]
|
||||
[Tooltip("SkillSequence RepeatIfPlayerInRange 的检测半径(m)")]
|
||||
[SerializeField, Min(1f)] private float _repeatRangeCheck = 8f;
|
||||
|
||||
private BossSkillSO _currentSkill;
|
||||
private bool _isExecuting;
|
||||
private Coroutine _activeCoroutine;
|
||||
private Coroutine _vulnCoroutine; // 弱点窗口协程(中断时需同步停止)
|
||||
private bool _patternHitConfirmed; // 本次技能执行期间是否有 HitBox 命中
|
||||
|
||||
// 技能冷却:skillId → 冷却结束的 Time.time 时刻
|
||||
private readonly Dictionary<string, float> _skillCooldownEndTimes = new();
|
||||
|
||||
public bool IsExecuting => _isExecuting;
|
||||
|
||||
/// <summary>检查指定技能是否冷却就绪(无冷却记录或已过冷却时间)。</summary>
|
||||
public bool CanUseSkill(string skillId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(skillId)) return false;
|
||||
if (_skillCooldownEndTimes.TryGetValue(skillId, out float endTime))
|
||||
return Time.time >= endTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>强制重置指定技能的冷却(阶段切换、复活等场景使用)。</summary>
|
||||
public void ResetSkillCooldown(string skillId)
|
||||
{
|
||||
_skillCooldownEndTimes.Remove(skillId);
|
||||
}
|
||||
|
||||
/// <summary>重置所有技能冷却。</summary>
|
||||
public void ResetAllCooldowns() => _skillCooldownEndTimes.Clear();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
ValidateSkillConfig();
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnValidate() => ValidateSkillConfig();
|
||||
|
||||
private void ValidateSkillConfig()
|
||||
{
|
||||
if (_skills == null || _skills.Length == 0)
|
||||
{
|
||||
Debug.LogError($"[BossSkillExecutor] Boss '{_bossId}' ({gameObject.name}) 未配置任何技能 SO。", this);
|
||||
return;
|
||||
}
|
||||
foreach (var skill in _skills)
|
||||
{
|
||||
if (skill == null)
|
||||
Debug.LogError($"[BossSkillExecutor] Boss '{_bossId}' ({gameObject.name}) _skills 数组含 null 元素。", this);
|
||||
else if (string.IsNullOrEmpty(skill.skillId))
|
||||
Debug.LogError($"[BossSkillExecutor] Boss '{_bossId}' ({gameObject.name}) 技能 '{skill.name}' 缺少 skillId。", this);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// 按 float 值复用 WaitForSeconds 实例,消除协程中每次 new WaitForSeconds 的 GC 分配。
|
||||
/// Domain Reload 禁用时静态缓存跨 PlayMode 会话保留,但 WaitForSeconds 是幂等值对象,
|
||||
/// 不会引发功能错误;[RuntimeInitializeOnLoadMethod] 确保每次进入 Play 时清空。
|
||||
/// </summary>
|
||||
private static readonly Dictionary<float, WaitForSeconds> _wfsCache = new();
|
||||
|
||||
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ClearWFSCache() => _wfsCache.Clear();
|
||||
|
||||
private const int MaxWFSCacheSize = 64;
|
||||
|
||||
private static WaitForSeconds GetWFS(float t)
|
||||
{
|
||||
if (!_wfsCache.TryGetValue(t, out var wfs))
|
||||
{
|
||||
if (_wfsCache.Count < MaxWFSCacheSize)
|
||||
_wfsCache[t] = wfs = new WaitForSeconds(t);
|
||||
else
|
||||
return new WaitForSeconds(t);
|
||||
}
|
||||
return wfs;
|
||||
}
|
||||
|
||||
// ── 公共 API ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 按 skillId 查找已在 Inspector 注册的技能 SO。未找到返回 null。
|
||||
/// </summary>
|
||||
public BossSkillSO FindSkill(string skillId)
|
||||
{
|
||||
if (_skills == null) return null;
|
||||
foreach (var s in _skills)
|
||||
if (s != null && s.skillId == skillId) return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>返回当前正在执行的技能 SO,未执行时返回 null。</summary>
|
||||
public BossSkillSO FindCurrentSkill() => _isExecuting ? _currentSkill : null;
|
||||
|
||||
/// <summary>Inspector 中注册的全部技能 SO(只读)。</summary>
|
||||
public BossSkillSO[] Skills => _skills;
|
||||
|
||||
/// <summary>
|
||||
/// 执行一个 Boss 技能。若当前正在执行或技能冷却未就绪则返回。
|
||||
/// </summary>
|
||||
public void ExecuteSkill(BossSkillSO skill)
|
||||
{
|
||||
if (_isExecuting || skill == null) return;
|
||||
if (!CanUseSkill(skill.skillId))
|
||||
{
|
||||
Debug.Log($"[BossSkillExecutor] 技能 '{skill.skillId}' 冷却中,无法执行。", this);
|
||||
return;
|
||||
}
|
||||
// 提前订阅,确保 InterruptCurrentSkill() 中断时 FinishExecution() 能正常取消
|
||||
SubscribeHitCallbacks();
|
||||
_activeCoroutine = StartCoroutine(ExecuteSkillCoroutine(skill));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 立即打断正在执行的技能(阶段切换时调用)。
|
||||
/// </summary>
|
||||
public void InterruptCurrentSkill()
|
||||
{
|
||||
// 同步停止弱点窗口协程,防止中断后继续激活 WeakPointSystem
|
||||
if (_vulnCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_vulnCoroutine);
|
||||
_vulnCoroutine = null;
|
||||
}
|
||||
if (_activeCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_activeCoroutine);
|
||||
_activeCoroutine = null;
|
||||
}
|
||||
FinishExecution();
|
||||
}
|
||||
|
||||
// 等待事件触发的 VulnWindow(事件驱动类型,存储后由 NotifyVulnTrigger 逐个激活)
|
||||
private readonly List<VulnerabilityWindow> _pendingEventWindows = new();
|
||||
|
||||
/// <summary>
|
||||
/// 通知执行器某一外部事件已发生(如格挡成功、反制命中等),
|
||||
/// 激活所有注册该触发类型的弱点窗口。
|
||||
/// 由 BossBase.HandleParrySuccess / ApplyCounterResponse 等调用。
|
||||
/// </summary>
|
||||
public void NotifyVulnTrigger(VulnTriggerType triggerType)
|
||||
{
|
||||
for (int i = _pendingEventWindows.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var w = _pendingEventWindows[i];
|
||||
if (w.TriggerType == triggerType)
|
||||
{
|
||||
_pendingEventWindows.RemoveAt(i);
|
||||
StartCoroutine(OpenWindowCoroutine(w));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHitConfirmedCallback(DamageInfo _) => _patternHitConfirmed = true;
|
||||
|
||||
private void SubscribeHitCallbacks()
|
||||
{
|
||||
if (_hitBoxes == null) return;
|
||||
foreach (var hb in _hitBoxes) if (hb != null) hb.OnHitConfirmed += OnHitConfirmedCallback;
|
||||
}
|
||||
|
||||
private void UnsubscribeHitCallbacks()
|
||||
{
|
||||
if (_hitBoxes == null) return;
|
||||
foreach (var hb in _hitBoxes) if (hb != null) hb.OnHitConfirmed -= OnHitConfirmedCallback;
|
||||
}
|
||||
|
||||
private IEnumerator ExecuteSkillCoroutine(BossSkillSO skill)
|
||||
{
|
||||
_isExecuting = true;
|
||||
_currentSkill = skill;
|
||||
_patternHitConfirmed = false;
|
||||
|
||||
// HitBox 订阅已在 ExecuteSkill() 入口完成(确保 Interrupt 中断时能在 FinishExecution 取消)
|
||||
|
||||
_onBossSkillStarted?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
|
||||
|
||||
// 播放技能动画
|
||||
if (skill.skillAnimation != null)
|
||||
_animancer.Play(skill.skillAnimation);
|
||||
|
||||
// 启动 VulnerabilityWindow 协程(与主序列并行)
|
||||
_vulnCoroutine = null;
|
||||
if (skill.vulnerabilityWindows != null && skill.vulnerabilityWindows.Length > 0)
|
||||
_vulnCoroutine = StartCoroutine(ActivateVulnerabilityWindowsCoroutine(skill));
|
||||
|
||||
// 执行主攻击序列(始终执行 sequenceOnMiss;sequenceOnHit 是命中后的追加序列)
|
||||
if (skill.sequenceOnMiss != null)
|
||||
yield return ExecuteSequenceCoroutine(skill.sequenceOnMiss);
|
||||
|
||||
// 若本次有命中确认且配置了 sequenceOnHit,执行追加序列(连段、击倒追击等)
|
||||
if (_patternHitConfirmed && skill.sequenceOnHit != null)
|
||||
yield return ExecuteSequenceCoroutine(skill.sequenceOnHit);
|
||||
|
||||
// 若弱点协程还在运行则等待其结束(避免孤立协程)
|
||||
if (_vulnCoroutine != null)
|
||||
yield return _vulnCoroutine;
|
||||
|
||||
FinishExecution();
|
||||
}
|
||||
|
||||
private void FinishExecution()
|
||||
{
|
||||
UnsubscribeHitCallbacks(); // 无论正常结束还是被 Interrupt,均在此取消订阅
|
||||
_pendingEventWindows.Clear(); // 清除未触发的事件驱动弱点窗口,防止跨技能积压
|
||||
_vulnCoroutine = null; // 正常结束时已自然结束,仅清除引用
|
||||
_isExecuting = false;
|
||||
if (_currentSkill != null)
|
||||
{
|
||||
// 记录冷却结束时刻
|
||||
if (_currentSkill.cooldown > 0f)
|
||||
_skillCooldownEndTimes[_currentSkill.skillId] = Time.time + _currentSkill.cooldown;
|
||||
|
||||
_onBossSkillEnded?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = _currentSkill.skillId });
|
||||
_currentSkill = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 序列协程 ────────────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator ExecuteSequenceCoroutine(SkillSequenceSO seq)
|
||||
{
|
||||
int repeatCount = 0;
|
||||
do
|
||||
{
|
||||
foreach (var step in seq.steps)
|
||||
{
|
||||
if (step.delayBeforeStep > 0f)
|
||||
yield return GetWFS(step.delayBeforeStep);
|
||||
|
||||
if (step.pattern != null)
|
||||
yield return ExecutePatternCoroutine(step.pattern);
|
||||
}
|
||||
|
||||
repeatCount++;
|
||||
|
||||
if (seq.RepeatIfPlayerInRange && seq.RepeatDelay > 0f)
|
||||
yield return GetWFS(seq.RepeatDelay);
|
||||
}
|
||||
while (seq.RepeatIfPlayerInRange
|
||||
&& (seq.MaxRepeatCount == 0 || repeatCount < seq.MaxRepeatCount)
|
||||
&& IsPlayerInRange());
|
||||
}
|
||||
|
||||
private IEnumerator ExecutePatternCoroutine(AttackPatternSO pattern)
|
||||
{
|
||||
// 预备
|
||||
if (pattern.WindupDuration > 0f)
|
||||
yield return GetWFS(pattern.WindupDuration);
|
||||
|
||||
// 激活 HitBox(架构 06 §4:Activate(DamageSourceSO, Transform))
|
||||
if (_hitBoxes != null && _hitBoxes.Length > 0)
|
||||
foreach (var hb in _hitBoxes)
|
||||
if (hb != null) hb.Activate(pattern.DamageSource, transform);
|
||||
|
||||
if (pattern.ActiveDuration > 0f)
|
||||
yield return GetWFS(pattern.ActiveDuration);
|
||||
|
||||
// 关闭 HitBox
|
||||
if (_hitBoxes != null && _hitBoxes.Length > 0)
|
||||
foreach (var hb in _hitBoxes)
|
||||
if (hb != null) hb.Deactivate();
|
||||
|
||||
// 后摇
|
||||
if (pattern.RecoveryDuration > 0f)
|
||||
yield return GetWFS(pattern.RecoveryDuration);
|
||||
}
|
||||
|
||||
// ── VulnerabilityWindow 协程 ─────────────────────────────────────────────
|
||||
|
||||
private IEnumerator ActivateVulnerabilityWindowsCoroutine(BossSkillSO skill)
|
||||
{
|
||||
_pendingEventWindows.Clear();
|
||||
|
||||
foreach (var window in skill.vulnerabilityWindows)
|
||||
{
|
||||
if (window.TriggerType == VulnTriggerType.OnAttackRecovery)
|
||||
{
|
||||
// 时间驱动:按 TriggerDelay 延迟后自动激活
|
||||
if (window.TriggerDelay > 0f)
|
||||
yield return GetWFS(window.TriggerDelay);
|
||||
StartCoroutine(OpenWindowCoroutine(window));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 事件驱动(OnParriedSuccess / Manual 等):注册到待触发列表
|
||||
_pendingEventWindows.Add(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>实际开启并持续弱点窗口,支持独立并行运行。</summary>
|
||||
private IEnumerator OpenWindowCoroutine(VulnerabilityWindow window)
|
||||
{
|
||||
bool activateSpecific = window.ActivateWeakPointHurtBox;
|
||||
_weakPointSystem?.SetActive(true, window.DamageMultiplier, activateSpecific);
|
||||
window.OpenFeedback?.Play();
|
||||
|
||||
yield return GetWFS(window.Duration);
|
||||
|
||||
_weakPointSystem?.SetActive(false, 1f, activateSpecific);
|
||||
window.CloseFeedback?.Play();
|
||||
}
|
||||
|
||||
// ── 工具 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 在指定时长内开启弱点窗口(格挡/闪避反制时调用,独立于技能 VulnerabilityWindow 序列)。
|
||||
/// </summary>
|
||||
public void OpenVulnerabilityWindow(float duration, float damageMultiplier)
|
||||
{
|
||||
if (_weakPointSystem == null || duration <= 0f) return;
|
||||
StartCoroutine(VulnWindowOverride(duration, damageMultiplier));
|
||||
}
|
||||
|
||||
private IEnumerator VulnWindowOverride(float duration, float multiplier)
|
||||
{
|
||||
_weakPointSystem.SetActive(true, multiplier, false);
|
||||
yield return GetWFS(duration);
|
||||
_weakPointSystem.SetActive(false, 1f, false);
|
||||
}
|
||||
|
||||
private bool IsPlayerInRange() =>
|
||||
_playerTransform != null &&
|
||||
Vector2.Distance(transform.position, _playerTransform.position) < _repeatRangeCheck;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4dfa1c525eaca5640b3cfe945626a466
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,69 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Animancer;
|
||||
using BaseGames.Combat;
|
||||
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss 单个技能的所有数据,包括攻击模式、弱点窗口、互动标签等。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Boss/BossSkill")]
|
||||
public class BossSkillSO : ScriptableObject
|
||||
{
|
||||
[Header("元信息")]
|
||||
[Tooltip("技能唯一标识符(全小写英文 + 下划线,如 'slash_combo'、'phase_dash')。BD_UseBossSkill 通过此 Id 引用")]
|
||||
public string skillId;
|
||||
[Tooltip("编辑器中显示的可读名称,不影响运行逻辑")]
|
||||
public string displayName;
|
||||
[TextArea(1, 4)]
|
||||
[Tooltip("设计备注(仅供编辑器参考,不影响运行)")]
|
||||
public string designNote;
|
||||
|
||||
[Header("技能分类")]
|
||||
public BossSkillCategory category;
|
||||
public BossSkillType skillType;
|
||||
|
||||
[Header("阶段可用性")]
|
||||
[Tooltip("空数组 = 全阶段可用")]
|
||||
public int[] availablePhaseIndices;
|
||||
|
||||
[Header("核心攻击动作引用")]
|
||||
public AttackPatternSO[] attackPatterns;
|
||||
|
||||
[Header("弱点窗口(至少 1 个)")]
|
||||
public VulnerabilityWindow[] vulnerabilityWindows;
|
||||
|
||||
[Header("互动标签")]
|
||||
public InteractionTag interactionTags;
|
||||
|
||||
[Header("连段")]
|
||||
public SkillSequenceSO sequenceOnHit;
|
||||
public SkillSequenceSO sequenceOnMiss;
|
||||
|
||||
[Header("玩家反制接口")]
|
||||
public PlayerCounterResponse[] counterResponses;
|
||||
|
||||
[Header("场景联动")]
|
||||
public ArenaEventTrigger[] arenaEvents;
|
||||
|
||||
[Header("Boss 资源")]
|
||||
public BossResourceCost resourceCost;
|
||||
public bool buildsRage;
|
||||
|
||||
[Header("霸体配置")]
|
||||
public PoiseWindowConfig poiseWindow;
|
||||
|
||||
[Header("动画")]
|
||||
public ClipTransition skillAnimation;
|
||||
|
||||
[Header("冷却")]
|
||||
[Min(0f)]
|
||||
public float cooldown;
|
||||
|
||||
[Header("权重随机(UseBossSkillWeighted 使用)")]
|
||||
[Tooltip("相对权重,数值越大被随机选中的概率越高;0 = 禁用随机选择")]
|
||||
[Min(0f)]
|
||||
public float weight = 1f;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: de92221c7c3fb4a42a7cd122a8f97632
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user