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 "E005": SceneObjectPlacerTool.PlaceE005_FeiZhi_Enemy(); break;
case "E006": SceneObjectPlacerTool.PlaceE006_Huan(); break;
default: SceneObjectPlacerTool.PlaceEnemy(); break;
default:
Debug.LogError($"[CharacterWizardWindow] 未注册的敌人 id '{id}',请在 SceneObjectPlacerTool 中实现对应 PlaceE...() 方法并注册。");
SceneObjectPlacerTool.PlaceEnemy();
break;
}
}

View File

@@ -249,6 +249,9 @@ namespace BaseGames.Editor
public static void PlaceEnemy()
{
var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place Basic Enemy");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("BasicEnemy");
Undo.RegisterCreatedObjectUndo(go, "Place Enemy");
@@ -259,13 +262,20 @@ namespace BaseGames.Editor
rb.bodyType = RigidbodyType2D.Dynamic;
rb.gravityScale = 2f;
rb.constraints = RigidbodyConstraints2D.FreezeRotation;
rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
GetOrAddComponent<CapsuleCollider2D>(go);
GetOrAddComponent<Animator>(go);
SetupSpriteRenderer(go);
AnimancerComponent animancer = GetOrAddComponent<AnimancerComponent>(go);
SpriteRenderer sr = SetupSpriteRenderer(go);
EnemyBase enemyBase = GetOrAddComponent<EnemyBase>(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
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -283,33 +293,38 @@ namespace BaseGames.Editor
HitBox hitBox = GetOrAddComponent<HitBox>(hitBodyT.gameObject);
GetOrAddComponent<BodyContactDamage>(hitBodyT.gameObject);
// References
// Wire EnemyBase
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, "_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);
// 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");
if (dmgSrc != null)
AssignReference(hitBox, "_defaultSource", dmgSrc, report);
else
report.Add("未找到 EnemyStatsSOEnemyBase._statsSO 未绑定。请在 Data/Enemies/ 创建 ENM_{id}_Stats.asset 后手动指定。");
report.Add("未找到 DamageSourceSOHitBox_Body._defaultSource 未绑定。请创建 CMB_DS_EnemyBody.asset。");
report.Add("行为树、导航参数NavAgent、动画片段需后续手工挂载。");
report.Add("★ 指定 EnemyBase._statsSO、_animConfig 资产(按所创建的敌人类型命名)。");
report.Add("★ 挂载行为树 BehaviorTree 组件,指定对应 .asset。");
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("Enemy (Basic)", go, report);
}
@@ -413,6 +428,7 @@ namespace BaseGames.Editor
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -455,6 +471,9 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_E001_AnimConfig");
AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", sr1, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignAsset(alertAbility, "_config", report, false, "ABL_E001_Alert");
AssignAsset(chaseAbility, "_config", report, false, "ABL_E001_Chase");
@@ -581,6 +600,7 @@ namespace BaseGames.Editor
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -620,6 +640,9 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_E003_AnimConfig");
AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", sr3, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignAsset(fallAbility, "_config", report, false, "ABL_E003_Fall");
AssignReference(fallAbility, "_contactDamage", bodyContact, report);
@@ -668,6 +691,7 @@ namespace BaseGames.Editor
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -734,8 +758,11 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_E004_AnimConfig");
AssignReference(movement, "_animancer", animancer, 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(acidAbl, "_config", report, false, "ABL_E004_Acid");
AssignAsset(chargeAbl, "_config", report, false, "ABL_E004_Charge");
@@ -794,6 +821,7 @@ namespace BaseGames.Editor
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -837,6 +865,9 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_E005_AnimConfig");
AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", sr5, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignAsset(biteAbl, "_config", report, false, "ABL_E005_Bite");
AssignAsset(acidAbl, "_config", report, false, "ABL_E005_Acid");
@@ -887,6 +918,7 @@ namespace BaseGames.Editor
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
EnemySensorHub sensorHub = GetOrAddComponent<EnemySensorHub>(go);
Transform hurtBoxT = GetOrCreateChild(go.transform, "HurtBox");
@@ -936,8 +968,11 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_E006_AnimConfig");
AssignReference(movement, "_animancer", animancer, 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");
AssignReference(leapAbl, "_landingHitBox", landHitBox, report);
@@ -992,6 +1027,7 @@ namespace BaseGames.Editor
EnemyMovement movement = GetOrAddComponent<EnemyMovement>(go);
GetOrAddComponent<EnemyNavAgent>(go);
GetOrAddComponent<NavAgent>(go);
GetOrAddComponent<TransformBasedMovement>(go); // required by EnemyNavAgent [RequireComponent]
BossSkillExecutor skillExec = GetOrAddComponent<BossSkillExecutor>(go);
ChaoFengFloatController floatCtrl = GetOrAddComponent<ChaoFengFloatController>(go);
ChaoFengKnockdownCounter knockdown = GetOrAddComponent<ChaoFengKnockdownCounter>(go);
@@ -1044,6 +1080,9 @@ namespace BaseGames.Editor
AssignAsset(movement, "_animConfig", report, false, "ENM_ChaoFeng_AnimConfig");
AssignReference(movement, "_animancer", animancer, report);
AssignReference(movement, "_spriteRenderer", srBoss, report);
AssignLayerMask(movement, "_groundMask",
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
// Collect BossSkillSOs and assign to executor
var skillAssets = new System.Collections.Generic.List<Object>();

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,34 +9,19 @@ namespace BaseGames.Enemies.AI
{
/// <summary>
/// BD Action来回踱步巡逻——持续向当前方向移动遇墙或悬崖时自动翻转方向。
/// 转向检测依赖 EnemySensorHub 的 "wall_ahead" / "ledge" 槽SensorToolkit
///
/// 若需要按预设路点顺序巡逻,请使用 <see cref="BD_PatrolWaypoints"/>(支持 Transform 引用和内联坐标)。
///
/// 转向检测优先级:
/// <list type="number">
/// <item>EnemySensorHub "wall_ahead" / "ledge" 槽SensorToolkit已配置时使用</item>
/// <item>Physics2D Raycast 兜底Prefab 未配置 Sensor 时自动启用)</item>
/// </list>
/// </summary>
[TaskName("Patrol (Pace)")]
[TaskCategory("BaseGames/Enemy/Movement")]
[TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(SensorToolkit 优先")]
[TaskDescription("来回踱步巡逻:遇墙或悬崖自动翻转方向(需配置 EnemySensorHub wall_ahead / ledge 槽")]
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 EnemySensorHub _hub;
private float _dir = 1f;
private float _flipCooldown; // 翻转后短暂冷却,等待 RaySensor2D 刷新到新朝向
// 缓存SensorHub 中对应槽位是否已配置Awake 时查询一次,避免每帧 Dictionary 查找)
private bool _hasWallSensor;
@@ -50,14 +35,27 @@ namespace BaseGames.Enemies.AI
_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()
{
if (_enemy == null) return TaskStatus.Failure;
if (ShouldFlip())
// 翻转冷却期间跳过传感器检测(等待 RaySensor2D 在新朝向完成刷新)
if (_flipCooldown > 0f)
_flipCooldown -= Time.deltaTime;
else if (ShouldFlip())
{
_dir = -_dir;
_flipCooldown = 0.1f; // ~6 帧缓冲60 fps防止传感器残留信号导致抖动
}
_enemy.MoveInDirection(_dir);
return TaskStatus.Running;
@@ -67,29 +65,13 @@ namespace BaseGames.Enemies.AI
private bool ShouldFlip()
{
if (_hub != null)
{
// 有传感器配置:用传感器结果,完全跳过 Raycast
if (_hasWallSensor || _hasEdgeSensor)
{
// 转身进行中时不重复检测,防止 _dir 在转身期间被传感器残留信号反复翻转
if (_enemy.Movement != null && _enemy.Movement.IsTurning) return false;
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;
}
}
}
#endif

View File

@@ -38,11 +38,22 @@ namespace BaseGames.Enemies.AI
[Tooltip("每个路点到达后等待时长s")]
[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 int _index = 0;
private int _dir = 1;
private float _waitTimer = 0f;
private bool _waiting = false;
private float _stuckTimer = 0f;
private bool _pathFailed = false;
private bool _hasMoved = false; // 首次成功开始移动后置 true
// ── 统一路点访问 ────────────────────────────────────────────────────
private int WaypointCount =>
@@ -70,7 +81,17 @@ namespace BaseGames.Enemies.AI
if (WaypointCount == 0) return;
_waiting = false;
_waitTimer = 0f;
_stuckTimer = 0f;
_pathFailed = false;
_hasMoved = false;
_enemy?.SetAiPhase(AiPhase.Patrol);
if (_enemy?.Nav != null)
{
_enemy.Nav.OnGoalReached += HandleGoalReached;
_enemy.Nav.OnNavPathFailed += HandlePathFailed;
}
RequestCurrent();
}
@@ -86,19 +107,56 @@ namespace BaseGames.Enemies.AI
_waiting = false;
Advance();
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
{
Vector2 wp = GetWaypoint(_index);
float sqrDist = ((Vector2)_enemy.transform.position - wp).sqrMagnitude;
_hasMoved = true;
_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)
{
_waiting = true;
_waitTimer = m_WaitAtWaypoint;
_enemy.StopMovement();
_enemy?.StopMovement();
}
else
{
@@ -106,18 +164,16 @@ namespace BaseGames.Enemies.AI
RequestCurrent();
}
}
else
// 路径失败时不在回调中立即重试——此时 NavAgent.HandlePathRequest 尚未调用
// currentPathRequest.Reset(),直接提交新请求会被随后的 Reset() 覆盖清除。
// 改为设置标志,交由 OnUpdate 的计时兜底在下一帧安全重试。
private void HandlePathFailed()
{
_enemy.MoveTo(wp);
_enemy.Movement?.FaceTarget(wp);
}
}
return TaskStatus.Running;
_pathFailed = true;
_stuckTimer = 0f; // 重置计时器,使兜底在 StuckRetryDelay 后触发
}
public override void OnEnd() => _enemy?.StopMovement();
// ── 内部辅助 ────────────────────────────────────────────────────────
private void RequestCurrent()
{
if (WaypointCount == 0) return;

View File

@@ -54,12 +54,6 @@ namespace BaseGames.Enemies.AI
// 切换为行走速度
float walkSpeed = _enemy.Stats?.WalkSpeed ?? 2f;
_enemy.Nav?.SetSpeed(walkSpeed);
// 播放行走动画
var ac = _enemy.AnimConfig;
if (_enemy.Animancer != null && ac?.Walk != null)
_enemy.Animancer.Play(ac.Walk);
_enemy.MoveTo(_enemy.HomePosition);
}
@@ -85,8 +79,8 @@ namespace BaseGames.Enemies.AI
{
_enemy.Nav.OnGoalReached -= HandleReached;
_enemy.Nav.OnNavPathFailed -= HandleFailed;
_subscribed = false;
}
_subscribed = false;
}
private TaskStatus CompleteReturn()

View File

@@ -23,12 +23,16 @@ namespace BaseGames.Enemies.AI
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()
{
if (_enemy == null) return TaskStatus.Failure;
if (_enemy.Nav.IsAtDestination()) TryWalk();
if (_enemy.Nav?.IsAtDestination() ?? true) TryWalk();
return TaskStatus.Running;
}
@@ -37,7 +41,7 @@ namespace BaseGames.Enemies.AI
private void TryWalk()
{
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) { }
/// <summary>子类辅助:朝向目标。</summary>
/// <summary>子类辅助:朝向目标。委托给 EnemyMovement.FaceTarget 以保持转身动画系统一致。</summary>
protected void FaceTarget(Transform target)
{
if (target == null || _enemy == null) return;
float dx = target.position.x - _transform.position.x;
if (Mathf.Abs(dx) < 0.001f) return;
var s = _transform.localScale;
s.x = Mathf.Abs(s.x) * Mathf.Sign(dx);
_transform.localScale = s;
if (target == null || _enemy?.Movement == null) return;
_enemy.Movement.FaceTarget(target.position);
}
}

View File

@@ -100,6 +100,18 @@ namespace BaseGames.Enemies
/// 由 BD_ChasePlayer 在持有视线时每帧更新;视线丢失后保留最后记录值供 BD_InvestigateLastKnown 使用。
/// </summary>
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 不变。
// 子类可在 Awake() 重写条目注入自定义状态对象。
protected readonly System.Collections.Generic.Dictionary<EnemyStateType, IEnemyState> _stateObjs
@@ -219,12 +231,24 @@ namespace BaseGames.Enemies
=> _nav?.RequestMoveTo(target);
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()
{
_nav?.StopNavigation();
_movement?.StopHorizontal();
if (_movement != null) _movement.PendingInput.WantStop = true;
}
/// <summary>施加状态效果(需要 EnemyStatusEffectManager 组件)。同类型效果自动刷新。</summary>
@@ -279,8 +303,24 @@ namespace BaseGames.Enemies
public virtual void FacePlayer()
{
if (_playerTransform != null)
_movement?.FaceTarget(_playerTransform.position);
if (_movement == null || _playerTransform == null) return;
_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)
@@ -416,6 +456,7 @@ namespace BaseGames.Enemies
AiPhase.Alert => _animConfig.Alert,
AiPhase.Investigate => _animConfig.Investigate ?? _animConfig.Walk,
AiPhase.Patrol => _animConfig.Walk,
AiPhase.ReturnHome => _animConfig.Walk,
AiPhase.Chase => _animConfig.Run,
AiPhase.Idle => _animConfig.Idle,
_ => null,
@@ -499,6 +540,7 @@ namespace BaseGames.Enemies
_stateObjs[EnemyStateType.Dead] = new EnemyDeadState();
_nav = GetComponent<IPathAgent>() ?? new NullPathAgent();
if (_movement == null) _movement = GetComponent<EnemyMovement>();
_poiseSource = GetComponent<IPoiseSource>();
_sensorHub = GetComponentInChildren<Perception.EnemySensorHub>();
_statusEffects = GetComponent<StatusEffects.EnemyStatusEffectManager>();
@@ -509,6 +551,7 @@ namespace BaseGames.Enemies
Debug.Assert(_statsSO != null, "[EnemyBase] _statsSO 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this);
Debug.Assert(_stats != null, "[EnemyBase] _stats 未绑定,请在 Prefab Inspector 中绑定 EnemyStats 组件。", this);
Debug.Assert(_movement != null, "[EnemyBase] _movement 未找到,请确保同 GameObject 上挂有 EnemyMovement 组件。", this);
_stats.Initialize(_statsSO);
// 订阅玩家生成事件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
}
@@ -768,6 +820,26 @@ namespace BaseGames.Enemies
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 连线 ────────────────────────────────────────
if (!Application.isPlaying || _playerTransform == null) return;
@@ -814,7 +886,7 @@ namespace BaseGames.Enemies
UnityEditor.Handles.matrix = prevM;
}
// 运行时AiPhase 彩色圆 + 状态标签
// 运行时:选中时绘制 AiPhase 彩色外圆(突出显示当前状态)
if (Application.isPlaying)
{
Color phaseColor = _currentAiPhase switch
@@ -830,11 +902,6 @@ namespace BaseGames.Enemies
};
Gizmos.color = phaseColor;
Gizmos.DrawWireSphere(transform.position, 0.5f);
UnityEditor.Handles.color = phaseColor;
UnityEditor.Handles.Label(
transform.position + Vector3.up * 1.0f,
$"{_currentAiPhase} | {_currentState}");
}
#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 Coroutine _turnCoroutine;
// ── 输入信号BD 任务在 Update 写入FixedUpdate 消费后自动清零)──
public EnemyMoveInput PendingInput;
public bool IsGrounded { get; private set; }
/// <summary>当前朝向1 = 右,-1 = 左。</summary>
public int FacingDirection => _facingDir;
/// <summary>当前是否正在播放转身动画(移动输入在此期间被屏蔽)。</summary>
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 ────────────────────────────────────────────
private static readonly NavLinkType[] _handledTypes =
new[] { NavLinkType.Jump, NavLinkType.Fall };
@@ -136,6 +155,25 @@ namespace BaseGames.Enemies
Debug.Assert(_config != null, "[EnemyMovement] _config 未赋值,请在 Prefab Inspector 中指定 EnemyStatsSO。", this);
_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 (_animancer == null) _animancer = GetComponentInParent<AnimancerComponent>(true);
@@ -147,9 +185,71 @@ namespace BaseGames.Enemies
}
}
private void OnDisable()
{
// 持久信号在对象禁用时必须清零,防止重新启用时继承残留移动状态。
PendingInput = default;
StopHorizontal();
}
private void FixedUpdate()
{
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>
@@ -267,15 +367,10 @@ namespace BaseGames.Enemies
_isTurning = true;
StopHorizontal();
_animancer.Play(_animConfig.Turn);
float elapsed = 0f;
float duration = _animConfig.Turn.length;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
yield return null;
}
// yield return stateAnimancer 的 AnimancerState 是 CustomYieldInstruction
// 等待动画自然播完,与 Layer/State 速度缩放无关,比手动计时更可靠。
var state = _animancer.Play(_animConfig.Turn);
yield return state;
ApplyFacingFlip(newDir);
_isTurning = false;
@@ -298,18 +393,16 @@ namespace BaseGames.Enemies
}
}
/// <summary>真正执行朝向翻转(修改 SpriteRenderer.flipX 或 localScale。</summary>
/// <summary>真正执行朝向翻转。始终用 localScale 翻转,子对象(传感器 RaySensor2D随之正确翻转。</summary>
private void ApplyFacingFlip(int newDir)
{
_facingDir = newDir;
// 若挂有 SpriteRenderer重置 flipX = falselocalScale 已负责镜像,避免双重翻转)。
if (_spriteRenderer != null)
_spriteRenderer.flipX = newDir < 0;
else
{
_spriteRenderer.flipX = false;
Vector3 s = transform.localScale;
transform.localScale = new Vector3(Mathf.Abs(s.x) * newDir, s.y, s.z);
}
}
private void OnDrawGizmos()
{
@@ -326,6 +419,18 @@ namespace BaseGames.Enemies
Vector3 center = transform.position;
DrawArrow2D(center, center + new Vector3(_facingDir * 0.5f, 0f, 0f),
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
}

View File

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