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:
27
.github/copilot-instructions.md
vendored
Normal file
27
.github/copilot-instructions.md
vendored
Normal 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
12
.github/hooks/rtk-rewrite.json
vendored
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("未找到 DamageSourceSO,HitBox_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("未找到 EnemyStatsSO,EnemyBase._statsSO 未绑定。请在 Data/Enemies/ 创建 ENM_{id}_Stats.asset 后手动指定。");
|
||||
report.Add("未找到 DamageSourceSO,HitBox_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,6 +758,9 @@ 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(slamAbl, "_config", report, false, "ABL_E004_Slam");
|
||||
@@ -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,6 +968,9 @@ 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(chaseAbl, "_config", report, false, "ABL_E006_Chase");
|
||||
@@ -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>();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
38
Assets/_Game/Scripts/Enemies/EnemyMoveInput.cs
Normal file
38
Assets/_Game/Scripts/Enemies/EnemyMoveInput.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Enemies/EnemyMoveInput.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/EnemyMoveInput.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b496b4a0673bb1548869cc1f78420024
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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 state:Animancer 的 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 = false(localScale 已负责镜像,避免双重翻转)。
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -45,4 +45,4 @@ Physics2DSettings:
|
||||
m_ReuseCollisionCallbacks: 1
|
||||
m_AutoSyncTransforms: 0
|
||||
m_GizmoOptions: 10
|
||||
m_LayerCollisionMatrix: ffffffffffffffffffffffffbffffffffffffffffffffffff7ebfffffff0ffff7fffffff7fdfffff3fffffff7fbfffffbffffef7ffbdffffff57ffdfffbffeffff6fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdfffffffffffefffffffffffffffbffffdffffffffffffffff
|
||||
m_LayerCollisionMatrix: ffffffffffffffffffffffffbffffffffffffffffffffffff7ebfffffff0ffff7fffffff7fdfffff3fffffff7fbfffffbffffef7ff9dffffff57ffdfffbffeffff6fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdfffffffffffefffffffffffffffbffffdffffffffffffffff
|
||||
|
||||
Reference in New Issue
Block a user