feat: Implement Room Streaming System
- Add RoomStreamingManager to manage room loading and unloading based on player proximity. - Create StreamingBudgetConfigSO for memory and performance budgeting of the streaming system. - Introduce TransitionDirector to handle seamless and atmospheric fade transitions between rooms. - Develop WorldGraph to represent room connectivity and facilitate neighbor queries and distance calculations. - Implement RoomNode and RoomEdge classes to structure room data and connections.
This commit is contained in:
@@ -13,4 +13,68 @@ MonoBehaviour:
|
||||
m_Name: ICN_Keyboard
|
||||
m_EditorClassIdentifier:
|
||||
_deviceType: 0
|
||||
_entries: []
|
||||
_entries:
|
||||
- BindingPath: <Keyboard>/w
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/s
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/a
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/d
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/space
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/j
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/k
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/i
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/l
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/shift
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/e
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/1
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/2
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/3
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/q
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/z
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/x
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/f
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/escape
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/upArrow
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/downArrow
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/leftArrow
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Keyboard>/rightArrow
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: '*/{Submit}'
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: '*/{Cancel}'
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/position
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Pen>/position
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/leftButton
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Pen>/tip
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/scroll
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/middleButton
|
||||
Icon: {fileID: 0}
|
||||
- BindingPath: <Mouse>/rightButton
|
||||
Icon: {fileID: 0}
|
||||
|
||||
@@ -31,6 +31,8 @@ namespace BaseGames.Combat
|
||||
CanClash = 1 << 5,
|
||||
ForceBreak = 1 << 6,
|
||||
NoKnockback = 1 << 7,
|
||||
/// <summary>击飞:使敌人进入 KnockUp 状态(腾空 + 落地)。仅在伤害量 >= HitTierConfig.launchThreshold 时生效。</summary>
|
||||
Launch = 1 << 8,
|
||||
}
|
||||
|
||||
// ── 交互标签 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,7 +51,10 @@ namespace BaseGames.Core.Assets
|
||||
public const string PrefabWeaponSoulStaff = "WPN_SoulStaff";
|
||||
|
||||
// ── Config ScriptableObjects ─────────────────────────────────────
|
||||
public const string DataFootstepCatalog = "Config/FootstepCatalog";
|
||||
public const string DataFootstepCatalog = "Config/FootstepCatalog";
|
||||
|
||||
/// <summary>流式加载预算配置 SO,RoomStreamingManager 与 TransitionDirector 均依赖此资产。</summary>
|
||||
public const string DataStreamingBudgetConfig = "Config/StreamingBudgetConfig";
|
||||
|
||||
/// <summary>
|
||||
/// Addressable Label 常量(用于批量加载与预热)。
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 场景过渡类型,决定 <see cref="BaseGames.Core.SceneService"/> 的演出行为。
|
||||
/// 场景过渡类型,决定 <see cref="BaseGames.Core.SceneService"/> 或
|
||||
/// <see cref="BaseGames.Core.ITransitionDirector"/> 的演出行为。
|
||||
/// </summary>
|
||||
public enum TransitionType
|
||||
{
|
||||
@@ -12,5 +13,14 @@ namespace BaseGames.Core.Events
|
||||
/// <summary>跨大区域切换。完整淡出,显示加载画面。
|
||||
/// 适用于地图间传送、返回标题、大区域入口等有明显空间跳跃感的切换。</summary>
|
||||
Scene,
|
||||
|
||||
/// <summary>无缝切换。无任何遮挡,目标房间必须已预加载(Dormant 状态)。
|
||||
/// 相机跟随玩家越过边界,视觉上无任何打断感。
|
||||
/// 若目标房间尚未就绪,TransitionDirector 将等待预加载完成后再执行切换(有超时保护)。</summary>
|
||||
Seamless,
|
||||
|
||||
/// <summary>氛围淡入淡出切换。短暂淡出(≈0.25 s)+ 显示新区域名称 + 淡入。
|
||||
/// 适用于跨大区域边界、目标房间已预加载的情况,比 Room 有更强的"抵达感"。</summary>
|
||||
AtmosphericFade,
|
||||
}
|
||||
}
|
||||
|
||||
28
Assets/_Game/Scripts/Core/ITransitionDirector.cs
Normal file
28
Assets/_Game/Scripts/Core/ITransitionDirector.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 过渡导演接口。
|
||||
/// <para>
|
||||
/// <see cref="SceneService"/> 在处理 <see cref="SceneLoadRequest"/> 时,
|
||||
/// 若过渡类型为 <see cref="TransitionType.Seamless"/> 或 <see cref="TransitionType.AtmosphericFade"/>,
|
||||
/// 则通过 ServiceLocator 查找此接口并委托处理。
|
||||
/// 若未找到实现(非流式模式),则退回原有淡出加载流程。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ITransitionDirector
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理过渡请求。由 SceneService 在确认过渡类型后调用。
|
||||
/// 实现方负责完整的过渡流程(激活目标房间、相机切换、播放演出等)。
|
||||
/// </summary>
|
||||
void HandleTransition(SceneLoadRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// 查询目标场景是否已预加载完毕(处于 Dormant 状态),可执行无缝切换。
|
||||
/// 若返回 false,SceneService 将退回带黑屏的 Room 过渡。
|
||||
/// </summary>
|
||||
bool CanHandleSeamless(string targetSceneName);
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,29 @@ namespace BaseGames.Core
|
||||
private void OnDisable() => _subscriptions.Clear();
|
||||
|
||||
private void HandleSceneLoadRequest(SceneLoadRequest request)
|
||||
=> StartCoroutine(LoadSceneCoroutine(request));
|
||||
{
|
||||
// Seamless / AtmosphericFade 由 ITransitionDirector 处理(需要预加载支持)
|
||||
if (request.TransitionType == TransitionType.Seamless ||
|
||||
request.TransitionType == TransitionType.AtmosphericFade)
|
||||
{
|
||||
var director = ServiceLocator.GetOrDefault<ITransitionDirector>();
|
||||
if (director != null)
|
||||
{
|
||||
// TransitionDirector 内部处理"立即切换"与"等待预加载后切换"两条路径
|
||||
director.HandleTransition(request);
|
||||
return;
|
||||
}
|
||||
|
||||
// 未注册 ITransitionDirector(非流式模式):降级为 Room 过渡
|
||||
Debug.LogWarning($"[SceneService] 未找到 ITransitionDirector,{request.TransitionType} 降级为 Room 过渡。");
|
||||
var degraded = request;
|
||||
degraded.TransitionType = TransitionType.Room;
|
||||
StartCoroutine(LoadSceneCoroutine(degraded));
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(LoadSceneCoroutine(request));
|
||||
}
|
||||
|
||||
public IEnumerator LoadSceneCoroutine(SceneLoadRequest request)
|
||||
{
|
||||
|
||||
@@ -57,6 +57,7 @@ namespace BaseGames.Editor
|
||||
("SPL_", "Config"), // 法术配置 SO
|
||||
("ABL_", "Config"), // 能力配置 SO
|
||||
("MAP_", "Config"), // 地图数据 SO(AssetFolderSpec §4)
|
||||
("STR_", "Config"), // 流式加载配置 SO(StreamingBudgetConfigSO)
|
||||
("Config/", "Config"), // 路径前缀配置(AssetFolderSpec §8.2)
|
||||
// ── 音频(AUD_BGM_ / AUD_SFX_ 必须在通配 AUD_ 之前)─────────────
|
||||
("AUD_BGM_", "Audio_Music"), // BGM 流式音频
|
||||
@@ -79,6 +80,8 @@ namespace BaseGames.Editor
|
||||
{ AddressKeys.PrefabUIFloatingDmgText, new[] { AddressKeys.Labels.Poolable, AddressKeys.Labels.Preload } },
|
||||
// FootstepCatalog 是首帧必须可用的配置
|
||||
{ AddressKeys.DataFootstepCatalog, new[] { AddressKeys.Labels.Config, AddressKeys.Labels.Preload } },
|
||||
// 流式加载预算配置,运行时初始化前必须可用
|
||||
{ AddressKeys.DataStreamingBudgetConfig, new[] { AddressKeys.Labels.Config, AddressKeys.Labels.Preload } },
|
||||
};
|
||||
|
||||
// ── 前缀 → 标签列表 ─────────────────────────────────────────────────────
|
||||
@@ -105,6 +108,7 @@ namespace BaseGames.Editor
|
||||
// ── 配置数据 ─────────────────────────────────────────────────────
|
||||
("CHM_", new[] { AddressKeys.Labels.Charms }),
|
||||
("MAP_", new[] { AddressKeys.Labels.Config }), // 地图数据 SO 为动态加载配置
|
||||
("STR_", new[] { AddressKeys.Labels.Config }), // 流式加载配置 SO(StreamingBudgetConfigSO)
|
||||
("Config/", new[] { AddressKeys.Labels.Config }),
|
||||
// ── 技能 / 法术 / 能力 / 世界物件 / 持久化:无批量加载需求,不加 Label ──
|
||||
("SKL_", Array.Empty<string>()),
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"BaseGames.Parry",
|
||||
"BaseGames.Skills",
|
||||
"BaseGames.World.Map",
|
||||
"BaseGames.World.Streaming",
|
||||
"BaseGames.EventChain",
|
||||
"BaseGames.VFX",
|
||||
"Unity.InputSystem"
|
||||
|
||||
@@ -76,6 +76,7 @@ namespace BaseGames.Editor
|
||||
_modules.Add(new FormModule());
|
||||
_modules.Add(new BossSkillModule());
|
||||
_modules.Add(new CharmModule());
|
||||
_modules.Add(new StreamingModule());
|
||||
}
|
||||
|
||||
// ── 布局 ─────────────────────────────────────────────────────────────
|
||||
|
||||
155
Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs
Normal file
155
Assets/_Game/Scripts/Editor/Modules/StreamingModule.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using BaseGames.World.Streaming;
|
||||
|
||||
namespace BaseGames.Editor.Modules
|
||||
{
|
||||
/// <summary>
|
||||
/// DataHub 流式加载模块 —— 管理 <see cref="StreamingBudgetConfigSO"/> 资产。
|
||||
/// </summary>
|
||||
public class StreamingModule : IDataModule
|
||||
{
|
||||
private const string Folder = "Assets/_Game/Data/Streaming";
|
||||
private const string Prefix = "STR_";
|
||||
|
||||
public string ModuleId => "streaming";
|
||||
public string DisplayName => "流式加载";
|
||||
public string IconName => "d_RectTransformBlueprint";
|
||||
|
||||
private SoListPane<StreamingBudgetConfigSO> _listPane;
|
||||
private DetailHeader _header;
|
||||
private StreamingBudgetConfigSO _selected;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_listPane = new SoListPane<StreamingBudgetConfigSO>(
|
||||
Folder, Prefix,
|
||||
cfg => $"休眠上限 {cfg.MaxDormantRooms} 预加载深度 {cfg.PreloadLookaheadHops}跳");
|
||||
_listPane.SelectionChanged = sel =>
|
||||
{
|
||||
_selected = sel;
|
||||
};
|
||||
}
|
||||
|
||||
public void BuildListPane(VisualElement container, Action<UnityEngine.Object> onSelected)
|
||||
{
|
||||
_listPane.SelectionChanged = sel =>
|
||||
{
|
||||
_selected = sel;
|
||||
onSelected?.Invoke(sel);
|
||||
};
|
||||
|
||||
// 顶部操作栏(新建)
|
||||
var topBar = new VisualElement();
|
||||
topBar.style.flexDirection = FlexDirection.Row;
|
||||
topBar.style.paddingLeft = 8;
|
||||
topBar.style.paddingRight = 8;
|
||||
topBar.style.paddingTop = 6;
|
||||
topBar.style.paddingBottom = 6;
|
||||
topBar.style.borderBottomWidth = 1;
|
||||
topBar.style.borderBottomColor = new StyleColor(new Color(0.5f, 0.5f, 0.5f, 0.3f));
|
||||
container.Add(topBar);
|
||||
|
||||
var createBtn = new Button(() =>
|
||||
{
|
||||
var created = AssetOperations.Create<StreamingBudgetConfigSO>(Folder, "STR_BudgetConfig_New");
|
||||
if (created != null) _listPane.Refresh(created);
|
||||
}) { text = "+ 新建配置" };
|
||||
createBtn.style.flexGrow = 1;
|
||||
topBar.Add(createBtn);
|
||||
|
||||
container.Add(_listPane);
|
||||
_listPane.style.flexGrow = 1;
|
||||
_listPane.Refresh();
|
||||
}
|
||||
|
||||
public void BuildDetailPane(VisualElement container, UnityEngine.Object selected)
|
||||
{
|
||||
_selected = selected as StreamingBudgetConfigSO;
|
||||
|
||||
_header = new DetailHeader();
|
||||
_header.SetAsset(_selected);
|
||||
_header.RenameRequested += newName => OnRenameRequested(selected, newName);
|
||||
container.Add(_header);
|
||||
|
||||
if (_selected == null) return;
|
||||
|
||||
container.Add(BuildStatsCard(_selected));
|
||||
container.Add(BuildActionBar(_selected));
|
||||
container.Add(SkillModule.MakeDivider());
|
||||
container.Add(new InspectorElement(_selected));
|
||||
}
|
||||
|
||||
public void OnActivated() => _listPane?.Refresh();
|
||||
|
||||
// ── Stats Card ────────────────────────────────────────────────────────
|
||||
|
||||
private static VisualElement BuildStatsCard(StreamingBudgetConfigSO cfg)
|
||||
{
|
||||
var card = SkillModule.MakeCard();
|
||||
SkillModule.AddChip(card, "最大休眠房间", $"{cfg.MaxDormantRooms}");
|
||||
SkillModule.AddChip(card, "内存上限", $"{cfg.MaxMemoryMB} MB");
|
||||
SkillModule.AddChip(card, "并发加载数", $"{cfg.MaxConcurrentLoads}");
|
||||
SkillModule.AddChip(card, "预加载跳数", $"{cfg.PreloadLookaheadHops}");
|
||||
SkillModule.AddChip(card, "冷却时长", $"{cfg.CoolingDuration:F1}s");
|
||||
SkillModule.AddChip(card, "每帧激活数", $"{cfg.LifecycleActivatePerFrame}");
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Action Bar ────────────────────────────────────────────────────────
|
||||
|
||||
private VisualElement BuildActionBar(StreamingBudgetConfigSO cfg)
|
||||
{
|
||||
var bar = SkillModule.MakeActionBar();
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
EditorGUIUtility.PingObject(cfg);
|
||||
Selection.activeObject = cfg;
|
||||
}) { text = "定位" }.AlsoAddTo(bar);
|
||||
|
||||
new Button(() =>
|
||||
{
|
||||
var c = AssetOperations.Clone(cfg, Folder);
|
||||
if (c != null) _listPane.Refresh(c);
|
||||
}) { text = "克隆..." }.AlsoAddTo(bar);
|
||||
|
||||
var del = new Button(() =>
|
||||
{
|
||||
if (AssetOperations.Delete(cfg)) _listPane.Refresh(null);
|
||||
}) { text = "删除" };
|
||||
ApplyDeleteStyle(del);
|
||||
del.AlsoAddTo(bar);
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ── 重命名 ────────────────────────────────────────────────────────────
|
||||
|
||||
private void OnRenameRequested(UnityEngine.Object asset, string newName)
|
||||
{
|
||||
var (ok, err) = AssetOperations.Rename(asset, newName);
|
||||
if (!ok) EditorUtility.DisplayDialog("重命名失败", err, "确定");
|
||||
else { _header.SetAsset(asset); _listPane.Invalidate(); }
|
||||
}
|
||||
|
||||
// ── 共用 ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static void ApplyDeleteStyle(Button btn)
|
||||
{
|
||||
var c = new StyleColor(new Color(0.8f, 0.3f, 0.3f, 0.6f));
|
||||
btn.style.borderLeftColor = c;
|
||||
btn.style.borderRightColor = c;
|
||||
btn.style.borderTopColor = c;
|
||||
btn.style.borderBottomColor = c;
|
||||
btn.style.borderLeftWidth = 1;
|
||||
btn.style.borderRightWidth = 1;
|
||||
btn.style.borderTopWidth = 1;
|
||||
btn.style.borderBottomWidth = 1;
|
||||
btn.style.marginLeft = 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ using BaseGames.UI.MainMenu;
|
||||
using BaseGames.UI.Menus;
|
||||
using BaseGames.UI.Splash;
|
||||
using BaseGames.World;
|
||||
using BaseGames.World.Map;
|
||||
using BaseGames.World.Streaming;
|
||||
using PathBerserker2d;
|
||||
using Unity.Cinemachine;
|
||||
using UnityEditor;
|
||||
@@ -230,6 +232,9 @@ namespace BaseGames.Editor
|
||||
|
||||
AddScaffoldNote(hudRootGo, "HUDController 已挂载。其内部图片/文本/图标 Prefab 依赖较多,需后续手工补 UI 资源与事件频道。", report);
|
||||
|
||||
// ── 流式加载系统 ──────────────────────────────────────────────────
|
||||
ScaffoldStreamingSystem(services, report);
|
||||
|
||||
MarkDirtyAndLog("Persistent 场景脚手架", root, report);
|
||||
}
|
||||
|
||||
@@ -392,6 +397,79 @@ namespace BaseGames.Editor
|
||||
MarkDirtyAndLog("Game Room 脚手架", root, report);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 流式加载系统(RoomStreamingManager + TransitionDirector)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 在 [Services] 下创建或更新 SYS_RoomStreamingManager,
|
||||
/// 挂载 <see cref="RoomStreamingManager"/> 与 <see cref="TransitionDirector"/>,
|
||||
/// 并自动绑定已存在的事件频道与配置资产。
|
||||
/// </summary>
|
||||
private static void ScaffoldStreamingSystem(Transform services, List<string> report)
|
||||
{
|
||||
// 预算配置 SO(不存在时自动创建)
|
||||
StreamingBudgetConfigSO budgetConfig = EnsureStreamingBudgetConfigAsset(report);
|
||||
|
||||
// MapDatabaseSO(查找已存在的资产)
|
||||
Object mapDbAsset = FindFirstAssetByType<MapDatabaseSO>("MapDatabase", "MAP_Database", "MapDatabaseSO");
|
||||
if (mapDbAsset == null)
|
||||
report.Add("未找到 MapDatabaseSO 资产。请将 MapDatabaseSO 手工赋给 RoomStreamingManager._mapDatabase 与 TransitionDirector._mapDatabase。");
|
||||
|
||||
// ── SYS_RoomStreamingManager GameObject ──────────────────────────
|
||||
GameObject streamingGo = GetOrCreateChild(services, "SYS_RoomStreamingManager").gameObject;
|
||||
|
||||
RoomStreamingManager streamingMgr = GetOrAddComponent<RoomStreamingManager>(streamingGo);
|
||||
TransitionDirector transitionDir = GetOrAddComponent<TransitionDirector>(streamingGo);
|
||||
|
||||
// ── RoomStreamingManager 字段 ─────────────────────────────────────
|
||||
AssignReference(streamingMgr, "_mapDatabase", mapDbAsset);
|
||||
AssignReference(streamingMgr, "_budget", budgetConfig);
|
||||
AssignAsset(streamingMgr, "_onRoomEntered", report, false, "EVT_RoomEntered");
|
||||
AssignAsset(streamingMgr, "_onRoomPreloaded", report, false, "EVT_RoomPreloaded");
|
||||
|
||||
// ── TransitionDirector 字段 ───────────────────────────────────────
|
||||
AssignReference(transitionDir, "_streamingManager", streamingMgr);
|
||||
AssignReference(transitionDir, "_mapDatabase", mapDbAsset);
|
||||
AssignReference(transitionDir, "_budget", budgetConfig);
|
||||
AssignAsset(transitionDir, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest");
|
||||
AssignAsset(transitionDir, "_onFadeInRequest", report, false, "EVT_FadeInRequest");
|
||||
AssignAsset(transitionDir, "_onRegionNameDisplay", report, false, "EVT_RegionNameDisplay");
|
||||
AssignAsset(transitionDir, "_onSceneWorldStateRestored", report, false, "EVT_SceneWorldStateRestored");
|
||||
|
||||
report.Add("SYS_RoomStreamingManager:流式加载系统已创建。如 EVT_RoomEntered / EVT_RoomPreloaded 频道尚未存在,请通过 DataHub > Streaming 创建后重新运行脚手架。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 <c>Assets/_Game/Data/Streaming/</c> 下确保默认预算配置 SO 存在。
|
||||
/// 已存在时直接返回;不存在时自动创建 <c>STR_BudgetConfig_Default.asset</c>。
|
||||
/// </summary>
|
||||
private static StreamingBudgetConfigSO EnsureStreamingBudgetConfigAsset(List<string> report)
|
||||
{
|
||||
// 先查找已有资产
|
||||
string[] guids = AssetDatabase.FindAssets("t:StreamingBudgetConfigSO");
|
||||
if (guids != null && guids.Length > 0)
|
||||
{
|
||||
string path = AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||
var existing = AssetDatabase.LoadAssetAtPath<StreamingBudgetConfigSO>(path);
|
||||
if (existing != null)
|
||||
return existing;
|
||||
}
|
||||
|
||||
// 没有则创建默认资产
|
||||
const string folder = "Assets/_Game/Data/Streaming";
|
||||
const string assetPath = folder + "/STR_BudgetConfig_Default.asset";
|
||||
EnsureFolder(folder);
|
||||
|
||||
var created = ScriptableObject.CreateInstance<StreamingBudgetConfigSO>();
|
||||
AssetDatabase.CreateAsset(created, assetPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
report.Add($"已自动创建流式加载预算配置:{assetPath}。可在 DataHub > 流式加载 中编辑默认参数。");
|
||||
return created;
|
||||
}
|
||||
|
||||
private static void AssignString(Object target, string propertyName, string value, List<string> report = null)
|
||||
{
|
||||
SerializedObject serializedObject = new SerializedObject(target);
|
||||
|
||||
37
Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs
Normal file
37
Assets/_Game/Scripts/Enemies/AI/BDTaskAttributes.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using System;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 指定 BD Task 在编辑器任务面板中显示的名称。
|
||||
/// 本版本 BehaviorDesigner 不内置此特性,由项目自行定义以保持代码可读性与前向兼容性。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class TaskNameAttribute : Attribute
|
||||
{
|
||||
public string Name { get; }
|
||||
public TaskNameAttribute(string name) => Name = name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 指定 BD Task 在编辑器任务面板中所属分类(路径形式,如 "BaseGames/Enemy/Combat")。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class TaskCategoryAttribute : Attribute
|
||||
{
|
||||
public string Category { get; }
|
||||
public TaskCategoryAttribute(string category) => Category = category;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为 BD Task 提供编辑器 Tooltip 描述文本。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
public sealed class TaskDescriptionAttribute : Attribute
|
||||
{
|
||||
public string Description { get; }
|
||||
public TaskDescriptionAttribute(string description) => Description = description;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
@@ -9,14 +9,14 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:发动攻击。
|
||||
/// CanAttack() 检查通过后调用 BeginAttack,立即返回 Success。
|
||||
/// </summary>
|
||||
[TaskName("Attack")]
|
||||
[TaskCategory("BaseGames/Enemy/Combat")]
|
||||
[TaskDescription("执行近战攻击(单段或连击序列)")]
|
||||
public class BD_Attack : Action
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
54
Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs
Normal file
54
Assets/_Game/Scripts/Enemies/AI/BD_BossPhaseTransition.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:启动 Boss 阶段过渡演出(无敌帧 + 可选定格),等待过渡完成后返回 Success。
|
||||
///
|
||||
/// 返回 Running:过渡演出进行中(BossBase.IsPhaseTransitioning = true)。
|
||||
/// 返回 Success:过渡完成,已切换到目标阶段。
|
||||
/// 返回 Failure:BossBase 组件不存在。
|
||||
///
|
||||
/// 典型 BT 用法:
|
||||
/// Sequence [ BD_IsHPBelow(0.5) → BD_BossPhaseTransition(Phase=1, Duration=1.5) → ... ]
|
||||
/// </summary>
|
||||
[TaskName("Boss Phase Transition")]
|
||||
[TaskCategory("BaseGames/Enemy/Boss")]
|
||||
[TaskDescription("触发 Boss 阶段过渡演出(无敌 + 动画 + 广播事件)")]
|
||||
public class BD_BossPhaseTransition : Action
|
||||
{
|
||||
[Tooltip("切换到的目标阶段索引")]
|
||||
[SerializeField] private int m_TargetPhase = 1;
|
||||
|
||||
[Tooltip("无敌帧 + 过渡演出持续时间(秒)")]
|
||||
[SerializeField, Min(0f)] private float m_InvincibleDuration = 1.5f;
|
||||
|
||||
private BossBase _boss;
|
||||
private bool _started;
|
||||
|
||||
public override void OnAwake() => _boss = GetComponent<BossBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_started = false;
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_boss == null) return TaskStatus.Failure;
|
||||
|
||||
if (!_started)
|
||||
{
|
||||
_boss.BeginPhaseTransition(m_TargetPhase, m_InvincibleDuration);
|
||||
_started = true;
|
||||
}
|
||||
|
||||
return _boss.IsPhaseTransitioning ? TaskStatus.Running : TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e86991de79acdb3498c9187ea96a8c3c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
39
Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs
Normal file
39
Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:向半径内其他敌人广播警戒(Group Alert)。
|
||||
///
|
||||
/// 返回 Success:广播完成(不保证命中任何敌人)。
|
||||
///
|
||||
/// 典型用法:在 BD_SetAiPhase(Chase) 之后立即调用此节点,
|
||||
/// 使发现玩家的敌人将警报传播给周围同伴,实现群体索敌效果。
|
||||
///
|
||||
/// 半径优先使用 m_OverrideRadius;若为 0 则读取 EnemyStatsSO.AlertBroadcastRadius。
|
||||
/// </summary>
|
||||
[TaskName("Broadcast Alert")]
|
||||
[TaskCategory("BaseGames/Enemy/Perception")]
|
||||
[TaskDescription("向附近的友方广播警报,使其进入 Alert 状态")]
|
||||
public class BD_BroadcastAlert : Action
|
||||
{
|
||||
[Tooltip("广播半径(m);0 = 使用 EnemyStatsSO.AlertBroadcastRadius")]
|
||||
[SerializeField, Min(0f)] private float m_OverrideRadius = 0f;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
float r = m_OverrideRadius > 0f ? m_OverrideRadius : (_enemy.StatsSO?.AlertBroadcastRadius ?? 0f);
|
||||
_enemy.AlertNearby(r);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_BroadcastAlert.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93ffcd4596213bf4d8499aa565712213
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
@@ -8,14 +8,14 @@ namespace BaseGames.Enemies.AI
|
||||
/// <summary>
|
||||
/// BD Conditional:攻击冷却是否完毕(可以发动攻击)。
|
||||
/// </summary>
|
||||
[TaskName("Can Attack?")]
|
||||
[TaskCategory("BaseGames/Enemy/Combat")]
|
||||
[TaskDescription("检查当前是否满足近战攻击条件(距离 + 视线)")]
|
||||
public class BD_CanAttack : Conditional
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
35
Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs
Normal file
35
Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 条件:目标坐标从当前位置是否可到达(调用 NavAgent.CanReach,无法寻路时 Failure)。
|
||||
/// 用于 Boss 阶段切换或小怪决策能否追击到跨平台目标。
|
||||
/// </summary>
|
||||
[TaskName("Can Reach Target?")]
|
||||
[TaskCategory("BaseGames/Enemy/Perception")]
|
||||
[TaskDescription("检查 NavAgent 是否可以规划到目标的有效路径")]
|
||||
public sealed class BD_CanReachTarget : Conditional
|
||||
{
|
||||
[Tooltip("检查目标(留空则使用玩家位置)")]
|
||||
[SerializeField] private Transform m_Target;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy?.Nav == null) return TaskStatus.Failure;
|
||||
Vector2 pos = m_Target != null
|
||||
? (Vector2)m_Target.position
|
||||
: _enemy.PlayerTransform != null ? (Vector2)_enemy.PlayerTransform.position : Vector2.zero;
|
||||
return _enemy.Nav.CanReach(pos) ? TaskStatus.Success : TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_CanReachTarget.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 436d3fd564880ae46a899b347f9a3494
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
71
Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs
Normal file
71
Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 条件:abilityId 当前可用(冷却完毕且未在运行)。
|
||||
/// 可选开启范围、视线、脚踏地面等调度提示检查;
|
||||
/// 仅当所有启用的条件均满足时才返回 Success。
|
||||
/// </summary>
|
||||
[TaskName("Can Use Ability?")]
|
||||
[TaskCategory("BaseGames/Enemy/Combat")]
|
||||
[TaskDescription("检查能力是否存在且冷却就绪,支持范围/视线/地面等调度提示检查")]
|
||||
public sealed class BD_CanUseAbility : Conditional
|
||||
{
|
||||
[Tooltip("能力 ScriptableObject(优先级高于下方字符串 ID)")]
|
||||
[SerializeField] private EnemyAbilitySO m_AbilitySO;
|
||||
|
||||
[Tooltip("能力 ID(当 AbilitySO 未赋值时使用)")]
|
||||
[SerializeField] private string m_AbilityId = "";
|
||||
|
||||
[Header("调度提示(可选)")]
|
||||
[Tooltip("启用后,检查玩家是否在 AbilitySO.preferredRange 范围内")]
|
||||
[SerializeField] private bool m_CheckRange = false;
|
||||
|
||||
[Tooltip("启用后,检查 AbilitySO.requiresLineOfSight 是否满足视线条件")]
|
||||
[SerializeField] private bool m_CheckLineOfSight = false;
|
||||
|
||||
[Tooltip("启用后,检查 AbilitySO.requiresGrounded 是否满足落地条件")]
|
||||
[SerializeField] private bool m_CheckGrounded = false;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
|
||||
string id = m_AbilitySO != null ? m_AbilitySO.abilityId : m_AbilityId;
|
||||
var ab = _enemy.Abilities.Get(id);
|
||||
if (ab == null || !ab.CanUse) return TaskStatus.Failure;
|
||||
|
||||
// ── 调度提示检查(均可在 Inspector 独立开关)──────────────────────────
|
||||
var config = ab.Config;
|
||||
if (config != null)
|
||||
{
|
||||
if (m_CheckGrounded && config.requiresGrounded && !(_enemy.Movement?.IsGrounded ?? false))
|
||||
return TaskStatus.Failure;
|
||||
|
||||
if (m_CheckLineOfSight && config.requiresLineOfSight && !_enemy.IsPlayerVisible())
|
||||
return TaskStatus.Failure;
|
||||
|
||||
if (m_CheckRange && _enemy.Stats != null)
|
||||
{
|
||||
float sqrDist = _enemy.Stats.SqrDistanceToPlayer;
|
||||
float minSqr = config.preferredMinRange * config.preferredMinRange;
|
||||
float maxSqr = config.preferredMaxRange * config.preferredMaxRange;
|
||||
if (sqrDist < minSqr || sqrDist > maxSqr)
|
||||
return TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_CanUseAbility.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fbc6b3634e77bf40bfeb918c7e45e5f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
41
Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs
Normal file
41
Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using UnityEngine;
|
||||
using BaseGames.Boss;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Conditional:检查 Boss 技能是否冷却就绪。
|
||||
/// 支持拖拽 BossSkillSO 或直接填写 skillId 字符串(SO 优先)。
|
||||
/// </summary>
|
||||
[TaskName("Can Use Boss Skill?")]
|
||||
[TaskCategory("BaseGames/Enemy/Boss")]
|
||||
[TaskDescription("检查指定 Boss 技能是否在当前阶段可用且冷却就绪")]
|
||||
public class BD_CanUseBossSkill : Conditional
|
||||
{
|
||||
[SerializeField] private BossSkillSO m_SkillSO;
|
||||
[SerializeField] private string m_SkillId;
|
||||
|
||||
private BossBase _boss;
|
||||
private BossSkillExecutor _executor;
|
||||
|
||||
public override void OnAwake()
|
||||
{
|
||||
_boss = GetComponent<BossBase>();
|
||||
_executor = GetComponentInChildren<BossSkillExecutor>();
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_executor == null) return TaskStatus.Failure;
|
||||
|
||||
string id = m_SkillSO != null ? m_SkillSO.skillId : m_SkillId;
|
||||
if (string.IsNullOrEmpty(id)) return TaskStatus.Failure;
|
||||
|
||||
return _executor.CanUseSkill(id) ? TaskStatus.Success : TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_CanUseBossSkill.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb7c971f5193f174189337814487bd7d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
140
Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs
Normal file
140
Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:追击玩家(三阶段视线丢失模型)。
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Tracking</b>:持有视线,以奔跑速度追击并更新 LastKnownPlayerPosition。</item>
|
||||
/// <item><b>Searching</b>:视线丢失超过 LostSightBuffer(~0.3s)后进入。速度降低,向最后可见位置行进;若恢复视线即回到 Tracking。</item>
|
||||
/// <item><b>Lost</b>:Searching 持续超过 LoseLinkTimeout → 返回 Failure,让 BD 树切入 BD_InvestigateLastKnown。</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 短暂遮挡(如绕过柱子)在 LostSightBuffer 内不会改变速度,避免追击手感割裂。
|
||||
/// </summary>
|
||||
[TaskName("Chase Player")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("三段式追击(Tracking→Searching→Lost);丢失视线后进入缓冲期")]
|
||||
public sealed class BD_ChasePlayer : Action
|
||||
{
|
||||
[Header("距离限制")]
|
||||
[Tooltip("追击距离上限(m);0 = 使用 EnemyStatsSO.MaxChaseDistance")]
|
||||
[SerializeField] [Min(0f)] private float m_MaxChaseDistance = 0f;
|
||||
|
||||
[Header("视线丢失")]
|
||||
[Tooltip("视线丢失判定超时(s);0 = 使用 EnemyStatsSO.LoseLinkTimeout")]
|
||||
[SerializeField] [Min(0f)] private float m_LoseLinkTimeout = 0f;
|
||||
|
||||
[Tooltip("视线刚丢失后的缓冲期(s):此期间保持追击速度,短暂遮挡不触发搜索模式")]
|
||||
[SerializeField] [Min(0f)] private float m_LostSightBuffer = 0.3f;
|
||||
|
||||
[Header("路径规划")]
|
||||
[Tooltip("路径重规划阈值(m):玩家移动超过此距离后才重新规划,避免每帧请求")]
|
||||
[SerializeField] [Min(0.1f)] private float m_ReplanThreshold = 1.5f;
|
||||
|
||||
[Header("搜索阶段")]
|
||||
[Tooltip("Searching 阶段速度倍率(相对于行走速度;<1 = 减速搜索)")]
|
||||
[SerializeField] [UnityEngine.Range(0.3f, 1.5f)] private float m_SearchSpeedMultiplier = 0.7f;
|
||||
|
||||
private enum ChaseSubState { Tracking, Searching }
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private float _losTimer;
|
||||
private Vector2 _lastReplanPos;
|
||||
private ChaseSubState _subState;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy.SetAiPhase(AiPhase.Chase);
|
||||
_losTimer = 0f;
|
||||
_lastReplanPos = Vector2.positiveInfinity;
|
||||
_subState = ChaseSubState.Tracking;
|
||||
|
||||
float runSpeed = _enemy.Stats?.RunSpeed ?? 4f;
|
||||
_enemy.Nav?.SetSpeed(runSpeed);
|
||||
|
||||
var ac = _enemy.AnimConfig;
|
||||
if (_enemy.Animancer != null && ac?.Run != null)
|
||||
_enemy.Animancer.Play(ac.Run);
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null || _enemy.PlayerTransform == null)
|
||||
return TaskStatus.Failure;
|
||||
|
||||
float maxDist = m_MaxChaseDistance > 0f ? m_MaxChaseDistance : (_enemy.Stats?.MaxChaseDistance ?? 15f);
|
||||
float loseTime = m_LoseLinkTimeout > 0f ? m_LoseLinkTimeout : (_enemy.Stats?.LoseLinkTimeout ?? 2f);
|
||||
|
||||
// 超出最大追击距离 → 放弃追击
|
||||
if (_enemy.Stats != null && _enemy.Stats.SqrDistanceToPlayer > maxDist * maxDist)
|
||||
return TaskStatus.Failure;
|
||||
|
||||
Vector2 playerPos = _enemy.PlayerTransform.position;
|
||||
|
||||
if (_enemy.IsPlayerVisible())
|
||||
{
|
||||
// 视线恢复:Searching → Tracking,恢复奔跑速度
|
||||
if (_subState == ChaseSubState.Searching)
|
||||
{
|
||||
_subState = ChaseSubState.Tracking;
|
||||
_enemy.Nav?.SetSpeed(_enemy.Stats?.RunSpeed ?? 4f);
|
||||
}
|
||||
_losTimer = 0f;
|
||||
_enemy.LastKnownPlayerPosition = playerPos;
|
||||
}
|
||||
else
|
||||
{
|
||||
_losTimer += Time.deltaTime;
|
||||
|
||||
if (_subState == ChaseSubState.Tracking)
|
||||
{
|
||||
// 缓冲期结束 → 切入 Searching:减速并向最后可见位置行进
|
||||
if (_losTimer >= m_LostSightBuffer)
|
||||
{
|
||||
_subState = ChaseSubState.Searching;
|
||||
float searchSpeed = (_enemy.Stats?.WalkSpeed ?? 2f) * m_SearchSpeedMultiplier;
|
||||
_enemy.Nav?.SetSpeed(searchSpeed);
|
||||
_enemy.MoveTo(_enemy.LastKnownPlayerPosition);
|
||||
_lastReplanPos = _enemy.LastKnownPlayerPosition;
|
||||
}
|
||||
}
|
||||
else // Searching
|
||||
{
|
||||
if (_losTimer >= loseTime)
|
||||
return TaskStatus.Failure; // 搜索超时 → 进入 BD_InvestigateLastKnown
|
||||
}
|
||||
}
|
||||
|
||||
// Tracking 阶段按阈值重规划路径
|
||||
if (_subState == ChaseSubState.Tracking)
|
||||
{
|
||||
float sqrReplan = m_ReplanThreshold * m_ReplanThreshold;
|
||||
if ((playerPos - _lastReplanPos).sqrMagnitude > sqrReplan)
|
||||
{
|
||||
_enemy.MoveTo(playerPos);
|
||||
_lastReplanPos = playerPos;
|
||||
}
|
||||
}
|
||||
|
||||
_enemy.FacePlayer();
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
float walkSpeed = _enemy?.Stats?.WalkSpeed ?? 2f;
|
||||
_enemy?.Nav?.SetSpeed(walkSpeed);
|
||||
_enemy?.StopMovement();
|
||||
_subState = ChaseSubState.Tracking; // 重置子状态,防止下次激活时以错误状态重入
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_ChasePlayer.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a7ecfbdf1fee6a141a18c72d36fcfbf2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
@@ -9,22 +9,26 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:Boss 切换阶段(Phase)。
|
||||
/// 调用 BossBase.EnterPhase(PhaseIndex),单帧完成,返回 Success。
|
||||
/// </summary>
|
||||
[TaskName("Enter Boss Phase")]
|
||||
[TaskCategory("BaseGames/Enemy/Boss")]
|
||||
[TaskDescription("立即切换 Boss 到目标阶段序号(单帧完成)")]
|
||||
public class BD_EnterPhase : Action
|
||||
{
|
||||
[UnityEngine.SerializeField] private int m_PhaseIndex = 1;
|
||||
|
||||
private BossBase _boss;
|
||||
|
||||
public override void OnAwake() => _boss = GetComponent<BossBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_boss = GetComponent<BossBase>();
|
||||
// 阶段切换是单帧操作,在 OnStart 完成;OnUpdate 仅汇报结果
|
||||
_boss?.EnterPhase(m_PhaseIndex);
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_boss == null) return TaskStatus.Failure;
|
||||
_boss.EnterPhase(m_PhaseIndex);
|
||||
return TaskStatus.Success;
|
||||
return _boss == null ? TaskStatus.Failure : TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -9,21 +9,18 @@ namespace BaseGames.Enemies.AI
|
||||
/// <summary>
|
||||
/// BD Action:立即朝向玩家,单帧完成,返回 Success。
|
||||
/// </summary>
|
||||
[TaskName("Face Target")]
|
||||
[TaskCategory("BaseGames/Enemy/Animation")]
|
||||
[TaskDescription("立即朝向目标 Transform 或玩家,单帧返回 Success")]
|
||||
public class BD_FaceTarget : Action
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
private Transform _player;
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
var go = GameObject.FindWithTag("Player");
|
||||
_player = go != null ? go.transform : null;
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null || _player == null) return TaskStatus.Failure;
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
_enemy.FacePlayer();
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
|
||||
32
Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs
Normal file
32
Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>动作:中断指定能力。</summary>
|
||||
[TaskName("Interrupt Ability")]
|
||||
[TaskCategory("BaseGames/Enemy/Combat")]
|
||||
[TaskDescription("立即中断指定能力或全部能力")]
|
||||
public sealed class BD_InterruptAbility : Action
|
||||
{
|
||||
[SerializeField] private string m_AbilityId = "";
|
||||
[SerializeField] private InterruptReason m_Reason = InterruptReason.ExternalRequest;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
var ab = _enemy.Abilities.Get(m_AbilityId);
|
||||
if (ab == null) return TaskStatus.Failure;
|
||||
ab.Interrupt(m_Reason);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_InterruptAbility.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3db7ca634cc20574dba7f11ab018b23a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
184
Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs
Normal file
184
Assets/_Game/Scripts/Enemies/AI/BD_InvestigateLastKnown.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:多步搜查(Navigate → LookAround → RandomWalk × N)。
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Navigate</b>:导航至最后可见位置。</item>
|
||||
/// <item><b>LookAround</b>:到达后原地环顾(播放 Look 动画,持续 LookAroundDuration)。</item>
|
||||
/// <item><b>WalkRandom</b>:向搜查半径内的随机点移动,重复 RandomStepCount 次。</item>
|
||||
/// <item>任意阶段重新发现玩家 → 返回 Failure,BD 树重入追击。</item>
|
||||
/// <item>所有步骤完成仍未发现 → 返回 Success,BD 树归位/恢复巡逻。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[TaskName("Investigate Last Known Pos")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("多步搜查:导航到最后已知位置 → 环顾四周 → 随机游走 N 次")]
|
||||
public sealed class BD_InvestigateLastKnown : Action
|
||||
{
|
||||
[Tooltip("到达搜查点后环顾的时长(s)")]
|
||||
[SerializeField] private float m_LookAroundDuration = 0.8f;
|
||||
|
||||
[Tooltip("随机行走步数(完成环顾后随机游荡的次数)")]
|
||||
[SerializeField] [Min(0)] private int m_RandomStepCount = 2;
|
||||
|
||||
[Tooltip("随机行走半径(m)")]
|
||||
[SerializeField] private float m_SearchRadius = 2.5f;
|
||||
|
||||
[Tooltip("到达目标点的判定半径(m)")]
|
||||
[SerializeField] private float m_ArriveRadius = 0.6f;
|
||||
|
||||
private enum InvestigateSubStep { Navigate, LookAround, WalkRandom }
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private InvestigateSubStep _step;
|
||||
private float _stepTimer;
|
||||
private int _stepsRemaining;
|
||||
private Vector2 _randomTarget;
|
||||
private bool _pathFailed;
|
||||
private bool _subscribed;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy.SetAiPhase(AiPhase.Investigate);
|
||||
_step = InvestigateSubStep.Navigate;
|
||||
_stepTimer = 0f;
|
||||
_stepsRemaining = m_RandomStepCount;
|
||||
_pathFailed = false;
|
||||
|
||||
var ac = _enemy.AnimConfig;
|
||||
if (_enemy.Animancer != null)
|
||||
{
|
||||
var clip = ac?.Investigate ?? ac?.Walk;
|
||||
if (clip != null) _enemy.Animancer.Play(clip);
|
||||
}
|
||||
|
||||
if (!_subscribed && _enemy.Nav != null)
|
||||
{
|
||||
_enemy.Nav.OnNavPathFailed += HandlePathFailed;
|
||||
_subscribed = true;
|
||||
}
|
||||
|
||||
_enemy.MoveTo(_enemy.LastKnownPlayerPosition);
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
|
||||
if (_enemy.IsPlayerVisible()) return TaskStatus.Failure;
|
||||
|
||||
switch (_step)
|
||||
{
|
||||
case InvestigateSubStep.Navigate: return UpdateNavigate();
|
||||
case InvestigateSubStep.LookAround: return UpdateLookAround();
|
||||
case InvestigateSubStep.WalkRandom: return UpdateWalkRandom();
|
||||
default: return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
if (_subscribed && _enemy?.Nav != null)
|
||||
{
|
||||
_enemy.Nav.OnNavPathFailed -= HandlePathFailed;
|
||||
_subscribed = false;
|
||||
}
|
||||
_enemy?.StopMovement();
|
||||
}
|
||||
|
||||
// ── 阶段逻辑 ────────────────────────────────────────────────────
|
||||
private TaskStatus UpdateNavigate()
|
||||
{
|
||||
Vector2 self = _enemy.transform.position;
|
||||
bool arrived = _pathFailed ||
|
||||
(self - _enemy.LastKnownPlayerPosition).sqrMagnitude <= m_ArriveRadius * m_ArriveRadius;
|
||||
|
||||
if (!arrived) return TaskStatus.Running;
|
||||
|
||||
_enemy.StopMovement();
|
||||
EnterLookAround();
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
private TaskStatus UpdateLookAround()
|
||||
{
|
||||
_stepTimer += Time.deltaTime;
|
||||
if (_stepTimer < m_LookAroundDuration) return TaskStatus.Running;
|
||||
|
||||
if (_stepsRemaining > 0)
|
||||
EnterRandomWalk();
|
||||
else
|
||||
return TaskStatus.Success;
|
||||
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
private TaskStatus UpdateWalkRandom()
|
||||
{
|
||||
Vector2 self = _enemy.transform.position;
|
||||
bool arrived = _pathFailed ||
|
||||
(self - _randomTarget).sqrMagnitude <= m_ArriveRadius * m_ArriveRadius;
|
||||
|
||||
if (!arrived) return TaskStatus.Running;
|
||||
|
||||
_enemy.StopMovement();
|
||||
_stepsRemaining--;
|
||||
_pathFailed = false;
|
||||
|
||||
if (_stepsRemaining > 0)
|
||||
EnterRandomWalk();
|
||||
else
|
||||
return TaskStatus.Success;
|
||||
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
// ── 辅助方法 ────────────────────────────────────────────────────
|
||||
private void EnterLookAround()
|
||||
{
|
||||
_step = InvestigateSubStep.LookAround;
|
||||
_stepTimer = 0f;
|
||||
|
||||
var ac = _enemy.AnimConfig;
|
||||
if (_enemy.Animancer != null)
|
||||
{
|
||||
var clip = ac?.Investigate ?? ac?.Idle;
|
||||
if (clip != null) _enemy.Animancer.Play(clip);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnterRandomWalk()
|
||||
{
|
||||
_step = InvestigateSubStep.WalkRandom;
|
||||
_pathFailed = false;
|
||||
|
||||
Vector2 origin = _enemy.LastKnownPlayerPosition;
|
||||
// 横板地形中只在水平方向随机偏移,保留 origin.y;
|
||||
// PathBerserker2d 寻路负责处理跨平台的纵向路径规划。
|
||||
float dir = Random.value > 0.5f ? 1f : -1f;
|
||||
float dist = Random.Range(0.5f, m_SearchRadius);
|
||||
_randomTarget = new Vector2(origin.x + dir * dist, origin.y);
|
||||
|
||||
_enemy.MoveTo(_randomTarget);
|
||||
|
||||
var ac = _enemy.AnimConfig;
|
||||
if (_enemy.Animancer != null)
|
||||
{
|
||||
var clip = ac?.Walk;
|
||||
if (clip != null) _enemy.Animancer.Play(clip);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePathFailed() => _pathFailed = true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e43db9982a49864e95812919b0efd10
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
34
Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs
Normal file
34
Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>条件:abilityId 当前是否正在运行。</summary>
|
||||
[TaskName("Is Ability Running?")]
|
||||
[TaskCategory("BaseGames/Enemy/Combat")]
|
||||
[TaskDescription("检查指定能力当前是否正在执行中")]
|
||||
public sealed class BD_IsAbilityRunning : Conditional
|
||||
{
|
||||
[Tooltip("能力 ScriptableObject(优先级高于下方字符串 ID)")]
|
||||
[SerializeField] private EnemyAbilitySO m_AbilitySO;
|
||||
|
||||
[Tooltip("能力 ID(当 AbilitySO 未赋值时使用)")]
|
||||
[SerializeField] private string m_AbilityId = "";
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
string id = m_AbilitySO != null ? m_AbilitySO.abilityId : m_AbilityId;
|
||||
var ab = _enemy.Abilities.Get(id);
|
||||
return ab != null && ab.IsRunning ? TaskStatus.Success : TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_IsAbilityRunning.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 254e8e3d6b56229498e94e24e7e53393
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
36
Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs
Normal file
36
Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Conditional:当前 AI 行为阶段是否与目标匹配。
|
||||
///
|
||||
/// 常用于 Decorator Conditional Abort 或 Sequence 头部保护子树,
|
||||
/// 例如只有在 <see cref="AiPhase.Patrol"/> 阶段时才执行巡逻序列。
|
||||
/// </summary>
|
||||
[TaskName("Is Ai Phase?")]
|
||||
[TaskCategory("BaseGames/Enemy/State")]
|
||||
[TaskDescription("检查当前 AI 行为阶段是否与目标枚举值匹配")]
|
||||
public sealed class BD_IsAiPhase : Conditional
|
||||
{
|
||||
[Tooltip("目标 AI 行为阶段")]
|
||||
[SerializeField] private AiPhase m_Phase = AiPhase.Patrol;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
return _enemy.CurrentAiPhase == m_Phase
|
||||
? TaskStatus.Success
|
||||
: TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_IsAiPhase.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c5d65c8544c21a146a5f30140acdb5ce
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
@@ -8,14 +8,14 @@ namespace BaseGames.Enemies.AI
|
||||
/// <summary>
|
||||
/// BD Conditional:敌人是否处于地面。
|
||||
/// </summary>
|
||||
[TaskName("Is Grounded?")]
|
||||
[TaskCategory("BaseGames/Enemy/State")]
|
||||
[TaskDescription("检查敌人是否接地(适用于触发跳跃或落地逻辑的条件保护)")]
|
||||
public class BD_IsGrounded : Conditional
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
@@ -9,22 +9,24 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Conditional:Boss HP 是否低于阈值(用于触发阶段切换)。
|
||||
/// HPThreshold 为 0~1 归一化比例(如 0.5 = HP ≤ 50%)。
|
||||
/// </summary>
|
||||
[TaskName("Is HP Below Threshold?")]
|
||||
[TaskCategory("BaseGames/Enemy/State")]
|
||||
[TaskDescription("检查当前 HP 是否低于指定百分比阈值")]
|
||||
public class BD_IsHPBelow : Conditional
|
||||
{
|
||||
[UnityEngine.Range(0f, 1f)]
|
||||
[UnityEngine.SerializeField] private float m_HPThreshold = 0.5f;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null || _enemy.Stats == null) return TaskStatus.Failure;
|
||||
|
||||
float ratio = (float)_enemy.Stats.CurrentHP / _enemy.Stats.MaxHP;
|
||||
float maxHP = UnityEngine.Mathf.Max(1f, _enemy.Stats.MaxHP);
|
||||
float ratio = (float)_enemy.Stats.CurrentHP / maxHP;
|
||||
return ratio <= m_HPThreshold ? TaskStatus.Success : TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
@@ -9,14 +9,14 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Conditional:敌人是否靠近平台边缘。
|
||||
/// 通过 EnemyNavAgent.IsNearEdge() 检测(双射线检测脚下/前方地面)。
|
||||
/// </summary>
|
||||
[TaskName("Is Near Edge?")]
|
||||
[TaskCategory("BaseGames/Enemy/State")]
|
||||
[TaskDescription("检查前方是否有悬崖边缘(基于 SensorToolkit 或 Raycast)")]
|
||||
public class BD_IsNearEdge : Conditional
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
35
Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs
Normal file
35
Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 条件:NavAgent 当前是否正在穿越连接段(跳跃/下落/爬梯/传送等)。
|
||||
/// 可选过滤具体连接类型(填 None = 任意类型均匹配)。
|
||||
/// </summary>
|
||||
[TaskName("Is On NavLink?")]
|
||||
[TaskCategory("BaseGames/Enemy/State")]
|
||||
[TaskDescription("检查 NavAgent 当前是否正在穿越 NavLink(如跳跃传送门)")]
|
||||
public sealed class BD_IsOnNavLink : Conditional
|
||||
{
|
||||
[Tooltip("填 None 匹配任意类型;填具体类型则只在该类型时 Success。")]
|
||||
[SerializeField] private NavLinkType m_FilterType = NavLinkType.None;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy?.Nav == null) return TaskStatus.Failure;
|
||||
if (!_enemy.Nav.IsOnLink) return TaskStatus.Failure;
|
||||
if (m_FilterType != NavLinkType.None && _enemy.Nav.CurrentLinkType != m_FilterType)
|
||||
return TaskStatus.Failure;
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_IsOnNavLink.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44406bb7e06320746950682adf59f59c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
@@ -9,6 +9,9 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Conditional:检查玩家是否在指定范围内。
|
||||
/// 成功/失败直接驱动 BT 分支选择(Selector / Sequence 节点)。
|
||||
/// </summary>
|
||||
[TaskName("Is Player In Range?")]
|
||||
[TaskCategory("BaseGames/Enemy/Perception")]
|
||||
[TaskDescription("检查玩家是否在指定距离内")]
|
||||
public class BD_IsPlayerInRange : Conditional
|
||||
{
|
||||
/// <summary>检测范围(Inspector 可配置,默认 6 米)。</summary>
|
||||
@@ -16,10 +19,7 @@ namespace BaseGames.Enemies.AI
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
@@ -9,14 +9,14 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Conditional:玩家是否可见(LOS 检测)。
|
||||
/// 读取 EnemyBase._losResult 字段(由 BatchLOSSystem 每帧写入,或降级 3 帧节流 Raycast)。
|
||||
/// </summary>
|
||||
[TaskName("Is Player Visible?")]
|
||||
[TaskCategory("BaseGames/Enemy/Perception")]
|
||||
[TaskDescription("检查是否有视线到达玩家(通过 EnemyBase.HasLineOfSight)")]
|
||||
public class BD_IsPlayerVisible : Conditional
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
41
Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs
Normal file
41
Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies.Perception;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 条件:EnemySensorHub 中名为 slotName 的 Sensor 是否检测到目标。
|
||||
/// 若 m_Target 为空则使用 EnemyBase.PlayerTransform。
|
||||
/// </summary>
|
||||
[TaskName("Is Sensor Detecting?")]
|
||||
[TaskCategory("BaseGames/Enemy/Perception")]
|
||||
[TaskDescription("检查 EnemySensorHub 中指定 Sensor 槽是否检测到目标")]
|
||||
public sealed class BD_IsSensorDetecting : Conditional
|
||||
{
|
||||
[SerializeField] private string m_SlotName = "aggro";
|
||||
[SerializeField] private bool m_AnyTarget = false;
|
||||
|
||||
private EnemySensorHub _hub;
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake()
|
||||
{
|
||||
_hub = gameObject.GetComponent<EnemySensorHub>();
|
||||
_enemy = gameObject.GetComponent<EnemyBase>();
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_hub == null) return TaskStatus.Failure;
|
||||
if (m_AnyTarget)
|
||||
return _hub.HasAnyDetection(m_SlotName) ? TaskStatus.Success : TaskStatus.Failure;
|
||||
var tgt = _enemy != null ? _enemy.PlayerTransform : null;
|
||||
if (tgt == null) return TaskStatus.Failure;
|
||||
return _hub.IsDetecting(m_SlotName, tgt.gameObject) ? TaskStatus.Success : TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_IsSensorDetecting.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5a1015b63dbb3da4aa877d7e898b394a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
@@ -7,36 +7,23 @@ using BaseGames.Enemies;
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Conditional:敌人当前 EnemyStateType 是否与目标状态名称匹配。
|
||||
/// TargetStateName 直接输入枚举名称字符串(Controlled / Hurt / Stagger / Dead),
|
||||
/// 枚举值顺序变化时 BD 图不会静默失效。
|
||||
/// BD Conditional:敌人当前 EnemyStateType 是否与目标状态匹配。
|
||||
/// </summary>
|
||||
[TaskName("Is State Match?")]
|
||||
[TaskCategory("BaseGames/Enemy/State")]
|
||||
[TaskDescription("检查当前物理/战斗状态是否与目标枚举值匹配")]
|
||||
public class BD_IsStateMatch : Conditional
|
||||
{
|
||||
/// <summary>目标状态名称(Controlled / Hurt / Stagger / Dead)。</summary>
|
||||
[SerializeField] private string m_TargetStateName = "Controlled";
|
||||
[SerializeField] private EnemyStateType m_TargetState = EnemyStateType.Controlled;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
|
||||
if (!System.Enum.TryParse<EnemyStateType>(m_TargetStateName, out var target))
|
||||
{
|
||||
Debug.LogError($"[BD_IsStateMatch] 未知状态名: '{m_TargetStateName}'," +
|
||||
"有效值为 Controlled / Hurt / Stagger / Dead", gameObject);
|
||||
return TaskStatus.Failure;
|
||||
}
|
||||
|
||||
return _enemy.CurrentState == target
|
||||
? TaskStatus.Success
|
||||
: TaskStatus.Failure;
|
||||
return _enemy.CurrentState == m_TargetState ? TaskStatus.Success : TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -10,6 +10,9 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:跳跃至目标坐标(抛物线跳跃)。
|
||||
/// 调用 EnemyBase.JumpTo,待落地(IsGrounded)后返回 Success。
|
||||
/// </summary>
|
||||
[TaskName("Jump To")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("执行跳跃动作到达目标高度或 NavLink 对接点")]
|
||||
public class BD_JumpTo : Action
|
||||
{
|
||||
[SerializeField] private Vector2 m_Target;
|
||||
@@ -17,9 +20,10 @@ namespace BaseGames.Enemies.AI
|
||||
private EnemyBase _enemy;
|
||||
private bool _jumped;
|
||||
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
_jumped = false;
|
||||
}
|
||||
|
||||
|
||||
96
Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs
Normal file
96
Assets/_Game/Scripts/Enemies/AI/BD_MaintainCombatDistance.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:战斗站位控制。
|
||||
/// 在 [m_PreferredMinDist, m_PreferredMaxDist] 范围内保持与玩家的水平距离:
|
||||
/// - 距离过近 → 后退
|
||||
/// - 距离过远 → 靠近
|
||||
/// - 在范围内 → 停止移动,持续朝向玩家
|
||||
/// 始终返回 Running,上层 BT 通过 Abort 或条件终止该 Task。
|
||||
/// </summary>
|
||||
[TaskName("Maintain Combat Distance")]
|
||||
[TaskCategory("BaseGames/Enemy/Combat")]
|
||||
[TaskDescription("在战斗中维持与目标的理想距离范围")]
|
||||
public class BD_MaintainCombatDistance : Action
|
||||
{
|
||||
[Tooltip("期望最小距离(m);小于此值开始后退")]
|
||||
[SerializeField] private float m_PreferredMinDist = 2f;
|
||||
|
||||
[Tooltip("期望最大距离(m);大于此值开始靠近")]
|
||||
[SerializeField] private float m_PreferredMaxDist = 4f;
|
||||
|
||||
[Tooltip("后退速度(m/s),默认使用 WalkSpeed")]
|
||||
[SerializeField] private float m_BackpedaleSpeed = 0f;
|
||||
|
||||
[Tooltip("每帧重新规划路径的阈值(m);玩家移动超过此距离才重规划,降低频率")]
|
||||
[SerializeField] private float m_ReplanThreshold = 0.5f;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private Vector2 _lastPlayerPos;
|
||||
private float _sqrMin;
|
||||
private float _sqrMax;
|
||||
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_sqrMin = m_PreferredMinDist * m_PreferredMinDist;
|
||||
_sqrMax = m_PreferredMaxDist * m_PreferredMaxDist;
|
||||
_lastPlayerPos = Vector2.positiveInfinity;
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null || _enemy.PlayerTransform == null)
|
||||
return TaskStatus.Failure;
|
||||
|
||||
if (_enemy.Stats == null)
|
||||
return TaskStatus.Failure;
|
||||
|
||||
_enemy.FacePlayer();
|
||||
|
||||
float sqrDist = _enemy.Stats.SqrDistanceToPlayer;
|
||||
|
||||
if (sqrDist < _sqrMin)
|
||||
{
|
||||
// 距离过近 → 后退(向远离玩家方向移动)
|
||||
_enemy.Nav?.StopNavigation();
|
||||
Vector2 toPlayer = ((Vector2)_enemy.PlayerTransform.position - (Vector2)_enemy.transform.position).normalized;
|
||||
float backDir = -Mathf.Sign(toPlayer.x);
|
||||
float speed = m_BackpedaleSpeed > 0f ? m_BackpedaleSpeed : _enemy.Stats.WalkSpeed;
|
||||
_enemy.Movement?.MoveWithSpeed(backDir, speed);
|
||||
}
|
||||
else if (sqrDist > _sqrMax)
|
||||
{
|
||||
// 距离过远 → 靠近
|
||||
Vector2 playerPos = _enemy.PlayerTransform.position;
|
||||
float moved = ((Vector2)playerPos - _lastPlayerPos).sqrMagnitude;
|
||||
if (moved > m_ReplanThreshold * m_ReplanThreshold)
|
||||
{
|
||||
_lastPlayerPos = playerPos;
|
||||
_enemy.MoveTo(playerPos);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 在最优范围内 → 停止导航,原地保持朝向
|
||||
_enemy.Nav?.StopNavigation();
|
||||
_enemy.Movement?.StopHorizontal();
|
||||
}
|
||||
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
_enemy?.Movement?.StopHorizontal();
|
||||
_enemy?.Nav?.StopNavigation();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 480875a7bad333140a3c46e637eb7bc6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -10,16 +10,16 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:移动到指定世界坐标 Target。
|
||||
/// 到达目标(IsAtDestination)后返回 Success。
|
||||
/// </summary>
|
||||
[TaskName("Move To")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("导航到目标 Transform 或世界坐标点;到达返回 Success")]
|
||||
public class BD_MoveTo : Action
|
||||
{
|
||||
[SerializeField] private Vector2 m_Target;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
58
Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs
Normal file
58
Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 动作:移动到目标并等待 OnGoalReached 事件(事件驱动,替代轮询版 BD_MoveTo)。
|
||||
/// OnGoalReached 触发后返回 Success;路径失败返回 Failure。
|
||||
/// </summary>
|
||||
[TaskName("Move To And Wait")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("导航到目标点,到达后原地等待指定时长,然后返回 Success")]
|
||||
public sealed class BD_MoveToAndWait : Action
|
||||
{
|
||||
[SerializeField] private Transform m_Target;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private bool _reached;
|
||||
private bool _failed;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_reached = _failed = false;
|
||||
if (_enemy?.Nav == null) { _failed = true; return; }
|
||||
_enemy.Nav.OnGoalReached += HandleReached;
|
||||
_enemy.Nav.OnNavPathFailed += HandleFailed;
|
||||
var pos = m_Target != null ? (Vector2)m_Target.position
|
||||
: _enemy.PlayerTransform != null ? (Vector2)_enemy.PlayerTransform.position
|
||||
: (Vector2)transform.position;
|
||||
_enemy.Nav.RequestMoveTo(pos);
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_failed) return TaskStatus.Failure;
|
||||
if (_reached) return TaskStatus.Success;
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
if (_enemy?.Nav != null)
|
||||
{
|
||||
_enemy.Nav.OnGoalReached -= HandleReached;
|
||||
_enemy.Nav.OnNavPathFailed -= HandleFailed;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleReached() => _reached = true;
|
||||
private void HandleFailed() => _failed = true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_MoveToAndWait.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df51ca671ebccea47a45c1e1bca3caa3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
@@ -10,14 +10,14 @@ namespace BaseGames.Enemies.AI
|
||||
/// OnStart 从 EnemyBase.PlayerTransform 获取玩家引用(由 _onPlayerSpawned 事件缓存,
|
||||
/// 避免 FindWithTag 全场景扫描)。OnUpdate 每帧更新导航目标。
|
||||
/// </summary>
|
||||
[TaskName("Move To Player")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("持续向玩家方向移动,失去视线或超出追踪距离返回 Failure")]
|
||||
public class BD_MoveToPlayer : Action
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
30
Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs
Normal file
30
Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Conditionals;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Conditional Task:检查敌人本次 BT Tick 内是否发生了弹反事件。
|
||||
///
|
||||
/// 返回 <c>Success</c> 时同时清除标志(每次弹反只触发一次响应分支)。
|
||||
/// 典型用法:在 BD 树根部优先分支放置此条件,触发时执行受击硬直 / 反制动画等子树。
|
||||
/// </summary>
|
||||
[TaskName("On Parried?")]
|
||||
[TaskCategory("BaseGames/Enemy/Utility")]
|
||||
[TaskDescription("检查上一次攻击是否被玩家格挡(用于 Boss 反制逻辑)")]
|
||||
public class BD_OnParried : Conditional
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
return _enemy.ConsumeParryEvent() ? TaskStatus.Success : TaskStatus.Failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_OnParried.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fdae429711fc46d41a7b025eed43784f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,32 +1,57 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
using UnityEngine;
|
||||
using BaseGames.Enemies.Perception;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:敌人巡逻行为。
|
||||
/// 持续令敌人向当前朝向移动,遇墙/边缘时自动转向。
|
||||
/// BD Action:来回踱步巡逻——持续向当前方向移动,遇墙或悬崖时自动翻转方向。
|
||||
///
|
||||
/// 若需要按预设路点顺序巡逻,请使用 <see cref="BD_PatrolWaypoints"/>(支持 Transform 引用和内联坐标)。
|
||||
///
|
||||
/// 转向检测优先级:
|
||||
/// <list type="number">
|
||||
/// <item>EnemySensorHub "wall_ahead" / "ledge" 槽(SensorToolkit,已配置时使用)</item>
|
||||
/// <item>Physics2D Raycast 兜底(Prefab 未配置 Sensor 时自动启用)</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[TaskName("Patrol (Pace)")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(SensorToolkit 优先)")]
|
||||
public class BD_Patrol : Action
|
||||
{
|
||||
[Tooltip("检测地面边缘的向下射线长度")]
|
||||
[Tooltip("(兜底)检测地面边缘的向下射线长度(m)")]
|
||||
public float edgeCheckLength = 1.2f;
|
||||
[Tooltip("检测障碍物的水平射线长度")]
|
||||
[Tooltip("(兜底)检测障碍物的水平射线长度(m)")]
|
||||
public float wallCheckLength = 0.4f;
|
||||
[Tooltip("地面/墙壁 LayerMask")]
|
||||
[Tooltip("(兜底)边缘检测射线起点相对角色的前向偏移(m)")]
|
||||
public float edgeCheckFwdOffset = 0.3f;
|
||||
[Tooltip("(兜底)边缘检测射线起点相对角色的向下偏移(m)")]
|
||||
public float edgeCheckDownOffset = 0.1f;
|
||||
[Tooltip("(兜底)地面/墙壁 LayerMask")]
|
||||
public LayerMask groundLayer;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private float _dir = 1f;
|
||||
private EnemyBase _enemy;
|
||||
private EnemySensorHub _hub;
|
||||
private float _dir = 1f;
|
||||
|
||||
public override void OnStart()
|
||||
// 缓存:SensorHub 中对应槽位是否已配置(Awake 时查询一次,避免每帧 Dictionary 查找)
|
||||
private bool _hasWallSensor;
|
||||
private bool _hasEdgeSensor;
|
||||
|
||||
public override void OnAwake()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
_hub = GetComponent<EnemySensorHub>();
|
||||
_hasWallSensor = _hub != null && _hub.Get(SensorSlotNames.WallAhead) != null;
|
||||
_hasEdgeSensor = _hub != null && _hub.Get(SensorSlotNames.Ledge) != null;
|
||||
}
|
||||
|
||||
public override void OnStart() => _enemy?.SetAiPhase(AiPhase.Patrol);
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
@@ -38,27 +63,33 @@ namespace BaseGames.Enemies.AI
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
_enemy?.StopMovement();
|
||||
}
|
||||
public override void OnEnd() => _enemy?.StopMovement();
|
||||
|
||||
private bool ShouldFlip()
|
||||
{
|
||||
Transform t = _enemy.transform;
|
||||
Vector2 pos = t.position;
|
||||
if (_hub != null)
|
||||
{
|
||||
// 有传感器配置:用传感器结果,完全跳过 Raycast
|
||||
if (_hasWallSensor || _hasEdgeSensor)
|
||||
{
|
||||
bool wallHit = _hasWallSensor && _hub.HasAnyDetection(SensorSlotNames.WallAhead);
|
||||
bool edgeHit = _hasEdgeSensor && _hub.HasAnyDetection(SensorSlotNames.Ledge);
|
||||
return wallHit || edgeHit;
|
||||
}
|
||||
}
|
||||
|
||||
// 前方边缘检测:在脚前方向下射线,若无地面则转向
|
||||
Vector2 edgeOrigin = pos + Vector2.right * (_dir * 0.3f) + Vector2.down * 0.1f;
|
||||
// Raycast 兜底:仅在未配置 Sensor 时执行
|
||||
Transform t = _enemy.transform;
|
||||
Vector2 pos = t.position;
|
||||
|
||||
Vector2 edgeOrigin = pos + Vector2.right * (_dir * edgeCheckFwdOffset) + Vector2.down * edgeCheckDownOffset;
|
||||
bool hasGround = Physics2D.Raycast(edgeOrigin, Vector2.down, edgeCheckLength, groundLayer);
|
||||
if (!hasGround) return true;
|
||||
|
||||
// 前方障碍检测:水平射线,若撞墙则转向
|
||||
bool hitWall = Physics2D.Raycast(pos, Vector2.right * _dir, wallCheckLength, groundLayer);
|
||||
if (hitWall) return true;
|
||||
|
||||
return false;
|
||||
return hitWall;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
185
Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs
Normal file
185
Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:按预设路点顺序巡逻(支持 Transform 引用或内联 Vector2 坐标两种模式)。
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>m_Waypoints(Transform[])</b>:拖入场景中的路点对象;适合动态路点(可在运行时移动)。</item>
|
||||
/// <item><b>m_InlineWaypoints(Vector2[])</b>:直接填写世界坐标;无需在场景中放置对象,编辑器中以 Gizmo 可视化路径。</item>
|
||||
/// <item>两者同时设置时 m_Waypoints 优先。</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 到达最后一个路点后可循环(Loop)或往返(PingPong)。
|
||||
/// 与 PathBerserker2d 集成:通过 IPathAgent.RequestMoveTo 导航,支持跨平台跳跃 NavLink。
|
||||
/// </summary>
|
||||
[TaskName("Patrol (Waypoints)")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("按预设路点顺序巡逻;支持 Transform 引用或内联 Vector2 坐标,可循环或折返")]
|
||||
public sealed class BD_PatrolWaypoints : Action
|
||||
{
|
||||
[Tooltip("路点列表(世界空间 Transform);与 m_InlineWaypoints 同时设置时此项优先")]
|
||||
[SerializeField] private Transform[] m_Waypoints;
|
||||
|
||||
[Tooltip("内联路点坐标(世界空间 Vector2);m_Waypoints 为空时使用;在 Scene 视图中以绿色 Gizmo 可视化")]
|
||||
[SerializeField] private Vector2[] m_InlineWaypoints;
|
||||
|
||||
[Tooltip("到达路点的判定半径(m)")]
|
||||
[SerializeField] private float m_ArriveRadius = 0.3f;
|
||||
|
||||
[Tooltip("true = 往返; false = 循环")]
|
||||
[SerializeField] private bool m_PingPong = false;
|
||||
|
||||
[Tooltip("每个路点到达后等待时长(s)")]
|
||||
[SerializeField] private float m_WaitAtWaypoint = 0f;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private int _index = 0;
|
||||
private int _dir = 1;
|
||||
private float _waitTimer = 0f;
|
||||
private bool _waiting = false;
|
||||
|
||||
// ── 统一路点访问 ────────────────────────────────────────────────────
|
||||
private int WaypointCount =>
|
||||
m_Waypoints != null && m_Waypoints.Length > 0
|
||||
? m_Waypoints.Length
|
||||
: m_InlineWaypoints?.Length ?? 0;
|
||||
|
||||
private Vector2 GetWaypoint(int index)
|
||||
{
|
||||
if (m_Waypoints != null && m_Waypoints.Length > 0)
|
||||
{
|
||||
var t = m_Waypoints[index];
|
||||
return t != null ? (Vector2)t.position : (Vector2)transform.position;
|
||||
}
|
||||
return m_InlineWaypoints != null && index < m_InlineWaypoints.Length
|
||||
? m_InlineWaypoints[index]
|
||||
: (Vector2)transform.position;
|
||||
}
|
||||
|
||||
// ── BD 生命周期 ─────────────────────────────────────────────────────
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
if (WaypointCount == 0) return;
|
||||
_waiting = false;
|
||||
_waitTimer = 0f;
|
||||
_enemy?.SetAiPhase(AiPhase.Patrol);
|
||||
RequestCurrent();
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null || WaypointCount == 0)
|
||||
return TaskStatus.Failure;
|
||||
|
||||
if (_waiting)
|
||||
{
|
||||
_waitTimer -= Time.deltaTime;
|
||||
if (_waitTimer > 0f) return TaskStatus.Running;
|
||||
_waiting = false;
|
||||
Advance();
|
||||
RequestCurrent();
|
||||
}
|
||||
else
|
||||
{
|
||||
Vector2 wp = GetWaypoint(_index);
|
||||
float sqrDist = ((Vector2)_enemy.transform.position - wp).sqrMagnitude;
|
||||
|
||||
if (sqrDist <= m_ArriveRadius * m_ArriveRadius)
|
||||
{
|
||||
if (m_WaitAtWaypoint > 0f)
|
||||
{
|
||||
_waiting = true;
|
||||
_waitTimer = m_WaitAtWaypoint;
|
||||
_enemy.StopMovement();
|
||||
}
|
||||
else
|
||||
{
|
||||
Advance();
|
||||
RequestCurrent();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_enemy.MoveTo(wp);
|
||||
_enemy.Movement?.FaceTarget(wp);
|
||||
}
|
||||
}
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
public override void OnEnd() => _enemy?.StopMovement();
|
||||
|
||||
// ── 内部辅助 ────────────────────────────────────────────────────────
|
||||
private void RequestCurrent()
|
||||
{
|
||||
if (WaypointCount == 0) return;
|
||||
_enemy.MoveTo(GetWaypoint(_index));
|
||||
}
|
||||
|
||||
private void Advance()
|
||||
{
|
||||
if (WaypointCount <= 1) return;
|
||||
if (m_PingPong)
|
||||
{
|
||||
_index += _dir;
|
||||
if (_index >= WaypointCount) { _index = WaypointCount - 2; _dir = -1; }
|
||||
else if (_index < 0) { _index = 1; _dir = 1; }
|
||||
}
|
||||
else
|
||||
{
|
||||
_index = (_index + 1) % WaypointCount;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gizmo 可视化(仅 m_InlineWaypoints 模式)────────────────────────
|
||||
#if UNITY_EDITOR
|
||||
private new void OnDrawGizmos()
|
||||
{
|
||||
if (m_InlineWaypoints == null || m_InlineWaypoints.Length < 1) return;
|
||||
// m_Waypoints 存在时不绘制,避免与场景路点重叠
|
||||
if (m_Waypoints != null && m_Waypoints.Length > 0) return;
|
||||
|
||||
Gizmos.color = new Color(0.2f, 0.85f, 0.2f, 0.8f);
|
||||
for (int i = 0; i < m_InlineWaypoints.Length; i++)
|
||||
{
|
||||
Gizmos.DrawWireSphere(m_InlineWaypoints[i], m_ArriveRadius);
|
||||
|
||||
if (i < m_InlineWaypoints.Length - 1)
|
||||
Gizmos.DrawLine(m_InlineWaypoints[i], m_InlineWaypoints[i + 1]);
|
||||
}
|
||||
|
||||
// PingPong 时也画返回线;Loop 时画首尾连接线
|
||||
if (m_InlineWaypoints.Length >= 2)
|
||||
{
|
||||
if (m_PingPong)
|
||||
{
|
||||
// 往返:虚线效果(半透明线)
|
||||
Gizmos.color = new Color(0.2f, 0.85f, 0.2f, 0.35f);
|
||||
Gizmos.DrawLine(m_InlineWaypoints[m_InlineWaypoints.Length - 1], m_InlineWaypoints[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 循环:画首尾连接
|
||||
Gizmos.color = new Color(0.2f, 0.85f, 0.2f, 0.5f);
|
||||
Gizmos.DrawLine(m_InlineWaypoints[m_InlineWaypoints.Length - 1], m_InlineWaypoints[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器标签(路点序号)
|
||||
UnityEditor.Handles.color = new Color(0.2f, 0.85f, 0.2f, 1f);
|
||||
for (int i = 0; i < m_InlineWaypoints.Length; i++)
|
||||
UnityEditor.Handles.Label(m_InlineWaypoints[i] + Vector2.up * 0.25f, $"WP{i}");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_PatrolWaypoints.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e7a2cb1e940ff92499a0ebb9d3063e21
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -10,6 +10,9 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:通过 AnimationClip 名称播放 Animancer 动画,立即返回 Success。
|
||||
/// ClipName 需与 EnemyAnimationConfigSO 中字段名一致。
|
||||
/// </summary>
|
||||
[TaskName("Play Animation")]
|
||||
[TaskCategory("BaseGames/Enemy/Animation")]
|
||||
[TaskDescription("通过 Animancer 播放指定动画 Clip;支持等待动画结束后返回")]
|
||||
public class BD_PlayAnimation : Action
|
||||
{
|
||||
/// <summary>EnemyAnimationConfigSO 中的 AnimationClip 字段名(如 "Attack_Melee")。</summary>
|
||||
@@ -17,10 +20,7 @@ namespace BaseGames.Enemies.AI
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
103
Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs
Normal file
103
Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:归位到出生点(<see cref="EnemyBase.HomePosition"/>)。
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>切换 <see cref="AiPhase.ReturnHome"/>,使用行走速度导航回初始位置。</item>
|
||||
/// <item>到达(距离 ≤ homeRadius)或路径失败时:切换 <see cref="AiPhase.Idle"/>,返回 Success。</item>
|
||||
/// <item>路径失败也返回 Success,避免 BD 树卡死;上层节点继续恢复巡逻。</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 典型 BD 用法:
|
||||
/// <code>
|
||||
/// Sequence
|
||||
/// BD_InvestigateLastKnown → Success
|
||||
/// BD_ReturnToHome → Success
|
||||
/// BD_SetAiPhase(Patrol)
|
||||
/// </code>
|
||||
/// </summary>
|
||||
[TaskName("Return To Home")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("导航回出生点;到达后切换 Idle 并返回 Success")]
|
||||
public sealed class BD_ReturnToHome : Action
|
||||
{
|
||||
[Tooltip("到达判定半径(m);0 = 使用 EnemyStatsSO.HomeRadius")]
|
||||
[SerializeField] private float m_ArriveRadius = 0f;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private bool _reached;
|
||||
private bool _pathFailed;
|
||||
private bool _subscribed;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy.SetAiPhase(AiPhase.ReturnHome);
|
||||
_reached = false;
|
||||
_pathFailed = false;
|
||||
|
||||
if (!_subscribed && _enemy.Nav != null)
|
||||
{
|
||||
_enemy.Nav.OnGoalReached += HandleReached;
|
||||
_enemy.Nav.OnNavPathFailed += HandleFailed;
|
||||
_subscribed = true;
|
||||
}
|
||||
|
||||
// 切换为行走速度
|
||||
float walkSpeed = _enemy.Stats?.WalkSpeed ?? 2f;
|
||||
_enemy.Nav?.SetSpeed(walkSpeed);
|
||||
|
||||
// 播放行走动画
|
||||
var ac = _enemy.AnimConfig;
|
||||
if (_enemy.Animancer != null && ac?.Walk != null)
|
||||
_enemy.Animancer.Play(ac.Walk);
|
||||
|
||||
_enemy.MoveTo(_enemy.HomePosition);
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
|
||||
if (_pathFailed) return CompleteReturn();
|
||||
if (_reached) return CompleteReturn();
|
||||
|
||||
// 兜底距离判断(事件可能因帧序问题延迟一帧)
|
||||
float radius = m_ArriveRadius > 0f ? m_ArriveRadius : (_enemy.Stats?.HomeRadius ?? 0.5f);
|
||||
float sqr = ((Vector2)_enemy.transform.position - _enemy.HomePosition).sqrMagnitude;
|
||||
if (sqr <= radius * radius)
|
||||
return CompleteReturn();
|
||||
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
if (_subscribed && _enemy?.Nav != null)
|
||||
{
|
||||
_enemy.Nav.OnGoalReached -= HandleReached;
|
||||
_enemy.Nav.OnNavPathFailed -= HandleFailed;
|
||||
_subscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
private TaskStatus CompleteReturn()
|
||||
{
|
||||
_enemy.StopMovement();
|
||||
_enemy.SetAiPhase(AiPhase.Idle);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
|
||||
private void HandleReached() => _reached = true;
|
||||
private void HandleFailed() => _pathFailed = true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_ReturnToHome.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0555fce4edd236847b580a2b436bd22f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
34
Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs
Normal file
34
Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:设置 AI 行为阶段(<see cref="AiPhase"/>)。
|
||||
/// 单帧完成,返回 Success。常用于行为树中作为阶段过渡的标记节点,
|
||||
/// 例如在 Selector 分支开头标记当前进入了哪个阶段。
|
||||
/// </summary>
|
||||
[TaskName("Set Ai Phase")]
|
||||
[TaskCategory("BaseGames/Enemy/State")]
|
||||
[TaskDescription("立即切换 AI 行为阶段(AiPhase),单帧返回 Success")]
|
||||
public sealed class BD_SetAiPhase : Action
|
||||
{
|
||||
[Tooltip("目标 AI 行为阶段")]
|
||||
[SerializeField] private AiPhase m_Phase = AiPhase.Idle;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
_enemy.SetAiPhase(m_Phase);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_SetAiPhase.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f70a0ea7dc70bb4cbf300d19eacfba0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
@@ -8,14 +8,14 @@ namespace BaseGames.Enemies.AI
|
||||
/// <summary>
|
||||
/// BD Action:设置警觉状态,并通知 EnemyBase 调整 BT Tick 频率。
|
||||
/// </summary>
|
||||
[TaskName("Set Alert Tick Rate")]
|
||||
[TaskCategory("BaseGames/Enemy/Perception")]
|
||||
[TaskDescription("切换行为树 Tick 频率:警觉时高频,巡逻时低频")]
|
||||
public class BD_SetAlert : Action
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
45
Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs
Normal file
45
Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:强制切换敌人到指定 EnemyStateType,并中断所有正在执行的能力。
|
||||
/// 单帧完成,立即返回 Success。
|
||||
///
|
||||
/// 典型用法:
|
||||
/// - 进入 Stagger 状态后重置为 Idle(替代手动停止动画)
|
||||
/// - 技能打断后显式回到 Controlled 状态
|
||||
/// </summary>
|
||||
[TaskName("Set State")]
|
||||
[TaskCategory("BaseGames/Enemy/State")]
|
||||
[TaskDescription("强制切换敌人物理/战斗状态(ForceState)")]
|
||||
public class BD_SetState : Action
|
||||
{
|
||||
[Tooltip("切换到的目标状态")]
|
||||
[SerializeField] private EnemyStateType m_TargetState = EnemyStateType.Controlled;
|
||||
|
||||
[Tooltip("切换状态时同时中断所有正在执行的能力")]
|
||||
[SerializeField] private bool m_InterruptAbilities = true;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
|
||||
if (m_InterruptAbilities)
|
||||
_enemy.Abilities?.InterruptAll(InterruptReason.ExternalRequest);
|
||||
|
||||
_enemy.ForceState(m_TargetState);
|
||||
return TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_SetState.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d3406f00433d44e449c24b4340b0c49a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -11,6 +11,9 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:在敌人当前位置生成弹射物。
|
||||
/// ProjectileKey 为 Addressable 键,通过 GlobalObjectPool 实例化。
|
||||
/// </summary>
|
||||
[TaskName("Spawn Projectile")]
|
||||
[TaskCategory("BaseGames/Enemy/Utility")]
|
||||
[TaskDescription("在指定位置生成投射物并赋予初速度")]
|
||||
public class BD_SpawnProjectile : Action
|
||||
{
|
||||
[SerializeField] private Vector2 m_Direction = Vector2.right;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
@@ -8,14 +8,14 @@ namespace BaseGames.Enemies.AI
|
||||
/// <summary>
|
||||
/// BD Action:立即停止移动,单帧完成,返回 Success。
|
||||
/// </summary>
|
||||
[TaskName("Stop Movement")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("立即停止 NavAgent 移动,单帧返回 Success")]
|
||||
public class BD_StopMovement : Action
|
||||
{
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
}
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -12,28 +12,40 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:Boss 专用召唤小兵。
|
||||
/// 通过 GlobalObjectPool 在 Boss 周围生成 MinionPrefabKey 对应的敌人。
|
||||
/// </summary>
|
||||
[TaskName("Summon Minions")]
|
||||
[TaskCategory("BaseGames/Enemy/Boss")]
|
||||
[TaskDescription("在指定位置生成小怪预制体(支持延迟和数量配置)")]
|
||||
public class BD_SummonMinions : Action
|
||||
{
|
||||
[Header("召唤配置")]
|
||||
[Tooltip("对象池 Key,对应 PoolRegistry 中注册的小怪预制体。")]
|
||||
[SerializeField] private string m_MinionPrefabKey = "";
|
||||
[Tooltip("单次召唤的小兵数量。")]
|
||||
[Min(1)]
|
||||
[SerializeField] private int m_Count = 2;
|
||||
[Tooltip("小兵生成位置距 Boss 中心的横向散开半径(m)。")]
|
||||
[Min(0.1f)]
|
||||
[SerializeField] private float m_SpawnRadius = 3f;
|
||||
|
||||
// 延迟缓存:首次调用时解析,避免每帧服务定位开销
|
||||
private IObjectPoolService _pool;
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (string.IsNullOrEmpty(m_MinionPrefabKey)) return TaskStatus.Failure;
|
||||
|
||||
var pool = ServiceLocator.GetOrDefault<IObjectPoolService>();
|
||||
if (pool == null) return TaskStatus.Failure;
|
||||
_pool ??= ServiceLocator.GetOrDefault<IObjectPoolService>();
|
||||
if (_pool == null) return TaskStatus.Failure;
|
||||
|
||||
for (int i = 0; i < m_Count; i++)
|
||||
{
|
||||
var angle = Random.Range(0f, 360f) * Mathf.Deg2Rad;
|
||||
var offset = new Vector2(Mathf.Cos(angle), 0f) * m_SpawnRadius;
|
||||
var spawnPos = new Vector3(
|
||||
float angleRad = (i / (float)m_Count) * Mathf.PI * 2f;
|
||||
var offset = new Vector2(Mathf.Cos(angleRad), 0f) * m_SpawnRadius;
|
||||
var spawnPos = new Vector3(
|
||||
transform.position.x + offset.x,
|
||||
transform.position.y + offset.y,
|
||||
transform.position.y,
|
||||
0f);
|
||||
pool.Spawn(m_MinionPrefabKey, spawnPos, Quaternion.identity);
|
||||
_pool.Spawn(m_MinionPrefabKey, spawnPos, Quaternion.identity);
|
||||
}
|
||||
|
||||
return TaskStatus.Success;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -10,6 +10,9 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:触发攻击预警(TelegraphSystem),等待 Duration 秒后返回 Success。
|
||||
/// 对应架构 07_EnemyModule §8 BD_TelegraphAttack;与 TelegraphSystem 协作。
|
||||
/// </summary>
|
||||
[TaskName("Telegraph Attack")]
|
||||
[TaskCategory("BaseGames/Enemy/Combat")]
|
||||
[TaskDescription("播放攻击前摇动画并等待结束后返回 Success")]
|
||||
public class BD_TelegraphAttack : Action
|
||||
{
|
||||
[SerializeField] private float m_Duration = 1f;
|
||||
@@ -17,15 +20,18 @@ namespace BaseGames.Enemies.AI
|
||||
|
||||
private TelegraphSystem _telegraph;
|
||||
private float _elapsed;
|
||||
private Coroutine _telegraphCoroutine;
|
||||
|
||||
public override void OnAwake() => _telegraph = GetComponent<TelegraphSystem>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_telegraph = GetComponent<TelegraphSystem>();
|
||||
_elapsed = 0f;
|
||||
_elapsed = 0f;
|
||||
_telegraphCoroutine = null;
|
||||
|
||||
if (_telegraph != null && !string.IsNullOrEmpty(m_VfxKey))
|
||||
{
|
||||
_telegraph.StartCoroutine(
|
||||
_telegraphCoroutine = _telegraph.StartCoroutine(
|
||||
_telegraph.ShowTelegraph(m_VfxKey, m_Duration, transform.position));
|
||||
}
|
||||
}
|
||||
@@ -35,6 +41,16 @@ namespace BaseGames.Enemies.AI
|
||||
_elapsed += Time.deltaTime;
|
||||
return _elapsed >= m_Duration ? TaskStatus.Success : TaskStatus.Running;
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
// BD 任务被 Abort 时停止仍在播放的预警协程,避免孤立 VFX
|
||||
if (_telegraphCoroutine != null)
|
||||
{
|
||||
_telegraph?.StopCoroutine(_telegraphCoroutine);
|
||||
_telegraphCoroutine = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -9,6 +9,9 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:瞬移(传送)到目标坐标,Boss 专用。
|
||||
/// 单帧直接修改 transform.position,不使用导航。
|
||||
/// </summary>
|
||||
[TaskName("Teleport To")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("瞬移到目标位置(配合粒子/闪烁能力)")]
|
||||
public class BD_TeleportTo : Action
|
||||
{
|
||||
[SerializeField] private Vector2 m_Target;
|
||||
|
||||
58
Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs
Normal file
58
Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 通用:触发能力。OnStart 调用 ability.Execute(),OnUpdate 等待结束。
|
||||
/// 失败条件:能力不存在、能力 CanUse=false 或 Execute 返回 false。
|
||||
/// </summary>
|
||||
[TaskName("Use Ability")]
|
||||
[TaskCategory("BaseGames/Enemy/Combat")]
|
||||
[TaskDescription("触发指定能力(拖拽 EnemyAbilitySO 或填写 ID),等待执行结束")]
|
||||
public sealed class BD_UseAbility : Action
|
||||
{
|
||||
[Tooltip("可直接拖拽 EnemyAbilitySO 资产(推荐),或填写裸字符串 ID 作为兜底")]
|
||||
[SerializeField] private EnemyAbilitySO m_AbilitySO;
|
||||
[SerializeField] private string m_AbilityId = "";
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private EnemyAbilityBase _ability;
|
||||
private bool _startedSuccessfully;
|
||||
|
||||
public override void OnAwake()
|
||||
{
|
||||
_enemy = gameObject.GetComponent<EnemyBase>();
|
||||
}
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_startedSuccessfully = false;
|
||||
if (_enemy == null) return;
|
||||
|
||||
// SO 拖拽优先;裸字符串兜底
|
||||
string id = m_AbilitySO != null ? m_AbilitySO.abilityId : m_AbilityId;
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
|
||||
_ability = _enemy.Abilities.Get(id);
|
||||
if (_ability == null) return;
|
||||
_startedSuccessfully = _ability.Execute();
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (!_startedSuccessfully || _ability == null) return TaskStatus.Failure;
|
||||
return _ability.IsRunning ? TaskStatus.Running : TaskStatus.Success;
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
_ability = null;
|
||||
_startedSuccessfully = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_UseAbility.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4865a86627f84a54292736e6dc2b9e15
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
53
Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs
Normal file
53
Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Boss;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 触发 Boss 技能。OnStart 调用 BossBase.UseBossSkill(skillId),OnUpdate 等待技能结束。
|
||||
/// 失败条件:非 Boss 敌人、技能 ID 无效、BossSkillExecutor 未就绪或技能执行中。
|
||||
/// </summary>
|
||||
[TaskName("Use Boss Skill")]
|
||||
[TaskCategory("BaseGames/Enemy/Boss")]
|
||||
[TaskDescription("通过技能 ID 执行 Boss 指定技能")]
|
||||
public sealed class BD_UseBossSkill : Action
|
||||
{
|
||||
[SerializeField] private string m_SkillId = "";
|
||||
[Tooltip("可选:直接拖拽 BossSkillSO 资产以替代裸字符串(优先于 m_SkillId)")]
|
||||
[SerializeField] private BossSkillSO m_SkillSO;
|
||||
|
||||
private BossBase _boss;
|
||||
private bool _startedSuccessfully;
|
||||
|
||||
public override void OnAwake()
|
||||
{
|
||||
_boss = gameObject.GetComponent<BossBase>();
|
||||
}
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_startedSuccessfully = false;
|
||||
if (_boss == null) return;
|
||||
|
||||
string id = m_SkillSO != null ? m_SkillSO.skillId : m_SkillId;
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
|
||||
_startedSuccessfully = _boss.UseBossSkill(id);
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (!_startedSuccessfully || _boss == null) return TaskStatus.Failure;
|
||||
return _boss.IsBossSkillExecuting ? TaskStatus.Running : TaskStatus.Success;
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
_startedSuccessfully = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkill.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f86542f8dede80438ab28ec682758b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
54
Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs
Normal file
54
Assets/_Game/Scripts/Enemies/AI/BD_UseBossSkillWeighted.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:在当前阶段可用且冷却就绪的技能中,按 weight 加权随机选择一个技能并执行。
|
||||
///
|
||||
/// 返回 Running:技能正在执行。
|
||||
/// 返回 Success:技能执行完成。
|
||||
/// 返回 Failure:无可用技能,或执行器忙。
|
||||
///
|
||||
/// 用法:在 Boss BT 的 Combat 阶段,替代 BD_UseBossSkill(固定 ID)实现随机化技能组合。
|
||||
/// 每个 BossSkillSO 设置 weight 字段控制出现概率:越大越常见。
|
||||
/// </summary>
|
||||
[TaskName("Use Boss Skill (Weighted)")]
|
||||
[TaskCategory("BaseGames/Enemy/Boss")]
|
||||
[TaskDescription("加权随机选择并执行可用技能;对上一次使用的技能施加权重惩罚")]
|
||||
public class BD_UseBossSkillWeighted : Action
|
||||
{
|
||||
[Tooltip("等待技能执行完成后才返回 Success。关闭后执行开始即返回 Success,BT 继续运行其他节点。")]
|
||||
[SerializeField] private bool m_WaitForCompletion = true;
|
||||
|
||||
private BossBase _boss;
|
||||
private bool _started;
|
||||
|
||||
public override void OnAwake() => _boss = GetComponent<BossBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_started = false;
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_boss == null) return TaskStatus.Failure;
|
||||
|
||||
if (!_started)
|
||||
{
|
||||
bool ok = _boss.UseBossSkillWeighted();
|
||||
if (!ok) return TaskStatus.Failure;
|
||||
_started = true;
|
||||
|
||||
if (!m_WaitForCompletion) return TaskStatus.Success;
|
||||
}
|
||||
|
||||
// 等待技能执行完成
|
||||
return _boss.IsBossSkillExecuting ? TaskStatus.Running : TaskStatus.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5cf18a1d81f80c646947ea0475c31108
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -9,6 +9,9 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:等待固定 Duration 秒后返回 Success。
|
||||
/// 适用于攻击前摇、冷却间隔等固定等待。
|
||||
/// </summary>
|
||||
[TaskName("Wait")]
|
||||
[TaskCategory("BaseGames/Enemy/Utility")]
|
||||
[TaskDescription("等待固定 Duration 秒后返回 Success")]
|
||||
public class BD_Wait : Action
|
||||
{
|
||||
[UnityEngine.SerializeField] private float m_Duration = 1f;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -8,23 +9,36 @@ namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:等待当前 Animancer 动画播放完毕再返回 Success。
|
||||
/// 通过 EnemyBase.IsAnimationComplete 轮询(Animancer CurrentState.NormalizedTime >= 1)。
|
||||
/// 通过 Animancer CurrentState.NormalizedTime >= 1 轮询;
|
||||
/// 若动画为循环动画(NormalizedTime 每帧 wrap 到 0),超时后强制返回 Success,防止永久挂死。
|
||||
/// </summary>
|
||||
[TaskName("Wait For Animation")]
|
||||
[TaskCategory("BaseGames/Enemy/Animation")]
|
||||
[TaskDescription("等待 Animancer 当前动画播放完毕后返回 Success。循环动画请设置超时时间。")]
|
||||
public class BD_WaitForAnimation : Action
|
||||
{
|
||||
[Tooltip("安全超时(秒)。若动画是循环型,超时后自动返回 Success。0 = 不启用超时(仅适合非循环动画)。")]
|
||||
[SerializeField, Min(0f)] private float m_Timeout = 5f;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private float _deadline;
|
||||
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_enemy = GetComponent<EnemyBase>();
|
||||
_deadline = m_Timeout > 0f ? Time.time + m_Timeout : float.MaxValue;
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
|
||||
// 超时保护:防止循环动画导致永久阻塞
|
||||
if (Time.time >= _deadline) return TaskStatus.Success;
|
||||
|
||||
var state = _enemy.Animancer?.States?.Current;
|
||||
if (state == null) return TaskStatus.Success; // 无动画直接继续
|
||||
if (state == null) return TaskStatus.Success; // 无动画直接继续
|
||||
if (state.NormalizedTime >= 1f) return TaskStatus.Success;
|
||||
|
||||
return TaskStatus.Running;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if GRAPH_DESIGNER
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
@@ -9,6 +9,9 @@ namespace BaseGames.Enemies.AI
|
||||
/// BD Action:在 [Min, Max] 范围内随机等待后返回 Success。
|
||||
/// 适用于巡逻间隔、随机攻击延迟等自然行为。
|
||||
/// </summary>
|
||||
[TaskName("Wait (Random)")]
|
||||
[TaskCategory("BaseGames/Enemy/Utility")]
|
||||
[TaskDescription("在 Min~Max 范围内随机等待后返回 Success")]
|
||||
public class BD_WaitRandom : Action
|
||||
{
|
||||
[UnityEngine.SerializeField] private float m_Min = 0.5f;
|
||||
|
||||
68
Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs
Normal file
68
Assets/_Game/Scripts/Enemies/AI/BD_WaitUntilAbilityEnd.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
using BaseGames.Enemies.Abilities;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// BD Action:等待指定能力执行结束后返回 Success。
|
||||
/// 适用于需要同步等待某个由外部触发的能力(而非本节点触发)的场景。
|
||||
///
|
||||
/// 返回 Running:能力正在执行。
|
||||
/// 返回 Success:能力执行完毕(IsRunning = false)。
|
||||
/// 返回 Failure:能力不存在,或超时保护触发。
|
||||
///
|
||||
/// 典型用法:Sequence [ BD_UseAbility(A) → BD_WaitUntilAbilityEnd(B) ] 等待 B 被 A 内部链式触发后结束。
|
||||
/// </summary>
|
||||
[TaskName("Wait Until Ability End")]
|
||||
[TaskCategory("BaseGames/Enemy/Combat")]
|
||||
[TaskDescription("等待指定能力执行完成后返回 Success")]
|
||||
public sealed class BD_WaitUntilAbilityEnd : Action
|
||||
{
|
||||
[Tooltip("可直接拖拽 EnemyAbilitySO 资产(推荐),或填写裸字符串 ID 作为兜底")]
|
||||
[SerializeField] private EnemyAbilitySO m_AbilitySO;
|
||||
[SerializeField] private string m_AbilityId = "";
|
||||
|
||||
[Tooltip("超时保护(秒);0 = 永不超时")]
|
||||
[SerializeField, Min(0f)] private float m_Timeout = 5f;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
private EnemyAbilityBase _ability;
|
||||
private float _deadline;
|
||||
|
||||
public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
_ability = null;
|
||||
_deadline = m_Timeout > 0f ? Time.time + m_Timeout : float.MaxValue;
|
||||
|
||||
if (_enemy == null) return;
|
||||
string id = m_AbilitySO != null ? m_AbilitySO.abilityId : m_AbilityId;
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
_ability = _enemy.Abilities?.Get(id);
|
||||
}
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_ability == null) return TaskStatus.Failure;
|
||||
|
||||
if (Time.time >= _deadline)
|
||||
{
|
||||
Debug.LogWarning($"[BD_WaitUntilAbilityEnd] 能力 '{_ability.Config?.abilityId}' 超时,强制中断", gameObject);
|
||||
// 强制中断仍在运行的能力,防止 HitBox/动画协程泄漏
|
||||
if (_ability.IsRunning)
|
||||
_ability.Interrupt(InterruptReason.ExternalRequest);
|
||||
return TaskStatus.Failure;
|
||||
}
|
||||
|
||||
return _ability.IsRunning ? TaskStatus.Running : TaskStatus.Success;
|
||||
}
|
||||
|
||||
public override void OnEnd() => _ability = null;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9311a809097042f4b8b5de9681d51d0d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
44
Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs
Normal file
44
Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
#if GRAPH_DESIGNER
|
||||
using UnityEngine;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks;
|
||||
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.AI
|
||||
{
|
||||
/// <summary>
|
||||
/// 动作:随机游走(调用 NavAgent.SetRandomDestination)。
|
||||
/// 到达目的地后立即选下一个随机点,持续 Running。
|
||||
/// 常用于巡逻 fallback 或无路点的小怪。
|
||||
/// </summary>
|
||||
[TaskName("Walk Random")]
|
||||
[TaskCategory("BaseGames/Enemy/Movement")]
|
||||
[TaskDescription("随机游走:持续选取随机目的地导航(常用于待机巡逻兜底)")]
|
||||
public sealed class BD_WalkRandom : Action
|
||||
{
|
||||
[Tooltip("尝试选取随机点的次数")]
|
||||
[SerializeField] private int m_RetryCount = 5;
|
||||
|
||||
private EnemyBase _enemy;
|
||||
|
||||
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
|
||||
|
||||
public override void OnStart() => TryWalk();
|
||||
|
||||
public override TaskStatus OnUpdate()
|
||||
{
|
||||
if (_enemy == null) return TaskStatus.Failure;
|
||||
if (_enemy.Nav.IsAtDestination()) TryWalk();
|
||||
return TaskStatus.Running;
|
||||
}
|
||||
|
||||
public override void OnEnd() => _enemy?.StopMovement();
|
||||
|
||||
private void TryWalk()
|
||||
{
|
||||
for (int i = 0; i < m_RetryCount; i++)
|
||||
if (_enemy.Nav.WalkToRandom()) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
11
Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/AI/BD_WalkRandom.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a55d2b270a2c1d34a9b08771feedba62
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -7,13 +7,14 @@
|
||||
"noEngineReferences": false,
|
||||
"versionDefines": [],
|
||||
"rootNamespace": "BaseGames.Enemies.AI",
|
||||
"references": [
|
||||
"references": [
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Enemies",
|
||||
"BaseGames.Enemies.Boss.Patterns",
|
||||
"Opsive.BehaviorDesigner.Runtime",
|
||||
"Kybernetik.Animancer"
|
||||
"Kybernetik.Animancer",
|
||||
"Micosmo.SensorToolkit"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
|
||||
8
Assets/_Game/Scripts/Enemies/Abilities.meta
Normal file
8
Assets/_Game/Scripts/Enemies/Abilities.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5d161f28bc68404ead8b5d52e9ad335
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
26
Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs
Normal file
26
Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 能力执行的阶段枚举(架构 07_EnemyModule §8)。
|
||||
/// 用于 BD/Animator/UI 查询能力当前在哪个阶段(telegraph/出招/恢复)。
|
||||
/// </summary>
|
||||
public enum AbilityRunState
|
||||
{
|
||||
Idle,
|
||||
Telegraph,
|
||||
Windup,
|
||||
Active,
|
||||
Recovery,
|
||||
Interrupted
|
||||
}
|
||||
|
||||
/// <summary>能力中断原因。供 BD 判断是否需要重新调度。</summary>
|
||||
public enum InterruptReason
|
||||
{
|
||||
ExternalRequest,
|
||||
Hurt,
|
||||
Stagger,
|
||||
KnockUp,
|
||||
Dead
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 82020b408d70f45448de169667bc80a9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
193
Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs
Normal file
193
Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Pool;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 闪烁突袭能力:消失后瞬移到目标侧后方/上方/侧翼,现身后衔接出手。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 选择闪烁目标点(玩家侧后 / 上方 / 侧翼,依 <see cref="BlinkPosition"/>)
|
||||
/// 2. 隐身(隐藏 SpriteRenderer、关闭碰撞、播放消失 VFX)
|
||||
/// 3. 等待 disappearDuration 营造"消失"感
|
||||
/// 4. 瞬移到目标点
|
||||
/// 5. 现身 + 出现 VFX + 预警闪光(reappearTelegraph)
|
||||
/// 6. 调用 followUpAbilityId 指定的能力作为出手(若为空则播放 attackSequence[0])
|
||||
///
|
||||
/// 同时实现 <see cref="INavLinkHandler"/> for <see cref="NavLinkType.Teleport"/>:
|
||||
/// 当 PathBerserker2d 路径包含 teleport NavLink 时,本能力接管穿越逻辑,
|
||||
/// 播放消失/出现动画后告知 NavAgent 继续路径,而不触发战斗出手。
|
||||
/// </summary>
|
||||
public sealed class BlinkStrikeAbility : EnemyAbilityBase, INavLinkHandler
|
||||
{
|
||||
public enum BlinkPosition { BehindTarget, FrontTarget, AboveTarget, FlankTarget }
|
||||
|
||||
[Header("闪烁参数")]
|
||||
[SerializeField] private BlinkPosition _blinkPosition = BlinkPosition.BehindTarget;
|
||||
[SerializeField] private float _offsetDistance = 1.8f;
|
||||
[SerializeField] private float _verticalOffsetAbove = 3.0f;
|
||||
[SerializeField] private float _disappearDuration = 0.25f;
|
||||
[SerializeField] private float _reappearTelegraph = 0.20f;
|
||||
[SerializeField] private LayerMask _groundMask;
|
||||
[SerializeField] private float _groundSnapMaxDistance = 3f;
|
||||
|
||||
[Header("视觉")]
|
||||
[SerializeField] private SpriteRenderer[] _renderers;
|
||||
[SerializeField] private Collider2D[] _disableDuringBlink;
|
||||
[SerializeField] private string _disappearVfxKey = "";
|
||||
[SerializeField] private string _appearVfxKey = "";
|
||||
|
||||
[Header("出手能力(在现身后触发,若为空则播放第一段攻击动画)")]
|
||||
[SerializeField] private string _followUpAbilityId = "";
|
||||
|
||||
private Rigidbody2D _rb;
|
||||
private IObjectPoolService _pool;
|
||||
private Coroutine _navLinkCoroutine;
|
||||
|
||||
// ── INavLinkHandler(Teleport 连接段穿越)─────────────────────
|
||||
private static readonly NavLinkType[] _handledTypes = new[] { NavLinkType.Teleport };
|
||||
public NavLinkType[] HandledLinkTypes => _handledTypes;
|
||||
|
||||
public bool CanHandleLink(NavLinkType type, Vector2 linkStart, Vector2 linkEnd) => true;
|
||||
|
||||
public void BeginLinkTraversal(NavLinkType type, Vector2 linkStart, Vector2 linkEnd, Action onComplete)
|
||||
{
|
||||
if (_navLinkCoroutine != null) StopCoroutine(_navLinkCoroutine);
|
||||
_navLinkCoroutine = StartCoroutine(TeleportNavLinkCoroutine(linkEnd, onComplete));
|
||||
}
|
||||
|
||||
public void AbortLinkTraversal()
|
||||
{
|
||||
if (_navLinkCoroutine != null) { StopCoroutine(_navLinkCoroutine); _navLinkCoroutine = null; }
|
||||
SetVisible(true);
|
||||
}
|
||||
|
||||
/// <summary>纯导航用传送:消失 → 移动到连接终点 → 出现,不触发战斗出手。</summary>
|
||||
private IEnumerator TeleportNavLinkCoroutine(Vector2 destination, Action onComplete)
|
||||
{
|
||||
_pool ??= ServiceLocator.GetOrDefault<IObjectPoolService>();
|
||||
|
||||
if (!string.IsNullOrEmpty(_disappearVfxKey))
|
||||
_pool?.Spawn(_disappearVfxKey, _transform.position, Quaternion.identity);
|
||||
SetVisible(false);
|
||||
if (_rb != null) _rb.velocity = Vector2.zero;
|
||||
yield return EnemyAbilityWaits.Get(_disappearDuration);
|
||||
|
||||
if (_rb != null) _rb.position = destination;
|
||||
else _transform.position = destination;
|
||||
|
||||
SetVisible(true);
|
||||
if (!string.IsNullOrEmpty(_appearVfxKey))
|
||||
_pool?.Spawn(_appearVfxKey, _transform.position, Quaternion.identity);
|
||||
|
||||
_navLinkCoroutine = null;
|
||||
onComplete?.Invoke(); // 通知 EnemyNavAgent → CompleteLinkTraversal
|
||||
}
|
||||
|
||||
protected override void Awake()
|
||||
{
|
||||
base.Awake();
|
||||
_rb = GetComponentInParent<Rigidbody2D>();
|
||||
if (_renderers == null || _renderers.Length == 0)
|
||||
_renderers = GetComponentsInChildren<SpriteRenderer>(true);
|
||||
}
|
||||
|
||||
protected override IEnumerator ExecuteCoroutine()
|
||||
{
|
||||
_pool ??= ServiceLocator.GetOrDefault<IObjectPoolService>();
|
||||
var target = _enemy != null ? _enemy.PlayerTransform : null;
|
||||
if (target == null) yield break;
|
||||
|
||||
// 1. 消失
|
||||
if (!string.IsNullOrEmpty(_disappearVfxKey))
|
||||
_pool?.Spawn(_disappearVfxKey, _transform.position, Quaternion.identity);
|
||||
SetVisible(false);
|
||||
if (_rb != null) _rb.velocity = Vector2.zero;
|
||||
yield return EnemyAbilityWaits.Get(_disappearDuration);
|
||||
|
||||
// 2. 选目标点 + 瞬移
|
||||
Vector2 dest = ComputeBlinkPosition(target);
|
||||
if (_rb != null) _rb.position = dest;
|
||||
else _transform.position = dest;
|
||||
FaceTarget(target);
|
||||
|
||||
// 3. 现身 + 预警
|
||||
SetVisible(true);
|
||||
if (!string.IsNullOrEmpty(_appearVfxKey))
|
||||
_pool?.Spawn(_appearVfxKey, _transform.position, Quaternion.identity);
|
||||
Phase = AbilityRunState.Telegraph;
|
||||
yield return EnemyAbilityWaits.Get(_reappearTelegraph);
|
||||
|
||||
// 4. 出手
|
||||
Phase = AbilityRunState.Active;
|
||||
if (!string.IsNullOrEmpty(_followUpAbilityId) && _enemy != null)
|
||||
{
|
||||
var follow = _enemy.Abilities.Get(_followUpAbilityId);
|
||||
if (follow != null)
|
||||
{
|
||||
follow.Execute();
|
||||
while (follow.IsRunning) yield return null;
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
// 退化路径:播放第一段动画
|
||||
var seq = _config != null ? _config.attackSequence : null;
|
||||
if (seq != null && seq.Length > 0)
|
||||
{
|
||||
var atk = seq[0];
|
||||
float dur = atk.fallbackDuration;
|
||||
if (atk.clip != null && _animancer != null)
|
||||
{
|
||||
var st = _animancer.Play(atk.clip);
|
||||
if (st != null && st.Length > 0f) dur = st.Length;
|
||||
}
|
||||
yield return EnemyAbilityWaits.Get(dur);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInterrupted(InterruptReason reason)
|
||||
{
|
||||
// 中断时同时终止 NavLink 穿越协程(若正在进行),防止 onComplete 回调污染导航状态
|
||||
AbortLinkTraversal();
|
||||
}
|
||||
|
||||
private Vector2 ComputeBlinkPosition(Transform target)
|
||||
{
|
||||
Vector2 t = target.position;
|
||||
Vector2 selfDir = (t - (Vector2)_transform.position);
|
||||
float facing = selfDir.x >= 0f ? 1f : -1f;
|
||||
Vector2 raw;
|
||||
switch (_blinkPosition)
|
||||
{
|
||||
case BlinkPosition.FrontTarget: raw = t + new Vector2(facing * _offsetDistance, 0f); break;
|
||||
case BlinkPosition.AboveTarget: raw = t + new Vector2(0f, _verticalOffsetAbove); break;
|
||||
case BlinkPosition.FlankTarget:
|
||||
raw = t + new Vector2((UnityEngine.Random.value < 0.5f ? -1f : 1f) * _offsetDistance, 0f);
|
||||
break;
|
||||
case BlinkPosition.BehindTarget:
|
||||
default:
|
||||
float tFacing = target.localScale.x >= 0f ? 1f : -1f;
|
||||
raw = t - new Vector2(tFacing * _offsetDistance, 0f);
|
||||
break;
|
||||
}
|
||||
// 贴地(避免悬空)
|
||||
var hit = Physics2D.Raycast(raw + Vector2.up * 0.5f, Vector2.down,
|
||||
_groundSnapMaxDistance + 0.5f, _groundMask);
|
||||
if (hit.collider != null) raw.y = hit.point.y + 0.05f;
|
||||
return raw;
|
||||
}
|
||||
|
||||
private void SetVisible(bool visible)
|
||||
{
|
||||
if (_renderers != null)
|
||||
for (int i = 0; i < _renderers.Length; i++)
|
||||
if (_renderers[i] != null) _renderers[i].enabled = visible;
|
||||
if (_disableDuringBlink != null)
|
||||
for (int i = 0; i < _disableDuringBlink.Length; i++)
|
||||
if (_disableDuringBlink[i] != null) _disableDuringBlink[i].enabled = visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8fca68990cbd3b1428ba2c45bbf87d86
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
91
Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs
Normal file
91
Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using BaseGames.Combat;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂顶下落能力:怪物初始挂在天花板(kinematic,重力 0)。能力触发后:
|
||||
/// 1. 切换为 dynamic + 恢复重力
|
||||
/// 2. 自由落体到接触地面
|
||||
/// 3. 落地播放 AoE HitBox + 砸地反馈
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Rigidbody2D))]
|
||||
public sealed class CeilingDropAbility : EnemyAbilityBase
|
||||
{
|
||||
[Header("下落")]
|
||||
[SerializeField] private float _fallGravityScale = 3.5f;
|
||||
[SerializeField] private float _maxFallTime = 3f;
|
||||
[SerializeField] private LayerMask _groundMask;
|
||||
|
||||
[Header("落地")]
|
||||
[SerializeField] private HitBox _landingHitBox;
|
||||
[SerializeField] private float _hitBoxActiveTime = 0.2f;
|
||||
[SerializeField] private float _recoveryTime = 0.4f;
|
||||
|
||||
private Rigidbody2D _rb;
|
||||
private RigidbodyType2D _origBodyType;
|
||||
private float _origGravityScale;
|
||||
|
||||
protected override void Awake()
|
||||
{
|
||||
base.Awake();
|
||||
_rb = GetComponentInParent<Rigidbody2D>();
|
||||
}
|
||||
|
||||
protected override IEnumerator ExecuteCoroutine()
|
||||
{
|
||||
if (_rb == null) yield break;
|
||||
var atk = (_config.attackSequence != null && _config.attackSequence.Length > 0)
|
||||
? _config.attackSequence[0] : null;
|
||||
|
||||
Phase = AbilityRunState.Active;
|
||||
|
||||
// 切换到动态 + 恢复重力
|
||||
_origBodyType = _rb.bodyType;
|
||||
_origGravityScale = _rb.gravityScale;
|
||||
_rb.bodyType = RigidbodyType2D.Dynamic;
|
||||
_rb.gravityScale = _fallGravityScale;
|
||||
_rb.velocity = Vector2.zero;
|
||||
|
||||
float t = 0f;
|
||||
while (t < _maxFallTime)
|
||||
{
|
||||
t += Time.fixedDeltaTime;
|
||||
yield return new WaitForFixedUpdate();
|
||||
if (t > 0.05f && IsGrounded()) break;
|
||||
}
|
||||
|
||||
_rb.velocity = Vector2.zero;
|
||||
|
||||
if (_landingHitBox != null)
|
||||
{
|
||||
_landingHitBox.Activate(atk != null ? atk.damageSource : null, _transform);
|
||||
yield return EnemyAbilityWaits.Get(_hitBoxActiveTime);
|
||||
_landingHitBox.Deactivate();
|
||||
}
|
||||
|
||||
yield return EnemyAbilityWaits.Get(_recoveryTime);
|
||||
|
||||
// 落地后不恢复挂顶状态(一般转为地面行为),保持动态 Rigidbody
|
||||
}
|
||||
|
||||
private bool IsGrounded()
|
||||
{
|
||||
var hit = Physics2D.Raycast(_rb.position, Vector2.down, 0.6f, _groundMask);
|
||||
return hit.collider != null;
|
||||
}
|
||||
|
||||
protected override void OnInterrupted(InterruptReason reason)
|
||||
{
|
||||
if (_landingHitBox != null && _landingHitBox.IsActive) _landingHitBox.Deactivate();
|
||||
// 中断时恢复 Rigidbody 原始状态,防止物理参数泄漏
|
||||
if (_rb != null)
|
||||
{
|
||||
_rb.velocity = Vector2.zero;
|
||||
_rb.bodyType = _origBodyType;
|
||||
_rb.gravityScale = _origGravityScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e91a8a04726e4144ab7c4d87064ec2f8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
95
Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs
Normal file
95
Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using BaseGames.Combat;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 直线冲锋能力:朝目标方向高速直线冲锋,撞墙时进入硬直恢复。
|
||||
/// 流程:朝向目标 → 蓄力 → 高速直线冲锋直到撞墙或超距 → 恢复。
|
||||
/// 冲锋期间 HitBox 持续激活;可选撞墙时硬直。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Rigidbody2D))]
|
||||
public sealed class ChargeAbility : EnemyAbilityBase
|
||||
{
|
||||
[Header("冲锋参数")]
|
||||
[SerializeField] [Min(0.1f)] private float _chargeSpeed = 14f;
|
||||
[SerializeField] [Min(0.1f)] private float _maxDistance = 12f;
|
||||
[SerializeField] private float _windupTime = 0.4f;
|
||||
[SerializeField] private float _recoveryTime = 0.6f;
|
||||
[SerializeField] private float _wallCheckDist = 0.4f;
|
||||
[SerializeField] private LayerMask _wallMask;
|
||||
[SerializeField] private bool _stunOnWallHit = true;
|
||||
[SerializeField] private float _wallStunTime = 0.8f;
|
||||
|
||||
[Header("HitBox")]
|
||||
[SerializeField] private HitBox _chargeHitBox;
|
||||
|
||||
[Header("动画 Key")]
|
||||
[SerializeField] private string _windupAnimSlot = "";
|
||||
[SerializeField] private string _chargeAnimSlot = "";
|
||||
|
||||
private Rigidbody2D _rb;
|
||||
private float _direction;
|
||||
|
||||
protected override void Awake()
|
||||
{
|
||||
base.Awake();
|
||||
_rb = GetComponentInParent<Rigidbody2D>();
|
||||
}
|
||||
|
||||
protected override IEnumerator ExecuteCoroutine()
|
||||
{
|
||||
if (_rb == null) yield break;
|
||||
|
||||
FaceTarget(_enemy != null ? _enemy.PlayerTransform : null);
|
||||
_direction = _transform.localScale.x >= 0f ? 1f : -1f;
|
||||
|
||||
var seq = _config != null ? _config.attackSequence : null;
|
||||
var windupAtk = (seq != null && seq.Length > 0) ? seq[0] : null;
|
||||
var chargeAtk = (seq != null && seq.Length > 1) ? seq[1] : windupAtk;
|
||||
|
||||
// Windup
|
||||
if (windupAtk != null && windupAtk.clip != null && _animancer != null)
|
||||
_animancer.Play(windupAtk.clip);
|
||||
_rb.velocity = new Vector2(0f, _rb.velocity.y);
|
||||
yield return EnemyAbilityWaits.Get(_windupTime);
|
||||
|
||||
// Charge
|
||||
Phase = AbilityRunState.Active;
|
||||
if (chargeAtk != null && chargeAtk.clip != null && _animancer != null)
|
||||
_animancer.Play(chargeAtk.clip);
|
||||
if (_chargeHitBox != null)
|
||||
_chargeHitBox.Activate(chargeAtk != null ? chargeAtk.damageSource : null, _transform);
|
||||
|
||||
Vector2 start = _rb.position;
|
||||
bool wallHit = false;
|
||||
float maxTime = (_maxDistance / _chargeSpeed) * 3f; // 3 倍容差兜底,防止极端物理情况下死循环
|
||||
float elapsed = 0f;
|
||||
while (true)
|
||||
{
|
||||
_rb.velocity = new Vector2(_chargeSpeed * _direction, _rb.velocity.y);
|
||||
yield return new WaitForFixedUpdate();
|
||||
elapsed += Time.fixedDeltaTime;
|
||||
if (elapsed >= maxTime) break; // 超时保护
|
||||
float traveled = Mathf.Abs(_rb.position.x - start.x);
|
||||
if (traveled >= _maxDistance) break;
|
||||
var hit = Physics2D.Raycast(_rb.position, new Vector2(_direction, 0f), _wallCheckDist, _wallMask);
|
||||
if (hit.collider != null) { wallHit = true; break; }
|
||||
}
|
||||
|
||||
_rb.velocity = new Vector2(0f, _rb.velocity.y);
|
||||
if (_chargeHitBox != null && _chargeHitBox.IsActive) _chargeHitBox.Deactivate();
|
||||
|
||||
// Recovery
|
||||
float recover = wallHit && _stunOnWallHit ? _wallStunTime : _recoveryTime;
|
||||
yield return EnemyAbilityWaits.Get(recover);
|
||||
}
|
||||
|
||||
protected override void OnInterrupted(InterruptReason reason)
|
||||
{
|
||||
if (_chargeHitBox != null && _chargeHitBox.IsActive) _chargeHitBox.Deactivate();
|
||||
if (_rb != null) _rb.velocity = Vector2.zero; // 完整清零速度,包含 y 轴
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66784dd946e412049ad4425aa7be8a47
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
189
Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs
Normal file
189
Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using Animancer;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Pool;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 敌人能力抽象基类(架构 07_EnemyModule §8.4)。
|
||||
/// 责任:生命周期管理、冷却计时、中断分发。子类只实现 <see cref="ExecuteCoroutine"/>。
|
||||
///
|
||||
/// 设计要点:
|
||||
/// - 单一执行实例:同一能力同时只有一个协程在跑。
|
||||
/// - 协程内 yield WaitForSeconds 复用 <see cref="EnemyAbilityWaits"/>(无 GC)。
|
||||
/// - 受击/死亡时由 <see cref="EnemyBase"/> 调用 <see cref="Interrupt"/>。
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public abstract class EnemyAbilityBase : MonoBehaviour
|
||||
{
|
||||
[Header("配置 SO")]
|
||||
[SerializeField] protected EnemyAbilitySO _config;
|
||||
|
||||
// 缓存依赖(Awake 填入,热路径无 GetComponent)
|
||||
protected EnemyBase _enemy;
|
||||
protected AnimancerComponent _animancer;
|
||||
protected Transform _transform;
|
||||
|
||||
private Coroutine _runner;
|
||||
private float _cooldownEndTime = -1f;
|
||||
private bool _isRunning;
|
||||
|
||||
// ── 公共状态 ─────────────────────────────────────────────────────
|
||||
public EnemyAbilitySO Config => _config;
|
||||
public bool IsRunning => _isRunning;
|
||||
public AbilityRunState Phase { get; protected set; } = AbilityRunState.Idle;
|
||||
public float CooldownRemaining => Mathf.Max(0f, _cooldownEndTime - Time.time);
|
||||
public bool IsOnCooldown => CooldownRemaining > 0f;
|
||||
|
||||
/// <summary>能力被外部中断时触发(BD Task / 状态机订阅用)。</summary>
|
||||
public event System.Action<InterruptReason> Interrupted;
|
||||
|
||||
/// <summary>BD 任务统一查询入口:当前是否可用(冷却完毕且未执行中)。</summary>
|
||||
public virtual bool CanUse => !_isRunning && !IsOnCooldown && _enemy != null && _enemy.IsAlive;
|
||||
|
||||
protected virtual void Awake()
|
||||
{
|
||||
_enemy = GetComponentInParent<EnemyBase>();
|
||||
_animancer = _enemy != null ? _enemy.Animancer : GetComponentInParent<AnimancerComponent>();
|
||||
_transform = transform;
|
||||
if (_enemy == null)
|
||||
Debug.LogError($"[EnemyAbilityBase] {GetType().Name} 找不到 EnemyBase。", this);
|
||||
if (_animancer == null)
|
||||
Debug.LogWarning($"[EnemyAbilityBase] {GetType().Name} 找不到 AnimancerComponent,动画能力将无法播放动画。", this);
|
||||
}
|
||||
|
||||
protected virtual void OnDisable()
|
||||
{
|
||||
if (_isRunning) Interrupt(InterruptReason.ExternalRequest);
|
||||
}
|
||||
|
||||
// ── 执行 ─────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 启动能力。重复调用、冷却中或已运行将返回 false。
|
||||
/// 若 <see cref="EnemyAbilitySO.exclusionGroup"/> 非空,会先中断同组其他能力(互斥)。
|
||||
/// </summary>
|
||||
public bool Execute()
|
||||
{
|
||||
if (!CanUse) return false;
|
||||
|
||||
// 互斥组:启动前中断同组正在运行的其他能力
|
||||
if (_config != null && !string.IsNullOrEmpty(_config.exclusionGroup))
|
||||
_enemy?.Abilities.InterruptGroup(_config.exclusionGroup, InterruptReason.ExternalRequest);
|
||||
|
||||
_runner = StartCoroutine(RunInternal());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 强制启动能力,忽略冷却检查(连段语义:外部组合技调用子能力时使用)。
|
||||
/// 若能力正在运行则先中断再重启。
|
||||
/// </summary>
|
||||
public bool ForceExecute()
|
||||
{
|
||||
if (_enemy == null || !_enemy.IsAlive) return false;
|
||||
if (_isRunning) Interrupt(InterruptReason.ExternalRequest);
|
||||
_runner = StartCoroutine(RunInternal());
|
||||
return true;
|
||||
}
|
||||
|
||||
private IEnumerator RunInternal()
|
||||
{
|
||||
_isRunning = true;
|
||||
Phase = AbilityRunState.Telegraph;
|
||||
try
|
||||
{
|
||||
if (_config != null && _config.telegraphDuration > 0f)
|
||||
yield return TelegraphRoutine();
|
||||
|
||||
Phase = AbilityRunState.Windup;
|
||||
yield return ExecuteCoroutine();
|
||||
Phase = AbilityRunState.Recovery;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isRunning = false;
|
||||
_runner = null;
|
||||
_cooldownEndTime = Time.time + (_config != null ? _config.cooldown : 0f);
|
||||
if (Phase != AbilityRunState.Interrupted) Phase = AbilityRunState.Idle;
|
||||
OnAbilityEnded();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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() { }
|
||||
|
||||
/// <summary>中断当前执行。冷却仍会按配置计入。</summary>
|
||||
public void Interrupt(InterruptReason reason)
|
||||
{
|
||||
if (!_isRunning) return;
|
||||
if (_config != null)
|
||||
{
|
||||
if (reason == InterruptReason.Hurt && !_config.interruptOnHurt) return;
|
||||
if (reason == InterruptReason.Stagger && !_config.interruptOnStagger) return;
|
||||
}
|
||||
if (_runner != null) StopCoroutine(_runner);
|
||||
_runner = null;
|
||||
_isRunning = false;
|
||||
Phase = AbilityRunState.Interrupted;
|
||||
OnInterrupted(reason);
|
||||
Interrupted?.Invoke(reason);
|
||||
OnAbilityEnded();
|
||||
_cooldownEndTime = Time.time + (_config != null ? _config.cooldown * 0.5f : 0f);
|
||||
}
|
||||
|
||||
protected virtual void OnInterrupted(InterruptReason reason) { }
|
||||
|
||||
/// <summary>子类辅助:朝向目标。</summary>
|
||||
protected void FaceTarget(Transform target)
|
||||
{
|
||||
if (target == null || _enemy == null) return;
|
||||
float dx = target.position.x - _transform.position.x;
|
||||
if (Mathf.Abs(dx) < 0.001f) return;
|
||||
var s = _transform.localScale;
|
||||
s.x = Mathf.Abs(s.x) * Mathf.Sign(dx);
|
||||
_transform.localScale = s;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WaitForSeconds 池(架构 §10 GC 优化)。能力协程统一通过此获取等待指令。</summary>
|
||||
internal static class EnemyAbilityWaits
|
||||
{
|
||||
private const int MaxCacheSize = 64;
|
||||
|
||||
private static readonly System.Collections.Generic.Dictionary<float, WaitForSeconds> _cache
|
||||
= new System.Collections.Generic.Dictionary<float, WaitForSeconds>(32);
|
||||
public static WaitForSeconds Get(float seconds)
|
||||
{
|
||||
if (seconds <= 0f) return null;
|
||||
if (!_cache.TryGetValue(seconds, out var w))
|
||||
{
|
||||
if (_cache.Count < MaxCacheSize)
|
||||
{
|
||||
w = new WaitForSeconds(seconds);
|
||||
_cache[seconds] = w;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new WaitForSeconds(seconds);
|
||||
}
|
||||
}
|
||||
return w;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d701244b04517a34d8f9214a606ba46e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 能力注册表(架构 07_EnemyModule §8.3)。
|
||||
/// EnemyBase.Awake 时调用 <see cref="CollectFrom"/> 自动扫描子物体上所有
|
||||
/// <see cref="EnemyAbilityBase"/> 组件,按 abilityId 建立 O(1) 查询字典。
|
||||
/// </summary>
|
||||
public sealed class EnemyAbilityRegistry
|
||||
{
|
||||
private readonly Dictionary<string, EnemyAbilityBase> _byId
|
||||
= new Dictionary<string, EnemyAbilityBase>(8);
|
||||
private readonly List<EnemyAbilityBase> _all = new List<EnemyAbilityBase>(8);
|
||||
|
||||
/// <summary>所有已注册能力(只读)。</summary>
|
||||
public IReadOnlyList<EnemyAbilityBase> All => _all;
|
||||
|
||||
public void CollectFrom(GameObject root)
|
||||
{
|
||||
_byId.Clear();
|
||||
_all.Clear();
|
||||
root.GetComponentsInChildren(true, _all);
|
||||
for (int i = 0; i < _all.Count; i++)
|
||||
{
|
||||
var ab = _all[i];
|
||||
if (ab == null || ab.Config == null || string.IsNullOrEmpty(ab.Config.abilityId))
|
||||
continue;
|
||||
if (_byId.ContainsKey(ab.Config.abilityId))
|
||||
{
|
||||
Debug.LogError($"[EnemyAbilityRegistry] 重复 abilityId='{ab.Config.abilityId}' 于 {ab.gameObject.name},后注册者被忽略。请检查配置。", ab);
|
||||
continue;
|
||||
}
|
||||
_byId.Add(ab.Config.abilityId, ab);
|
||||
}
|
||||
}
|
||||
|
||||
public EnemyAbilityBase Get(string abilityId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(abilityId)) return null;
|
||||
_byId.TryGetValue(abilityId, out var ab);
|
||||
return ab;
|
||||
}
|
||||
|
||||
public T Get<T>() where T : EnemyAbilityBase
|
||||
{
|
||||
for (int i = 0; i < _all.Count; i++)
|
||||
if (_all[i] is T t) return t;
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool Has(string abilityId) => _byId.ContainsKey(abilityId);
|
||||
|
||||
public void InterruptAll(InterruptReason reason)
|
||||
{
|
||||
for (int i = 0; i < _all.Count; i++)
|
||||
{
|
||||
var ab = _all[i];
|
||||
if (ab != null && ab.IsRunning) ab.Interrupt(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 中断指定互斥组内所有正在执行的能力。
|
||||
/// 由 <see cref="EnemyAbilityBase"/> 在 Execute 开始时调用,确保同组互斥。
|
||||
/// </summary>
|
||||
public void InterruptGroup(string group, InterruptReason reason = InterruptReason.ExternalRequest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(group)) return;
|
||||
for (int i = 0; i < _all.Count; i++)
|
||||
{
|
||||
var ab = _all[i];
|
||||
if (ab != null && ab.IsRunning && ab.Config?.exclusionGroup == group)
|
||||
ab.Interrupt(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7542012f25c120f4aad1914db8852b59
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
46
Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs
Normal file
46
Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Enemies.Abilities
|
||||
{
|
||||
/// <summary>
|
||||
/// 能力配置包(架构 07_EnemyModule §8.2)。
|
||||
/// 一个能力 = 一组攻击段 + 公共参数(冷却/预警/中断规则)。
|
||||
/// 由对应 EnemyAbilityBase 子类组件读取并执行。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Ability", fileName = "EAB_")]
|
||||
public class EnemyAbilitySO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
[Tooltip("BD 任务通过此 Id 调用能力(如 \"melee_combo\" / \"blink_strike\")")]
|
||||
public string abilityId = "ability_id";
|
||||
|
||||
[Header("攻击序列")]
|
||||
public EnemyAttackSO[] attackSequence;
|
||||
|
||||
[Header("冷却(秒,从能力执行结束开始计)")]
|
||||
[Min(0f)] public float cooldown = 1.5f;
|
||||
|
||||
[Header("预警(Telegraph)")]
|
||||
[Tooltip("预警 VFX key(IObjectPoolService.Spawn 的池 key),为空则跳过")]
|
||||
public string telegraphVfxKey = "";
|
||||
[Min(0f)] public float telegraphDuration = 0f;
|
||||
|
||||
[Header("中断规则")]
|
||||
[Tooltip("受击时是否打断能力(false=能力具霸体)")]
|
||||
public bool interruptOnHurt = true;
|
||||
[Tooltip("Stagger 时是否打断(一般为 true)")]
|
||||
public bool interruptOnStagger = true;
|
||||
|
||||
[Header("调度提示(供 AI 选择参考,不强制)")]
|
||||
[Min(0f)] public float preferredMinRange = 0f;
|
||||
[Min(0f)] public float preferredMaxRange = 5f;
|
||||
public bool requiresLineOfSight = true;
|
||||
public bool requiresGrounded = true;
|
||||
|
||||
[Header("互斥组与调度优先级")]
|
||||
[Tooltip("互斥组名:同组能力只能有一个同时执行,执行时自动中断同组其他能力;为空 = 无互斥")]
|
||||
public string exclusionGroup = "";
|
||||
[Tooltip("AI 调度优先级(值越高越优先被选择,冷却就绪时对比)")]
|
||||
[Min(0)] public int priority = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9050afa76362dff469c64fbb48c9ff8d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user