merge: Boss AI 双轨统一 — 决策层与执行层一并并入小怪轨

核心洞察:Boss 侧那层"富编排"从未真正存在——全项目 0 个 AttackPatternSO/
SkillSequenceSO 资产、5 个 BossSkillSO 全空、BossSkillExecutor 跑一遍纯空转、
UseBossSkillWeighted 零调用者、Boss 预制体上没有 EnemyAiBrain。唯一的 Boss(嘲风)
实际用的就是动画事件驱动,即小怪轨的做法。所以"按复杂度分层"的前提不成立,
双轨只是让机制原语出现平行实现并已开始漂移。

- 决策归 AI 图:Boss 走 [AiDefinition] AiScript 手写图(不建 Boss 骨架/配方——
  Boss 间差异远大于小怪,骨架会退化成开关面板)
- 阶段 = 换招池:BossPhaseAbilityGate 按阶段启停能力组件,不在 SO 上写阶段字段
- 招走 EnemyAbilitySO + EnemyAttackSO;判定/生成/音效/无敌帧全挂动画事件
- Boss 专属只剩 IBossControl facet(非 Boss 访问即抛)+ 几个旁挂 MonoBehaviour
- 删旧轨 9 个脚本与 5 个空能力资产;两条脚手架创建链路改挂新轨
- 选招器补防重复折扣(WeightedRandomAntiRepeat),取代旧轨同名逻辑

顺带修掉三个波及全体敌人的缺陷:
- EnemyHurtState/EnemyStaggerState 在无对应动画 Clip 时守卫式早退却丢掉恢复安排,
  敌人被打一次即永久失能(美术未接入时影响大部分敌人)
- 池化复活带着上一条命的能力冷却:OnSpawn 那行 InterruptAll 的两道 IsRunning 门
  让它恒为空转,注释所称的"重置冷却"从未发生
- BrainBuilder.Build() 只做正向校验,已声明却无出边的非终态会让敌人永久卡住且
  零报错;现加"非终态必须有出口"校验,部分图另走 BuildPartial()

净变化:139 文件,+3808/-4211;删 10 个脚本、加 14 个。
验证:编译 0 错;EditMode 269/269。
未做:进 Play 实跑嘲风战的人工验收(嘲风动画 Clip 仍缺,卡美术资源)。

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

Some files were not shown because too many files have changed in this diff Show More