feat: Update enemy AI and movement systems

- Enhanced Physics2D layer collision report with new interactions between Player and Enemy layers.
- Refactored BD_InvestigateLastKnown to streamline animation handling and improve readability.
- Simplified BD_MaintainCombatDistance by consolidating movement stop logic.
- Updated BD_MoveToPlayer to set AI phase on start.
- Improved BD_Patrol logic with better handling of stuck states and path failures.
- Enhanced BD_PatrolWaypoints to manage stuck conditions and retry logic more effectively.
- Refined BD_ReturnToHome to remove unnecessary animation calls.
- Updated BD_WalkRandom to ensure AI phase is set correctly on start.
- Improved EnemyAbilityBase to delegate target facing to the movement system.
- Enhanced EnemyBase with new movement methods for better control.
- Refactored EnemyMovement to introduce a new input system for handling movement and facing.
- Added EnemyMoveInput struct to encapsulate movement intentions.
- Updated Physics2DSettings to reflect new layer collision matrix.
- Introduced RTK CLI instructions for optimized command usage.
This commit is contained in:
2026-05-29 17:01:59 +08:00
parent e24ecc9589
commit bcd8b0e90b
19 changed files with 179534 additions and 175 deletions

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
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -613,7 +613,10 @@ namespace BaseGames.Editor
case "E004": SceneObjectPlacerTool.PlaceE004_ZhiMu_Enemy(); break; case "E004": SceneObjectPlacerTool.PlaceE004_ZhiMu_Enemy(); break;
case "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(); break; case "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(); break;
case "E006": SceneObjectPlacerTool.PlaceE006_Huan(); break; case "E006": SceneObjectPlacerTool.PlaceE006_Huan(); break;
default: SceneObjectPlacerTool.PlaceEnemy(); break; default:
Debug.LogError($"[CharacterWizardWindow] 未注册的敌人 id '{id}',请在 SceneObjectPlacerTool 中实现对应 PlaceE...() 方法并注册。");
SceneObjectPlacerTool.PlaceEnemy();
break;
} }
} }

View File

@@ -248,7 +248,10 @@ 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()
{ {
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");
@@ -256,16 +259,23 @@ namespace BaseGames.Editor
SetLayer(go, "Enemy", report); SetLayer(go, "Enemy", report);
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go); Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
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); GetOrAddComponent<CapsuleCollider2D>(go);
GetOrAddComponent<Animator>(go); GetOrAddComponent<Animator>(go);
SetupSpriteRenderer(go); AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(go);
SpriteRenderer sr = SetupSpriteRenderer(go);
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]
GetOrAddComponent<EnemySensorHub>(go);
// HurtBox child // HurtBox child
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -283,33 +293,38 @@ 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) AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied");
AssignAsset(enemyBase, "_onPlayerSpawned", report, false, "EVT_PlayerSpawned");
AssignAsset(enemyStats, "_onDifficultyChanged", report, false, "EVT_DifficultyChanged");
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
// Wire EnemyMovement
AssignReference(movement, "_animancer", animancer, 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"); Object dmgSrc = FindFirstAsset("CMB_DS_EnemyBody", "DS_EnemyBody");
if (dmgSrc != null) if (dmgSrc != null)
AssignReference(hitBox, "_defaultSource", dmgSrc, report); AssignReference(hitBox, "_defaultSource", dmgSrc, report);
else else
report.Add("未找到 DamageSourceSOHitBox_Body._defaultSource 未绑定。请按规范创建 CMB_DS_EnemyBody.asset。"); report.Add("未找到 DamageSourceSOHitBox_Body._defaultSource 未绑定。请创建 CMB_DS_EnemyBody.asset。");
// Event channels report.Add("★ 指定 EnemyBase._statsSO、_animConfig 资产(按所创建的敌人类型命名)。");
AssignAsset(enemyBase, "_onEnemyDied", report, false, "EVT_EnemyDied"); report.Add("★ 挂载行为树 BehaviorTree 组件,指定对应 .asset。");
AssignAsset(enemyBase, "_onPlayerSpawned", report, false, "EVT_PlayerSpawned");
AssignAsset(enemyStats, "_onDifficultyChanged", report, false, "EVT_DifficultyChanged");
AssignAsset(hurtBox, "_onDamageDealt", report, false, "EVT_DamageDealt");
AssignAsset(hurtBox, "_onHitConfirmed", report, false, "EVT_HitConfirmed");
// EnemyStatsSO (optional)
Object enemyStatsSO = FindFirstAsset("BasicEnemyStats", "EnemyStatsSO");
if (enemyStatsSO != null)
AssignReference(enemyBase, "_statsSO", enemyStatsSO, report);
else
report.Add("未找到 EnemyStatsSOEnemyBase._statsSO 未绑定。请在 Data/Enemies/ 创建 ENM_{id}_Stats.asset 后手动指定。");
report.Add("行为树、导航参数NavAgent、动画片段需后续手工挂载。");
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go; Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("Enemy (Basic)", go, report); MarkDirtyAndLog("Enemy (Basic)", go, report);
} }
@@ -413,6 +428,7 @@ 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]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -455,6 +471,9 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_E001_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_E001_AnimConfig");
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");
@@ -581,6 +600,7 @@ 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]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -618,8 +638,11 @@ 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, "_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);
@@ -668,6 +691,7 @@ 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]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -734,8 +758,11 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_E004_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_E004_AnimConfig");
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");
AssignAsset(acidAbl, "_config", report, false, "ABL_E004_Acid"); AssignAsset(acidAbl, "_config", report, false, "ABL_E004_Acid");
AssignAsset(chargeAbl, "_config", report, false, "ABL_E004_Charge"); AssignAsset(chargeAbl, "_config", report, false, "ABL_E004_Charge");
@@ -794,6 +821,7 @@ 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]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -837,6 +865,9 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_E005_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_E005_AnimConfig");
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");
@@ -887,6 +918,7 @@ 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]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go); EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox"); Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -936,8 +968,11 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_E006_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_E006_AnimConfig");
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);
@@ -992,6 +1027,7 @@ 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);
@@ -1044,6 +1080,9 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_ChaoFeng_AnimConfig"); AssignAsset(movement, "_animConfig", report, false, "ENM_ChaoFeng_AnimConfig");
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>();

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

@@ -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

@@ -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

@@ -9,34 +9,19 @@ namespace BaseGames.Enemies.AI
{ {
/// <summary> /// <summary>
/// BD Action来回踱步巡逻——持续向当前方向移动遇墙或悬崖时自动翻转方向。 /// BD Action来回踱步巡逻——持续向当前方向移动遇墙或悬崖时自动翻转方向。
/// 转向检测依赖 EnemySensorHub 的 "wall_ahead" / "ledge" 槽SensorToolkit
/// ///
/// 若需要按预设路点顺序巡逻,请使用 <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("来回踱步巡逻:遇墙或悬崖自动翻转方向(需配置 EnemySensorHub wall_ahead / ledge 槽")]
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 EnemySensorHub _hub;
private float _dir = 1f; private float _dir = 1f;
private float _flipCooldown; // 翻转后短暂冷却,等待 RaySensor2D 刷新到新朝向
// 缓存SensorHub 中对应槽位是否已配置Awake 时查询一次,避免每帧 Dictionary 查找) // 缓存SensorHub 中对应槽位是否已配置Awake 时查询一次,避免每帧 Dictionary 查找)
private bool _hasWallSensor; private bool _hasWallSensor;
@@ -50,14 +35,27 @@ namespace BaseGames.Enemies.AI
_hasEdgeSensor = _hub != null && _hub.Get(SensorSlotNames.Ledge) != 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 (_enemy?.Movement != null)
_dir = _enemy.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()) // 翻转冷却期间跳过传感器检测(等待 RaySensor2D 在新朝向完成刷新)
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 +65,11 @@ namespace BaseGames.Enemies.AI
private bool ShouldFlip() private bool ShouldFlip()
{ {
if (_hub != null) // 转身进行中时不重复检测,防止 _dir 在转身期间被传感器残留信号反复翻转
{ if (_enemy.Movement != null && _enemy.Movement.IsTurning) return false;
// 有传感器配置:用传感器结果,完全跳过 Raycast bool wallHit = _hasWallSensor && _hub.HasAnyDetection(SensorSlotNames.WallAhead);
if (_hasWallSensor || _hasEdgeSensor) bool edgeHit = _hasEdgeSensor && _hub.HasAnyDetection(SensorSlotNames.Ledge);
{ return wallHit || edgeHit;
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 =>
@@ -68,9 +79,19 @@ namespace BaseGames.Enemies.AI
public override void OnStart() public override void OnStart()
{ {
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,38 +107,73 @@ namespace BaseGames.Enemies.AI
_waiting = false; _waiting = false;
Advance(); Advance();
RequestCurrent(); RequestCurrent();
return TaskStatus.Running;
} }
else
{
Vector2 wp = GetWaypoint(_index);
float sqrDist = ((Vector2)_enemy.transform.position - wp).sqrMagnitude;
if (sqrDist <= m_ArriveRadius * m_ArriveRadius) // 兜底重试:用时间门控避免与 PB2d 的 Finished-状态路径请求产生竞争。
// 首次启动前_hasMoved=false使用短延迟处理 NavAgent 位置映射尚未就绪的情况;
// 正常巡逻中使用长延迟,确保在途路径请求已被 HandlePathRequest 消费完毕。
if (_enemy.Nav != null)
{
if (!_enemy.Nav.IsMoving)
{ {
if (m_WaitAtWaypoint > 0f) _stuckTimer += Time.deltaTime;
float retryDelay = _hasMoved ? StuckRetryDelay : InitialRetryDelay;
if (_stuckTimer >= retryDelay)
{ {
_waiting = true; _stuckTimer = 0f;
_waitTimer = m_WaitAtWaypoint; _pathFailed = false;
_enemy.StopMovement();
}
else
{
Advance();
RequestCurrent(); RequestCurrent();
} }
} }
else else
{ {
_enemy.MoveTo(wp); _hasMoved = true;
_enemy.Movement?.FaceTarget(wp); _stuckTimer = 0f;
} }
} }
return TaskStatus.Running; return TaskStatus.Running;
} }
public override void OnEnd() => _enemy?.StopMovement(); 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)
{
_waiting = true;
_waitTimer = m_WaitAtWaypoint;
_enemy?.StopMovement();
}
else
{
Advance();
RequestCurrent();
}
}
// 路径失败时不在回调中立即重试——此时 NavAgent.HandlePathRequest 尚未调用
// currentPathRequest.Reset(),直接提交新请求会被随后的 Reset() 覆盖清除。
// 改为设置标志,交由 OnUpdate 的计时兜底在下一帧安全重试。
private void HandlePathFailed()
{
_pathFailed = true;
_stuckTimer = 0f; // 重置计时器,使兜底在 StuckRetryDelay 后触发
}
private void RequestCurrent() private void RequestCurrent()
{ {
if (WaypointCount == 0) return; if (WaypointCount == 0) return;

View File

@@ -54,12 +54,6 @@ 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;
if (_enemy.Animancer != null && ac?.Walk != null)
_enemy.Animancer.Play(ac.Walk);
_enemy.MoveTo(_enemy.HomePosition); _enemy.MoveTo(_enemy.HomePosition);
} }
@@ -85,8 +79,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

@@ -149,15 +149,11 @@ namespace BaseGames.Enemies.Abilities
protected virtual void OnInterrupted(InterruptReason reason) { } protected virtual void OnInterrupted(InterruptReason reason) { }
/// <summary>子类辅助:朝向目标。</summary> /// <summary>子类辅助:朝向目标。委托给 EnemyMovement.FaceTarget 以保持转身动画系统一致。</summary>
protected void FaceTarget(Transform target) protected void FaceTarget(Transform target)
{ {
if (target == null || _enemy == null) return; if (target == null || _enemy?.Movement == null) return;
float dx = target.position.x - _transform.position.x; _enemy.Movement.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

@@ -100,6 +100,18 @@ namespace BaseGames.Enemies
/// 由 BD_ChasePlayer 在持有视线时每帧更新;视线丢失后保留最后记录值供 BD_InvestigateLastKnown 使用。 /// 由 BD_ChasePlayer 在持有视线时每帧更新;视线丢失后保留最后记录值供 BD_InvestigateLastKnown 使用。
/// </summary> /// </summary>
public Vector2 LastKnownPlayerPosition { get; set; } public Vector2 LastKnownPlayerPosition { get; set; }
#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
@@ -219,12 +231,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>
@@ -279,8 +303,24 @@ 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>
/// 搜查"环顾"子步骤:停止移动,播放原地环顾动画。
/// 由搜查行为触发;动画细节由角色自己决定,外部无需感知 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 +456,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,6 +540,7 @@ 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.EnemySensorHub>();
_statusEffects = GetComponent<StatusEffects.EnemyStatusEffectManager>(); _statusEffects = GetComponent<StatusEffects.EnemyStatusEffectManager>();
@@ -507,8 +549,9 @@ namespace BaseGames.Enemies
_abilities.CollectFrom(gameObject); _abilities.CollectFrom(gameObject);
_colliders = GetComponentsInChildren<Collider2D>(true); _colliders = GetComponentsInChildren<Collider2D>(true);
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 +599,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
} }
@@ -768,6 +820,26 @@ namespace BaseGames.Enemies
UnityEditor.Handles.matrix = prevM; UnityEditor.Handles.matrix = prevM;
} }
// ── 运行时AI 状态标签(常态可见,无需选中)────────────────
if (Application.isPlaying)
{
Color phaseColor = _currentAiPhase switch
{
AiPhase.Idle => Color.gray,
AiPhase.Patrol => Color.green,
AiPhase.Alert => Color.yellow,
AiPhase.Chase => new Color(1f, 0.5f, 0f),
AiPhase.Combat => Color.red,
AiPhase.Investigate => Color.cyan,
AiPhase.ReturnHome => Color.blue,
_ => 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;
@@ -814,7 +886,7 @@ namespace BaseGames.Enemies
UnityEditor.Handles.matrix = prevM; UnityEditor.Handles.matrix = prevM;
} }
// 运行时AiPhase 彩色圆 + 状态标签 // 运行时:选中时绘制 AiPhase 彩色外圆(突出显示当前状态)
if (Application.isPlaying) if (Application.isPlaying)
{ {
Color phaseColor = _currentAiPhase switch Color phaseColor = _currentAiPhase switch
@@ -830,11 +902,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

@@ -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

@@ -50,12 +50,31 @@ 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>当前朝向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;
#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_IsTurning;
[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 =
new[] { NavLinkType.Jump, NavLinkType.Fall }; new[] { NavLinkType.Jump, NavLinkType.Fall };
@@ -136,6 +155,25 @@ namespace BaseGames.Enemies
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>();
// 从 Sprite 或 localScale 的初始状态推断朝向,并统一切换为 localScale 翻转。
// 这样子对象(含 RaySensor2D会随 localScale 正确翻转,不再依赖 flipX。
if (_spriteRenderer != null)
{
// 两个信号均可能携带初始朝向信息flipX 或 localScale.x < 0
// XOR 组合:恰好一个翻转 → 面左;两个都翻(互相抵消)→ 面右。
bool flippedBySprite = _spriteRenderer.flipX;
bool flippedByScale = transform.localScale.x < 0f;
_facingDir = (flippedBySprite ^ flippedByScale) ? -1 : 1;
_spriteRenderer.flipX = false; // 后续由 localScale 驱动,避免双重镜像
Vector3 s = transform.localScale;
transform.localScale = new Vector3(Mathf.Abs(s.x) * _facingDir, s.y, s.z);
}
else
{
_facingDir = transform.localScale.x >= 0f ? 1 : -1;
}
if (_enableTurnAnimation) if (_enableTurnAnimation)
{ {
if (_animancer == null) _animancer = GetComponentInParent<AnimancerComponent>(true); if (_animancer == null) _animancer = GetComponentInParent<AnimancerComponent>(true);
@@ -147,9 +185,71 @@ namespace BaseGames.Enemies
} }
} }
private void OnDisable()
{
// 持久信号在对象禁用时必须清零,防止重新启用时继承残留移动状态。
PendingInput = default;
StopHorizontal();
}
private void FixedUpdate() private void FixedUpdate()
{ {
IsGrounded = IsGroundedCheck(); IsGrounded = IsGroundedCheck();
#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_IsTurning = _isTurning;
#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;
// ── 持久字段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>
@@ -267,18 +367,13 @@ namespace BaseGames.Enemies
_isTurning = true; _isTurning = true;
StopHorizontal(); StopHorizontal();
_animancer.Play(_animConfig.Turn); // yield return stateAnimancer 的 AnimancerState 是 CustomYieldInstruction
float elapsed = 0f; // 等待动画自然播完,与 Layer/State 速度缩放无关,比手动计时更可靠。
float duration = _animConfig.Turn.length; var state = _animancer.Play(_animConfig.Turn);
yield return state;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
yield return null;
}
ApplyFacingFlip(newDir); ApplyFacingFlip(newDir);
_isTurning = false; _isTurning = false;
_turnCoroutine = null; _turnCoroutine = null;
} }
@@ -298,17 +393,15 @@ 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;
{ transform.localScale = new Vector3(Mathf.Abs(s.x) * newDir, s.y, s.z);
Vector3 s = transform.localScale;
transform.localScale = new Vector3(Mathf.Abs(s.x) * newDir, s.y, s.z);
}
} }
private void OnDrawGizmos() private void OnDrawGizmos()
@@ -326,6 +419,18 @@ 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);
Vector3 origin = transform.position;
Gizmos.DrawLine(origin, origin + Vector3.down * _groundCheckDist);
Gizmos.DrawWireSphere(origin + Vector3.down * _groundCheckDist, 0.04f);
}
#endif #endif
} }

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