This commit is contained in:
yangyu
2026-06-02 16:36:19 +08:00
59 changed files with 180640 additions and 555 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"mcp__unity-mcp__unity_list_instances",
"Bash(rtk ls *)",
"Bash(rtk git *)"
]
}
}

27
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,27 @@
<!-- rtk-instructions v2 -->
# RTK — Token-Optimized CLI
**rtk** is a CLI proxy that filters and compresses command outputs, saving 60-90% tokens.
## Rule
Always prefix shell commands with `rtk`:
```bash
# Instead of: Use:
git status rtk git status
git log -10 rtk git log -10
cargo test rtk cargo test
docker ps rtk docker ps
kubectl get pods rtk kubectl pods
```
## Meta commands (use directly)
```bash
rtk gain # Token savings dashboard
rtk gain --history # Per-command savings history
rtk discover # Find missed rtk opportunities
rtk proxy <cmd> # Run raw (no filtering) but track usage
```
<!-- /rtk-instructions -->

12
.github/hooks/rtk-rewrite.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "rtk hook copilot",
"cwd": ".",
"timeout": 5
}
]
}
}

View File

@@ -61,7 +61,7 @@ AnimationClip:
m_Level: 0 m_Level: 0
m_CycleOffset: 0 m_CycleOffset: 0
m_HasAdditiveReferencePose: 0 m_HasAdditiveReferencePose: 0
m_LoopTime: 1 m_LoopTime: 0
m_LoopBlend: 0 m_LoopBlend: 0
m_LoopBlendOrientation: 0 m_LoopBlendOrientation: 0
m_LoopBlendPositionY: 0 m_LoopBlendPositionY: 0

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,12 @@ namespace BaseGames.Combat
public string SourceId; public string SourceId;
public string SkillId; public string SkillId;
/// <summary> /// <summary>
/// HitBox 激活实例 ID由 HitBox.Activate() 自动生成并写入0 = 无追踪路径)。
/// HurtBoxOwnerGuard 用此字段做同激活去重,防止多 HurtBox 节点被同一次攻击重复扣血。
/// [NonSerialized]:每次激活动态生成,不需要序列化。
/// </summary>
[System.NonSerialized] public uint HitActivationId;
/// <summary>
/// 攻击来源投射物(仅当攻击方是 Projectile 时非 null /// 攻击来源投射物(仅当攻击方是 Projectile 时非 null
/// 用于弹反成功时调用 ReflectAsPlayerProjectile() 翻转阵营。 /// 用于弹反成功时调用 ReflectAsPlayerProjectile() 翻转阵营。
/// [NonSerialized]MonoBehaviour 引用不参与 Unity 资产序列化。 /// [NonSerialized]MonoBehaviour 引用不参与 Unity 资产序列化。

View File

@@ -8,8 +8,10 @@ namespace BaseGames.Combat
/// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。 /// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。
/// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。 /// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。
/// Collider2D 需设 IsTrigger = trueLayer = PlayerHitBox 或 EnemyHitBox。 /// Collider2D 需设 IsTrigger = trueLayer = PlayerHitBox 或 EnemyHitBox。
///
/// 多碰撞体模式:在子节点上挂载 HitBoxColliderProxy + Collider2D 即可组合任意形状。
/// HitBox 本身可不带 Collider2D仅代理子节点或同时拥有直属 Collider2D。
/// </summary> /// </summary>
[RequireComponent(typeof(Collider2D))]
public class HitBox : MonoBehaviour public class HitBox : MonoBehaviour
{ {
[SerializeField] private DamageSourceSO _defaultSource; [SerializeField] private DamageSourceSO _defaultSource;
@@ -33,7 +35,16 @@ namespace BaseGames.Combat
private Rigidbody2D _ownerRigidbody; private Rigidbody2D _ownerRigidbody;
private bool _isActive; private bool _isActive;
private IClashService _clashService; private IClashService _clashService;
private Collider2D _collider;
// 直属碰撞体(本 GameObject 上)
private Collider2D[] _directColliders = System.Array.Empty<Collider2D>();
// 子节点代理碰撞体
private HitBoxColliderProxy[] _proxies = System.Array.Empty<HitBoxColliderProxy>();
// 激活 ID每次 Activate() 递增,写入 DamageInfo.HitActivationId
// HurtBoxOwnerGuard 据此防止多 HurtBox 节点被同一次攻击重复扣血。
private static uint _nextActivationId = 1;
private uint _currentActivationId;
/// <summary>HitBox 当前是否激活(供 ClashResolver 查询)。</summary> /// <summary>HitBox 当前是否激活(供 ClashResolver 查询)。</summary>
public bool IsActive => _isActive; public bool IsActive => _isActive;
@@ -58,12 +69,13 @@ namespace BaseGames.Combat
/// </summary> /// </summary>
public void Activate(DamageSourceSO source = null, Transform attacker = null) public void Activate(DamageSourceSO source = null, Transform attacker = null)
{ {
_currentActivationId = _nextActivationId++;
_currentSource = source ?? _defaultSource; _currentSource = source ?? _defaultSource;
_attackerTransform = attacker ?? transform; _attackerTransform = attacker ?? transform;
_isActive = true; _isActive = true;
_collider.enabled = true;
// 缓存宿主 Rigidbody2D沿父层级向上查找
_ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>(); _ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>();
foreach (var col in _directColliders) col.enabled = true;
foreach (var proxy in _proxies) proxy.SetEnabled(true);
// 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标) // 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标)
_hitThisActivation.Clear(); _hitThisActivation.Clear();
_hitCooldownTimers.Clear(); _hitCooldownTimers.Clear();
@@ -72,7 +84,8 @@ namespace BaseGames.Combat
public void Deactivate() public void Deactivate()
{ {
_isActive = false; _isActive = false;
_collider.enabled = false; foreach (var col in _directColliders) col.enabled = false;
foreach (var proxy in _proxies) proxy.SetEnabled(false);
_hitThisActivation.Clear(); _hitThisActivation.Clear();
_hitCooldownTimers.Clear(); _hitCooldownTimers.Clear();
} }
@@ -85,12 +98,18 @@ namespace BaseGames.Combat
private void Awake() private void Awake()
{ {
// 确保 Collider2DTrigger // 收集本节点上所有直属 Collider2D,并验证 isTrigger
_collider = GetComponent<Collider2D>(); _directColliders = GetComponents<Collider2D>();
if (!_collider.isTrigger) foreach (var col in _directColliders)
Debug.LogWarning($"[HitBox] {name}: Collider2D.isTrigger 应为 true。", this); {
// 初始状态关闭碰撞体,防止未激活时产生物理检测 if (!col.isTrigger)
_collider.enabled = false; Debug.LogWarning($"[HitBox] {name}: Collider2D ({col.GetType().Name}) isTrigger 应为 true。", this);
col.enabled = false;
}
// 注册所有子代 HitBoxColliderProxy子节点多形状模式
_proxies = GetComponentsInChildren<HitBoxColliderProxy>(true);
foreach (var proxy in _proxies)
proxy.Init(this);
// 缓存 IClashServiceOnTriggerEnter2D 为物理热路径,避免每次调用 Dictionary 查找 // 缓存 IClashServiceOnTriggerEnter2D 为物理热路径,避免每次调用 Dictionary 查找
_clashService = ServiceLocator.GetOrDefault<IClashService>(); _clashService = ServiceLocator.GetOrDefault<IClashService>();
// 缓存宿主投射物(仅 Projectile GameObject 上挂载的 HitBox 非 null // 缓存宿主投射物(仅 Projectile GameObject 上挂载的 HitBox 非 null
@@ -100,20 +119,18 @@ namespace BaseGames.Combat
private void OnDisable() private void OnDisable()
{ {
_isActive = false; _isActive = false;
if (_collider != null) _collider.enabled = false; foreach (var col in _directColliders) if (col != null) col.enabled = false;
foreach (var proxy in _proxies) proxy.SetEnabled(false);
_hitThisActivation.Clear(); _hitThisActivation.Clear();
_hitCooldownTimers.Clear(); _hitCooldownTimers.Clear();
} }
private void OnTriggerExit2D(Collider2D other) private void OnTriggerEnter2D(Collider2D other) => HandleTriggerEnter(other, null);
{ private void OnTriggerExit2D(Collider2D other) => HandleTriggerExit(other);
// 目标离开判定区域时清除其冷却记录,防止持续激活的 HitBox环境危险等
// 因有效目标持续流动而无限积累已离场对象。
// 注意_hitThisActivation 刻意保留,确保同一激活期内不重复命中。
_hitCooldownTimers.Remove(other);
}
private void OnTriggerEnter2D(Collider2D other) { /// <summary>代理入口:由 HitBoxColliderProxy 或本节点 OnTriggerEnter2D 转发。</summary>
internal void HandleTriggerEnter(Collider2D other, Collider2D sourceCollider)
{
if (!_isActive) return; if (!_isActive) return;
if (_currentSource == null) if (_currentSource == null)
{ {
@@ -136,6 +153,7 @@ namespace BaseGames.Combat
_attackerTransform.position, _attackerTransform.position,
_attackerTransform.gameObject.layer, _attackerTransform.gameObject.layer,
_ownerProjectile); _ownerProjectile);
info.HitActivationId = _currentActivationId;
// ① 拼刀检测:当前 HitBox 携带 CanClash 标记,且碰到对立阵营的 HitBox 层 // ① 拼刀检测:当前 HitBox 携带 CanClash 标记,且碰到对立阵营的 HitBox 层
int otherLayer = other.gameObject.layer; int otherLayer = other.gameObject.layer;
@@ -153,9 +171,12 @@ namespace BaseGames.Combat
// ② 命中 HurtBox // ② 命中 HurtBox
if (other.TryGetComponent<HurtBox>(out var hurtBox)) if (other.TryGetComponent<HurtBox>(out var hurtBox))
{ {
// 用 HitBox 自身碰撞中心在 HurtBox 表面的最近点作为受击位置。 // hitPoint优先使用触发命中的碰撞中心在 HurtBox 表面的最近点
// 对大体积/长条形受击体(如地刺),此点远比 HurtBox 节点中心更准确 // 无 sourceCollider直属碰撞体时回退到 HitBox 节点坐标
Vector3 hitPoint = other.ClosestPoint(_collider.bounds.center); Vector2 hitOrigin = sourceCollider != null
? (Vector2)sourceCollider.bounds.center
: (Vector2)transform.position;
Vector3 hitPoint = other.ClosestPoint(hitOrigin);
hurtBox.ReceiveDamage(info, hitPoint); hurtBox.ReceiveDamage(info, hitPoint);
OnHitConfirmed?.Invoke(info); OnHitConfirmed?.Invoke(info);
return; return;
@@ -171,6 +192,15 @@ namespace BaseGames.Combat
// ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)── // ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)──
private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new(8); private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new(8);
/// <summary>代理出口:由 HitBoxColliderProxy 或本节点 OnTriggerExit2D 转发。</summary>
internal void HandleTriggerExit(Collider2D other)
{
// 目标离开判定区域时清除其冷却记录,防止持续激活的 HitBox环境危险等
// 因有效目标持续流动而无限积累已离场对象。
// 注意_hitThisActivation 刻意保留,确保同一激活期内不重复命中。
_hitCooldownTimers.Remove(other);
}
private bool CheckCooldown(Collider2D other) private bool CheckCooldown(Collider2D other)
{ {
float now = Time.time; float now = Time.time;
@@ -183,11 +213,17 @@ namespace BaseGames.Combat
#if UNITY_EDITOR #if UNITY_EDITOR
private void OnDrawGizmos() private void OnDrawGizmos()
{ {
var col = GetComponent<Collider2D>();
if (col == null) return;
Color fill = _isActive ? new Color(1f, 0.15f, 0.15f, 0.25f) : new Color(1f, 0.15f, 0.15f, 0.05f); Color fill = _isActive ? new Color(1f, 0.15f, 0.15f, 0.25f) : new Color(1f, 0.15f, 0.15f, 0.05f);
Color outline = _isActive ? new Color(1f, 0.15f, 0.15f, 0.90f) : new Color(1f, 0.15f, 0.15f, 0.25f); Color outline = _isActive ? new Color(1f, 0.15f, 0.15f, 0.90f) : new Color(1f, 0.15f, 0.15f, 0.25f);
// 直属碰撞体
foreach (var col in GetComponents<Collider2D>())
DrawCollider2DFilled(col, fill, outline); DrawCollider2DFilled(col, fill, outline);
// 子代代理碰撞体
foreach (var proxy in GetComponentsInChildren<HitBoxColliderProxy>(true))
{
var proxyCol = proxy.GetComponent<Collider2D>();
if (proxyCol != null) DrawCollider2DFilled(proxyCol, fill, outline);
}
} }
// ── Gizmo 辅助(填充 + 轮廓,不依赖外部工具类)────────────────────────── // ── Gizmo 辅助(填充 + 轮廓,不依赖外部工具类)──────────────────────────

View File

@@ -0,0 +1,41 @@
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 子碰撞体代理。挂载在 HitBox 子节点上,将 Trigger 事件转发给父级 HitBox
/// 实现"单一 HitBox 组件 + 多个异形 Collider2D"的组合判定盒。
///
/// 配置说明(子节点多形状模式):
/// [AttackNode] ← HitBox 组件(本身可不带 Collider2D
/// ├── [Shape_Box] ← BoxCollider2D + HitBoxColliderProxy
/// └── [Shape_Circle] ← CircleCollider2D + HitBoxColliderProxy
///
/// ⚠️ 子节点 Collider2D 须设 IsTrigger = trueLayer 与父 HitBox 一致。
/// ⚠️ 无需手动调用 Init()HitBox.Awake() 自动完成注册。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public sealed class HitBoxColliderProxy : MonoBehaviour
{
private HitBox _owner;
private Collider2D _col;
/// <summary>由父 HitBox.Awake() 调用,完成双向注册。</summary>
internal void Init(HitBox owner)
{
_owner = owner;
_col = GetComponent<Collider2D>();
if (!_col.isTrigger)
Debug.LogWarning($"[HitBoxColliderProxy] {name}: Collider2D.isTrigger 应为 true。", this);
_col.enabled = false;
}
internal void SetEnabled(bool value)
{
if (_col != null) _col.enabled = value;
}
private void OnTriggerEnter2D(Collider2D other) => _owner?.HandleTriggerEnter(other, _col);
private void OnTriggerExit2D(Collider2D other) => _owner?.HandleTriggerExit(other);
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 58cb3ac0e49c151429cad39d3e164a3d guid: e11b931e351246344aec20aa35489592
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}
serializedVersion: 2 serializedVersion: 2

View File

@@ -20,6 +20,8 @@ namespace BaseGames.Combat
private bool _isHurtBoxInvincible; private bool _isHurtBoxInvincible;
private bool _isActive = true; private bool _isActive = true;
// 所有者级去重保护:防止同一角色的多个 HurtBox 子节点在同一次 HitBox 激活中被重复伤害
private HurtBoxOwnerGuard _ownerGuard;
// ── 事件频道 ────────────────────────────────────────────────────────── // ── 事件频道 ──────────────────────────────────────────────────────────
[SerializeField] private DamageInfoEventChannelSO _onDamageDealt; [SerializeField] private DamageInfoEventChannelSO _onDamageDealt;
@@ -45,6 +47,10 @@ namespace BaseGames.Combat
_statusEffectable = GetComponentInParent<IStatusEffectable>(); _statusEffectable = GetComponentInParent<IStatusEffectable>();
if (_owner == null) if (_owner == null)
Debug.LogWarning($"[HurtBox] {name}: 父节点中未找到 IDamageable 实现。", this); Debug.LogWarning($"[HurtBox] {name}: 父节点中未找到 IDamageable 实现。", this);
// 在角色根节点查找或自动创建 HurtBoxOwnerGuard多 HurtBox 共享所有者时只有一个 Guard
_ownerGuard = transform.root.GetComponent<HurtBoxOwnerGuard>();
if (_ownerGuard == null && _owner != null)
_ownerGuard = transform.root.gameObject.AddComponent<HurtBoxOwnerGuard>();
} }
/// <summary> /// <summary>
@@ -56,6 +62,8 @@ namespace BaseGames.Combat
{ {
Vector3 resolvedHitPoint = hitPoint ?? transform.position; Vector3 resolvedHitPoint = hitPoint ?? transform.position;
if (!_isActive || _owner == null) return; if (!_isActive || _owner == null) return;
// 所有者级去重:同一 HitBox 激活期内多个 HurtBox 子节点只处理首次命中(共享 HP
if (_ownerGuard != null && !_ownerGuard.TryRegisterHit(info.HitActivationId)) return;
// 1. 无敌帧检查 // 1. 无敌帧检查
if ((_owner.IsInvincible || _isHurtBoxInvincible) if ((_owner.IsInvincible || _isHurtBoxInvincible)

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// HurtBox 所有者保护组件。防止同一角色身上的多个 HurtBox 在同一次 HitBox 激活中被重复伤害。
///
/// 工作原理:每次 HitBox 激活携带唯一 ActivationId由 HitBox.Activate() 生成递增值)。
/// HurtBox.ReceiveDamage 调用 TryRegisterHit(id) 时,同一 id 只有首次调用返回 true
/// 后续同 id 调用(来自同一角色的其他 HurtBox 子节点)返回 false 并跳过伤害流水线,
/// 从而保证"多个 HurtBox 共享同一 HP 池时,一次攻击只扣一次血"。
///
/// 生命周期:由 HurtBox.Awake() 在角色根节点自动添加(如不存在则创建)。无需手动挂载。
/// 逐帧通过 frameCount 差异懒清空处理集,零 GC 开销。
/// </summary>
[AddComponentMenu("")] // 隐藏菜单:由 HurtBox 自动管理
public sealed class HurtBoxOwnerGuard : MonoBehaviour
{
private readonly HashSet<uint> _processedIds = new(4);
private int _lastClearFrame = -1;
/// <summary>
/// 尝试注册一次命中。
/// </summary>
/// <param name="activationId">HitBox 激活 ID0 = 无追踪路径,始终允许通过)。</param>
/// <returns>true = 首次注册应继续处理伤害false = 同 id 已被处理,跳过。</returns>
public bool TryRegisterHit(uint activationId)
{
// activationId == 0LethalTrap / BodyContactDamage 等旁路路径,不做去重
if (activationId == 0) return true;
EnsureClearedThisFrame();
return _processedIds.Add(activationId);
}
private void EnsureClearedThisFrame()
{
int frame = Time.frameCount;
if (frame == _lastClearFrame) return;
_processedIds.Clear();
_lastClearFrame = frame;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d9d0dceef43f3534c9bcc84af2134c53
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using UnityEditor; using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine; using UnityEngine;
using UnityEngine.UIElements; using UnityEngine.UIElements;
using BaseGames.Boss; using BaseGames.Boss;
@@ -15,6 +15,8 @@ using BaseGames.Parry;
using BaseGames.Player; using BaseGames.Player;
using BaseGames.Player.States; using BaseGames.Player.States;
using BaseGames.Skills; using BaseGames.Skills;
using BaseGames.Editor.Combat;
using BaseGames.Editor.Skills;
namespace BaseGames.Editor namespace BaseGames.Editor
{ {
@@ -59,6 +61,7 @@ namespace BaseGames.Editor
// 小怪类型选择 — 具体敌人类型 // 小怪类型选择 — 具体敌人类型
private int _enemyTypeIndex = 0; private int _enemyTypeIndex = 0;
private SceneObjectPlacerTool.EnemyBodyColliderType _enemyBodyCollider = SceneObjectPlacerTool.EnemyBodyColliderType.Box;
private static readonly (string id, string name)[] EnemyTypes = private static readonly (string id, string name)[] EnemyTypes =
{ {
("E001", "草蛭"), ("E001", "草蛭"),
@@ -195,6 +198,8 @@ namespace BaseGames.Editor
var jumpGroup = MakeActionGroup(); var jumpGroup = MakeActionGroup();
jumpGroup.Add(MakeJumpButton("Data Hub武器/技能/形态)", DataHubWindow.Open)); jumpGroup.Add(MakeJumpButton("Data Hub武器/技能/形态)", DataHubWindow.Open));
jumpGroup.Add(MakeJumpButton("武器 HitBox 向导", WeaponHitBoxWizard.Open));
jumpGroup.Add(MakeJumpButton("技能 HitBox 向导", SkillHitBoxWizard.Open));
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu)); jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
root.Add(jumpGroup); root.Add(jumpGroup);
@@ -249,6 +254,7 @@ namespace BaseGames.Editor
var jumpGroup = MakeActionGroup(); var jumpGroup = MakeActionGroup();
jumpGroup.Add(MakeJumpButton("Data Hub敌人数据", DataHubWindow.Open)); jumpGroup.Add(MakeJumpButton("Data Hub敌人数据", DataHubWindow.Open));
jumpGroup.Add(MakeJumpButton("武器 HitBox 向导", WeaponHitBoxWizard.Open));
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu)); jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
root.Add(jumpGroup); root.Add(jumpGroup);
@@ -286,11 +292,16 @@ namespace BaseGames.Editor
container.Add(MakeSeparator()); container.Add(MakeSeparator());
container.Add(MakeSectionHeader("▶ 场景搭建")); container.Add(MakeSectionHeader("▶ 场景搭建"));
container.Add(MakeHelpBox("在当前活动场景中放置完整组件树并自动绑定已有 SO。")); container.Add(MakeHelpBox("在当前活动场景中放置完整组件树(含 Visual 子节点对齐 Collider并自动绑定已有 SO。"));
var colliderField = new UnityEngine.UIElements.EnumField("主体碰撞器类型", _enemyBodyCollider);
colliderField.RegisterValueChangedCallback(evt => _enemyBodyCollider = (SceneObjectPlacerTool.EnemyBodyColliderType)evt.newValue);
container.Add(colliderField);
var sceneGroup = MakeActionGroup(); var sceneGroup = MakeActionGroup();
string sceneLabel = $"放置 {id} {name} 到场景"; string sceneLabel = $"放置 {id} {name} 到场景";
sceneGroup.Add(MakeSceneButton(sceneLabel, () => PlaceSpecificEnemy(id))); sceneGroup.Add(MakeSceneButton(sceneLabel, () => PlaceSpecificEnemy(id, _enemyBodyCollider)));
sceneGroup.Add(MakeSceneButton("迁移选中对象 Visual 节点", MigrateSelectedEnemyVisualNode));
container.Add(sceneGroup); container.Add(sceneGroup);
} }
@@ -330,6 +341,7 @@ namespace BaseGames.Editor
var sceneGroup = MakeActionGroup(); var sceneGroup = MakeActionGroup();
sceneGroup.Add(MakeSceneButton("放置嘲风到场景并绑定 SO", SceneObjectPlacerTool.PlaceChaoFeng)); sceneGroup.Add(MakeSceneButton("放置嘲风到场景并绑定 SO", SceneObjectPlacerTool.PlaceChaoFeng));
sceneGroup.Add(MakeSceneButton("迁移选中对象 Visual 节点", MigrateSelectedEnemyVisualNode));
root.Add(sceneGroup); root.Add(sceneGroup);
root.Add(MakeSeparator()); root.Add(MakeSeparator());
@@ -337,6 +349,7 @@ namespace BaseGames.Editor
var jumpGroup = MakeActionGroup(); var jumpGroup = MakeActionGroup();
jumpGroup.Add(MakeJumpButton("Boss 技能序列查看器", BossSkillSequenceWindow.OpenWindow)); jumpGroup.Add(MakeJumpButton("Boss 技能序列查看器", BossSkillSequenceWindow.OpenWindow));
jumpGroup.Add(MakeJumpButton("武器 HitBox 向导", WeaponHitBoxWizard.Open));
jumpGroup.Add(MakeJumpButton("Data HubBoss技能", DataHubWindow.Open)); jumpGroup.Add(MakeJumpButton("Data HubBoss技能", DataHubWindow.Open));
jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu)); jumpGroup.Add(MakeJumpButton("SO 全局校验", SOValidationRunner.ValidateMenu));
root.Add(jumpGroup); root.Add(jumpGroup);
@@ -603,20 +616,45 @@ namespace BaseGames.Editor
$"全部 {id} SO 已创建(已存在的跳过)。\n请放置到场景后检查组件绑定。", "确定"); $"全部 {id} SO 已创建(已存在的跳过)。\n请放置到场景后检查组件绑定。", "确定");
} }
private static void PlaceSpecificEnemy(string id) private static void PlaceSpecificEnemy(string id, SceneObjectPlacerTool.EnemyBodyColliderType bodyCollider)
{ {
switch (id) switch (id)
{ {
case "E001": SceneObjectPlacerTool.PlaceE001_CaoZhi(); break; case "E001": SceneObjectPlacerTool.PlaceE001_CaoZhi(bodyCollider); break;
case "E002": SceneObjectPlacerTool.PlaceE002_HuangZhi(); break; case "E002": SceneObjectPlacerTool.PlaceE002_HuangZhi(bodyCollider); break;
case "E003": SceneObjectPlacerTool.PlaceE003_YouZhi_Enemy(); break; case "E003": SceneObjectPlacerTool.PlaceE003_YouZhi_Enemy(bodyCollider); break;
case "E004": SceneObjectPlacerTool.PlaceE004_ZhiMu_Enemy(); break; case "E004": SceneObjectPlacerTool.PlaceE004_ZhiMu_Enemy(bodyCollider); break;
case "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(); break; case "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(bodyCollider); break;
case "E006": SceneObjectPlacerTool.PlaceE006_Huan(); break; case "E006": SceneObjectPlacerTool.PlaceE006_Huan(bodyCollider); break;
default: SceneObjectPlacerTool.PlaceEnemy(); break; default:
Debug.LogError($"[CharacterWizardWindow] 未注册的敌人 id '{id}',请在 SceneObjectPlacerTool 中实现对应 PlaceE...() 方法并注册。");
SceneObjectPlacerTool.PlaceEnemy(bodyCollider);
break;
} }
} }
private static void MigrateSelectedEnemyVisualNode()
{
var targets = Selection.gameObjects;
if (targets == null || targets.Length == 0)
{
EditorUtility.DisplayDialog("迁移 Visual 节点",
"请先在 Hierarchy 中选中一个或多个 Enemy 对象。", "确定");
return;
}
int count = 0;
foreach (var go in targets)
{
var movement = go.GetComponent<EnemyMovement>();
if (movement != null) { movement.SetupVisualNode(); count++; }
}
if (count == 0)
EditorUtility.DisplayDialog("迁移 Visual 节点",
"选中的对象中没有找到 EnemyMovement 组件。", "确定");
else
Debug.Log($"[CharacterWizardWindow] 已对 {count} 个对象完成 Visual 节点迁移。");
}
// ── SO 资产工厂:嘲风 Boss ───────────────────────────────────────────── // ── SO 资产工厂:嘲风 Boss ─────────────────────────────────────────────
private static void CreateChaoFengStatsSO() private static void CreateChaoFengStatsSO()

View File

@@ -0,0 +1,115 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Editor.Combat
{
/// <summary>
/// HitBox 自定义 Inspector。
/// 在"多形状碰撞体"面板中提供快捷按钮,一键创建带 HitBoxColliderProxy 的子形状节点。
///
/// 生成子节点结构:
/// [HitBoxParent] ← HitBox 组件(可无 Collider2D
/// ├── [Shape_Box] ← BoxCollider2D(isTrigger) + HitBoxColliderProxy
/// ├── [Shape_Circle] ← CircleCollider2D(isTrigger) + HitBoxColliderProxy
/// ├── [Shape_Capsule] ← CapsuleCollider2D(isTrigger) + HitBoxColliderProxy
/// └── [Shape_Polygon] ← PolygonCollider2D(isTrigger) + HitBoxColliderProxy
/// </summary>
[CustomEditor(typeof(HitBox))]
public class HitBoxEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("── 多形状碰撞体 ──", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"在子节点添加 HitBoxColliderProxy + Collider2D 以组合多形状判定盒。\n" +
"子节点 Layer 自动继承本节点Collider2D.IsTrigger 自动设为 true。",
MessageType.Info);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("+ Box"))
AddShapeChild((HitBox)target, ShapeKind.Box);
if (GUILayout.Button("+ Circle"))
AddShapeChild((HitBox)target, ShapeKind.Circle);
if (GUILayout.Button("+ Capsule"))
AddShapeChild((HitBox)target, ShapeKind.Capsule);
if (GUILayout.Button("+ Polygon"))
AddShapeChild((HitBox)target, ShapeKind.Polygon);
EditorGUILayout.EndHorizontal();
if (Application.isPlaying)
{
EditorGUILayout.Space(2);
int proxyCount = ((HitBox)target).GetComponentsInChildren<HitBoxColliderProxy>(true).Length;
EditorGUILayout.LabelField($"子代 HitBoxColliderProxy 数:{proxyCount}", EditorStyles.miniLabel);
Repaint();
}
}
// ── 内部工具 ──────────────────────────────────────────────────────────
private enum ShapeKind { Box, Circle, Capsule, Polygon }
private static void AddShapeChild(HitBox hitBox, ShapeKind kind)
{
var child = new GameObject($"[Shape_{kind}]");
Undo.RegisterCreatedObjectUndo(child, $"Add HitBox {kind} Shape");
child.transform.SetParent(hitBox.transform, false);
child.layer = hitBox.gameObject.layer;
Collider2D col = kind switch
{
ShapeKind.Box => CreateBox(child),
ShapeKind.Circle => CreateCircle(child),
ShapeKind.Capsule => CreateCapsule(child),
ShapeKind.Polygon => CreatePolygon(child),
_ => CreateBox(child),
};
col.isTrigger = true;
Undo.AddComponent<HitBoxColliderProxy>(child);
Selection.activeGameObject = child;
EditorGUIUtility.PingObject(child);
}
private static BoxCollider2D CreateBox(GameObject go)
{
var c = Undo.AddComponent<BoxCollider2D>(go);
c.size = new Vector2(1f, 0.5f);
return c;
}
private static CircleCollider2D CreateCircle(GameObject go)
{
var c = Undo.AddComponent<CircleCollider2D>(go);
c.radius = 0.4f;
return c;
}
private static CapsuleCollider2D CreateCapsule(GameObject go)
{
var c = Undo.AddComponent<CapsuleCollider2D>(go);
c.size = new Vector2(0.5f, 1f);
return c;
}
private static PolygonCollider2D CreatePolygon(GameObject go)
{
var c = Undo.AddComponent<PolygonCollider2D>(go);
c.SetPath(0, new Vector2[]
{
new( 0f, 0.3f),
new( 0.5f, 0f ),
new( 0f, -0.3f),
new(-0.5f, 0f ),
});
return c;
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 414d2528016107148929f4d65d5507e7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -6,9 +6,14 @@ using BaseGames.Combat;
namespace BaseGames.Editor namespace BaseGames.Editor
{ {
/// <summary> /// <summary>
/// HurtBox 运行时注入状态可视化面板 /// HurtBox 自定义 Inspector
/// 通过 HurtBox 上的 Editor* 属性读取注入状态,以颜色区分是否注入成功 /// ① 提供"多形状受击区域"快捷按钮:一键创建带 HurtBox 的子形状节点(共享同一 HP 池)
/// 绿色 = 注入完成;橙色 = 未注入(该能力静默不生效);灰色 = 非 PlayMode。 /// ② 运行时注入状态可视化面板:绿色 = 注入完成;橙色 = 未注入;灰色 = 非 PlayMode。
///
/// 生成子节点结构:
/// [HurtBoxParent] ← HurtBox + 任意 Collider2D主受击区
/// ├── [HurtShape_Box] ← BoxCollider2D(isTrigger) + HurtBox共享 HP
/// └── [HurtShape_Circle] ← CircleCollider2D(isTrigger) + HurtBox共享 HP
/// </summary> /// </summary>
[CustomEditor(typeof(HurtBox))] [CustomEditor(typeof(HurtBox))]
public class HurtBoxEditor : UnityEditor.Editor public class HurtBoxEditor : UnityEditor.Editor
@@ -27,6 +32,24 @@ namespace BaseGames.Editor
{ {
DrawDefaultInspector(); DrawDefaultInspector();
// ── 多形状受击区域 ──────────────────────────────────────────────
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("── 多形状受击区域 ──", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"在子节点添加 HurtBox + Collider2D 以组合多形状受击区域,各子节点共享同一 HP 池。\n" +
"子节点 Layer 自动继承本节点HurtBoxOwnerGuard 防止同一次攻击重复扣血。",
MessageType.Info);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("+ Box"))
AddShapeChild((HurtBox)target, ShapeKind.Box);
if (GUILayout.Button("+ Circle"))
AddShapeChild((HurtBox)target, ShapeKind.Circle);
if (GUILayout.Button("+ Capsule"))
AddShapeChild((HurtBox)target, ShapeKind.Capsule);
EditorGUILayout.EndHorizontal();
// ── 运行时注入状态 ──────────────────────────────────────────────
EditorGUILayout.Space(4); EditorGUILayout.Space(4);
EditorGUILayout.LabelField("── 运行时注入状态 ──", EditorStyles.boldLabel); EditorGUILayout.LabelField("── 运行时注入状态 ──", EditorStyles.boldLabel);
@@ -59,6 +82,34 @@ namespace BaseGames.Editor
// 持续刷新(避免只显示初始状态) // 持续刷新(避免只显示初始状态)
if (Application.isPlaying) Repaint(); if (Application.isPlaying) Repaint();
} }
// ── 子形状创建工具 ─────────────────────────────────────────────────────
private enum ShapeKind { Box, Circle, Capsule }
private static void AddShapeChild(HurtBox hurtBox, ShapeKind kind)
{
var child = new GameObject($"[HurtShape_{kind}]");
Undo.RegisterCreatedObjectUndo(child, $"Add HurtBox {kind} Shape");
child.transform.SetParent(hurtBox.transform, false);
child.layer = hurtBox.gameObject.layer;
// 先加 Collider2D 以满足 HurtBox 的 [RequireComponent(typeof(Collider2D))]
// 再 AddComponent<HurtBox>() 则不会重复创建 Collider2D。
Collider2D col = kind switch
{
ShapeKind.Box => Undo.AddComponent<BoxCollider2D>(child),
ShapeKind.Circle => Undo.AddComponent<CircleCollider2D>(child),
ShapeKind.Capsule => Undo.AddComponent<CapsuleCollider2D>(child),
_ => Undo.AddComponent<BoxCollider2D>(child),
};
col.isTrigger = true;
Undo.AddComponent<HurtBox>(child);
Selection.activeGameObject = child;
EditorGUIUtility.PingObject(child);
}
} }
} }
#endif #endif

View File

@@ -0,0 +1,227 @@
using System.Text;
using UnityEditor;
using UnityEngine;
using BaseGames.Enemies.Perception;
namespace BaseGames.Editor
{
[CustomEditor(typeof(PhysicsPerceptionSystem))]
public sealed class PhysicsPerceptionSystemEditor : UnityEditor.Editor
{
// ── Scene 视图 Gizmo ──────────────────────────────────────────────────
[DrawGizmo(GizmoType.NonSelected | GizmoType.Selected | GizmoType.InSelectionHierarchy)]
static void DrawGizmos(PhysicsPerceptionSystem system, GizmoType gizmoType)
{
var slots = system.EditorSlots;
if (slots == null) return;
bool isSelected = (gizmoType & (GizmoType.Selected | GizmoType.InSelectionHierarchy)) != 0;
Vector3 rootPos = system.transform.position;
float facingSign = system.transform.localScale.x < 0f ? -1f : 1f;
foreach (var slot in slots)
{
if (string.IsNullOrEmpty(slot.slotName)) continue;
Color baseColor = ResolveGizmoColor(slot.gizmoColor);
Color fill = baseColor; fill.a = isSelected ? 0.12f : 0.04f;
Color outline = baseColor; outline.a = isSelected ? 0.90f : 0.40f;
// 每个 Slot 独立检测原点X 随朝向翻转)
Vector3 slotCenter = rootPos + new Vector3(slot.offset.x * facingSign, slot.offset.y, 0f);
switch (slot.type)
{
case PhysicsPerceptionSystem.SlotType.RangeCircle:
if (slot.radius <= 0f) break;
Handles.color = fill;
Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius);
Handles.color = outline;
Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius);
break;
case PhysicsPerceptionSystem.SlotType.FanCast:
if (slot.radius > 0f && slot.fanAngle > 0f)
{
DrawFanGizmo(slotCenter, facingSign, slot, fill, outline);
if (isSelected) DrawOriginDot(slotCenter, outline);
}
break;
case PhysicsPerceptionSystem.SlotType.BoxCast:
if (slot.boxSize.x > 0f && slot.boxSize.y > 0f)
{
DrawBoxGizmo(slotCenter, facingSign, slot, fill, outline);
if (isSelected) DrawOriginDot(slotCenter, outline);
}
break;
// BatchLOS: eye-position marker + optional range disc + runtime ray
case PhysicsPerceptionSystem.SlotType.BatchLOS:
DrawBatchLOSGizmo(system, slotCenter, slot, outline, isSelected);
break;
}
}
Handles.color = Color.white;
}
/// <summary>在 Slot 原点处画一个小十字点,帮助可视化 offset 配置。</summary>
static void DrawOriginDot(Vector3 pos, Color color)
{
Handles.color = color;
Handles.DrawSolidDisc(pos, Vector3.forward, 0.04f);
}
/// <summary>
/// 仅当颜色字段为 Unity 默认值 Color(0,0,0,0)(全零 = 未配置)时,
/// 返回易辨识的紫色回退alpha=1用户明确设置的任何颜色包括近黑色均原样保留。
/// </summary>
static Color ResolveGizmoColor(Color c)
{
bool isDefault = c.r + c.g + c.b + c.a < 0.01f;
return isDefault ? new Color(0.85f, 0.3f, 1.0f, 1.0f) : c;
}
static void DrawBatchLOSGizmo(PhysicsPerceptionSystem system, Vector3 slotCenter,
PhysicsPerceptionSystem.PerceptionSlot slot, Color slotColor, bool isSelected)
{
// slotColor 已经过 ResolveGizmoColor 处理alpha 已由调用方设为 outline alpha。
// 所有 gizmo 元素统一画在 slotCenter由 slot.offset 控制),
// 与 LOSOrigin实际射线起点解耦——gizmo 跟 offset 走。
float facingSign = system.transform.localScale.x < 0f ? -1f : 1f;
// ── 最大检测范围圆slot.radius > 0 时)──
if (slot.radius > 0f)
{
Color fill = slotColor; fill.a = isSelected ? 0.08f : 0.03f;
Handles.color = fill;
Handles.DrawSolidDisc(slotCenter, Vector3.forward, slot.radius);
Color rim = slotColor; rim.a = isSelected ? 0.70f : 0.30f;
Handles.color = rim;
Handles.DrawWireDisc(slotCenter, Vector3.forward, slot.radius);
}
// ── 眼睛中心圆点 ──
Handles.color = slotColor;
Handles.DrawSolidDisc(slotCenter, Vector3.forward, 0.08f);
// ── 朝向指示箭头(沿 facingSign 方向,明确"方向性"视线感知)──
float arrowLen = slot.radius > 0f ? Mathf.Min(slot.radius * 0.6f, 0.6f) : 0.4f;
Vector3 fwdArrow = new Vector3(facingSign * arrowLen, 0f, 0f);
Handles.DrawLine(slotCenter, slotCenter + fwdArrow);
Vector3 arrowTip = slotCenter + fwdArrow;
float headLen = 0.08f;
Handles.DrawLine(arrowTip, arrowTip + new Vector3(-facingSign * headLen, headLen, 0f));
Handles.DrawLine(arrowTip, arrowTip + new Vector3(-facingSign * headLen, -headLen, 0f));
// ── 放射线(仅选中时显示,避免杂乱)──
if (isSelected)
{
float innerR = 0.12f;
float outerR = slot.radius > 0f ? Mathf.Min(slot.radius, 0.30f) : 0.26f;
for (int i = 0; i < 8; i++)
{
float deg = i * 45f;
float rad = deg * Mathf.Deg2Rad;
Vector3 dir = new Vector3(Mathf.Cos(rad), Mathf.Sin(rad), 0f);
Handles.DrawLine(slotCenter + dir * innerR, slotCenter + dir * outerR);
}
}
// ── 运行时:视线连线;绿 = 可见 / 红 = 遮挡 ──
if (!Application.isPlaying) return;
var owner = system.EditorOwner ?? system.GetComponentInParent<BaseGames.Enemies.EnemyBase>();
if (owner == null || owner.PlayerTransform == null) return;
bool visible = owner.IsPlayerVisible();
Color rayCol = visible
? new Color(0.2f, 1.0f, 0.3f, 0.9f)
: new Color(1.0f, 0.3f, 0.3f, 0.45f);
Handles.color = rayCol;
Handles.DrawDottedLine(slotCenter, owner.PlayerTransform.position, 3f);
if (visible)
Handles.DrawSolidDisc(owner.PlayerTransform.position, Vector3.forward, 0.09f);
}
static void DrawFanGizmo(Vector3 center, float facingSign,
PhysicsPerceptionSystem.PerceptionSlot slot, Color fill, Color outline)
{
float halfAngle = slot.fanAngle * 0.5f;
Vector3 fromDir = RotateVec3(new Vector3(facingSign, 0f, 0f), -halfAngle);
Handles.color = fill;
Handles.DrawSolidArc(center, Vector3.forward, fromDir, slot.fanAngle, slot.radius);
Handles.color = outline;
Handles.DrawWireArc(center, Vector3.forward, fromDir, slot.fanAngle, slot.radius);
// Edge rays for clarity
Vector3 edgeL = RotateVec3(new Vector3(facingSign, 0f, 0f), -halfAngle) * slot.radius;
Vector3 edgeR = RotateVec3(new Vector3(facingSign, 0f, 0f), halfAngle) * slot.radius;
Handles.DrawLine(center, center + edgeL);
Handles.DrawLine(center, center + edgeR);
}
static void DrawBoxGizmo(Vector3 center, float facingSign,
PhysicsPerceptionSystem.PerceptionSlot slot, Color fill, Color outline)
{
Vector3 boxCenter = center + new Vector3(slot.boxOffset.x * facingSign, slot.boxOffset.y, 0f);
float hw = slot.boxSize.x * 0.5f;
float hh = slot.boxSize.y * 0.5f;
Vector3[] corners =
{
boxCenter + new Vector3(-hw, -hh, 0f),
boxCenter + new Vector3( hw, -hh, 0f),
boxCenter + new Vector3( hw, hh, 0f),
boxCenter + new Vector3(-hw, hh, 0f),
};
Handles.DrawSolidRectangleWithOutline(corners, fill, outline);
}
static Vector3 RotateVec3(Vector3 v, float angleDeg)
{
float rad = angleDeg * Mathf.Deg2Rad;
float cos = Mathf.Cos(rad);
float sin = Mathf.Sin(rad);
return new Vector3(cos * v.x - sin * v.y, sin * v.x + cos * v.y, 0f);
}
// ── Inspector ─────────────────────────────────────────────────────────
public override void OnInspectorGUI()
{
DrawDefaultInspector();
if (!Application.isPlaying) return;
var system = (PhysicsPerceptionSystem)target;
var detected = system.EditorDetected;
if (detected == null || detected.Count == 0) return;
EditorGUILayout.Space();
EditorGUILayout.LabelField("── 实时检测结果 ──", EditorStyles.boldLabel);
var sb = new StringBuilder();
foreach (var kvp in detected)
{
sb.Clear();
if (kvp.Value.Count > 0)
{
sb.Append("✓");
foreach (var go in kvp.Value)
sb.Append(' ').Append(go != null ? go.name : "null");
}
EditorGUILayout.LabelField(kvp.Key, kvp.Value.Count > 0 ? sb.ToString() : "—");
}
Repaint();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2cd6fa1109af6d04fafb6c1556eea81d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using Animancer; using Animancer;
using BaseGames.Boss; using BaseGames.Boss;
@@ -36,6 +36,9 @@ namespace BaseGames.Editor
/// </summary> /// </summary>
public static class SceneObjectPlacerTool public static class SceneObjectPlacerTool
{ {
// ── 碰撞器类型 ────────────────────────────────────────────────────────
public enum EnemyBodyColliderType { Box, Capsule, Circle }
// ══ 菜单入口 ══════════════════════════════════════════════════════════ // ══ 菜单入口 ══════════════════════════════════════════════════════════
[MenuItem("BaseGames/Scene/Place/Player", priority = 100)] [MenuItem("BaseGames/Scene/Place/Player", priority = 100)]
@@ -246,9 +249,14 @@ namespace BaseGames.Editor
} }
[MenuItem("BaseGames/Scene/Place/Enemy (Basic)", priority = 110)] [MenuItem("BaseGames/Scene/Place/Enemy (Basic)", priority = 110)]
public static void PlaceEnemy() public static void PlaceEnemy() => PlaceEnemy(EnemyBodyColliderType.Box);
public static void PlaceEnemy(EnemyBodyColliderType bodyCollider)
{ {
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place Basic Enemy");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("BasicEnemy"); GameObject go = new GameObject("BasicEnemy");
Undo.RegisterCreatedObjectUndo(go, "Place Enemy"); Undo.RegisterCreatedObjectUndo(go, "Place Enemy");
@@ -259,13 +267,22 @@ namespace BaseGames.Editor
rb.bodyType = RigidbodyType2D.Dynamic; rb.bodyType = RigidbodyType2D.Dynamic;
rb.gravityScale = 2f; rb.gravityScale = 2f;
rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
GetOrAddComponent<CapsuleCollider2D>(go); Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.7f, 0.9f));
GetOrAddComponent<Animator>(go); Transform visual = GetOrCreateChild(go.transform, "Visual");
SetupSpriteRenderer(go); visual.localPosition = (Vector3)(Vector2)body.offset;
GetOrAddComponent<Animator>(visual.gameObject);
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(visual.gameObject);
SpriteRenderer sr = SetupSpriteRenderer(visual.gameObject);
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go); EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go); EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
// HurtBox child // HurtBox child
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -283,38 +300,47 @@ namespace BaseGames.Editor
HitBox hitBox = GetOrAddComponent<HitBox>(hitBodyT.gameObject); HitBox hitBox = GetOrAddComponent<HitBox>(hitBodyT.gameObject);
GetOrAddComponent<BodyContactDamage>(hitBodyT.gameObject); GetOrAddComponent<BodyContactDamage>(hitBodyT.gameObject);
// References // Wire EnemyBase
AssignReference(enemyBase, "_stats", enemyStats, report); AssignReference(enemyBase, "_stats", enemyStats, report);
AssignReference(enemyBase, "_movement", movement, report);
AssignReference(enemyBase, "_animancer", animancer, report);
AssignReference(enemyBase, "_hurtBox", hurtBox, report);
// DamageSourceSO for body contact (optional — create manually if missing)
Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody", "DS_EnemyBody");
if (dmgSrc != null)
AssignReference(hitBox, "_defaultSource", dmgSrc, report);
else
report.Add("未找到 DamageSourceSOHitBox_Body._defaultSource 未绑定。请按规范创建 CMB_DS_EnemyBody.asset。");
// Event channels
AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied"); AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
AssignAsset(enemyBase, "_onPlayerSpawned", report, false, "EVT_PlayerSpawned"); AssignAsset(enemyBase, "_onPlayerSpawned", report, false, "EVT_PlayerSpawned");
AssignAsset(enemyStats, "_onDifficultyChanged", report, false, "EVT_DifficultyChanged"); AssignAsset(enemyStats, "_onDifficultyChanged", report, false, "EVT_DifficultyChanged");
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt"); AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed"); AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
// EnemyStatsSO (optional) // Wire EnemyMovement
Object enemyStatsSO = FindFirstAsset("BasicEnemyStats", "EnemyStatsSO"); AssignReference(movement, "_visualRoot", visual, report);
if (enemyStatsSO != null) AssignReference(movement, "_animancer", animancer, report);
AssignReference(enemyBase, "_statsSO", enemyStatsSO, report); AssignReference(movement, "_spriteRenderer", sr, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
// DamageSourceSO for body contact
Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody", "DS_EnemyBody");
if (dmgSrc != null)
AssignReference(hitBox, "_defaultSource", dmgSrc, report);
else else
report.Add("未找到 EnemyStatsSOEnemyBase._statsSO 未绑定。请在 Data/Enemies/ 创建 ENM_{id}_Stats.asset 后手动指定。"); report.Add("未找到 DamageSourceSOHitBox_Body._defaultSource 未绑定。请创建 CMB_DS_EnemyBody.asset。");
report.Add("行为树、导航参数NavAgent、动画片段需后续手工挂载。"); SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report);
report.Add("★ 指定 EnemyBase._statsSO、_animConfig 资产(按所创建的敌人类型命名)。");
report.Add("★ 挂载行为树 BehaviorTree 组件,指定对应 .asset。");
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("Enemy (Basic)", go, report); MarkDirtyAndLog("Enemy (Basic)", go, report);
} }
[MenuItem("BaseGames/Scene/Place/Boss Enemy", priority = 115)] [MenuItem("BaseGames/Scene/Place/Boss Enemy", priority = 115)]
public static void PlaceBossEnemy() public static void PlaceBossEnemy() => PlaceBossEnemy(EnemyBodyColliderType.Box);
public static void PlaceBossEnemy(EnemyBodyColliderType bodyCollider)
{ {
var report = new List<string>(); var report = new List<string>();
@@ -330,12 +356,13 @@ namespace BaseGames.Editor
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
rb.interpolation = RigidbodyInterpolation2D.Interpolate; rb.interpolation = RigidbodyInterpolation2D.Interpolate;
GetOrAddComponent<CapsuleCollider2D>(go); CreateBodyCollider(go, bodyCollider, new Vector2(1.5f, 2.5f));
GetOrAddComponent<Animator>(go); GetOrAddComponent<Animator>(go);
SetupSpriteRenderer(go); SetupSpriteRenderer(go);
BossBase bossBase = GetOrAddComponent<BossBase>(go); BossBase bossBase = GetOrAddComponent<BossBase>(go);
EnemyStats bossStats = GetOrAddComponent<EnemyStats>(go); EnemyStats bossStats = GetOrAddComponent<EnemyStats>(go);
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
// HurtBox child // HurtBox child
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -373,6 +400,7 @@ namespace BaseGames.Editor
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt"); AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed"); AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report);
report.Add("填写 _bossId。"); report.Add("填写 _bossId。");
report.Add("挂载 BossSkillSequencer 组件并指定技能序列 SO行为树、NavAgent 需手工添加。"); report.Add("挂载 BossSkillSequencer 组件并指定技能序列 SO行为树、NavAgent 需手工添加。");
report.Add("多阶段 Boss 可在此 GameObject 上继续 AddComponent 阶段切换控制器。"); report.Add("多阶段 Boss 可在此 GameObject 上继续 AddComponent 阶段切换控制器。");
@@ -384,7 +412,9 @@ namespace BaseGames.Editor
// ══ 具体敌人快速放置 ════════════════════════════════════════════════════ // ══ 具体敌人快速放置 ════════════════════════════════════════════════════
[MenuItem("BaseGames/Scene/Place/Enemy E001 (草蛭)", priority = 111)] [MenuItem("BaseGames/Scene/Place/Enemy E001 (草蛭)", priority = 111)]
public static void PlaceE001_CaoZhi() public static void PlaceE001_CaoZhi() => PlaceE001_CaoZhi(EnemyBodyColliderType.Box);
public static void PlaceE001_CaoZhi(EnemyBodyColliderType bodyCollider)
{ {
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
@@ -401,19 +431,20 @@ namespace BaseGames.Editor
rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
CapsuleCollider2D body = GetOrAddComponent<CapsuleCollider2D>(go); Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.6f, 0.8f));
body.size = new Vector2(0.6f, 0.8f); Transform visual = GetOrCreateChild(go.transform, "Visual");
visual.localPosition = (Vector3)(Vector2)body.offset;
GetOrAddComponent<Animator>(go); GetOrAddComponent<Animator>(visual.gameObject);
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(go); AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(visual.gameObject);
SpriteRenderer sr1 = SetupSpriteRenderer(go); SpriteRenderer sr1 = SetupSpriteRenderer(visual.gameObject);
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go); EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go); EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go); EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go); GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go); GetOrAddComponent<NavAgent>(go);
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
@@ -453,19 +484,21 @@ namespace BaseGames.Editor
AssignAsset(movement, "_config", report, false, "ENM_E001_Stats"); AssignAsset(movement, "_config", report, false, "ENM_E001_Stats");
AssignAsset(movement, "_animConfig", report, false, "ENM_E001_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_E001_AnimConfig");
AssignReference(movement, "_visualRoot", visual, report);
AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", sr1, report); AssignReference(movement, "_spriteRenderer", sr1, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignAsset(alertAbility, "_config", report, false, "ABL_E001_Alert"); AssignAsset(alertAbility, "_config", report, false, "ABL_E001_Alert");
AssignAsset(chaseAbility, "_config", report, false, "ABL_E001_Chase"); AssignAsset(chaseAbility, "_config", report, false, "ABL_E001_Chase");
AssignReference(chaseAbility, "_contactDamage", bodyContact, report); AssignReference(chaseAbility, "_contactDamage", bodyContact, report);
AssignReference(chaseAbility, "_sensorHub", sensorHub, report);
Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody");
if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report); if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report);
SetupSensorHubSlotNames(sensorHub, new[] { "aggro", "wall_ahead", "ledge" }, report); SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report);
report.Add("★ 在 EnemySensorHub Inspector 中绑定 Sensor 子节点aggro/wall_ahead/ledge。");
report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E001_CaoZhi.asset。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E001_CaoZhi.asset。");
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
@@ -475,7 +508,9 @@ namespace BaseGames.Editor
} }
[MenuItem("BaseGames/Scene/Place/Enemy E002 (簧蛭)", priority = 112)] [MenuItem("BaseGames/Scene/Place/Enemy E002 (簧蛭)", priority = 112)]
public static void PlaceE002_HuangZhi() public static void PlaceE002_HuangZhi() => PlaceE002_HuangZhi(EnemyBodyColliderType.Box);
public static void PlaceE002_HuangZhi(EnemyBodyColliderType bodyCollider)
{ {
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
@@ -490,18 +525,24 @@ namespace BaseGames.Editor
rb.bodyType = RigidbodyType2D.Kinematic; rb.bodyType = RigidbodyType2D.Kinematic;
rb.gravityScale = 0f; rb.gravityScale = 0f;
CapsuleCollider2D body = GetOrAddComponent<CapsuleCollider2D>(go); Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.5f, 0.7f));
body.size = new Vector2(0.5f, 0.7f);
GetOrAddComponent<Animator>(go); // Visual 子节点:挂载精灵 / 动画EnemyMovement 翻转时操作此节点)
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(go); Transform visual = GetOrCreateChild(go.transform, "Visual");
SetupSpriteRenderer(go); visual.localPosition = (Vector3)(Vector2)body.offset;
GetOrAddComponent<Animator>(visual.gameObject);
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(visual.gameObject);
SpriteRenderer sr = SetupSpriteRenderer(visual.gameObject);
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go); EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go); EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
// HurtBox初始禁用附着天花板时不受伤 // HurtBox初始禁用悬挂阶段无法被攻击
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
CapsuleCollider2D hurtCap = GetOrAddComponent<CapsuleCollider2D>(hurtBoxT.gameObject); CapsuleCollider2D hurtCap = GetOrAddComponent<CapsuleCollider2D>(hurtBoxT.gameObject);
@@ -510,24 +551,35 @@ namespace BaseGames.Editor
HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject); HurtBox hurtBox = GetOrAddComponent<HurtBox>(hurtBoxT.gameObject);
hurtBoxT.gameObject.SetActive(false); hurtBoxT.gameObject.SetActive(false);
// AttackHitBox下坠发动时由能力启用 // LandingHitBox落地瞬间 AoE由 CeilingDropAbility 激活
Transform atkT = GetOrCreateChild(go.transform, "AttackHitBox"); Transform landingHitBoxT = GetOrCreateChild(go.transform, "LandingHitBox");
SetLayer(atkT.gameObject, "EnemyHitBox", report); SetLayer(landingHitBoxT.gameObject, "EnemyHitBox", report);
BoxCollider2D atkCol = GetOrAddComponent<BoxCollider2D>(atkT.gameObject); BoxCollider2D landingCol = GetOrAddComponent<BoxCollider2D>(landingHitBoxT.gameObject);
atkCol.isTrigger = true; landingCol.isTrigger = true;
atkCol.size = new Vector2(0.5f, 0.5f); landingCol.size = new Vector2(0.8f, 0.3f);
HitBox atkHitBox = GetOrAddComponent<HitBox>(atkT.gameObject); HitBox landingHitBox = GetOrAddComponent<HitBox>(landingHitBoxT.gameObject);
atkT.gameObject.SetActive(false); landingHitBoxT.gameObject.SetActive(false);
// ContactDamageZone地面巡逻时造成接触伤害落地后由行为树启用
Transform contactT = GetOrCreateChild(go.transform, "ContactDamageZone");
SetLayer(contactT.gameObject, "EnemyHitBox", report);
CircleCollider2D contactCol = GetOrAddComponent<CircleCollider2D>(contactT.gameObject);
contactCol.isTrigger = true;
contactCol.radius = 0.35f;
HitBox contactHitBox = GetOrAddComponent<HitBox>(contactT.gameObject);
GetOrAddComponent<BodyContactDamage>(contactT.gameObject);
contactT.gameObject.SetActive(false);
Transform abilitiesT = GetOrCreateChild(go.transform, "Abilities"); Transform abilitiesT = GetOrCreateChild(go.transform, "Abilities");
Transform strikeT = GetOrCreateChild(abilitiesT, "CeilingHangStrikeAbility"); Transform dropT = GetOrCreateChild(abilitiesT, "CeilingDropAbility");
CeilingHangStrikeAbility strikeAbility = GetOrAddComponent<CeilingHangStrikeAbility>(strikeT.gameObject); CeilingDropAbility dropAbility = GetOrAddComponent<CeilingDropAbility>(dropT.gameObject);
// SOs — assign first so OnValidate doesn't warn during wiring // SOs — assign first so OnValidate doesn't warn during wiring
AssignAsset(enemyBase, "_statsSO", report, false, "ENM_E002_Stats"); AssignAsset(enemyBase, "_statsSO", report, false, "ENM_E002_Stats");
AssignAsset(enemyBase, "_animConfig", report, false, "ENM_E002_AnimConfig"); AssignAsset(enemyBase, "_animConfig", report, false, "ENM_E002_AnimConfig");
AssignReference(enemyBase, "_stats", enemyStats, report); AssignReference(enemyBase, "_stats", enemyStats, report);
AssignReference(enemyBase, "_movement", movement, report);
AssignReference(enemyBase, "_animancer", animancer, report); AssignReference(enemyBase, "_animancer", animancer, report);
AssignReference(enemyBase, "_hurtBox", hurtBox, report); AssignReference(enemyBase, "_hurtBox", hurtBox, report);
AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied"); AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
@@ -536,16 +588,32 @@ namespace BaseGames.Editor
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt"); AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed"); AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
AssignAsset(strikeAbility, "_config", report, false, "ABL_E002_Strike"); AssignAsset(movement, "_config", report, false, "ENM_E002_Stats");
AssignReference(strikeAbility, "_attackHitBox", atkHitBox, report); AssignAsset(movement, "_animConfig", report, false, "ENM_E002_AnimConfig");
AssignReference(strikeAbility, "_hurtBox", hurtBox, report); AssignReference(movement, "_visualRoot", visual, report);
AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", sr, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignReference(dropAbility, "_landingHitBox", landingHitBox, report);
AssignLayerMask(dropAbility, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody");
if (dmgSrc != null) AssignReference(atkHitBox, "_defaultSource", dmgSrc, report); if (dmgSrc != null)
{
AssignReference(landingHitBox, "_defaultSource", dmgSrc, report);
AssignReference(contactHitBox, "_defaultSource", dmgSrc, report);
}
SetupSensorHubSlotNames(sensorHub, new[] { "attack_range" }, report); SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_range" }, report);
report.Add("★ 将此对象放置于天花板,调整位置使 CapsuleCollider 正好贴合天花板底面。"); report.Add("★ 将此对象放置于天花板,调整位置使 CapsuleCollider 正好贴合天花板底面。");
report.Add("★ HurtBox / ContactDamageZone 初始禁用;落地后由行为树激活。");
report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E002_HuangZhi.asset。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E002_HuangZhi.asset。");
report.Add("★ BD 树逻辑建议Idle悬挂→ IsSensorDetecting(aggro) → UseAbility(CeilingDrop) → IsGrounded → Patrol(Pace)。");
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
@@ -554,7 +622,9 @@ namespace BaseGames.Editor
} }
[MenuItem("BaseGames/Scene/Place/Enemy E003 (幼蛭)", priority = 113)] [MenuItem("BaseGames/Scene/Place/Enemy E003 (幼蛭)", priority = 113)]
public static void PlaceE003_YouZhi_Enemy() public static void PlaceE003_YouZhi_Enemy() => PlaceE003_YouZhi_Enemy(EnemyBodyColliderType.Box);
public static void PlaceE003_YouZhi_Enemy(EnemyBodyColliderType bodyCollider)
{ {
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
@@ -569,19 +639,20 @@ namespace BaseGames.Editor
rb.bodyType = RigidbodyType2D.Kinematic; rb.bodyType = RigidbodyType2D.Kinematic;
rb.gravityScale = 0f; rb.gravityScale = 0f;
CapsuleCollider2D body = GetOrAddComponent<CapsuleCollider2D>(go); Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.5f, 0.6f));
body.size = new Vector2(0.5f, 0.6f); Transform visual = GetOrCreateChild(go.transform, "Visual");
visual.localPosition = (Vector3)(Vector2)body.offset;
GetOrAddComponent<Animator>(go); GetOrAddComponent<Animator>(visual.gameObject);
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(go); AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(visual.gameObject);
SpriteRenderer sr3 = SetupSpriteRenderer(go); SpriteRenderer sr3 = SetupSpriteRenderer(visual.gameObject);
E003_YouZhi enemyBase = GetOrAddComponent<E003_YouZhi>(go); E003_YouZhi enemyBase = GetOrAddComponent<E003_YouZhi>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go); EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go); EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go); GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go); GetOrAddComponent<NavAgent>(go);
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
@@ -618,8 +689,12 @@ namespace BaseGames.Editor
AssignAsset(movement, "_config", report, false, "ENM_E003_Stats"); AssignAsset(movement, "_config", report, false, "ENM_E003_Stats");
AssignAsset(movement, "_animConfig", report, false, "ENM_E003_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_E003_AnimConfig");
AssignReference(movement, "_visualRoot", visual, report);
AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", sr3, report); AssignReference(movement, "_spriteRenderer", sr3, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignAsset(fallAbility, "_config", report, false, "ABL_E003_Fall"); AssignAsset(fallAbility, "_config", report, false, "ABL_E003_Fall");
AssignReference(fallAbility, "_contactDamage", bodyContact, report); AssignReference(fallAbility, "_contactDamage", bodyContact, report);
@@ -627,7 +702,7 @@ namespace BaseGames.Editor
Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody");
if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report); if (dmgSrc != null) AssignReference(contactHitBox, "_defaultSource", dmgSrc, report);
SetupSensorHubSlotNames(sensorHub, new[] { "aggro" }, report); SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report);
report.Add("★ 将此对象放置于天花板下方E003_YouZhi 会在 OnSpawn/ActivateFromCeiling 时执行下坠。"); report.Add("★ 将此对象放置于天花板下方E003_YouZhi 会在 OnSpawn/ActivateFromCeiling 时执行下坠。");
report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E003_YouZhi.asset。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E003_YouZhi.asset。");
@@ -638,7 +713,9 @@ namespace BaseGames.Editor
} }
[MenuItem("BaseGames/Scene/Place/Enemy E004 (蛭母)", priority = 114)] [MenuItem("BaseGames/Scene/Place/Enemy E004 (蛭母)", priority = 114)]
public static void PlaceE004_ZhiMu_Enemy() public static void PlaceE004_ZhiMu_Enemy() => PlaceE004_ZhiMu_Enemy(EnemyBodyColliderType.Box);
public static void PlaceE004_ZhiMu_Enemy(EnemyBodyColliderType bodyCollider)
{ {
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
@@ -655,12 +732,12 @@ namespace BaseGames.Editor
rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
CapsuleCollider2D body = GetOrAddComponent<CapsuleCollider2D>(go); Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.8f, 1.2f));
body.size = new Vector2(0.8f, 1.2f); Transform visual = GetOrCreateChild(go.transform, "Visual");
visual.localPosition = (Vector3)(Vector2)body.offset;
GetOrAddComponent<Animator>(go); GetOrAddComponent<Animator>(visual.gameObject);
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(go); AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(visual.gameObject);
SpriteRenderer sr4 = SetupSpriteRenderer(go); SpriteRenderer sr4 = SetupSpriteRenderer(visual.gameObject);
E004_ZhiMu enemyBase = GetOrAddComponent<E004_ZhiMu>(go); E004_ZhiMu enemyBase = GetOrAddComponent<E004_ZhiMu>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go); EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
@@ -668,7 +745,8 @@ namespace BaseGames.Editor
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go); EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go); GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go); GetOrAddComponent<NavAgent>(go);
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
@@ -732,8 +810,12 @@ namespace BaseGames.Editor
AssignAsset(movement, "_config", report, false, "ENM_E004_Stats"); AssignAsset(movement, "_config", report, false, "ENM_E004_Stats");
AssignAsset(movement, "_animConfig", report, false, "ENM_E004_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_E004_AnimConfig");
AssignReference(movement, "_visualRoot", visual, report);
AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", sr4, report); AssignReference(movement, "_spriteRenderer", sr4, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignAsset(biteAbl,"_config", report, false, "ABL_E004_Bite"); AssignAsset(biteAbl,"_config", report, false, "ABL_E004_Bite");
AssignAsset(slamAbl, "_config", report, false, "ABL_E004_Slam"); AssignAsset(slamAbl, "_config", report, false, "ABL_E004_Slam");
@@ -745,7 +827,6 @@ namespace BaseGames.Editor
AssignReference(slamAbl, "_hitBox", slamHitBox, report); AssignReference(slamAbl, "_hitBox", slamHitBox, report);
AssignReference(acidAbl, "_muzzle", acidMuzzleT, report); AssignReference(acidAbl, "_muzzle", acidMuzzleT, report);
AssignReference(chargeAbl, "_chargeHitBox", chargeHitBox, report); AssignReference(chargeAbl, "_chargeHitBox", chargeHitBox, report);
AssignReference(chaseAbl, "_sensorHub", sensorHub, report);
Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody");
if (dmgSrc != null) if (dmgSrc != null)
@@ -755,7 +836,7 @@ namespace BaseGames.Editor
AssignReference(chargeHitBox, "_defaultSource", dmgSrc, report); AssignReference(chargeHitBox, "_defaultSource", dmgSrc, report);
} }
SetupSensorHubSlotNames(sensorHub, new[] { "aggro", "los" }, report); SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report);
report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E004_ZhiMu.asset。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E004_ZhiMu.asset。");
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
@@ -765,7 +846,9 @@ namespace BaseGames.Editor
} }
[MenuItem("BaseGames/Scene/Place/Enemy E005 (肥蛭)", priority = 115)] [MenuItem("BaseGames/Scene/Place/Enemy E005 (肥蛭)", priority = 115)]
public static void PlaceE005_FeiZhi_Enemy() public static void PlaceE005_FeiZhi_Enemy() => PlaceE005_FeiZhi_Enemy(EnemyBodyColliderType.Box);
public static void PlaceE005_FeiZhi_Enemy(EnemyBodyColliderType bodyCollider)
{ {
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
@@ -782,19 +865,20 @@ namespace BaseGames.Editor
rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
CapsuleCollider2D body = GetOrAddComponent<CapsuleCollider2D>(go); Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.9f, 1.0f));
body.size = new Vector2(0.9f, 1.0f); Transform visual = GetOrCreateChild(go.transform, "Visual");
visual.localPosition = (Vector3)(Vector2)body.offset;
GetOrAddComponent<Animator>(go); GetOrAddComponent<Animator>(visual.gameObject);
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(go); AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(visual.gameObject);
SpriteRenderer sr5 = SetupSpriteRenderer(go); SpriteRenderer sr5 = SetupSpriteRenderer(visual.gameObject);
E005_FeiZhi enemyBase = GetOrAddComponent<E005_FeiZhi>(go); E005_FeiZhi enemyBase = GetOrAddComponent<E005_FeiZhi>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go); EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go); EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go); GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go); GetOrAddComponent<NavAgent>(go);
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
@@ -835,8 +919,12 @@ namespace BaseGames.Editor
AssignAsset(movement, "_config", report, false, "ENM_E005_Stats"); AssignAsset(movement, "_config", report, false, "ENM_E005_Stats");
AssignAsset(movement, "_animConfig", report, false, "ENM_E005_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_E005_AnimConfig");
AssignReference(movement, "_visualRoot", visual, report);
AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", sr5, report); AssignReference(movement, "_spriteRenderer", sr5, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignAsset(biteAbl, "_config", report, false, "ABL_E005_Bite"); AssignAsset(biteAbl, "_config", report, false, "ABL_E005_Bite");
AssignAsset(acidAbl, "_config", report, false, "ABL_E005_Acid"); AssignAsset(acidAbl, "_config", report, false, "ABL_E005_Acid");
@@ -847,7 +935,7 @@ namespace BaseGames.Editor
Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody");
if (dmgSrc != null) AssignReference(biteHitBox, "_defaultSource", dmgSrc, report); if (dmgSrc != null) AssignReference(biteHitBox, "_defaultSource", dmgSrc, report);
SetupSensorHubSlotNames(sensorHub, new[] { "aggro" }, report); SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report);
report.Add("★ 在 E005_FeiZhi._deathPreClip 上添加 AnimationEvent 调用 SpawnProjectile(\"spawn_e003\")。"); report.Add("★ 在 E005_FeiZhi._deathPreClip 上添加 AnimationEvent 调用 SpawnProjectile(\"spawn_e003\")。");
report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E005_FeiZhi.asset。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E005_FeiZhi.asset。");
@@ -858,7 +946,9 @@ namespace BaseGames.Editor
} }
[MenuItem("BaseGames/Scene/Place/Enemy E006 (讙)", priority = 116)] [MenuItem("BaseGames/Scene/Place/Enemy E006 (讙)", priority = 116)]
public static void PlaceE006_Huan() public static void PlaceE006_Huan() => PlaceE006_Huan(EnemyBodyColliderType.Box);
public static void PlaceE006_Huan(EnemyBodyColliderType bodyCollider)
{ {
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
@@ -875,19 +965,20 @@ namespace BaseGames.Editor
rb.constraints = RigidbodyConstraints2D.FreezeRotation; rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
CapsuleCollider2D body = GetOrAddComponent<CapsuleCollider2D>(go); Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(0.7f, 1.0f));
body.size = new Vector2(0.7f, 1.0f); Transform visual = GetOrCreateChild(go.transform, "Visual");
visual.localPosition = (Vector3)(Vector2)body.offset;
GetOrAddComponent<Animator>(go); GetOrAddComponent<Animator>(visual.gameObject);
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(go); AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(visual.gameObject);
SpriteRenderer sr6 = SetupSpriteRenderer(go); SpriteRenderer sr6 = SetupSpriteRenderer(visual.gameObject);
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go); EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(go);
EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go); EnemyStats enemyStats = GetOrAddComponent<EnemyStats>(go);
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go); EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go); GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go); GetOrAddComponent<NavAgent>(go);
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report); SetLayer(hurtBoxT.gameObject, "EnemyHurtBox", report);
@@ -934,15 +1025,18 @@ namespace BaseGames.Editor
AssignAsset(movement, "_config", report, false, "ENM_E006_Stats"); AssignAsset(movement, "_config", report, false, "ENM_E006_Stats");
AssignAsset(movement, "_animConfig", report, false, "ENM_E006_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_E006_AnimConfig");
AssignReference(movement, "_visualRoot", visual, report);
AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", sr6, report); AssignReference(movement, "_spriteRenderer", sr6, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignAsset(leapAbl,"_config", report, false, "ABL_E006_Leap"); AssignAsset(leapAbl,"_config", report, false, "ABL_E006_Leap");
AssignAsset(chaseAbl, "_config", report, false, "ABL_E006_Chase"); AssignAsset(chaseAbl, "_config", report, false, "ABL_E006_Chase");
AssignReference(leapAbl, "_landingHitBox", landHitBox, report); AssignReference(leapAbl, "_landingHitBox", landHitBox, report);
AssignReference(chaseAbl, "_contactDamage", bodyContact, report); AssignReference(chaseAbl, "_contactDamage", bodyContact, report);
AssignReference(chaseAbl, "_sensorHub", sensorHub, report);
Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody"); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody");
if (dmgSrc != null) if (dmgSrc != null)
@@ -951,7 +1045,7 @@ namespace BaseGames.Editor
AssignReference(landHitBox, "_defaultSource", dmgSrc, report); AssignReference(landHitBox, "_defaultSource", dmgSrc, report);
} }
SetupSensorHubSlotNames(sensorHub, new[] { "aggro", "wall_ahead", "ledge" }, report); SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "los" }, report);
report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E006_Huan.asset。"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定 E006_Huan.asset。");
Undo.CollapseUndoOperations(undoGroup); Undo.CollapseUndoOperations(undoGroup);
@@ -961,7 +1055,9 @@ namespace BaseGames.Editor
} }
[MenuItem("BaseGames/Scene/Place/Boss 嘲风 (ChaoFeng)", priority = 117)] [MenuItem("BaseGames/Scene/Place/Boss 嘲风 (ChaoFeng)", priority = 117)]
public static void PlaceChaoFeng() public static void PlaceChaoFeng() => PlaceChaoFeng(EnemyBodyColliderType.Box);
public static void PlaceChaoFeng(EnemyBodyColliderType bodyCollider)
{ {
var report = new List<string>(); var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup(); int undoGroup = Undo.GetCurrentGroup();
@@ -979,12 +1075,12 @@ namespace BaseGames.Editor
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
rb.interpolation = RigidbodyInterpolation2D.Interpolate; rb.interpolation = RigidbodyInterpolation2D.Interpolate;
CapsuleCollider2D body = GetOrAddComponent<CapsuleCollider2D>(go); Collider2D body = CreateBodyCollider(go, bodyCollider, new Vector2(1.2f, 2.0f));
body.size = new Vector2(1.2f, 2.0f); Transform visual = GetOrCreateChild(go.transform, "Visual");
visual.localPosition = (Vector3)(Vector2)body.offset;
GetOrAddComponent<Animator>(go); GetOrAddComponent<Animator>(visual.gameObject);
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(go); AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(visual.gameObject);
SpriteRenderer srBoss = SetupSpriteRenderer(go); SpriteRenderer srBoss = SetupSpriteRenderer(visual.gameObject);
ChaoFengBoss bossBase = GetOrAddComponent<ChaoFengBoss>(go); ChaoFengBoss bossBase = GetOrAddComponent<ChaoFengBoss>(go);
EnemyStats bossStats = GetOrAddComponent<EnemyStats>(go); EnemyStats bossStats = GetOrAddComponent<EnemyStats>(go);
@@ -992,10 +1088,11 @@ namespace BaseGames.Editor
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go); EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go); GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go); GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
BossSkillExecutor skillExec = GetOrAddComponent<BossSkillExecutor>(go); BossSkillExecutor skillExec = GetOrAddComponent<BossSkillExecutor>(go);
ChaoFengFloatController floatCtrl = GetOrAddComponent<ChaoFengFloatController>(go); ChaoFengFloatController floatCtrl = GetOrAddComponent<ChaoFengFloatController>(go);
ChaoFengKnockdownCounter knockdown = GetOrAddComponent<ChaoFengKnockdownCounter>(go); ChaoFengKnockdownCounter knockdown = GetOrAddComponent<ChaoFengKnockdownCounter>(go);
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); PhysicsPerceptionSystem sensorHub = GetOrAddComponent<PhysicsPerceptionSystem>(go);
// HurtBox // HurtBox
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -1042,8 +1139,12 @@ namespace BaseGames.Editor
AssignAsset(movement, "_config", report, false, "ENM_ChaoFeng_Stats"); AssignAsset(movement, "_config", report, false, "ENM_ChaoFeng_Stats");
AssignAsset(movement, "_animConfig", report, false, "ENM_ChaoFeng_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_ChaoFeng_AnimConfig");
AssignReference(movement, "_visualRoot", visual, report);
AssignReference(movement, "_animancer", animancer, report); AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", srBoss, report); AssignReference(movement, "_spriteRenderer", srBoss, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
// Collect BossSkillSOs and assign to executor // Collect BossSkillSOs and assign to executor
var skillAssets = new System.Collections.Generic.List<Object>(); var skillAssets = new System.Collections.Generic.List<Object>();
@@ -1064,7 +1165,7 @@ namespace BaseGames.Editor
if (hb != null) AssignReference(hb, "_defaultSource", dmgSrc, report); if (hb != null) AssignReference(hb, "_defaultSource", dmgSrc, report);
} }
SetupSensorHubSlotNames(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report); SetupPerceptionSystemSlots(sensorHub, new[] { "aggro", "attack_melee", "attack_range", "los" }, report);
report.Add("★ 设置 BossSkillExecutor._bossId = \"ChaoFeng\"。"); report.Add("★ 设置 BossSkillExecutor._bossId = \"ChaoFeng\"。");
report.Add("★ 将各 Phase1 HitBox 引用拖入 BossSkillExecutor._hitBoxes 数组。"); report.Add("★ 将各 Phase1 HitBox 引用拖入 BossSkillExecutor._hitBoxes 数组。");
@@ -1106,20 +1207,56 @@ namespace BaseGames.Editor
} }
/// <summary> /// <summary>
/// 设置 EnemySensorHub._slots 的 slotName 字段Sensor 引用需在 Inspector 中手工绑定)。 /// 在 <see cref="PhysicsPerceptionSystem"/> 上预填充 <c>_slots</c> 数组,
/// 根据 slotName 自动选择类型、半径、检测层及 GizmoColor。
/// </summary> /// </summary>
private static void SetupSensorHubSlotNames(EnemySensorHub hub, string[] slotNames, List<string> report) private static void SetupPerceptionSystemSlots(PhysicsPerceptionSystem system, string[] slotNames, List<string> report)
{ {
var so = new SerializedObject(hub); var so = new SerializedObject(system);
var slots = so.FindProperty("_slots"); var slots = so.FindProperty("_slots");
if (slots == null || !slots.isArray) if (slots == null || !slots.isArray)
{ {
report?.Add("EnemySensorHub._slots 字段未找到,传感器槽位需手工配置。"); report?.Add("PhysicsPerceptionSystem._slots 字段未找到,请检查脚本序列化。");
return; return;
} }
int playerLayer = LayerMask.GetMask("Player");
slots.arraySize = slotNames.Length; slots.arraySize = slotNames.Length;
for (int i = 0; i < slotNames.Length; i++) for (int i = 0; i < slotNames.Length; i++)
slots.GetArrayElementAtIndex(i).FindPropertyRelative("slotName").stringValue = slotNames[i]; {
var elem = slots.GetArrayElementAtIndex(i);
string name = slotNames[i];
elem.FindPropertyRelative("slotName").stringValue = name;
int enumIdx = 0; // RangeCircle
float radius = 3f;
int layer = playerLayer;
switch (name)
{
case "aggro": enumIdx = 0; radius = 5f; layer = playerLayer; break;
case "los": enumIdx = 1; radius = 0f; layer = 0; break;
case "attack_melee":enumIdx = 0; radius = 1.5f; layer = playerLayer; break;
case "attack_range":enumIdx = 0; radius = 8f; layer = playerLayer; break;
}
elem.FindPropertyRelative("type").enumValueIndex = enumIdx;
elem.FindPropertyRelative("radius").floatValue = radius;
elem.FindPropertyRelative("detectLayer").intValue = layer;
// 各 slot 分配语义化默认颜色,可在 Inspector 中按需覆盖
Color defaultColor = name switch
{
"aggro" => new Color(1.00f, 0.60f, 0.10f, 1f), // 橙
"los" => new Color(0.00f, 0.80f, 1.00f, 1f), // 青
"attack_melee" => new Color(1.00f, 0.20f, 0.20f, 1f), // 红
"attack_range" => new Color(1.00f, 0.40f, 0.60f, 1f), // 粉红
_ => Color.clear, // 未知 slot 回退为紫色
};
elem.FindPropertyRelative("gizmoColor").colorValue = defaultColor;
}
so.ApplyModifiedPropertiesWithoutUndo(); so.ApplyModifiedPropertiesWithoutUndo();
} }
@@ -1674,6 +1811,25 @@ namespace BaseGames.Editor
return Vector3.zero; return Vector3.zero;
} }
private static Collider2D CreateBodyCollider(GameObject go, EnemyBodyColliderType type, Vector2 size)
{
switch (type)
{
case EnemyBodyColliderType.Capsule:
var cap = GetOrAddComponent<CapsuleCollider2D>(go);
cap.size = size;
return cap;
case EnemyBodyColliderType.Circle:
var cir = GetOrAddComponent<CircleCollider2D>(go);
cir.radius = Mathf.Min(size.x, size.y) * 0.5f;
return cir;
default: // Box
var box = GetOrAddComponent<BoxCollider2D>(go);
box.size = size;
return box;
}
}
private static T GetOrAddComponent<T>(GameObject go) where T : Component private static T GetOrAddComponent<T>(GameObject go) where T : Component
{ {
T comp = go.GetComponent<T>(); T comp = go.GetComponent<T>();

View File

@@ -37,6 +37,8 @@ namespace BaseGames.Editor
/// · HazardHitBox ↔ EnemyHurtBox → 应碰撞(环境危险伤害敌人,阵营中立) /// · HazardHitBox ↔ EnemyHurtBox → 应碰撞(环境危险伤害敌人,阵营中立)
/// · HazardHitBox ↔ PlayerHitBox → 应忽略(环境不触发拼刀) /// · HazardHitBox ↔ PlayerHitBox → 应忽略(环境不触发拼刀)
/// · HazardHitBox ↔ EnemyHitBox → 应忽略(环境不触发拼刀) /// · HazardHitBox ↔ EnemyHitBox → 应忽略(环境不触发拼刀)
/// · Player ↔ Enemy → 应忽略(角色身体可重叠,不互相阻挡;伤害检测由 HitBox/HurtBox 层负责)
/// · Enemy ↔ Enemy → 应忽略(敌人身体可重叠,不互相阻挡;伤害检测由 HitBox/HurtBox 层负责)
/// </summary> /// </summary>
public static class Physics2DLayerReport public static class Physics2DLayerReport
{ {
@@ -67,6 +69,8 @@ namespace BaseGames.Editor
new("HazardHitBox", "EnemyHurtBox", true, "环境危险伤害敌人(阵营中立)"), new("HazardHitBox", "EnemyHurtBox", true, "环境危险伤害敌人(阵营中立)"),
new("HazardHitBox", "PlayerHitBox", false, "环境不触发拼刀"), new("HazardHitBox", "PlayerHitBox", false, "环境不触发拼刀"),
new("HazardHitBox", "EnemyHitBox", false, "环境不触发拼刀"), new("HazardHitBox", "EnemyHitBox", false, "环境不触发拼刀"),
new("Player", "Enemy", false, "角色身体可重叠,不互相阻挡(伤害检测由 HitBox/HurtBox 层负责)"),
new("Enemy", "Enemy", false, "敌人身体可重叠,不互相阻挡(伤害检测由 HitBox/HurtBox 层负责)"),
}; };
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -197,7 +201,7 @@ namespace BaseGames.Editor
} }
/// <summary> /// <summary>
/// 通过 SerializedObject 修改 ProjectSettings/DynamicsManager.asset 以持久化 Physics2D 层矩阵。 /// 通过 SerializedObject 修改 ProjectSettings/Physics2DSettings.asset 以持久化 Physics2D 层矩阵。
/// Unity 在退出时也会自动保存,但显式调用可立即落盘。 /// Unity 在退出时也会自动保存,但显式调用可立即落盘。
/// </summary> /// </summary>
private static void SavePhysicsSettings() private static void SavePhysicsSettings()

View File

@@ -79,6 +79,11 @@ namespace BaseGames.Enemies.AI
Vector2 playerPos = _enemy.PlayerTransform.position; Vector2 playerPos = _enemy.PlayerTransform.position;
// 若配置了巡逻区域,且玩家超出追击边界 → 放弃追击(优先级高于纯距离限制)
var zone = _enemy.PatrolZone;
if (zone != null && !zone.ContainsChase(playerPos))
return TaskStatus.Failure;
if (_enemy.IsPlayerVisible()) if (_enemy.IsPlayerVisible())
{ {
// 视线恢复Searching → Tracking恢复奔跑速度 // 视线恢复Searching → Tracking恢复奔跑速度

View File

@@ -54,13 +54,6 @@ namespace BaseGames.Enemies.AI
_stepsRemaining = m_RandomStepCount; _stepsRemaining = m_RandomStepCount;
_pathFailed = false; _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) if (!_subscribed && _enemy.Nav != null)
{ {
_enemy.Nav.OnNavPathFailed += HandlePathFailed; _enemy.Nav.OnNavPathFailed += HandlePathFailed;
@@ -88,10 +81,8 @@ namespace BaseGames.Enemies.AI
public override void OnEnd() public override void OnEnd()
{ {
if (_subscribed && _enemy?.Nav != null) if (_subscribed && _enemy?.Nav != null)
{
_enemy.Nav.OnNavPathFailed -= HandlePathFailed; _enemy.Nav.OnNavPathFailed -= HandlePathFailed;
_subscribed = false; _subscribed = false;
}
_enemy?.StopMovement(); _enemy?.StopMovement();
} }
@@ -147,13 +138,7 @@ namespace BaseGames.Enemies.AI
{ {
_step = InvestigateSubStep.LookAround; _step = InvestigateSubStep.LookAround;
_stepTimer = 0f; _stepTimer = 0f;
_enemy.BeginLookAround();
var ac = _enemy.AnimConfig;
if (_enemy.Animancer != null)
{
var clip = ac?.Investigate ?? ac?.Idle;
if (clip != null) _enemy.Animancer.Play(clip);
}
} }
private void EnterRandomWalk() private void EnterRandomWalk()
@@ -169,13 +154,6 @@ namespace BaseGames.Enemies.AI
_randomTarget = new Vector2(origin.x + dir * dist, origin.y); _randomTarget = new Vector2(origin.x + dir * dist, origin.y);
_enemy.MoveTo(_randomTarget); _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; private void HandlePathFailed() => _pathFailed = true;

View File

@@ -11,7 +11,7 @@ namespace BaseGames.Enemies.AI
/// </summary> /// </summary>
[TaskName("Is Near Edge?")] [TaskName("Is Near Edge?")]
[TaskCategory("BaseGames/Enemy/State")] [TaskCategory("BaseGames/Enemy/State")]
[TaskDescription("检查前方是否有悬崖边缘(基于 SensorToolkit 或 Raycast")] [TaskDescription("检查前方是否有悬崖边缘(基于 EnemyNavAgent Raycast 检测")]
public class BD_IsNearEdge : Conditional public class BD_IsNearEdge : Conditional
{ {
private EnemyBase _enemy; private EnemyBase _enemy;

View File

@@ -0,0 +1,61 @@
#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判断目标坐标是否超出指定区域边界。
///
/// <list type="bullet">
/// <item>未配置 PatrolZone 时返回 Failure表示"无限制",等同于不超界)。</item>
/// <item>超界 → Success区域内 → Failure。</item>
/// </list>
///
/// 典型用法:在 Patrol BT 子树中用 BD_IsOutsideZone 检查敌人坐标,
/// 超出巡逻区域时触发归位序列。
/// </summary>
[TaskName("Is Outside Zone")]
[TaskCategory("BaseGames/Enemy/Zone")]
[TaskDescription("判断敌人/玩家坐标是否超出巡逻或追击区域;无 Zone 时返回 Failure不限制")]
public sealed class BD_IsOutsideZone : Conditional
{
[Tooltip("true = 检查追击区域false = 检查巡逻区域")]
[SerializeField] private bool m_CheckChaseZone = false;
[Tooltip("true = 检查敌人自身坐标false = 检查玩家坐标")]
[SerializeField] private bool m_CheckEnemy = true;
private EnemyBase _enemy;
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
public override TaskStatus OnUpdate()
{
if (_enemy == null) return TaskStatus.Failure;
var zone = _enemy.PatrolZone;
if (zone == null) return TaskStatus.Failure; // 无区域 = 不限制
Vector2 pos;
if (m_CheckEnemy)
{
pos = _enemy.transform.position;
}
else
{
if (_enemy.PlayerTransform == null) return TaskStatus.Failure;
pos = _enemy.PlayerTransform.position;
}
bool inside = m_CheckChaseZone
? zone.ContainsChase(pos)
: zone.ContainsPatrol(pos);
return inside ? TaskStatus.Failure : TaskStatus.Success;
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: bff87912e6defb849bea247df2801172
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -7,23 +7,23 @@ using BaseGames.Enemies.Perception;
namespace BaseGames.Enemies.AI namespace BaseGames.Enemies.AI
{ {
/// <summary> /// <summary>
/// 条件:EnemySensorHub 中名为 slotName 的 Sensor 是否检测到目标。 /// 条件:<see cref="IPerceptionSystem"/> 中名为 slotName 的槽位是否检测到目标。
/// 若 m_Target 为空则使用 EnemyBase.PlayerTransform。 /// 若 m_Target 为空则使用 EnemyBase.PlayerTransform。
/// </summary> /// </summary>
[TaskName("Is Sensor Detecting?")] [TaskName("Is Sensor Detecting?")]
[TaskCategory("BaseGames/Enemy/Perception")] [TaskCategory("BaseGames/Enemy/Perception")]
[TaskDescription("检查 EnemySensorHub 中指定 Sensor 槽是否检测到目标")] [TaskDescription("检查 PhysicsPerceptionSystem 中指定 Sensor 槽是否检测到目标")]
public sealed class BD_IsSensorDetecting : Conditional public sealed class BD_IsSensorDetecting : Conditional
{ {
[SerializeField] private string m_SlotName = "aggro"; [SerializeField] private string m_SlotName = "aggro";
[SerializeField] private bool m_AnyTarget = false; [SerializeField] private bool m_AnyTarget = false;
private EnemySensorHub _hub; private IPerceptionSystem _hub;
private EnemyBase _enemy; private EnemyBase _enemy;
public override void OnAwake() public override void OnAwake()
{ {
_hub = gameObject.GetComponent<EnemySensorHub>(); _hub = gameObject.GetComponent<IPerceptionSystem>();
_enemy = gameObject.GetComponent<EnemyBase>(); _enemy = gameObject.GetComponent<EnemyBase>();
} }

View File

@@ -63,7 +63,7 @@ namespace BaseGames.Enemies.AI
Vector2 toPlayer = ((Vector2)_enemy.PlayerTransform.position - (Vector2)_enemy.transform.position).normalized; Vector2 toPlayer = ((Vector2)_enemy.PlayerTransform.position - (Vector2)_enemy.transform.position).normalized;
float backDir = -Mathf.Sign(toPlayer.x); float backDir = -Mathf.Sign(toPlayer.x);
float speed = m_BackpedaleSpeed > 0f ? m_BackpedaleSpeed : _enemy.Stats.WalkSpeed; float speed = m_BackpedaleSpeed > 0f ? m_BackpedaleSpeed : _enemy.Stats.WalkSpeed;
_enemy.Movement?.MoveWithSpeed(backDir, speed); _enemy.MoveInDirectionWithSpeed(backDir, speed);
} }
else if (sqrDist > _sqrMax) else if (sqrDist > _sqrMax)
{ {
@@ -79,8 +79,7 @@ namespace BaseGames.Enemies.AI
else else
{ {
// 在最优范围内 → 停止导航,原地保持朝向 // 在最优范围内 → 停止导航,原地保持朝向
_enemy.Nav?.StopNavigation(); _enemy.StopMovement();
_enemy.Movement?.StopHorizontal();
} }
return TaskStatus.Running; return TaskStatus.Running;
@@ -88,8 +87,7 @@ namespace BaseGames.Enemies.AI
public override void OnEnd() public override void OnEnd()
{ {
_enemy?.Movement?.StopHorizontal(); _enemy?.StopMovement();
_enemy?.Nav?.StopNavigation();
} }
} }
} }

View File

@@ -19,6 +19,11 @@ namespace BaseGames.Enemies.AI
public override void OnAwake() => _enemy = GetComponent<EnemyBase>(); public override void OnAwake() => _enemy = GetComponent<EnemyBase>();
public override void OnStart()
{
_enemy.SetAiPhase(AiPhase.Chase);
}
public override TaskStatus OnUpdate() public override TaskStatus OnUpdate()
{ {
if (_enemy == null) return TaskStatus.Failure; if (_enemy == null) return TaskStatus.Failure;

View File

@@ -3,61 +3,53 @@ using UnityEngine;
using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks;
using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions;
using BaseGames.Enemies; using BaseGames.Enemies;
using BaseGames.Enemies.Perception;
namespace BaseGames.Enemies.AI namespace BaseGames.Enemies.AI
{ {
/// <summary> /// <summary>
/// BD Action来回踱步巡逻——持续向当前方向移动遇墙或悬崖时自动翻转方向。 /// BD Action来回踱步巡逻——持续向当前方向移动遇墙或悬崖时自动翻转方向。
/// 转向检测通过 <see cref="EnemyMovement.IsWallAhead"/> / <see cref="EnemyMovement.IsLedgeAhead"/>
/// 进行;这两项是 EnemyMovement 内置的物理射线检测不属于感知系统PhysicsPerceptionSystem
/// ///
/// 若需要按预设路点顺序巡逻,请使用 <see cref="BD_PatrolWaypoints"/>(支持 Transform 引用和内联坐标)。 /// 若需要按预设路点顺序巡逻,请使用 <see cref="BD_PatrolWaypoints"/>(支持 Transform 引用和内联坐标)。
///
/// 转向检测优先级:
/// <list type="number">
/// <item>EnemySensorHub "wall_ahead" / "ledge" 槽SensorToolkit已配置时使用</item>
/// <item>Physics2D Raycast 兜底Prefab 未配置 Sensor 时自动启用)</item>
/// </list>
/// </summary> /// </summary>
[TaskName("Patrol (Pace)")] [TaskName("Patrol (Pace)")]
[TaskCategory("BaseGames/Enemy/Movement")] [TaskCategory("BaseGames/Enemy/Movement")]
[TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(SensorToolkit 优先")] [TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(通过 EnemyMovement 物理射线检测,无需感知槽")]
public class BD_Patrol : Action public class BD_Patrol : Action
{ {
[Tooltip("兜底检测地面边缘的向下射线长度m")]
public float edgeCheckLength = 1.2f;
[Tooltip("兜底检测障碍物的水平射线长度m")]
public float wallCheckLength = 0.4f;
[Tooltip("兜底边缘检测射线起点相对角色的前向偏移m")]
public float edgeCheckFwdOffset = 0.3f;
[Tooltip("兜底边缘检测射线起点相对角色的向下偏移m")]
public float edgeCheckDownOffset = 0.1f;
[Tooltip("(兜底)地面/墙壁 LayerMask")]
public LayerMask groundLayer;
private EnemyBase _enemy; private EnemyBase _enemy;
private EnemySensorHub _hub; private EnemyMovement _movement;
private float _dir = 1f; private float _dir = 1f;
private float _flipCooldown; // 翻转后短暂冷却,等待射线刷新到新朝向
// 缓存SensorHub 中对应槽位是否已配置Awake 时查询一次,避免每帧 Dictionary 查找)
private bool _hasWallSensor;
private bool _hasEdgeSensor;
public override void OnAwake() public override void OnAwake()
{ {
_enemy = GetComponent<EnemyBase>(); _enemy = GetComponent<EnemyBase>();
_hub = GetComponent<EnemySensorHub>(); _movement = _enemy?.Movement;
_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 void OnStart()
{
_enemy?.SetAiPhase(AiPhase.Patrol);
// 与敌人实际朝向同步,防止任务重入时 _dir 与朝向不符(如战斗后朝向已改变)
if (_movement != null)
_dir = _movement.FacingDirection;
_flipCooldown = 0f;
}
public override TaskStatus OnUpdate() public override TaskStatus OnUpdate()
{ {
if (_enemy == null) return TaskStatus.Failure; if (_enemy == null) return TaskStatus.Failure;
if (ShouldFlip()) // 翻转冷却期间跳过物理检测(等待射线在新朝向完成刷新)
if (_flipCooldown > 0f)
_flipCooldown -= Time.deltaTime;
else if (ShouldFlip())
{
_dir = -_dir; _dir = -_dir;
_flipCooldown = 0.1f; // ~6 帧缓冲60 fps防止射线残留信号导致抖动
}
_enemy.MoveInDirection(_dir); _enemy.MoveInDirection(_dir);
return TaskStatus.Running; return TaskStatus.Running;
@@ -67,27 +59,9 @@ namespace BaseGames.Enemies.AI
private bool ShouldFlip() private bool ShouldFlip()
{ {
if (_hub != null) // 转身进行中时不重复检测,防止 _dir 在转身期间被残留信号反复翻转
{ if (_movement == null || _movement.IsTurning) return false;
// 有传感器配置:用传感器结果,完全跳过 Raycast return _movement.IsWallAhead || _movement.IsLedgeAhead;
if (_hasWallSensor || _hasEdgeSensor)
{
bool wallHit = _hasWallSensor && _hub.HasAnyDetection(SensorSlotNames.WallAhead);
bool edgeHit = _hasEdgeSensor && _hub.HasAnyDetection(SensorSlotNames.Ledge);
return wallHit || edgeHit;
}
}
// 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);
return hitWall;
} }
} }
} }

View File

@@ -38,11 +38,22 @@ namespace BaseGames.Enemies.AI
[Tooltip("每个路点到达后等待时长s")] [Tooltip("每个路点到达后等待时长s")]
[SerializeField] private float m_WaitAtWaypoint = 0f; [SerializeField] private float m_WaitAtWaypoint = 0f;
// 首次启动重试间隔OnStart() 的第一次 RequestCurrent() 可能因 NavAgent 尚未完成
// UpdateMappedPosition()(脚本执行顺序问题)而静默失败。此值只需大于一帧即可。
private const float InitialRetryDelay = 0.05f;
// 正常巡逻中卡住的重试间隔:必须足够长,使任何在途 PB2d 路径请求
// Pending → Finished → HandlePathRequest 消费)都已处理完毕,避免竞争覆盖。
private const float StuckRetryDelay = 0.5f;
private EnemyBase _enemy; private EnemyBase _enemy;
private int _index = 0; private int _index = 0;
private int _dir = 1; private int _dir = 1;
private float _waitTimer = 0f; private float _waitTimer = 0f;
private bool _waiting = false; private bool _waiting = false;
private float _stuckTimer = 0f;
private bool _pathFailed = false;
private bool _hasMoved = false; // 首次成功开始移动后置 true
// ── 统一路点访问 ──────────────────────────────────────────────────── // ── 统一路点访问 ────────────────────────────────────────────────────
private int WaypointCount => private int WaypointCount =>
@@ -70,7 +81,17 @@ namespace BaseGames.Enemies.AI
if (WaypointCount == 0) return; if (WaypointCount == 0) return;
_waiting = false; _waiting = false;
_waitTimer = 0f; _waitTimer = 0f;
_stuckTimer = 0f;
_pathFailed = false;
_hasMoved = false;
_enemy?.SetAiPhase(AiPhase.Patrol); _enemy?.SetAiPhase(AiPhase.Patrol);
if (_enemy?.Nav != null)
{
_enemy.Nav.OnGoalReached += HandleGoalReached;
_enemy.Nav.OnNavPathFailed += HandlePathFailed;
}
RequestCurrent(); RequestCurrent();
} }
@@ -86,19 +107,56 @@ namespace BaseGames.Enemies.AI
_waiting = false; _waiting = false;
Advance(); Advance();
RequestCurrent(); RequestCurrent();
return TaskStatus.Running;
}
// 兜底重试:用时间门控避免与 PB2d 的 Finished-状态路径请求产生竞争。
// 首次启动前_hasMoved=false使用短延迟处理 NavAgent 位置映射尚未就绪的情况;
// 正常巡逻中使用长延迟,确保在途路径请求已被 HandlePathRequest 消费完毕。
if (_enemy.Nav != null)
{
if (!_enemy.Nav.IsMoving)
{
_stuckTimer += Time.deltaTime;
float retryDelay = _hasMoved ? StuckRetryDelay : InitialRetryDelay;
if (_stuckTimer >= retryDelay)
{
_stuckTimer = 0f;
_pathFailed = false;
RequestCurrent();
}
} }
else else
{ {
Vector2 wp = GetWaypoint(_index); _hasMoved = true;
float sqrDist = ((Vector2)_enemy.transform.position - wp).sqrMagnitude; _stuckTimer = 0f;
}
}
if (sqrDist <= m_ArriveRadius * m_ArriveRadius) return TaskStatus.Running;
}
public override void OnEnd()
{ {
if (_enemy?.Nav != null)
{
_enemy.Nav.OnGoalReached -= HandleGoalReached;
_enemy.Nav.OnNavPathFailed -= HandlePathFailed;
}
_enemy?.StopMovement();
}
// ── 内部辅助 ────────────────────────────────────────────────────────
private void HandleGoalReached()
{
_hasMoved = true;
_stuckTimer = 0f;
_pathFailed = false;
if (m_WaitAtWaypoint > 0f) if (m_WaitAtWaypoint > 0f)
{ {
_waiting = true; _waiting = true;
_waitTimer = m_WaitAtWaypoint; _waitTimer = m_WaitAtWaypoint;
_enemy.StopMovement(); _enemy?.StopMovement();
} }
else else
{ {
@@ -106,18 +164,16 @@ namespace BaseGames.Enemies.AI
RequestCurrent(); RequestCurrent();
} }
} }
else
// 路径失败时不在回调中立即重试——此时 NavAgent.HandlePathRequest 尚未调用
// currentPathRequest.Reset(),直接提交新请求会被随后的 Reset() 覆盖清除。
// 改为设置标志,交由 OnUpdate 的计时兜底在下一帧安全重试。
private void HandlePathFailed()
{ {
_enemy.MoveTo(wp); _pathFailed = true;
_enemy.Movement?.FaceTarget(wp); _stuckTimer = 0f; // 重置计时器,使兜底在 StuckRetryDelay 后触发
}
}
return TaskStatus.Running;
} }
public override void OnEnd() => _enemy?.StopMovement();
// ── 内部辅助 ────────────────────────────────────────────────────────
private void RequestCurrent() private void RequestCurrent()
{ {
if (WaypointCount == 0) return; if (WaypointCount == 0) return;

View File

@@ -35,6 +35,7 @@ namespace BaseGames.Enemies.AI
private bool _reached; private bool _reached;
private bool _pathFailed; private bool _pathFailed;
private bool _subscribed; private bool _subscribed;
private Vector2 _returnTarget;
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>(); public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
@@ -55,12 +56,11 @@ namespace BaseGames.Enemies.AI
float walkSpeed = _enemy.Stats?.WalkSpeed ?? 2f; float walkSpeed = _enemy.Stats?.WalkSpeed ?? 2f;
_enemy.Nav?.SetSpeed(walkSpeed); _enemy.Nav?.SetSpeed(walkSpeed);
// 播放行走动画 // 优先归位到巡逻区域中心;无区域时退回出生点
var ac = _enemy.AnimConfig; _returnTarget = _enemy.PatrolZone != null
if (_enemy.Animancer != null && ac?.Walk != null) ? _enemy.PatrolZone.PatrolCenter
_enemy.Animancer.Play(ac.Walk); : _enemy.HomePosition;
_enemy.MoveTo(_returnTarget);
_enemy.MoveTo(_enemy.HomePosition);
} }
public override TaskStatus OnUpdate() public override TaskStatus OnUpdate()
@@ -72,7 +72,7 @@ namespace BaseGames.Enemies.AI
// 兜底距离判断(事件可能因帧序问题延迟一帧) // 兜底距离判断(事件可能因帧序问题延迟一帧)
float radius = m_ArriveRadius > 0f ? m_ArriveRadius : (_enemy.Stats?.HomeRadius ?? 0.5f); float radius = m_ArriveRadius > 0f ? m_ArriveRadius : (_enemy.Stats?.HomeRadius ?? 0.5f);
float sqr = ((Vector2)_enemy.transform.position - _enemy.HomePosition).sqrMagnitude; float sqr = ((Vector2)_enemy.transform.position - _returnTarget).sqrMagnitude;
if (sqr <= radius * radius) if (sqr <= radius * radius)
return CompleteReturn(); return CompleteReturn();
@@ -85,8 +85,8 @@ namespace BaseGames.Enemies.AI
{ {
_enemy.Nav.OnGoalReached -= HandleReached; _enemy.Nav.OnGoalReached -= HandleReached;
_enemy.Nav.OnNavPathFailed -= HandleFailed; _enemy.Nav.OnNavPathFailed -= HandleFailed;
_subscribed = false;
} }
_subscribed = false;
} }
private TaskStatus CompleteReturn() private TaskStatus CompleteReturn()

View File

@@ -23,12 +23,16 @@ namespace BaseGames.Enemies.AI
public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>(); public override void OnAwake() => _enemy = gameObject.GetComponent<EnemyBase>();
public override void OnStart() => TryWalk(); public override void OnStart()
{
_enemy.SetAiPhase(AiPhase.Patrol);
TryWalk();
}
public override TaskStatus OnUpdate() public override TaskStatus OnUpdate()
{ {
if (_enemy == null) return TaskStatus.Failure; if (_enemy == null) return TaskStatus.Failure;
if (_enemy.Nav.IsAtDestination()) TryWalk(); if (_enemy.Nav?.IsAtDestination() ?? true) TryWalk();
return TaskStatus.Running; return TaskStatus.Running;
} }
@@ -37,7 +41,7 @@ namespace BaseGames.Enemies.AI
private void TryWalk() private void TryWalk()
{ {
for (int i = 0; i < m_RetryCount; i++) for (int i = 0; i < m_RetryCount; i++)
if (_enemy.Nav.WalkToRandom()) break; if (_enemy.Nav?.WalkToRandom() ?? false) break;
} }
} }
} }

View File

@@ -13,8 +13,7 @@
"BaseGames.Enemies", "BaseGames.Enemies",
"BaseGames.Enemies.Boss.Patterns", "BaseGames.Enemies.Boss.Patterns",
"Opsive.BehaviorDesigner.Runtime", "Opsive.BehaviorDesigner.Runtime",
"Kybernetik.Animancer", "Kybernetik.Animancer"
"Micosmo.SensorToolkit"
], ],
"autoReferenced": true, "autoReferenced": true,
"overrideReferences": false, "overrideReferences": false,

View File

@@ -1,7 +1,6 @@
using System.Collections; using System.Collections;
using Animancer; using Animancer;
using UnityEngine; using UnityEngine;
using BaseGames.Enemies.Perception;
namespace BaseGames.Enemies.Abilities namespace BaseGames.Enemies.Abilities
{ {
@@ -18,7 +17,6 @@ namespace BaseGames.Enemies.Abilities
[Header("感知与接触伤害")] [Header("感知与接触伤害")]
[SerializeField] private BodyContactDamage _contactDamage; [SerializeField] private BodyContactDamage _contactDamage;
[SerializeField] private EnemySensorHub _sensorHub;
[Tooltip("用于追击感知判断的传感器槽位名,通常为 \"aggro\"")] [Tooltip("用于追击感知判断的传感器槽位名,通常为 \"aggro\"")]
[SerializeField] private string _aggroSlotName = "aggro"; [SerializeField] private string _aggroSlotName = "aggro";
@@ -36,7 +34,7 @@ namespace BaseGames.Enemies.Abilities
while (true) while (true)
{ {
if (_enemy.PlayerTransform == null) break; if (_enemy.PlayerTransform == null) break;
if (_sensorHub != null && !_sensorHub.IsDetecting(_aggroSlotName, _enemy.PlayerTransform.gameObject)) if (_enemy.SensorHub != null && !_enemy.SensorHub.IsDetecting(_aggroSlotName, _enemy.PlayerTransform.gameObject))
break; break;
_enemy.MoveTo(_enemy.PlayerTransform.position); _enemy.MoveTo(_enemy.PlayerTransform.position);
yield return null; yield return null;

View File

@@ -149,15 +149,11 @@ namespace BaseGames.Enemies.Abilities
protected virtual void OnInterrupted(InterruptReason reason) { } protected virtual void OnInterrupted(InterruptReason reason) { }
/// <summary>子类辅助:朝向目标。</summary> /// <summary>子类辅助:朝向目标(写入输入信号,下一 FixedUpdate 由 EnemyMovement 消费)。</summary>
protected void FaceTarget(Transform target) protected void FaceTarget(Transform target)
{ {
if (target == null || _enemy == null) return; if (target == null || _enemy == null) return;
float dx = target.position.x - _transform.position.x; _enemy.FaceTarget(target.position);
if (Mathf.Abs(dx) < 0.001f) return;
var s = _transform.localScale;
s.x = Mathf.Abs(s.x) * Mathf.Sign(dx);
_transform.localScale = s;
} }
} }

View File

@@ -31,7 +31,7 @@ namespace BaseGames.Enemies.Abilities
{ {
Phase = AbilityRunState.Active; Phase = AbilityRunState.Active;
_enemy.Movement?.FaceTarget(_enemy.PlayerTransform.position); _enemy.FacePlayer();
if (_faceClip.Clip == null) yield break; if (_faceClip.Clip == null) yield break;

View File

@@ -12,7 +12,6 @@
"Opsive.BehaviorDesigner.Runtime", "Opsive.BehaviorDesigner.Runtime",
"Unity.Addressables", "Unity.Addressables",
"Unity.ResourceManager", "Unity.ResourceManager",
"Micosmo.SensorToolkit",
"BaseGames.Parry" "BaseGames.Parry"
], ],
"includePlatforms": [], "includePlatforms": [],

View File

@@ -39,6 +39,10 @@ namespace BaseGames.Enemies
[SerializeField] protected EnemyFeedback _feedback; [SerializeField] protected EnemyFeedback _feedback;
[SerializeField] protected HurtBox _hurtBox; [SerializeField] protected HurtBox _hurtBox;
[Header("区域(可选)")]
[Tooltip("地图固定巡逻/追击区域;配置后 BD_ChasePlayer 以区域边界替代 MaxChaseDistanceBD_ReturnToHome 归位至区域中心。留空则沿用出生点 + MaxChaseDistance 旧逻辑。")]
[SerializeField] private EnemyPatrolZone _patrolZone;
[Header("事件频道")] [Header("事件频道")]
[SerializeField] private BaseGames.Core.Events.StringEventChannelSO _onEnemyDied; [SerializeField] private BaseGames.Core.Events.StringEventChannelSO _onEnemyDied;
/// <summary> /// <summary>
@@ -100,6 +104,25 @@ namespace BaseGames.Enemies
/// 由 BD_ChasePlayer 在持有视线时每帧更新;视线丢失后保留最后记录值供 BD_InvestigateLastKnown 使用。 /// 由 BD_ChasePlayer 在持有视线时每帧更新;视线丢失后保留最后记录值供 BD_InvestigateLastKnown 使用。
/// </summary> /// </summary>
public Vector2 LastKnownPlayerPosition { get; set; } public Vector2 LastKnownPlayerPosition { get; set; }
/// <summary>
/// 地图固定巡逻/追击区域(可选)。
/// 配置后 BD_ChasePlayer 以区域边界为追击上限BD_ReturnToHome 归位至区域中心。
/// 未配置时退回旧逻辑HomePosition + MaxChaseDistance
/// </summary>
public EnemyPatrolZone PatrolZone => _patrolZone;
#if UNITY_EDITOR
[Header("── 运行时调试(仅 Editor──")]
[SerializeField] private EnemyStateType _dbg_CurrentState;
[SerializeField] private AiPhase _dbg_AiPhase;
[SerializeField] private bool _dbg_HasPlayer;
[SerializeField] private Vector2 _dbg_LastKnownPos;
#if GRAPH_DESIGNER
[SerializeField] private float _dbg_BtTickInterval;
#endif
#endif
// POCO 状态对象字典:枚举保持对外 API 不变。 // POCO 状态对象字典:枚举保持对外 API 不变。
// 子类可在 Awake() 重写条目注入自定义状态对象。 // 子类可在 Awake() 重写条目注入自定义状态对象。
protected readonly System.Collections.Generic.Dictionary<EnemyStateType, IEnemyState> _stateObjs protected readonly System.Collections.Generic.Dictionary<EnemyStateType, IEnemyState> _stateObjs
@@ -200,9 +223,9 @@ namespace BaseGames.Enemies
private readonly EnemyAbilityRegistry _abilities = new EnemyAbilityRegistry(); private readonly EnemyAbilityRegistry _abilities = new EnemyAbilityRegistry();
/// <summary>由 _onPlayerSpawned 事件缓存的玩家 Transform供 BD 任务读取。</summary> /// <summary>由 _onPlayerSpawned 事件缓存的玩家 Transform供 BD 任务读取。</summary>
public Transform PlayerTransform => _playerTransform; public Transform PlayerTransform => _playerTransform;
/// <summary>感知 HubSensorToolkit;供 QuotaManager 暂停/恢复 Sensor 使用。</summary> /// <summary>感知 Hub BD 任务及 QuotaManager 暂停/恢复感知使用。</summary>
public Perception.IPerceptionSystem SensorHub => _sensorHub; public Perception.IPerceptionSystem SensorHub => _sensorHub;
private Perception.EnemySensorHub _sensorHub; private Perception.IPerceptionSystem _sensorHub;
/// <summary>威胁评估器(可选):为原始 LOS 结果叠加反应延迟,使感知更自然。</summary> /// <summary>威胁评估器(可选):为原始 LOS 结果叠加反应延迟,使感知更自然。</summary>
public Perception.EnemyThreatAssessor ThreatAssessor => _threatAssessor; public Perception.EnemyThreatAssessor ThreatAssessor => _threatAssessor;
private Perception.EnemyThreatAssessor _threatAssessor; private Perception.EnemyThreatAssessor _threatAssessor;
@@ -219,12 +242,24 @@ namespace BaseGames.Enemies
=> _nav?.RequestMoveTo(target); => _nav?.RequestMoveTo(target);
public virtual void MoveInDirection(float dir) public virtual void MoveInDirection(float dir)
=> _movement?.MoveHorizontal(dir); {
if (_movement == null) return;
_movement.PendingInput.MoveDir = dir;
_movement.PendingInput.WantStop = false; // 移动意图覆盖停止脉冲
}
public virtual void MoveInDirectionWithSpeed(float dir, float speed)
{
if (_movement == null) return;
_movement.PendingInput.MoveDir = dir;
_movement.PendingInput.MoveSpeed = speed;
_movement.PendingInput.WantStop = false; // 移动意图覆盖停止脉冲
}
public virtual void StopMovement() public virtual void StopMovement()
{ {
_nav?.StopNavigation(); _nav?.StopNavigation();
_movement?.StopHorizontal(); if (_movement != null) _movement.PendingInput.WantStop = true;
} }
/// <summary>施加状态效果(需要 EnemyStatusEffectManager 组件)。同类型效果自动刷新。</summary> /// <summary>施加状态效果(需要 EnemyStatusEffectManager 组件)。同类型效果自动刷新。</summary>
@@ -251,26 +286,6 @@ namespace BaseGames.Enemies
public virtual bool IsPlayerInRange(float range) public virtual bool IsPlayerInRange(float range)
=> _stats != null && _stats.SqrDistanceToPlayer <= range * range; => _stats != null && _stats.SqrDistanceToPlayer <= range * range;
/// <summary>
/// 检查玩家是否在感知范围内。若 <see cref="EnemyStatsSO.DetectAngleDeg"/> > 0
/// 同时验证玩家是否在自身朝向的扇形角度内。
/// </summary>
public virtual bool IsPlayerInDetectRange()
{
if (_stats == null || _playerTransform == null) return false;
float detectRange = _statsSO != null ? _statsSO.DetectRange : 6f;
if (_stats.SqrDistanceToPlayer > detectRange * detectRange) return false;
float angleDeg = _statsSO?.DetectAngleDeg ?? 0f;
if (angleDeg <= 0f) return true; // 0 = 关闭方向限制
Vector2 toPlayer = ((Vector2)_playerTransform.position - (Vector2)transform.position).normalized;
float facingDir = _movement != null ? _movement.FacingDirection : 1f;
var forward = new Vector2(facingDir, 0f);
float angle = Vector2.Angle(forward, toPlayer);
return angle <= angleDeg;
}
/// <summary>原始视线检测结果BatchLOSSystem 写入,无感知延迟修正)。</summary> /// <summary>原始视线检测结果BatchLOSSystem 写入,无感知延迟修正)。</summary>
public bool HasLineOfSight => _losResult; public bool HasLineOfSight => _losResult;
@@ -279,8 +294,41 @@ namespace BaseGames.Enemies
public virtual void FacePlayer() public virtual void FacePlayer()
{ {
if (_playerTransform != null) if (_movement == null || _playerTransform == null) return;
_movement?.FaceTarget(_playerTransform.position); _movement.PendingInput.WantFace = true;
_movement.PendingInput.FaceTargetPos = _playerTransform.position;
_movement.PendingInput.FaceDir = 0;
}
/// <summary>朝向世界坐标点(通过输入信号,下一 FixedUpdate 消费)。</summary>
public void FaceTarget(Vector2 worldPos)
{
if (_movement == null) return;
_movement.PendingInput.WantFace = true;
_movement.PendingInput.FaceTargetPos = worldPos;
_movement.PendingInput.FaceDir = 0;
}
/// <summary>直接指定朝向方向(+1 右 / -1 左,通过输入信号)。</summary>
public void FaceDirection(int dir)
{
if (_movement == null) return;
_movement.PendingInput.WantFace = true;
_movement.PendingInput.FaceDir = dir;
}
/// <summary>
/// 搜查"环顾"子步骤:停止移动,播放原地环顾动画。
/// 由搜查行为触发;动画细节由角色自己决定,外部无需感知 AnimConfig。
/// </summary>
public void BeginLookAround()
{
StopMovement();
if (_animancer != null && _animConfig != null)
{
var clip = _animConfig.Investigate ?? _animConfig.Idle;
if (clip != null) _animancer.Play(clip);
}
} }
public virtual void Knockback(DamageInfo info) public virtual void Knockback(DamageInfo info)
@@ -416,6 +464,7 @@ namespace BaseGames.Enemies
AiPhase.Alert => _animConfig.Alert, AiPhase.Alert => _animConfig.Alert,
AiPhase.Investigate => _animConfig.Investigate ?? _animConfig.Walk, AiPhase.Investigate => _animConfig.Investigate ?? _animConfig.Walk,
AiPhase.Patrol => _animConfig.Walk, AiPhase.Patrol => _animConfig.Walk,
AiPhase.ReturnHome => _animConfig.Walk,
AiPhase.Chase => _animConfig.Run, AiPhase.Chase => _animConfig.Run,
AiPhase.Idle => _animConfig.Idle, AiPhase.Idle => _animConfig.Idle,
_ => null, _ => null,
@@ -499,8 +548,9 @@ namespace BaseGames.Enemies
_stateObjs[EnemyStateType.Dead] = new EnemyDeadState(); _stateObjs[EnemyStateType.Dead] = new EnemyDeadState();
_nav = GetComponent<IPathAgent>() ?? new NullPathAgent(); _nav = GetComponent<IPathAgent>() ?? new NullPathAgent();
if (_movement == null) _movement = GetComponent<EnemyMovement>();
_poiseSource = GetComponent<IPoiseSource>(); _poiseSource = GetComponent<IPoiseSource>();
_sensorHub = GetComponentInChildren<Perception.EnemySensorHub>(); _sensorHub = GetComponentInChildren<Perception.IPerceptionSystem>();
_statusEffects = GetComponent<StatusEffects.EnemyStatusEffectManager>(); _statusEffects = GetComponent<StatusEffects.EnemyStatusEffectManager>();
_threatAssessor = GetComponent<Perception.EnemyThreatAssessor>(); _threatAssessor = GetComponent<Perception.EnemyThreatAssessor>();
_pooledObject = GetComponent<PooledObject>(); _pooledObject = GetComponent<PooledObject>();
@@ -509,6 +559,7 @@ namespace BaseGames.Enemies
Debug.Assert(_statsSO != null, "[EnemyBase] _statsSO 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this); Debug.Assert(_statsSO != null, "[EnemyBase] _statsSO 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this);
Debug.Assert(_stats != null, "[EnemyBase] _stats 未绑定,请在 Prefab Inspector 中绑定 EnemyStats 组件。", this); Debug.Assert(_stats != null, "[EnemyBase] _stats 未绑定,请在 Prefab Inspector 中绑定 EnemyStats 组件。", this);
Debug.Assert(_movement != null, "[EnemyBase] _movement 未找到,请确保同 GameObject 上挂有 EnemyMovement 组件。", this);
_stats.Initialize(_statsSO); _stats.Initialize(_statsSO);
// 订阅玩家生成事件PlayerController.Start 广播),避免每个敌人独立 FindWithTag // 订阅玩家生成事件PlayerController.Start 广播),避免每个敌人独立 FindWithTag
@@ -556,6 +607,15 @@ namespace BaseGames.Enemies
} }
} }
} }
#endif
#if UNITY_EDITOR
_dbg_CurrentState = _currentState;
_dbg_AiPhase = _currentAiPhase;
_dbg_HasPlayer = _playerTransform != null;
_dbg_LastKnownPos = LastKnownPlayerPosition;
#if GRAPH_DESIGNER
_dbg_BtTickInterval = _btCurrentInterval;
#endif
#endif #endif
} }
@@ -730,51 +790,40 @@ namespace BaseGames.Enemies
#if UNITY_EDITOR #if UNITY_EDITOR
if (_statsSO == null) return; if (_statsSO == null) return;
// ── 侦测范围(淡橙;若配置扇形角则绘制扇形弧)+ 攻击范围(淡红圆)──── // 感知范围圆形 Gizmo 由 PhysicsPerceptionSystemEditor [DrawGizmo] 统一绘制,
{ // 此处不重复绘制,避免叠加覆盖导致 gizmoColor 设置无效。
var c = new Vector3(transform.position.x, transform.position.y, 0f);
var prevM = UnityEditor.Handles.matrix;
UnityEditor.Handles.matrix = Matrix4x4.identity;
// 攻击范围(全圆) // ── 运行时AI 状态标签(常态可见,无需选中)────────────────
UnityEditor.Handles.color = new Color(1f, 0.15f, 0.15f, 0.15f); if (Application.isPlaying)
UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.AttackRange);
UnityEditor.Handles.color = new Color(1f, 0.15f, 0.15f, 0.55f);
UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.AttackRange);
// 侦测范围
float angleDeg = _statsSO.DetectAngleDeg;
if (angleDeg > 0f)
{ {
// 扇形感知:绘制弧形扇区 Color phaseColor = _currentAiPhase switch
float facing = Application.isPlaying && _movement != null ? _movement.FacingDirection : 1f;
Vector3 forward3 = new Vector3(facing, 0f, 0f);
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.12f);
UnityEditor.Handles.DrawSolidArc(c, Vector3.back, forward3, angleDeg, _statsSO.DetectRange);
UnityEditor.Handles.DrawSolidArc(c, Vector3.back, forward3, -angleDeg, _statsSO.DetectRange);
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.6f);
UnityEditor.Handles.DrawWireArc(c, Vector3.back, forward3, angleDeg, _statsSO.DetectRange);
UnityEditor.Handles.DrawWireArc(c, Vector3.back, forward3, -angleDeg, _statsSO.DetectRange);
}
else
{ {
// 全圆感知 AiPhase.Idle => Color.gray,
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.15f); AiPhase.Patrol => Color.green,
UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.DetectRange); AiPhase.Alert => Color.yellow,
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.55f); AiPhase.Chase => new Color(1f, 0.5f, 0f),
UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.DetectRange); AiPhase.Combat => Color.red,
} AiPhase.Investigate => Color.cyan,
AiPhase.ReturnHome => Color.blue,
UnityEditor.Handles.matrix = prevM; _ => Color.white,
};
UnityEditor.Handles.color = phaseColor;
UnityEditor.Handles.Label(
transform.position + Vector3.up * 1.2f,
$"[{_currentAiPhase}] {_currentState}");
} }
// ── 运行时LOS 连线 ──────────────────────────────────────── // ── 运行时LOS 连线 ────────────────────────────────────────
if (!Application.isPlaying || _playerTransform == null) return; if (!Application.isPlaying || _playerTransform == null) return;
float drawDetectRange = _sensorHub != null
? _sensorHub.GetSensorRadius(Perception.SensorSlotNames.Aggro)
: -1f;
Vector3 eyeWorld = transform.position + new Vector3(_statsSO.EyeOffset.x, _statsSO.EyeOffset.y, 0f); Vector3 eyeWorld = transform.position + new Vector3(_statsSO.EyeOffset.x, _statsSO.EyeOffset.y, 0f);
Vector3 playerPos = _playerTransform.position; Vector3 playerPos = _playerTransform.position;
float sqrDist = (playerPos - transform.position).sqrMagnitude; float sqrDist = (playerPos - transform.position).sqrMagnitude;
bool inRange = sqrDist <= _statsSO.DetectRange * _statsSO.DetectRange; bool inRange = drawDetectRange >= 0f && sqrDist <= drawDetectRange * drawDetectRange;
// 眼睛位置小圆点(金黄) // 眼睛位置小圆点(金黄)
Gizmos.color = new Color(1f, 0.9f, 0.2f, 0.85f); Gizmos.color = new Color(1f, 0.9f, 0.2f, 0.85f);
@@ -795,26 +844,10 @@ namespace BaseGames.Enemies
#if UNITY_EDITOR #if UNITY_EDITOR
if (_statsSO == null) return; if (_statsSO == null) return;
// 选中时加亮范围圆 // 感知范围圆形 Gizmo 由 PhysicsPerceptionSystemEditor [DrawGizmo] 统一绘制,
{ // 此处不重复绘制。
var c = new Vector3(transform.position.x, transform.position.y, 0f);
var prevM = UnityEditor.Handles.matrix;
UnityEditor.Handles.matrix = Matrix4x4.identity;
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.25f); // 运行时:选中时绘制 AiPhase 彩色外圆(突出显示当前状态)
UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.DetectRange);
UnityEditor.Handles.color = new Color(1f, 0.6f, 0.1f, 0.90f);
UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.DetectRange);
UnityEditor.Handles.color = new Color(1f, 0.2f, 0.2f, 0.25f);
UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, _statsSO.AttackRange);
UnityEditor.Handles.color = new Color(1f, 0.2f, 0.2f, 0.90f);
UnityEditor.Handles.DrawWireDisc(c, Vector3.back, _statsSO.AttackRange);
UnityEditor.Handles.matrix = prevM;
}
// 运行时AiPhase 彩色圆 + 状态标签
if (Application.isPlaying) if (Application.isPlaying)
{ {
Color phaseColor = _currentAiPhase switch Color phaseColor = _currentAiPhase switch
@@ -830,11 +863,6 @@ namespace BaseGames.Enemies
}; };
Gizmos.color = phaseColor; Gizmos.color = phaseColor;
Gizmos.DrawWireSphere(transform.position, 0.5f); Gizmos.DrawWireSphere(transform.position, 0.5f);
UnityEditor.Handles.color = phaseColor;
UnityEditor.Handles.Label(
transform.position + Vector3.up * 1.0f,
$"{_currentAiPhase} | {_currentState}");
} }
#endif #endif
} }

View File

@@ -79,6 +79,7 @@ namespace BaseGames.Enemies
private System.Collections.Generic.List<string> BuildLines() private System.Collections.Generic.List<string> BuildLines()
{ {
var list = new System.Collections.Generic.List<string>(8); var list = new System.Collections.Generic.List<string>(8);
// 名称 // 名称

View File

@@ -0,0 +1,38 @@
using UnityEngine;
namespace BaseGames.Enemies
{
/// <summary>
/// 敌人移动意图信号。
/// <para><b>持久字段</b><see cref="MoveDir"/>/<see cref="MoveSpeed"/>):写入后持续生效,
/// 解决 FixedUpdate 频率快于 Update 时的空帧抖动;由 <see cref="WantStop"/> 显式清零。</para>
/// <para><b>一次性脉冲字段</b><see cref="WantStop"/>/<see cref="WantFace"/>
/// EnemyMovement 消费后自动清零,无需 BD 任务每帧续写。</para>
/// </summary>
public struct EnemyMoveInput
{
/// <summary>
/// 水平移动方向:-1 左 / +1 右。<b>持久</b>——写入后保持,直到 WantStop 被置 true 或
/// 再次写 0。不写 = 维持上一次方向(不会自动停止)。
/// </summary>
public float MoveDir;
/// <summary>显式移动速度。0 = 使用配置中的 WalkSpeed。<b>持久</b>,随 MoveDir 同步清零。</summary>
public float MoveSpeed;
/// <summary>
/// 一次性脉冲:置 true 后 EnemyMovement 立即停止并将 MoveDir/MoveSpeed 清零,
/// 然后自动将本字段清零。BD 任务在 OnEnd() 中调用 StopMovement() 即可,无需每帧续写。
/// </summary>
public bool WantStop;
/// <summary>一次性脉冲:本 FixedUpdate 帧处理朝向对准后自动清零。</summary>
public bool WantFace;
/// <summary>朝向目标的世界坐标WantFace 为 true 且 FaceDir == 0 时生效)。</summary>
public Vector2 FaceTargetPos;
/// <summary>直接指定朝向方向:+1 右 / -1 左 / 0 表示由 FaceTargetPos 计算。</summary>
public int FaceDir;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b496b4a0673bb1548869cc1f78420024
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -31,16 +31,36 @@ namespace BaseGames.Enemies
[Tooltip("动画配置 SO留空则在 Awake 时自动从 EnemyBase 读取")] [Tooltip("动画配置 SO留空则在 Awake 时自动从 EnemyBase 读取")]
[SerializeField] private EnemyAnimationConfigSO _animConfig; [SerializeField] private EnemyAnimationConfigSO _animConfig;
[Header("视觉节点")]
[Tooltip("包含 SpriteRenderer / AnimancerComponent 的子节点Visual设置后 Awake 自动将其 localPosition 对齐到 Collider2D offset使视觉中心与碰撞体中心重合。留空则不做偏移处理。")]
[SerializeField] private Transform _visualRoot;
[Tooltip("精灵资源本身的默认朝向1 = 右localScale.x 为正时面朝右),-1 = 左localScale.x 为正时面朝左)。如果美术资源绘制方向朝左,此值填 -1朝右填 1。大多数 Unity 项目美术朝右,默认值为 1。")]
[SerializeField] private int _spriteDefaultFacingDir = 1;
[Header("导航跳跃能力INavLinkHandler")] [Header("导航跳跃能力INavLinkHandler")]
[Tooltip("可处理的最大跳跃垂直高度(超出则让 TBM 兜底)")] [Tooltip("可处理的最大跳跃垂直高度(超出则让 TBM 兜底)")]
[SerializeField] private float _navJumpMaxHeight = 6f; [SerializeField] private float _navJumpMaxHeight = 6f;
[Tooltip("可处理的最大跳跃水平距离")] [Tooltip("可处理的最大跳跃水平距离")]
[SerializeField] private float _navJumpMaxDist = 10f; [SerializeField] private float _navJumpMaxDist = 10f;
[Tooltip("地面检测射线长度(用于判断跳跃是否落地)")] [Tooltip("用于确定射线起点宽度和底边的 Collider2D留空则 Awake 时自动查找")]
[SerializeField] private float _groundCheckDist = 0.35f; [SerializeField] private Collider2D _groundCheckCollider;
[Tooltip("从碰撞体底边向下的射线检测距离")]
[SerializeField] private float _groundCheckDist = 0.15f;
[Tooltip("射线数量1 = 仅中心,>1 时沿碰撞体底边均匀分布)")]
[SerializeField] [Min(1)] private int _groundCheckCount = 3;
[Tooltip("地面层 LayerMask")] [Tooltip("地面层 LayerMask")]
[SerializeField] private LayerMask _groundMask; [SerializeField] private LayerMask _groundMask;
[Header("墙体 / 悬崖检测")]
[Tooltip("从碰撞体朝向前边缘水平发射的墙体检测距离0 = 禁用)")]
[SerializeField] private float _wallCheckDist = 0.2f;
[Tooltip("悬崖检测:从碰撞体前下角再向前偏移此距离后向下发射射线(用于检测脚边是否有地面)")]
[SerializeField] private float _ledgeCheckFwdOffset = 0.1f;
[Tooltip("悬崖检测:向下的射线长度;射线未命中地面则 IsLedgeAhead = true0 = 禁用)")]
[SerializeField] private float _ledgeCheckDownDist = 0.4f;
[Tooltip("墙体层 LayerMask留空时复用地面 LayerMask")]
[SerializeField] private LayerMask _wallMask;
private Rigidbody2D _rb; private Rigidbody2D _rb;
private int _facingDir = 1; private int _facingDir = 1;
private Coroutine _linkCoroutine; private Coroutine _linkCoroutine;
@@ -50,11 +70,43 @@ namespace BaseGames.Enemies
private int _pendingFacingDir; // 转身目标方向,转身完成后 ApplyFacingFlip 使用 private int _pendingFacingDir; // 转身目标方向,转身完成后 ApplyFacingFlip 使用
private Coroutine _turnCoroutine; private Coroutine _turnCoroutine;
// ── 输入信号BD 任务在 Update 写入FixedUpdate 消费后自动清零)──
public EnemyMoveInput PendingInput;
public bool IsGrounded { get; private set; } public bool IsGrounded { get; private set; }
/// <summary>前方是否有墙体。在 FixedUpdate 中更新,仅当 _wallCheckDist > 0 时有效。</summary>
public bool IsWallAhead { get; private set; }
/// <summary>前方是否有悬崖(脚边地面缺失)。在 FixedUpdate 中更新,仅当 _ledgeCheckDownDist > 0 时有效。</summary>
public bool IsLedgeAhead { get; private set; }
/// <summary>当前朝向1 = 右,-1 = 左。</summary> /// <summary>当前朝向1 = 右,-1 = 左。</summary>
public int FacingDirection => _facingDir; public int FacingDirection => _facingDir;
/// <summary>当前是否正在播放转身动画(移动输入在此期间被屏蔽)。</summary> /// <summary>当前是否正在播放转身动画(移动输入在此期间被屏蔽)。</summary>
public bool IsTurning => _isTurning; public bool IsTurning => _isTurning;
/// <summary>
/// 当 PathBerserker2d TransformBasedMovement 正在直接驱动 transform.position 时由
/// <see cref="Navigation.EnemyNavAgent"/> 设为 true。
/// 此时 MoveHorizontal/MoveWithSpeed 仅更新朝向,不写 rb.velocity防止双重驱动冲突。
/// </summary>
public bool NavDriving { get; set; }
#if UNITY_EDITOR
[Header("── 运行时调试(仅 Editor──")]
[SerializeField] private int _dbg_FacingDirection;
[SerializeField] private float _dbg_VelocityX;
[SerializeField] private float _dbg_VelocityY;
[SerializeField] private bool _dbg_IsGrounded;
[SerializeField] private bool _dbg_IsWallAhead;
[SerializeField] private bool _dbg_IsLedgeAhead;
[SerializeField] private bool _dbg_IsTurning;
[SerializeField] private bool _dbg_NavDriving;
[Header("── 输入信号(仅 Editor──")]
[SerializeField] private float _dbg_Input_MoveDir;
[SerializeField] private float _dbg_Input_MoveSpeed;
[SerializeField] private bool _dbg_Input_WantStop;
[SerializeField] private bool _dbg_Input_WantFace;
[SerializeField] private Vector2 _dbg_Input_FaceTargetPos;
[SerializeField] private int _dbg_Input_FaceDir;
#endif
// ── INavLinkHandler ──────────────────────────────────────────── // ── INavLinkHandler ────────────────────────────────────────────
private static readonly NavLinkType[] _handledTypes = private static readonly NavLinkType[] _handledTypes =
@@ -128,17 +180,111 @@ namespace BaseGames.Enemies
onComplete?.Invoke(); onComplete?.Invoke();
} }
private bool IsGroundedCheck() => private Vector2 GetGroundRayOrigin(int index)
Physics2D.Raycast(_rb.position, Vector2.down, _groundCheckDist, _groundMask); {
// 优先用序列化字段,编辑器模式下 Awake 未执行时也能直接 GetComponent
var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();
if (col == null)
return (Vector2)transform.position;
Bounds b = col.bounds;
float x = _groundCheckCount <= 1
? b.center.x
: Mathf.Lerp(b.min.x, b.max.x, (float)index / (_groundCheckCount - 1));
return new Vector2(x, b.min.y);
}
private bool IsGroundedCheck()
{
for (int i = 0; i < _groundCheckCount; i++)
{
if (Physics2D.Raycast(GetGroundRayOrigin(i), Vector2.down, _groundCheckDist, _groundMask))
return true;
}
return false;
}
// 墙体射线起点:碰撞体朝向侧边缘中心高度
private Vector2 GetWallRayOrigin()
{
var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();
if (col == null) return (Vector2)transform.position;
Bounds b = col.bounds;
float x = _facingDir >= 0 ? b.max.x : b.min.x;
return new Vector2(x, b.center.y);
}
// 悬崖射线起点:碰撞体前下角再向前偏移 _ledgeCheckFwdOffset
private Vector2 GetLedgeRayOrigin()
{
var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();
if (col == null) return (Vector2)transform.position;
Bounds b = col.bounds;
float x = _facingDir >= 0
? b.max.x + _ledgeCheckFwdOffset
: b.min.x - _ledgeCheckFwdOffset;
return new Vector2(x, b.min.y);
}
private void WallAndLedgeCheck()
{
LayerMask wallLayer = (_wallMask.value != 0) ? _wallMask : _groundMask;
if (_wallCheckDist > 0f)
IsWallAhead = Physics2D.Raycast(
GetWallRayOrigin(),
new Vector2(_facingDir, 0f),
_wallCheckDist,
wallLayer);
if (_ledgeCheckDownDist > 0f)
IsLedgeAhead = !Physics2D.Raycast(
GetLedgeRayOrigin(),
Vector2.down,
_ledgeCheckDownDist,
_groundMask);
}
private void Awake() private void Awake()
{ {
Debug.Assert(_config != null, "[EnemyMovement] _config 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this); Debug.Assert(_config != null, "[EnemyMovement] _config 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this);
_rb = GetComponent<Rigidbody2D>(); _rb = GetComponent<Rigidbody2D>();
if (_groundCheckCollider == null)
_groundCheckCollider = GetComponent<Collider2D>();
// 从 Sprite 或 localScale 的初始状态推断朝向,并统一切换为 localScale 翻转。
// 这样子对象(含 RaySensor2D会随 localScale 正确翻转,不再依赖 flipX。
// 三个信号均可能携带初始朝向信息,任意奇数个翻转表示实际方向与默认方向相反:
// flippedBySprite : SpriteRenderer.flipX
// flippedByScale : ROOT localScale.x < 0
// flippedByVisual : _visualRoot.localScale.x < 0需归一化否则与 ROOT 产生双重翻转)
bool flippedBySprite = _spriteRenderer != null && _spriteRenderer.flipX;
bool flippedByScale = transform.localScale.x < 0f;
bool flippedByVisual = _visualRoot != null && _visualRoot.localScale.x < 0f;
_facingDir = (flippedBySprite ^ flippedByScale ^ flippedByVisual)
? -_spriteDefaultFacingDir
: _spriteDefaultFacingDir;
// 归一化:清除所有翻转来源,仅保留 ROOT localScale.x 作为唯一翻转驱动。
if (_spriteRenderer != null)
_spriteRenderer.flipX = false;
if (_visualRoot != null && flippedByVisual)
{
var vs = _visualRoot.localScale;
_visualRoot.localScale = new Vector3(Mathf.Abs(vs.x), vs.y, vs.z);
}
Vector3 s = transform.localScale;
float signX = (_facingDir == _spriteDefaultFacingDir) ? Mathf.Abs(s.x) : -Mathf.Abs(s.x);
transform.localScale = new Vector3(signX, s.y, s.z);
// 将 Visual 子节点的 localPosition 对齐到 Collider2D offset使视觉中心与碰撞体中心重合
if (_visualRoot != null && _groundCheckCollider != null)
_visualRoot.localPosition = _groundCheckCollider.offset;
if (_enableTurnAnimation) if (_enableTurnAnimation)
{ {
if (_animancer == null) _animancer = GetComponentInParent<AnimancerComponent>(true); // AnimancerComponent 可能在 Visual 子节点上,用 GetComponentInChildren 兼容两种布局
if (_animancer == null) _animancer = GetComponentInChildren<AnimancerComponent>(true);
if (_animConfig == null) if (_animConfig == null)
{ {
var enemyBase = GetComponentInParent<EnemyBase>(true); var enemyBase = GetComponentInParent<EnemyBase>(true);
@@ -147,29 +293,107 @@ namespace BaseGames.Enemies
} }
} }
private void OnDisable()
{
// 持久信号在对象禁用时必须清零,防止重新启用时继承残留移动状态。
PendingInput = default;
StopHorizontal();
}
private void FixedUpdate() private void FixedUpdate()
{ {
// localScale.x 为正 → 精灵以 _spriteDefaultFacingDir 方向显示;为负则相反。
if (!_isTurning)
_facingDir = transform.localScale.x >= 0f ? _spriteDefaultFacingDir : -_spriteDefaultFacingDir;
// NavDriving: TBM 直接写 transform.position零速防止物理重力积累和双重驱动冲突。
if (NavDriving)
_rb.velocity = Vector2.zero;
IsGrounded = IsGroundedCheck(); IsGrounded = IsGroundedCheck();
WallAndLedgeCheck();
#if UNITY_EDITOR
_dbg_Input_MoveDir = PendingInput.MoveDir;
_dbg_Input_MoveSpeed = PendingInput.MoveSpeed;
_dbg_Input_WantStop = PendingInput.WantStop;
_dbg_Input_WantFace = PendingInput.WantFace;
_dbg_Input_FaceTargetPos = PendingInput.FaceTargetPos;
_dbg_Input_FaceDir = PendingInput.FaceDir;
#endif
ConsumeInput();
#if UNITY_EDITOR
_dbg_FacingDirection = _facingDir;
_dbg_VelocityX = _rb != null ? _rb.velocity.x : 0f;
_dbg_VelocityY = _rb != null ? _rb.velocity.y : 0f;
_dbg_IsGrounded = IsGrounded;
_dbg_IsWallAhead = IsWallAhead;
_dbg_IsLedgeAhead = IsLedgeAhead;
_dbg_IsTurning = _isTurning;
_dbg_NavDriving = NavDriving;
#endif
}
private void ConsumeInput()
{
// ── 一次性脉冲:消费后清零 ─────────────────────────────────────
// WantStop / WantFace 只需写一次,消费后自动清除,
// 避免 BD 任务每帧续写而产生的不必要开销。
bool wantStop = PendingInput.WantStop;
bool wantFace = PendingInput.WantFace;
int faceDir = PendingInput.FaceDir;
var facePosSnapshot = PendingInput.FaceTargetPos;
PendingInput.WantStop = false;
PendingInput.WantFace = false;
PendingInput.FaceDir = 0; // clear to prevent stale Inspector display
PendingInput.FaceTargetPos = default; // clear to prevent stale Inspector display
// ── 持久字段MoveDir / MoveSpeed 不清零 ─────────────────────
// 解决 FixedUpdate 频率 > Update 频率时的空帧问题:
// 两次 Update 之间如果 FixedUpdate 多执行一次,之前写入的 MoveDir
// 仍然有效,不会产生意外的 StopHorizontal。
if (wantStop)
{
PendingInput.MoveDir = 0f;
PendingInput.MoveSpeed = 0f;
StopHorizontal();
}
else if (PendingInput.MoveDir != 0f)
{
if (PendingInput.MoveSpeed > 0f)
MoveWithSpeed(PendingInput.MoveDir, PendingInput.MoveSpeed);
else
MoveHorizontal(PendingInput.MoveDir);
}
if (wantFace && !_isTurning)
{
if (faceDir != 0)
UpdateFacing(faceDir > 0 ? 1f : -1f);
else
FaceTarget(facePosSnapshot);
}
} }
/// <summary>按 SO 配置速度水平移动。dir: +1 右 / -1 左 / 0 停止。转身动画期间调用无效。</summary> /// <summary>按 SO 配置速度水平移动。dir: +1 右 / -1 左 / 0 停止。转身动画期间调用无效。</summary>
public void MoveHorizontal(float dir) public void MoveHorizontal(float dir)
{ {
if (_isTurning) return; if (_isTurning) return;
UpdateFacing(dir);
if (NavDriving) return; // TBM 驱动位置,仅更新朝向
var vel = _rb.velocity; var vel = _rb.velocity;
vel.x = dir * _config.WalkSpeed; vel.x = dir * _config.WalkSpeed;
_rb.velocity = vel; _rb.velocity = vel;
UpdateFacing(dir);
} }
/// <summary>显式指定速度BD 追击任务调用)。转身动画期间调用无效。</summary> /// <summary>显式指定速度BD 追击任务调用)。转身动画期间调用无效。</summary>
public void MoveWithSpeed(float dir, float speed) public void MoveWithSpeed(float dir, float speed)
{ {
if (_isTurning) return; if (_isTurning) return;
UpdateFacing(dir);
if (NavDriving) return; // TBM 驱动位置,仅更新朝向
var vel = _rb.velocity; var vel = _rb.velocity;
vel.x = dir * speed; vel.x = dir * speed;
_rb.velocity = vel; _rb.velocity = vel;
UpdateFacing(dir);
} }
/// <summary>朝向指定世界坐标(通常传入玩家位置)。</summary> /// <summary>朝向指定世界坐标(通常传入玩家位置)。</summary>
@@ -261,25 +485,54 @@ namespace BaseGames.Enemies
} }
} }
/// <summary>转身动画协程:停止水平移动 → 播放 Turn 动画 → 翻转朝向 → 恢复。</summary> /// <summary>转身动画协程:停止水平移动 → 播放 Turn 动画 → 翻转朝向 → 恢复移动动画。</summary>
private IEnumerator TurnCoroutine(int newDir) private IEnumerator TurnCoroutine(int newDir)
{ {
_isTurning = true; _isTurning = true;
StopHorizontal(); StopHorizontal();
_animancer.Play(_animConfig.Turn); // 用 WaitForSeconds 代替 "yield return state"
float elapsed = 0f; // AnimancerState.IsLooping 是只读属性(反映 clip 自身设置),无法强制单次播放;
float duration = _animConfig.Turn.length; // 若 Turn clip 被误配为 Loop"yield return state" 的 keepWaiting 永远为 true
// 导致 _isTurning 卡住、走路/攻击动画无法播放。
while (elapsed < duration) // WaitForSeconds(Length / Speed) 精确等待一个周期,与 clip 的 Loop 设置无关。
{ var state = _animancer.Play(_animConfig.Turn);
elapsed += Time.deltaTime; float waitSec = state.Length > 0f
yield return null; ? state.Length / Mathf.Max(0.001f, Mathf.Abs(state.EffectiveSpeed))
} : 0.3f;
yield return new WaitForSeconds(waitSec);
ApplyFacingFlip(newDir); ApplyFacingFlip(newDir);
_isTurning = false; _isTurning = false;
_turnCoroutine = null; _turnCoroutine = null;
// 转身完成后恢复运动动画Turn 覆盖了之前的 Walk/Run
// 上层EnemyBase.SetAiPhase只在阶段切换时播放一次动画不会在此处重播。
ResumeMovementAnimation();
}
/// <summary>
/// 根据当前输入状态恢复合适的移动动画Walk / Run / Idle
/// 转身协程结束、CancelTurn 时调用,避免动画停留在 Turn 最后一帧。
/// </summary>
private void ResumeMovementAnimation()
{
if (_animancer == null || _animConfig == null) return;
if (PendingInput.WantStop || Mathf.Approximately(PendingInput.MoveDir, 0f))
{
if (_animConfig.Idle != null) _animancer.Play(_animConfig.Idle);
return;
}
// 有速度且明显超过步行速度 → 跑步动画
float spd = PendingInput.MoveSpeed > 0f ? PendingInput.MoveSpeed : 0f;
if (_animConfig.Run != null && _config != null && spd > _config.WalkSpeed + 0.05f)
_animancer.Play(_animConfig.Run);
else if (_animConfig.Walk != null)
_animancer.Play(_animConfig.Walk);
else if (_animConfig.Idle != null)
_animancer.Play(_animConfig.Idle);
} }
/// <summary> /// <summary>
@@ -298,17 +551,17 @@ namespace BaseGames.Enemies
} }
} }
/// <summary>真正执行朝向翻转(修改 SpriteRenderer.flipX 或 localScale。</summary> /// <summary>真正执行朝向翻转。始终用 localScale 翻转,子对象(传感器 RaySensor2D随之正确翻转。</summary>
private void ApplyFacingFlip(int newDir) private void ApplyFacingFlip(int newDir)
{ {
_facingDir = newDir; _facingDir = newDir;
// 若挂有 SpriteRenderer重置 flipX = falselocalScale 已负责镜像,避免双重翻转)。
if (_spriteRenderer != null) if (_spriteRenderer != null)
_spriteRenderer.flipX = newDir < 0; _spriteRenderer.flipX = false;
else
{
Vector3 s = transform.localScale; Vector3 s = transform.localScale;
transform.localScale = new Vector3(Mathf.Abs(s.x) * newDir, s.y, s.z); // newDir 与精灵默认方向一致 → 正比例(不翻转),否则取反(翻转)。
} float signX = (newDir == _spriteDefaultFacingDir) ? Mathf.Abs(s.x) : -Mathf.Abs(s.x);
transform.localScale = new Vector3(signX, s.y, s.z);
} }
private void OnDrawGizmos() private void OnDrawGizmos()
@@ -326,6 +579,47 @@ namespace BaseGames.Enemies
Vector3 center = transform.position; Vector3 center = transform.position;
DrawArrow2D(center, center + new Vector3(_facingDir * 0.5f, 0f, 0f), DrawArrow2D(center, center + new Vector3(_facingDir * 0.5f, 0f, 0f),
new Color(1f, 0.6f, 0.1f, 0.9f)); new Color(1f, 0.6f, 0.1f, 0.9f));
// ── 3. 地面检测射线(接地亮绿 / 未接地暗绿)─────────────────
if (_groundCheckDist > 0f)
{
bool grounded = Application.isPlaying && IsGrounded;
Gizmos.color = grounded
? new Color(0.2f, 1f, 0.35f, 0.90f)
: new Color(0.4f, 0.75f, 0.4f, 0.40f);
for (int i = 0; i < _groundCheckCount; i++)
{
Vector3 origin = GetGroundRayOrigin(i);
Gizmos.DrawLine(origin, origin + Vector3.down * _groundCheckDist);
Gizmos.DrawWireSphere(origin + Vector3.down * _groundCheckDist, 0.04f);
}
}
// ── 4. 墙体检测射线(命中红色 / 无命中青色)─────────────────
if (_wallCheckDist > 0f)
{
bool hit = Application.isPlaying && IsWallAhead;
Gizmos.color = hit
? new Color(1f, 0.2f, 0.2f, 0.90f)
: new Color(0.2f, 0.9f, 1f, 0.50f);
Vector3 wallOrigin = GetWallRayOrigin();
Vector3 wallEnd = wallOrigin + new Vector3(_facingDir * _wallCheckDist, 0f, 0f);
Gizmos.DrawLine(wallOrigin, wallEnd);
Gizmos.DrawWireSphere(wallEnd, 0.04f);
}
// ── 5. 悬崖检测射线(无地面橙色 / 有地面灰色)───────────────
if (_ledgeCheckDownDist > 0f)
{
bool ledge = Application.isPlaying && IsLedgeAhead;
Gizmos.color = ledge
? new Color(1f, 0.65f, 0.1f, 0.90f)
: new Color(0.6f, 0.6f, 0.6f, 0.40f);
Vector3 ledgeOrigin = GetLedgeRayOrigin();
Vector3 ledgeEnd = ledgeOrigin + Vector3.down * _ledgeCheckDownDist;
Gizmos.DrawLine(ledgeOrigin, ledgeEnd);
Gizmos.DrawWireSphere(ledgeEnd, 0.04f);
}
#endif #endif
} }
@@ -341,6 +635,76 @@ namespace BaseGames.Enemies
#endif #endif
} }
#if UNITY_EDITOR
/// <summary>
/// 一键在 Enemy Prefab 上创建 Visual 子节点,将 SpriteRenderer / AnimancerComponent
/// 迁移到该子节点,并自动将 _visualRoot / _spriteRenderer / EnemyBase._animancer 引用指向新节点。
/// 在 Inspector 右键菜单或 Component Header 菜单中调用。
/// ⚠️ 请在 Prefab 编辑模式(或 Prefab Stage中执行以便变更能正确保存。
/// </summary>
[ContextMenu("Setup Visual Node")]
public void SetupVisualNode()
{
// 1. 找或创建 Visual 子节点
Transform visual = transform.Find("Visual");
if (visual == null)
{
var go = new GameObject("Visual");
UnityEditor.Undo.RegisterCreatedObjectUndo(go, "Create Enemy Visual Node");
go.transform.SetParent(transform, false);
visual = go.transform;
}
// 2. 对齐 localPosition 到 Collider2D offset
var col = _groundCheckCollider != null ? _groundCheckCollider : GetComponent<Collider2D>();
if (col != null)
{
UnityEditor.Undo.RecordObject(visual, "Set Visual LocalPosition");
visual.localPosition = col.offset;
}
// 3. 迁移 SpriteRenderer仅在 Visual 上尚无 SpriteRenderer 时执行)
var sr = GetComponent<SpriteRenderer>();
if (sr != null && visual.GetComponent<SpriteRenderer>() == null)
{
UnityEditorInternal.ComponentUtility.CopyComponent(sr);
UnityEditorInternal.ComponentUtility.PasteComponentAsNew(visual.gameObject);
UnityEditor.Undo.DestroyObjectImmediate(sr);
}
// 4. 迁移 AnimancerComponent
var anim = GetComponent<AnimancerComponent>();
if (anim != null && visual.GetComponent<AnimancerComponent>() == null)
{
UnityEditorInternal.ComponentUtility.CopyComponent(anim);
UnityEditorInternal.ComponentUtility.PasteComponentAsNew(visual.gameObject);
UnityEditor.Undo.DestroyObjectImmediate(anim);
}
// 5. 更新 EnemyMovement 字段引用
var movSO = new UnityEditor.SerializedObject(this);
movSO.FindProperty("_visualRoot").objectReferenceValue = visual;
movSO.FindProperty("_spriteRenderer").objectReferenceValue = visual.GetComponent<SpriteRenderer>();
movSO.FindProperty("_animancer").objectReferenceValue = visual.GetComponent<AnimancerComponent>();
movSO.ApplyModifiedProperties();
// 6. 更新 EnemyBase._animancer 引用
var enemyBase = GetComponent<EnemyBase>();
if (enemyBase != null)
{
var baseSO = new UnityEditor.SerializedObject(enemyBase);
baseSO.FindProperty("_animancer").objectReferenceValue = visual.GetComponent<AnimancerComponent>();
baseSO.ApplyModifiedProperties();
}
UnityEditor.EditorUtility.SetDirty(gameObject);
Debug.Log($"[EnemyMovement] Visual node setup complete on '{gameObject.name}'.\n" +
$"Visual.localPosition = {visual.localPosition}\n" +
$"请在 Prefab 编辑器中手动保存Ctrl+S。", this);
}
#endif
// 在 Gizmos 空间绘制带箭头的 2D 有向线段 // 在 Gizmos 空间绘制带箭头的 2D 有向线段
private static void DrawArrow2D(Vector3 from, Vector3 to, Color color, float headLen = 0.15f) private static void DrawArrow2D(Vector3 from, Vector3 to, Color color, float headLen = 0.15f)
{ {

View File

@@ -0,0 +1,128 @@
using UnityEngine;
namespace BaseGames.Enemies
{
/// <summary>
/// 地图固定巡逻/追击区域(矩形)。
///
/// 放置于场景中(非敌人子节点),通过 EnemyBase Inspector 的 _patrolZone 字段引用。
/// 同一区域可被多个敌人共享(哨兵组、竞技场等)。
///
/// 区域层级:
/// <list type="bullet">
/// <item><b>巡逻区域Patrol</b>:绿色矩形,巡逻点限制范围。</item>
/// <item><b>追击区域Chase</b>:橙色矩形 = 巡逻区域 + <see cref="ChaseExpandPadding"/>
/// 玩家进入/出此区域触发追击开始/放弃。</item>
/// </list>
/// </summary>
public class EnemyPatrolZone : MonoBehaviour
{
[Header("巡逻区域")]
[Tooltip("巡逻区域中心相对 transform.position 的偏移(局部偏移,方便在 Scene 中移动节点)")]
public Vector2 PatrolOffset = Vector2.zero;
[Tooltip("巡逻区域尺寸(宽 × 高,单位 m")]
public Vector2 PatrolSize = new Vector2(10f, 4f);
[Header("追击区域")]
[Tooltip("追击区域向四周扩展的边距m追击区域 = 巡逻区域各边 + 此值。0 = 不扩展(追击 = 巡逻)")]
[Min(0f)]
public float ChaseExpandPadding = 8f;
[Tooltip("自定义追击区域尺寸(宽×高);保持 Vector2.zero 时使用巡逻区域 + ChaseExpandPadding 自动计算")]
public Vector2 CustomChaseSize = Vector2.zero;
// ── 计算属性 ──────────────────────────────────────────────────────
/// <summary>巡逻区域中心(世界坐标)。</summary>
public Vector2 PatrolCenter => (Vector2)transform.position + PatrolOffset;
/// <summary>追击区域中心与巡逻区域共享。</summary>
public Vector2 ChaseCenter => PatrolCenter;
/// <summary>有效追击区域尺寸:自定义 > 0 时使用自定义,否则巡逻区域 + 边距。</summary>
public Vector2 EffectiveChaseSize
{
get
{
if (CustomChaseSize.sqrMagnitude > 0f) return CustomChaseSize;
return PatrolSize + Vector2.one * (ChaseExpandPadding * 2f);
}
}
// ── 空间查询 ──────────────────────────────────────────────────────
/// <summary>判断世界坐标 <paramref name="worldPos"/> 是否在巡逻区域内。</summary>
public bool ContainsPatrol(Vector2 worldPos)
{
Vector2 delta = worldPos - PatrolCenter;
Vector2 half = PatrolSize * 0.5f;
return Mathf.Abs(delta.x) <= half.x && Mathf.Abs(delta.y) <= half.y;
}
/// <summary>判断世界坐标 <paramref name="worldPos"/> 是否在追击区域内。</summary>
public bool ContainsChase(Vector2 worldPos)
{
Vector2 delta = worldPos - ChaseCenter;
Vector2 half = EffectiveChaseSize * 0.5f;
return Mathf.Abs(delta.x) <= half.x && Mathf.Abs(delta.y) <= half.y;
}
/// <summary>将 <paramref name="worldPos"/> 夹紧到巡逻区域内最近点(用于归位目标)。</summary>
public Vector2 ClampToPatrol(Vector2 worldPos)
{
Vector2 center = PatrolCenter;
Vector2 half = PatrolSize * 0.5f;
return new Vector2(
Mathf.Clamp(worldPos.x, center.x - half.x, center.x + half.x),
Mathf.Clamp(worldPos.y, center.y - half.y, center.y + half.y)
);
}
// ── Gizmos ────────────────────────────────────────────────────────
#if UNITY_EDITOR
private void OnDrawGizmos()
{
Vector2 patrol = PatrolCenter;
Vector2 chase = ChaseCenter;
// 追击区域(橙色,后绘,垫在巡逻区域下方)
Vector2 chaseSize = EffectiveChaseSize;
Gizmos.color = new Color(1f, 0.55f, 0.1f, 0.07f);
Gizmos.DrawCube(chase, chaseSize);
Gizmos.color = new Color(1f, 0.55f, 0.1f, 0.7f);
Gizmos.DrawWireCube(chase, chaseSize);
// 巡逻区域(绿色,前绘,覆盖在追击区域上方)
Gizmos.color = new Color(0.2f, 0.9f, 0.2f, 0.12f);
Gizmos.DrawCube(patrol, PatrolSize);
Gizmos.color = new Color(0.2f, 0.9f, 0.2f, 0.85f);
Gizmos.DrawWireCube(patrol, PatrolSize);
// 中心点
Gizmos.color = new Color(0.2f, 0.9f, 0.2f, 1f);
Gizmos.DrawSphere(patrol, 0.15f);
}
private void OnDrawGizmosSelected()
{
// 选中时用更鲜艳的线框突出显示
Vector2 patrol = PatrolCenter;
Gizmos.color = Color.green;
Gizmos.DrawWireCube(patrol, PatrolSize);
Gizmos.color = new Color(1f, 0.7f, 0f);
Gizmos.DrawWireCube(ChaseCenter, EffectiveChaseSize);
// 标注尺寸(仅在 Selected 时绘制)
UnityEditor.Handles.color = Color.green;
UnityEditor.Handles.Label(patrol + Vector2.right * PatrolSize.x * 0.5f,
$"Patrol {PatrolSize.x:F1}×{PatrolSize.y:F1}");
UnityEditor.Handles.color = new Color(1f, 0.7f, 0f);
Vector2 cs = EffectiveChaseSize;
UnityEditor.Handles.Label(ChaseCenter + Vector2.right * cs.x * 0.5f,
$"Chase {cs.x:F1}×{cs.y:F1}");
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6eaf6ff027820884daab5fa51164c899
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -19,8 +19,8 @@ namespace BaseGames.Enemies
{ {
_currentPoiseLevel = _defaultPoiseLevel; _currentPoiseLevel = _defaultPoiseLevel;
// 自动注入到节点 HurtBox架构 06 §13 // 自动注入到所有子节点 HurtBox支持多形状受击区
if (TryGetComponent<HurtBox>(out var hurtBox)) foreach (var hurtBox in GetComponentsInChildren<HurtBox>(true))
hurtBox.SetPoiseSource(this); hurtBox.SetPoiseSource(this);
} }

View File

@@ -113,7 +113,7 @@ namespace BaseGames.Enemies
if (bt != null && bt.enabled != active) if (bt != null && bt.enabled != active)
{ {
bt.enabled = active; bt.enabled = active;
// 同步暂停/恢复 SensorToolkit Sensor,避免远处敌人无效 tick // 同步暂停/恢复感知系统,避免远处敌人无效 tick
enemy.SensorHub?.SetSuspended(!active); enemy.SensorHub?.SetSuspended(!active);
} }
} }

View File

@@ -45,6 +45,7 @@ namespace BaseGames.Enemies.Navigation
// ── 私有 ──────────────────────────────────────────────────────── // ── 私有 ────────────────────────────────────────────────────────
private NavAgent _navAgent; private NavAgent _navAgent;
private TransformBasedMovement _movement; private TransformBasedMovement _movement;
private EnemyMovement _enemyMovement;
// 能力 handler 注册表NavLinkType → INavLinkHandler // 能力 handler 注册表NavLinkType → INavLinkHandler
private readonly Dictionary<NavLinkType, INavLinkHandler> _handlers private readonly Dictionary<NavLinkType, INavLinkHandler> _handlers
@@ -53,6 +54,7 @@ namespace BaseGames.Enemies.Navigation
// 连接段状态缓存 // 连接段状态缓存
private NavLinkType _currentLinkType = NavLinkType.None; private NavLinkType _currentLinkType = NavLinkType.None;
private bool _wasNavOnSegment;
private Vector2 _currentLinkStart; private Vector2 _currentLinkStart;
private Vector2 _currentLinkEnd; private Vector2 _currentLinkEnd;
@@ -61,6 +63,7 @@ namespace BaseGames.Enemies.Navigation
{ {
_navAgent = GetComponent<NavAgent>(); _navAgent = GetComponent<NavAgent>();
_movement = GetComponent<TransformBasedMovement>(); _movement = GetComponent<TransformBasedMovement>();
_enemyMovement = GetComponent<EnemyMovement>();
// 自动发现 INavLinkHandler 组件并注册(包含子对象) // 自动发现 INavLinkHandler 组件并注册(包含子对象)
foreach (var handler in GetComponentsInChildren<INavLinkHandler>(true)) foreach (var handler in GetComponentsInChildren<INavLinkHandler>(true))
@@ -196,6 +199,32 @@ namespace BaseGames.Enemies.Navigation
private void HandlePathFailed(NavAgent _) => OnNavPathFailed?.Invoke(); private void HandlePathFailed(NavAgent _) => OnNavPathFailed?.Invoke();
private void HandleGoalReached(NavAgent _) => OnGoalReached?.Invoke(); private void HandleGoalReached(NavAgent _) => OnGoalReached?.Invoke();
// ── NavDriving 信号桥 ───────────────────────────────────────────
/// <summary>
/// 每物理帧向 <see cref="EnemyMovement"/> 写入导航方向信号,并设置 NavDriving 标志。
/// NavDriving=true 时 EnemyMovement 只更新朝向TBM 保留对 transform.position 的控制权。
/// </summary>
private void FixedUpdate()
{
if (_enemyMovement == null || _navAgent == null) return;
bool onSegment = _navAgent.IsMovingOnSegment;
_enemyMovement.NavDriving = onSegment;
if (onSegment)
{
float dx = _navAgent.PathSubGoal.x - transform.position.x;
_enemyMovement.PendingInput.MoveDir = Mathf.Abs(dx) > 0.01f ? Mathf.Sign(dx) : 0f;
}
else if (_wasNavOnSegment && !_navAgent.IsFollowingAPath)
{
// 导航刚结束(到达目标 / 路径失败)→ 清除残留 MoveDir
_enemyMovement.PendingInput.WantStop = true;
}
_wasNavOnSegment = onSegment;
}
// ── 工具 ─────────────────────────────────────────────────────── // ── 工具 ───────────────────────────────────────────────────────
private static NavLinkType ParseLinkType(string name) => name switch private static NavLinkType ParseLinkType(string name) => name switch
{ {

View File

@@ -1,5 +1,6 @@
using System; using System;
using UnityEngine; using UnityEngine;
using BaseGames.Enemies;
namespace BaseGames.Enemies.Navigation namespace BaseGames.Enemies.Navigation
{ {
@@ -49,6 +50,7 @@ namespace BaseGames.Enemies.Navigation
// ── 状态 ─────────────────────────────────────────────────────── // ── 状态 ───────────────────────────────────────────────────────
private Rigidbody2D _rb; private Rigidbody2D _rb;
private EnemyMovement _movement;
private Vector2? _destination; private Vector2? _destination;
private bool _isMoving; private bool _isMoving;
private bool _goalFired; private bool _goalFired;
@@ -68,6 +70,7 @@ namespace BaseGames.Enemies.Navigation
private void Awake() private void Awake()
{ {
_rb = GetComponent<Rigidbody2D>(); _rb = GetComponent<Rigidbody2D>();
_movement = GetComponent<EnemyMovement>();
_rb.gravityScale = 0f; _rb.gravityScale = 0f;
_rb.constraints = RigidbodyConstraints2D.FreezeRotation; _rb.constraints = RigidbodyConstraints2D.FreezeRotation;
} }
@@ -139,15 +142,25 @@ namespace BaseGames.Enemies.Navigation
Vector2 newPos = Vector2.MoveTowards(myPos, target, _moveSpeed * Time.fixedDeltaTime); Vector2 newPos = Vector2.MoveTowards(myPos, target, _moveSpeed * Time.fixedDeltaTime);
_rb.MovePosition(newPos); _rb.MovePosition(newPos);
// 面向移动方向 // 面向移动方向(通过 EnemyMovement 输入信号,保持 _facingDir 与动画系统同步)
float dx = target.x - myPos.x; float dx = target.x - myPos.x;
if (Mathf.Abs(dx) > 0.05f) if (Mathf.Abs(dx) > 0.05f)
{ {
int dir = dx > 0f ? 1 : -1;
if (_movement != null)
{
_movement.PendingInput.WantFace = true;
_movement.PendingInput.FaceDir = dir;
}
else
{
// 降级:没有 EnemyMovement 时直接翻转(独立飞行单位)
var s = transform.localScale; var s = transform.localScale;
s.x = Mathf.Abs(s.x) * Mathf.Sign(dx); s.x = Mathf.Abs(s.x) * dir;
transform.localScale = s; transform.localScale = s;
} }
} }
}
private void UpdateHover() private void UpdateHover()
{ {

View File

@@ -1,89 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
using Micosmo.SensorToolkit;
namespace BaseGames.Enemies.Perception
{
/// <summary>
/// 敌人感知 Hub架构 07_EnemyModule §9
/// 集中暴露挂载在敌人 Prefab 上的各种 SensorToolkit SensorBD 任务通过
/// 字符串槽位查询,避免在 BD 任务 Inspector 中拖具体 Sensor 引用。
///
/// 典型槽位命名约定:
/// - "aggro" : RangeSensor2D玩家入侵警戒圈
/// - "attack_melee" : RangeSensor2D近战触发距离
/// - "attack_range" : RangeSensor2D远程触发距离
/// - "los" : LOSSensor2D视线
/// - "wall_ahead" : RaySensor2D前方墙体检测
/// - "ledge" : RaySensor2D前方悬崖检测
/// </summary>
[DisallowMultipleComponent]
public sealed class EnemySensorHub : MonoBehaviour, IPerceptionSystem
{
[System.Serializable]
public struct SensorSlot
{
public string slotName;
public Sensor sensor;
}
[SerializeField] private SensorSlot[] _slots;
private Dictionary<string, Sensor> _map;
private void Awake()
{
_map = new Dictionary<string, Sensor>(_slots?.Length ?? 0);
if (_slots == null) return;
for (int i = 0; i < _slots.Length; i++)
{
var s = _slots[i];
if (s.sensor != null && !string.IsNullOrEmpty(s.slotName))
_map[s.slotName] = s.sensor;
}
}
public Sensor Get(string slotName)
{
if (_map == null || string.IsNullOrEmpty(slotName)) return null;
_map.TryGetValue(slotName, out var s);
return s;
}
public bool IsDetecting(string slotName, GameObject target)
{
var s = Get(slotName);
return s != null && target != null && s.IsDetected(target);
}
public bool HasAnyDetection(string slotName)
{
var s = Get(slotName);
if (s == null) return false;
foreach (var _ in s.Detections) return true;
return false;
}
public GameObject GetFirstDetection(string slotName)
{
var s = Get(slotName);
if (s == null) return null;
foreach (var go in s.Detections) return go;
return null;
}
/// <summary>
/// 暂停或恢复所有插槽的 Sensor。
/// 当敌人超出 QuotaManager 活跃范围时调用(关闭),归入活跃范围时恢复(开启)。
/// </summary>
public void SetSuspended(bool suspended)
{
if (_slots == null) return;
for (int i = 0; i < _slots.Length; i++)
{
var sensor = _slots[i].sensor;
if (sensor != null) sensor.enabled = !suspended;
}
}
}
}

View File

@@ -4,10 +4,15 @@ namespace BaseGames.Enemies.Perception
{ {
/// <summary> /// <summary>
/// 敌人感知系统接口。 /// 敌人感知系统接口。
/// EnemyBase 通过此接口与感知实现解耦,支持运行时替换SensorToolkit / 自定义实现) /// EnemyBase 通过此接口与感知实现解耦,支持运行时替换。
/// 当前实现为 <see cref="PhysicsPerceptionSystem"/>(纯物理射线 / 圆形范围检测)。
/// 若未来替换底层传感器实现,只需重新实现此接口,上层代码无需改动。
/// </summary> /// </summary>
public interface IPerceptionSystem public interface IPerceptionSystem
{ {
/// <summary>指定槽位是否已配置(用于运行前的能力检测,避免无效查询)。</summary>
bool HasSlot(string slotName);
/// <summary>指定槽位是否检测到任意目标。</summary> /// <summary>指定槽位是否检测到任意目标。</summary>
bool HasAnyDetection(string slotName); bool HasAnyDetection(string slotName);
@@ -17,6 +22,20 @@ namespace BaseGames.Enemies.Perception
/// <summary>返回指定槽位第一个检测到的对象,无检测则返回 null。</summary> /// <summary>返回指定槽位第一个检测到的对象,无检测则返回 null。</summary>
GameObject GetFirstDetection(string slotName); GameObject GetFirstDetection(string slotName);
/// <summary>
/// 返回指定槽位感知区域的半径(圆形区域)。
/// 槽位不存在、非圆形区域或实现不支持时返回 -1。
/// 主要供编辑器 Gizmos 绘制使用。
/// </summary>
float GetSensorRadius(string slotName);
/// <summary>
/// 返回指定槽位检测原点相对于感知组件 transform 的偏移X 分量已根据朝向翻转)。
/// 槽位不存在时返回 <see cref="Vector2.zero"/>。
/// 供 EnemyBase.OnDrawGizmos 定位各感知圆心使用,避免所有圆重叠在 transform.position。
/// </summary>
Vector2 GetSensorOffset(string slotName);
/// <summary>暂停或恢复感知系统LOD / 超出活跃范围时调用)。</summary> /// <summary>暂停或恢复感知系统LOD / 超出活跃范围时调用)。</summary>
void SetSuspended(bool suspended); void SetSuspended(bool suspended);
} }

View File

@@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.Enemies.Perception
{
/// <summary>
/// 敌人感知系统(自研纯物理实现)。
/// 每个 <see cref="PerceptionSlot"/> 独立配置并独立运行,支持四种检测模式:
/// • RangeCircle — Physics2D.OverlapCircleNonAlloc可选 LOS 视线遮挡校验)
/// • BatchLOS — 委托 EnemyBase.IsPlayerVisible()BatchLOSSystem 批量射线)
/// • FanCast — 以朝向为轴的扇形多射线视野(支持遮挡层)
/// • BoxCast — 矩形区域重叠检测X 偏移随 localScale.x 自动翻转)
///
/// EnemyBase.Awake() 通过 GetComponentInChildren&lt;IPerceptionSystem&gt;()
/// 自动发现本组件,无需修改 EnemyBase。
/// 槽位名称常量统一定义于 <see cref="SensorSlotNames"/>。
/// </summary>
[DisallowMultipleComponent]
public sealed class PhysicsPerceptionSystem : MonoBehaviour, IPerceptionSystem
{
// ── 槽位类型 ──────────────────────────────────────────────────────────
public enum SlotType
{
/// <summary>Physics2D 圆形重叠检测</summary>
RangeCircle,
/// <summary>委托 EnemyBase.IsPlayerVisible()BatchLOSSystem 批量射线视线检测)</summary>
BatchLOS,
/// <summary>以朝向为轴的扇形射线视野,遮挡层阻断视线</summary>
FanCast,
/// <summary>矩形区域重叠检测X 偏移随 localScale.x 自动翻转</summary>
BoxCast
}
// ── 槽位定义 ──────────────────────────────────────────────────────────
[Serializable]
public struct PerceptionSlot
{
[Tooltip("槽位名称,与 SensorSlotNames 常量保持一致\naggro / los / attack_melee / attack_range")]
public string slotName;
[Tooltip("RangeCirclePhysics2D 圆形范围检测\nBatchLOS视线射线检测BatchLOSSystem\nFanCast以朝向为轴的扇形射线视野\nBoxCast矩形区域重叠检测")]
public SlotType type;
[Min(0f)]
[Tooltip("RangeCircle / FanCast检测半径\nBatchLOS最大视线检测距离0 = 不限制)\nBoxCast忽略此值")]
public float radius;
[Tooltip("目标检测层(通常为 Player 层BatchLOS 忽略此值")]
public LayerMask detectLayer;
[Tooltip("RangeCircle / BoxCast基础重叠命中后额外校验视线Physics2D.Raycast\nFanCasttrue = 射线被 losBlockMask 层遮挡false = 穿透所有障碍物")]
public bool requireLOS;
[Tooltip("requireLOS = true / FanCastrequireLOS = true视线遮挡检测层通常为 Platform + Wall\nFanCast 射线只在 requireLOS = true 时被此层遮挡")]
public LayerMask losBlockMask;
[Header("Origin")]
[Tooltip("检测原点相对于 transform.position 的偏移(米)。\nX 分量随 localScale.x 朝向自动翻转BatchLOS 仅影响 Gizmo不影响实际射线。")]
public Vector2 offset;
[Header("FanCast")]
[Tooltip("FanCast扇形张角以朝向为中轴左右均匀展开")]
public float fanAngle;
[Tooltip("FanCast扇形内均匀分布的射线数量建议 511 条)")]
[Min(2)]
public int fanRayCount;
[Header("BoxCast")]
[Tooltip("BoxCast检测框尺寸 (宽, 高),单位米")]
public Vector2 boxSize;
[Tooltip("BoxCast相对于感知中心的偏移X 分量随 localScale.x 自动翻转")]
public Vector2 boxOffset;
[Header("Gizmos")]
[Tooltip("Scene 视图 Gizmo 颜色。留空(全透明黑色)时自动使用紫色回退,以确保可见。")]
public Color gizmoColor;
}
// ── 字段 ──────────────────────────────────────────────────────────────
[SerializeField] private PerceptionSlot[] _slots;
private readonly Dictionary<string, List<GameObject>> _detected =
new Dictionary<string, List<GameObject>>();
private readonly Collider2D[] _overlapBuffer = new Collider2D[32];
private bool _suspended;
private EnemyBase _owner;
// ── Unity 生命周期 ────────────────────────────────────────────────────
private void Awake()
{
_owner = GetComponentInParent<EnemyBase>();
if (_slots == null) return;
foreach (var slot in _slots)
{
if (!string.IsNullOrEmpty(slot.slotName) && !_detected.ContainsKey(slot.slotName))
_detected[slot.slotName] = new List<GameObject>(4);
}
}
private void FixedUpdate()
{
if (_suspended || _slots == null) return;
foreach (var slot in _slots)
RefreshSlot(slot);
}
// ── 内部检测逻辑 ──────────────────────────────────────────────────────
private void RefreshSlot(PerceptionSlot slot)
{
if (string.IsNullOrEmpty(slot.slotName)) return;
if (!_detected.TryGetValue(slot.slotName, out var list)) return;
list.Clear();
switch (slot.type)
{
case SlotType.BatchLOS:
if (_owner != null && _owner.IsPlayerVisible() && _owner.PlayerTransform != null)
{
if (slot.radius > 0f)
{
float dist = Vector2.Distance(
(Vector2)transform.position, (Vector2)_owner.PlayerTransform.position);
if (dist > slot.radius) break;
}
list.Add(_owner.PlayerTransform.gameObject);
}
break;
case SlotType.RangeCircle:
RefreshRangeCircle(slot, list);
break;
case SlotType.FanCast:
RefreshFanCast(slot, list);
break;
case SlotType.BoxCast:
RefreshBoxCast(slot, list);
break;
}
}
private void RefreshRangeCircle(in PerceptionSlot slot, System.Collections.Generic.List<GameObject> list)
{
if (slot.radius <= 0f || slot.detectLayer == 0) return;
float facingSign = transform.localScale.x < 0f ? -1f : 1f;
Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y);
int count = Physics2D.OverlapCircleNonAlloc(origin, slot.radius, _overlapBuffer, slot.detectLayer);
for (int i = 0; i < count; i++)
{
var col = _overlapBuffer[i];
if (col == null) continue;
if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue;
list.Add(col.gameObject);
}
}
private void RefreshFanCast(in PerceptionSlot slot, System.Collections.Generic.List<GameObject> list)
{
if (slot.radius <= 0f || slot.detectLayer == 0 || slot.fanAngle <= 0f) return;
float facingSign = transform.localScale.x < 0f ? -1f : 1f;
var forward = new Vector2(facingSign, 0f);
Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y);
float halfAngle = slot.fanAngle * 0.5f;
int rays = Mathf.Max(2, slot.fanRayCount);
// requireLOS = true射线被 losBlockMask 遮挡false仅检测 detectLayer穿透障碍物
int castMask = slot.requireLOS
? ((int)slot.detectLayer | (int)slot.losBlockMask)
: (int)slot.detectLayer;
for (int r = 0; r < rays; r++)
{
float t = (float)r / (rays - 1);
Vector2 dir = RotateVector(forward, Mathf.Lerp(-halfAngle, halfAngle, t));
RaycastHit2D hit = Physics2D.Raycast(origin, dir, slot.radius, castMask);
if (hit.collider == null) continue;
if (((1 << hit.collider.gameObject.layer) & (int)slot.detectLayer) == 0) continue;
var go = hit.collider.gameObject;
if (!list.Contains(go)) list.Add(go);
}
}
private void RefreshBoxCast(in PerceptionSlot slot, System.Collections.Generic.List<GameObject> list)
{
if (slot.boxSize.x <= 0f || slot.boxSize.y <= 0f || slot.detectLayer == 0) return;
float facingSign = transform.localScale.x < 0f ? -1f : 1f;
Vector2 origin = (Vector2)transform.position + new Vector2(slot.offset.x * facingSign, slot.offset.y);
Vector2 center = origin + new Vector2(slot.boxOffset.x * facingSign, slot.boxOffset.y);
int count = Physics2D.OverlapBoxNonAlloc(center, slot.boxSize, 0f, _overlapBuffer, slot.detectLayer);
for (int i = 0; i < count; i++)
{
var col = _overlapBuffer[i];
if (col == null) continue;
if (slot.requireLOS && !HasLineOfSight(origin, col.gameObject, slot.losBlockMask)) continue;
list.Add(col.gameObject);
}
}
private bool HasLineOfSight(Vector2 origin, GameObject target, LayerMask blockMask)
{
// 优先使用碰撞体包围盒中心(比 transform.position 更准确,避免偏心轴误判)
Vector2 targetPos;
var col = target.GetComponent<Collider2D>();
targetPos = col != null ? (Vector2)col.bounds.center : (Vector2)target.transform.position;
var dir = targetPos - origin;
float dist = dir.magnitude;
if (dist <= 0f) return true;
var hit = Physics2D.Raycast(origin, dir / dist, dist, blockMask);
// 未命中任何障碍,或者第一个命中的就是目标自身(含子物体)
return hit.collider == null || hit.collider.transform.IsChildOf(target.transform) || hit.collider.gameObject == target;
}
private static Vector2 RotateVector(Vector2 v, float angleDeg)
{
float rad = angleDeg * Mathf.Deg2Rad;
float cos = Mathf.Cos(rad);
float sin = Mathf.Sin(rad);
return new Vector2(cos * v.x - sin * v.y, sin * v.x + cos * v.y);
}
// ── IPerceptionSystem ─────────────────────────────────────────────────
public bool HasSlot(string slotName)
{
if (string.IsNullOrEmpty(slotName)) return false;
// 运行时通过字典; 编辑器模式遍历数组
if (_detected.Count > 0) return _detected.ContainsKey(slotName);
if (_slots == null) return false;
foreach (var s in _slots)
if (s.slotName == slotName) return true;
return false;
}
public bool HasAnyDetection(string slotName) =>
_detected.TryGetValue(slotName, out var list) && list.Count > 0;
public bool IsDetecting(string slotName, GameObject target)
{
if (!_detected.TryGetValue(slotName, out var list)) return false;
for (int i = 0; i < list.Count; i++)
if (list[i] == target) return true;
return false;
}
public GameObject GetFirstDetection(string slotName)
{
if (!_detected.TryGetValue(slotName, out var list) || list.Count == 0) return null;
return list[0];
}
public float GetSensorRadius(string slotName)
{
if (string.IsNullOrEmpty(slotName) || _slots == null) return -1f;
foreach (var s in _slots)
if (s.slotName == slotName && s.type == SlotType.RangeCircle)
return s.radius;
return -1f;
}
public Vector2 GetSensorOffset(string slotName)
{
if (string.IsNullOrEmpty(slotName) || _slots == null) return Vector2.zero;
float facingSign = transform.localScale.x < 0f ? -1f : 1f;
foreach (var s in _slots)
if (s.slotName == slotName)
return new Vector2(s.offset.x * facingSign, s.offset.y);
return Vector2.zero;
}
public void SetSuspended(bool suspended)
{
_suspended = suspended;
if (suspended)
foreach (var list in _detected.Values)
list.Clear();
}
// ── 编辑器 API仅 UNITY_EDITOR 访问)────────────────────────────────
#if UNITY_EDITOR
public PerceptionSlot[] EditorSlots => _slots;
public IReadOnlyDictionary<string, List<GameObject>> EditorDetected => _detected;
public EnemyBase EditorOwner => _owner;
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c0026fe36cfaffc4e95698bccd0a8380
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,43 +1,33 @@
namespace BaseGames.Enemies.Perception namespace BaseGames.Enemies.Perception
{ {
/// <summary> /// <summary>
/// <see cref="EnemySensorHub"/> 槽位名称常量。 /// <see cref="PhysicsPerceptionSystem"/> 槽位名称常量。
/// ///
/// 统一定义字符串键,避免在 BD Task Inspector 和代码中散布魔法字符串。 /// 统一定义字符串键,避免在 BD Task Inspector 和代码中散布魔法字符串。
/// Prefab 上 EnemySensorHub 组件的 slotName 字段必须与此处常量保持一致。 /// Prefab 上 <see cref="PhysicsPerceptionSystem"/> 组件的 slotName 字段必须与此处常量保持一致。
/// </summary> /// </summary>
public static class SensorSlotNames public static class SensorSlotNames
{ {
/// <summary> /// <summary>
/// 警戒范围RangeSensor2D):玩家进入此圈触发 Alert 阶段。 /// 警戒范围RangeCircle):玩家进入此圈触发 Alert 阶段。
/// 通常半径大于攻击范围,小于视线检测范围。 /// 通常半径大于攻击范围,小于视线检测范围。
/// </summary> /// </summary>
public const string Aggro = "aggro"; public const string Aggro = "aggro";
/// <summary> /// <summary>
/// 视线检测(LOSSensor2D):敌我之间无遮挡时持续为 true。 /// 视线检测(BatchLOS):敌我之间无遮挡时持续为 true。
/// 由 BatchLOSSystem 批量计算BD_IsPlayerVisible 读取结果。 /// 由 BatchLOSSystem 批量计算BD_IsPlayerVisible 读取结果。
/// </summary> /// </summary>
public const string LOS = "los"; public const string LOS = "los";
/// <summary> /// <summary>
/// 近战攻击范围RangeSensor2D):玩家进入时触发近战攻击条件。 /// 近战攻击范围RangeCircle):玩家进入时触发近战攻击条件。
/// </summary> /// </summary>
public const string AttackMelee = "attack_melee"; public const string AttackMelee = "attack_melee";
/// <summary> /// <summary>
/// 远程攻击范围RangeSensor2D):玩家进入时触发远程攻击条件。 /// 远程攻击范围RangeCircle):玩家进入时触发远程攻击条件。
/// </summary> /// </summary>
public const string AttackRange = "attack_range"; public const string AttackRange = "attack_range";
/// <summary>
/// 前方墙体RaySensor2D水平方向检测用于巡逻转向。
/// </summary>
public const string WallAhead = "wall_ahead";
/// <summary>
/// 前方悬崖RaySensor2D斜向下检测地面是否存在用于巡逻转向。
/// </summary>
public const string Ledge = "ledge";
} }
} }

View File

@@ -47,11 +47,14 @@ namespace BaseGames.Input
// ── Polling ─────────────────────────────────────────────────────────── // ── Polling ───────────────────────────────────────────────────────────
public Vector2 MoveInput { get; private set; } public Vector2 MoveInput { get; private set; }
/// <summary>跳跃键当前是否处于按下状态。供 JumpState 在进入时检测短按是否已松开。</summary>
public bool IsJumpHeld => _jumpAction != null && _jumpAction.IsPressed();
// ── Runtime state ───────────────────────────────────────────────────── // ── Runtime state ─────────────────────────────────────────────────────
private InputActionMap _gameplay; private InputActionMap _gameplay;
private InputActionMap _ui; private InputActionMap _ui;
private bool _isBound; private bool _isBound;
private InputAction _jumpAction;
private void EnsureInitialized() private void EnsureInitialized()
{ {
@@ -92,6 +95,7 @@ namespace BaseGames.Input
_gameplay = null; _gameplay = null;
_ui = null; _ui = null;
_isBound = false; _isBound = false;
_jumpAction = null;
EnsureInitialized(); EnsureInitialized();
} }
@@ -171,6 +175,7 @@ namespace BaseGames.Input
BindStarted(_gameplay, "Jump", () => JumpStartedEvent?.Invoke()); BindStarted(_gameplay, "Jump", () => JumpStartedEvent?.Invoke());
BindCanceled(_gameplay, "Jump", () => JumpCancelledEvent?.Invoke()); BindCanceled(_gameplay, "Jump", () => JumpCancelledEvent?.Invoke());
_jumpAction = _gameplay.FindAction("Jump", throwIfNotFound: false);
BindStarted(_gameplay, "Attack", () => AttackEvent?.Invoke()); BindStarted(_gameplay, "Attack", () => AttackEvent?.Invoke());
BindStarted(_gameplay, "Parry", () => ParryEvent?.Invoke()); BindStarted(_gameplay, "Parry", () => ParryEvent?.Invoke());
BindStarted(_gameplay, "Dash", () => DashEvent?.Invoke()); BindStarted(_gameplay, "Dash", () => DashEvent?.Invoke());

View File

@@ -100,6 +100,20 @@ namespace BaseGames.Player
// 开启连续碰撞检测CCDKinematic 移动平台通过 MovePosition 将 Dynamic 角色推向墙体时, // 开启连续碰撞检测CCDKinematic 移动平台通过 MovePosition 将 Dynamic 角色推向墙体时,
// CCD 会沿移动路径追踪碰撞,确保角色在物理层被墙体表面拦截,而不是在离散步骤中穿透墙体。 // CCD 会沿移动路径追踪碰撞,确保角色在物理层被墙体表面拦截,而不是在离散步骤中穿透墙体。
_rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; _rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
if (_config != null)
_config.OnConfigChanged += OnMovementConfigChanged;
}
private void OnDestroy()
{
if (_config != null)
_config.OnConfigChanged -= OnMovementConfigChanged;
}
private void OnMovementConfigChanged()
{
if (_rb != null)
_rb.gravityScale = _config.DefaultGravityScale;
} }
private void FixedUpdate() private void FixedUpdate()

View File

@@ -1,3 +1,4 @@
using System;
using UnityEngine; using UnityEngine;
namespace BaseGames.Player namespace BaseGames.Player
@@ -26,6 +27,11 @@ namespace BaseGames.Player
[Tooltip("松开跳跃键时速度保留比例(变高跳)。推荐 0.35,越小跳跃越低。")] [Tooltip("松开跳跃键时速度保留比例(变高跳)。推荐 0.35,越小跳跃越低。")]
[Range(0f, 1f)] [Range(0f, 1f)]
public float JumpCutMultiplier = 0.35f; public float JumpCutMultiplier = 0.35f;
[Tooltip("短按最小跳跃保护窗口(秒)。\n" +
"窗口内松开跳跃键不会立即截断速度;窗口结束时统一判断是否截断,\n" +
"保证无论短按多快/多慢,最小跳跃高度始终一致(消除帧级手感抖动)。\n" +
"推荐 0.08≈5帧@60fps。调高 → 最小跳更高;调低 → 最小跳更低。")]
public float MinJumpTime = 0.08f;
[Header("跳跃 — 顶点悬停")] [Header("跳跃 — 顶点悬停")]
[Tooltip("顶点悬停触发阈值(单位/秒)。当 |垂直速度| 低于此值时,重力缩减为 ApexGravityMultiplier 倍,\n产生\"滞空感\"。推荐 3。调高 → 悬停段更长;调低 → 悬停段更短乃至消失。")] [Tooltip("顶点悬停触发阈值(单位/秒)。当 |垂直速度| 低于此值时,重力缩减为 ApexGravityMultiplier 倍,\n产生\"滞空感\"。推荐 3。调高 → 悬停段更长;调低 → 悬停段更短乃至消失。")]
@@ -92,5 +98,16 @@ namespace BaseGames.Player
[Header("重力")] [Header("重力")]
public float DefaultGravityScale = 3f; public float DefaultGravityScale = 3f;
// ── 运行时热更新通知 ──────────────────────────────────────────────────
/// <summary>
/// Inspector 中修改配置时触发(仅编辑器),订阅方可立即应用新值,无需重启 PlayMode。
/// </summary>
public event Action OnConfigChanged;
private void OnValidate()
{
OnConfigChanged?.Invoke();
}
} }
} }

View File

@@ -16,6 +16,11 @@ namespace BaseGames.Player.States
{ {
private bool _isDoubleJump; private bool _isDoubleJump;
// 最小跳跃窗口:窗口内松键记录 _cutPending窗口结束时统一执行 CutJump
// 保证短按始终在相同 vy 时截断 → 最小跳跃高度完全一致,消除帧级手感抖动。
private float _minJumpTimer;
private bool _cutPending;
public JumpState(PlayerController owner) : base(owner) { } public JumpState(PlayerController owner) : base(owner) { }
/// <summary> /// <summary>
@@ -40,6 +45,7 @@ namespace BaseGames.Player.States
Move.Jump(); Move.Jump();
_isDoubleJump = false; // 消耗标记 _isDoubleJump = false; // 消耗标记
ResetMinJumpWindow();
Input.JumpCancelledEvent += OnJumpCancelled; Input.JumpCancelledEvent += OnJumpCancelled;
// 开启上升阶段贴墙 vy 保护:防止物理摩擦降低跳跃最高点 // 开启上升阶段贴墙 vy 保护:防止物理摩擦降低跳跃最高点
Move.SetPreserveVyOnWallContact(true); Move.SetPreserveVyOnWallContact(true);
@@ -96,6 +102,7 @@ namespace BaseGames.Player.States
{ {
Owner.UseAirJump(); Owner.UseAirJump();
Move.DoubleJump(); Move.DoubleJump();
ResetMinJumpWindow(); // 二段跳重置最小跳跃窗口
// 播放空中跳跃动画,未配置时回退到 Jump // 播放空中跳跃动画,未配置时回退到 Jump
var airJumpClip = AnimCfg?.AirJump ?? AnimCfg?.Jump; var airJumpClip = AnimCfg?.AirJump ?? AnimCfg?.Jump;
if (airJumpClip != null) Anim?.Play(airJumpClip); if (airJumpClip != null) Anim?.Play(airJumpClip);
@@ -134,6 +141,20 @@ namespace BaseGames.Player.States
public override void OnStateFixedUpdate() public override void OnStateFixedUpdate()
{ {
// ── 最小跳跃窗口:窗口内松键不立即截断,窗口结束时统一处理 ─────────────
// 保证短按(无论在窗口内哪帧松键)都在相同 vy 时执行 CutJump → 一致的最小跳跃高度。
if (_minJumpTimer > 0f)
{
_minJumpTimer -= Time.fixedDeltaTime;
if (_minJumpTimer <= 0f)
{
// 窗口到期已收到松键事件或当前键未按住InputBuffer 延迟导致进入时键已释放)
if (_cutPending || !Input.IsJumpHeld)
Move.CutJump();
_cutPending = false;
}
}
// ── 顶点悬停:第一判断 |vy|,动态切换重力缩放系数 ──────────────── // ── 顶点悬停:第一判断 |vy|,动态切换重力缩放系数 ────────────────
// |垂直速度| 低于顶点阈值时,重力缩减至 ApexGravityMultiplier 倍, // |垂直速度| 低于顶点阈值时,重力缩减至 ApexGravityMultiplier 倍,
// 产生角色在跳跃顶点起起“滒空”的手感,属于高重力平台游戏的标志性特征。 // 产生角色在跳跃顶点起起“滒空”的手感,属于高重力平台游戏的标志性特征。
@@ -191,6 +212,18 @@ namespace BaseGames.Player.States
Input.JumpCancelledEvent -= OnJumpCancelled; Input.JumpCancelledEvent -= OnJumpCancelled;
} }
private void OnJumpCancelled() => Move.CutJump(); private void OnJumpCancelled()
{
if (_minJumpTimer > 0f)
_cutPending = true; // 窗口内:推迟到窗口结束时执行,保证一致的截断时机
else
Move.CutJump(); // 窗口已过(长按):立即截断 → 变高跳
}
private void ResetMinJumpWindow()
{
_minJumpTimer = Cfg.MinJumpTime;
_cutPending = false;
}
} }
} }

View File

@@ -226,12 +226,12 @@ namespace BaseGames.Player.States
_overlayLayer = _animancer.Layers[1]; _overlayLayer = _animancer.Layers[1];
} }
// 注入 HurtBox 依赖 // 注入 HurtBox 依赖(覆盖所有子节点 HurtBox支持多形状受击区
if (_hurtBox != null) foreach (var hb in GetComponentsInChildren<HurtBox>(true))
{ {
if (_shield != null) _hurtBox.SetShieldable(_shield); if (_shield != null) hb.SetShieldable(_shield);
if (_parrySystem != null) _hurtBox.SetParrySystem(_parrySystem); if (_parrySystem != null) hb.SetParrySystem(_parrySystem);
_hurtBox.SetPoiseSource(this); hb.SetPoiseSource(this);
} }
// 将唯一配置点_inputReader注入到 ParrySystem。 // 将唯一配置点_inputReader注入到 ParrySystem。

View File

@@ -45,4 +45,4 @@ Physics2DSettings:
m_ReuseCollisionCallbacks: 1 m_ReuseCollisionCallbacks: 1
m_AutoSyncTransforms: 0 m_AutoSyncTransforms: 0
m_GizmoOptions: 10 m_GizmoOptions: 10
m_LayerCollisionMatrix: ffffffffffffffffffffffffbffffffffffffffffffffffff7ebfffffff0ffff7fffffff7fdfffff3fffffff7fbfffffbffffef7ffbdffffff57ffdfffbffeffff6fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdfffffffffffefffffffffffffffbffffdffffffffffffffff m_LayerCollisionMatrix: ffffffffffffffffffffffffbffffffffffffffffffffffff7ebfffffff0ffff7fffffff7fdfffff3fffffff7fbfffffbffffef7ff9dffffff57ffdfffbffeffff6fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdfffffffffffefffffffffffffffbffffdffffffffffffffff