diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8f9bd872..8fe766d2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Bash(*)", "mcp__unity__unity_*", "PowerShell(Get-Command *)", - "PowerShell(*)" + "PowerShell(*)", + "WebSearch" ], "dangerouslySkipPermissions": true }, diff --git a/Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs b/Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs index b9e40fb5..400edb24 100644 --- a/Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs +++ b/Assets/Tests/EditMode/AI/AiDefinitionRegistryTests.cs @@ -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"); } } diff --git a/Assets/Tests/EditMode/AI/AiFrameworkTests.cs b/Assets/Tests/EditMode/AI/AiFrameworkTests.cs index 1c52d160..1494a843 100644 --- a/Assets/Tests/EditMode/AI/AiFrameworkTests.cs +++ b/Assets/Tests/EditMode/AI/AiFrameworkTests.cs @@ -7,6 +7,10 @@ namespace BaseGames.Tests.EditMode.AI /// BrainGraph 框架健壮性/边界测试:构图校验、转换优先级、全局转换、 /// 事件与条件优先、队列信号、自转换、trace 上限、Blackboard、dt 传递。 /// 与 AiRuntimeTests(主干行为)互补,覆盖易回归的边角。 + /// + /// 本文件的夹具多为「A → 桩汇态」的最小图,汇态刻意不挂出边, + /// 因此用 BuildPartial() 构建——它跳过「非终态必须有出口」校验, + /// 其余校验照旧。断言 Build() 抛异常的用例仍用 Build()。 /// 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("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("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("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("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 diff --git a/Assets/Tests/EditMode/AI/AiRuntimeTests.cs b/Assets/Tests/EditMode/AI/AiRuntimeTests.cs index ab5559f5..5311acfd 100644 --- a/Assets/Tests/EditMode/AI/AiRuntimeTests.cs +++ b/Assets/Tests/EditMode/AI/AiRuntimeTests.cs @@ -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); diff --git a/Assets/Tests/EditMode/AI/AiScriptTests.cs b/Assets/Tests/EditMode/AI/AiScriptTests.cs index efb6a863..66835abf 100644 --- a/Assets/Tests/EditMode/AI/AiScriptTests.cs +++ b/Assets/Tests/EditMode/AI/AiScriptTests.cs @@ -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"); } } diff --git a/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs b/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs index bfe448a8..603c6a28 100644 --- a/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs +++ b/Assets/Tests/EditMode/AI/AiStateFragmentsTests.cs @@ -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] diff --git a/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs b/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs index f084d8ea..c62907c3 100644 --- a/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs +++ b/Assets/Tests/EditMode/AI/ApproachAttackEngagementTests.cs @@ -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] diff --git a/Assets/Tests/EditMode/AI/BrainBuilderDeadEndTests.cs b/Assets/Tests/EditMode/AI/BrainBuilderDeadEndTests.cs new file mode 100644 index 00000000..d468dee1 --- /dev/null +++ b/Assets/Tests/EditMode/AI/BrainBuilderDeadEndTests.cs @@ -0,0 +1,81 @@ +using NUnit.Framework; +using BaseGames.AI; +using BaseGames.Enemies; + +namespace BaseGames.Tests.EditMode.AI +{ + /// + /// Build() 必须挡住「非终态却没有出口」的状态。 + /// + /// 既有校验只做正向——边指向的态是否已声明(RequireState 的同款关切); + /// 反向从不检查。一个已声明却没有任何出边的非终态会让敌人永久停在那里, + /// 且全程零报错,与 RequireState 要防的静默失败是同一类,只是方向相反。 + /// + 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(() => 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(() => 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(() => 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()); + } + } +} diff --git a/Assets/_Game/Scripts/Editor/Enemies/BossSkillSequenceWindow.cs.meta b/Assets/Tests/EditMode/AI/BrainBuilderDeadEndTests.cs.meta similarity index 83% rename from Assets/_Game/Scripts/Editor/Enemies/BossSkillSequenceWindow.cs.meta rename to Assets/Tests/EditMode/AI/BrainBuilderDeadEndTests.cs.meta index 397db5f6..68581558 100644 --- a/Assets/_Game/Scripts/Editor/Enemies/BossSkillSequenceWindow.cs.meta +++ b/Assets/Tests/EditMode/AI/BrainBuilderDeadEndTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: d47145d394333184eb3ff822e3c4aa4d +guid: 282b4a740e6e4f84ca26c5dbed5aee91 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Tests/EditMode/AI/BrainBuilderTests.cs b/Assets/Tests/EditMode/AI/BrainBuilderTests.cs index 899846aa..3fc11233 100644 --- a/Assets/Tests/EditMode/AI/BrainBuilderTests.cs +++ b/Assets/Tests/EditMode/AI/BrainBuilderTests.cs @@ -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] diff --git a/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs b/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs new file mode 100644 index 00000000..c4ac848e --- /dev/null +++ b/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs @@ -0,0 +1,171 @@ +using NUnit.Framework; +using BaseGames.AI; +using BaseGames.Enemies; + +namespace BaseGames.Tests.EditMode.AI +{ + /// + /// 嘲风定制图的状态序列断言。 + /// + /// 注意 只入队信号,事件转换在下一次 Tick 的第一步出队处理, + /// 因此每个 Send 之后都必须跟一次 Tick 才会真正换态(不是"发了就到")。 + /// + 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; + } + + /// 推进到地面阶段:开战 → 入场演出播完 → 地面逼近态。 + 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; + } + + /// 再推进到空中阶段:血量过半 → 阶段过渡 → 过渡结束。 + 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, "不应再次发起阶段过渡"); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/Boss/AttackPatternSO.cs.meta b/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs.meta similarity index 83% rename from Assets/_Game/Scripts/Enemies/Boss/AttackPatternSO.cs.meta rename to Assets/Tests/EditMode/AI/ChaoFengAiTests.cs.meta index 6e0a41d7..fe7d973e 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/AttackPatternSO.cs.meta +++ b/Assets/Tests/EditMode/AI/ChaoFengAiTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 81f89b6e2f8f2774ab7cedbe45dcb810 +guid: ca3207eb8b4a0ca4f9e57bf3c12aa95c MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs index 34b6b28e..d3ad1a33 100644 --- a/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs +++ b/Assets/Tests/EditMode/AI/Fakes/FakeAiContext.cs @@ -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; } } diff --git a/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs new file mode 100644 index 00000000..142a51b1 --- /dev/null +++ b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs @@ -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]); + } +} diff --git a/Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs.meta b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs.meta similarity index 83% rename from Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs.meta rename to Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs.meta index 3f77eaa3..e0ae2ee8 100644 --- a/Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs.meta +++ b/Assets/Tests/EditMode/AI/Fakes/FakeBossControl.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: f0d0425e529293e469da3762fe3bf8f0 +guid: dc93ec3a62b58c24da5b420bdeeed029 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs index a6649097..b4def37f 100644 --- a/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionRecipeSoTests.cs @@ -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(); @@ -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; diff --git a/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs index 882dd2ab..bb21a5d5 100644 --- a/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs +++ b/Assets/Tests/EditMode/AI/PerceptionSkeletonTests.cs @@ -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)); diff --git a/Assets/Tests/EditMode/AI/RushEngagementTests.cs b/Assets/Tests/EditMode/AI/RushEngagementTests.cs index ed5c41f2..c4a8b0a5 100644 --- a/Assets/Tests/EditMode/AI/RushEngagementTests.cs +++ b/Assets/Tests/EditMode/AI/RushEngagementTests.cs @@ -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] diff --git a/Assets/Tests/EditMode/AI/UnawareModuleTests.cs b/Assets/Tests/EditMode/AI/UnawareModuleTests.cs index bddeef41..4fe71bd7 100644 --- a/Assets/Tests/EditMode/AI/UnawareModuleTests.cs +++ b/Assets/Tests/EditMode/AI/UnawareModuleTests.cs @@ -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] diff --git a/Assets/_Game/Scripts/Enemies/Boss/Patterns.meta b/Assets/Tests/EditMode/Combat.meta similarity index 77% rename from Assets/_Game/Scripts/Enemies/Boss/Patterns.meta rename to Assets/Tests/EditMode/Combat.meta index a881eff4..525b8828 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/Patterns.meta +++ b/Assets/Tests/EditMode/Combat.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 6d8f5f23ee1dde046b1a7361ac1b6386 +guid: e314cb3fe82112a409b7632389cdb35c folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs b/Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs new file mode 100644 index 00000000..259c5c54 --- /dev/null +++ b/Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs @@ -0,0 +1,77 @@ +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using BaseGames.Combat; + +namespace BaseGames.Tests.EditMode.Combat +{ + public class HurtBoxMultiplierTests + { + /// 测试期建出的宿主对象;在 TearDown 统一销毁,断言失败也不会残留到编辑器场景。 + 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; + } + + /// + /// HurtBox 标了 [RequireComponent(typeof(Collider2D))],而 Collider2D 是抽象类, + /// Unity 无法自动补齐——裸 AddComponent<HurtBox>() 会返回真 null。 + /// 必须先挂一个具体碰撞体。 + /// + private HurtBox CreateHurtBox() + { + _host = new GameObject("hurtbox"); + _host.AddComponent(); + var hb = _host.AddComponent(); + 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 的钳制"); + } + } +} diff --git a/Assets/_Game/Scripts/Core/Events/BossSkillEventChannelSO.cs.meta b/Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs.meta similarity index 83% rename from Assets/_Game/Scripts/Core/Events/BossSkillEventChannelSO.cs.meta rename to Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs.meta index 62966fd6..745164e5 100644 --- a/Assets/_Game/Scripts/Core/Events/BossSkillEventChannelSO.cs.meta +++ b/Assets/Tests/EditMode/Combat/HurtBoxMultiplierTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 8230eab2acba8c24499b2d20df81adb7 +guid: faac4f58895b7714b97a548a16181462 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs b/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs new file mode 100644 index 00000000..3d7fe35f --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs @@ -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 + { + /// 最小可实例化能力:不播动画、不碰 HitBox,只为验证启停与 CanUse。 + private sealed class StubAbility : EnemyAbilityBase + { + /// + /// 编辑模式下 Unity 不会在 AddComponent 时调用 Awake,_enemy 会留空导致 CanUse 恒假。 + /// 这里手动跑一次真实的 Awake(而非反射直写字段),让依赖解析走生产代码路径。 + /// + public void RunAwake() => Awake(); + + protected override IEnumerator ExecuteCoroutine() { yield break; } + } + + /// 测试期建出的宿主对象;在 TearDown 统一销毁,断言失败也不会残留到编辑器场景。 + 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 的 _currentState=Controlled → IsAlive 为真 + var ab = _host.AddComponent(); + 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(); + var p0 = _host.AddComponent(); + // EnemyAbilityBase 是 [DisallowMultipleComponent],第二个能力必须另起子节点 + // (挂在 _host 下,TearDown 会连带销毁) + var p1Go = new GameObject("p1"); p1Go.transform.SetParent(_host.transform); + var p1 = p1Go.AddComponent(); + + 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(); + var any = _host.AddComponent(); + + 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(); + var listed = _host.AddComponent(); + var otherGo = new GameObject("unlisted"); otherGo.transform.SetParent(_host.transform); + var unlisted = otherGo.AddComponent(); + + SetEntries(gate, (listed, new[] { 1 })); + + gate.ApplyPhase(0); + Assert.IsFalse(listed.enabled, "登记的能力应被阶段门管控"); + Assert.IsTrue (unlisted.enabled, "未登记的能力不受阶段门影响"); + } + } +} diff --git a/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs.meta b/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs.meta new file mode 100644 index 00000000..f3c91fef --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cc78469b00f228648844d2e34c13f80c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs b/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs new file mode 100644 index 00000000..33e32b7d --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs @@ -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 +{ + /// + /// 池化敌人复活后不得带着上一条命的能力冷却。 + /// + /// EnemyBase.OnSpawn 里 InterruptAll 那一行的注释写着「重置能力冷却」,实际三处都不成立: + /// EnemyAbilityRegistry.InterruptAll 与 EnemyAbilityBase.Interrupt 各有一道 IsRunning 门, + /// 出生时无一在跑,循环体一次都不执行;即便执行到,末行也是**设置**半冷却而非清零。 + /// 冷却基于绝对 Time.time,于是上一条命的剩余冷却原样活到下一条命。 + /// + public class EnemyAbilityCooldownResetTests + { + /// 最小可实例化能力:不播动画、不碰 HitBox,只为验证冷却的跨池化生命周期。 + private sealed class StubAbility : EnemyAbilityBase + { + /// 编辑模式下 AddComponent 不触发 Awake,手动跑一次真实的依赖解析路径。 + public void RunAwake() => Awake(); + + /// 等价于 Inspector 对 [SerializeField] _config 的赋值(子类可见,非反射伪造)。 + 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; + } + + /// + /// 建一个能力已进入冷却的敌人。冷却全程由生产代码写入: + /// Execute() 启真实协程(编辑模式下跑到首个 yield 即挂起,_isRunning 留 true), + /// 再由 Interrupt() 经真实路径写 _cooldownEndTime。 + /// 不反射直写冷却字段——那样测到的是伪造状态,而非真实时序产出的状态。 + /// + private (EnemyBase enemy, StubAbility ability) MakeEnemyWithAbilityOnCooldown() + { + _host = new GameObject("pooled-enemy"); + var enemy = _host.AddComponent(); // 裸 EnemyBase 的 _currentState=Controlled → IsAlive 为真 + var ab = _host.AddComponent(); + + _config = ScriptableObject.CreateInstance(); + _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); + } + } +} diff --git a/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs.meta b/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs.meta new file mode 100644 index 00000000..8005e64f --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyAbilityCooldownResetTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b8c4bfbd21220fd429f7f845fee03cff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs index ee4b13e6..d161bc1e 100644 --- a/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs +++ b/Assets/Tests/EditMode/Enemies/EnemyAttackSelectorTests.cs @@ -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(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(); + 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)); + } } } diff --git a/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs new file mode 100644 index 00000000..ca368656 --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs @@ -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(string name) where T : EnemyBase + { + _go = new GameObject(name); + return _go.AddComponent(); + } + + [Test] + public void NonBoss_AccessingBossFacet_Throws() + { + var enemy = MakeEnemy("plain-enemy"); + var ctx = new EnemyBrainContext(enemy); + + Assert.Throws(() => { var _ = ctx.Boss; }); + } + + [Test] + public void Boss_AccessingBossFacet_ReturnsTheBoss() + { + var boss = MakeEnemy("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(() => { var _ = ctx.Boss.ResourceFull; }); + } + } +} diff --git a/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs.meta b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs.meta new file mode 100644 index 00000000..e99766cb --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyBrainContextBossFacetTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 90d0535e362d3b9469d2c91a70c4c8ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs b/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs new file mode 100644 index 00000000..a9078d3b --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs @@ -0,0 +1,107 @@ +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using BaseGames.Enemies; +using BaseGames.Enemies.States; + +namespace BaseGames.Tests.EditMode.Enemies +{ + /// + /// 受击类状态(Hurt / Stagger / KnockUp)在**没有对应动画 Clip** 时, + /// 必须仍然安排回到 Controlled——否则敌人被打一次就永久停在该态, + /// IsControllable 恒假、AI 永久停摆。 + /// + /// 这是本项目「禁止下游兜底掩盖问题」的反面:早退本身不是兜底, + /// 但早退**同时丢掉了恢复安排**,把「动画没配」这个可恢复的缺失 + /// 变成了「敌人永久失能」这个不可恢复的故障。 + /// + public class EnemyHitStateRecoveryTests + { + /// + /// 记录 ScheduleStateRecovery 调用而不真正启协程——EditMode 下没有播放循环, + /// 协程不会推进,直接断言"是否安排了恢复"才是这个缺陷的可测形态。 + /// + 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; + } + + /// + /// EditMode 下 AddComponent 不触发 Awake,动画引用保持为空—— + /// 这正好等价于"美术未接入"的生产场景,无需再造假配置。 + /// 前提用 AnimConfig 断言(而非 Animancer):后者会把 Animancer 程序集 + /// 拖进测试程序集的引用,而 AnimConfig 为空已足以让三个状态都走无动画分支。 + /// + private RecordingEnemy MakeEnemyWithoutAnimation() + { + _host = new GameObject("enemy-without-anim"); + var e = _host.AddComponent(); + 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); + } + } +} diff --git a/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs.meta b/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs.meta new file mode 100644 index 00000000..f10d3423 --- /dev/null +++ b/Assets/Tests/EditMode/Enemies/EnemyHitStateRecoveryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df7e877126c6c344f8cbf5fe74c4b324 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Boomerang.asset b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Boomerang.asset index 1743b35a..8591b60a 100644 --- a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Boomerang.asset +++ b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Boomerang.asset @@ -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: [] diff --git a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_FanCombo.asset b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_FanCombo.asset index 73349e44..731e98aa 100644 --- a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_FanCombo.asset +++ b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_FanCombo.asset @@ -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} diff --git a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Intro.asset b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Intro.asset new file mode 100644 index 00000000..551bf3cb --- /dev/null +++ b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Intro.asset @@ -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: [] diff --git a/Assets/_Game/Data/Events/Boss/EVT_BossSkill.asset.meta b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Intro.asset.meta similarity index 79% rename from Assets/_Game/Data/Events/Boss/EVT_BossSkill.asset.meta rename to Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Intro.asset.meta index a13164da..48329828 100644 --- a/Assets/_Game/Data/Events/Boss/EVT_BossSkill.asset.meta +++ b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_Intro.asset.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 8e2f5df884c7f624da572ab81162da4d +guid: eddeb2d9e64934d4eab9acf5efd8902e NativeFormatImporter: externalObjects: {} mainObjectFileID: 11400000 diff --git a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoLarge.asset b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoLarge.asset index eaf3aee3..d95919d6 100644 --- a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoLarge.asset +++ b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoLarge.asset @@ -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: [] diff --git a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoSmall.asset b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoSmall.asset index e808eccb..3a1ce704 100644 --- a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoSmall.asset +++ b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_TornadoSmall.asset @@ -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: [] diff --git a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_WindStone.asset b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_WindStone.asset index ba43f777..48fb3730 100644 --- a/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_WindStone.asset +++ b/Assets/_Game/Data/Enemies/ChaoFeng/Abilities/ABL_ChaoFeng_WindStone.asset @@ -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: [] diff --git a/Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Chase.asset b/Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Chase.asset index 459d2715..0034fd3e 100644 --- a/Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Chase.asset +++ b/Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Chase.asset @@ -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: [] diff --git a/Assets/_Game/Data/Enemies/E002/Abilities/ABL_E002_CeilingStrike.asset b/Assets/_Game/Data/Enemies/E002/Abilities/ABL_E002_CeilingStrike.asset index 4454c564..ff9102d4 100644 --- a/Assets/_Game/Data/Enemies/E002/Abilities/ABL_E002_CeilingStrike.asset +++ b/Assets/_Game/Data/Enemies/E002/Abilities/ABL_E002_CeilingStrike.asset @@ -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 diff --git a/Assets/_Game/Data/Enemies/E003/Abilities/ABL_E003_Fall.asset b/Assets/_Game/Data/Enemies/E003/Abilities/ABL_E003_Fall.asset index 603a8511..5547f466 100644 --- a/Assets/_Game/Data/Enemies/E003/Abilities/ABL_E003_Fall.asset +++ b/Assets/_Game/Data/Enemies/E003/Abilities/ABL_E003_Fall.asset @@ -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 diff --git a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Acid.asset b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Acid.asset index faba0db1..eb4203ad 100644 --- a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Acid.asset +++ b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Acid.asset @@ -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} diff --git a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Appear.asset b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Appear.asset index 2570f0a8..b982b27d 100644 --- a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Appear.asset +++ b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Appear.asset @@ -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 diff --git a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Bite.asset b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Bite.asset index 15de9e87..81af8369 100644 --- a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Bite.asset +++ b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Bite.asset @@ -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} diff --git a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Flip.asset b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Flip.asset index 0bd7b5ee..80c3ffce 100644 --- a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Flip.asset +++ b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_Flip.asset @@ -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 diff --git a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_HeadSlam.asset b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_HeadSlam.asset index 31e26906..dae7721e 100644 --- a/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_HeadSlam.asset +++ b/Assets/_Game/Data/Enemies/E004/Abilities/ABL_E004_HeadSlam.asset @@ -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 diff --git a/Assets/_Game/Data/Enemies/E005/Abilities/ABL_E005_Acid.asset b/Assets/_Game/Data/Enemies/E005/Abilities/ABL_E005_Acid.asset index 62aaffc3..24a8c5d2 100644 --- a/Assets/_Game/Data/Enemies/E005/Abilities/ABL_E005_Acid.asset +++ b/Assets/_Game/Data/Enemies/E005/Abilities/ABL_E005_Acid.asset @@ -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} diff --git a/Assets/_Game/Data/Enemies/E005/Abilities/ABL_E005_Bite.asset b/Assets/_Game/Data/Enemies/E005/Abilities/ABL_E005_Bite.asset index b6282819..5732d58a 100644 --- a/Assets/_Game/Data/Enemies/E005/Abilities/ABL_E005_Bite.asset +++ b/Assets/_Game/Data/Enemies/E005/Abilities/ABL_E005_Bite.asset @@ -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} diff --git a/Assets/_Game/Data/Enemies/E006/Abilities/ABL_E006_Chase.asset b/Assets/_Game/Data/Enemies/E006/Abilities/ABL_E006_Chase.asset index 7a8732ce..50422f0a 100644 --- a/Assets/_Game/Data/Enemies/E006/Abilities/ABL_E006_Chase.asset +++ b/Assets/_Game/Data/Enemies/E006/Abilities/ABL_E006_Chase.asset @@ -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: [] diff --git a/Assets/_Game/Data/Enemies/E006/Abilities/ABL_E006_Leap.asset b/Assets/_Game/Data/Enemies/E006/Abilities/ABL_E006_Leap.asset index bbdba211..b5ecee8d 100644 --- a/Assets/_Game/Data/Enemies/E006/Abilities/ABL_E006_Leap.asset +++ b/Assets/_Game/Data/Enemies/E006/Abilities/ABL_E006_Leap.asset @@ -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} diff --git a/Assets/_Game/Data/Events/Boss/EVT_BossSkill.asset b/Assets/_Game/Data/Events/Boss/EVT_BossSkill.asset deleted file mode 100644 index 9b19d35e..00000000 --- a/Assets/_Game/Data/Events/Boss/EVT_BossSkill.asset +++ /dev/null @@ -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: diff --git a/Assets/_Game/Prefabs/Enemies/ChaoFeng/ENM_ChaoFeng.prefab b/Assets/_Game/Prefabs/Enemies/ChaoFeng/ENM_ChaoFeng.prefab index a277c6ef..1eafcae2 100644 --- a/Assets/_Game/Prefabs/Enemies/ChaoFeng/ENM_ChaoFeng.prefab +++ b/Assets/_Game/Prefabs/Enemies/ChaoFeng/ENM_ChaoFeng.prefab @@ -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 diff --git a/Assets/_Game/Scenes/Testings/TestRoomA.unity b/Assets/_Game/Scenes/Testings/TestRoomA.unity index ec2ee748..5ba7a2a3 100644 --- a/Assets/_Game/Scenes/Testings/TestRoomA.unity +++ b/Assets/_Game/Scenes/Testings/TestRoomA.unity @@ -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} diff --git a/Assets/_Game/Scripts/AI/AiSignal.cs b/Assets/_Game/Scripts/AI/AiSignal.cs index c7994bfc..bd19c90e 100644 --- a/Assets/_Game/Scripts/AI/AiSignal.cs +++ b/Assets/_Game/Scripts/AI/AiSignal.cs @@ -1,16 +1,21 @@ namespace BaseGames.AI { /// - /// 决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。 + /// 外部 → 决策层的推送信号,用于事件驱动的状态转换(不靠轮询)。 + /// 事件边不受 IsControllable 让位门阻挡。 /// /// 只保留真实存在发送方与消费方的信号。受击 / 硬直 / 击飞 / 弹反**不在此列**—— /// 它们由敌人物理状态机(EnemyStateType)处理,决策层经 IActorVitals.IsControllable /// 让位门自动挂起。在这里重复定义会诱导写出与物理层打架的第二套受击逻辑。 /// - /// 新增信号的判据:已经有确定的发送方与消费方,否则不要预留。 + /// 新增信号的判据:已经有确定的发送方与消费方,否则不要预留——无发送方的占位枚举只会变成死值。 /// public enum AiSignal { + /// 敌人已死亡:由 EnemyBase 的死亡流程发出,让决策停止。 Died, + + /// 开战:由竞技场开战触发器发出,让 Boss 从静置态进入入场演出。 + Engaged, } } diff --git a/Assets/_Game/Scripts/AI/AiState.cs b/Assets/_Game/Scripts/AI/AiState.cs index b95bf287..dc8e178d 100644 --- a/Assets/_Game/Scripts/AI/AiState.cs +++ b/Assets/_Game/Scripts/AI/AiState.cs @@ -14,6 +14,12 @@ namespace BaseGames.AI public Action OnExit { get; internal set; } public IReadOnlyList Transitions => _transitions; + /// + /// 是否为终态:进入后不再离开是设计意图,而非漏挂出边。 + /// 由 标记,供 Build() 的死角校验放行。 + /// + public bool IsTerminal { get; internal set; } + internal void AddTransition(Transition t) => _transitions.Add(t); public AiState(string name) => Name = name; diff --git a/Assets/_Game/Scripts/AI/BrainBuilder.cs b/Assets/_Game/Scripts/AI/BrainBuilder.cs index 948c5e3b..5f6dec4e 100644 --- a/Assets/_Game/Scripts/AI/BrainBuilder.cs +++ b/Assets/_Game/Scripts/AI/BrainBuilder.cs @@ -59,7 +59,22 @@ namespace BaseGames.AI internal void AddGlobal(Transition t) => _globals.Add(t); - public AiGraph Build() + /// + /// 构建**完整**图。除了引用完整性,还校验「非终态必须有出口」。 + /// 生产的两个装配点(AiScript.GetOrBuildGraph / AiRecipeSO)都走这里, + /// 将来新增的入口默认即受保护——这正是校验放在 Build() 而非各入口的理由。 + /// + public AiGraph Build() => BuildInternal(checkDeadEnds: true); + + /// + /// 构建**部分**图:跳过「非终态必须有出口」校验,其余校验照旧。 + /// 单个模块(IUnawareModule / IEngagementModule)按设计只声明自己的态, + /// 升级边由 PerceptionSkeleton 事后挂上——单独构建时的死角是正常中间态,不是缺陷。 + /// 仅用于隔离验证单个模块或片段;装配真实敌人图一律用 。 + /// + 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); } + /// + /// 该状态是否有真正的出口。两点刻意的判定: + /// 1. 自转换不算——AiRuntime.Switch 对 Target == 当前态直接 return false,出不去; + /// 2. 全局转换不算——全局事件边要外部推信号才触发,不是自主出口。把它算作出口, + /// 等于让「只有死了才出得去」的死角通过校验,而那正是本校验要暴露的东西。 + /// + 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 fn) { _state.OnTick = fn; return this; } public StateBuilder OnExit(Action fn) { _state.OnExit = fn; return this; } + /// + /// 标记为终态:声明"进入后不再离开"是设计意图。 + /// Build() 会对非终态且无出口的态抛异常,本标记是唯一的豁免方式—— + /// 用它而不是随手挂一条永假的边,意图才留在代码里。 + /// + public StateBuilder Terminal() { _state.IsTerminal = true; return this; } + public TransitionBuilder To(string target) => new TransitionBuilder(this, _state, target); } diff --git a/Assets/_Game/Scripts/AI/IAiContext.cs b/Assets/_Game/Scripts/AI/IAiContext.cs index 308c544e..6e52a91e 100644 --- a/Assets/_Game/Scripts/AI/IAiContext.cs +++ b/Assets/_Game/Scripts/AI/IAiContext.cs @@ -11,5 +11,11 @@ namespace BaseGames.AI ICombatant Combat { get; } IActorVitals Vitals { get; } Blackboard Blackboard { get; } + + /// + /// Boss 专属决策面。**非 Boss 敌人访问本属性会抛 InvalidOperationException**—— + /// 小怪图误用 Boss 边必须立刻暴露,不返回空对象把问题盖住。 + /// + IBossControl Boss { get; } } } diff --git a/Assets/_Game/Scripts/AI/IBossControl.cs b/Assets/_Game/Scripts/AI/IBossControl.cs new file mode 100644 index 00000000..4d37dcd8 --- /dev/null +++ b/Assets/_Game/Scripts/AI/IBossControl.cs @@ -0,0 +1,31 @@ +using UnityEngine; + +namespace BaseGames.AI +{ + /// + /// Boss 专属决策面。挂在 上供 Boss 图读取阶段状态、发起阶段过渡。 + /// 只暴露"决策需要知道的",过渡演出与无敌帧由实现方(BossBase)负责。 + /// 非 Boss 敌人访问 会抛异常——这是有意的显式失败。 + /// + public interface IBossControl + { + /// 当前阶段索引(出生为 0)。 + int CurrentPhase { get; } + + /// 是否处于阶段过渡(无敌 + 过渡演出)期间。 + bool IsPhaseTransitioning { get; } + + /// Boss 资源是否已满。未挂资源组件时抛异常——图问了资源却没配组件是配置错误。 + bool ResourceFull { get; } + + /// 发起阶段过渡:无敌 invincibleDuration 秒后切入 targetPhase。 + /// 过渡进行中重复调用会被忽略并告警——应在 OnEnter 调用一次,不要放在 Tick。 + void BeginPhaseTransition(int targetPhase, float invincibleDuration); + + /// 第 index 个竞技场锚点的世界坐标。未挂锚点组件 / 下标越界即抛。 + Vector2 AnchorAt(int index); + + /// Boss 当前位置到第 index 个锚点的距离。未挂锚点组件 / 下标越界即抛。 + float DistanceToAnchor(int index); + } +} diff --git a/Assets/_Game/Scripts/AI/IBossControl.cs.meta b/Assets/_Game/Scripts/AI/IBossControl.cs.meta new file mode 100644 index 00000000..157c24bb --- /dev/null +++ b/Assets/_Game/Scripts/AI/IBossControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20ae59ebfd68f04488a369b1c1911040 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs b/Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs index ea0e9d95..ffa4c76b 100644 --- a/Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs +++ b/Assets/_Game/Scripts/Animation/EnemyAnimationEvents.cs @@ -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 /// /// 敌人动画事件接收器(架构 §AnimationModule)。 /// 挂载于敌人 Prefab 根节点。负责将 Animancer 动画时间点回调路由到 - /// HitBox 激活、弹幕生成、嘶吼状态、二阶段切换等系统。 + /// HitBox 激活、弹幕生成、嘶吼状态、二阶段切换、无敌帧、反馈等系统。 /// /// 使用方式: - /// 1. 在 Inspector 中填充 _hitBoxes、_enemy。 + /// 1. 在 Inspector 中填充 _hitBoxes、_hurtBox、_enemy。 /// 2. 将每条 ClipTransition + AnimationEventConfigSO 配对添加到 _bindings。 /// 3. Awake 中 AnimationEventBinder.Bind 自动完成注入。 /// @@ -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(); + // IFeedbackPlayer 由父层或同层实现(EnemyFeedback / NullFeedbackPlayer),与玩家侧同写法 + _feedback = GetComponentInParent() + ?? 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); diff --git a/Assets/_Game/Scripts/Combat/HurtBox.cs b/Assets/_Game/Scripts/Combat/HurtBox.cs index 72dd7fa4..8b0e9b58 100644 --- a/Assets/_Game/Scripts/Combat/HurtBox.cs +++ b/Assets/_Game/Scripts/Combat/HurtBox.cs @@ -4,7 +4,7 @@ using BaseGames.Parry; namespace BaseGames.Combat { /// - /// 受击盒组件。实现完整 8 步伤害流水线(架构 06_CombatModule §5)。 + /// 受击盒组件。实现完整 9 步伤害流水线(架构 06_CombatModule §5)。 /// 挂载在角色根节点或指定子节点上,Collider2D 需设 IsTrigger = true, /// Layer = PlayerHurtBox 或 EnemyHurtBox。 /// @@ -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; + + /// 本 HurtBox 当前的受击伤害倍率。 + public float DamageMultiplier => _damageMultiplier; + + /// 设置受击倍率(弱点窗口开合时调用)。负值钳到 0。 + public void SetDamageMultiplier(float value) + => _damageMultiplier = UnityEngine.Mathf.Max(0f, value); + + /// + /// 按本 HurtBox 的倍率缩放伤害。最低 1 点,与防御减免同规则 + /// (倍率为 0 也不该让攻击完全无效——那属于无敌帧的语义,不是弱点倍率的)。 + /// 倍率为 1 时原样返回,保证默认状态下伤害数值与未引入倍率前完全一致。 + /// + 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); } diff --git a/Assets/_Game/Scripts/Core/Events/BossEvents.cs b/Assets/_Game/Scripts/Core/Events/BossEvents.cs index 589e79af..929d78b0 100644 --- a/Assets/_Game/Scripts/Core/Events/BossEvents.cs +++ b/Assets/_Game/Scripts/Core/Events/BossEvents.cs @@ -1,15 +1,5 @@ namespace BaseGames.Core.Events { - /// - /// Boss 技能事件负载。 - /// - [System.Serializable] - public struct BossSkillEvent - { - public string BossId; - public string SkillId; - } - /// /// Boss 阶段切换事件负载。 /// diff --git a/Assets/_Game/Scripts/Core/Events/BossSkillEventChannelSO.cs b/Assets/_Game/Scripts/Core/Events/BossSkillEventChannelSO.cs deleted file mode 100644 index 887cba37..00000000 --- a/Assets/_Game/Scripts/Core/Events/BossSkillEventChannelSO.cs +++ /dev/null @@ -1,7 +0,0 @@ -using UnityEngine; - -namespace BaseGames.Core.Events -{ - [CreateAssetMenu(menuName = "BaseGames/Events/BossSkill")] - public class BossSkillEventChannelSO : BaseEventChannelSO { } -} diff --git a/Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs b/Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs index 4f269798..c59d40fd 100644 --- a/Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs +++ b/Assets/_Game/Scripts/Editor/Character/CharacterWizardWindow.cs @@ -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(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(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(); } - /// 嘲风技能集(计划):Phase0 四技能加权随机 + Phase1 风石。 - private static readonly (string name, string id, int[] phases, float weight)[] ChaoFengSkillDefs = + /// + /// 嘲风能力集。阶段可用性不在 SO 上——由 BossPhaseAbilityGate 承载 + /// (见放置工具 PlaceChaoFeng),这里只记录供说明文本使用。 + /// rangeRadius 必须 > 0:category==Attack 且射程为 0 的招永远够不着玩家, + /// EnemyAbilitySO.Validate 与 EnemyBase.BuildAttackSelector 都会显式报错。 + /// grounded 是选招器的硬门:阶段 1 是空中阶段(Boss 浮空,IsGrounded 恒 false), + /// 该阶段的招必须为 false,否则永远选不中,空中阶段会站着不打。 + /// + 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($"{dir}/ENM_ChaoFeng_Stats.asset")), ("ENM_ChaoFeng_AnimConfig",FindAtPath($"{dir}/ENM_ChaoFeng_AnimConfig.asset")), }; - foreach (var (skName, _, _, _) in ChaoFengSkillDefs) - checks.Add(($"ABL_ChaoFeng_{skName}", FindAtPath($"{ablDir}/ABL_ChaoFeng_{skName}.asset"))); + foreach (var (abName, _, _, _, _, _, _, _) in ChaoFengAbilityDefs) + checks.Add(($"ABL_ChaoFeng_{abName}", + FindAtPath($"{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\")] 特性")); } // ── 辅助:状态格 ───────────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/Editor/Enemies/BossSkillSequenceWindow.cs b/Assets/_Game/Scripts/Editor/Enemies/BossSkillSequenceWindow.cs deleted file mode 100644 index 70b5fe72..00000000 --- a/Assets/_Game/Scripts/Editor/Enemies/BossSkillSequenceWindow.cs +++ /dev/null @@ -1,306 +0,0 @@ -using System.Collections.Generic; -using UnityEditor; -using UnityEngine; -using BaseGames.Boss; - -namespace BaseGames.Editor -{ - /// - /// Boss 技能序列甘特图可视化窗口(架构 23_BossSkillModule §12)。 - /// 菜单:BaseGames/Tools/Boss Skill Sequence Viewer - /// - /// 功能: - /// - 拖放 BossSkillSO 或 SkillSequenceSO 资产加载 - /// - 甘特图:Windup(黄色)→ Active(红色)→ Recovery(灰色)各阶段时序条 - /// - VulnerabilityWindow 绿色覆盖层(TriggerDelay 偏移 + Duration 宽度) - /// - DurationNormalized < 0.1 时阶段条变红警告 - /// - 点击阶段条高亮对应 AttackPatternSO(EditorGUIUtility.PingObject) - /// - 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("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(); - } - } -} diff --git a/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs b/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs index 990e607e..06e84c72 100644 --- a/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs +++ b/Assets/_Game/Scripts/Editor/Events/CreateEventChannelAssets.cs @@ -60,7 +60,6 @@ namespace BaseGames.Editor CreateAsset ("Combat", "EVT_ShieldRestored"); // ── Boss ────────────────────────────────────────────────────────── - CreateAsset ("Boss", "EVT_BossSkill"); CreateAsset ("Boss", "EVT_BossPhase"); CreateAsset ("Boss", "EVT_BossPhaseChanged"); CreateAsset ("Boss", "EVT_BossDefeated"); diff --git a/Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs b/Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs deleted file mode 100644 index efca1016..00000000 --- a/Assets/_Game/Scripts/Editor/Modules/BossSkillModule.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System; -using UnityEditor; -using UnityEditor.UIElements; -using UnityEngine; -using UnityEngine.UIElements; -using BaseGames.Boss; - -namespace BaseGames.Editor.Modules -{ - /// - /// DataHub Boss技能模块 —— Tab 切换管理 BossSkillSO 和 SkillSequenceSO。 - /// - 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 _skillPane; - private SoListPane _seqPane; - private Action _onSelected; - - private DetailHeader _header; - private BossSkillSO _selectedSkill; - private SkillSequenceSO _selectedSeq; - - public void Initialize() - { - _skillPane = new SoListPane( - SkillFolder, "ABL_Boss_", - s => s.category.ToString()); - _skillPane.SelectionChanged = s => { _selectedSkill = s; _onSelected?.Invoke(s); }; - - _seqPane = new SoListPane(SeqFolder, "ABL_Seq_"); - _seqPane.SelectionChanged = s => { _selectedSeq = s; _onSelected?.Invoke(s); }; - } - - public void BuildListPane(VisualElement container, Action 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 asset, string folder, SoListPane 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; - } - } -} diff --git a/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs b/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs index b7ebec27..ef961fc5 100644 --- a/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs +++ b/Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs @@ -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(go); EnemyMovement movement = GetOrAddComponent(go); GetOrAddComponent(go); // 地面导航(直接移动,无寻路图) + GetOrAddComponent(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地 PhysicsPerceptionSystem sensorHub = GetOrAddComponent(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(go); EnemyMovement movement = GetOrAddComponent(go); GetOrAddComponent(go); // 地面导航(直接移动,无寻路图) + GetOrAddComponent(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地 PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); var (hurtBox, bodyContact, contactHitBox) = @@ -495,9 +495,7 @@ namespace BaseGames.Editor SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report); - GetOrAddComponent(go); - - // BrainGraph AI:挂载决策组件并绑定 AI 配方资产(取代旧的 Opsive BehaviorTree) + // BrainGraph AI:挂载决策组件并绑定 AI 配方资产 var brain = GetOrAddComponent(go); var recipe = AssetDatabase.LoadAssetAtPath( "Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset"); @@ -552,6 +550,7 @@ namespace BaseGames.Editor EnemyStats enemyStats = GetOrAddComponent(go); EnemyMovement movement = GetOrAddComponent(go); GetOrAddComponent(go); // 地面导航(直接移动,无寻路图) + GetOrAddComponent(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地 PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); // HurtBox + ContactDamageZone(身体接触伤害常驻开启;HurtBox 悬挂期外关闭见下) @@ -653,6 +652,7 @@ namespace BaseGames.Editor EnemyStats enemyStats = GetOrAddComponent(go); EnemyMovement movement = GetOrAddComponent(go); GetOrAddComponent(go); // 地面导航(直接移动,无寻路图) + GetOrAddComponent(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地 PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); var (hurtBox, bodyContact, contactHitBox) = @@ -743,6 +743,7 @@ namespace BaseGames.Editor EnemyFeedback feedback = GetOrAddComponent(go); EnemyMovement movement = GetOrAddComponent(go); GetOrAddComponent(go); // 地面导航(直接移动,无寻路图) + GetOrAddComponent(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地 PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); var (hurtBox, bodyContact, contactHitBox) = @@ -873,6 +874,7 @@ namespace BaseGames.Editor EnemyStats enemyStats = GetOrAddComponent(go); EnemyMovement movement = GetOrAddComponent(go); GetOrAddComponent(go); // 地面导航(直接移动,无寻路图) + GetOrAddComponent(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地 PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); var (hurtBox, bodyContact, contactHitBox) = @@ -984,6 +986,7 @@ namespace BaseGames.Editor EnemyStats enemyStats = GetOrAddComponent(go); EnemyMovement movement = GetOrAddComponent(go); GetOrAddComponent(go); // 地面导航(直接移动,无寻路图) + GetOrAddComponent(go); // 移动执行器:EnemyBase.Awake 必须找得到,否则 AI 图的移动意图无处落地 PhysicsPerceptionSystem sensorHub = GetOrAddComponent(go); var (hurtBox, bodyContact, contactHitBox) = @@ -1086,7 +1089,12 @@ namespace BaseGames.Editor EnemyFeedback feedback = GetOrAddComponent(go); EnemyMovement movement = GetOrAddComponent(go); GetOrAddComponent(go); // 地面导航(直接移动,无寻路图) - BossSkillExecutor skillExec = GetOrAddComponent(go); + // 移动执行器:AI 图的移动意图(Idle/Face/Approach)全靠它落地。 + // 少了它 EnemyBase.Awake 就报「未找到 EnemyLocomotion」,且 Locomotion 为 null, + // 建图时 AiStateFragments.ApplyLocomotion 会 NRE,AiRuntime 构造失败 → Boss 完全不动。 + GetOrAddComponent(go); + EnemyAiBrain brain = GetOrAddComponent(go); + BossPhaseAbilityGate phaseGate = GetOrAddComponent(go); ChaoFengFloatController floatCtrl = GetOrAddComponent(go); ChaoFengKnockdownCounter knockdown = GetOrAddComponent(go); PhysicsPerceptionSystem sensorHub = GetOrAddComponent(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( + GetOrCreateChild(abilitiesT, "PlayClipAbility_Intro").gameObject); + var abFan = GetOrAddComponent( + GetOrCreateChild(abilitiesT, "MeleeAttackAbility_FanCombo").gameObject); + var abBoomer = GetOrAddComponent( + GetOrCreateChild(abilitiesT, "PlayClipAbility_Boomerang").gameObject); + var abTornadoS = GetOrAddComponent( + GetOrCreateChild(abilitiesT, "PlayClipAbility_TornadoSmall").gameObject); + var abTornadoL = GetOrAddComponent( + GetOrCreateChild(abilitiesT, "PlayClipAbility_TornadoLarge").gameObject); + var abWind = GetOrAddComponent( + 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(); - 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"); + + /// + /// 放置 Boss 开战触发区:玩家进入即广播开战事件并让 Boss 决策层进战。 + /// 没有它,Boss 会一直停在静置态——开战事件频道此前全项目零发送方。 + /// + /// + /// Boss id。既写进触发器的 _bossId(BGM / 血条按它定位 Boss), + /// 也用来在活动场景里定位目标 Boss——按 EnemyAiBrain._definitionId 匹配。 + /// + public static void PlaceBossFightTrigger(string bossId) + { + var report = new List(); + 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(go); + col.isTrigger = true; + col.size = new Vector2(4f, 6f); // 门口尺寸占位,按竞技场入口在 Inspector 调整 + + BossFightTrigger trigger = GetOrAddComponent(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(); + foreach (var root in UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().GetRootGameObjects()) + foreach (var b in root.GetComponentsInChildren(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); + } + // ══ 敌人放置辅助方法 ═══════════════════════════════════════════════════ /// @@ -2448,6 +2565,37 @@ namespace BaseGames.Editor report?.Add($"[OK] MeleeAttackAbility._hitBoxSlots 已配置 {slots.Length} 个槽位。"); } + /// + /// 为 BossPhaseAbilityGate._entries 赋值(struct 数组 {ability, phases})。 + /// phases 为空数组 = 该能力全阶段可用。 + /// + private static void AssignPhaseGate(BossPhaseAbilityGate gate, + (EnemyAbilityBase ability, int[] phases)[] entries, + List 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(); + 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) diff --git a/Assets/_Game/Scripts/Editor/Shared/EditorScaffoldUtils.cs b/Assets/_Game/Scripts/Editor/Shared/EditorScaffoldUtils.cs index 7c627512..b380af19 100644 --- a/Assets/_Game/Scripts/Editor/Shared/EditorScaffoldUtils.cs +++ b/Assets/_Game/Scripts/Editor/Shared/EditorScaffoldUtils.cs @@ -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") }, diff --git a/Assets/_Game/Scripts/Enemies/Boss/Patterns/BaseGames.Enemies.Boss.Patterns.asmdef.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Ai.meta similarity index 57% rename from Assets/_Game/Scripts/Enemies/Boss/Patterns/BaseGames.Enemies.Boss.Patterns.asmdef.meta rename to Assets/_Game/Scripts/Enemies/AIBrain/Ai.meta index 0bab4b52..93649e8b 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/Patterns/BaseGames.Enemies.Boss.Patterns.asmdef.meta +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Ai.meta @@ -1,6 +1,7 @@ fileFormatVersion: 2 -guid: 8bc3529e552a34a45998814c7cd056e6 -AssemblyDefinitionImporter: +guid: d0934d78d9f1fe34aa6c0429a0822df8 +folderAsset: yes +DefaultImporter: externalObjects: {} userData: assetBundleName: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs new file mode 100644 index 00000000..9436e89a --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs @@ -0,0 +1,80 @@ +using BaseGames.AI; + +namespace BaseGames.Enemies +{ + /// + /// 嘲风的 AI 图(定制路径:Boss 数量少、彼此差异大,手写图而非配方)。 + /// + /// 阶段 0(地面):逼近 ↔ 选招。地面招由 BossPhaseAbilityGate 在阶段 0 放行。 + /// 阶段 1(空中):悬停 ↔ 选招。空中招由阶段门在阶段 1 放行,地面招被禁用。 + /// + /// 招式的射程 / 冷却 / 权重全归各能力自己的 EnemyAbilitySO;本图只决定 + /// 「什么时候该逼近、什么时候该出手、什么时候该换阶段」。 + /// 死亡不在本图:EnemyBase 的死亡流程发 Died 事件,全局边把决策停在 Death 终态。 + /// + /// 三条刻意的形状,改图时别顺手抹掉: + /// 1. Boss 不脱战——逼近态的 rest 指回自身,脱离感知区也不回静置态。 + /// 2. 阶段单向推进——空中阶段没有回地面阶段的边,血量回升也不退阶段(招池已换)。 + /// 3. 起手就打完——攻击态不挂脱战边,前摇中玩家跑开仍完整打完。 + /// + [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"; + + /// 入场演出能力 id(对应入场能力资产的 abilityId)。 + public const string IntroAbility = "chaofeng_intro"; + + /// 空中阶段的血量阈值。 + private const float AirPhaseHpRatio = 0.5f; + + /// 阶段过渡的目标阶段索引。 + private const int AirPhaseIndex = 1; + + /// 阶段过渡无敌时长,须 ≥ 浮空上升时长 + 缓冲。 + 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); + } + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs.meta new file mode 100644 index 00000000..94c05b8d --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9df917389cefced4692c7e56ed9bd6e3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs index d832b329..b09d1083 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs @@ -5,7 +5,7 @@ namespace BaseGames.Enemies { /// /// 把 BrainGraph 决策层挂到敌人上:按 id 取共享 AiGraph、构造 AiRuntime、逐帧推进。 - /// 取代旧的行为树组件。 + /// 是敌人唯一的决策层组件(小怪与 Boss 共用)。 /// [DisallowMultipleComponent] [RequireComponent(typeof(EnemyBase))] diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs index 30dc8e08..6fb2bf63 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/EnemyBrainContext.cs @@ -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; + /// + /// Boss 专属决策面。非 Boss 敌人访问即抛——小怪图误挂了 Boss 边必须立刻暴露, + /// 不返回空对象让错误静默通过(CLAUDE.md 第 6 条)。 + /// + public IBossControl Boss => _boss ?? throw new InvalidOperationException( + $"[EnemyBrainContext] '{_enemy.name}' 不是 Boss(未挂 BossBase 或其子类)," + + "但它的 AI 图访问了 IAiContext.Boss。请检查该敌人是否误用了 Boss 专属的图或图片段。"); + /// 每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时(供需要的敌人用)。 public void Refresh(float dt) { @@ -53,10 +67,26 @@ namespace BaseGames.Enemies public Vector2 LastKnown => _lastKnown; // ---- ICombatant ---- + /// + /// 执行指定 id 的能力。解析不到该 id 一律显式报错—— + /// 建图期 AiStateFragments.Ability/AbilityOnce 已挡掉空 id, + /// 所以运行期解析不到只可能是漏挂能力组件或 SO 上的 abilityId 写错,没有合法情形。 + /// 静默返回 false 会让「能力态进入后什么都不做、IsAbilityRunning 恒假、 + /// 完成条件立刻成立」表现为"演出被跳过"却零报错(CLAUDE.md 第 6 条)。 + /// 用 LogError 而非抛异常:这是每帧路径,抛异常会打断整个 AiRuntime.Tick, + /// 让一个漏配的敌人拖垮整场战斗。 + /// 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(); diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs index 3660df69..629d2402 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs @@ -46,9 +46,10 @@ namespace BaseGames.Enemies /// /// 无行为的终态。不需要在这里停移动——转入本态时,上一个态的 OnExit 已经收尾。 /// 用于"死亡演出交给物理状态机(EnemyBase.PerformDeath)"的敌人。 + /// 自带终态标记,因此能通过 Build() 的"非终态必须有出口"校验。 /// public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state) - => b.DeclareState(state); + => b.DeclareState(state).Terminal(); static void ApplyLocomotion(IAiContext x, LocomotionMode mode) { diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs new file mode 100644 index 00000000..ddaf4bb6 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs @@ -0,0 +1,64 @@ +using System; +using BaseGames.AI; + +namespace BaseGames.Enemies +{ + /// + /// Boss 图的共享建态片段。与 同级——是静态建态原语, + /// 不是可插拔模块:Boss 图手写,重复的形状抽成片段即可,不需要 SO 下拉与多态序列化。 + /// + public static class BossFragments + { + /// + /// 阶段过渡态:进入时发起过渡(无敌 + 演出由 BossBase 负责),过渡结束后由调用方挂出边。 + /// 只发起一次——放 OnEnter 而非 Tick,因为 BossBase.BeginPhaseTransition 对重入会告警。 + /// + 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); + }); + } + + /// + /// 一组「逼近 ↔ 到射程选招攻击」。转发到 ApproachAttackEngagement 的共享实现, + /// 使模块路径与 Boss 图路径永远是同一份行为。 + /// 返回逼近态的 builder,调用方可继续挂阶段过渡等出边。 + /// + public static BrainBuilder.StateBuilder ApproachAttack( + BrainBuilder b, string approach, string attack, string rest) + => ApproachAttackEngagement.Declare(b, approach, attack, rest); + + /// + /// 移动到第 index 个竞技场锚点(),到位后由调用方挂出边。 + /// 坐标经 IBossControl 取得——声明层的 lambda 不得闭包捕获具体实例。 + /// + 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()); + } + + /// 共享条件:已到达第 index 个锚点(容差内)。 + public static Func AtAnchor(int index, float tolerance = 0.2f) + => x => x.Boss.DistanceToAnchor(index) <= tolerance; + } +} diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs.meta b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs.meta new file mode 100644 index 00000000..6398217e --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0f04cf21bca8c61418a9ce9e88f6ba91 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs index 108ebe62..019ad0d4 100644 --- a/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs +++ b/Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/ApproachAttackEngagement.cs @@ -22,8 +22,17 @@ namespace BaseGames.Enemies public string EngageLabel => "InChaseZone"; public void Build(BrainBuilder b, string rest) + => Declare(b, Approach, Attack, rest); + + /// + /// 建一组「逼近 ↔ 到射程选招攻击」。态名参数化,供本模块与 Boss 图共用同一份实现 + /// (Boss 可能有多个阶段各一组,如地面组与空中组)。 + /// 返回逼近态的 builder,调用方可在其上继续挂自己的出边(如阶段过渡)。 + /// + 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, diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs b/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs index 710241c5..fdfcceb8 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs @@ -2,11 +2,12 @@ namespace BaseGames.Enemies.Abilities { /// /// 能力执行的阶段枚举(架构 07_EnemyModule §8)。 - /// 用于 BD/Animator/UI 查询能力当前在哪个阶段(telegraph/出招/恢复)。 + /// 用于 AI 决策层 / 动画 / UI 查询能力当前在哪个阶段(预警 / 出招 / 恢复)。 /// public enum AbilityRunState { Idle, + /// 预警:由能力自身在协程里显式置位(如 BlinkStrikeAbility 的现身闪光)。 Telegraph, Windup, Active, @@ -14,7 +15,7 @@ namespace BaseGames.Enemies.Abilities Interrupted } - /// 能力中断原因。供 BD 判断是否需要重新调度。 + /// 能力中断原因。供 AI 决策层判断是否需要重新调度。 public enum InterruptReason { ExternalRequest, diff --git a/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs b/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs index 5f704bf0..42f3667f 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/AttackSelectionMode.cs @@ -3,7 +3,8 @@ namespace BaseGames.Enemies.Abilities /// 多攻击选招策略。 public enum AttackSelectionMode { - WeightedRandom, // 合格集内按 weight 加权随机 - Priority // 合格集内取 priority 最高(并列取首个) + WeightedRandom, // 合格集内按 weight 加权随机 + Priority, // 合格集内取 priority 最高(并列取首个) + WeightedRandomAntiRepeat, // 同 WeightedRandom,但对上一次选中的招施加权重折扣,压低连续重复 } } diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs index 42d1a474..bf823aa6 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs @@ -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; - /// 能力被外部中断时触发(BD Task / 状态机订阅用)。 + /// 能力被外部中断时触发(AI 决策层 / 状态机订阅用)。 public event System.Action Interrupted; - /// BD 任务统一查询入口:当前是否可用(冷却完毕且未执行中)。 - public virtual bool CanUse => !_isRunning && !IsOnCooldown && _enemy != null && _enemy.IsAlive; + /// + /// 统一可用性查询:组件启用 + 冷却完毕 + 未执行中 + 宿主存活。 + /// enabled 这一维供阶段门使用——被阶段禁用的能力不得进入选招候选, + /// 否则选招器会选中它然后 StartCoroutine 在禁用组件上必然失败。 + /// + 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 /// 子类实现:能力主体。可分多段、含 HitBox 激活/弹幕生成/物理推进等。 protected abstract IEnumerator ExecuteCoroutine(); - /// 预警阶段(默认生成 VFX 后等待 telegraphDuration)。子类可重写。 - protected virtual IEnumerator TelegraphRoutine() - { - if (!string.IsNullOrEmpty(_config.telegraphVfxKey)) - { - var pool = ServiceLocator.GetOrDefault(); - pool?.Spawn(_config.telegraphVfxKey, _transform.position, Quaternion.identity); - } - yield return EnemyAbilityWaits.Get(_config.telegraphDuration); - } - /// 能力结束钩子(被中断或正常结束都会调用)。 protected virtual void OnAbilityEnded() { } @@ -167,6 +155,15 @@ namespace BaseGames.Enemies.Abilities protected virtual void OnInterrupted(InterruptReason reason) { } + /// + /// 把冷却清回出生态,使能力立刻可用。 + /// 供对象池复活()调用:冷却以绝对 Time.time 记时, + /// 不显式清零就会原样活过 despawn/spawn,让新生的敌人带着上一条命的剩余冷却。 + /// 注意不能靠 代劳——它有 _isRunning 门(出生时无一在跑), + /// 且其语义是"中断后计半程冷却",是写入冷却而非清除。 + /// + public void ResetCooldown() => _cooldownEndTime = -1f; + /// 子类辅助:朝向目标(写入输入信号,下一 FixedUpdate 由 EnemyMovement 消费)。 protected void FaceTarget(Transform target) { diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs index 15e9a290..2ea275f6 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityRegistry.cs @@ -61,6 +61,17 @@ namespace BaseGames.Enemies.Abilities } } + /// + /// 清空所有能力的冷却。对象池复活时调用。 + /// 与 的区别是**不看 IsRunning**——出生时没有能力在跑, + /// 那道门会让中断路径一次都不执行,正是冷却跨命残留的成因。 + /// + public void ResetAllCooldowns() + { + for (int i = 0; i < _all.Count; i++) + if (_all[i] != null) _all[i].ResetCooldown(); + } + /// /// 中断指定互斥组内所有正在执行的能力。 /// 由 在 Execute 开始时调用,确保同组互斥。 diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs index a6382088..5a0fff81 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs @@ -6,27 +6,28 @@ namespace BaseGames.Enemies.Abilities { /// /// 能力配置包(架构 07_EnemyModule §8.2)。 - /// 一个能力 = 一组攻击段 + 公共参数(冷却/预警/中断规则)。 + /// 一个能力 = 一组攻击段 + 公共参数(冷却/中断规则)。 + /// 预警不在此配置:预警的载体是动画 clip 本身(姿态在动画里,音效与特效挂动画事件)。 /// 由对应 EnemyAbilityBase 子类组件读取并执行。 /// - [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; diff --git a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs index d69ef58e..45f8b3dd 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSelector.cs @@ -9,18 +9,41 @@ namespace BaseGames.Enemies.Abilities /// public sealed class EnemyAttackSelector { + /// 防重复折扣系数的默认值。EnemyStatsSO 字段初值与 EnemyBase 的兜底分支共用此常量。 + public const float DefaultAntiRepeatFactor = 0.3f; + private readonly List _candidates; // 权重缓冲区(与 _candidates 等长,每次选招复用):避免每次选招 new List 造成 GC private readonly List _weightBuf; + // 防重复折扣系数:0 = 有替代时绝不连续重复,1 = 不惩罚 + private float _antiRepeatFactor; - public EnemyAttackSelector(IEnumerable candidates) + private IAttackCandidate _lastPicked; + + public EnemyAttackSelector(IEnumerable candidates, + float antiRepeatFactor = DefaultAntiRepeatFactor) { - _candidates = new List(candidates); - _weightBuf = new List(_candidates.Count); + _candidates = new List(candidates); + _weightBuf = new List(_candidates.Count); + AntiRepeatFactor = antiRepeatFactor; // 经属性赋值,夹紧逻辑只有一处 } public int Count => _candidates.Count; + /// 防重复折扣系数。与 attackSelectionMode 一样每次选招前由调用方刷新, + /// 使策划在播放模式下调参能立即生效。 + public float AntiRepeatFactor + { + get => _antiRepeatFactor; + set => _antiRepeatFactor = Mathf.Clamp01(value); + } + + /// 上一次实际选中的招(防重复折扣的作用对象;供测试与调试面板读取)。 + public IAttackCandidate LastPicked => _lastPicked; + + /// 对象池复用 / 阶段切换时清除防重复记忆。 + 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); } } diff --git a/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs b/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs index 2cb61610..f76783bc 100644 --- a/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs +++ b/Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs @@ -6,7 +6,7 @@ namespace BaseGames.Enemies.Abilities /// public interface IAttackCandidate { - bool CanUse { get; } // 未冷却 + 未运行 + 存活 + bool CanUse { get; } // 组件启用 + 未冷却 + 未运行 + 存活 bool RequiresLineOfSight { get; } // 是否需要视线(由敌人级 LOS 提供) bool RequiresGrounded { get; } // 是否需要着地 float Weight { get; } // WeightedRandom 权重 diff --git a/Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs b/Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs index ccc90118..cd9cbffe 100644 --- a/Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs +++ b/Assets/_Game/Scripts/Enemies/Behaviors/EnemyDeathSequence.cs @@ -9,7 +9,7 @@ namespace BaseGames.Enemies.Behaviors /// /// 死亡前摇无敌演出(零代码替代每敌人专属的 Die() 重写)。 /// - /// 会委托本组件:停行为树 / 停移动、停用受击框,播放前摇动画并等待, + /// 会委托本组件:通知决策层终止 / 停移动、停用受击框,播放前摇动画并等待, /// 期间敌人处于无敌(),演出结束后回调真正的死亡清理。 /// 前摇动画上可放置 SpawnProjectile 动画事件,配合 在演出中生成小怪。 /// @@ -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); diff --git a/Assets/_Game/Scripts/Enemies/Boss/AttackPatternSO.cs b/Assets/_Game/Scripts/Enemies/Boss/AttackPatternSO.cs deleted file mode 100644 index ee62b7ab..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/AttackPatternSO.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using UnityEngine; -using UnityEngine.AddressableAssets; -using BaseGames.Combat; - -namespace BaseGames.Boss -{ - /// - /// 单个攻击图案的数据。伤害参数只写在此处,BossSkillSO 不存参数。 - /// - [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; - } -} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs b/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs new file mode 100644 index 00000000..152d5685 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs @@ -0,0 +1,55 @@ +using UnityEngine; + +namespace BaseGames.Enemies +{ + /// + /// 竞技场锚点集合:Boss 定点走位(跳到悬空平台、传送到房间固定角落)的坐标来源。 + /// 挂在 Boss 上;锚点物体本身放在场景里(随房间布局走,不进 SO)。 + /// + /// AI 图不直接持有本组件——坐标经 取得 + /// (图的 lambda 不得闭包捕获实例)。BossBase 在 Awake 解析本组件并转发。 + /// + public sealed class BossArenaAnchors : MonoBehaviour + { + [Tooltip("锚点物体(顺序即下标,AI 图按下标引用)")] + [SerializeField] private Transform[] _anchors; + + public int Count => _anchors != null ? _anchors.Length : 0; + + /// 取第 index 个锚点坐标。下标越界或漏配即抛——不静默回退到自身位置。 + 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 + } +} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs.meta new file mode 100644 index 00000000..ea9dde72 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossArenaAnchors.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c2e82425fc5d93141b283d4b30a67a9b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs index afbcfc5d..c2e4340e 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs @@ -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 { /// - /// Boss 敌人基类。扩展 以支持多阶段切换、技能执行与战斗结束广播。 - /// 具体 Boss 继承此类并重写 。 + /// Boss 敌人基类。扩展 以支持多阶段切换与战斗结束广播。 + /// 招式的选取与执行走小怪同轨( + 能力组件), + /// 本类不再持有独立的技能执行器。具体 Boss 继承此类并重写 。 /// - 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; - /// 当前是否有 Boss 技能正在执行(BD_UseBossSkill 轮询此值)。 - public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting; - protected int _currentPhase = 0; - /// 当前 Boss 阶段索引(BD Task 可直接查询)。 + /// 当前 Boss 阶段索引。IBossControl 实现。 public int CurrentPhase => _currentPhase; - private Coroutine _counterStaggerCoroutine; - // 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配 - private readonly List _weightedCandidates = new(8); - private readonly List _candidateWeights = new(8); + /// Boss 资源是否已满。IBossControl 实现。 + /// 未挂 BossResource 即抛——AI 图问了资源却没配资源组件是配置错误,必须暴露。 + 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]; + /// 第 index 个竞技场锚点坐标。IBossControl 实现。未挂锚点组件即抛—— + /// AI 图用了锚点片段却没配锚点是配置错误,必须暴露而非回退到自身位置。 + public Vector2 AnchorAt(int index) => RequireAnchors().At(index); + + /// 当前位置到第 index 个锚点的距离。IBossControl 实现。 + 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(true); + // includeInactive:true 确保禁用状态的子组件也能被发现(如阶段门按阶段停用的组件) if (_bossResource == null) _bossResource = GetComponentInChildren(true); + if (_phaseGate == null) _phaseGate = GetComponentInChildren(true); + if (_arenaAnchors == null) _arenaAnchors = GetComponentInChildren(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); } /// @@ -65,99 +81,6 @@ namespace BaseGames.Enemies /// public override bool IsInvincible => IsPhaseTransitioning || base.IsInvincible; - /// - /// 上一次成功执行的技能 ID。 对其施加权重惩罚,防止相同技能连续重复。 - /// - public string LastUsedSkillId { get; private set; } - - // ── 技能执行(BD Task 调用入口)───────────────────────────────────── - - /// - /// 通过技能 ID 执行 Boss 技能。 - /// 若技能未找到、执行器忙或冷却中则返回 false,否则返回 true。 - /// - 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; - } - - /// - /// 在当前阶段可用且冷却就绪的技能中,按 加权随机选择一个并执行。 - /// 若上一次已使用某技能,则对该技能施加 0.3× 权重惩罚,降低连续重复的概率。 - /// 若无可用技能或执行器忙则返回 false。 - /// - 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; - } - - /// 检查技能的 availablePhaseIndices 是否包含当前阶段(空数组 = 全阶段可用)。 - 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; - } - - /// - /// 检查 Boss 资源是否满足技能的 minRequired 门槛。 - /// 未配置资源组件或 minRequired <= 0 时视为通过。 - /// - private bool CheckResourceCost(BossSkillSO skill) - { - if (_bossResource == null) return true; - float min = skill.resourceCost.minRequired; - if (min <= 0f) return true; - return _bossResource.CurrentValue >= min; - } - // ── 阶段 ────────────────────────────────────────────────────────────── /// 当前是否处于阶段过渡(无敌帧 + 过渡演出)期间。 @@ -166,16 +89,19 @@ namespace BaseGames.Enemies private Coroutine _phaseTransitionCoroutine; /// - /// 进入指定阶段。自动打断当前执行中的技能,广播 供 UI / 音乐系统响应。 + /// 进入指定阶段。自动打断在跑的招,广播 供 UI / 音乐系统响应。 /// 子类可重写以添加额外过渡逻辑(动画、无敌帧等)。 /// 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 /// /// 启动阶段过渡演出:无敌帧 + 可选定格时间,结束后自动调用 。 - /// BD_BossPhaseTransition 检查 来等待过渡完成。 + /// 决策层检查 来等待过渡完成。 /// /// 过渡目标阶段索引。 /// 无敌帧持续时间(秒)。 - 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); - } - - /// - /// 根据 counterType 查找当前技能(或所有技能)的 PlayerCounterResponse 并应用效果。 - /// 可由外部系统(闪避穿越、弱点命中等)直接调用。 - /// - 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 的启用集 } } } diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs b/Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs new file mode 100644 index 00000000..b6a492d9 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs @@ -0,0 +1,96 @@ +using UnityEngine; +using BaseGames.AI; +using BaseGames.Core.Events; + +namespace BaseGames.Enemies +{ + /// + /// Boss 开战触发:玩家进入竞技场触发区时,广播开战事件并让 Boss 的决策层从静置态进战。 + /// 此前开战事件频道全项目零发送方,Boss 永远不会开战。 + /// + /// 放在 Boss 房间入口的触发区上(Collider2D + isTrigger)。只触发一次。 + /// 漏配一律在 Awake 显式报错——静默不触发会表现为"Boss 站着不动",极难定位。 + /// + [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(); + 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); + } + + /// 供存档回载 / 重试时复位(Boss 未被击败则重新可触发)。 + public void ResetTrigger() => _fired = false; + } +} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs.meta new file mode 100644 index 00000000..1438cefb --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd8a9d00a149c124a9bd90ffba663a32 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs b/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs new file mode 100644 index 00000000..4adc303f --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs @@ -0,0 +1,61 @@ +using System; +using UnityEngine; +using BaseGames.Enemies.Abilities; + +namespace BaseGames.Enemies +{ + /// + /// 阶段 = 换招池:按当前阶段启用 / 禁用能力组件。 + /// 被禁用的能力其 为 false, + /// 因此自动从 的候选里消失——阶段门不需要碰选招器。 + /// + /// 由 直接调用 + /// (而非订阅阶段频道),保证与阶段切换严格同序。 + /// + public sealed class BossPhaseAbilityGate : MonoBehaviour + { + [Serializable] + public struct PhaseEntry + { + [Tooltip("受阶段管控的能力组件")] + public EnemyAbilityBase ability; + [Tooltip("该能力可用的阶段索引;空数组 = 全阶段可用")] + public int[] phases; + } + + [Tooltip("阶段可用性表。未登记的能力不受本组件影响(保持其自身启用状态)")] + [SerializeField] private PhaseEntry[] _entries; + + /// 把指定阶段的启用集应用到所有登记的能力。 + 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 + } +} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs.meta new file mode 100644 index 00000000..225b5dc1 --- /dev/null +++ b/Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a9f265c84eb5db34b8fd06472949ee83 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs b/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs index 681ac2bd..934745b0 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossResource.cs @@ -9,7 +9,7 @@ namespace BaseGames.Boss /// - 每帧以 自动积累或消耗。 /// - 受击时增加 。 /// - 技能使用时增加 。 - /// - 满值时若 ,自动让 BossBase 执行配置的技能。 + /// - 满值时 为真,由 AI 图的条件边决定放什么招(决策不在本组件)。 /// public sealed class BossResource : MonoBehaviour { @@ -17,7 +17,6 @@ namespace BaseGames.Boss [SerializeField] private BossBase _boss; private float _currentValue; - private bool _fullTriggered; /// 当前资源值(0 ~ config.maxValue)。 public float CurrentValue => _currentValue; @@ -26,6 +25,10 @@ namespace BaseGames.Boss public float NormalizedValue => _config != null && _config.maxValue > 0f ? _currentValue / _config.maxValue : 0f; + /// 资源是否已达上限(供 AI 图条件边判定"可放大招")。 + public bool IsFull => _config != null && _config.maxValue > 0f + && _currentValue >= _config.maxValue; + private void Awake() { if (_boss == null) _boss = GetComponentInParent(); @@ -55,14 +58,14 @@ namespace BaseGames.Boss AddValue(_config.onTakeDamageGain); } - /// Boss 使用技能时调用。由 BossBase.UseBossSkill 触发。 + /// Boss 使用技能时调用。由使用了资源的能力触发(Boss 的招在自己的 EnemyAbilityBase 里调用)。 public void OnBossUseSkill() { if (_config == null) return; AddValue(_config.onSkillUseGain); } - /// 直接设置资源值(外部强制赋值,跳过满值触发)。 + /// 直接设置资源值(外部强制赋值)。 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; } } } diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossResourceConfigSO.cs b/Assets/_Game/Scripts/Enemies/Boss/BossResourceConfigSO.cs index f27722ba..2d3461c8 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/BossResourceConfigSO.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/BossResourceConfigSO.cs @@ -5,6 +5,10 @@ namespace BaseGames.Boss /// /// Boss 自身资源(如愤怒值)的配置 ScriptableObject。 /// + /// + /// 资源满值不再由本组件自动放招——那是绕过决策层直接执行,违反「AI 只决策、能力负责实现」。 + /// 现在的形态是:本组件只维护数值,AI 图挂一条 When(x => x.Boss.ResourceFull) 边进专属招态。 + /// [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; } } diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs b/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs deleted file mode 100644 index 9be6312a..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs +++ /dev/null @@ -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 -{ - /// - /// 挂在 Boss GameObject 上,接收 BossOrchestrator 的指令执行指定 BossSkillSO。 - /// 管理 VulnerabilityWindow 计时和 WeakPointSystem 激活。 - /// - 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; - /// PlayerController 无 Instance(架构 05 §2),由 Inspector 指定。 - [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 _skillCooldownEndTimes = new(); - - public bool IsExecuting => _isExecuting; - - /// 检查指定技能是否冷却就绪(无冷却记录或已过冷却时间)。 - public bool CanUseSkill(string skillId) - { - if (string.IsNullOrEmpty(skillId)) return false; - if (_skillCooldownEndTimes.TryGetValue(skillId, out float endTime)) - return Time.time >= endTime; - return true; - } - - /// 强制重置指定技能的冷却(阶段切换、复活等场景使用)。 - public void ResetSkillCooldown(string skillId) - { - _skillCooldownEndTimes.Remove(skillId); - } - - /// 重置所有技能冷却。 - 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 - - /// - /// 按 float 值复用 WaitForSeconds 实例,消除协程中每次 new WaitForSeconds 的 GC 分配。 - /// Domain Reload 禁用时静态缓存跨 PlayMode 会话保留,但 WaitForSeconds 是幂等值对象, - /// 不会引发功能错误;[RuntimeInitializeOnLoadMethod] 确保每次进入 Play 时清空。 - /// - private static readonly Dictionary _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 ─────────────────────────────────────────────────────────── - - /// - /// 按 skillId 查找已在 Inspector 注册的技能 SO。未找到返回 null。 - /// - 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; - } - - /// 返回当前正在执行的技能 SO,未执行时返回 null。 - public BossSkillSO FindCurrentSkill() => _isExecuting ? _currentSkill : null; - - /// Inspector 中注册的全部技能 SO(只读)。 - public BossSkillSO[] Skills => _skills; - - /// - /// 执行一个 Boss 技能。若当前正在执行或技能冷却未就绪则返回。 - /// - 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)); - } - - /// - /// 立即打断正在执行的技能(阶段切换时调用)。 - /// - public void InterruptCurrentSkill() - { - // 同步停止弱点窗口协程,防止中断后继续激活 WeakPointSystem - if (_vulnCoroutine != null) - { - StopCoroutine(_vulnCoroutine); - _vulnCoroutine = null; - } - if (_activeCoroutine != null) - { - StopCoroutine(_activeCoroutine); - _activeCoroutine = null; - } - FinishExecution(); - } - - // 等待事件触发的 VulnWindow(事件驱动类型,存储后由 NotifyVulnTrigger 逐个激活) - private readonly List _pendingEventWindows = new(); - - /// - /// 通知执行器某一外部事件已发生(如格挡成功、反制命中等), - /// 激活所有注册该触发类型的弱点窗口。 - /// 由 BossBase.HandleParrySuccess / ApplyCounterResponse 等调用。 - /// - 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); - } - } - } - - /// 实际开启并持续弱点窗口,支持独立并行运行。 - 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(); - } - - // ── 工具 ─────────────────────────────────────────────────────────────── - - /// - /// 在指定时长内开启弱点窗口(格挡/闪避反制时调用,独立于技能 VulnerabilityWindow 序列)。 - /// - 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; - } -} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs.meta deleted file mode 100644 index cce54276..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/BossSkillExecutor.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 4dfa1c525eaca5640b3cfe945626a466 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs b/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs deleted file mode 100644 index 4c67271b..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using UnityEngine; -using Animancer; -using BaseGames.Combat; - -namespace BaseGames.Boss -{ - /// - /// Boss 单个技能的所有数据,包括攻击模式、弱点窗口、互动标签等。 - /// - [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; - } -} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs.meta deleted file mode 100644 index aaceeee8..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/BossSkillSO.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: de92221c7c3fb4a42a7cd122a8f97632 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossSkillTypes.cs b/Assets/_Game/Scripts/Enemies/Boss/BossSkillTypes.cs deleted file mode 100644 index a612f79a..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/BossSkillTypes.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using UnityEngine; -using BaseGames.Feedback; - -namespace BaseGames.Boss -{ - // ── 分类枚举 ─────────────────────────────────────────────────────────────── - - public enum BossSkillCategory - { - Melee, Ranged, Charge, AoE, Environmental, Summon, - Buff, Debuff, Phase, Passive, Reactive - } - - public enum BossSkillType - { - MeleeSlash, - ChargeAttack, - LeapSlam, - ProjectileVolley, - LaserBeam, - PhaseTransition, - SummonMinion, - ArenaTrap, - SpeedBurst, - DefensiveShell, - } - - [Flags] - public enum InteractionTag - { - None = 0, - Parryable = 1 << 0, - PerfectParryOnly = 1 << 1, - DodgeWindow = 1 << 2, - Unblockable = 1 << 3, - CanBeReflected = 1 << 4, - ExposesWeakPoint = 1 << 5, - GrantsPlayerReso = 1 << 6, - ArenaHazard = 1 << 7, - PhaseGate = 1 << 8, - } - - // ── VulnerabilityWindow ──────────────────────────────────────────────────── - - public enum VulnTriggerType - { - OnAttackRecovery, - OnParriedSuccess, - OnCounterSkillHit, - OnPhaseTransition, - OnHazardBackfire, - OnSummonDefeated, - Manual, - } - - public enum WeakPointType - { - FullBody, - HeadOnly, - BackOnly, - CoreExposed, - CustomPoint, - } - - [Serializable] - public struct VulnerabilityWindow - { - [Tooltip("弱点触发方式")] - public VulnTriggerType TriggerType; - - [Tooltip("触发后延迟出现(秒)")] - [Min(0f)] - public float TriggerDelay; - - [Tooltip("弱点持续时长(秒)")] - [Min(0.1f)] - public float Duration; - - public WeakPointType WeakPointType; - - [Tooltip("弱点激活时 Boss 受击乘数")] - [Min(0.1f)] - public float DamageMultiplier; - - public bool ForceStagger; - - [Min(0f)] - public float StaggerDuration; - - public SceneFeedback OpenFeedback; - public SceneFeedback CloseFeedback; - public Color HighlightColor; - - public bool ActivateWeakPointHurtBox => WeakPointType != WeakPointType.FullBody; - } - - // ── PlayerCounterResponse ───────────────────────────────────────────────── - - public enum CounterType - { - Parry, - PerfectParry, - DodgeThrough, - SpecificSkill, - WeakPointHit, - HazardBackfire, - SummonKill, - } - - [Serializable] - public struct PlayerCounterResponse - { - [Header("反制条件")] - public CounterType counterType; - public string requiredSkillId; - - [Header("反制效果(对 Boss)")] - public float bossStaggerDuration; - public float bossDamageBonus; - public bool openVulnWindow; - public bool interruptSkill; - - [Header("反制收益(对玩家)")] - public int soulPowerGrant; - public int spiritPowerGrant; - public SceneFeedback counterFeedback; - } - - // ── ArenaEvent ──────────────────────────────────────────────────────────── - - public enum ArenaEventType - { - DestroyPlatform, - ActivateHazard, - DeactivateHazard, - SpawnHazardArea, - ShakeArena, - ToggleLighting, - SpawnPlatform, - TriggerCutscene, - } - - [Serializable] - public struct ArenaEventParams - { - public float duration; - public float intensity; - public bool revertsOnPhaseEnd; - } - - [Serializable] - public struct ArenaEventTrigger - { - public string targetArenaObjectId; - public ArenaEventType eventType; - public float delay; - public ArenaEventParams parameters; - } - - [Serializable] - public struct ArenaEventData - { - public ArenaEventType type; - public ArenaEventParams parameters; - public string sourceSkillId; - } - - public interface IArenaInteractable - { - string ArenaObjectId { get; } - void OnBossArenaEvent(ArenaEventData data); - } - - // ── BossResourceCost ────────────────────────────────────────────────────── - - [Serializable] - public struct BossResourceCost - { - public string resourceId; - public float cost; - public float minRequired; - } -} diff --git a/Assets/_Game/Scripts/Enemies/Boss/BossSkillTypes.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/BossSkillTypes.cs.meta deleted file mode 100644 index 2f8a3962..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/BossSkillTypes.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: a1beb8f8f7958b84c9ab60abe5f8c4ed -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/ChaoFengBoss.cs b/Assets/_Game/Scripts/Enemies/Boss/ChaoFengBoss.cs index 7c094d55..4bfad656 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/ChaoFengBoss.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/ChaoFengBoss.cs @@ -18,7 +18,7 @@ namespace BaseGames.Enemies.Boss /// 1. BossBase.BeginPhaseTransition → OnBeginPhaseTransition(1) 立即播放过渡动画 + 开始浮空 /// 2. 无敌期结束(≥ _riseDuration+buffer)→ BossBase.EnterPhase(1) 广播阶段切换事件 /// - /// ⚠️ 动画由本脚本通过 Animancer.Play() 完整控制,不在 BD 中调用 BD_PlayAnimation。 + /// ⚠️ 动画由本脚本通过 Animancer.Play() 完整控制,不由 AI 决策层驱动播放。 /// public class ChaoFengBoss : BossBase { @@ -57,7 +57,7 @@ namespace BaseGames.Enemies.Boss /// /// 阶段过渡开始时立即播放过渡动画并启动浮空协程。 - /// invincibleDuration 需在 BD_BossPhaseTransition 中配置为 ≥ _riseDuration + 缓冲(约 2.0s)。 + /// invincibleDuration 需在触发阶段过渡的调用处配置为 ≥ _riseDuration + 缓冲(约 2.0s)。 /// protected override void OnBeginPhaseTransition(int targetPhase) { @@ -185,7 +185,7 @@ namespace BaseGames.Enemies.Boss private IEnumerator DefeatSequence() { - StopBehaviorTree(); + NotifyDecisionStop(); _knockdownCounter?.ForceEnd(); // Phase 2(空中)先落地 diff --git a/Assets/_Game/Scripts/Enemies/Boss/ChaoFengKnockdownCounter.cs b/Assets/_Game/Scripts/Enemies/Boss/ChaoFengKnockdownCounter.cs index 9ed20228..69f9f910 100644 --- a/Assets/_Game/Scripts/Enemies/Boss/ChaoFengKnockdownCounter.cs +++ b/Assets/_Game/Scripts/Enemies/Boss/ChaoFengKnockdownCounter.cs @@ -2,6 +2,7 @@ using System.Collections; using Animancer; using BaseGames.Boss; using BaseGames.Combat; +using BaseGames.Enemies.Abilities; using UnityEngine; namespace BaseGames.Enemies.Boss @@ -79,7 +80,8 @@ namespace BaseGames.Enemies.Boss { _inKnockdown = true; - _boss.GetComponentInChildren()?.InterruptCurrentSkill(); + // 击落打断当前正在执行的所有能力(招式统一由能力注册表管理) + _boss.Abilities.InterruptAll(InterruptReason.ExternalRequest); if (_knockdownHitClip.Clip != null) _boss.Animancer.Play(_knockdownHitClip); diff --git a/Assets/_Game/Scripts/Enemies/Boss/Patterns/BaseGames.Enemies.Boss.Patterns.asmdef b/Assets/_Game/Scripts/Enemies/Boss/Patterns/BaseGames.Enemies.Boss.Patterns.asmdef deleted file mode 100644 index 757971ee..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/Patterns/BaseGames.Enemies.Boss.Patterns.asmdef +++ /dev/null @@ -1,19 +0,0 @@ -{ - "excludePlatforms": [], - "allowUnsafeCode": false, - "precompiledReferences": [], - "name": "BaseGames.Enemies.Boss.Patterns", - "defineConstraints": [], - "noEngineReferences": false, - "versionDefines": [], - "rootNamespace": "BaseGames.Enemies.Boss.Patterns", - "references": [ - "BaseGames.Core", - "BaseGames.Core.Events", - "BaseGames.Enemies", - "BaseGames.Combat" - ], - "autoReferenced": true, - "overrideReferences": false, - "includePlatforms": [] -} diff --git a/Assets/_Game/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs b/Assets/_Game/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs deleted file mode 100644 index 64e2991c..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Collections; -using UnityEngine; -using BaseGames.Core; -using BaseGames.Core.Pool; - -namespace BaseGames.Enemies.Boss.Patterns -{ - /// - /// 攻击预警系统(架构 07_EnemyModule §11)。 - /// 在攻击前若干帧显示视觉提示(VFX 从对象池取出,到期归还)。 - /// 由 BD_TelegraphAttack 通过协程调用 ShowTelegraph。 - /// - public class TelegraphSystem : MonoBehaviour - { - /// - /// 开始预警:从对象池取出 vfxKey 对应预警 VFX,等待 duration 秒后归还。 - /// 由 BD_TelegraphAttack.OnStart 通过 StartCoroutine 调用。 - /// - public IEnumerator ShowTelegraph(string vfxKey, float duration, Vector2 position) - { - if (string.IsNullOrEmpty(vfxKey) || duration <= 0f) - { - yield return null; - yield break; - } - - GameObject vfx = null; - var pool = ServiceLocator.GetOrDefault(); - if (pool != null) - vfx = pool.Spawn(vfxKey, new Vector3(position.x, position.y, 0f), Quaternion.identity); - else - Debug.LogWarning($"[TelegraphSystem] IObjectPoolService 未就绪,预警 VFX '{vfxKey}' 无法显示。"); - - yield return new WaitForSeconds(duration); - - if (vfx != null && pool != null) - { - var po = vfx.GetComponent(); - if (po != null) po.ReturnToPool(); - else vfx.SetActive(false); - } - } - - /// 立即隐藏所有活跃预警 VFX(技能被打断时调用)。 - public void CancelTelegraph() - { - StopAllCoroutines(); - } - } -} diff --git a/Assets/_Game/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs.meta deleted file mode 100644 index 7c15784a..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: e6f4987894dfe1648909b6863c003c31 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/SkillSequenceSO.cs b/Assets/_Game/Scripts/Enemies/Boss/SkillSequenceSO.cs deleted file mode 100644 index 978598e4..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/SkillSequenceSO.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using UnityEngine; - -namespace BaseGames.Boss -{ - /// - /// 有序攻击序列(一个技能内的多段连段)。 - /// - [CreateAssetMenu(menuName = "BaseGames/Boss/SkillSequence")] - public class SkillSequenceSO : ScriptableObject - { - [Serializable] - public struct SequenceStep - { - public AttackPatternSO pattern; - [Min(0f)] public float delayBeforeStep; - } - - public SequenceStep[] steps; - - [Header("序列完成后的行为")] - public bool RepeatIfPlayerInRange; - [Min(0f)] public float RepeatDelay; - [Range(0, 10)] public int MaxRepeatCount; - } -} diff --git a/Assets/_Game/Scripts/Enemies/Boss/SkillSequenceSO.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/SkillSequenceSO.cs.meta deleted file mode 100644 index 8a1169fc..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/SkillSequenceSO.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: ab2ec01e225283d4face08cef0d72c87 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/Boss/WeakPointSystem.cs b/Assets/_Game/Scripts/Enemies/Boss/WeakPointSystem.cs deleted file mode 100644 index d89df133..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/WeakPointSystem.cs +++ /dev/null @@ -1,51 +0,0 @@ -using UnityEngine; -using BaseGames.Combat; -using BaseGames.Core.Events; - -namespace BaseGames.Boss -{ - /// - /// 管理 Boss 的专属弱点 HurtBox(如核心、眼睛等)。 - /// 弱点激活期间受到的伤害会乘以 DamageMultiplier。 - /// - public class WeakPointSystem : MonoBehaviour - { - [System.Serializable] - public struct WeakPoint - { - public HurtBox hurtBox; - public GameObject visualIndicator; - } - - [SerializeField] private WeakPoint[] _weakPoints; - [SerializeField] private string _bossId; - [SerializeField] private StringEventChannelSO _onVulnerabilityWindowOpened; - - private float _damageMultiplier = 1f; - - /// 激活或关闭弱点 HurtBox 及视觉指示器。 - /// 是否激活。 - /// 激活时的受击伤害乘数。 - /// true = 仅激活弱点专属 HurtBox;false = 全身视为弱点(不改变 HurtBox 状态)。 - public void SetActive(bool active, float multiplier = 1f, bool activateSpecific = false) - { - _damageMultiplier = active ? multiplier : 1f; - - if (activateSpecific) - { - foreach (var wp in _weakPoints) - { - wp.hurtBox.gameObject.SetActive(active); - if (wp.visualIndicator != null) - wp.visualIndicator.SetActive(active); - } - } - - if (active) - _onVulnerabilityWindowOpened?.Raise(_bossId); - } - - /// 弱点 HurtBox 受击时,由 BossStats 调用此方法获取最终伤害系数。 - public float GetDamageMultiplier() => _damageMultiplier; - } -} diff --git a/Assets/_Game/Scripts/Enemies/Boss/WeakPointSystem.cs.meta b/Assets/_Game/Scripts/Enemies/Boss/WeakPointSystem.cs.meta deleted file mode 100644 index 2c79579d..00000000 --- a/Assets/_Game/Scripts/Enemies/Boss/WeakPointSystem.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 96ffe91642a6ccc4ea4c6076d80f5e27 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs b/Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs index 0e8ebca4..6d88953a 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyAnimationConfigSO.cs @@ -33,7 +33,7 @@ namespace BaseGames.Enemies public AnimationClip Investigate; /// - /// 按字段名返回 Clip(BD_PlayAnimation 使用)。 + /// 按字段名返回 Clip(AI 状态片段与能力模块按名取动画时使用)。 /// 支持字段名和常见别名(如 Attack_Melee / Idle 等)。 /// public AnimationClip GetClipByName(string name) diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs index 6ed64367..aa6a7f44 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs @@ -11,8 +11,8 @@ namespace BaseGames.Enemies { /// /// 敌人基类(架构 07_EnemyModule §1)。 - /// 实现 IDamageable,为 Behavior Designer 任务提供统一虚方法接口。 - /// 包含:BD 接口、受击、死亡流程。 + /// 实现 IDamageable,为 AI 决策层提供统一虚方法接口。 + /// 包含:决策层接口、受击、死亡流程。 /// ⚠️ _nav 字段类型为 IEnemyNavigator(在 BaseGames.Enemies.Navigation 中实现具体类)。 /// 实现 IPoolable:配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。 /// @@ -49,7 +49,7 @@ namespace BaseGames.Enemies [SerializeField] private Collider2D _bodyCollider; [Header("区域(可选)")] - [Tooltip("地图固定巡逻/追击区域;配置后 BD_ChasePlayer 以区域边界替代 MaxChaseDistance,BD_ReturnToHome 归位至区域中心。留空则沿用出生点 + MaxChaseDistance 旧逻辑。")] + [Tooltip("地图固定巡逻/追击区域;配置后追击以区域边界替代 MaxChaseDistance,归位至区域中心。留空则沿用出生点 + MaxChaseDistance 旧逻辑。")] [SerializeField] private EnemyPatrolZone _patrolZone; [Header("事件频道")] @@ -97,18 +97,18 @@ namespace BaseGames.Enemies public EnemyStateType CurrentState => _currentState; // ── 导航语义(归位 / 搜查)──────────────────────────────────────────── - /// Awake/Start 时记录的初始世界坐标,供 BD_ReturnToHome 归位使用。 + /// Awake/Start 时记录的初始世界坐标,供归位行为使用。 public Vector2 HomePosition { get; private set; } /// /// 玩家最后一次可见的世界坐标。 - /// 由 BD_ChasePlayer 在持有视线时每帧更新;视线丢失后保留最后记录值供 BD_InvestigateLastKnown 使用。 + /// 由追击行为在持有视线时每帧更新;视线丢失后保留最后记录值供「搜查最后已知位置」行为使用。 /// public Vector2 LastKnownPlayerPosition { get; set; } /// /// 地图固定巡逻/追击区域(可选)。 - /// 配置后 BD_ChasePlayer 以区域边界为追击上限;BD_ReturnToHome 归位至区域中心。 + /// 配置后追击以区域边界为上限;归位至区域中心。 /// 未配置时退回旧逻辑(HomePosition + MaxChaseDistance)。 /// public EnemyPatrolZone PatrolZone => _patrolZone; @@ -179,6 +179,8 @@ namespace BaseGames.Enemies } else { + // 存储硬直时长供 EnemyHurtState 在无受击动画时使用 + _pendingHitStun = info.HitStunDuration; nextState = EnemyStateType.Hurt; reason = InterruptReason.Hurt; } @@ -200,17 +202,37 @@ namespace BaseGames.Enemies private Vector2 _pendingLaunchDir; /// - /// 协程兜底:在无对应 Animancer 动画时按时长自动恢复到 Controlled 状态。 - /// 仅在 AnimConfig 对应 Clip 为 null 时由状态类调用。 + /// 本次受击的硬直时长(由 TakeDamage / Knockback 从 写入, + /// 供 在无受击动画时作为恢复时长)。 + /// 权威在攻击方:同一敌人挨重击比挨轻击僵得久。有受击动画时以动画长度为准。 /// - public void ScheduleStateRecovery(EnemyStateType fromState, float delay) + internal float PendingHitStun => _pendingHitStun; + private float _pendingHitStun; + + /// + /// 在无对应 Animancer 动画时按时长自动恢复到 Controlled 状态。 + /// 由受击类状态(Hurt / Stagger / KnockUp)在其 Clip 为 null 时调用—— + /// 缺了它,敌人会永久停在该态,IsControllable 恒假、决策层永久停摆。 + /// + /// 同一时刻只保留一个待恢复安排:后一次调用取消前一次。 + /// 否则两次重叠安排里较短的那个会先把状态改回 Controlled, + /// 静默缩短调用方本想要的时长(如弹反惩罚窗口)。 + /// + /// virtual:供 EditMode 测试替换掉协程,直接断言"是否安排了恢复"。 + /// + public virtual void ScheduleStateRecovery(EnemyStateType fromState, float delay) { - StartCoroutine(StateRecoveryRoutine(fromState, delay)); + if (_stateRecovery != null) StopCoroutine(_stateRecovery); + _stateRecovery = StartCoroutine(StateRecoveryRoutine(fromState, delay)); } + private Coroutine _stateRecovery; + private System.Collections.IEnumerator StateRecoveryRoutine(EnemyStateType fromState, float delay) { - yield return new WaitForSeconds(delay); + if (delay > 0f) yield return new WaitForSeconds(delay); + else yield return null; // 时长为 0 = 不硬直,下一帧即恢复 + _stateRecovery = null; if (_currentState == fromState) ForceState(EnemyStateType.Controlled); } @@ -222,7 +244,7 @@ namespace BaseGames.Enemies public IEnemyLocomotion Locomotion => _locomotion ?? (_locomotion = GetComponentInChildren(true)); public EnemyMovement Movement => _movement; public EnemyStats Stats => _stats; - /// 敌人配置 SO,供 BD Task / 状态对象读取配置数据(如 knockUpDuration)。 + /// 敌人配置 SO,供 AI 决策层 / 状态对象读取配置数据(如 knockUpDuration)。 public EnemyStatsSO StatsSO => _statsSO; public AnimancerComponent Animancer => _animancer; public EnemyAnimationConfigSO AnimConfig => _animConfig; @@ -234,9 +256,9 @@ namespace BaseGames.Enemies private Abilities.EnemyAttackSelector _attackSelector; /// 身体碰撞体几何的唯一权威查询口。移动/导航/能力一律经此获取身体尺寸。 public IEnemyBody Body => _body ??= new EnemyBody(gameObject, _bodyCollider); - /// 由 _onPlayerSpawned 事件缓存的玩家 Transform,供 BD 任务读取。 + /// 由 _onPlayerSpawned 事件缓存的玩家 Transform,供 AI 决策层读取。 public Transform PlayerTransform => _playerTransform; - /// 感知 Hub;供 BD 任务及 QuotaManager 暂停/恢复感知使用。 + /// 感知 Hub;供 AI 决策层及 QuotaManager 暂停/恢复感知使用。 public Perception.IPerceptionSystem SensorHub => _sensorHub; private Perception.IPerceptionSystem _sensorHub; /// 威胁评估器(可选):为原始 LOS 结果叠加反应延迟,使感知更自然。 @@ -253,7 +275,7 @@ namespace BaseGames.Enemies public bool IsEngaged { get; private set; } public void SetEngaged(bool engaged) => IsEngaged = engaged; - // ── BD 行为树接口(虚方法)──────────────────────────────────────── + // ── AI 决策层接口(虚方法)────────────────────────────────────── public virtual void MoveTo(Vector2 target) => _nav?.MoveTowards(target); @@ -385,6 +407,7 @@ namespace BaseGames.Enemies if (info.Flags.HasFlag(DamageFlags.NoKnockback)) return; _movement?.ApplyKnockback(info.KnockbackDirection, info.KnockbackForce); // 统一路径:击退必须经过状态机,确保能力被中断且动画一致。 + _pendingHitStun = info.HitStunDuration; // 无受击动画时的恢复时长 ForceState(EnemyStateType.Hurt); _abilities.InterruptAll(InterruptReason.Hurt); } @@ -402,37 +425,16 @@ namespace BaseGames.Enemies } // ── 弹反(Parry)响应 ────────────────────────────────────────────── - private bool _wasParried; - private float _parryTimestamp; - // BD 树因阶段切换/死亡未能消费弹反事件时,超过此时长自动过期 - private const float ParryEventTTL = 2f; /// - /// 消费弹反事件标志(读取并清除)。 - /// BD_OnParried Conditional Task 在每次 Tick 时调用此方法。 - /// 若 BD 树因阶段切换或死亡未能及时消费,超过 TTL 后自动过期返回 false。 - /// - public bool ConsumeParryEvent() - { - if (!_wasParried) return false; - if (Time.time - _parryTimestamp > ParryEventTTL) - { - _wasParried = false; - return false; - } - _wasParried = false; - return true; - } - - /// - /// 被弹反时调用:强制进入 Stagger 状态并在 秒后恢复。 - /// 由近战攻击碰到玩家弹反框时触发(例如 BossParryDetector 或通用 ParryDetector)。 + /// 被弹反时调用:强制进入 Stagger 并在 staggerDuration 秒后恢复。 + /// Stagger 期间 IsControllable 为 false,AI 决策自动让位——不需要额外的 AI 信号。 + /// ⚠️ 目前**零调用者**:弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用, + /// 接线是一件独立任务(DamageInfo 加 attacker,或弹反成功时回调发起攻击的 HitBox)。 /// public virtual void ReceiveParry(float staggerDuration = 0.5f) { if (!IsAlive) return; - _wasParried = true; - _parryTimestamp = Time.time; ForceState(EnemyStateType.Stagger); _abilities.InterruptAll(InterruptReason.Stagger); ScheduleStateRecovery(EnemyStateType.Stagger, staggerDuration); @@ -601,7 +603,12 @@ namespace BaseGames.Enemies candidates.Add(ab); } } - _attackSelector = new Abilities.EnemyAttackSelector(candidates); + // 缺 StatsSO 已由本类其它处显式报错,这里的三目只是避免空引用; + // 真正的业务缺省是 SO 字段自身的初值。 + float antiRepeat = _statsSO != null + ? _statsSO.attackAntiRepeatFactor + : BaseGames.Enemies.Abilities.EnemyAttackSelector.DefaultAntiRepeatFactor; + _attackSelector = new Abilities.EnemyAttackSelector(candidates, antiRepeat); } protected virtual void Update() @@ -621,7 +628,7 @@ namespace BaseGames.Enemies protected virtual void Start() { - // 记录出生位置,供 BD_ReturnToHome 归位使用 + // 记录出生位置,供归位行为使用 HomePosition = transform.position; LastKnownPlayerPosition = transform.position; @@ -654,11 +661,11 @@ namespace BaseGames.Enemies protected virtual void OnDestroy() { } /// - /// 通知决策层终止(死亡演出 / 出场演出等期间调用,防止决策继续覆盖演出逻辑)。 - /// 语义为"通知决策层终止",与 PerformDeath 的 Died 信号幂等(重复 Died 无害)。 + /// 通知决策层终止(死亡演出 / 出场演出期间调用,防止决策继续覆盖演出逻辑)。 + /// 与 PerformDeath 发的 Died 信号幂等(重复 Died 无害)。 /// 供配置型行为组件(如 EnemyDeathSequence)调用,故为 public。 /// - public void StopBehaviorTree() + public void NotifyDecisionStop() { _brain?.Send(BaseGames.AI.AiSignal.Died); } @@ -743,19 +750,27 @@ namespace BaseGames.Enemies // 重置状态(对象池复用:跳过 Dead 终态守卫,强制恢复到 Controlled) ForceStateRespawn(EnemyStateType.Controlled); IsEngaged = false; + // 上一条命若在受击恢复途中被回收,协程已随物体停用而终止, + // 这里清掉句柄,避免跨池化周期持有一个已死的 Coroutine 引用 + _stateRecovery = null; // 重置对象池复用相关的运行时感知数据 // 注意:_playerTransform 不重置(场景中玩家仍存在),只重置追踪历史 LastKnownPlayerPosition = transform.position; - _wasParried = false; _deathSequenceActive = false; // 重置生命值 if (_stats != null && _statsSO != null) _stats.Initialize(_statsSO); - // 重置能力冷却 + // 上一条命若在出招途中被回收,OnDespawn 与组件 OnDisable 已经中断过;此处为兜底中断。 + // 它**不**负责重置冷却:两侧的 IsRunning 门让它在出生时恒为空转(曾被误注释为"重置能力冷却")。 _abilities.InterruptAll(InterruptReason.Dead); + // 冷却以绝对时间记,必须显式清零,否则新生的敌人带着上一条命的剩余冷却出不了招 + _abilities.ResetAllCooldowns(); + + // 对象池复用不得继承上一条命的防重复记忆(否则新生即带着上次的招被折扣) + _attackSelector?.ResetRepeatMemory(); // 重置决策层(回到 Entry、清临时态) if (_brain != null) _brain.enabled = true; // 复用时恢复决策组件(可能曾被 QuotaManager 裁剪禁用) diff --git a/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs b/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs index 89f52dfd..f1478caa 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyDebugOverlay.cs @@ -11,7 +11,7 @@ namespace BaseGames.Enemies /// - AI 决策状态 / EnemyStateType /// - 当前激活的能力名称及运行状态 /// - LOS 状态 / 到玩家距离 - /// - Boss:技能执行状态 + 各技能冷却倒计时 + /// - Boss:当前阶段与阶段过渡状态 /// [AddComponentMenu("BaseGames/Debug/Enemy Debug Overlay")] public sealed class EnemyDebugOverlay : MonoBehaviour @@ -110,7 +110,9 @@ namespace BaseGames.Enemies // Boss 信息 if (_boss != null) { - list.Add($"BossSkillExec: {_boss.IsBossSkillExecuting}"); + // 上面第 2 行已用 "Phase:" 表示 AI 决策状态名,这里用 "BossPhase:" 区分,避免同名两行 + list.Add($"BossPhase: {_boss.CurrentPhase}" + + (_boss.IsPhaseTransitioning ? " (transitioning)" : "")); } // 状态效果 diff --git a/Assets/_Game/Scripts/Enemies/EnemyPoiseComponent.cs b/Assets/_Game/Scripts/Enemies/EnemyPoiseComponent.cs index 035b8254..9e2bafbf 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyPoiseComponent.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyPoiseComponent.cs @@ -6,7 +6,7 @@ namespace BaseGames.Enemies /// /// 敌人霸体组件(架构 06_CombatModule §13)。 /// Awake 时自动注入到同节点 HurtBox(无需手动 Inspector 赋值)。 - /// EnemyBase 或 BehaviorDesigner 任务可调用 SetPoiseLevel / ResetPoiseLevel 切换超甲状态。 + /// EnemyBase、能力模块或动画事件可调用 SetPoiseLevel / ResetPoiseLevel 切换超甲状态。 /// [RequireComponent(typeof(EnemyBase))] public class EnemyPoiseComponent : MonoBehaviour, IPoiseSource @@ -31,7 +31,7 @@ namespace BaseGames.Enemies // ── 外部 API ────────────────────────────────────────────────────────── - /// 进入/退出超甲时由 EnemyBase 或 BehaviorDesigner 任务调用。 + /// 进入/退出超甲时由 EnemyBase、能力模块或动画事件调用。 public void SetPoiseLevel(PoiseLevel level) => _currentPoiseLevel = level; /// 恢复到默认霸体等级(超甲结束时调用)。 diff --git a/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs b/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs index f10fdcab..8b106edd 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyQuotaManager.cs @@ -5,14 +5,16 @@ using BaseGames.Core.Events; namespace BaseGames.Enemies { /// - /// 敌人 BehaviorTree 配额管理器(架构 07_EnemyModule §13)。 + /// 敌人决策层配额管理器(架构 07_EnemyModule §13)。 /// 每 REBALANCE_INTERVAL 帧,按到玩家距离排序已注册敌人, - /// 仅对最近的 _maxActiveBehaviorTrees 个敌人启用 BT。 + /// 仅对最近的 _maxActiveBrains 个敌人启用 ,其余暂停决策与感知。 /// 挂载在场景管理器 GameObject 上(每场景一个实例)。 /// public class EnemyQuotaManager : MonoBehaviour { - [SerializeField, Min(1)] private int _maxActiveBehaviorTrees = 12; + [Tooltip("同时保持决策活跃的敌人上限;超出的按距离由远及近暂停")] + [UnityEngine.Serialization.FormerlySerializedAs("_maxActiveBehaviorTrees")] + [SerializeField, Min(1)] private int _maxActiveBrains = 12; [Tooltip("PlayerController.Start() 广播此频道,替代 FindWithTag")] [SerializeField] private TransformEventChannelSO _onPlayerSpawned; @@ -107,11 +109,11 @@ namespace BaseGames.Enemies if (enemy == null) { _registered.RemoveAt(i); continue; } var brain = enemy.Brain; - bool active = i < _maxActiveBehaviorTrees; + bool active = i < _maxActiveBrains; if (brain != null && brain.enabled != active) { - // 禁用 brain 即停其 Update/Tick,等价于旧的禁用行为树。 + // 禁用 brain 即停其 Update/Tick,敌人保持静止但仍存在于场景中。 brain.enabled = active; // 同步暂停/恢复感知系统,避免远处敌人无效 tick enemy.SensorHub?.SetSuspended(!active); diff --git a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs index c8761222..c7882ee4 100644 --- a/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs +++ b/Assets/_Game/Scripts/Enemies/EnemyStatsSO.cs @@ -18,6 +18,9 @@ namespace BaseGames.Enemies [Min(0f)] public float launchHorzForce; [Tooltip("无 KnockUp 动画时的状态持续时长(s)")] [Min(0f)] public float knockUpDuration; + [Tooltip("无 Stagger 动画时的僵直持续时长(s);0 = 用默认 0.5s。" + + "受击(Hurt)的时长不在这里——它取攻击方 DamageSourceSO 的 HitStunDuration")] + [Min(0f)] public float staggerDuration; } /// @@ -44,6 +47,11 @@ namespace BaseGames.Enemies public BaseGames.Enemies.Abilities.AttackSelectionMode attackSelectionMode = BaseGames.Enemies.Abilities.AttackSelectionMode.WeightedRandom; + [Tooltip("WeightedRandomAntiRepeat 模式下,上一次选中的招的权重折扣系数。" + + "0 = 有替代招时绝不连续重复;1 = 不惩罚。仅该模式生效")] + [Range(0f, 1f)] public float attackAntiRepeatFactor + = BaseGames.Enemies.Abilities.EnemyAttackSelector.DefaultAntiRepeatFactor; + [Header("追击 & AI 阶段")] [Tooltip("是否有警觉状态:勾选=未发现态进入视野先进警觉(朝向+警觉动画)再追击;不勾选=进入追逐感知直接追击")] public bool HasAlertState = true; diff --git a/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs b/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs index e5f5c34e..c4e57c16 100644 --- a/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs +++ b/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs @@ -8,7 +8,7 @@ namespace BaseGames.Enemies /// 飞行敌人基类。 /// /// 导航由 实现(IEnemyNavigator), - /// AI 行为逻辑由挂载的 Behavior Designer 树驱动。 + /// AI 决策由挂载的 (BrainGraph)驱动。 /// 本类仅负责: /// /// Rigidbody2D 无重力初始化 diff --git a/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs index d2334000..1996644c 100644 --- a/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs +++ b/Assets/_Game/Scripts/Enemies/Perception/SensorSlotNames.cs @@ -16,7 +16,7 @@ namespace BaseGames.Enemies.Perception /// /// 视线检测(BatchLOS):敌我之间无遮挡时持续为 true。 - /// 由 PhysicsPerceptionSystem 自研批量计算(OverlapCircle + 单次 Raycast),BD_IsPlayerVisible 读取结果。 + /// 由 PhysicsPerceptionSystem 批量计算(OverlapCircle + 单次 Raycast),AI 图的可见性条件读取结果。 /// public const string LOS = "los"; diff --git a/Assets/_Game/Scripts/Enemies/RangedEnemy.cs b/Assets/_Game/Scripts/Enemies/RangedEnemy.cs index 9ccfd679..a7f0bb02 100644 --- a/Assets/_Game/Scripts/Enemies/RangedEnemy.cs +++ b/Assets/_Game/Scripts/Enemies/RangedEnemy.cs @@ -7,7 +7,7 @@ namespace BaseGames.Enemies { /// /// 远程敌人。重写 以从对象池生成抛射物。 - /// Behavior Designer 任务通过调用 SpawnProjectile(payload) 触发发射。 + /// 能力模块或动画事件通过调用 SpawnProjectile(payload) 触发发射。 /// public class RangedEnemy : EnemyBase { diff --git a/Assets/_Game/Scripts/Enemies/States/EnemyControlledState.cs b/Assets/_Game/Scripts/Enemies/States/EnemyControlledState.cs index f8f80827..95bc5657 100644 --- a/Assets/_Game/Scripts/Enemies/States/EnemyControlledState.cs +++ b/Assets/_Game/Scripts/Enemies/States/EnemyControlledState.cs @@ -2,7 +2,7 @@ namespace BaseGames.Enemies.States { /// /// 受控状态(默认行为)。 - /// 敌人处于 AI 正常控制下(Behavior Designer 运行中),无特殊 Enter/Exit 动画逻辑。 + /// 敌人处于 AI 正常控制下(决策层运行中),无特殊 Enter/Exit 动画逻辑。 /// public sealed class EnemyControlledState : IEnemyState { diff --git a/Assets/_Game/Scripts/Enemies/States/EnemyHurtState.cs b/Assets/_Game/Scripts/Enemies/States/EnemyHurtState.cs index f25831f8..1ad3fe30 100644 --- a/Assets/_Game/Scripts/Enemies/States/EnemyHurtState.cs +++ b/Assets/_Game/Scripts/Enemies/States/EnemyHurtState.cs @@ -2,7 +2,8 @@ namespace BaseGames.Enemies.States { /// /// 受击状态。播放受击动画,动画结束后自动回到 Controlled。 - /// 原 EnemyBase.ForceState 中的 Hurt if-else 逻辑已迁移至此。 + /// 无受击动画时按 (来自攻击方的硬直时长)恢复—— + /// 二者必须都安排恢复,否则敌人会永久停在本态,决策层被 IsControllable 门永久挂起。 /// public sealed class EnemyHurtState : IEnemyState { @@ -10,15 +11,22 @@ namespace BaseGames.Enemies.States public void Enter(EnemyBase owner) { - if (owner.Animancer == null || owner.AnimConfig?.Hurt == null) return; - - var animState = owner.Animancer.Play(owner.AnimConfig.Hurt); - animState.Events(owner).OnEnd = () => + if (owner.Animancer != null && owner.AnimConfig?.Hurt != null) { - // 只在仍处于 Hurt 时才回 Controlled,避免 Die 时被覆盖 - if (owner.CurrentState == EnemyStateType.Hurt) - owner.ForceState(EnemyStateType.Controlled); - }; + var animState = owner.Animancer.Play(owner.AnimConfig.Hurt); + animState.Events(owner).OnEnd = () => + { + // 只在仍处于 Hurt 时才回 Controlled,避免 Die 时被覆盖 + if (owner.CurrentState == EnemyStateType.Hurt) + owner.ForceState(EnemyStateType.Controlled); + }; + } + else + { + // 无受击动画:按本次攻击的硬直时长恢复。 + // 时长为 0 是合法的"不硬直",由 ScheduleStateRecovery 处理为下一帧恢复。 + owner.ScheduleStateRecovery(EnemyStateType.Hurt, owner.PendingHitStun); + } } public void Exit(EnemyBase owner) { } diff --git a/Assets/_Game/Scripts/Enemies/States/EnemyStaggerState.cs b/Assets/_Game/Scripts/Enemies/States/EnemyStaggerState.cs index 4b08c01c..fa42820a 100644 --- a/Assets/_Game/Scripts/Enemies/States/EnemyStaggerState.cs +++ b/Assets/_Game/Scripts/Enemies/States/EnemyStaggerState.cs @@ -3,21 +3,35 @@ namespace BaseGames.Enemies.States /// /// 僵直状态(霸体耗尽时触发)。 /// 播放 Stagger 动画(若配置),动画结束后自动回到 Controlled。 + /// 无僵直动画时按 的 staggerDuration 恢复—— + /// 二者必须都安排恢复,否则敌人会永久停在本态,决策层被 IsControllable 门永久挂起。 /// public sealed class EnemyStaggerState : IEnemyState { + /// 未配置 staggerDuration 时的兜底时长(与 KnockUp 同规格)。 + private const float DefaultStaggerDuration = 0.5f; + public EnemyStateType StateType => EnemyStateType.Stagger; public void Enter(EnemyBase owner) { - if (owner.Animancer == null || owner.AnimConfig?.Stagger == null) return; - - var animState = owner.Animancer.Play(owner.AnimConfig.Stagger); - animState.Events(owner).OnEnd = () => + if (owner.Animancer != null && owner.AnimConfig?.Stagger != null) { - if (owner.CurrentState == EnemyStateType.Stagger) - owner.ForceState(EnemyStateType.Controlled); - }; + var animState = owner.Animancer.Play(owner.AnimConfig.Stagger); + animState.Events(owner).OnEnd = () => + { + if (owner.CurrentState == EnemyStateType.Stagger) + owner.ForceState(EnemyStateType.Controlled); + }; + } + else + { + // 无僵直动画:按配置时长恢复。僵直是给玩家的惩罚窗口, + // 时长必须为正,否则等同于霸体被破却没有任何回报。 + float cfg = owner.StatsSO?.HitTiers.staggerDuration ?? 0f; + float duration = cfg > 0f ? cfg : DefaultStaggerDuration; + owner.ScheduleStateRecovery(EnemyStateType.Stagger, duration); + } } public void Exit(EnemyBase owner) { } diff --git a/Docs/Architecture/00_CoverageIndex.md b/Docs/Architecture/00_CoverageIndex.md index 506cd5c7..99e75e64 100644 --- a/Docs/Architecture/00_CoverageIndex.md +++ b/Docs/Architecture/00_CoverageIndex.md @@ -53,7 +53,7 @@ | 16 | MapSystem | 15_MapShopModule §1 | ✅ | 完整 | | 17 | EquipmentSystem | 09_ProgressionModule | ✅ | 完整 | | 18 | CutsceneSystem | 14_NarrativeModule §7 | ✅ | CutsceneTrigger/CutsceneSO 已定义 | -| 19 | BossPatternLibrary | **23_BossSkillModule** | ✅ | BossSkillSO/SkillSequenceSO/WeakPointSystem 全部定义 | +| 19 | BossPatternLibrary | **07_EnemyModule** · `Docs/Guides/09_BossAi_Authoring_Guide` | ⚠️ | 旧 Boss 技能轨(BossSkillSO/SkillSequenceSO/WeakPointSystem)已于 2026-07-30 整体退役,Boss 归并到敌人能力轨。Design 19 仍是设计意图文档,实现见作者指南 | | 20 | AnimationEventSystem | **24_AnimEventModule** | ✅ | PlayerAnimationEvents/EnemyAnimationEvents/AnimationEventBinder 全部定义 | | 21 | SpellSystem | 09_ProgressionModule | ✅ | 完整 | | 22 | LocalizationSystem | 16_SupportingModules §1 | ✅ | 完整 | @@ -81,7 +81,7 @@ | 44 | LevelDesignGuide | — | 📖 | 关卡设计规范,无需 Architecture | | 45 | TutorialSystem | **21_LiquidPuzzleModule** §Tutorial | ✅ | TutorialManager/ContextualHintTrigger 已定义 | | 46 | PlatformIntegration | 16_SupportingModules §3 | ✅ | IPlatformService/SteamPlatformService/NullPlatformService(ServiceLocator 模式)已定义 | -| 47 | BossSkillSystem | **23_BossSkillModule** | ✅ | BossSkillSO/SkillSequenceSO/BossSkillExecutor/WeakPointSystem 全部定义 | +| 47 | ~~BossSkillSystem~~ | — | ❌ | 已废止:Design 47 与 Architecture 23 于 2026-07-30 一并删除。Boss 与小怪统一走 `EnemyAbilitySO` + `EnemyAiBrain`,写法见 `Docs/Guides/09_BossAi_Authoring_Guide.md`,设计依据见 `Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md` | | 48 | EnemyRoster | — | 📖 | 敌人名单(策划),无需 Architecture | | 49 | AntiSoftlockSystem | 16_SupportingModules §5 | ✅ | 完整 | | 50 | NarrativeDesignSystem | — | 📖 | 叙事结构设计,无需 Architecture | @@ -130,7 +130,7 @@ | [20_ShieldModule.md](20_ShieldModule.md) | 30 | ShieldComponent、ShieldConfigSO、IShieldable、伤害管道修正 | | [21_LiquidPuzzleModule.md](21_LiquidPuzzleModule.md) | 40、35、36、45 | LiquidZone、SwimState、PuzzleSwitch/Receiver/Wire、WorldMarker、TutorialManager | | [22_QuestChallengeModule.md](22_QuestChallengeModule.md) | 38、39 | QuestSO、QuestManager、QuestGiver、ChallengeRoomSO、ChallengeRoomManager | -| [23_BossSkillModule.md](23_BossSkillModule.md) | 47、19 | BossSkillSO、SkillSequenceSO、BossSkillExecutor、WeakPointSystem | +| ~~23_BossSkillModule.md~~ | — | 已于 2026-07-30 删除(旧 Boss 技能轨退役)。Boss 实现并入 07_EnemyModule 的能力体系,作者指南见 `Docs/Guides/09_BossAi_Authoring_Guide.md` | | [24_AnimEventModule.md](24_AnimEventModule.md) | 20 | PlayerAnimationEvents、EnemyAnimationEvents、AnimationEventBinder | --- @@ -163,7 +163,7 @@ | [16_SupportingModules.md](16_SupportingModules.md) | **§1.1** LanguageManagerSO(SO 单例,PlayerPrefs 持久化,SetLocale/GetCurrentLocaleCode/LoadSavedLocale 三方法);与静态 LocalizationManager 职责对比说明(文本查询 vs 语言切换+持久化) | ✅ 已应用 | | [12_SaveModule.md](12_SaveModule.md) | EquipmentSaveData 移除重复工具字段(ToolSlot0/ToolSlot1/OwnedToolIds/ToolStates)——这些字段仅属于 ToolsSaveData(顶层 SaveData.Tools);添加注释说明归属 | ✅ 已应用 | | [09_ProgressionModule.md](09_ProgressionModule.md) | ToolSlotManager.OnSave() 数据写入路径从 `data.Equipment.ToolSlotX` 修正为 `data.Tools.ToolSlotX`;OnLoad 注释同步为"从 data.Tools 恢复" | ✅ 已应用 | -| [23_BossSkillModule.md](23_BossSkillModule.md) | BossSkillSO 补全 `attackPatterns: AttackPatternSO[]`、`counterResponses: PlayerCounterResponse[]`、`arenaEvents: ArenaEventTrigger[]`、`resourceCost: BossResourceCost`、`buildsRage: bool`、`poiseWindow: PoiseWindowConfig` 六个缺失字段;新增 §4.1 PlayerCounterResponse + CounterType 枚举、§4.2 ArenaEventTrigger/ArenaEventType/ArenaEventParams/ArenaEventData/IArenaInteractable、§4.3 BossResourceCost + BossResourceConfigSO 定义 | ✅ 已应用 | +| `23_BossSkillModule.md`(该文档已于 2026-07-30 删除,本行仅存历史) | BossSkillSO 补全 `attackPatterns: AttackPatternSO[]`、`counterResponses: PlayerCounterResponse[]`、`arenaEvents: ArenaEventTrigger[]`、`resourceCost: BossResourceCost`、`buildsRage: bool`、`poiseWindow: PoiseWindowConfig` 六个缺失字段;新增 §4.1 PlayerCounterResponse + CounterType 枚举、§4.2 ArenaEventTrigger/ArenaEventType/ArenaEventParams/ArenaEventData/IArenaInteractable、§4.3 BossResourceCost + BossResourceConfigSO 定义 | ✅ 已应用 | | [16_SupportingModules.md](16_SupportingModules.md) | §2 AchievementManager 完全重写(对齐 Design/32 SO 策略模式):AchievementDef/AchievementDatabaseSO 替换为 AchievementSO + AchievementType/AchievementTier 枚举 + AchievementCondition 抽象基类(RegisterListeners/UnregisterListeners/IsMet)+ 内置 12 种条件类型表 + DefeatedBossCondition 示例 + AchievementManager(_allAchievements: AchievementSO[],中继 C# 事件,EvaluateAll/Unlock)+ AchievementRuntimeState | ✅ 已应用 | | [24_AnimEventModule.md](24_AnimEventModule.md) | `IAnimEventReceiver` 重命名为 `IAnimationEventHandler`;方法签名 `OnAnimationEvent(type, data)` 改为 `HandleEvent(type, payload)`;AnimationEventBinder.Bind() 参数类型、PlayerAnimationEvents/EnemyAnimationEvents 实现声明及方法体内 payload 用法全部同步 | ✅ 已应用 | | [16_SupportingModules.md](16_SupportingModules.md) | §5 AntiSoftlockSystem 后新增 §5.1 RoomEscapeInfoSO(设计时房间逃离 SO,含 EscapeRoute 嵌套类,对齐 Design/49 §3)+ §5.2 HardAbilityGate(AbilityGate 子类,_requirePhysicalValidation + _sequenceBreakRisk,对齐 Design/49 §4.2) | ✅ 已应用 | diff --git a/Docs/Architecture/02_EventSystem.md b/Docs/Architecture/02_EventSystem.md index 4b52d59a..96a3352e 100644 --- a/Docs/Architecture/02_EventSystem.md +++ b/Docs/Architecture/02_EventSystem.md @@ -140,12 +140,6 @@ public class ShopPurchaseEventChannelSO : BaseEventChannelSO [CreateAssetMenu(menuName = "Events/DialogueRequest")] public class DialogueEventChannelSO : BaseEventChannelSO { } -// Assets/Scripts/Core/Events/BossSkillEventChannelSO.cs -[System.Serializable] -public struct BossSkillEvent { public string BossId; public string SkillId; } -[CreateAssetMenu(menuName = "Events/BossSkill")] -public class BossSkillEventChannelSO : BaseEventChannelSO { } - // Assets/Scripts/Core/Events/BossPhaseEventChannelSO.cs [System.Serializable] public struct BossPhaseEvent { public string BossId; public int Phase; } @@ -246,16 +240,12 @@ public class StatusEffectEventChannelSO : BaseEventChannelSO { | `EVT_EnemyDied` | `TransformEventChannelSO` | `EnemyBase`(HP ≤ 0) | `PlayerStats`(击杀点)、`QuestManager` | | `EVT_HitConfirmed` | `HitConfirmedEventChannelSO`(HitInfo) | `HurtBox.ReceiveDamage` | `HitFXSpawner`、`AudioManager`、`PlayerFeedback`(受击方) | | `EVT_DamageDealt` | `DamageInfoEventChannelSO` | `HurtBox.ReceiveDamage` | `AnalyticsManager` | -| `EVT_BossFightStarted` | `StringEventChannelSO`(bossId) | `BossOrchestrator` | `GameManager`(切换 BossFight 状态)、`AudioManager` | -| `EVT_BossFightEnded` | `BoolEventChannelSO`(胜利/失败) | `BossBase.Die()` | `GameManager`、`AudioManager`、`UIManager` | -| `EVT_BossFightToggled` | `BoolEventChannelSO`(true=开始,false=结束) | `BossOrchestrator`(开始)、`BossBase.Die()`(结束) | `BossHPBar`(显示/隐藏 Boss 血条) | +| `EVT_BossFightStarted` | `StringEventChannelSO`(bossId) | `BossFightTrigger`(玩家进入竞技场触发区) | `GameManager`(切换 BossFight 状态)、`AudioManager` | +| `EVT_BossFightEnded` | `BoolEventChannelSO`(true=开始 / false=结束) | `BossFightTrigger`(开始)、`BossBase.Die()`(结束) | `BossHPBar`(显示/隐藏血条)、`AudioManager`、`UIManager` | | `EVT_BossHPChanged` | `IntEventChannelSO` | `BossBase`(受击/回血) | `BossHPBar`(HP 进度条更新) | -| `EVT_BossNameSet` | `StringEventChannelSO`(bossName) | `BossOrchestrator`(战斗开始时) | `BossHPBar`(显示 Boss 名称标签) | -| `EVT_BossHPMaxSet` | `IntEventChannelSO` | `BossOrchestrator`(战斗开始时) | `BossHPBar`(初始化满血值) | +| `EVT_BossNameSet` | `StringEventChannelSO`(bossName) | ⚠️ 暂无发送方(旧编排器已删除) | `BossHPBar`(显示 Boss 名称标签) | +| `EVT_BossHPMaxSet` | `IntEventChannelSO` | ⚠️ 暂无发送方(旧编排器已删除) | `BossHPBar`(初始化满血值) | | `EVT_BossPhaseChanged` | `BossPhaseEventChannelSO`(bossId, phase) | `BossBase.EnterPhase()` | `BossHUD`(相机切换通过 `CameraStateController.Instance` 直接调用,不订阅事件)| -| `EVT_BossSkillStarted` | `BossSkillEventChannelSO`(bossId, skillId) | `BossSkillExecutor` | `BossHUD`(显示技能名)(震动通过 `CameraStateController.Instance.TriggerImpulse()` 直接调用)| -| `EVT_BossSkillEnded` | `BossSkillEventChannelSO`(bossId, skillId) | `BossSkillExecutor` | `BossOrchestrator`(BD 决策推进) | -| `EVT_BossVulnerabilityWindowOpened` | `StringEventChannelSO`(bossId) | `WeakPointSystem` | `PlayerFeedback`(提示音效)、`HUDController` | | `EVT_NailClash` | `VoidEventChannelSO` | `ClashResolver`(玩家与敌人 HitBox 对碰) | `VFXSpawner`(拼刀特效)、`AudioManager`(拼刀音效)、`CameraStateController`(轻振动) | | `EVT_StatusEffectApplied` | `StatusEffectEventChannelSO` | `StatusEffectManager.ApplyEffect()` | `HUDController`(状态图标)、`PlayerFeedback`(特效) | | `EVT_StatusEffectExpired` | `StatusEffectEventChannelSO` | `StatusEffectManager.Tick()` | `HUDController`(移除状态图标) | diff --git a/Docs/Architecture/07_EnemyModule.md b/Docs/Architecture/07_EnemyModule.md index 30db45cd..fa152493 100644 --- a/Docs/Architecture/07_EnemyModule.md +++ b/Docs/Architecture/07_EnemyModule.md @@ -1,9 +1,30 @@ # 07 · 敌人模块 -> **命名空间** `BaseGames.Enemies`、`BaseGames.Enemies.AI`、`BaseGames.Enemies.Navigation`、`BaseGames.Enemies.Boss.Patterns` +> **命名空间** `BaseGames.Enemies`、`BaseGames.Enemies.Abilities`、`BaseGames.Enemies.Navigation`、`BaseGames.AI` > **程序集** 见各 asmdef -> **路径** `Assets/Scripts/Enemies/` -> **依赖** `BaseGames.Combat`、`Kybernetik.Animancer`、`BehaviorDesigner.Runtime`、`PathBerserker2d` +> **路径** `Assets/_Game/Scripts/Enemies/` +> **依赖** `BaseGames.Combat`、`Kybernetik.Animancer` + +--- + +> ## ⚠️ 本文部分章节已过时(2026-07-30) +> +> 决策层与 Boss 轨已两度重构,以下章节描述的类型**在代码库中已不存在**,保留仅供追溯历史, +> **不要照着它们去找类型或写新代码**: +> +> | 章节 | 状态 | +> |---|---| +> | §8 `Behavior Designer 任务类目录` | 第三方行为树插件已移除。决策层现为自研 `BrainGraph`(`AiScript` / `BrainBuilder` / `AiRuntime` / `EnemyAiBrain`) | +> | §10 `BossOrchestrator` | 已删除。开战由 `BossFightTrigger` 发 `AiSignal.Engaged`,阶段由 `BossBase.EnterPhase` + `BossPhaseAbilityGate` 承担 | +> | §11 `AttackPatternSO 与 TelegraphSystem` | 整节已删除的类型;预警统一改为动画时间轴上的事件 | +> | §9 `BossBase` 的字段清单 | 已大幅瘦身:技能执行器、预警系统、弹反反制均已移除;新增 `IBossControl` 实现与阶段门引用 | +> +> **当前有效的写法**(这两份是权威): +> - 小怪:[`Docs/Guides/08_EnemyAi_Authoring_Guide.md`](../Guides/08_EnemyAi_Authoring_Guide.md) +> - Boss:[`Docs/Guides/09_BossAi_Authoring_Guide.md`](../Guides/09_BossAi_Authoring_Guide.md) +> - 设计依据:`Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md` +> +> 本文其余章节(`EnemyBase` / `EnemyStats` / `EnemyMovement` / 感知 / 掉落 / 池化等)仍然有效。 --- @@ -16,10 +37,10 @@ 5. [EnemyNavAgent](#5-enemynavagent) 6. [EnemyStatsSO](#6-enemystatsso) 7. [EnemyAnimationConfigSO](#7-enemyanimationconfigso) -8. [Behavior Designer 任务类目录](#8-behavior-designer-任务类目录) -9. [BossBase](#9-bossbase) -10. [BossOrchestrator](#10-bossorchestrator) -11. [AttackPatternSO 与 TelegraphSystem](#11-attackpatternso-与-telegraphsystem) +8. ~~[决策层任务类目录](#8-behavior-designer-任务类目录)~~(已过时,见页首提示) +9. [BossBase](#9-bossbase)(字段清单已过时) +10. ~~[BossOrchestrator](#10-bossorchestrator)~~(已删除) +11. ~~[AttackPatternSO 与 TelegraphSystem](#11-attackpatternso-与-telegraphsystem)~~(已删除) 12. [DeathShade(死亡遗骸)](#12-deathshade) 13. [Enemy Prefab 层级结构](#13-enemy-prefab-层级结构) 14. [LootTableSO 与 LootResolver](#14-loottableso-与-lootresolver) @@ -551,147 +572,17 @@ public class BossOrchestrator : MonoBehaviour --- -## 11. AttackPatternSO 与 TelegraphSystem +## 11. ~~AttackPatternSO 与 TelegraphSystem~~(已删除) -```csharp -// 路径: Assets/Scripts/Enemies/Boss/Patterns/AttackPatternSO.cs -[CreateAssetMenu(menuName = "Enemies/Boss/AttackPattern")] -public class AttackPatternSO : ScriptableObject -{ - [System.Serializable] - public struct AttackEntry - { - public string AttackId; // BD 任务通过此 ID 触发 - public float Weight; // 随机权重(模式随机选择) - public float Cooldown; - public float TelegraphDuration; // 0 = 无预警 - public string TelegraphVfxKey; // Addressable 键 - public DamageSourceSO DamageSource; - } +这两个类型连同整套旧 Boss 技能轨已于 2026-07-30 删除。当前等价物: - public AttackEntry[] Attacks; - public float PhaseSpeedMultiplier = 1f; // 此阶段移速倍率 -} +| 旧类型 | 现在由谁承担 | +|---|---| +| `AttackPatternSO`(攻击条目 + 权重 + 冷却 + 预警时长) | `EnemyAbilitySO`(id / 冷却 / 权重 / 优先级 / 触发范围 / 中断规则)+ `EnemyAttackSO`(clip + 归一化时机);选招由 `EnemyAttackSelector` 负责 | +| `AttackPatternEditor`(命中帧可视化 Inspector) | 暂无替代。能力资产的编辑器总览(`EnemyAbilityModule`)是已记录的待办 | +| `TelegraphSystem`(预警 VFX 协程) | 动画时间轴:预警是 clip 的一段,判定 / VFX / 音效 / 无敌帧全部挂动画事件(见 `EnemyAnimationEvents`) | -// 路径: Assets/Scripts/Enemies/Boss/Patterns/TelegraphSystem.cs -// 攻击预警系统:在攻击前若干帧显示视觉提示 -public class TelegraphSystem : MonoBehaviour -{ - [SerializeField] private ObjectPoolManager _pool; - - // 开始预警(BD_TelegraphAttack 调用) - public IEnumerator ShowTelegraph(string vfxKey, float duration, Vector2 position); - // 1. 从对象池取出预警 VFX - // 2. 等待 duration 秒 - // 3. 归还 VFX(攻击开始) -} -``` - ---- - -### 11.1 AttackPatternEditor(命中帧可视化 Inspector) - -> **路径**:`Assets/Editor/Enemies/AttackPatternEditor.cs` -> **目标用户**:Boss 战斗设计师(配置攻击权重、预警时长、伤害数值时,无需对照原始 Inspector 多层嵌套) - -```csharp -[CustomEditor(typeof(AttackPatternSO))] -public class AttackPatternEditor : UnityEditor.Editor -{ - // ── 折叠状态 ───────────────────────────────────────────────────── - private bool[] _foldouts; - - public override void OnInspectorGUI() - { - var so = (AttackPatternSO)target; - var prop = serializedObject; - prop.Update(); - - // ── 整体参数 ───────────────────────────────────────────────── - EditorGUILayout.PropertyField(prop.FindProperty("PhaseSpeedMultiplier")); - EditorGUILayout.Space(6); - - // ── 攻击条目列表(可视化条形图 + 折叠面板) ───────────────── - EditorGUILayout.LabelField("AttackEntries", EditorStyles.boldLabel); - var attacks = so.Attacks; - if (attacks == null || attacks.Length == 0) - { - EditorGUILayout.HelpBox("尚无攻击条目。", MessageType.Info); - } - else - { - if (_foldouts == null || _foldouts.Length != attacks.Length) - _foldouts = new bool[attacks.Length]; - - float totalWeight = 0f; - foreach (var a in attacks) totalWeight += Mathf.Max(a.Weight, 0.001f); - - for (int i = 0; i < attacks.Length; i++) - { - var entry = attacks[i]; - float ratio = entry.Weight / totalWeight; - - // 折叠标题行:「[Bar████░░] AttackId 权重%」 - EditorGUILayout.BeginHorizontal(); - var barRect = GUILayoutUtility.GetRect(0, 16, GUILayout.Width(80)); - EditorGUI.DrawRect(barRect, new Color(0.2f, 0.4f, 0.8f, 0.3f)); - EditorGUI.DrawRect(new Rect(barRect.x, barRect.y, barRect.width * ratio, barRect.height), - new Color(0.2f, 0.6f, 1f, 0.8f)); - - _foldouts[i] = EditorGUILayout.Foldout(_foldouts[i], - $" {entry.AttackId} ({ratio:P0})", true); - EditorGUILayout.EndHorizontal(); - - if (_foldouts[i]) - { - EditorGUI.indentLevel++; - var arrayProp = prop.FindProperty("Attacks").GetArrayElementAtIndex(i); - EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("AttackId")); - EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("Weight")); - EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("Cooldown")); - - // 预警时长:0 = 无预警(灰色提示) - float tele = entry.TelegraphDuration; - EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("TelegraphDuration")); - if (tele <= 0f) - EditorGUILayout.HelpBox("TelegraphDuration = 0:此攻击无预警(即时)", MessageType.None); - - EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("TelegraphVfxKey")); - EditorGUILayout.PropertyField(arrayProp.FindPropertyRelative("DamageSource")); - - // 快速展示 DamageSource 摘要(避免策划必须展开嵌套 SO) - if (entry.DamageSource != null) - { - EditorGUI.indentLevel++; - EditorGUILayout.LabelField( - $"伤害预览: {entry.DamageSource.BaseDamage} × {entry.DamageSource.DamageMultiplier:F1} " + - $"= {Mathf.RoundToInt(entry.DamageSource.BaseDamage * entry.DamageSource.DamageMultiplier)} [{entry.DamageSource.Type}]", - EditorStyles.miniLabel); - EditorGUI.indentLevel--; - } - - // 一键 Ping 关联 SO - if (entry.DamageSource != null && GUILayout.Button("Ping DamageSourceSO", GUILayout.Height(18))) - EditorGUIUtility.PingObject(entry.DamageSource); - - EditorGUI.indentLevel--; - } - } - } - - prop.ApplyModifiedProperties(); - } -} -``` - -**提供能力一览**: - -| 功能 | 收益 | -|------|------| -| 随机权重百分比 + 条形图 | 策划直观感受攻击频率分布 | -| TelegraphDuration=0 警告 | 防止无意做成即时攻击 | -| DamageSource 内联摘要 | 查看伤害值无需展开嵌套 SO | -| Ping 关联 SO 按钮 | 快速定位引用资产 | +配招写法见 [`Docs/Guides/09_BossAi_Authoring_Guide.md`](../Guides/09_BossAi_Authoring_Guide.md) §4。 --- diff --git a/Docs/Architecture/23_BossSkillModule.md b/Docs/Architecture/23_BossSkillModule.md deleted file mode 100644 index e0291591..00000000 --- a/Docs/Architecture/23_BossSkillModule.md +++ /dev/null @@ -1,841 +0,0 @@ -# 23 · Boss 技能模块(Boss Skill Module) - -> **命名空间** `BaseGames.Boss` -> **程序集** `BaseGames.Enemy`(并入敌人程序集) -> **依赖** `BaseGames.Core.Events` · `BaseGames.Combat`(DamageInfo · HitBox)· `BaseGames.AI`(BossOrchestrator · BehaviorDesigner)· `Kybernetik.Animancer` -> **Design 来源** [47_BossSkillSystem](../Design/47_BossSkillSystem.md) - ---- - -## 目录 - -1. [模块职责与层级](#1-模块职责与层级) -2. [BossSkillType 枚举](#2-bossskilltype-枚举) -3. [VulnerabilityWindow](#3-vulnerabilitywindow) -4. [BossSkillSO](#4-bossskillso) -5. [AttackPatternSO](#5-attackpatternso) -6. [SkillSequenceSO](#6-skillsequenceso) -7. [BossSkillExecutor](#7-bossskillexecutor) -8. [WeakPointSystem](#8-weakpointsystem) -9. [BossOrchestrator 集成](#9-bossorchestrator-集成) -10. [设计规则一览](#10-设计规则一览) -11. [事件频道](#11-事件频道) - ---- - -## 1. 模块职责与层级 - -``` -数据层(ScriptableObject): - ┌─────────────────────────────────────────────────┐ - │ BossSkillSO │ - │ ├─ 技能元信息(类型/弱点窗口/互动标签) │ - │ └─ SkillSequenceSO[] 伤害序列 │ - │ └─ AttackPatternSO[] 单个攻击图案 │ - └─────────────────────────────────────────────────┘ - -运行时层(MonoBehaviour / UniTask): - BossOrchestrator(AI 决策,Behavior Designer 树) - └─ BossSkillExecutor(执行技能序列,处理 VulnerabilityWindow) - ├─ WeakPointSystem(弱点 HurtBox 激活管理) - └─ HitBox[](输出伤害) - -设计原则: - ① 伤害值只写在 AttackPatternSO,不在 BossSkillSO 重复 - ② 每个技能必须有 ≥1 VulnerabilityWindow(后摇 ≥0.5s 或专属弱点) - ③ 技能顺序由 BossOrchestrator 决定,BossSkillExecutor 只负责执行 -``` - ---- - -## 2. BossSkillCategory 和 BossSkillType 枚举 - -```csharp -namespace BaseGames.Boss -{ - /// 高层技能分类(平衡框架用)。 - public enum BossSkillCategory - { - Melee, Ranged, Charge, AoE, Environmental, Summon, - Buff, Debuff, Phase, Passive, Reactive - } - - /// 具体技能类型(战斗设计用)。 - public enum BossSkillType - { - // 基础攻击 - MeleeSlash, // 近战斩击 - ChargeAttack, // 冲刺撞击 - LeapSlam, // 跳起落地震 - ProjectileVolley, // 弹幕齐射 - LaserBeam, // 激光扫射 - - // 阶段技能 - PhaseTransition, // 阶段切换(无攻击,触发动画/护盾等) - SummonMinion, // 召唤小兵 - - // 特殊 - ArenaTrap, // 改变战斗区域地形 - SpeedBurst, // 速度爆发(数帧内免疫/高速) - DefensiveShell, // 防御壳(需攻击弱点) - } - - /// 互动标签:定义玩家可以对该技能执行哪些反制操作。 - [Flags] - public enum InteractionTag - { - None = 0, - Parryable = 1 << 0, // 可弹反 - PerfectParryOnly = 1 << 1, // 仅完美弹反有效 - DodgeWindow = 1 << 2, // 打开逃避窗口 - Unblockable = 1 << 3, // 无法拦截 - CanBeReflected = 1 << 4, // 弹幕可被反射 - ExposesWeakPoint = 1 << 5, // 暴露弱点 - GrantsPlayerReso = 1 << 6, // 多典:命中后给予玩家资源 - ArenaHazard = 1 << 7, // 场地危机(环境相关) - PhaseGate = 1 << 8, // 阶段门关(必须触发才进入下一阶段) - } -} -``` - ---- - -## 3. VulnerabilityWindow - -```csharp -namespace BaseGames.Boss -{ - /// 弱点出现时机类型。 - public enum VulnTriggerType - { - OnAttackRecovery, // 攻击后摇 - OnParriedSuccess, // 弹反成功 - OnCounterSkillHit, // 计算技能命中 - OnPhaseTransition, // 阶段切换时 - OnHazardBackfire, // 场地尃8个 - OnSummonDefeated, // 召唤物被击败 - Manual, // BossSkillExecutor 手动触发 - } - - /// 弱点位置类型。 - public enum WeakPointType - { - FullBody, // 主体全身都是弱点 - HeadOnly, // 仅头部 - BackOnly, // 仅背部 - CoreExposed, // 核心暴露(展开中心 HurtBox) - CustomPoint, // 自定义弱点 HurtBox - } - - [Serializable] - public struct VulnerabilityWindow - { - [Tooltip("弱点触发方式")] - public VulnTriggerType TriggerType; - - [Tooltip("触发后延迟出现(秒)")] - [Min(0f)] - public float TriggerDelay; - - [Tooltip("弱点持续时长(秒,设计约定 ≥0.5s)")] - [Min(0.1f)] - public float Duration; - - [Tooltip("弱点位置类型")] - public WeakPointType WeakPointType; - - [Tooltip("弱点激活时 Boss 的受击乘数(1 = 正常,>1 = 额外伤害)")] - [Min(0.1f)] - public float DamageMultiplier; - - [Tooltip("命中后强制驰恶")] - public bool ForceStagger; - - [Tooltip("驰恶时间(秒),ForceStagger=true 时生效")] - [Min(0f)] - public float StaggerDuration; - - [Tooltip("弱点开启时播放的 Feedback(光效等)")] - public MMF_Player OpenFeedback; - - [Tooltip("弱点关闭时播放的 Feedback")] - public MMF_Player CloseFeedback; - - [Tooltip("弱点激活时的高亮颜色(指示玩家该攻击哪里)")] - public Color HighlightColor; - - // 与旧字段对应(保留为展示用) - public bool ActivateWeakPointHurtBox => WeakPointType != WeakPointType.FullBody; - } -} -``` - ---- - -## 4. BossSkillSO - -```csharp -namespace BaseGames.Boss -{ - [CreateAssetMenu(menuName = "Boss/BossSkill")] - public class BossSkillSO : ScriptableObject - { - [Header("元信息")] - public string skillId; - public string displayName; - [TextArea(1, 4)] - public string designNote; // 设计师注释(不进游戏) - - [Header("技能分类")] - public BossSkillCategory category; // 高层分类(Melee/Ranged 等) - public BossSkillType skillType; // 具体技能类型 - - [Header("阶段可用性")] - [Tooltip("和数据层 BossPhaseConfigSO.PhaseIndex 对应;空 = 全阶段可用")] - public int[] availablePhaseIndices; // 空数组 = 全阶段可用 - - [Header("核心攻击动作引用")] - [Tooltip("构成此技能的 AttackPatternSO 序列(单个技能 = 长度 1;连击/多段 = 多个)")] - public AttackPatternSO[] attackPatterns; // 按执行顺序排列 - - [Header("弱点窗口(至少 1 个)")] - public VulnerabilityWindow[] vulnerabilityWindows; - - [Header("互动标签(与谜题/道具联动)")] - public InteractionTag interactionTags; // [Flags] 枚举,替代旧 string interactionTag - - [Header("连段(执行中的子序列)")] - public SkillSequenceSO sequenceOnHit; // 被玩家攻击弱点时触发的序列(可空) - public SkillSequenceSO sequenceOnMiss; // 弱点未被攻击时触发(可空) - - [Header("玩家反制接口")] - [Tooltip("被不同玩家行为反制时 Boss 的应激反应(见 §4.1)")] - public PlayerCounterResponse[] counterResponses; - - [Header("场景联动")] - [Tooltip("此技能执行时触发的场景事件(见 §4.2)")] - public ArenaEventTrigger[] arenaEvents; - - [Header("Boss 资源")] - [Tooltip("使用此技能消耗的 Boss 自身资源(见 §7)")] - public BossResourceCost resourceCost; - [Tooltip("使用此技能后是否积累愤怒值")] - public bool buildsRage; - - [Header("霸体配置")] - [Tooltip("此技能执行期间的霸体窗口(见 54_PoiseSystem §8);None = 可被打断")] - public PoiseWindowConfig poiseWindow; - - [Header("动画")] - public ClipTransition skillAnimation; // Animancer ClipTransition(技能播放动画) - - [Header("冷却")] - [Min(0f)] - public float cooldown; // 秒,0 = 无冷却(由 BossOrchestrator 决策层管理) - } -} -``` - -### 4.1 PlayerCounterResponse — 玩家反制接口 - -```csharp -namespace BaseGames.Boss -{ - [Serializable] - public struct PlayerCounterResponse - { - [Header("反制条件")] - public CounterType counterType; // 玩家用什么行为触发反制 - public string requiredSkillId; // counterType = SpecificSkill 时填写技能 ID - - [Header("反制效果(对 Boss)")] - public float bossStaggerDuration; // 强制硬直时长(秒,0 = 不强制) - public float bossDamageBonus; // 对 Boss 的额外伤害倍率(0 = 不额外) - public bool openVulnWindow; // 是否触发 VulnerabilityWindow - public bool interruptSkill; // 是否打断 Boss 当前技能 - - [Header("反制收益(对玩家)")] - public int soulPowerGrant; // 给予玩家的灵力数量 - public int spiritPowerGrant; // 给予玩家的魄元数量 - public MMF_Player counterFeedback; // 成功反制的特效/音效反馈 - } - - public enum CounterType - { - Parry, // 弹反成功 - PerfectParry, // 完美弹反 - DodgeThrough, // 冲刺穿越攻击(无敌帧通过) - SpecificSkill, // 使用特定玩家技能(填 requiredSkillId) - WeakPointHit, // 命中暴露的弱点 - HazardBackfire, // 利用场景危险伤到 Boss - SummonKill, // 击败召唤物 - } -} -``` - -### 4.2 ArenaEventTrigger — 场景联动 - -```csharp -namespace BaseGames.Boss -{ - [Serializable] - public struct ArenaEventTrigger - { - public string targetArenaObjectId; // 要影响的场景物件 ID(空 = 广播给所有) - public ArenaEventType eventType; - public float delay; // 从技能触发到场景事件的延迟(秒) - public ArenaEventParams parameters; - } - - public enum ArenaEventType - { - DestroyPlatform, // 破坏指定平台 - ActivateHazard, // 激活陷阱(如喷火管) - DeactivateHazard, // 关闭陷阱 - SpawnHazardArea, // 生成持续危险区域(熔岩/毒液) - ShakeArena, // 场景震动 - ToggleLighting, // 切换场景光照 - SpawnPlatform, // 生成新平台(阶段 2 变化) - TriggerCutscene, // 触发小型过场 - } - - [Serializable] - public struct ArenaEventParams - { - public float duration; // 效果持续时间(0 = 永久) - public float intensity; // 强度(震动幅度、危险区域半径等) - public bool revertsOnPhaseEnd; // 阶段结束时是否恢复 - } - - [Serializable] - public struct ArenaEventData - { - public ArenaEventType type; - public ArenaEventParams parameters; - public string sourceSkillId; // 来源技能 ID(供场景物件做条件判断) - } - - /// - /// 场景中可被 Boss 技能交互的对象实现此接口。 - /// Boss 通过 ArenaEventBus 广播,场景物件监听并响应。 - /// - public interface IArenaInteractable - { - string ArenaObjectId { get; } - void OnBossArenaEvent(ArenaEventData data); - } -} -``` - -### 4.3 BossResourceCost — Boss 资源消耗 - -```csharp -namespace BaseGames.Boss -{ - [Serializable] - public struct BossResourceCost - { - public string resourceId; // 对应 BossResourceConfigSO.resourceId(如 "Rage") - public float cost; // 消耗量(0 = 不消耗资源) - public float minRequired; // 使用此技能的最低资源要求 - } - - [CreateAssetMenu(menuName = "Boss/ResourceConfig")] - public class BossResourceConfigSO : ScriptableObject - { - public string resourceId; // 如 "Rage" / "PhaseCharge" - public string displayName; - public float maxValue; - public float startValue; // 初始值(通常 0) - - [Header("自动变化")] - public float passiveRate; // 每秒自动变化量(正=增长/负=衰减,0=不变) - public float onTakeDamageGain; // 每受到 1 点伤害积累量 - public float onSkillUseGain; // 每使用一次技能积累量 - - [Header("满值效果")] - public bool autoTriggerOnFull; - public BossSkillSO fullTriggerSkill; - public float resetValueAfterTrigger; - } -} -``` - ---- - -## 5. AttackPatternSO - -```csharp -namespace BaseGames.Boss -{ - /// - /// 单个攻击图案的数据。 - /// 存放伤害/速度等实际参数,BossSkillSO 不存参数。 - /// - [CreateAssetMenu(menuName = "Boss/AttackPattern")] - public class AttackPatternSO : ScriptableObject - { - [Header("输出")] - public DamageSourceSO DamageSource; // ⚠️ HitBox.Activate() 需要 DamageSourceSO(架构 06 §4) - public float KnockbackAngle; // 击退角度(度)(基础击退力由 DamageSourceSO 内配置) - - [Header("弹幕(若为弹幕类型)")] - public AssetReferenceGameObject ProjectilePrefab; - public int ProjectileCount = 1; - public float SpreadAngle = 0f; // 散射角(度) - public float ProjectileSpeed = 8f; - - [Header("范围攻击(若为 AoE 类型)")] - public float AoERadius; - public Vector2 AoEOffset; // 相对于 Boss 的偏移 - - [Header("时序")] - public float WindupDuration; // 预备动作时间 - public float ActiveDuration; // HitBox 激活时长 - public float RecoveryDuration; // 后摇时长(VulnerabilityWindow 通常在此段) - } -} -``` - ---- - -## 6. SkillSequenceSO - -```csharp -namespace BaseGames.Boss -{ - /// - /// 有序攻击序列(一个技能内的多段连段)。 - /// - [CreateAssetMenu(menuName = "Boss/SkillSequence")] - public class SkillSequenceSO : ScriptableObject - { - [Serializable] - public struct SequenceStep - { - public AttackPatternSO pattern; - public float delayBeforeStep; // 执行本步前等待的秒数 - } - - public SequenceStep[] steps; - - [Header("序列完成后的行为")] - public bool RepeatIfPlayerInRange; // 若玩家仍在范围内则重复(Survival 关卡用) - public float RepeatDelay; - [Range(0, 10)] - public int MaxRepeatCount; // 0 = 无限 - } -} -``` - ---- - -## 7. BossSkillExecutor - -```csharp -namespace BaseGames.Boss -{ - /// - /// 挂在 Boss GameObject 上。 - /// 接收 BossOrchestrator 的指令,执行指定 BossSkillSO。 - /// 管理 VulnerabilityWindow 计时和 WeakPointSystem 激活。 - /// - public class BossSkillExecutor : MonoBehaviour - { - [SerializeField] HitBox[] _hitBoxes; // Boss 身上的所有 HitBox - [SerializeField] WeakPointSystem _weakPointSystem; - [SerializeField] AnimancerComponent _animancer; - [SerializeField] private string _bossId; // Boss 资产唯一 ID - [SerializeField] private BossSkillEventChannelSO _onBossSkillStarted; // 发布:技能开始 - [SerializeField] private BossSkillEventChannelSO _onBossSkillEnded; // 发布:技能结束 - // ⚠️ PlayerController 无 Instance(Architecture 05 §2);Boss 居小场景持有玩家 Transform 引用 - [SerializeField] private Transform _playerTransform; // 由 Inspector 指定玩家 Transform - - BossSkillSO _currentSkill; - AnimancerState _currentState; - bool _isExecuting; - CancellationTokenSource _cts; - - public bool IsExecuting => _isExecuting; - - // ── 公共 API ─────────────────────────────────────────────── - public async UniTask ExecuteSkill(BossSkillSO skill, CancellationToken ct) - { - if (_isExecuting) return; - _isExecuting = true; - _currentSkill = skill; - _onBossSkillStarted?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId }); - - // 播放技能动画(Animancer ClipTransition,直接从 BossSkillSO 引用) - _currentState = _animancer.Play(skill.skillAnimation); - - // 执行 SkillSequenceSO(若挂载 sequenceOnMiss) - if (skill.sequenceOnMiss != null) - await ExecuteSequence(skill.sequenceOnMiss, ct); - - _isExecuting = false; - _onBossSkillEnded?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId }); - } - - async UniTask ExecuteSequence(SkillSequenceSO seq, CancellationToken ct) - { - int repeatCount = 0; - do - { - foreach (var step in seq.steps) - { - ct.ThrowIfCancellationRequested(); - await UniTask.WaitForSeconds(step.delayBeforeStep, cancellationToken: ct); - await ExecutePattern(step.pattern, ct); - } - repeatCount++; - if (seq.RepeatDelay > 0) - await UniTask.WaitForSeconds(seq.RepeatDelay, cancellationToken: ct); - } - while (seq.RepeatIfPlayerInRange - && repeatCount < seq.MaxRepeatCount - && IsPlayerInRange() - && !ct.IsCancellationRequested); - } - - async UniTask ExecutePattern(AttackPatternSO pattern, CancellationToken ct) - { - // 预备 - await UniTask.WaitForSeconds(pattern.WindupDuration, cancellationToken: ct); - - // 激活 HitBox(⚠️ 以架构 06_CombatModule §4 为准:Activate(DamageSourceSO, Transform)) - foreach (var hb in _hitBoxes) - hb.Activate(pattern.DamageSource, transform); - - await UniTask.WaitForSeconds(pattern.ActiveDuration, cancellationToken: ct); - - // 关闭 HitBox - foreach (var hb in _hitBoxes) hb.Deactivate(); - - // 后摇(VulnerabilityWindow 由动画事件配合 VulnerabilityWindow 数据触发) - await UniTask.WaitForSeconds(pattern.RecoveryDuration, cancellationToken: ct); - } - - // ── VulnerabilityWindow 激活(由 ExecuteSkill 协程驱动)───── - // 注意:新版 VulnerabilityWindow 改用绝对时间(TriggerDelay + Duration), - // 而非归一化时间,故弃用 Update 轮询,改为 UniTask 序列激活。 - async UniTask ActivateVulnerabilityWindows(BossSkillSO skill, CancellationToken ct) - { - foreach (var window in skill.vulnerabilityWindows) - { - // 等待触发延迟 - await UniTask.Delay( - TimeSpan.FromSeconds(window.TriggerDelay), - cancellationToken: ct); - - // 激活弱点 - bool isCustom = window.ActivateWeakPointHurtBox; - _weakPointSystem.SetActive(true, window.DamageMultiplier, isCustom); - window.OpenFeedback?.PlayFeedbacks(); - - // 持续弱点窗口 - await UniTask.Delay( - TimeSpan.FromSeconds(window.Duration), - cancellationToken: ct); - - // 关闭弱点 - _weakPointSystem.SetActive(false, 1f, isCustom); - window.CloseFeedback?.PlayFeedbacks(); - } - } - - bool IsPlayerInRange() => - _playerTransform != null && - Vector2.Distance(transform.position, _playerTransform.position) < 8f; - } -} -``` - ---- - -## 8. WeakPointSystem - -```csharp -namespace BaseGames.Boss -{ - /// - /// 管理 Boss 的专属弱点 HurtBox(如核心、眼睛等)。 - /// 弱点激活期间受到的伤害会乘以 DamageMultiplier。 - /// - public class WeakPointSystem : MonoBehaviour - { - [Serializable] - public struct WeakPoint - { - public HurtBox hurtBox; - public GameObject visualIndicator; // 弱点亮光/标记(可为 null) - } - - [SerializeField] WeakPoint[] _weakPoints; - [SerializeField] private string _bossId; // Boss 资产唯一 ID - [SerializeField] private StringEventChannelSO _onVulnerabilityWindowOpened; // 发布:弱点窗口开启 - - float _damageMultiplier = 1f; - - public void SetActive(bool active, float multiplier = 1f) - { - _damageMultiplier = multiplier; - foreach (var wp in _weakPoints) - { - wp.hurtBox.gameObject.SetActive(active); - if (wp.visualIndicator != null) - wp.visualIndicator.SetActive(active); - } - if (active) _onVulnerabilityWindowOpened?.Raise(_bossId); - } - - /// - /// 弱点 HurtBox 受击时,由 BossStats 调用此方法获取最终伤害系数。 - /// - public float GetDamageMultiplier() => _damageMultiplier; - } -} -``` - ---- - -## 9. BossOrchestrator 集成 - -`BossOrchestrator` 是 Behavior Designer 的宿主 MonoBehaviour,持有 `BossSkillExecutor` 引用: - -```csharp -// BossOrchestrator 片段(供 BD 节点调用) -public class BossOrchestrator : MonoBehaviour -{ - [SerializeField] BossSkillSO[] _phaseOneSkills; - [SerializeField] BossSkillSO[] _phaseTwoSkills; - [SerializeField] BossSkillExecutor _executor; - - int _currentPhase = 1; - CancellationTokenSource _cts; - - // BD Task 节点调用:ExecuteSkill(skillId) - public async UniTask ExecuteSkillById(string skillId) - { - var skills = _currentPhase == 1 ? _phaseOneSkills : _phaseTwoSkills; - var skill = System.Array.Find(skills, s => s.skillId == skillId); - if (skill == null) return; - - _cts?.Cancel(); - _cts = new CancellationTokenSource(); - await _executor.ExecuteSkill(skill, _cts.Token); - } - - public void EnterPhaseTwo() - { - _currentPhase = 2; - _cts?.Cancel(); // 打断当前技能 - } -} -``` - ---- - -## 10. 设计规则一览 - -| 规则 | 说明 | -|------|------| -| 每个技能 ≥1 VulnerabilityWindow | 后摇窗口 ≥0.5s(归一化时长约 0.15~0.4) | -| 伤害参数只在 AttackPatternSO | BossSkillSO 不存 BaseDamage 等参数 | -| 技能冷却由 BossOrchestrator 管理 | BossSkillExecutor 只执行,不维护冷却 | -| VulnerabilityWindow 在编辑器中校验 | 自定义 Validator Tool 检查 DurationNormalized ≥ 0.1 | -| Boss 阶段切换打断当前技能 | `_cts.Cancel()` 终止 UniTask 序列 | - ---- - -## 11. 事件频道 - -| 频道 SO | Payload | 发布者 | 订阅者 | -|--------|---------|--------|--------| -| `EVT_BossSkillStarted` | `(string bossId, string skillId)` | `BossSkillExecutor` | `BossHUD`(显示技能名)(震动通过 `CameraStateController.Instance.TriggerImpulse()` 直接调用,不订阅事件)| -| `EVT_BossSkillEnded` | `(string bossId, string skillId)` | `BossSkillExecutor` | `BossOrchestrator`(BD 决策推进) | -| `EVT_BossVulnerabilityWindowOpened` | `string bossId` | `WeakPointSystem` | `PlayerFeedback`(提示音效)、`HUDController` | -| `EVT_BossPhaseChanged` | `(string bossId, int phase)` | `BossBase.EnterPhase()` | `BossHUD`(相机切换通过 `CameraStateController.Instance` 直接调用,不订阅事件)| - ---- - -## 12. BossSkillSequenceEditorWindow — 技能序列可视化 - -> **痛点**:`SkillSequenceSO` 包含多个 `SequenceStep[]`(每步:AttackPatternSO + delayBeforeStep),但 Inspector 中只能逐字段查看数字,策划无法直觉感受技能序列的节奏感——哪段是攻击前摇、哪段是弱点窗口、总时长是否合理,全部要靠心算。本 EditorWindow 以 **甘特图(Gantt Chart)** 方式将时间线可视化。 - -### 12.1 功能规格 - -| 功能 | 说明 | -|------|------| -| 时间轴刻度 | 横轴为时间(秒),最大显示 `SkillSequenceSO.TotalDuration`,刻度精度 0.1s | -| 攻击阶段条 | 每个 `AttackPatternSO` 渲染为蓝色横条,长度 = `pattern.Duration` | -| 延迟段 | `delayBeforeStep` 渲染为灰色空隙 | -| 弱点窗口 | `VulnerabilityWindow`(`startNormalized`~`endNormalized`)渲染为绿色覆盖条 | -| 选中高亮 | 点击任一条可选中对应 `AttackPatternSO`,Inspector 同步 Ping | -| 验证警告 | `VulnerabilityWindow.DurationNormalized < 0.1` 时对应条变红 + Tooltip 提示 | -| SO 拖放 | 将 `SkillSequenceSO` 资产拖入窗口即可加载 | - -### 12.2 实现规范 - -```csharp -// 路径: Assets/Scripts/Editor/BossSkill/BossSkillSequenceWindow.cs -// 程序集: BaseGames.Editor(Editor Only) -#if UNITY_EDITOR -using UnityEditor; -using UnityEngine; -using System.Collections.Generic; - -namespace BaseGames.Editor.BossSkill -{ - public class BossSkillSequenceWindow : EditorWindow - { - [MenuItem("BaseGames/Tools/Boss Skill Sequence Viewer")] - public static void Open() => GetWindow("技能序列查看器"); - - // ── 状态 ────────────────────────────────────────────────────────────── - private SkillSequenceSO _target; - private Vector2 _scroll; - private float _zoom = 100f; // 像素/秒 - private const float TrackHeight = 28f; - private const float LabelWidth = 140f; - private const float HeaderHeight = 30f; - - // 颜色 - private static readonly Color ColDelay = new(0.3f, 0.3f, 0.3f, 0.5f); - private static readonly Color ColAttack = new(0.2f, 0.5f, 1.0f, 0.8f); - private static readonly Color ColVulnOk = new(0.2f, 0.8f, 0.3f, 0.6f); - private static readonly Color ColVulnWarn = new(1.0f, 0.3f, 0.3f, 0.7f); - - private void OnGUI() - { - DrawToolbar(); - if (_target == null) - { - EditorGUILayout.HelpBox("将 SkillSequenceSO 拖到此处或从 Toolbar 选择", MessageType.Info); - HandleDragDrop(); - return; - } - DrawTimeline(); - } - - private void DrawToolbar() - { - EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); - _target = (SkillSequenceSO)EditorGUILayout.ObjectField( - "技能序列", _target, typeof(SkillSequenceSO), false, - GUILayout.Width(300)); - GUILayout.Label($"缩放: {_zoom:0}px/s"); - _zoom = GUILayout.HorizontalSlider(_zoom, 30f, 300f, GUILayout.Width(100)); - EditorGUILayout.EndHorizontal(); - } - - private void DrawTimeline() - { - float totalSeconds = _target.TotalDuration; - float timelineWidth = totalSeconds * _zoom + LabelWidth + 20f; - - _scroll = EditorGUILayout.BeginScrollView(_scroll); - Rect viewRect = EditorGUILayout.GetControlRect(false, - HeaderHeight + _target.Steps.Length * (TrackHeight + 4f), - GUILayout.Width(timelineWidth)); - - DrawRuler(viewRect, totalSeconds); - - float trackY = viewRect.y + HeaderHeight; - float cursor = 0f; // 当前时间游标(秒) - - for (int i = 0; i < _target.Steps.Length; i++) - { - var step = _target.Steps[i]; - Rect trackRect = new(viewRect.x, trackY + i * (TrackHeight + 4f), - viewRect.width, TrackHeight); - - // 标签 - Rect labelRect = new(trackRect.x, trackRect.y, LabelWidth, TrackHeight); - GUI.Label(labelRect, step.Pattern?.name ?? "—", EditorStyles.miniLabel); - - // 延迟段 - if (step.DelayBeforeStep > 0f) - { - Rect delayRect = TimeToRect(trackRect, cursor, step.DelayBeforeStep); - EditorGUI.DrawRect(delayRect, ColDelay); - cursor += step.DelayBeforeStep; - } - - // 攻击段 - float patternDur = step.Pattern?.Duration ?? 0f; - if (patternDur > 0f) - { - Rect atkRect = TimeToRect(trackRect, cursor, patternDur); - EditorGUI.DrawRect(atkRect, ColAttack); - GUI.Label(atkRect, step.Pattern?.name ?? "", EditorStyles.centeredGreyMiniLabel); - - // 弱点窗口覆盖层 - DrawVulnerabilityWindows(atkRect, cursor, patternDur, step.Pattern); - cursor += patternDur; - } - } - - EditorGUILayout.EndScrollView(); - } - - private void DrawVulnerabilityWindows(Rect atkRect, float patternStart, - float patternDur, AttackPatternSO pattern) - { - if (pattern?.VulnerabilityWindows == null) return; - foreach (var vw in pattern.VulnerabilityWindows) - { - float wStart = vw.StartNormalized * patternDur; - float wDur = (vw.EndNormalized - vw.StartNormalized) * patternDur; - bool warn = vw.DurationNormalized < 0.1f; - Color col = warn ? ColVulnWarn : ColVulnOk; - - Rect wRect = TimeToRect(atkRect, patternStart + wStart, wDur); - EditorGUI.DrawRect(wRect, col); - if (warn) - GUI.Label(wRect, "⚠", new GUIStyle { fontSize = 10, - normal = { textColor = Color.white }, alignment = TextAnchor.MiddleCenter }); - } - } - - private Rect TimeToRect(Rect trackRect, float startSec, float durSec) - { - float x = trackRect.x + LabelWidth + startSec * _zoom; - float w = Mathf.Max(2f, durSec * _zoom); - return new Rect(x, trackRect.y + 2f, w, trackRect.height - 4f); - } - - private void DrawRuler(Rect viewRect, float totalSeconds) - { - float step = _zoom >= 80f ? 0.5f : 1f; - for (float t = 0; t <= totalSeconds; t += step) - { - float x = viewRect.x + LabelWidth + t * _zoom; - EditorGUI.DrawRect(new Rect(x, viewRect.y, 1f, HeaderHeight), Color.gray); - GUI.Label(new Rect(x + 2f, viewRect.y, 40f, 16f), $"{t:0.0}s", - EditorStyles.miniLabel); - } - } - - private void HandleDragDrop() - { - var evt = Event.current; - if (evt.type == EventType.DragUpdated || evt.type == EventType.DragPerform) - { - DragAndDrop.visualMode = DragAndDropVisualMode.Copy; - if (evt.type == EventType.DragPerform) - { - DragAndDrop.AcceptDrag(); - foreach (var obj in DragAndDrop.objectReferences) - if (obj is SkillSequenceSO seq) { _target = seq; break; } - } - evt.Use(); - } - } - } -} -#endif -``` - -> **打开方式**:菜单 `BaseGames → Tools → Boss Skill Sequence Viewer`,将 `SkillSequenceSO` 资产拖入即可。时间轴缩放通过 Toolbar 滑条控制;弱点窗口 `DurationNormalized < 0.1` 时变红警告,对应设计规则(§10 Validator 等价)。 - diff --git a/Docs/Architecture/25_CharacterArchitectureOverview.md b/Docs/Architecture/25_CharacterArchitectureOverview.md index c075bfc1..26fbb142 100644 --- a/Docs/Architecture/25_CharacterArchitectureOverview.md +++ b/Docs/Architecture/25_CharacterArchitectureOverview.md @@ -1,9 +1,12 @@ # 25 · 角色系统架构全景图 -> **命名空间** `BaseGames.Player`、`BaseGames.Player.States`、`BaseGames.Enemies`、`BaseGames.Enemies.AI`、`BaseGames.Boss`、`BaseGames.Combat` +> **命名空间** `BaseGames.Player`、`BaseGames.Player.States`、`BaseGames.Enemies`、`BaseGames.Enemies.Abilities`、`BaseGames.AI`、`BaseGames.Combat` > **路径** `Assets/_Game/Scripts/Player/`、`Assets/_Game/Scripts/Enemies/`、`Assets/_Game/Scripts/Combat/` -> **关联文档** [05_PlayerModule](05_PlayerModule.md) · [06_CombatModule](06_CombatModule.md) · [07_EnemyModule](07_EnemyModule.md) · [09_ProgressionModule](09_ProgressionModule.md) · [23_BossSkillModule](23_BossSkillModule.md) -> **建立日期** 2026-05-17 +> **关联文档** [05_PlayerModule](05_PlayerModule.md) · [06_CombatModule](06_CombatModule.md) · [07_EnemyModule](07_EnemyModule.md) · [09_ProgressionModule](09_ProgressionModule.md) · [`Docs/Guides/08_EnemyAi_Authoring_Guide`](../Guides/08_EnemyAi_Authoring_Guide.md) · [`Docs/Guides/09_BossAi_Authoring_Guide`](../Guides/09_BossAi_Authoring_Guide.md) +> **建立日期** 2026-05-17  **§4 Boss 章节重写** 2026-07-30(Boss 与小怪单轨统一) +> +> ⚠️ §3.3「AI — Behavior Designer 节点库」仍描述已移除的第三方行为树插件,尚未重写; +> 当前决策层是自研 `BrainGraph`,写法以上方两份作者指南为准。 本文档是跨模块的**角色系统实现全景图**,侧重三类角色(玩家 / 小怪 / Boss)在 Scene 中的节点结构、脚本职责划分、子系统数据流与相互协作关系。各模块深度细节请参阅上方关联文档。 @@ -28,7 +31,7 @@ 4. [Boss](#4-boss) - 4.1 [继承关系](#41-继承关系) - 4.2 [Scene 节点结构](#42-scene-节点结构) - - 4.3 [技能执行流 BossSkillSO + BossSkillExecutor](#43-技能执行流-bossskillso--bossskillexecutor) + - 4.3 [一招的数据流](#43-一招的数据流) - 4.4 [阶段切换](#44-阶段切换) 5. [共享战斗层 Combat Module](#5-共享战斗层-combat-module) - 5.1 [核心接口](#51-核心接口) @@ -444,100 +447,110 @@ EnemyBase.TakeDamage(DamageInfo) ``` MonoBehaviour └── EnemyBase(IDamageable / ILOSRequester) - └── BossBase - ├── 多阶段切换:EnterPhase(int) + BossPhaseEventChannelSO 广播 - ├── IsHPBelow(float ratio) → BD_IsHPBelow 轮询条件 - └── override Die() → _onBossFightEnded.Raise(true) + └── BossBase(+ BaseGames.AI.IBossControl) + ├── 阶段:CurrentPhase / EnterPhase(int) + BossPhaseEventChannelSO 广播 + ├── 阶段过渡:BeginPhaseTransition(targetPhase, invincibleDuration) + │ → IsPhaseTransitioning(期间 IsInvincible 为真)→ EnterPhase + ├── IsHPBelow(float ratio):供 AI 图的条件边读取 + ├── 竞技场锚点:AnchorAt(i) / DistanceToAnchor(i)(转发 BossArenaAnchors) + └── override Die() → 中止阶段过渡 + _onBossFightEnded.Raise(true) └── [具体 Boss 类] - override EnterPhase → 额外过渡逻辑(动画/无敌帧/BD 子树切换) + override EnterPhase / OnBeginPhaseTransition → 额外过渡演出 ``` +**Boss 没有独立的技能体系。** 招式即敌人能力(`EnemyAbilitySO` + `EnemyAbilityBase`), +选招即 `EnemyAttackSelector`,决策即 `EnemyAiBrain` 上的 `AiScript` 图。 +Boss 专属的只有 `IBossControl` 这一个决策面加几个旁挂 MonoBehaviour。 + --- ### 4.2 Scene 节点结构 ``` -[Boss_XxxGuardian] -│ BossBase(具体 Boss 脚本,继承 BossBase) -│ ├── EnemyStats / EnemyMovement / EnemyCombat / EnemyFeedback -│ ├── BossSkillExecutor 接收 BD/Orchestrator 指令,执行 BossSkillSO 协程 -│ ├── WeakPointSystem 弱点激活窗口管理(BossSkillExecutor 技能后开启) -│ ├── TelegraphSystem 攻击预兆提示(激光标线 / 范围高亮 / 地面警示) -│ ├── BehaviorTree 含 BD_EnterPhase 的行为树资产 +[ENM_XxxBoss] +│ 具体 Boss 脚本(继承 BossBase,实现 IBossControl) +│ ├── EnemyStats / EnemyMovement / EnemyLocomotion / EnemyFeedback +│ ├── GroundNavigator(或 FlyingNavigator) +│ ├── PhysicsPerceptionSystem 感知(追逐区 / 视野 / 攻击范围) +│ ├── EnemyAiBrain 决策层(_definitionId 指向 [AiDefinition] 的 AiScript) +│ ├── BossPhaseAbilityGate 阶段 = 换招池:按阶段启停能力组件 +│ ├── BossArenaAnchors(可选) 竞技场定点走位的锚点集合 +│ ├── EnemyAbilityBase × N 每招一个组件,各自引用一份 EnemyAbilitySO +│ ├── BossResource(可选) 怒气 / 充能;是否已满经 IBossControl.ResourceFull 读取 │ └── AnimancerComponent │ ├── HurtBox 主体受击盒(Layer=EnemyHurtBox) +│ └── 弱点受击盒同样是 HurtBox,靠 _damageMultiplier 表达倍率,无独立弱点系统 │ -├── WeakPoint_Head 弱点受击盒 -│ └── HurtBox(弱点专属,WeakPointSystem 控制激活/关闭) +├── ContactDamageZone 身体接触伤害(常驻,Layer=EnemyHitBox) +├── HitBox_XXX × N 各近战招的判定盒(默认禁用,由动画事件按时机开关) +├── XxxMuzzle × N 弹体发射点 │ -├── HitBox_Slam 近战攻击 HitBox(Layer=EnemyHitBox) -├── HitBox_Sweep 横扫 HitBox(可与 BossSkillSO 绑定) -│ -└── BossArenaBlocker(可选) 竞技场封锁触发器(Boss 战开始时封门) +└── [场景侧] BossFightTrigger 房间入口触发区:发开战事件 + AiSignal.Engaged ``` --- -### 4.3 技能执行流 BossSkillSO + BossSkillExecutor +### 4.3 一招的数据流 ``` -BossSkillSO(CreateAssetMenu: BaseGames/Boss/BossSkill) -├── skillId / displayName -├── BossSkillType(枚举) -├── AttackPatternSO -│ ├── DamageSource(DamageSourceSO) -│ ├── KnockbackAngle -│ ├── 弹幕参数:ProjectilePrefab / Count / SpreadAngle / Speed -│ ├── AoE 参数:Radius / Offset -│ └── 时序:WindupDuration / ActiveDuration / RecoveryDuration -├── VulnerabilityWindowDuration 技能结束后弱点暴露时长(秒) -└── SkillSequenceSO(可选) 组合技序列(按顺序或随机排列多个 BossSkillSO) +EnemyAbilitySO(CreateAssetMenu: BaseGames/Enemies/Enemy Ability,前缀 ABL_) +├── abilityId / designNote +├── attackSequence: EnemyAttackSO[] 一招内的多段 +├── cooldown 从执行结束起计 +├── interruptOnHurt / interruptOnStagger 中断规则 +├── category / weight / priority 选招器用 +├── rangeRadius / rangeOffset 招式自管的圆形触发范围 +└── requiresGrounded / requiresLineOfSight 选招硬门 -BossSkillExecutor.ExecuteSkill(BossSkillSO) - │ if _isExecuting → return - │ - └── StartCoroutine(ExecuteSkillCoroutine) - ├── _onBossSkillStarted.Raise(BossSkillEvent) - ├── TelegraphSystem.ShowTelegraph(skill.pattern) 预兆提示激活 - ├── WaitForSeconds(WindupDuration) 前摇等待 - ├── 激活 HitBox[] / ProjectileManager.Spawn(pattern) 伤害输出 - ├── WaitForSeconds(ActiveDuration) 活跃帧等待 - ├── 关闭 HitBox / 清理弹幕 - ├── WeakPointSystem.OpenWindow(VulnerabilityWindowDuration) 弱点窗口 - ├── WaitForSeconds(RecoveryDuration) 后摇等待 - └── _onBossSkillEnded.Raise(BossSkillEvent) +EnemyAttackSO(前缀 EATK_)—— 一段动作的时间轴 +├── clip: ClipTransition(Animancer) +├── hitBoxSlot / hitBoxEnterT / hitBoxExitT 判定开关的归一化时机 +├── damageSource: DamageSourceSO +├── projectileConfig / projectileCount / projectileFireT +└── hasPoiseWindow / poiseLevel / poiseStartT / poiseEndT -WaitForSeconds 缓存: - static Dictionary _wfsCache - [RuntimeInitializeOnLoadMethod] ClearWFSCache() - → 避免协程每次 new WaitForSeconds 的 GC 分配 +执行链: +AI 图的攻击态 OnEnter + └── IAiContext.Combat.UseBestAttack() + └── EnemyAttackSelector 从 category==Attack 且 CanUse 的能力里选一个 + (CanUse 含 enabled、冷却、射程、grounded / LOS 硬门; + WeightedRandomAntiRepeat 模式对上次选中的招施加权重折扣) + └── EnemyAbilityBase.Execute():逐段播 clip, + 判定 / 生成 / 音效 / 无敌帧全部由 clip 上的动画事件驱动 + (EnemyAnimationEvents.HandleEvent) ``` -**InterruptCurrentSkill()**:阶段切换时强制停止当前协程并调用 `FinishExecution()`,确保状态一致性。 - --- ### 4.4 阶段切换 ``` -BD_IsHPBelow(每帧轮询) - └── BossBase.IsHPBelow(ratio) = CurrentHP / MaxHP < ratio - → true 时 BD 子树激活 BD_EnterPhase +AI 图的条件边(如 x.Vitals.HpBelow(0.5f)) + └── 转入阶段过渡态(BossFragments.PhaseTransition) + OnEnter:Locomotion.Stop() + Combat.InterruptAbilities() + + IBossControl.BeginPhaseTransition(targetPhase, invincibleDuration) -BD_EnterPhase → BossBase.EnterPhase(phase) - ├── _currentPhase = phase - ├── BossPhaseEventChannelSO _onBossPhaseChanged.Raise(BossPhaseEvent{BossId, Phase}) - │ → Boss HP 条分段动画(UI) - │ → 音乐系统切换 BGM 段落 - │ → VFX 场景效果触发 - └── [子类重写 EnterPhase] - ├── BossSkillExecutor.InterruptCurrentSkill() 打断当前技能 - ├── 切换 BD 行为树活跃子树(换招式库) - ├── 播放阶段过渡动画(Animancer) - └── 短暂无敌帧(保护阶段切换不被秒杀) +BossBase.BeginPhaseTransition + ├── IsPhaseTransitioning = true → IsInvincible 随之为真 + ├── OnBeginPhaseTransition(targetPhase) [子类重写:浮空 / 过渡动画] + ├── 等待 invincibleDuration + ├── EnterPhase(targetPhase) + │ ├── _currentPhase = phase + │ ├── BossPhaseAbilityGate.ApplyPhase(phase) + │ │ → 按阶段表设置各能力组件的 enabled + │ │ → 被禁用的能力 CanUse 为 false,自动从选招器候选消失 + │ └── _onBossPhaseChanged.Raise(BossPhaseEvent{BossId, Phase}) + │ → Boss HP 条分段动画 / BGM 段落切换 / 场景 VFX + └── IsPhaseTransitioning = false + → AI 图上 .To(下一态).When(x => !x.Boss.IsPhaseTransitioning) 放行 ``` +**阶段不是 SO 上的字段,也不是选招器里的过滤条件——阶段就是"哪些能力组件当前启用"。** +`BossBase.OnSpawn` 会 `ApplyPhase(0)`,保证对象池复用时回到初始招池。 + +作者写法详见 [`Docs/Guides/09_BossAi_Authoring_Guide.md`](../Guides/09_BossAi_Authoring_Guide.md)。 + --- ## 5. 共享战斗层 Combat Module @@ -613,10 +626,10 @@ StatusEffectEventChannelSO 广播效果开始/结束(UI 状态图标) | `ProjectileConfigSO` | `BaseGames.Combat` | 投射物速度/碰撞层/生命时长/弹幕参数 | | `EnemyStatsSO` | `BaseGames.Enemies` | 敌人 HP/Defense/速度/攻击冷却 | | `EnemyAnimationConfigSO` | `BaseGames.Enemies` | 敌人动画 Clip 集合 | -| `AttackPatternSO` | `BaseGames.Enemies`(Boss) | 攻击图案:伤害/弹幕/AoE/时序 | -| `BossSkillSO` | `BaseGames.Enemies`(Boss) | Boss 技能全量配置 | -| `SkillSequenceSO` | `BaseGames.Enemies`(Boss) | Boss 组合技序列(有序/随机) | -| `BossResourceConfigSO` | `BaseGames.Enemies`(Boss) | Boss 阶段资源(HP 阈值/技能池) | +| `EnemyAbilitySO` | `BaseGames.Enemies.Abilities` | 一招的全量配置(id/冷却/权重/射程/中断规则);小怪与 Boss 共用 | +| `EnemyAttackSO` | `BaseGames.Enemies.Abilities` | 一段动作的时间轴(clip + 判定/弹体/霸体的归一化时机) | +| `AiRecipeSO` | `BaseGames.Enemies` | 小怪 AI 配方(未发现层 + 交战层两个可插拔模块)。Boss 不用配方,走 `[AiDefinition]` 定制图 | +| `BossResourceConfigSO` | `BaseGames.Enemies`(Boss) | Boss 资源(怒气/充能)配置 | **设计原则**:所有运行时组件在 `Awake` 中通过 `Initialize(SO)` 接收配置,与数据资产完全解耦,支持 Inspector 热替换和难度 A/B 测试,无需修改脚本代码。 diff --git a/Docs/Architecture/README.md b/Docs/Architecture/README.md index 7e3e5807..21664ef8 100644 --- a/Docs/Architecture/README.md +++ b/Docs/Architecture/README.md @@ -32,7 +32,7 @@ | [20](./20_ShieldModule.md) | 护盾模块 | ShieldComponent、ShieldConfigSO、IShieldable、护盾破碎/恢复管道 | 05、13 | | [21](./21_LiquidPuzzleModule.md) | 液体谜题模块 | LiquidZone、LiquidPhysicsConfigSO、SwimState、PuzzleSwitch/PuzzleReceiver、WorldMarker | 08、41 | | [22](./22_QuestChallengeModule.md) | 任务与挑战模块 | QuestManager、QuestSO、QuestObjectiveSO、ChallengeRoom、QuestEventChannelSO | 37、38、39 | -| [23](./23_BossSkillModule.md) | Boss 技能模块 | BossSkillSO、BossSkillExecutor、SkillSequenceSO、VulnerabilityWindow、WeakPointSystem | 19、47、48 | +| ~~23~~ | ~~Boss 技能模块~~ | **已于 2026-07-30 删除。** 旧 Boss 技能轨(`BossSkillSO` / `BossSkillExecutor` / `SkillSequenceSO` / `WeakPointSystem`)整体退役,Boss 与小怪统一走 `EnemyAbilitySO` + `EnemyAiBrain`。实现见 07;写法见 [`Docs/Guides/09_BossAi_Authoring_Guide.md`](../Guides/09_BossAi_Authoring_Guide.md) | 07 | | [24](./24_AnimEventModule.md) | 动画事件模块 | PlayerAnimationEvents、EnemyAnimationEvents、AnimEventBridge、Animancer 事件回调 | 03 | --- diff --git a/Docs/Design/19_BossPatternLibrary.md b/Docs/Design/19_BossPatternLibrary.md index 6952cd55..802f2307 100644 --- a/Docs/Design/19_BossPatternLibrary.md +++ b/Docs/Design/19_BossPatternLibrary.md @@ -1,8 +1,26 @@ # 19 · Boss 攻击模式库(Boss Pattern Library) -> **命名空间** `BaseGames.Enemies.Boss.Patterns` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) -> **依赖** `BaseGames.Enemies` · `BaseGames.Combat` · `BaseGames.Feedback` · `BaseGames.Core.Events` · BehaviorDesigner · Animancer +> **文档性质** 设计意图文档 + +--- + +> ## ⚠️ 本文描述的实现方案已废止(2026-07-30) +> +> 文中的 `AttackPatternSO` / `BossOrchestrator` / `TelegraphSystem` 三个类型**从未落地**, +> 并已随旧 Boss 技能轨一并退役。当前实现: +> +> | 本文提出的 | 实际由谁承担 | +> |---|---| +> | `AttackPatternSO`(攻击模式数据层) | `EnemyAbilitySO` + `EnemyAttackSO`(小怪与 Boss 共用一套) | +> | `BossOrchestrator`(运行时编排器) | `EnemyAiBrain` 上的 `AiScript` 定制图;选招归 `EnemyAttackSelector` | +> | `TelegraphSystem`(预警系统) | 动画时间轴:预警是 clip 的一段,判定/VFX/音效/无敌帧全挂动画事件 | +> | 阶段切换换招池 | `BossPhaseAbilityGate`(按阶段启停能力组件) | +> +> **本文的设计意图(攻击模式的节奏分层、预警的可读性、阶段递进的学习曲线)仍然有效**, +> 但类型名与代码结构一律以下列文档为准: +> - [`Docs/Guides/09_BossAi_Authoring_Guide.md`](../Guides/09_BossAi_Authoring_Guide.md)(作者指南) +> - `Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md`(设计依据) --- diff --git a/Docs/Design/47_BossSkillSystem.md b/Docs/Design/47_BossSkillSystem.md deleted file mode 100644 index 79e013bb..00000000 --- a/Docs/Design/47_BossSkillSystem.md +++ /dev/null @@ -1,875 +0,0 @@ -# 47 · Boss 技能系统框架(Boss Skill System Framework) - -> **命名空间** `BaseGames.Enemies.Boss.Skills` -> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) -> **依赖文档** -> - [04_CombatSystem](./04_CombatSystem.md) — `DamageInfo` / `HitBox` / `HurtBox` 底层 -> - [19_BossPatternLibrary](./19_BossPatternLibrary.md) — `AttackPatternSO` / `BossOrchestrator` / 阶段系统 -> - [21_SpellSystem](./21_SpellSystem.md) — `FormSkillSO` / 玩家技能(反制参照) -> - [05_ParrySystem](./05_ParrySystem.md) — 弹反流水线 -> - [54_PoiseSystem](./54_PoiseSystem.md) — 霸体 / 打断等级(`PoiseWindowConfig` / `BreakLevel`) - ---- - -## 目录 - -1. [系统定位与层次关系](#1-系统定位与层次关系) -2. [BossSkillSO — 技能数据层](#2-bossskillso--技能数据层) -3. [Boss 技能分类体系](#3-boss-技能分类体系) -4. [弱点与脆弱窗口系统](#4-弱点与脆弱窗口系统) -5. [玩家反制接口](#5-玩家反制接口) -6. [Boss 技能序列系统](#6-boss-技能序列系统) -7. [Boss 资源系统(愤怒 / 状态充能)](#7-boss-资源系统愤怒--状态充能) -8. [场景联动接口](#8-场景联动接口) -9. [平衡数据模板](#9-平衡数据模板) -10. [编辑器工作流](#10-编辑器工作流) -11. [事件频道扩展](#11-事件频道扩展) -12. [资产路径规范](#12-资产路径规范) - ---- - -## 1. 系统定位与层次关系 - -### 1.1 为什么需要 BossSkillSystem? - -`19_BossPatternLibrary` 的 `AttackPatternSO` 描述**单次攻击动作**(前摇/活跃帧/后摇/弹射物), -`BossSkillSystem` 在其上管理**更高阶的技能语义**: - -| 关注点 | AttackPatternSO | BossSkillSO | -|--------|----------------|-------------| -| 粒度 | 单个动作片段 | 一个完整技能(可包含多段动作)| -| 核心数据 | 时序 / 伤害 / 弹射物 | 分类 / 反制接口 / 脆弱窗口 / 序列编排 | -| 执行者 | `BossOrchestrator` | `BossSkillExecutor`(包装 Orchestrator)| -| 平衡视角 | 单次伤害值 | 技能风险/收益比、战斗节奏 | - -### 1.2 层次关系图 - -``` -┌─────────────────────────────────────────────────────────┐ -│ Boss 战斗管线 │ -│ │ -│ BossSkillSO ──────────────────────────────────────┐ │ -│ │ 技能语义 / 分类 / 反制接口 / 脆弱窗口 │ │ -│ ▼ │ │ -│ SkillSequenceSO (可选) │ │ -│ │ 多步骤序列(前置技 → 触发技) │ │ -│ ▼ │ │ -│ AttackPatternSO ←── 19_BossPatternLibrary │ │ -│ │ 单动作:时序 / 弹射物 / 预警 / 权重 │ │ -│ ▼ │ │ -│ BossOrchestrator ←── 19_BossPatternLibrary │ │ -│ │ 运行时选择 → 执行 │ │ -│ ▼ │ │ -│ HitBox / DamageInfo ←── 04_CombatSystem │ │ -│ │ │ -│ ◄──── WeakPointSystem (脆弱窗口) ─────────────────┘ │ -│ ◄──── PlayerInteractionContract (玩家反制接口) │ -│ ◄──── ArenaIntegration (场景联动) │ -└─────────────────────────────────────────────────────────┘ -``` - -### 1.3 设计原则 - -1. **分类先于设计** — 每个 Boss 技能必须在开发前确定其 `BossSkillType` 和 `InteractionTag` -2. **反制可读性** — 每个技能必须明确"玩家用什么技能/动作可以反制/利用" -3. **脆弱窗口强制存在** — 每个技能至少有一个 `VulnerabilityWindow`(后摇 ≥ 0.5s 或专属弱点) -4. **不与 AttackPatternSO 重复** — 伤害数值/时序数据保留在 AttackPatternSO,不在 BossSkillSO 中重复 - ---- - -## 2. BossSkillSO — 技能数据层 - -```csharp -[CreateAssetMenu(menuName = "Enemies/Boss/BossSkill")] -public class BossSkillSO : ScriptableObject -{ - // ───────────────────────────── - // 1. 基础信息(策划填写) - // ───────────────────────────── - - [Header("基础信息")] - public string skillId; // 唯一 ID,格式: {BossId}_{SkillName} - public string displayName; // 设计阶段展示名(不用于玩家 UI) - [TextArea(1, 4)] - public string designNote; // 设计意图(该技能考察玩家什么能力) - - // ───────────────────────────── - // 2. 分类标签 - // ───────────────────────────── - - [Header("分类")] - public BossSkillCategory category; // 主分类(见 §3) - public InteractionTag[] tags; // 可多选(见 §3.2) - public PhaseAvailability phaseAvail; // 在哪些阶段可用 - - // ───────────────────────────── - // 3. 核心攻击动作引用 - // ───────────────────────────── - - [Header("攻击动作")] - [Tooltip("构成此技能的 AttackPatternSO 序列(单个技能 = 数组长度 1;连击/多段 = 多个)")] - public AttackPatternSO[] attackPatterns; // 按执行顺序排列 - - // ───────────────────────────── - // 4. 脆弱窗口(见 §4) - // ───────────────────────────── - - [Header("脆弱窗口")] - public VulnerabilityWindowConfig vulnerabilityWindow; - - // ───────────────────────────── - // 5. 玩家反制接口(见 §5) - // ───────────────────────────── - - [Header("玩家反制接口")] - public PlayerCounterResponse[] counterResponses; // 被不同玩家技能反制时的反应 - - // ───────────────────────────── - // 6. 场景联动(见 §8) - // ───────────────────────────── - - [Header("场景联动")] - public ArenaEventTrigger[] arenaEvents; // 此技能触发的场景事件 - - // ───────────────────────────── - // 7. 资源消耗(Boss 资源系统) - // ───────────────────────────── - - [Header("Boss 资源")] - public BossResourceCost resourceCost; // 使用此技能消耗的 Boss 自身资源(见 §7) - public bool buildsRage; // 使用后是否积累愤怒值 - - // ───────────────────────────── - // 8. 霸体配置(新增) - // ───────────────────────────── - - [Header("霸体配置")] - [Tooltip("此技能执行期间的霸体窗口(None = 可被玩家攻击打断),见 54_PoiseSystem §8")] - public PoiseWindowConfig poiseWindow; // 见 54_PoiseSystem §8 -} -``` - ---- - -## 3. Boss 技能分类体系 - -### 3.1 主分类(BossSkillCategory) - -```csharp -public enum BossSkillCategory -{ - // ── 主动攻击 ── - Melee, // 近战物理攻击(覆盖正前方/扇形/冲刺) - Ranged, // 远程弹射物攻击 - Charge, // 冲刺突进类 - AoE, // 全场/大范围爆炸类 - Environmental, // 激活/改变场景(召唤陷阱/破坏平台/生成危险区域) - Summon, // 召唤小怪/分身/辅助体 - - // ── 特殊状态 ── - Buff, // Boss 自身增益(加速/护甲/无敌) - Debuff, // 对玩家施加负面状态(减速/沉默/中毒/禁锢) - Phase, // 阶段转换技(仅在 HP 阈值触发,不参与随机选择) - - // ── 被动 ── - Passive, // 持续生效的被动机制(不通过 BossOrchestrator 选取) - Reactive, // 响应玩家行为的反应技(玩家使用技能 → Boss 反制) -} -``` - -### 3.2 交互标签(InteractionTag) - -可多选的属性标记,用于确定技能与玩家系统的交互方式: - -```csharp -[Flags] -public enum InteractionTag -{ - None = 0, - Parryable = 1 << 0, // 可弹反(连接 ParrySystem) - PerfectParryOnly = 1 << 1, // 仅完美弹反有效 - DodgeWindow = 1 << 2, // 有明确的冲刺规避窗口 - Unblockable = 1 << 3, // 无法弹反 / 无法格挡 - CanBeReflected = 1 << 4, // 弹射物可被反弹回来 - ExposesWeakPoint = 1 << 5, // 使用后暴露弱点(触发 VulnerabilityWindow) - GrantsPlayerReso = 1 << 6, // 弹反/反制成功后为玩家提供资源(灵力/魄元) - ArenaHazard = 1 << 7, // 改变 Arena 状态(持续危险) - PhaseGate = 1 << 8, // 阶段转换技(不可被打断) -} -``` - -### 3.3 风险/收益分级 - -每个技能在设计阶段应确定其**威胁等级**与**反制收益**: - -| 等级 | 威胁(对玩家的压力)| 反制收益(成功反制的奖励)| 典型形态 | -|------|-----------------|------------------------|---------| -| Low | 低伤 / 可轻松规避 | 小量灵力 | 普通近战、单发弹射物 | -| Mid | 中伤 / 需要主动应对 | 中量灵力 + 脆弱窗口 | 连击、冲刺 | -| High | 高伤 / 必须精准反制 | 大量灵力 + 长脆弱窗口 | 超级技、召唤 | -| Lethal | 极高伤 / 全场 AoE | 击杀后阶段转换 / 大量灵力 | 阶段转换技 | - -```csharp -public enum SkillThreatLevel { Low, Mid, High, Lethal } -public enum CounterRewardLevel { None, Small, Medium, Large, Phase } -// 两者都作为 BossSkillSO 的设计标注字段(不参与运行时逻辑) -``` - ---- - -## 4. 弱点与脆弱窗口系统 - -### 4.1 VulnerabilityWindowConfig - -定义 Boss 在执行技能**后**何时、何处、如何暴露弱点: - -```csharp -[Serializable] -public struct VulnerabilityWindowConfig -{ - [Header("触发条件")] - public VulnTriggerType triggerType; // 何时触发(见下方枚举) - public float triggerDelay; // 从触发条件满足到窗口开启的延迟(秒) - - [Header("持续时间")] - public float duration; // 脆弱窗口持续时长(秒) - - [Header("弱点类型")] - public WeakPointType weakPointType; // 弱点位置/类型 - public float damageMultiplier;// 弱点期间受到伤害的倍率(默认 1.5) - public bool forceStagger; // 是否强制使 Boss 进入硬直动画 - public float staggerDuration; // 强制硬直时长(秒) - - [Header("视觉反馈")] - public FeedbackPresetSO openFeedback; // 窗口开启时的 VFX/SFX - public FeedbackPresetSO closeFeedback; // 窗口关闭时的 VFX/SFX - public Color highlightColor; // 弱点高亮颜色(材质颜色叠加) -} - -public enum VulnTriggerType -{ - OnAttackRecovery, // 攻击后摇阶段自动触发(最常用) - OnParriedSuccess, // 被成功弹反后触发 - OnCounterSkillHit, // 被特定玩家技能命中后触发 - OnPhaseTransition, // 阶段转换动画期间触发 - OnHazardBackfire, // 自己的场景危险伤到自己后触发 - OnSummonDefeated, // 召唤物被击败后触发 - Manual, // 由 Animator / Timeline 手动触发 -} - -public enum WeakPointType -{ - FullBody, // 全身都是弱点 - HeadOnly, // 仅头部 - BackOnly, // 仅背部(需要玩家绕后) - CoreExposed, // 核心部位暴露(通常在特定动画帧) - CustomPoint, // 自定义坐标(用 Transform 引用) -} -``` - -### 4.2 BossWeakPointTracker(运行时组件) - -```csharp -namespace BaseGames.Enemies.Boss.Skills -{ - /// - /// 挂载于 BossBase,追踪并管理当前活跃的脆弱窗口。 - /// 由 BossSkillExecutor 在技能执行过程中驱动。 - /// - public class BossWeakPointTracker : MonoBehaviour - { - [SerializeField] VoidEventChannelSO _onVulnerabilityOpened; - [SerializeField] VoidEventChannelSO _onVulnerabilityClosed; - - bool _isVulnerable; - float _damageMultiplierOverride = 1f; - WeakPointType _activeWeakPoint; - Coroutine _windowCoroutine; - - public bool IsVulnerable => _isVulnerable; - public float DamageMultiplierOverride => _damageMultiplierOverride; - - public void OpenWindow(VulnerabilityWindowConfig cfg) - { - if (_windowCoroutine != null) StopCoroutine(_windowCoroutine); - _windowCoroutine = StartCoroutine(RunWindow(cfg)); - } - - public void ForceCloseWindow() - { - if (_windowCoroutine != null) StopCoroutine(_windowCoroutine); - CloseWindow(); - } - - // ── HurtBox 伤害修正 Hook ── - // 当 BossHurtBox.ReceiveDamage 被调用时,检查 IsVulnerable - // 并将 DamageInfo 的 FinalDamage 乘以 DamageMultiplierOverride - // 此修正在 HurtBox 层完成,BossSkillSystem 无需感知具体伤害计算 - - IEnumerator RunWindow(VulnerabilityWindowConfig cfg) - { - yield return new WaitForSeconds(cfg.triggerDelay); - _isVulnerable = true; - _damageMultiplierOverride = cfg.damageMultiplier; - _activeWeakPoint = cfg.weakPointType; - cfg.openFeedback?.Play(transform.position); - _onVulnerabilityOpened.Raise(); - - yield return new WaitForSeconds(cfg.duration); - CloseWindow(); - cfg.closeFeedback?.Play(transform.position); - } - - void CloseWindow() - { - _isVulnerable = false; - _damageMultiplierOverride = 1f; - _onVulnerabilityClosed.Raise(); - } - } -} -``` - ---- - -## 5. 玩家反制接口 - -### 5.1 设计意图 - -每个 Boss 技能必须声明**玩家可用哪些动作/技能来反制它**,以及**反制成功后发生什么**。 -这保证了:策划在设计技能时同时设计反制路径;代码层提供一致的反制响应框架。 - -### 5.2 PlayerCounterResponse - -```csharp -[Serializable] -public struct PlayerCounterResponse -{ - [Header("反制条件")] - public CounterType counterType; // 玩家用什么触发反制 - public string requiredSkillId; // counterType = SpecificSkill 时填写技能 ID - - [Header("反制效果(对 Boss)")] - public float bossStaggerDuration; // 强制硬直时长(秒,0 = 不强制硬直) - public float bossDamageBonus; // 对 Boss 的额外伤害倍率(0 = 不额外加) - public bool openVulnWindow; // 是否触发 VulnerabilityWindow - public bool interruptSkill; // 是否打断 Boss 当前技能 - - [Header("反制收益(对玩家)")] - public int soulPowerGrant; // 给予玩家的灵力数量 - public int spiritPowerGrant; // 给予玩家的魄元数量 - public FeedbackPresetSO counterFeedback; // 成功反制的特效/音效反馈 -} - -public enum CounterType -{ - Parry, // 弹反成功 - PerfectParry, // 完美弹反 - DodgeThrough, // 冲刺穿越攻击(无敌帧通过) - SpecificSkill, // 使用特定技能(填 requiredSkillId) - WeakPointHit, // 命中暴露的弱点 - HazardBackfire, // 利用场景危险伤到 Boss - SummonKill, // 击败召唤物 -} -``` - -### 5.3 反制接口的运行时处理 - -```csharp -// BossSkillExecutor 在技能生命周期内注册/注销反制监听 -public class BossSkillExecutor : MonoBehaviour -{ - // 在 StartPattern 时调用: - void RegisterCounters(BossSkillSO skill) - { - foreach (var resp in skill.counterResponses) - { - CounterEventBus.Subscribe(resp.counterType, resp.requiredSkillId, - () => ApplyCounterResponse(resp)); - } - } - - // 在 EndPattern 时调用: - void UnregisterCounters(BossSkillSO skill) { /* 同步注销 */ } - - void ApplyCounterResponse(PlayerCounterResponse resp) - { - if (resp.interruptSkill) _orchestrator.AbortCurrentPattern(); - if (resp.bossStaggerDuration > 0) ApplyStagger(resp.bossStaggerDuration); - if (resp.openVulnWindow) _weakPointTracker.OpenWindow(/* 从技能配置取 */); - if (resp.soulPowerGrant > 0) _onGrantPlayerSoulPower.Raise(resp.soulPowerGrant); - if (resp.spiritPowerGrant > 0)_onGrantPlayerSpiritPower.Raise(resp.spiritPowerGrant); - resp.counterFeedback?.Play(transform.position); - } -} -``` - ---- - -## 6. Boss 技能序列系统 - -### 6.1 序列的定义 - -"技能序列"指一组技能按**条件/状态**依次执行,形成更复杂的战术模式: - -- **线性序列**:技能 A → 等待 → 技能 B(连招/二段技) -- **分支序列**:技能 A → 判断玩家位置 → 技能 B 或技能 C -- **条件序列**:技能 A 成功命中 → 追加技能 B(否则取消) - -### 6.2 SkillSequenceSO - -```csharp -[CreateAssetMenu(menuName = "Enemies/Boss/SkillSequence")] -public class SkillSequenceSO : ScriptableObject -{ - public string sequenceId; - public SequenceNode[] nodes; // 序列中的节点列表(按执行顺序) -} - -[Serializable] -public class SequenceNode -{ - [Header("节点信息")] - public string nodeId; - public SequenceNodeType nodeType; - - // ── SkillNode 类型 ── - public BossSkillSO skill; // 要执行的技能 - - // ── WaitNode 类型 ── - public float waitDuration; // 等待时长(秒) - - // ── BranchNode 类型 ── - public SequenceBranchCondition branchCondition; - public string trueNodeId; // 条件为真时跳转到的节点 ID - public string falseNodeId; // 条件为假时跳转到的节点 ID - - // ── AbortCondition(所有节点通用)── - [Tooltip("满足此条件时提前终止序列")] - public SequenceAbortCondition abortCondition; -} - -public enum SequenceNodeType -{ - SkillNode, // 执行一个 BossSkillSO - WaitNode, // 等待固定时间 - BranchNode, // 条件分支 - ArenaEventNode,// 触发场景事件(不执行攻击) -} - -[Serializable] -public struct SequenceBranchCondition -{ - public BranchConditionType type; - [Range(0f,1f)] public float hpThreshold; // HPBelow 时使用 - public float range; // InRange 时使用 -} - -public enum BranchConditionType -{ - PlayerInRange, - PlayerAbove, - HPBelow, - LastSkillParried, // 上一个技能被弹反 - LastSkillHit, // 上一个技能命中了玩家 -} -``` - -### 6.3 BossSequenceRunner - -```csharp -/// -/// 运行 SkillSequenceSO 的协程控制器,挂载于 BossBase。 -/// 由 BehaviorDesigner 的 Action Task RunBossSequence 调用。 -/// -public class BossSequenceRunner : MonoBehaviour -{ - public bool IsRunning { get; private set; } - - public void RunSequence(SkillSequenceSO sequence, Transform player, - System.Action onComplete = null) - { - if (IsRunning) return; - StartCoroutine(ExecuteSequence(sequence, player, onComplete)); - } - - public void AbortSequence() { StopAllCoroutines(); IsRunning = false; } - - IEnumerator ExecuteSequence(SkillSequenceSO seq, Transform player, - System.Action onComplete) - { - IsRunning = true; - var nodeMap = seq.nodes.ToDictionary(n => n.nodeId); - var node = seq.nodes[0]; // 从第一个节点开始 - - while (node != null) - { - // 检查提前终止 - if (CheckAbort(node.abortCondition)) break; - - switch (node.nodeType) - { - case SequenceNodeType.SkillNode: - yield return ExecuteSkillNode(node.skill, player); - node = GetNextNode(nodeMap, node, isSequential: true); - break; - - case SequenceNodeType.WaitNode: - yield return new WaitForSeconds(node.waitDuration); - node = GetNextNode(nodeMap, node, isSequential: true); - break; - - case SequenceNodeType.BranchNode: - bool cond = EvaluateBranch(node.branchCondition, player); - string nextId = cond ? node.trueNodeId : node.falseNodeId; - node = string.IsNullOrEmpty(nextId) ? null : nodeMap.GetValueOrDefault(nextId); - break; - - case SequenceNodeType.ArenaEventNode: - TriggerArenaEvent(node); - node = GetNextNode(nodeMap, node, isSequential: true); - break; - } - } - - IsRunning = false; - onComplete?.Invoke(); - } - // ... 内部辅助方法省略(由具体 Boss 实现扩展) -} -``` - ---- - -## 7. Boss 资源系统(愤怒 / 状态充能) - -### 7.1 设计意图 - -部分 Boss 需要"积累状态"才能释放最强技能,或受到一定伤害后进入特殊形态。 -`BossResourceSystem` 提供一个轻量级、可配置的通用资源层,不强制每个 Boss 都使用。 - -### 7.2 BossResourceConfigSO - -```csharp -[CreateAssetMenu(menuName = "Enemies/Boss/ResourceConfig")] -public class BossResourceConfigSO : ScriptableObject -{ - public string resourceId; // 如 "Rage" / "PhaseCharge" - public string displayName; - public float maxValue; - public float startValue; // 初始值(通常 0) - - [Header("自动变化")] - public float passiveRate; // 每秒自动变化量(正=增长/负=衰减,0=不自动) - public float onTakeDamageGain; // 每受到 1 点伤害积累量 - public float onSkillUseGain; // 每使用一次技能积累量 - - [Header("满值效果")] - public bool autoTriggerOnFull; // 是否满值时自动触发效果 - public BossSkillSO fullTriggerSkill; // 触发的技能 - public float resetValueAfterTrigger; // 触发后重置到的值 -} - -public struct BossResourceCost -{ - public string resourceId; // 对应 BossResourceConfigSO.resourceId - public float cost; // 消耗量(0 = 不消耗资源) - public float minRequired;// 使用此技能的最低资源要求 -} -``` - -### 7.3 BossResourceTracker(运行时) - -```csharp -public class BossResourceTracker : MonoBehaviour -{ - BossResourceConfigSO _config; - float _current; - - public float Current => _current; - public float Ratio => _current / _config.maxValue; - public bool IsFull => _current >= _config.maxValue; - - public void Gain(float amount) - { - _current = Mathf.Clamp(_current + amount, 0, _config.maxValue); - if (IsFull && _config.autoTriggerOnFull) TriggerFullEffect(); - } - - public bool TryConsume(BossResourceCost cost) - { - if (cost.resourceId != _config.resourceId) return true; // 不相关资源直接通过 - if (_current < cost.minRequired) return false; - _current -= cost.cost; - return true; - } -} -``` - ---- - -## 8. 场景联动接口 - -### 8.1 设计意图 - -Boss 技能可以**触发场景变化**(破坏平台、激活陷阱、改变重力等),这些变化通过接口解耦,不在 BossSkillSO 中硬编码场景引用。 - -### 8.2 IArenaInteractable - -```csharp -/// -/// 场景中可被 Boss 技能交互的对象实现此接口。 -/// 无需引用 Boss;Boss 通过 ArenaEventBus 广播,场景监听并响应。 -/// -public interface IArenaInteractable -{ - string ArenaObjectId { get; } // 唯一标识(在 BossSkillSO.arenaEvents 中引用) - void OnBossArenaEvent(ArenaEventData data); -} -``` - -### 8.3 ArenaEventTrigger & ArenaEventData - -```csharp -[Serializable] -public struct ArenaEventTrigger -{ - public string targetArenaObjectId; // 要影响的场景物件 ID(空 = 广播给所有) - public ArenaEventType eventType; - public float delay; // 从技能触发到场景事件的延迟(秒) - public ArenaEventParams parameters; -} - -public enum ArenaEventType -{ - DestroyPlatform, // 破坏指定平台 - ActivateHazard, // 激活陷阱(如喷火管) - DeactivateHazard, // 关闭陷阱 - SpawnHazardArea, // 生成持续危险区域(熔岩/毒液水坑) - ShakeArena, // 场景震动 - ToggleLighting, // 切换场景光照(Boss 技能令场景变暗等) - SpawnPlatform, // 生成新平台(阶段 2 变化) - TriggerCutscene, // 触发小型过场(无需外部管理) -} - -[Serializable] -public struct ArenaEventParams -{ - public float duration; // 效果持续时间(0 = 永久或由物件自行管理) - public float intensity; // 强度(如震动幅度、危险区域半径) - public bool revertsOnPhaseEnd; // 阶段结束时是否恢复 -} - -[Serializable] -public struct ArenaEventData -{ - public ArenaEventType type; - public ArenaEventParams parameters; - public string sourceSkillId; // 来源技能 ID(供物件做条件判断) -} -``` - -### 8.4 ArenaEventBus(单例) - -```csharp -public static class ArenaEventBus -{ - static readonly Dictionary> _registry = new(); - - public static void Register(IArenaInteractable obj) - { - if (!_registry.TryGetValue(obj.ArenaObjectId, out var list)) - _registry[obj.ArenaObjectId] = list = new List(); - list.Add(obj); - } - - public static void Unregister(IArenaInteractable obj) { /* ... */ } - - public static void Dispatch(ArenaEventTrigger trigger) - { - var data = new ArenaEventData - { - type = trigger.eventType, - parameters = trigger.parameters, - sourceSkillId = "" // 由调用方填入 - }; - - if (string.IsNullOrEmpty(trigger.targetArenaObjectId)) - { - // 广播给所有注册物件 - foreach (var list in _registry.Values) - foreach (var obj in list) obj.OnBossArenaEvent(data); - } - else if (_registry.TryGetValue(trigger.targetArenaObjectId, out var targets)) - { - foreach (var obj in targets) obj.OnBossArenaEvent(data); - } - } -} -``` - ---- - -## 9. 平衡数据模板 - -### 9.1 BossSkillBalanceSheet(SO 内联数据,用于策划填写) - -```csharp -[Serializable] -public struct BossSkillBalanceNote -{ - // ── 设计阶段填写(不参与运行时逻辑)── - [Header("威胁评估")] - public SkillThreatLevel threatLevel; - public CounterRewardLevel counterReward; - - [Header("节奏设计")] - [Tooltip("此技能在战斗中的频率意图(低/中/高)")] - public SkillFrequencyHint frequencyHint; - [Tooltip("此技能是否作为阶段标志性技能")] - public bool isSignatureSkill; - - [Header("学习曲线")] - [Tooltip("玩家第几次遭遇此 Boss 时通常能学会应对此技能")] - public int expectedLearnEncounter; - [Tooltip("正确应对此技能所需的前置技能/知识")] - [TextArea(1,3)] - public string prerequisiteKnowledge; -} - -public enum SkillFrequencyHint { Rare, Occasional, Frequent } -``` - -### 9.2 平衡时序模板 - -策划在填写 `AttackPatternSO` 的时序数据时,参考以下基准: - -| 技能类型 | 预警时长参考 | 前摇参考 | 后摇参考(脆弱窗口)| -|---------|-----------|---------|-----------------| -| 轻型近战 | 0.3-0.5s | 0.15-0.2s | 0.3-0.5s | -| 重型近战 | 0.7-1.0s | 0.3-0.5s | 0.6-1.0s | -| 冲刺 | 0.5-0.8s | 0.2s | 0.4-0.6s(撞墙后)| -| 单发弹射 | 0.5-0.7s | 0.2s | 0.3-0.5s | -| 多发弹幕 | 0.8-1.2s | 0.3s | 0.5-0.8s | -| 召唤技 | 1.0-1.5s | 0.5s | 1.0-2.0s(召唤期间脆弱)| -| 阶段技 | 1.5-2.5s(无法打断)| 0.5-1.0s | 全段脆弱 | - -### 9.3 难度缩放协议 - -每个 `BossSkillSO` 对应的 `AttackPatternSO` 不直接存储难度缩放值; -缩放通过 `DifficultyScalerSO`(见 [29_DifficultySystem](./29_DifficultySystem.md))统一处理: - -``` -DifficultyScalerSO - ├─ damageMultiplier → 作用于 DamageSourceSO.BaseDamage - ├─ telegraphMultiplier → 作用于 AttackPatternSO.telegraphDuration(Easy 加长 / Hard 缩短) - ├─ recoveryMultiplier → 作用于 AttackPatternSO.recoveryDuration(Easy 加长脆弱 / Hard 缩短) - └─ phaseTransitionDelay → 阶段转换动画时长(Easy 加长逃跑时间) -``` - ---- - -## 10. 编辑器工作流 - -### 10.1 创建一个 Boss 技能的完整步骤 - -``` -Step 1 — 在 Inspector 中填写 BossSkillSO - ├─ 填写 skillId: {BossId}_{SkillName}(如 "SpiderGuard_WebShot") - ├─ 选择 category 和 tags - ├─ 选择 threatLevel 和 counterReward(平衡笔记) - └─ 填写 designNote(一两句话说明该技能的设计意图) - -Step 2 — 创建对应 AttackPatternSO(19_BossPatternLibrary §2) - ├─ 填写时序数据(参考 §9.2 时序模板) - ├─ 配置 TelegraphConfig(预警类型 + 资产) - ├─ 配置 DamageSourceSO(复用或新建) - └─ 将 AttackPatternSO 拖入 BossSkillSO.attackPatterns[] - -Step 3 — 配置脆弱窗口(VulnerabilityWindowConfig) - ├─ 选择 triggerType(通常 OnAttackRecovery) - ├─ 设置 duration 和 damageMultiplier - └─ 拖入 openFeedback / closeFeedback 预设 - -Step 4 — 配置玩家反制接口(PlayerCounterResponse[]) - ├─ 为每种有效反制方式添加一个 Response 条目 - ├─ 设置对 Boss 的影响(stagger / vulnWindow) - └─ 设置对玩家的资源奖励 - -Step 5 — 将 BossSkillSO 添加到 BossPhaseConfigSO.patterns[] - └─ 注意:BossPhaseConfigSO 中存放 AttackPatternSO; - BossSkillSO 是对 AttackPatternSO 的上层包装, - BossSkillExecutor 通过 BossSkillSO 间接驱动 BossOrchestrator - -Step 6 — 在 Editor PlayMode 中使用 BossSkillTestWindow 测试 - ├─ 选择 Boss Prefab - ├─ 点击技能名称强制触发该技能 - └─ 查看脆弱窗口和反制效果是否正常 -``` - -### 10.2 BossSkillTestWindow(编辑器工具) - -```csharp -// Editor/BossSkillTestWindow.cs -// 依赖:仅在 UnityEditor 命名空间下可用(UNITY_EDITOR 宏保护) -public class BossSkillTestWindow : EditorWindow -{ - GameObject _bossPrefab; - BossSkillSO _selectedSkill; - - // 功能: - // 1. 在 PlayMode 中选择场景内 Boss,列出其所有 BossSkillSO - // 2. 点击"强制触发"→ 调用 BossSkillExecutor.ForceExecuteSkill(skill) - // 3. 实时显示当前脆弱窗口状态(时长倒计时 + 伤害倍率) - // 4. 显示最近 10 次技能触发记录(时间戳 + 玩家是否成功反制) -} -``` - ---- - -## 11. 事件频道扩展 - -以下事件频道为 `BossSkillSystem` 专有,在 `00_Overview §7` 频道清单基础上补充: - -| 频道 SO 名称 | 类型 | 数据 | 说明 | -|------------|------|------|------| -| `BossSkill_OnSkillStarted` | `StringEventChannelSO` | skillId | 技能开始执行时广播 | -| `BossSkill_OnSkillEnded` | `StringEventChannelSO` | skillId | 技能结束时广播 | -| `BossSkill_OnVulnWindowOpened` | `VoidEventChannelSO` | — | 脆弱窗口开启 | -| `BossSkill_OnVulnWindowClosed` | `VoidEventChannelSO` | — | 脆弱窗口关闭 | -| `BossSkill_OnCounterSuccess` | `StringEventChannelSO` | counterType | 玩家反制成功 | -| `BossSkill_OnGrantSoulPower` | `IntEventChannelSO` | 灵力数量 | 反制奖励灵力 | -| `BossSkill_OnGrantSpiritPower` | `IntEventChannelSO` | 魄元数量 | 反制奖励魄元 | -| `BossSkill_OnArenaEventTriggered` | `StringEventChannelSO` | eventType | 场景联动事件触发 | -| `BossSkill_OnSequenceStarted` | `StringEventChannelSO` | sequenceId | 技能序列开始 | -| `BossSkill_OnSequenceEnded` | `StringEventChannelSO` | sequenceId | 技能序列结束 | - ---- - -## 12. 资产路径规范 - -``` -Assets/ScriptableObjects/Enemies/Boss/ - ├── {BossId}/ - │ ├── Skills/ - │ │ ├── BSK_{BossId}_{SkillName}.asset ← BossSkillSO - │ │ └── SEQ_{BossId}_{SequenceName}.asset ← SkillSequenceSO(可选) - │ ├── Phases/ - │ │ ├── BPC_{BossId}_Phase1.asset ← BossPhaseConfigSO(来自 19) - │ │ └── ... - │ ├── Patterns/ - │ │ └── AP_{BossId}_{PatternName}.asset ← AttackPatternSO(来自 19) - │ └── Resources/ - │ └── BRC_{BossId}_Rage.asset ← BossResourceConfigSO(可选) - └── Shared/ - └── DamageSource/ - └── DS_Boss_{Type}.asset ← 多 Boss 复用的 DamageSourceSO -``` - -命名约定: -- `BSK_` — BossSkillSO -- `SEQ_` — SkillSequenceSO -- `BPC_` — BossPhaseConfigSO -- `AP_` — AttackPatternSO -- `BRC_` — BossResourceConfigSO -- `DS_` — DamageSourceSO diff --git a/Docs/Design/69_BossDesignBible.md b/Docs/Design/69_BossDesignBible.md index 989fcb96..fd4cd4f6 100644 --- a/Docs/Design/69_BossDesignBible.md +++ b/Docs/Design/69_BossDesignBible.md @@ -1,7 +1,7 @@ # 69 · Boss 设计圣经(Boss Design Bible) > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) -> **关联文档** 19_BossPatternLibrary · 47_BossSkillSystem · 60_Lore_WorldBible · 63_AudioCreativeDirection · 48_EnemyRoster +> **关联文档** 19_BossPatternLibrary · 60_Lore_WorldBible · 63_AudioCreativeDirection · 48_EnemyRoster · 实现写法见 [`Docs/Guides/09_BossAi_Authoring_Guide.md`](../Guides/09_BossAi_Authoring_Guide.md)(原 47_BossSkillSystem 已于 2026-07-30 废止) > **文档性质** 每个 Boss 的独立设计规格。包含攻击序列、阶段结构、学习曲线、叙事角色、死亡设计。19_BossPatternLibrary 是工具,本文档是作品。 --- diff --git a/Docs/Design/README.md b/Docs/Design/README.md index ade32e86..96e1eb4c 100644 --- a/Docs/Design/README.md +++ b/Docs/Design/README.md @@ -57,7 +57,7 @@ | 44 | [关卡设计指南](./44_LevelDesignGuide.md) | 房间尺寸规范、难度曲线与节奏、敌人布置原则、危险物规范、收集品与秘密房间设计、平台路径参数、房间连通性、视觉叙事、Tilemap SOP、关卡验收清单 | | 45 | [教程与情境提示系统](./45_TutorialSystem.md) | ContextualHintTrigger、AbilityTutorialSequence、InGameHintPanel、TooltipPopup、TutorialManager、SaveData 集成(seenHints[])、多语言支持 | | 46 | [平台发布集成指南](./46_PlatformIntegration.md) | IPlatformService 抽象接口、Steam PC/Deck 集成、Steamworks.NET 成就桥接、Steam Cloud 存档同步、Steam Rich Presence、多平台手柄振动适配、构建配置、发布验收清单 | -| 47 | [Boss 技能系统框架](./47_BossSkillSystem.md) | BossSkillSO 数据层、技能分类体系(BossSkillCategory + InteractionTag)、脆弱窗口系统、玩家反制接口(PlayerCounterResponse)、技能序列系统(SkillSequenceSO)、Boss 资源/愤怒系统、场景联动接口(ArenaEventBus)、平衡数据模板、编辑器工作流 | +| ~~47~~ | ~~Boss 技能系统框架~~ | **已于 2026-07-30 删除。** 该框架从未实现,且已被"Boss 与小怪单轨统一"取代:招式即能力(`EnemyAbilitySO`),阶段即招池(`BossPhaseAbilityGate`),决策即 AI 图(`EnemyAiBrain`)。作者指南见 [`Docs/Guides/09_BossAi_Authoring_Guide.md`](../Guides/09_BossAi_Authoring_Guide.md) | | 48 | [敌人图鉴](./48_EnemyRoster.md) | 5 区域 × 6-7 只普通敌人 + 5 只区域精英怪完整设计、AI 复杂度分级(T1-T4)、弹反标记、掉落表、房间编队配置建议 | | 49 | [防软锁系统](./49_AntiSoftlockSystem.md) | SoftlockDetector(45s 无移动检测)、EscapeTeleporter(安全传送回存档点)、RoomEscapeInfoSO(每间房逃生路线)、HardAbilityGate(高风险门扩展)、EscapeGuaranteeValidator(BFS 可达性编辑器工具)| | 50 | [叙事设计系统](./50_NarrativeDesignSystem.md) | NarrativeNodeSO 故事节点、WorldStateRegistry 全局状态标志、NarrativeNPC+DialogueVersion 世界状态对话版本、NPCMigrationConfigSO、LoreItemSO(题记/遗物/回忆/回响碎片)、EndingGate 结局评估、5阶段叙事弧线、NPC 迁移路线表 | diff --git a/Docs/Guides/08_EnemyAi_Authoring_Guide.md b/Docs/Guides/08_EnemyAi_Authoring_Guide.md index 94ad3a76..fcbec410 100644 --- a/Docs/Guides/08_EnemyAi_Authoring_Guide.md +++ b/Docs/Guides/08_EnemyAi_Authoring_Guide.md @@ -7,6 +7,10 @@ 其中约 95% 只需建一个配方资产、两个下拉选一选,零代码零编译。读完本文档应该能在 5 分钟内判断 「我这个敌人该走哪条路径」,并在动手前看到已知的坑。 +> **要写的是 Boss?** Boss 与小怪跑同一条轨(同一套能力、同一个选招器、同一个 `EnemyAiBrain`), +> 但一律走定制脚本路径,另有阶段门与竞技场锚点等专属件。 +> 先读完本文的 §2 / §6,再看 [09_BossAi_Authoring_Guide](09_BossAi_Authoring_Guide.md)。 + --- ## 目录 @@ -141,7 +145,7 @@ Build(b): ### E004 蛭母 —— 定制脚本路径(Boss,多技能轮转 + 出场演出) -出场剧情 → 三技能轮转(撕咬/头槌连段/酸液远程,按距离和 CD/权重选择)→ 死亡分两阶段(挣扎前摇 → 爆体)。技能选择逻辑(按距离分层、CD 轮转)比 `ApproachAttackEngagement` 复杂得多,且需要「玩家绕后触发 Flip、但技能执行中不检测」这类与感知骨架无关的定制转换,属于"完全定制"的判据(与范式无关的独门机制)。Boss 是否要接入 `PerceptionSkeleton` 视具体实现取舍:若拆出"未发现(几乎不存在,出场即战斗)→ 交战(技能轮转视为一个大号 `Attack` 态)"两层意义不大,直接写定制脚本更直接。死亡链遵循 [§6](#6-常见陷阱) 的死亡规则,`Death_Pre`/`Death` 完全交给物理层(`EnemyDeathSequence` 演出 + `EnemyBase.PerformDeath`),AI 图里只需要一个 `Death` 终态兜住全局边。 +出场剧情 → 三技能轮转(撕咬/头槌连段/酸液远程,按距离和 CD/权重选择)→ 死亡分两阶段(挣扎前摇 → 爆体)。技能选择逻辑(按距离分层、CD 轮转)比 `ApproachAttackEngagement` 复杂得多,且需要「玩家绕后触发 Flip、但技能执行中不检测」这类与感知骨架无关的定制转换,属于"完全定制"的判据(与范式无关的独门机制)。Boss 是否要接入 `PerceptionSkeleton` 视具体实现取舍:若拆出"未发现(几乎不存在,出场即战斗)→ 交战(技能轮转视为一个大号 `Attack` 态)"两层意义不大,直接写定制脚本更直接。死亡链遵循 [§6](#6-常见陷阱) 的死亡规则,`Death_Pre`/`Death` 完全交给物理层(`EnemyDeathSequence` 演出 + `EnemyBase.PerformDeath`),AI 图里只需要一个 `Death` 终态兜住全局边。Boss 图的写法(建态原语、阶段门、开战触发)见 [09_BossAi_Authoring_Guide](09_BossAi_Authoring_Guide.md),已落地的 `ChaoFengAi` 是完整范例。 ### E005 肥蛭 —— 配方路径(精英怪,攻击选择器覆盖远近两招) diff --git a/Docs/Guides/09_BossAi_Authoring_Guide.md b/Docs/Guides/09_BossAi_Authoring_Guide.md new file mode 100644 index 00000000..c47e1297 --- /dev/null +++ b/Docs/Guides/09_BossAi_Authoring_Guide.md @@ -0,0 +1,424 @@ +# Boss AI 作者指南 + +> 文件位置:`Docs/Guides/09_BossAi_Authoring_Guide.md` +> 版本:1.0 · 适用项目:zeling_v2 +> 前置阅读:[08_EnemyAi_Authoring_Guide](08_EnemyAi_Authoring_Guide.md)(术语、建态原语、常见陷阱在那边讲过,本文不重复) + +本文面向**给项目新增一个 Boss 的人**。核心结论只有一句: + +> **Boss 与小怪跑的是同一条轨。** 同一套能力(`EnemyAbilitySO` + `EnemyAbilityBase`)、同一个选招器 +> (`EnemyAttackSelector`)、同一套建态原语(`AiStateFragments`)、同一个决策层组件(`EnemyAiBrain`)。 +> Boss 独有的只有三样:**手写的 `AiScript` 图**、**一个 `IBossControl` 决策面(阶段 + 竞技场锚点)**、 +> 以及几个旁挂的 MonoBehaviour(阶段门 / 锚点 / 开战触发)。 + +不存在"Boss 技能系统"这种独立体系。招式就是能力,阶段就是招池,没有第二套数据模型。 + +--- + +## 目录 + +1. [Boss 走定制路径](#1-boss-走定制路径) +2. [可用的建态原语](#2-可用的建态原语) +3. [阶段 = 换招池](#3-阶段--换招池) +4. [一招怎么配](#4-一招怎么配) +5. [开战怎么触发](#5-开战怎么触发) +6. [死亡不在 AI 层](#6-死亡不在-ai-层) +7. [完整范例:嘲风](#7-完整范例嘲风) +8. [Boss 专属陷阱](#8-boss-专属陷阱) +9. [创建链路与自检](#9-创建链路与自检) + +--- + +## 1. Boss 走定制路径 + +小怪有三条作者路径(配方 / 配方+前置态 / 定制脚本,见 08 号指南 §1)。**Boss 一律走第三条:定制脚本。** + +理由不是"Boss 更高级",而是数量与形状:Boss 只有个位数,彼此差异极大(有的浮空、有的分身、有的换竞技场), +把这些差异塞进可插拔模块的下拉里,只会产生一堆各用一次的模块类。手写图更直接、可读性更好, +且改一个 Boss 不会波及另一个。 + +写法: + +```csharp +using BaseGames.AI; + +namespace BaseGames.Enemies +{ + [AiDefinition("")] // ← id 字符串,全项目唯一 + public sealed class XxxAi : AiScript + { + protected override void Build(BrainBuilder b) { /* 整张图写在这里 */ } + } +} +``` + +放在 `Assets/_Game/Scripts/Enemies/AIBrain/Ai/` 下。 + +接线:Boss 物体上的 `EnemyAiBrain` 组件,**`_definitionId` 填上面那个 id,`_recipe` 留空**。 +两者是二选一,`EnemyAiBrain.Awake` 做互斥校验——两个都配或都不配会 `Debug.LogError` 并禁用组件。 +id 由 `AiDefinitionRegistry` 反射收集所有 `[AiDefinition]` 的 `AiScript` 子类解析,重复 id 在进入 Play 时直接抛异常。 + +`AiGraph` 按 id 共享缓存:同一个 `AiScript` 只建一次图,所有实例共用。 +**直接后果:`Build()` 里的 lambda 绝对不能闭包捕获具体实例**(组件、Transform、Boss 引用一概不行), +一切实例数据只能经 `IAiContext x` 参数取。第 8 节还会再提一次,因为这是 Boss 图最容易踩的坑。 + +--- + +## 2. 可用的建态原语 + +建态原语是静态方法,不是可插拔模块——不需要 `[Serializable]`、不进 Inspector 下拉,直接调用。 + +### 2.1 通用原语 `AiStateFragments` + +`Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs` + +| 方法 | 语义 | 用在哪 | +|---|---|---| +| `Locomotion(b, state, mode)` | 进入 / 每帧声明移动意图,离开时停。`mode` 取 `LocomotionMode.Idle` / `Face` / `Patrol` / `Pursue`。 | 静置等待、悬停朝向玩家、走位 | +| `Ability(b, state, abilityId)` | 每帧 `EnsureAbility`:能力被受击打断后,下一帧自动重新触发。 | "只要还在这个态就该一直在做"的持续态(冲锋、追击) | +| `AbilityOnce(b, state, abilityId)` | 只在 `OnEnter` 触发一次,不每帧重触发。 | 一次性演出(入场、变身),**用 `Ability` 会无限重播** | +| `Terminal(b, state)` | 无行为终态。 | 死亡态 | +| `LostAllZones`(字段) | 共享条件:脱离全部感知区。 | 小怪脱战边;**Boss 一般不用**(见 §8) | + +### 2.2 Boss 原语 `BossFragments` + +`Assets/_Game/Scripts/Enemies/AIBrain/Modules/BossFragments.cs` + +| 方法 | 语义 | +|---|---| +| `PhaseTransition(b, state, targetPhase, invincibleDuration)` | 阶段过渡态:`OnEnter` 停移动 + 中断能力 + 调 `IBossControl.BeginPhaseTransition`。**刻意不带出边**——调用方必须自己挂 `.To(下一态).When(x => !x.Boss.IsPhaseTransitioning)`,忘了挂 Boss 会永久锁死。`targetPhase < 0` 或 `invincibleDuration <= 0` 在建图期抛异常。 | +| `ApproachAttack(b, approach, attack, rest)` | 一组「寻路逼近 ↔ 到射程选招攻击」。转发到 `ApproachAttackEngagement.Declare` 的**同一份实现**,所以小怪的交战模块与 Boss 图行为永远一致。返回**逼近态**的 builder,可继续挂阶段过渡等出边。 | +| `MoveToAnchor(b, state, index)` | 移动到第 `index` 个竞技场锚点(`BossArenaAnchors`),到位后由调用方挂出边。 | +| `AtAnchor(index, tolerance = 0.2f)` | 共享条件:已到达第 `index` 个锚点。配合上一条用。 | + +### 2.3 Boss 决策面 `IBossControl` + +`Assets/_Game/Scripts/AI/IBossControl.cs`。图里经 `x.Boss` 访问,由 `BossBase` 实现。 + +| 成员 | 用途 | +|---|---| +| `CurrentPhase` | 当前阶段索引(出生为 0) | +| `IsPhaseTransitioning` | 是否处于阶段过渡(无敌 + 演出)期间 | +| `ResourceFull` | Boss 资源(怒气 / 充能)是否已满。未挂资源组件时**抛异常**——图问了资源却没配组件是配置错误,不静默返回 false | +| `BeginPhaseTransition(targetPhase, invincibleDuration)` | 发起阶段过渡。过渡中重复调用会被忽略并告警,所以只能放 `OnEnter`,不能放 `Tick` | +| `AnchorAt(index)` / `DistanceToAnchor(index)` | 竞技场锚点坐标 / 距离。漏配组件或下标越界即抛 | + +**非 Boss 敌人访问 `x.Boss` 会抛异常**(`EnemyBrainContext` 里显式失败),这是有意的—— +在小怪图里误用 Boss 语义应当立刻炸掉,而不是拿到一个假的默认值。 + +--- + +## 3. 阶段 = 换招池 + +阶段不是 SO 上的一个字段,也不是选招器里的一个过滤条件。**阶段就是"哪些能力组件当前是启用的"。** + +承载它的是 `BossPhaseAbilityGate`(`Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs`), +挂在 Boss 根节点上,Inspector 里配一张表: + +| 列 | 含义 | +|---|---| +| `ability` | 受阶段管控的能力组件(`EnemyAbilityBase`) | +| `phases` | 该能力可用的阶段索引数组;**留空 = 全阶段可用** | + +`ApplyPhase(phase)` 把该阶段的启用集刷到所有登记的能力上:`ability.enabled = 该阶段是否放行`。 + +这条链之所以成立,是因为 `EnemyAbilityBase.CanUse` 把 `enabled` 纳入了判据—— +被禁用的能力自动从 `EnemyAttackSelector` 的候选里消失。**阶段门不需要碰选招器,选招器也不知道阶段的存在。** + +调用时机由 `BossBase` 直接驱动(不订阅事件频道,保证与阶段切换严格同序): + +- `BossBase.OnSpawn()` → `ApplyPhase(0)`(对象池复用时回到初始招池) +- `BossBase.EnterPhase(phase)` → `ApplyPhase(phase)` + +**不要在 `EnemyAbilitySO` 上写阶段字段。** SO 描述的是"这一招是什么",不是"什么时候能用"。 + +未登记在阶段表里的能力不受阶段门影响,保持自身启用状态——入场演出、位移类能力通常就这么放着。 +表里某行漏配能力组件会在 `OnValidate` 里 `Debug.LogError` 点名行号。 + +--- + +## 4. 一招怎么配 + +一招 = 一个 `EnemyAbilitySO` 资产 + 一个挂在 Boss 上的 `EnemyAbilityBase` 子类组件(+ 若干 `EnemyAttackSO`)。 +与小怪完全一致,没有 Boss 专用的招式资产类型。 + +### 4.1 `EnemyAbilitySO`(`ABL_` 前缀) + +`CreateAssetMenu: BaseGames/Enemies/Enemy Ability` + +| 字段 | 说明 | +|---|---| +| `abilityId` | 全小写英文 + 下划线,图里 `x.Combat.IsAbilityRunning("...")` 按它匹配 | +| `designNote` | 设计备注,只给编辑器看,不影响运行 | +| `attackSequence` | `EnemyAttackSO[]`,一招内的多段(如三连挥扇 = 3 个) | +| `cooldown` | 冷却(秒),从**执行结束**开始计 | +| `interruptOnHurt` / `interruptOnStagger` | 中断规则;`interruptOnHurt = false` 即该招自带霸体 | +| `category` | `AbilityCategory.Attack` 的才进选招器候选 | +| `weight` / `priority` | 选招权重与优先级 | +| `rangeRadius` / `rangeOffset` | **招式自管的圆形触发范围**。`category == Attack` 时必须 `> 0`,否则永远够不着(`Awake` 报错) | +| `requiresGrounded` | 选招器的**硬门**。Boss 浮空阶段 `IsGrounded` 恒为 false,空中招留默认 `true` 会永远选不中 | +| `requiresLineOfSight` | 同上,视线硬门 | +| `exclusionGroup` | 互斥组,同组能力不能同时执行 | + +需要额外可配参数(动画 / 数值 / LayerMask)的招,建 `EnemyAbilitySO` 的**类型化子类**, +由能力组件的 `ResolveConfig()` 解析;类型不符显式报错,不做兜底。场景引用(发射点 Transform 等)留在组件上,不进 SO。 + +### 4.2 `EnemyAttackSO`(`EATK_` 前缀) + +一段动作的时间轴描述。**判定 / 生成 / 音效 / 无敌帧全部挂在动画 clip 的归一化时机上,不写在代码里。** + +| 字段 | 说明 | +|---|---| +| `clip` | Animancer `ClipTransition`。**留空则该段瞬间完成**(见 §8 与 §9 的已知缺口) | +| `fallbackDuration` | 无 clip 时的兜底时长 | +| `hitBoxSlot` / `hitBoxEnterT` / `hitBoxExitT` | 命中框开关的归一化时机(0–1) | +| `damageSource` | `DamageSourceSO` | +| `projectileConfig` / `projectileCount` / `spreadAngleDeg` / `projectileFireT` | 弹体招的发射参数与时机 | +| `hasPoiseWindow` / `poiseLevel` / `poiseStartT` / `poiseEndT` | 霸体窗口 | +| `lockMovement` / `postDelay` | 收招锁移动 / 后摇 | + +### 4.3 动画事件 + +`EnemyAnimationEvents.HandleEvent(AnimationEventType, string payload)` 支持: +`EnableHitBox` / `DisableHitBox` / `EnableIFrame` / `DisableIFrame` / `SpawnProjectile` / +`RoarStart` / `RoarEnd` / `PhaseTwoStart` / `TriggerFeedback` / `PlaySFX` / `AnimationComplete`。 + +弹体招的判定跟着 `SpawnProjectile` 生成的弹体走,**Boss 身上不留对应 HitBox**—— +留一个无人引用的 HitBox 会让下一个作者误以为该招判定挂在 Boss 身上。 + +### 4.4 防重复选招 + +`EnemyAttackSelector` 新增了 `AttackSelectionMode.WeightedRandomAntiRepeat`: +同 `WeightedRandom`,但对上一次选中的招施加权重折扣,压低连续重复。 +折扣系数配在 `EnemyStatsSO.attackAntiRepeatFactor`(0–1)。Boss 招池小、重复感明显,建议开启。 + +--- + +## 5. 开战怎么触发 + +Boss 出生停在静置态(`LocomotionMode.Idle`),**不巡逻、不搜敌**——等玩家进场。 +把它叫醒的是 `BossFightTrigger`(`Assets/_Game/Scripts/Enemies/Boss/BossFightTrigger.cs`), +放在 Boss 房间入口的触发区上(`Collider2D` + `isTrigger`),只触发一次。 + +玩家进入时它做三件事: + +``` +_onBossFightStarted.Raise(bossId) // EVT_BossFightStarted:全局状态机 / 后处理 / HUD +_onBossFightToggled.Raise(true) // EVT_BossFightEnded(布尔开关):血条与 BGM +_bossBrain.Send(AiSignal.Engaged) // ← 决策层从静置态进战 +``` + +图里对应的边: + +```csharp +AiStateFragments.Locomotion(b, Wait, LocomotionMode.Idle) + .To(Intro).OnEvent(AiSignal.Engaged); +``` + +`AiSignal` 现有两个值:`Died` 与 `Engaged`。受击 / 硬直**不进 AI 图**(见 08 号指南 §6 第 5 条)。 + +漏配处理:`BossFightTrigger.Awake` 逐条点名报错(触发器未勾 Is Trigger、未指定 Brain、未填 bossId、 +两个频道、玩家层掩码为空),任一缺失就整体禁用组件——不做"缺谁跳过谁"的部分执行, +因为半开的战斗(有 BGM 没血条、Boss 不动)比彻底不开更难查。 + +--- + +## 6. 死亡不在 AI 层 + +与小怪同一条规则,原因见 08 号指南 §6 第 3 条:`EnemyBase.PerformDeath` 先 `ForceState(Dead)`, +之后 `IsControllable` 永久为假,`AiRuntime.Tick` 在让位门处直接 `return`, +**任何条件边都不会再被求值**。所以死亡链一条条件边都不能放。 + +图里只需要两行: + +```csharp +AiStateFragments.Terminal(b, Death); +b.Global().To(Death).OnEvent(AiSignal.Died); +``` + +演出全部归物理层:`EnemyDeathSequence`(前摇演出,`_stopDecisionLayer` 勾上则调 `EnemyBase.NotifyDecisionStop()`)、 +`EnemyBase.PerformDeath`(关碰撞体、对象池归还)、具体 Boss 的击败序列(如 `ChaoFengBoss` 里的 `NotifyDecisionStop()`)。 + +`BossBase` 在死亡时会立即中止阶段过渡(`IsPhaseTransitioning = false`), +防止该标志永久锁死影响对象池复用。 + +--- + +## 7. 完整范例:嘲风 + +`Assets/_Game/Scripts/Enemies/AIBrain/Ai/ChaoFengAi.cs` —— 整张图 30 行,逐段拆解。 + +### 7.1 态与常量 + +```csharp +[AiDefinition("ChaoFeng")] +public sealed class ChaoFengAi : AiScript +{ + public const string Wait = "Wait", Intro = "Intro"; + public const string Ground = "Ground", GroundAttack = "GroundAttack"; + public const string PhaseTx = "PhaseTransition"; + public const string Air = "Air", AirAttack = "AirAttack"; + public const string Death = "Death"; + + public const string IntroAbility = "chaofeng_intro"; // 与 ABL_ChaoFeng_Intro.abilityId 一致 + private const float AirPhaseHpRatio = 0.5f; // 50% 血进空中阶段 + private const int AirPhaseIndex = 1; + private const float PhaseTxInvincible = 2f; // 须 ≥ 浮空上升时长 + 缓冲 +``` + +态名用 `public const string` 而不是字面量,是为了让 EditMode 测试(`ChaoFengAiTests`)能断言状态序列 +而不靠拼字符串。态名本身只是 trace 标签,不驱动动画。 + +### 7.2 静置 → 入场 + +```csharp +b.Entry(Wait); + +AiStateFragments.Locomotion(b, Wait, LocomotionMode.Idle) + .To(Intro).OnEvent(AiSignal.Engaged); // ← BossFightTrigger 发的信号 + +AiStateFragments.AbilityOnce(b, Intro, IntroAbility) + .To(Ground).When(x => !x.Combat.IsAbilityRunning(IntroAbility), "introDone"); +``` + +入场用 `AbilityOnce` 而非 `Ability`:入场是一次性演出,用 `Ability` 会被每帧 `Tick` 无限重播、永远退不出去。 + +### 7.3 阶段 0(地面):逼近 ↔ 选招 + +```csharp +BossFragments.ApproachAttack(b, Ground, GroundAttack, rest: Ground) + .To(PhaseTx).When(x => x.Vitals.HpBelow(AirPhaseHpRatio), "hp<50%"); +``` + +一行建两个态:`Ground`(`Locomotion.Pursue` 逼近)与 `GroundAttack`(`Combat.UseBestAttack()` 出招)。 +`rest: Ground` 指回自身——**Boss 不脱战**,玩家跑出感知区 Boss 也不回静置态。 + +`ApproachAttack` 返回的是**逼近态**的 builder,所以后面 `.To(PhaseTx)` 挂在 `Ground` 上: +血量掉到 50% 时从逼近态切阶段。攻击态不挂这条边,是为了"起手就打完"(见 7.6)。 + +选哪一招不在图里——`Combat.UseBestAttack()` 交给 `EnemyAttackSelector`, +射程 / 冷却 / 权重全在各能力自己的 `EnemyAbilitySO` 上。 + +### 7.4 阶段过渡 + +```csharp +BossFragments.PhaseTransition(b, PhaseTx, AirPhaseIndex, PhaseTxInvincible) + .To(Air).When(x => !x.Boss.IsPhaseTransitioning, "txDone"); +``` + +`PhaseTransition` 片段**故意不自带出边**,这条 `txDone` 就是它唯一的出口——**删了 Boss 会永久锁死** +(`BrainBuilder.Build()` 目前不校验"非终态是否有出边",见 §8)。 + +过渡期间的无敌与浮空演出由 Boss 侧承担:`BossBase.BeginPhaseTransition` 置 `IsPhaseTransitioning = true` +(`IsInvincible` 随之为真),走完 `invincibleDuration` 后调 `EnterPhase(1)` → `BossPhaseAbilityGate.ApplyPhase(1)` +→ 地面四招被禁用、风石招被放行,最后清 `IsPhaseTransitioning`,图上这条边才放行。 + +### 7.5 阶段 1(空中):悬停 ↔ 选招 + +```csharp +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"); +``` + +空中阶段不需要寻路逼近(浮空 + 远程招),所以不用 `ApproachAttack`,改成 `Face`(原地朝向玩家)+ 手写攻击态。 +攻击态的形状与 `ApproachAttack` 生成的完全一致,只是逼近换成了悬停。 + +**刻意没有回地面阶段的边**——阶段单向推进,血量回升也不退阶段(招池已经换了)。 + +### 7.6 死亡 + +```csharp +AiStateFragments.Terminal(b, Death); +b.Global().To(Death).OnEvent(AiSignal.Died); +``` + +### 7.7 三条刻意的形状(改图时别顺手抹掉) + +1. **Boss 不脱战** —— 逼近态的 `rest` 指回自身。 +2. **阶段单向推进** —— 空中阶段没有回地面阶段的边。 +3. **起手就打完** —— 攻击态不挂脱战边、不挂阶段边,前摇中玩家跑开仍完整打完。 + 观感上比"起手到一半凭空收招"合理,也避免了在能力执行途中切阶段导致的状态撕裂。 + +这三条在源码注释里也写了一遍,因为它们看起来都像"漏挂了一条边"。 + +--- + +## 8. Boss 专属陷阱 + +08 号指南 §6 的七条陷阱**全部适用**,以下是 Boss 图额外的。 + +1. **`Build()` 的 lambda 不能闭包捕获实例。** + `AiGraph` 按 `[AiDefinition]` id 共享缓存,一张图被所有实例共用。捕获了某个具体 Boss 的组件引用, + 第二个实例就会去驱动第一个实例。锚点坐标要经 `x.Boss.AnchorAt(i)` 取,不能捕获 `Transform`—— + `BossFragments.MoveToAnchor` 存在的理由正是这个。 + +2. **`PhaseTransition` 不带出边,必须自己挂。** + `BrainBuilder.Build()` 只校验"边指向的态是否已声明",**不校验"已声明的非终态是否有出边"**。 + 一个没有出边的非终态会让 Boss 永久卡住且**零报错**。阶段过渡态是这类风险的头号候选。 + (补上这项校验是已记录的待办,见 §9。) + +3. **浮空阶段的招不能留 `requiresGrounded = true`。** + Boss 浮空时 `IsGrounded` 恒为 false,该字段是选招器的硬门,留默认值会让整个空中招池永远选不中—— + 表现为"Boss 悬停着什么都不做",且没有任何报错。 + +4. **阶段过渡的无敌时长必须覆盖演出。** + `PhaseTxInvincible` 须 ≥ 浮空上升时长 + 缓冲。配短了 Boss 会在还没升到位时就恢复可受击并进入下一阶段。 + `invincibleDuration <= 0` 在建图期就抛异常(0 意味着过渡演出期间可被打断,那不是阶段过渡的语义)。 + +5. **`BeginPhaseTransition` 只能在 `OnEnter` 调,不能在 `Tick`。** + 过渡进行中重复调用会被忽略并告警。`BossFragments.PhaseTransition` 已经把它放在 `OnEnter`, + 手写阶段态时别照抄成 `Tick`。 + +6. **Boss 的脱战语义与小怪相反。** + 小怪模块默认挂 `LostAllZones` 脱战边,Boss 一般不挂。别因为"小怪都这么写"就顺手加上。 + +7. **`x.Boss` 在非 Boss 上会抛异常。** + 这是设计如此。把 Boss 原语复制到小怪图里时会立刻炸掉,而不是拿到假数据。 + +--- + +## 9. 创建链路与自检 + +**不要裸建**(CLAUDE.md 第 2 条)。两条脚手架已经按新轨重建: + +| 目的 | 入口 | +|---|---| +| 建 Boss 的 SO 资产(`EnemyStatsSO` / `EnemyAnimationConfigSO` / 各 `EnemyAbilitySO`) | 菜单 `BaseGames/Data/Character Wizard` → **Boss** 标签页 | +| 在场景里放 Boss 完整组件树 | 菜单 `BaseGames/Scene/Place/Boss 嘲风 (ChaoFeng)`(`SceneObjectPlacerTool.PlaceChaoFeng`) | +| 放开战触发区 | 菜单 `BaseGames/Scene/Place/Boss 开战触发区 (嘲风)`(`SceneObjectPlacerTool.PlaceBossFightTrigger(bossId)`) | +| 存成预制体 | 菜单 `BaseGames/Scene/Save Prefab/Boss 嘲风 (ChaoFeng)` | + +放置器产出的组件树(嘲风为例):`ChaoFengBoss`(继承 `BossBase`) + `EnemyStats` + `EnemyFeedback` + +`EnemyMovement` + `GroundNavigator` + `EnemyLocomotion` + `EnemyAiBrain` + `BossPhaseAbilityGate` + +`PhysicsPerceptionSystem` + 演出组件 + `HurtBox` / `ContactDamageZone` / 各招 `HitBox` / 弹体发射点。 + +> `EnemyLocomotion` 是必需的:AI 图的全部移动意图靠它落地。缺了它 `AiStateFragments` 建态时 NRE、 +> `AiRuntime` 构造失败,Boss 完全不动。 + +新增一个 Boss 时,把上述放置器复制一份改名,**不要**在场景里手工 `AddComponent` 拼—— +脚手架保证了层、命名、事件频道绑定符合规范。 + +建完跑三项自检(CLAUDE.md 第 3 条): + +``` +菜单栏 → BaseGames → Tools → Validation → Validate All ScriptableObjects +菜单栏 → BaseGames → Addressables → Validate Address Keys +菜单栏 → BaseGames → Tools → Maintenance → Physics2D Layer Matrix → Check +``` + +### 已知缺口(写 Boss 时会碰到) + +| 缺口 | 影响 | +|---|---| +| **动画 clip 尚未接入** | `EnemyAttackSO.clip` 留空时该段瞬间完成。目前 Boss 的招"能选中、能跑完、能回到逼近态",但**判定时机、伤害、手感一律未经验证**。clip 到位后必须重新校时序 | +| **`EnemyHurtState` 无受击动画时永久卡死** | `Enter` 在 `AnimConfig.Hurt` 为空时提前返回且不安排回到 `Controlled`,敌人被打一次就永久停在 `Hurt`、`IsControllable` 恒假、AI 永久停摆。当前美术未接入 = 大部分敌人一挨打就废。**优先级最高的已知缺陷** | +| **池化敌人复活带着上一条命的冷却** | `EnemyBase.OnSpawn` 的中断调用注释写着"重置能力冷却"但实际没重置 | +| **`BrainBuilder.Build()` 不校验"非终态有无出边"** | 见 §8 第 2 条 | +| **`EnemyBase.ReceiveParry` 无调用方** | 伤害管线不带攻击者引用,"谁被弹反谁硬直"暂时接不上。旧的 Boss 侧弹反反制已删除(它的行为是"玩家弹反任意敌人都会让 Boss 硬直",是错的),属于**有意的移除且暂无替代** | +| **能力资产没有编辑器总览视图** | 需要一个 `EnemyAbilityModule` 编辑器窗口,尚未编写 | +| **没有 `AiDefinitionValidator`** | 能力引用漏配 / 拼错目前只能在 Play Mode 里靠肉眼排查 | diff --git a/Docs/Standards/AssetFolderSpec.md b/Docs/Standards/AssetFolderSpec.md index b4881ed4..6ce5ac89 100644 --- a/Docs/Standards/AssetFolderSpec.md +++ b/Docs/Standards/AssetFolderSpec.md @@ -857,7 +857,6 @@ var (prefab, _) = await AssetLoader.LoadAsync(AddressKeys.PrefabPlay | **Character Wizard** | `BaseGames/Data/Character Wizard` | 角色 SO(玩家形态 / Minion / Boss 通用入口) | 统一入口,自动在正确路径创建所有配套 SO,内含快捷跳转按钮 | | **Form Editor** | `BaseGames/Data/Form Editor` | 玩家三魂形态(FormSO) | 三栏可视化网格,自动绑定武器/技能引用,支持 TianHun / DiHun / MingHun | | **Enemy Data Manager** | `BaseGames/Data/Enemy Data Manager` | 敌人配置 SO(`ENM_*_Stats.asset`)+ 掉落表 | 双面板列表编辑,左栏搜索 + [New],右栏 Stats/Loot 标签 | -| **Boss Skill Sequence** | `BaseGames/Data/Boss Skill Sequence` | Boss 技能阶段(BossSkillSO / SkillSequenceSO) | Gantt 时间轴可视化,Windup / Active / Recovery 三段颜色标记 | | **Skill Editor** | `BaseGames/Data/Skill Editor` | 技能配置 SO(`SKL_*_Data.asset`) | 按 SkillEffectType 分组筛选,右栏含 HitBox 验证 + 资源费预览 | | **Weapon Editor** | `BaseGames/Data/Weapon Editor` | 武器配置 SO(`WPN_*_Data.asset`) | 双面板列表,右栏全属性 + HitBox Prefab 验证 + 快捷操作 | | **Weapon HitBox Wizard** | `BaseGames/Create/Weapon HitBox Prefab` | 武器 HitBox Prefab(4 方向 Ground/Up/Down/Air) | 自动生成 `WPN_{ID}_HitBox.prefab`,支持各方向碰撞体形状配置 | diff --git a/Docs_Dev/superpowers/plans/2026-07-30-boss-ai-single-track.md b/Docs_Dev/superpowers/plans/2026-07-30-boss-ai-single-track.md index 03d8a61c..60f68805 100644 --- a/Docs_Dev/superpowers/plans/2026-07-30-boss-ai-single-track.md +++ b/Docs_Dev/superpowers/plans/2026-07-30-boss-ai-single-track.md @@ -6,7 +6,7 @@ **Architecture:** 决策归 AI 图(每 Boss 一个 `AiScript` 定制类),编排归动画时间轴(clip + 归一化时机 + 动画事件),"能不能用"归招式自己(`CanUse` + 组件启用),"选哪个"归 `EnemyAttackSelector`,演出归旁挂组件。Boss 专属仅一个 `IBossControl` facet(阶段)+ 几个旁挂 MonoBehaviour。 -**Tech Stack:** Unity 6 / C# 9、Animancer、UI Toolkit(编辑器)、NUnit(EditMode 测试)、自研 `BrainGraph`(`BrainBuilder` / `AiRuntime` / `AiGraph`) +**Tech Stack:** Unity 2022.3.62f3c1 / C# 9、Animancer、UI Toolkit(编辑器)、NUnit(EditMode 测试)、自研 `BrainGraph`(`BrainBuilder` / `AiRuntime` / `AiGraph`) **Spec:** `Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md` @@ -35,6 +35,50 @@ ⚠️ **MCP 卡死排查**:Unity 编辑器窗口失焦会让 MCP 队列无限期卡住(不是超时)。若某个 MCP 调用长时间无返回,把 Unity 窗口置前即可恢复,不要盲目重试。 +### 两个实测踩过的坑(Task 3 执行时发现) + +**1. `EnemyBase.Abilities` 属性遮蔽 `BaseGames.Enemies.Abilities` 命名空间。** +`EnemyBase` 有 `public EnemyAbilityRegistry Abilities { get; }`。在**类型位置** +`new Abilities.EnemyAttackSelector(...)` 能编译(解析为命名空间),但在**成员访问位置** +`Abilities.EnemyAttackSelector.SomeConst` 编译失败(解析为那个属性)。 +在 `EnemyBase` 内引用该命名空间下的静态成员时必须写全 `BaseGames.Enemies.Abilities.…`。 +Task 16 / 17 都要改 `EnemyBase`,会再遇到。 + +**2. `unity_get_compilation_errors` 在程序集编译成功时不报警告。** +Unity 只对**编译失败**的程序集给出完整诊断集。`ChargeAbility.cs:29-30` 有两个既存 +`CS0414`(`[SerializeField]` 但从未读取),在绿色构建下完全不可见。 +所以"0 errors 0 warnings"是这个上报口径下的结果,不等于真的零警告—— +后续任务若在构建一度失败时看到这两条,那是既存问题,不是自己引入的。 + +**3. MCP 测试运行器忽略过滤参数。** `unity_testing_run_tests` 的 `testFilter` / `filter` 无效, +每次都跑整个 EditMode 套件。这不影响正确性(等于免费拿到全量回归),但别把全量结果误读成过滤结果。 + +**4. ⚠️ EditMode 下 `AddComponent` 不触发 `Awake`。**(Task 4 实测证实) +普通 MonoBehaviour 在编辑模式下 `AddComponent` 后 `Awake` **不会被调用**。 +后果:任何依赖 `Awake` 解析引用的成员在 EditMode 测试里都是未初始化状态。 +具体地,`EnemyAbilityBase._enemy` 会留空,使 `CanUse` **恒为 false**—— +断言"禁用后不可用"会在修复前也通过,是个永远不会失败的测试。 + +做法:给测试用的子类加一个 `public void RunAwake() => Awake();` 显式触发, +**不要**用反射直写字段——前者跑的是真实的 `GetComponentInParent` 解析路径,后者是伪造结果。 +只检查 `enabled` 标志本身(如阶段门的启停断言)则不需要 `Awake`。 + +**5. ⚠️ `[RequireComponent]` 指向抽象类型时 `AddComponent` 返回 null。**(Task 6 实测证实) +`HurtBox` 标了 `[RequireComponent(typeof(Collider2D))]`,而 `Collider2D` 是抽象类, +Unity 无法自动补齐 → `AddComponent()` 返回**真正的 null**(不是 fake-null)。 +建测试对象时必须先挂一个具体碰撞体: + +```csharp +_host.AddComponent(); // 必须在 HurtBox 之前 +var hb = _host.AddComponent(); +``` + +同样带该特性的还有 `HitBoxColliderProxy` / `PerceptionTriggerProxy` / `LethalTrap` 等 +(`HitBox` 本身**没有**);Task 12 新建的 `BossFightTrigger` 也标了它, +给它建测试或裸建物体时同理。 +旁证:`HurtBoxEditor.cs:97` 早就为此写了「先加 Collider2D 以满足 HurtBox 的 RequireComponent」—— +这个坑项目里踩过一次,只是没写进规范。 + ### 项目硬约束 1. **禁止下游兜底**(CLAUDE.md 第 6 条):不要用 `?? 默认值` / `try-catch` / `if(null) return` 掩盖上游漏配。缺配置就 `Debug.LogError` 或抛异常。 @@ -622,7 +666,7 @@ namespace BaseGames.Enemies.Abilities - [ ] **Step 5: 跑测试确认通过** -Test Runner → EditMode → `EnemyAttackSelectorTests`。期望:全部 passed(原 9 个 + 新 5 个 = 14)。 +Test Runner → EditMode → `EnemyAttackSelectorTests`。期望:全部 passed(原 10 个 + 新 5 个 = 15)。 - [ ] **Step 6: `EnemyStatsSO` 加系数字段** @@ -711,8 +755,14 @@ namespace BaseGames.Tests.EditMode.Enemies [Test] public void DisabledAbility_IsNotUsable() { - var go = new GameObject("ability-host"); - var ab = go.AddComponent(); + // 必须先挂 EnemyBase:否则 _enemy==null 会让 CanUse 恒假, + // 这条断言在修复前也会"通过",成为一个永远不会失败的测试。 + var go = new GameObject("ability-host"); + go.AddComponent(); // 裸 EnemyBase 的 _currentState=Controlled → IsAlive 为真 + var ab = go.AddComponent(); + + Assert.IsTrue(ab.CanUse, + "前提:启用且宿主存活时应可用——否则下一条断言分不清是 enabled 还是别的门在起作用"); ab.enabled = false; Assert.IsFalse(ab.CanUse, "禁用的能力组件不可用——否则选招器会选中一个启不动协程的招"); @@ -723,14 +773,15 @@ namespace BaseGames.Tests.EditMode.Enemies } ``` +> `using BaseGames.Enemies;` 需一并加入 using 区(`EnemyBase` 在该命名空间)。 +> `EnemyBase.Awake` 末尾的 `_stats.Initialize(_statsSO)` 会在裸物体上 NRE, +> 但那发生在所有组件解析之后,不影响本测试;`LogAssert.ignoreFailingMessages` 已覆盖噪声。 + - [ ] **Step 2: 跑测试确认失败** Test Runner → EditMode → `BossPhaseAbilityGateTests.DisabledAbility_IsNotUsable`。 -期望:FAIL——`CanUse` 当前不看 `enabled`,返回 true。 - -> 若因 `StubAbility` 上无 `EnemyBase` 导致 `_enemy == null` 使 `CanUse` 已为 false 而测试假通过: -> 给 `go` 先 `AddComponent()`,再断言 `ab.enabled = true` 时 `CanUse` 为 true、 -> `false` 时为 false 的对比,确保断言真正测到 `enabled` 这一维。 +期望:**第一条断言通过、第二条 FAIL**——`CanUse` 当前不看 `enabled`,禁用后仍返回 true。 +若两条都过,说明测试没测到 `enabled` 这一维,停下来修测试再继续。 - [ ] **Step 3: `CanUse` 纳入 `enabled`** @@ -774,7 +825,15 @@ StartCoroutine 必然失败的招。这也是阶段门(按阶段启停能力 **Files:** - Create: `Assets/_Game/Scripts/Enemies/Boss/BossPhaseAbilityGate.cs` - Modify: `Assets/_Game/Scripts/Enemies/Boss/BossBase.cs` -- Test: `Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs` +- Modify: `Assets/_Game/Scripts/Enemies/Abilities/IAttackCandidate.cs`(一行文档修正,见下) +- Test: `Assets/Tests/EditMode/Enemies/BossPhaseAbilityGateTests.cs`(Task 4 已建,本 Task 追加三条) + +> **前置说明**:`StubAbility` 与 `_host` / `TearDown` 骨架在 Task 4 已经建好,含 +> `public void RunAwake() => Awake();`(EditMode 不自动跑 `Awake`,见执行须知第 4 条)。 +> 本 Task 的三条测试只断言 `enabled` 标志本身,**不需要** `RunAwake`。 +> +> **顺带修正**:`IAttackCandidate.cs` 第 9 行把 `CanUse` 注释为 `// 未冷却 + 未运行 + 存活`, +> Task 4 加了 `enabled` 这一维后该注释已不完整。改为 `// 组件启用 + 未冷却 + 未运行 + 存活`。 - [ ] **Step 1: 写失败测试** @@ -964,9 +1023,11 @@ Test Runner → EditMode → `BossPhaseAbilityGateTests`。期望:4 passed。 ```csharp _phaseGate?.ApplyPhase(0); // 对象池复用:回到阶段 0 的启用集 - AttackSelector?.ResetRepeatMemory(); ``` +> 选招器的防重复记忆**不在这里**重置——Task 3 的代码审查发现它该归 `EnemyBase.OnSpawn()` +> (覆盖全部池化敌人,而非只有 Boss),已在 Task 3 的修正里落地。此处只管阶段门。 + - [ ] **Step 6: 编译 + 全量回归** MCP `unity_get_compilation_errors` 期望 0/0;`Run All` 期望全绿。 @@ -2362,6 +2423,46 @@ Inspector 检查新对象:`EnemyAiBrain._definitionId = ChaoFeng`、`_recipe` 覆盖的理由:旧预制体上带 `BossSkillExecutor` 与指向已删资产的 `_skills`, 不覆盖则 Task 20 删脚本后它会变成 missing script。这也与「敌人预制体由脚手架统一产出」的既有决策一致。 +- [ ] **Step 3.5: 判定 `Phase1_Tornado_HitBox` 的去留(Task 14 发现)** + +放置工具仍会创建 `Phase1_Tornado_HitBox` 并给它绑伤害源,但旧的 `_hitBoxes` 数组已随 +`BossSkillExecutor` 一起移除,**现在没有任何东西引用它**。龙卷是由技能 clip 上的 +`SpawnProjectile` 动画事件生成弹体、由弹体自带判定,Boss 身上这个 HitBox 很可能本就多余。 + +在真实对象树上确认:若确无消费者,从放置工具里删掉它的创建与伤害源绑定 +(留着会让作者以为龙卷判定挂在 Boss 身上,是误导)。若发现某招确实需要 Boss 自身的 +近身判定,则把它绑进对应能力的 HitBox 槽位——**不要让它以无引用状态留在对象树上**。 + +- [ ] **Step 4.5: 修 `EnemyBrainContext.UseAbility` 的静默失败(Task 11 发现,此处正是它变可观测的时点)** + +`EnemyBrainContext.UseAbility(id)` 对**找不到的能力 id 静默返回 false**。后果: +`chaofeng_intro` 资产若漏配,`Intro` 态进入后什么都不做,`IsAbilityRunning` 恒假, +`introDone` 立刻成立——入场演出被静默跳过,零报错。它"向前失败"(战斗仍能打), +但正是第 6 条禁止的那种静默通过。 + +`AiStateFragments.Ability/AbilityOnce` 已在**建图期**挡掉空 id,所以运行期解析不到的 id +一定是配置错误,没有合法情形。改为显式报错: + +```csharp + public bool UseAbility(string abilityId) + { + var a = _enemy.Abilities?.Get(abilityId); + if (a == null) + { + Debug.LogError($"[EnemyBrainContext] '{_enemy.name}' 的 AI 图请求能力 " + + $"'{abilityId}',但该敌人身上没有这个能力组件。" + + "请检查能力组件是否挂载、其 EnemyAbilitySO 的 abilityId 是否一致。", _enemy); + return false; + } + return a.Execute(); + } +``` + +用 `LogError` 而非抛异常:这是每帧路径,抛异常会打断整个 `AiRuntime.Tick`; +`LogError` 足以让问题在 Console 里立刻可见,且不会让一个漏配的敌人拖垮整场。 +⚠️ 会影响所有走配方路径的小怪——放在本 Task 是因为此刻嘲风的能力资产刚建好, +是第一次能真正观察到"配齐 vs 漏配"两种表现的时点。改完跑全量回归确认无既有敌人踩中。 + - [ ] **Step 5: 放一个开战触发区** 场景里新建一个空物体 `BossFightTrigger_ChaoFeng`,加 `BoxCollider2D`(勾 `Is Trigger`)+ `BossFightTrigger`: @@ -2369,7 +2470,10 @@ Inspector 检查新对象:`EnemyAiBrain._definitionId = ChaoFeng`、`_recipe` - `_bossId` → `ChaoFeng` - `_playerLayers` → `Player` - `_onBossFightStarted` → `EVT_BossFightStarted` -- `_onBossFightToggled` → `EVT_BossFightToggled` +- `_onBossFightToggled` → **`EVT_BossFightEnded`**(⚠️ Task 12 实测:**不存在**名为 + `EVT_BossFightToggled` 的资产。那个 `BoolEventChannelSO` 实际叫 `EVT_BossFightEnded`, + 但被当作开关用——`BGMController._onBossFightToggled` 与 `BossHPBar._onBossFightToggled` + 两个字段绑的都是它。资产名与用途不符是既存问题,本次不改名,只是别再去找那个不存在的资产。) 保存场景。 @@ -2420,6 +2524,11 @@ TestRoomA 加开战触发区。三件自检 0 error。 **Files:** - Modify: `Assets/_Game/Scripts/Enemies/Boss/BossBase.cs` +> ⚠️ **不要删掉 Task 15 新加的 `BossBase.Start()`。** 它调 `_phaseGate?.ApplyPhase(_currentPhase)`, +> 是场景直接放置的 Boss(非对象池生成)唯一一次应用阶段招池的时机—— +> `EnterPhase` 只在换阶段时触发、`OnSpawn` 只在池化复用时触发,两者开局都不会跑。 +> 缺了它,阶段 1 的招在阶段 0 就是启用状态。Task 15 实测确认过这一点。 + - [ ] **Step 1: 删技能执行相关成员** `Assets/_Game/Scripts/Enemies/Boss/BossBase.cs` 删除以下全部内容: @@ -2441,6 +2550,14 @@ TestRoomA 加开战触发区。三件自检 0 error。 **这属于既有缺陷,若出现请单独记录,不要在本 Task 顺手改**) 9. `LastUsedSkillId = null;` 两处(`EnterPhase` 与 `OnSpawn`)删掉——防重复记忆已归选招器 10. using 区里因此不再需要的 `using BaseGames.Boss;`、`using BaseGames.Parry;` +11. ~~**清掉本文件残余的三处行为树残留**~~ —— **已在 Task 5 顺带完成**(该任务本就在编辑 + `BossBase.cs`,按"清理你正在编辑的注释"规则一并做了)。三处均已改为"决策层"表述, + 含那条运行时告警字符串。本 Task 无需再动,保留原文仅作记录: + - 第 76 行附近的区段注释 `// ── 技能执行(BD Task 调用入口)` —— 随该区段一并删除 + - `BeginPhaseTransition` 文档里的 `BD_BossPhaseTransition 检查 IsPhaseTransitioning 来等待过渡完成。` + —— 改为 `AI 图挂 When(x => !x.Boss.IsPhaseTransitioning) 边等待过渡完成。` + - `BeginPhaseTransition` 重入告警里的运行时字符串 `请检查行为树逻辑是否重复触发阶段切换。` + —— 改为 `请检查 AI 图是否重复触发阶段切换。`(这条是开发期真的会看到的日志,不能留) - [ ] **Step 2: 编译检查** @@ -2634,6 +2751,30 @@ AI 图挂 When(x => x.Boss.ResourceFull) 边进专属招态。 **Files:** - Delete: 9 个脚本(见下) +- [ ] **Step 0: 先清掉两处计划遗漏的引用(Task 16–18 执行时发现,不清则本 Task 必然编译失败)** + +**0a. `ChaoFengKnockdownCounter.cs:82` —— 编译期类型引用,必须改:** + +```csharp +// 改前 +_boss.GetComponentInChildren()?.InterruptCurrentSkill(); +// 改后(与 BossBase 瘦身时同一形状) +_boss.Abilities.InterruptAll(InterruptReason.ExternalRequest); +``` + +该文件不在删除清单里且仍被 `ChaoFengBoss._knockdownCounter` 使用(`DefeatSequence` 与 +`OnDamageTaken` 都会调)。按需补 `using BaseGames.Enemies.Abilities;`。 +⚠️ 注意 `EnemyBase.Abilities` 属性遮蔽同名命名空间(执行须知第 1 条),写法参照 `BossBase` 里已落地的那处。 + +**0b. `EditorScaffoldUtils.cs:21-22` —— 命名前缀表里的死条目:** + +```csharp +{ "BossSkillSO", ("SKL_", "SKL_{Name},例:SKL_BossRage") }, +{ "SkillSequenceSO", ("SKL_", "SKL_Seq_{Name},例:SKL_Seq_RageCombo") }, +``` + +字符串字面量,不影响编译,但类型删除后是误导性死配置(还与 `AssetFolderSpec` 的 `ABL_` 冲突)。删掉这两行。 + - [ ] **Step 1: 确认零引用** ```bash @@ -2660,6 +2801,10 @@ rm -r Assets/_Game/Scripts/Enemies/Boss/Patterns rm -f Assets/_Game/Scripts/Enemies/Boss/Patterns.meta ``` +⚠️ `Patterns/` 是**独立程序集**(`BaseGames.Enemies.Boss.Patterns.asmdef`,`autoReferenced: true`)。 +已核实**零外部引用**(无任何 asmdef 的 `references` 含它),因此可随目录整包删除。 +删除后无需改动任何其他 asmdef。 + - [ ] **Step 3: 编译 + 全量回归** 回 Unity 刷新。MCP `unity_get_compilation_errors` 期望 0 errors 0 warnings;`Run All` 期望全绿。 @@ -2799,7 +2944,35 @@ grep -rn "BossSkill\|WeakPoint\|Telegraph\|AttackPattern\|SkillSequence\|StopBeh Assets/_Game/Scripts --include=*.cs ``` -期望:无命中。若有注释残留,一并清掉(CLAUDE.md 第 4 条)。 +`BossSkill|WeakPoint|Telegraph|AttackPattern|SkillSequence|StopBehaviorTree` 期望无命中(相关类型已删)。 + +`BD_|行为树` **预期仍有命中**——Task 3 执行时实测 `Assets/_Game/Scripts/Enemies/` 下约 20 处, +散落在 `EnemyAnimationConfigSO` / `SensorSlotNames` / `EnemyAbilityBase` / `EnemyDeathSequence` +/ `ChaoFengBoss` 等文件的注释与 `[Tooltip]` 里。其中一部分会随 Task 16/17/19/20 自然消失 +(`BossSkillSO`、`TelegraphSystem` 整文件删除;`BossBase`、`EnemyBase`、`EnemyDeathSequence` +在各自任务里改到)。**本步负责清掉剩余的**:逐个改为只描述功能的表述,不删除功能性内容。 +清完后再跑一次 grep,此时才应无命中。 + +**已点名的一处需特别处理**:`EnemyQuotaManager` 有序列化字段 `_maxActiveBehaviorTrees` +与注释 `等价于旧的禁用行为树`(Task 4 执行时发现)。字段改名会丢已有场景/预制体上的配置值, +必须加 `[UnityEngine.Serialization.FormerlySerializedAs("_maxActiveBehaviorTrees")]` +再改为 `_maxActiveBrains`(该管理器实际裁剪的是 `EnemyAiBrain`,不是能力组件)。 + +- [ ] **Step 2.5: 处理描述已删除系统的长期文档(Task 19 发现)** + +约 20 份 `Docs/` 与 `Docs_Dev/` 文档仍在描述旧 Boss 轨。按 CLAUDE.md 第 7 条, +`Docs/` 是需要长期维护、对项目持续有效的文档——留着整份描述已不存在系统的架构文档, +比没有文档更糟(新人会照着它去找不存在的类型)。逐类处理: + +| 文档 | 处理 | +|---|---| +| `Docs/Architecture/23_BossSkillModule.md` | 整份描述已删除的 `BossSkillSO`/`BossSkillExecutor` 体系 → **删除**,其内容已被本次 spec 取代 | +| `Docs/Design/47_BossSkillSystem.md` | 同上 → **删除** | +| `Docs/Standards/AssetFolderSpec.md` | 只需删掉旧 Boss 技能资产的条目,其余规范仍有效 → **局部修订** | +| `Docs/Guides/` 下提到旧轨的章节 | 随 Step 3 的新章节一并改写 | +| `Docs_Dev/` 下的历史计划与验证清单 | **保留不动**——那是开发过程记录,反映当时状态,改了反而失真 | + +删除前确认没有其他文档以链接方式引用它们;若有,把链接指向本次的 spec。 - [ ] **Step 3: 更新指导手册** @@ -2846,3 +3019,7 @@ EnemyAbilityModule(能力资产编辑器总览)。" | `AiDefinitionValidator` | 防 450+ 能力引用出错,优先级高于上一项。见组合式模块 spec §6 | | `BossEncounterCoordinator` | 多体 Boss 交替出招令牌。等真设计双体 Boss 时再做。见 spec §7.2 | | 嘲风动画 Clip 接入 | 美术资源缺口。Clip 到位后校招式判定时序与预警节奏 | +| `BrainBuilder.Build()` 校验"态是否有出边" | Task 9+10 执行时发现:`Build()` 只校验"边指向的态是否已声明"(`RequireState` 的正向),**不校验反向**——一个已声明却没有任何出边的非终态会让敌人永久卡住且零报错。`BossFragments.PhaseTransition` 按设计不自带出边,正是这类风险点。做法是给 `AiStateFragments.Terminal` 加显式终态标记,`Build()` 对"非终态且无出边"抛异常 | +| **池化敌人复活时带着上一条命的冷却** | Task 16 执行时确认:`EnemyBase.OnSpawn` 里 `_abilities.InterruptAll(InterruptReason.Dead)` 的注释写着「重置能力冷却」,但 `EnemyAbilityBase.Interrupt` 在 `!_isRunning` 时**提前返回**,真跑到时又是**设置** `_cooldownEndTime = Time.time + cooldown*0.5f` 而非清零。所以冷却从未被重置。修法:给 `EnemyAbilityBase` 加 `ResetCooldown()`,在 `EnemyBase.OnSpawn` 遍历调用。影响全体池化敌人 | +| **`EnemyHurtState` 无受击动画时永久卡死(优先级最高)** | Task 15 实测发现:`EnemyHurtState.Enter` 在 `AnimConfig.Hurt` 为空时**提前返回且不安排回到 `Controlled`**。任何没有受击动画的敌人被打一次就永久停在 `Hurt` 态,`IsControllable` 恒假、AI 永久停摆。当前美术未接入,等于**大部分敌人一挨打就废**。典型的规则 6 静默提前返回。与 Boss 轨无关,影响全体敌人,应尽快单独立项 | +| `MoveToAnchor` 只在 `OnEnter` 采样锚点 | 锚点 Transform 若会移动则不会重新取点。与"锚点是固定点"的设定一致,但这是个隐含假设,将来若出现移动锚点需显式处理 | diff --git a/Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md b/Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md index 7fc5fa69..8c55db18 100644 --- a/Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md +++ b/Docs_Dev/superpowers/specs/2026-07-30-boss-ai-single-track-design.md @@ -1,6 +1,7 @@ # Boss AI 双轨统一:决策层与执行层一并归并到小怪轨 -> 日期:2026-07-30  状态:设计已确认,待实施 +> 日期:2026-07-30  状态:**已实施**(阶段一 Task 1–15、阶段二 Task 16–21) +> 实施期偏差与仍为待办的项见 [§13 注记](#13-注记)。作者指南:`Docs/Guides/09_BossAi_Authoring_Guide.md` > 背景:小怪走 `EnemyAiBrain`(BrainGraph)+ 组合式模块 + `EnemyAbilityBase`/`EnemyAbilitySO`; > Boss 走 `BossBase` + `BossSkillExecutor` + `BossSkillSO`。2026-07-27 的抽取决策记录把终态定为 > 「决策层统一 BrainGraph + 执行层按复杂度分层(Boss 保留 `BossSkillExecutor` 富编排)」。 @@ -219,7 +220,7 @@ namespace BaseGames.AI { int CurrentPhase { get; } bool IsPhaseTransitioning { get; } - bool ResourceFull { get; } // BossResource 满值;无资源组件时恒 false + bool ResourceFull { get; } // BossResource 满值;未挂该组件即抛(见下) void BeginPhaseTransition(int targetPhase, float invincibleDuration); // 竞技场锚点(§5.5)。归本 facet 而非黑板:见下方修订说明。 @@ -242,6 +243,12 @@ namespace BaseGames.AI `EnemyBrainContext.Boss => _boss ?? throw new InvalidOperationException(...)`。 按 CLAUDE.md 第 6 条:小怪图误用它必须崩,不能返回一个空对象把问题盖住。 +> 修订(2026-07-30,Task 1+2 代码审查):`ResourceFull` 初稿定为「无资源组件时恒 false」, +> **已改为未挂 `BossResource` 时显式抛**。原设计是同一条第 6 条禁止的下游兜底—— +> 图上挂了 `When(x => x.Boss.ResourceFull)` 边、预制体却漏挂组件时,那条边会永远不成立且零报错。 +> 与紧邻的 `Boss` facet、以及 §5.5 的 `AnchorAt` 保持同一失败语义:**Boss 专属 facet 的每个成员, +> 在其依赖组件缺失时一律显式抛,不返回默认值。** + `BossBase` 实现 `IBossControl`;`EnemyBrainContext` 在构造时 `_boss = enemy as BossBase`。 ### 5.2 `BossPhaseAbilityGate` @@ -561,3 +568,75 @@ C# 单继承直接堵死。 本次确认 E004 归精英怪,`ApproachAttackEngagement` 的投入不作废,反而被 Boss 图复用(§4.2)。 - 竞品调研于 2026-07-30 完成,调研对象与来源链接见对应会话记录;本文档按 CLAUDE.md 第 4 条 只保留功能性描述,不写入具体作品与角色名。 + +--- + +### 13.1 实施期偏差(Task 21 收尾时记录) + +按实际落地情况修正设计文档中与最终代码不一致之处: + +| 设计原文 | 实际落地 | 原因 | +|---|---|---| +| Boss 图共享一个「Boss 骨架」 | **没有骨架,每个 Boss 手写整张 `AiScript` 图**;重复形状抽成 `BossFragments` 的静态建态原语 | Boss 只有个位数且形状差异大,骨架会退化成只服务单个 Boss 的一堆参数 | +| `AbilityRunState.Telegraph` 随预警系统一并删除 | **保留**。它现在是 `BlinkStrikeAbility` 的一个执行阶段(现身前的闪光),与已删除的 `TelegraphSystem` 无关 | 该枚举值描述的是能力自身的执行阶段,不是旧预警组件的一部分 | +| 阶段信息写入黑板供图读取 | 未引入黑板写入,阶段经 `IAiContext.Boss`(`IBossControl`)**直接查询** | 少一层同步,且非 Boss 访问 `x.Boss` 抛异常这条显式失败更有价值 | +| `BossSkillEvent` 事件频道保留 | **随旧轨一并删除**(`BossSkillEventChannelSO` + `BossEvents.BossSkillEvent` + `EVT_BossSkill.asset`) | Task 21 复核确认:全项目零发送方、零订阅方、无任何场景/预制体/Addressables 引用 | + +### 13.2 已知未完成项(本计划范围内确认,但**未做**) + +按严重度排列。这些不是遗漏,是明确记录下来的缺口。 + +1. **⚠️ `EnemyHurtState.Enter` 无受击动画时永久卡死(最严重,且与 Boss 轨无关)。** + `AnimConfig.Hurt` 为空时提前返回,且**不安排回到 `Controlled`**。任何没有受击动画的敌人被打一次 + 就永久停在 `Hurt` 态:`IsControllable` 恒假、`AiRuntime` 永久挂起、AI 完全停摆。 + 当前美术未接入 ⇒ **大部分敌人一挨打就废**。典型的 CLAUDE.md 第 6 条静默提前返回。 + Task 15 进 Play 验证时发现。应尽快单独立项。 + +2. **战斗本身未经验证。** 动画 clip 尚未接入,`EnemyAttackSO.clip` 留空则该段瞬间完成。 + 已验证的是「能开战、能追击、能选招、能跑完招式、50% 血能换阶段、能悬停、零报错」这条**流程**; + **判定时机、伤害数值、手感一律未验证**。clip 到位后必须重新校时序与预警节奏。 + +3. **`EnemyBase.ReceiveParry` 无调用方。** 方法本身正确,但 `DamageInfo` 不携带攻击者引用, + "谁被弹反谁硬直"接不上。旧的 Boss 侧弹反反制已在 Task 16 删除——它的实际行为是 + "玩家弹反**任意**敌人都会让 Boss 硬直",是错的,所以这是**有意的移除且暂无替代**。 + 接线需要在 Combat 层补攻击者引用(见 §6.2)。 + +4. **池化敌人复活时带着上一条命的能力冷却。** `EnemyBase.OnSpawn` 里 + `_abilities.InterruptAll(InterruptReason.Dead)` 的注释写着「重置能力冷却」,但 + `EnemyAbilityBase.Interrupt` 在 `!_isRunning` 时提前返回,真跑到时又是**设置** + `_cooldownEndTime = Time.time + cooldown * 0.5f` 而非清零。冷却从未被重置。 + 修法:加 `EnemyAbilityBase.ResetCooldown()`,`OnSpawn` 遍历调用。影响全体池化敌人。 + +5. **`BrainBuilder.Build()` 不校验「非终态是否有出边」。** 只校验正向(边指向的态是否已声明)。 + 一个已声明却没有任何出边的非终态会让敌人永久卡住且**零报错**。 + `BossFragments.PhaseTransition` 按设计不自带出边,正是这类风险的头号候选 + (`ChaoFengAi` 的 `txDone` 边就是它唯一出口,删了 Boss 永久锁死)。 + 做法:给 `AiStateFragments.Terminal` 加显式终态标记,`Build()` 对「非终态且无出边」抛异常。 + +6. **`EnemyAbilityModule` 编辑器总览未写。** 旧的 `BossSkillModule` 编辑器视图随旧轨删除, + 能力资产目前没有编辑器总览。 + +7. **`AiDefinitionValidator` 未写。** 能力引用漏配 / id 拼错目前只能在 Play Mode 里靠肉眼排查。 + 优先级高于第 6 项。 + +8. **`BossFragments.MoveToAnchor` 只在 `OnEnter` 采样锚点。** 锚点 Transform 若会移动则不重新取点。 + 与「锚点是固定点」的设定一致,但这是个隐含假设。 + +### 13.3 文档处理与残留(Task 21) + +- **删除**:`Docs/Architecture/23_BossSkillModule.md`、`Docs/Design/47_BossSkillSystem.md` + (整份描述已删除且从未实现的体系)。删除前已确认全库无 Markdown 链接残留, + 索引行与关联文档行均已改指本 spec 或新的作者指南。 +- **修订**:`Docs/Architecture/00_CoverageIndex.md`、`README.md`、`02_EventSystem.md`、 + `07_EnemyModule.md`(加过时提示 + §11 改为重定向)、`25_CharacterArchitectureOverview.md` + (§4 Boss 章节按实际代码重写)、`Docs/Design/README.md`、`19_BossPatternLibrary.md`(加废止提示)、 + `69_BossDesignBible.md`、`Docs/Standards/AssetFolderSpec.md`(删旧 Boss 技能编辑器条目)。 +- **保留不动**:`Docs_Dev/` 下的历史计划与验证清单——那是开发过程记录,反映当时状态,改了反而失真。 +- **仍有残留、超出本次范围**:`Docs/` 里还有约十余处提到 `BossOrchestrator` / `AttackPatternSO` / + `TelegraphSystem` 的单行残留(`03_CoreModule` / `10_UIModule` / `11_AudioModule` / + `14_NarrativeModule` / `16_SupportingModules` / `01_ProjectStructure` / `Design/00_Overview` / + `Design/54_PoiseSystem` / `DesignSpec/07_EnemySystem` / `EditorTools/00_EditorToolsPlan` + / `Architecture_Review.md`)。这些多是跨模块事件表与目录树里的顺带提及, + 属于更大范围的「文档与代码一致性」清理,应单独立项,不在本计划内假装做完。 + 最关键的两份入口文档(`07_EnemyModule` / `25_CharacterArchitectureOverview`)已加显式过时提示, + 不会把读者引向不存在的类型。 diff --git a/zeling_v2.sln b/zeling_v2.sln index 4f8bc59e..09257a25 100644 --- a/zeling_v2.sln +++ b/zeling_v2.sln @@ -23,14 +23,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Combat", "BaseGam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Dialogue", "BaseGames.Dialogue.csproj", "{4595E198-DE11-AAF7-3388-915563EBFE41}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Enemies", "BaseGames.Enemies.csproj", "{5E00F025-ED00-233A-3B2F-BAFF76D883F0}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.VFX", "BaseGames.VFX.csproj", "{31DBC108-839C-1442-F9AC-A39596C9E06A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.UI", "BaseGames.UI.csproj", "{7D3E9996-D17C-52A7-538D-3AEBAAF35DF3}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Micosmo.SensorToolkit.Editor", "Micosmo.SensorToolkit.Editor.csproj", "{DF93C827-75A1-E6E4-D7D2-206550211A63}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Enemies", "BaseGames.Enemies.csproj", "{5E00F025-ED00-233A-3B2F-BAFF76D883F0}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.World.Map", "BaseGames.World.Map.csproj", "{16BB97E7-3EA9-4707-2D93-441D9C908404}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Tests.EditMode", "BaseGames.Tests.EditMode.csproj", "{0CFAE763-03B9-0921-E4ED-03289E7D499F}" @@ -93,8 +93,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Spells", "BaseGam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.World.Streaming", "BaseGames.World.Streaming.csproj", "{8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Enemies.Boss.Patterns", "BaseGames.Enemies.Boss.Patterns.csproj", "{FABE4470-F5D8-FF10-6CF9-C03E9D2A8DBD}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.Tutorial", "BaseGames.Tutorial.csproj", "{0A1566C3-6032-C8A1-D015-8EF75B3F7099}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BaseGames.World.Shop", "BaseGames.World.Shop.csproj", "{3022C488-D174-AD8B-A390-675C8CB13DEF}" @@ -157,10 +155,6 @@ Global {4595E198-DE11-AAF7-3388-915563EBFE41}.Debug|Any CPU.Build.0 = Debug|Any CPU {4595E198-DE11-AAF7-3388-915563EBFE41}.Release|Any CPU.ActiveCfg = Release|Any CPU {4595E198-DE11-AAF7-3388-915563EBFE41}.Release|Any CPU.Build.0 = Release|Any CPU - {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Release|Any CPU.Build.0 = Release|Any CPU {31DBC108-839C-1442-F9AC-A39596C9E06A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {31DBC108-839C-1442-F9AC-A39596C9E06A}.Debug|Any CPU.Build.0 = Debug|Any CPU {31DBC108-839C-1442-F9AC-A39596C9E06A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -173,6 +167,10 @@ Global {DF93C827-75A1-E6E4-D7D2-206550211A63}.Debug|Any CPU.Build.0 = Debug|Any CPU {DF93C827-75A1-E6E4-D7D2-206550211A63}.Release|Any CPU.ActiveCfg = Release|Any CPU {DF93C827-75A1-E6E4-D7D2-206550211A63}.Release|Any CPU.Build.0 = Release|Any CPU + {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E00F025-ED00-233A-3B2F-BAFF76D883F0}.Release|Any CPU.Build.0 = Release|Any CPU {16BB97E7-3EA9-4707-2D93-441D9C908404}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {16BB97E7-3EA9-4707-2D93-441D9C908404}.Debug|Any CPU.Build.0 = Debug|Any CPU {16BB97E7-3EA9-4707-2D93-441D9C908404}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -297,10 +295,6 @@ Global {8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU {8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU {8FA0AF4D-7EF6-D3CD-F2A1-9C291AA06F3C}.Release|Any CPU.Build.0 = Release|Any CPU - {FABE4470-F5D8-FF10-6CF9-C03E9D2A8DBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FABE4470-F5D8-FF10-6CF9-C03E9D2A8DBD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FABE4470-F5D8-FF10-6CF9-C03E9D2A8DBD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FABE4470-F5D8-FF10-6CF9-C03E9D2A8DBD}.Release|Any CPU.Build.0 = Release|Any CPU {0A1566C3-6032-C8A1-D015-8EF75B3F7099}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0A1566C3-6032-C8A1-D015-8EF75B3F7099}.Debug|Any CPU.Build.0 = Debug|Any CPU {0A1566C3-6032-C8A1-D015-8EF75B3F7099}.Release|Any CPU.ActiveCfg = Release|Any CPU