Created
December 20, 2025 08:59
-
-
Save koras/ccb2004e482b7e83aca4e89b43e3ed0a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using UnityEngine; | |
| using UnityEngine.AI; | |
| using Weapons; | |
| using System; // ← добавь для Action | |
| using Weapons.Range; | |
| #if UNITY_EDITOR | |
| using UnityEditor; | |
| #endif | |
| namespace Heroes | |
| { | |
| public class WarriorAI : MonoBehaviour | |
| { | |
| // ===== ПАРАМЕТРЫ ПОВЕДЕНИЯ ===== | |
| [Header("Маркировка цели")] | |
| [SerializeField] private bool showTargetDebug = true; | |
| [SerializeField] private Color targetColor = Color.red; | |
| [SerializeField] private GameObject targetMarkerPrefab; | |
| [SerializeField] private bool _controlledHero = false; | |
| private GameObject targetMarker; | |
| float _senseTimer; | |
| float _senseTimerBoss; | |
| // [Header("Логика выбора цели")] | |
| // [SerializeField] private bool retargetInSight = false; // по умолчанию ведем себя по-старому | |
| [Header("Может атаковать")] | |
| [SerializeField] public bool canAttack = true; | |
| [SerializeField] private string namePNS = "NoName"; | |
| public NavMeshAgent Agent => _agent; | |
| // [SerializeField] private float roamingDistanceMax = 7f; // максимальная дистанция для блуждания | |
| [SerializeField] private float roamWaitTime = 2f; // сколько стоим на точке | |
| [SerializeField] private float roamStoppingDistance = 0.05f; // на сколько близко подходим к точке | |
| private Vector3 _roamTarget; | |
| private float _roamWaitTimer; | |
| private bool _hasRoamPoint; | |
| [Header("Скорость")] | |
| [SerializeField] private float _moveSpeed = 1f; // для боя / движения к боссу | |
| [SerializeField] private float _roamSpeed = 0.15f; // для роуминга (можно = moveSpeed) | |
| private bool _deathHandled; // <- чтобы не выполнить OnDeath дважды | |
| [Header("Идентификация")] | |
| [SerializeField] private LayerMask unitMask; // слой, где находятся другие юниты | |
| [Header("Параметры дистанций зрения")] [SerializeField] | |
| private float sightRadius = 4f; | |
| [Header("дистанция, на которой юнит начинает атаковать")] [SerializeField] | |
| private float _attackingDistance = 1f; // Радиус атаки | |
| // множители дистанции для входа/выхода из атаки | |
| [SerializeField] private float attackEnterMul = 0.8f; // начинаем атаку ближе | |
| [SerializeField] private float attackExitMul = 1.2f; // выходим из атаки, если цель сильно отошла | |
| private float AttackEnterDistance => _attackingDistance * attackEnterMul; | |
| private float AttackExitDistance => _attackingDistance * attackExitMul; | |
| [Header("частота обновления пути к цели")] [SerializeField] | |
| private float repathRate = 0.25f; // частота обновления пути к цели | |
| [SerializeField] private float flipThreshold = 0.02f; // мёртвая зона по скорости | |
| [Header("Атака")] [SerializeField] private float attackRate = 1.2f; // количество атак в секунду | |
| [SerializeField] private float debugInterval = 0.2f; | |
| [Header("Оружие общее")] [SerializeField] private WeaponBase weapon; // у каждого героя своё оружие | |
| public WeaponBase Weapon => weapon; // ← публичный геттер | |
| [Header("Оружие Лича")] | |
| [SerializeField] private LichWeapon _weaponLichFireball; | |
| // [SerializeField] private LichFireballAbility _weaponLichFireball; // у каждого героя своё оружие | |
| // public WeaponBase WeaponLichFireball => _weaponLichFireball; // ← публичный геттер | |
| // Позиция куда будет акатковать Лич | |
| private Vector3 _targetPosition; | |
| // [Header("Анимация персоны")] [SerializeField] | |
| private BaseVisualCharacter _character; | |
| // ===== КОМПОНЕНТЫ И ПЕРЕМЕННЫЕ ===== | |
| private NavMeshAgent _agent; // агент навигации Unity | |
| private float _attackCd; // кулдаун атаки | |
| private float _baseSpeed; // базовая скорость агента | |
| private Transform _boss; // цель (босс), к которому идём | |
| private Transform _currentTarget; // текущая цель атаки | |
| // Внутри WarriorAI | |
| public Transform CurrentTarget => _currentTarget; | |
| private float _dbgTimer; | |
| private int _lookDir = +1; | |
| [Header("Здоровье")] | |
| [SerializeField] private HeroesBase _heroesBase; | |
| private float _repathCd; // кулдаун пересчёта пути | |
| [Header("текущее состояние")] [SerializeField] | |
| private State _state = State.Appear; // текущее состояние | |
| [Header("Логирование")] | |
| [SerializeField] private bool debugAI; | |
| // относится только к личу | |
| private LichFireballAbility _lichFireball; | |
| public LichFireballAbility LichFireball => _lichFireball; | |
| /// <summary> | |
| /// Вызывается каждый раз при смене состояния ИИ (Idle/MovingToBoss/Chasing/Attacking/Death). | |
| /// На это событие может подписаться визуал, чтобы реагировать на смену (вкл/выкл бега и т.п.) | |
| /// </summary> | |
| public event Action<State> StateChanged; | |
| // [Header("компонент здоровья текущей цели")] | |
| private HeroesBase _targetHealth; // компонент здоровья текущей цели | |
| public bool IsSelected { get; private set; } | |
| public bool IsManualControl { get; private set; } | |
| private Vector3 _manualDestination; | |
| // Вверху класса | |
| private readonly Collider2D[] _senseHits = new Collider2D[16]; // подбери размер под свой максимум | |
| [SerializeField] private float senseInterval = 0.3f; | |
| // вызвать, когда кликнули по герою | |
| public void SetSelected(bool value) | |
| { | |
| IsSelected = value; | |
| DLog($"[{namePNS}] Показываем овал"); | |
| // if (value) | |
| // { | |
| _heroesBase.ShowOval(); | |
| // } | |
| // else | |
| // { | |
| // _heroesBase.HideOval(); | |
| // } | |
| } | |
| private void OnEnable() | |
| { | |
| if (_heroesBase == null) _heroesBase = GetComponent<HeroesBase>(); | |
| // ВАЖНО: регистрируем только настоящего босса | |
| if (_heroesBase != null && _heroesBase.GetIsBoss()) | |
| BossRegistry.RegisterBoss(_heroesBase.GetTeam(), transform); | |
| } | |
| private void OnDisable() | |
| { | |
| if (_heroesBase != null && _heroesBase.GetIsBoss()) | |
| BossRegistry.UnregisterBoss(_heroesBase.GetTeam(), transform); | |
| } | |
| // вызвать, когда кликнули по карте | |
| public void MoveToPointManual(Vector3 worldPos) | |
| { | |
| IsManualControl = true; | |
| ClearTarget(); // забываем врагов | |
| _manualDestination = worldPos; | |
| _agent.stoppingDistance = 0.05f; | |
| _agent.isStopped = false; | |
| _agent.SetDestination(_manualDestination); | |
| SwitchState(State.ManualMove); // добавим новое состояние | |
| } | |
| // ===== ИНИЦИАЛИЗАЦИЯ ===== | |
| private void Awake() | |
| { | |
| _lichFireball = GetComponent<LichFireballAbility>(); | |
| _agent = GetComponent<NavMeshAgent>(); | |
| _agent.updateRotation = false; // отключаем авто-поворот | |
| _agent.updateUpAxis = false; // отключаем выравнивание по оси Y (важно для 2D) | |
| _agent.angularSpeed = 0f; // чтобы не вращался | |
| _baseSpeed = _agent.speed; | |
| _character = GetComponentInChildren<BaseVisualCharacter>(true); // ищем у своих детей | |
| _heroesBase = GetComponent<HeroesBase>(); | |
| if (_heroesBase != null) | |
| _heroesBase.OnDeath += HandleDeath; | |
| if (weapon == null) | |
| weapon = GetComponentInChildren<WeaponBase>(true); // ищем у своих детей | |
| } | |
| private void Start() | |
| { | |
| _character?.PlayAppear(); | |
| } | |
| public void SetTargetPosition(Vector3 targetPosition) | |
| { | |
| _targetPosition = targetPosition; | |
| } | |
| public void ClearTargetPosition() | |
| { | |
| _targetPosition = Vector3.zero; | |
| } | |
| // ===== ОСНОВНОЙ ЦИКЛ ===== | |
| private void Update() | |
| { | |
| if (_state == State.Appear) return; | |
| TickState(); | |
| // UpdateFacing(); | |
| UpdateTargetVisualization(); | |
| } | |
| private void TickState() | |
| { | |
| if (_state == State.Appear) return; | |
| // Поведение в зависимости от состояния | |
| switch (_state) | |
| { | |
| case State.Idle: | |
| { | |
| _agent.isStopped = true; | |
| // сначала ищем врагов | |
| bool hasEnemy = SenseForEnemies(); | |
| if (hasEnemy) | |
| break; | |
| if (_heroesBase.canRoaming) | |
| { | |
| if (!_hasRoamPoint) | |
| { | |
| StartRoaming(); | |
| } | |
| break; | |
| } | |
| if (!_heroesBase.GetIsBoss()) | |
| { | |
| EnsureBoss(); | |
| if (_boss != null) GoToBoss(); | |
| } | |
| break; | |
| } | |
| case State.MovingToBoss: | |
| { | |
| DLog($"[{namePNS}] логика MovingToBoss"); | |
| bool hasEnemy = SenseForEnemies(); | |
| if (hasEnemy) | |
| break; | |
| // если это враг, не двигаемся к боссу, а роумим | |
| if (!_heroesBase.CheckMyTeam()) | |
| { | |
| SwitchState(State.Idle); // в Idle нас отправят в роуминг | |
| break; | |
| } | |
| UpdateMoveToBoss(); | |
| break; | |
| } | |
| case State.Chasing: | |
| DLog($"[{namePNS}] логика преследования врага"); | |
| UpdateChasing(); // логика преследования врага | |
| break; | |
| case State.Attacking: | |
| DLog($"[{namePNS}] логика атаки врага"); | |
| UpdateAttacking(); // логика атаки врага | |
| break; | |
| case State.Appear: | |
| DLog($"[{namePNS}] логика Appear"); | |
| // UpdateAttacking(); // логика атаки врага | |
| break; | |
| case State.Death: | |
| DLog($"[{namePNS}] смерть юнита"); | |
| // OnDeath(); // смерть юнита | |
| break; | |
| case State.Roaming: | |
| DLog($"[{namePNS}] Roaming"); | |
| UpdateRoaming(); | |
| break; | |
| case State.RoamingWait: | |
| UpdateRoamingWait(); | |
| break; | |
| case State.ManualMove: | |
| UpdateManualMove(); | |
| break; | |
| } | |
| } | |
| private void UpdateManualMove() | |
| { | |
| if (_state == State.Death || _state == State.Appear) | |
| return; | |
| // В ручном режиме полностью игнорируем врагов | |
| // if (SenseForEnemies()) return; // ← специально НЕ вызываем | |
| // периодически обновляем путь, если надо | |
| _repathCd -= Time.deltaTime; | |
| if (_repathCd <= 0f) | |
| { | |
| _agent.SetDestination(_manualDestination); | |
| _repathCd = repathRate; | |
| } | |
| // когда дошли до точки — выключаем ручной режим | |
| if (!_agent.pathPending && _agent.remainingDistance <= _agent.stoppingDistance + 0.05f) | |
| { | |
| IsManualControl = false; | |
| SwitchState(State.Idle); | |
| } | |
| } | |
| // ===== ВИЗУАЛИЗАЦИЯ РАДИУСОВ ===== | |
| private void OnDrawGizmosSelected() | |
| { | |
| #if UNITY_EDITOR | |
| // Линии | |
| Handles.color = Color.yellow; | |
| Handles.DrawWireDisc(transform.position, Vector3.forward, sightRadius); | |
| Handles.color = Color.red; | |
| Handles.DrawWireDisc(transform.position, Vector3.forward, _attackingDistance); | |
| Handles.color = Color.cyan; | |
| Handles.DrawWireDisc(transform.position, Vector3.forward, _attackingDistance); | |
| // радиус зрения | |
| Handles.Label(transform.position + Vector3.up * sightRadius, $"Sight: {sightRadius}"); | |
| // радиус атаки | |
| Handles.Label(transform.position + Vector3.up * _attackingDistance, $"Attack: {_attackingDistance}"); | |
| #endif | |
| } | |
| private void HandleDeath() | |
| { | |
| OnDeath(); | |
| DLog($"[{namePNS}] ⚰️ уничтожен"); | |
| } | |
| // ===== СМЕНА СОСТОЯНИЙ ===== | |
| private void SwitchState(State s) | |
| { | |
| if (s == State.Appear) | |
| { | |
| DLog($" не меняем состояние {_state} == State.Appear"); | |
| return; | |
| } | |
| // если уже умер, разрешаем только повторный Death (идемпотентно) | |
| if (_state == State.Death && s != State.Death) return; | |
| if (_state == s) | |
| { | |
| DLog($"уже в этом состоянии — ничего не делаем {_state}"); | |
| return; | |
| } // если уже в этом состоянии — ничего не делаем | |
| _state = s; | |
| DLog($"Меняем состояние на {_state}"); | |
| // Событие — только при смене! | |
| StateChanged?.Invoke(_state); | |
| // _agent.speed = _baseSpeed; | |
| switch (s) | |
| { | |
| case State.Roaming: | |
| case State.RoamingWait: | |
| DLog($"switch Roaming"); | |
| _agent.speed = _roamSpeed; | |
| _agent.isStopped = false; | |
| break; | |
| case State.MovingToBoss: | |
| case State.Chasing: | |
| DLog($"switch Chasing"); | |
| _agent.speed = _moveSpeed; | |
| _agent.isStopped = false; | |
| break; | |
| // case State.Appear: | |
| case State.Attacking: | |
| DLog($"switch State.Attacking"); | |
| _agent.isStopped = false; // позволяем подправлять позицию | |
| _agent.speed = _moveSpeed; | |
| break; | |
| case State.Idle: | |
| DLog($"switch State.Idle"); | |
| _agent.isStopped = true; | |
| _agent.speed = _moveSpeed; | |
| break; | |
| case State.Death: | |
| DLog($"switch State.Death"); | |
| _agent.isStopped = true; | |
| _agent.speed = 0f; | |
| break; | |
| } | |
| ChangeAnimation(); | |
| } | |
| // олько при смене состояния | |
| private void ChangeAnimation() | |
| { | |
| DLog($" Меняем анимацию [{namePNS}] {_state}"); | |
| switch (_state) | |
| { | |
| case State.Idle: | |
| _character?.PlayIdle(); | |
| break; | |
| case State.MovingToBoss: | |
| _character?.PlayWalk(); | |
| break; | |
| case State.Chasing: | |
| _character?.PlayWalk(); // логика преследования врага | |
| break; | |
| case State.Attacking: | |
| _character?.PlayAttack(); | |
| break; | |
| case State.Roaming: | |
| _character?.PlayRoaming(); | |
| break; | |
| case State.Death: | |
| _character?.PlayDeath(); | |
| break; | |
| case State.Appear: | |
| _character?.PlayAppear(); | |
| break; | |
| case State.RoamingWait: | |
| _character?.PlayIdle(); | |
| break; | |
| default: | |
| _character?.PlayIdle(); | |
| break; | |
| } | |
| } | |
| public void SetDeath() | |
| { | |
| _heroesBase.HideOval(); | |
| SwitchState(State.Death); | |
| } | |
| // ===== ДВИЖЕНИЕ К БОССУ ===== | |
| private void GoToBoss() | |
| { | |
| if (_controlledHero) | |
| { | |
| // управлемый герой игроком | |
| return; | |
| } | |
| if (_state == State.Appear) return; | |
| if (_state == State.Death) return; | |
| if (_heroesBase.CheckMyTeam()) | |
| { | |
| DLog($"ищет босса"); | |
| if (_heroesBase.GetIsBoss()) return; | |
| if (_boss == null) | |
| { | |
| _state = State.Idle; | |
| return; | |
| } | |
| _agent.stoppingDistance = _attackingDistance; | |
| _agent.SetDestination(_boss.position); | |
| SwitchState(State.MovingToBoss); | |
| } | |
| } | |
| private void UpdateMoveToBoss() | |
| { | |
| if (_controlledHero) | |
| { | |
| // управлемый герой игроком | |
| return; | |
| } | |
| if (_state == State.Appear) return; | |
| if (_boss == null) | |
| { | |
| DLog($" UpdateMoveToBoss State.Idle"); | |
| SwitchState(State.Idle); | |
| return; | |
| } | |
| DLog($" UpdateMoveToBoss"); | |
| // периодически обновляем путь | |
| _repathCd -= Time.deltaTime; | |
| if (_repathCd <= 0f) | |
| { | |
| _agent.SetDestination(_boss.position); | |
| _repathCd = repathRate; | |
| } | |
| // если дошли до босса — останавливаемся | |
| var dist = Vector3.Distance(transform.position, _boss.position); | |
| if (dist <= _attackingDistance + 0.1f) SwitchState(State.Idle); | |
| } | |
| // ===== ПРЕСЛЕДОВАНИЕ ВРАГА ===== | |
| private void UpdateChasing() | |
| { | |
| if (_state == State.Appear) return; | |
| if (_state == State.Death) return; | |
| if (!HasValidTarget()) | |
| { | |
| ClearTarget(); | |
| return; | |
| } | |
| var dist = Vector3.Distance(transform.position, _currentTarget.position); | |
| // если враг в радиусе атаки — начинаем бить | |
| if (dist <= AttackEnterDistance) | |
| { | |
| SwitchState(State.Attacking); | |
| return; | |
| } | |
| if (_dbgTimer <= 0f) | |
| { | |
| _dbgTimer = debugInterval; | |
| } | |
| // периодически пересчитываем путь | |
| _repathCd -= Time.deltaTime; | |
| if (_repathCd <= 0f) | |
| { | |
| _agent.SetDestination(_currentTarget.position); | |
| _repathCd = repathRate; | |
| } | |
| } | |
| public void SetCanAttack(bool canAttackCharacter) | |
| { | |
| DLog($"WarriorAI меняем состояние атаки canAttack {namePNS}"); | |
| canAttack = canAttackCharacter; | |
| } | |
| // ===== АТАКА ===== | |
| private void UpdateAttacking() | |
| { | |
| if (!canAttack) | |
| { | |
| DLog($"WarriorAI запрет на атаку canAttack {namePNS}"); | |
| return; | |
| } | |
| if (_state == State.Appear) return; | |
| if (_state == State.Death) return; | |
| if (!HasValidTarget()) | |
| { | |
| ClearTarget(); | |
| GoToBoss(); | |
| return; | |
| } | |
| // проверяем дистанцию — если враг убежал, догоняем снова | |
| var dist = Vector3.Distance(transform.position, _currentTarget.position); | |
| if (dist > AttackExitDistance) | |
| { | |
| _agent.isStopped = false; | |
| SwitchState(State.Chasing); | |
| return; | |
| } | |
| // кулдаун атаки | |
| _attackCd -= Time.deltaTime; | |
| if (_attackCd <= 0f) | |
| { | |
| _attackCd = 1f / Mathf.Max(0.01f, attackRate); | |
| // Перед каждым новым ударом — ищем новую цель, если это включено | |
| // if (retargetInSight) | |
| // { | |
| // после ретаргета могло не остаться валидных целей | |
| if (!HasValidTarget()) | |
| { | |
| ClearTarget(); | |
| GoToBoss(); | |
| return; | |
| } | |
| // } | |
| StartAttack(); | |
| } | |
| } | |
| public void InvokeAppearFromAnimation() | |
| { | |
| SwitchState(State.Idle); | |
| _heroesBase.HealthBarActive(); | |
| Debug.LogWarning($"InvokeAppearFromAnimation меняем состояние на "); | |
| if (_controlledHero) | |
| { | |
| // управлемый герой игроком | |
| return; | |
| } | |
| if (_heroesBase.CheckMyTeam()) | |
| { | |
| DLog($"InvokeAppearFromAnimation()"); | |
| _boss = null; | |
| EnsureBoss(); | |
| } | |
| else | |
| { | |
| if (!_heroesBase.GetIsBoss()) | |
| { | |
| DLog($"{name}: Меняем состояние на Roaming"); | |
| SwitchState(State.Roaming); | |
| } | |
| } | |
| } | |
| public void InvokeAttackFromAnimation() | |
| { | |
| if (!canAttack) | |
| { | |
| DLog($"WarriorAI запрет на атаку canAttack {namePNS}"); | |
| return; | |
| } | |
| if (_state == State.Appear) return; | |
| if (_state == State.Death) return; // мертвые не бьют | |
| // Ещё раз проверяем, что цель валидна | |
| // Ещё раз проверяем, что цель валидна | |
| if (!HasValidTarget()) | |
| { | |
| ClearTarget(); | |
| GoToBoss(); | |
| return; | |
| } | |
| // ПЕРЕД ВЫСТРЕЛОМ ОБНОВЛЯЕМ ЦЕЛЬ В ОРУЖИИ | |
| weapon?.SetEnemyTarget(_currentTarget); | |
| weapon?.SetTargetHealth(_targetHealth); | |
| if (_currentTarget != null) | |
| { | |
| weapon?.Attack(); | |
| } | |
| else | |
| { | |
| _state = State.Idle; | |
| ChangeAnimation(); | |
| DLog($"цель ☠️ ☠️ ☠️ ☠️ ☠️ ☠️ ☠️ ☠️"); | |
| } | |
| } | |
| public void InvokeAttackLichFireballFromAnimation() | |
| { | |
| if (_state == State.Appear) return; | |
| if (_state == State.Death) return; // мертвые не бьют | |
| if (_weaponLichFireball != null) | |
| { | |
| _currentTarget = null; | |
| _targetHealth = null; | |
| _weaponLichFireball.SetTargetPoint(_targetPosition); | |
| DLog($"АТАКА АТАКА АТАКА АТАКА АТАКА АТАКА АТАКА АТАКА "); | |
| _weaponLichFireball.Attack(); | |
| } | |
| else | |
| { | |
| DLog($"_weaponLichFireball != null"); | |
| } | |
| } | |
| // установка цели | |
| private void StartAttack() | |
| { | |
| if (!canAttack) | |
| { | |
| return; | |
| } | |
| // для координат | |
| weapon?.SetEnemyTarget(_currentTarget); | |
| // устанавливаем основного героя в виде цели | |
| weapon?.SetTargetHealth(_targetHealth); | |
| // weapon?.Attack(); | |
| // weapon?.InvokeAttack(); | |
| // Просто проигрываем анимацию атаки — урон пойдёт через Animation Event | |
| } | |
| /** | |
| * логика атаки | |
| */ | |
| private void PerformHit() | |
| { | |
| // 1) Дистанция | |
| var dist = Vector2.Distance(transform.position, _currentTarget.position); | |
| if (dist > _attackingDistance * 1.1f) | |
| { | |
| DLog($"[{namePNS}] Удар отменён: далеко ({dist:F2} > {_attackingDistance * 1.1f:F2})"); | |
| _state = State.Chasing; | |
| return; | |
| } | |
| // 2) Бьём оружием (если есть) | |
| if (weapon == null) | |
| { | |
| DLog($"Отсутствует оружие [{namePNS}] weapon == NULL — у героя не назначено оружие"); | |
| ClearTarget(); | |
| GoToBoss(); | |
| return; | |
| } | |
| // 3) Наносим урон напрямую (если нужно) и/или проверяем смерть | |
| // ВАЖНО: цель обязана иметь HeroesBase, иначе выходим | |
| if (_targetHealth == null) | |
| { | |
| // Debug.LogWarning($"[{namePNS}] У цели нет HeroesBase — сбрасываю цель"); | |
| ClearTarget(); | |
| GoToBoss(); | |
| return; | |
| } | |
| // Если урон наносит само оружие — ниже можно убрать. | |
| if (_targetHealth.IsDead) | |
| { | |
| DLog($"[{namePNS}] ❌ Цель уничтожена: {_currentTarget.name}"); | |
| ClearTarget(); | |
| // GoToBoss(); | |
| } | |
| } | |
| // ===== ОБНАРУЖЕНИЕ ВРАГОВ ===== | |
| private bool SenseForEnemies() | |
| { | |
| _senseTimer -= Time.deltaTime; | |
| if (_senseTimer <= 0f) | |
| { | |
| _senseTimer = senseInterval; | |
| int myTeam = _heroesBase.GetTeam(); | |
| // если сейчас ручное управление — никого не ищем | |
| if (IsManualControl) | |
| { | |
| DLog($"сейчас ручное управление — никого не ищем,{namePNS}"); | |
| return false; | |
| } | |
| DLog($"Ищу врагов в радиусе {sightRadius},{namePNS} маска слоев: {unitMask.value}"); | |
| if (HasValidTarget()) | |
| { | |
| DLog($"Ищу врагов в радиусе {sightRadius},{namePNS} маска слоев: {unitMask.value}"); | |
| return true; | |
| } | |
| // 2. Если цель была, но уже невалидна — обнуляем, чтобы можно было взять новую | |
| _currentTarget = null; | |
| _targetHealth = null; | |
| int count = Physics2D.OverlapCircleNonAlloc(transform.position, sightRadius, _senseHits, unitMask); | |
| Transform best = null; | |
| var bestSqr = float.MaxValue; | |
| for (int i = 0; i < count; i++) | |
| { | |
| var col = _senseHits[i]; | |
| if (col.transform == transform) | |
| { | |
| continue; | |
| } | |
| var hp = col.GetComponentInParent<HeroesBase>(); | |
| // ИСПРАВЛЕНИЕ: атаковать врагов, у которых команда НЕ совпадает с нашей | |
| if (!hp || hp.IsDead) continue; | |
| if (hp.GetTeam() == myTeam) | |
| { | |
| continue; | |
| } | |
| var sqr = (col.transform.position - transform.position).sqrMagnitude; | |
| if (sqr < bestSqr) | |
| { | |
| bestSqr = sqr; | |
| best = hp.transform; | |
| } | |
| } | |
| if (best != null) | |
| { | |
| if (_currentTarget != best) | |
| { | |
| SetTarget(best); | |
| } | |
| return true; | |
| } | |
| if (_currentTarget != null || _targetHealth != null) | |
| { | |
| ClearTarget(); | |
| } | |
| } | |
| return false; | |
| } | |
| // ===== УПРАВЛЕНИЕ ЦЕЛЬЮ ===== | |
| private void SetTarget(Transform t) | |
| { | |
| if (t == null) return; | |
| // Ищем здоровье на объекте или у его родителя | |
| // (подстрой под свою иерархию) | |
| var hp = t.GetComponent<HeroesBase>() ?? t.GetComponentInParent<HeroesBase>(); | |
| if (hp == null) | |
| { | |
| // Debug.LogWarning($"[{namePNS}] Попытка выбрать цель без HeroesBase: {t.name}"); | |
| return; | |
| } | |
| _currentTarget = hp.transform; // фиксируемся на том Transform, где есть здоровье | |
| // цель со здоровьем | |
| _targetHealth = hp; | |
| _agent.stoppingDistance = AttackEnterDistance; | |
| _agent.isStopped = false; // ← ВАЖНО | |
| _agent.SetDestination(_currentTarget.position); | |
| var ai = _currentTarget.GetComponent<WarriorAI>(); | |
| var targetPns = ai != null ? ai.namePNS : _currentTarget.name; | |
| SwitchState(State.Chasing); | |
| } | |
| private void ClearTarget() | |
| { | |
| if (_state == State.Death) return; | |
| if (_currentTarget) | |
| { | |
| var ai = _currentTarget.GetComponent<WarriorAI>(); | |
| var targetPns = ai ? ai.namePNS : _currentTarget.name; | |
| DLog($"[{namePNS}] 🔁 Сброс цели: {targetPns}"); | |
| } | |
| _currentTarget = null; | |
| _targetHealth = null; | |
| if (weapon != null) | |
| weapon.ClearTarget(); | |
| _agent.stoppingDistance = _attackingDistance; | |
| _agent.isStopped = true; | |
| SwitchState(State.Idle); | |
| DLog($"[{namePNS}] 🔁 Включаем роуминг 1"); | |
| StartRoaming(); | |
| } | |
| private bool HasValidTarget() | |
| { | |
| // Цель должна существовать И иметь HeroesBase | |
| if (_currentTarget == null) return false; | |
| if (_targetHealth == null) return false; | |
| if (_targetHealth.IsDead) return false; | |
| // _deathHandled | |
| return _currentTarget.gameObject.activeInHierarchy; | |
| } | |
| private void EnsureBoss() | |
| { | |
| if (_boss != null) return; | |
| _boss = BossRegistry.GetEnemyBoss(_heroesBase.GetTeam()); | |
| } | |
| // ===== ПОИСК БОССА ===== | |
| private Transform FindBoss() | |
| { | |
| Transform nearest = null; | |
| _senseTimerBoss -= Time.deltaTime; | |
| if (_senseTimerBoss <= 0f) | |
| { | |
| _senseTimerBoss = 1f; | |
| var all = FindObjectsOfType<HeroesBase>(); | |
| float best = float.MaxValue; | |
| foreach (var w in all) | |
| { | |
| if (!w.GetIsBoss()) continue; // ищем только боссов | |
| if (w == _heroesBase) continue; // не сам себя | |
| if (w.GetTeam() == _heroesBase.GetTeam()) continue; // ищем противоположную команду | |
| float d = (w.transform.position - transform.position).sqrMagnitude; | |
| if (d < best) | |
| { | |
| best = d; | |
| nearest = w.transform; | |
| } | |
| } | |
| } | |
| return nearest; | |
| } | |
| private bool TryGetRandomPointAround(Vector3 origin, float radius, out Vector3 result) | |
| { | |
| for (int i = 0; i < 10; i++) | |
| { | |
| // случайная точка в круге | |
| Vector2 random2D = UnityEngine.Random.insideUnitCircle * radius; | |
| var randomPos = origin + new Vector3(random2D.x, random2D.y, 0f); | |
| if (NavMesh.SamplePosition(randomPos, out var hit, 1f, NavMesh.AllAreas)) | |
| { | |
| result = hit.position; | |
| return true; | |
| } | |
| } | |
| result = origin; | |
| return false; | |
| } | |
| // Меняем цель БЕЗ смены состояния (остаёмся в Attacking) | |
| private void AssignTargetForAttack(Transform t, HeroesBase hp) | |
| { | |
| _currentTarget = t; | |
| _targetHealth = hp; | |
| _agent.stoppingDistance = AttackEnterDistance; | |
| _agent.isStopped = false; | |
| _agent.SetDestination(_currentTarget.position); | |
| } | |
| private void UpdateRoaming() | |
| { | |
| if (_state == State.Death || _state == State.Appear) | |
| return; | |
| // 1. Сначала проверяем, не появился ли враг | |
| if (SenseForEnemies()) | |
| { | |
| return; | |
| } | |
| if (!_hasRoamPoint) | |
| { | |
| StartRoaming(); | |
| return; | |
| } | |
| float dist = Vector3.Distance(transform.position, _roamTarget); | |
| // 2. Дошли до точки → переключаемся в RoamingWait | |
| if (dist <= _agent.stoppingDistance + 0.05f) | |
| { | |
| _agent.isStopped = true; | |
| _roamWaitTimer = 0f; | |
| SwitchState(State.RoamingWait); | |
| DLog($"[{namePNS}] Roaming: reached point, wait"); | |
| return; | |
| } | |
| // 3. Ещё идём к точке | |
| if (_agent.isStopped) | |
| _agent.isStopped = false; | |
| if (!_agent.pathPending && _agent.remainingDistance < 0.1f) | |
| { | |
| _agent.SetDestination(_roamTarget); | |
| } | |
| } | |
| private void UpdateRoamingWait() | |
| { | |
| if (_state == State.Death || _state == State.Appear) | |
| return; | |
| if (SenseForEnemies()) | |
| { | |
| // SetTarget переведёт в Chasing | |
| return; | |
| } | |
| _roamWaitTimer += Time.deltaTime; | |
| if (_roamWaitTimer >= roamWaitTime) | |
| { | |
| _hasRoamPoint = false; | |
| StartRoaming(); // снова пойдём, SwitchState переведёт в Roaming | |
| } | |
| } | |
| private void StartRoaming() | |
| { | |
| if (!_heroesBase.canRoaming) | |
| return; | |
| if (_state == State.Death || _state == State.Appear) | |
| { | |
| DLog($"[{namePNS}] 🔁 Только живые или то кто появились"); | |
| return; | |
| } | |
| if (_agent == null || !_agent.enabled) | |
| { | |
| DLog($"[{namePNS}] 🔁 не можем роумить нет агента"); | |
| return; | |
| } | |
| if (!TryGetRandomPointAround(transform.position, sightRadius, out _roamTarget)) | |
| { | |
| DLog($"[{namePNS}] 🔁 не можем роумить Idle ищем точку"); | |
| _state = State.Idle; | |
| _hasRoamPoint = false; | |
| return; | |
| } | |
| _hasRoamPoint = true; | |
| _roamWaitTimer = 0f; | |
| _agent.stoppingDistance = roamStoppingDistance; | |
| _agent.isStopped = false; | |
| _agent.SetDestination(_roamTarget); | |
| DLog($"[{namePNS}] 🔁 меняем состояние на Roaming"); | |
| SwitchState(State.Roaming); | |
| } | |
| /** | |
| * Мы поворачиваемся лицом к врагу. | |
| * Метод публичный потому что вызываем ото всюду | |
| */ | |
| public bool turnToFace() | |
| { | |
| // 1) Пытаемся смотреть по желаемой скорости агента (плавнее и без рывков пути) | |
| var v = _agent.desiredVelocity; // для NavMeshAgent в 2D это X/Y плоскость (Y = up) | |
| // 2) Если стоим или скорость очень маленькая — в атаке/преследовании смотрим на цель | |
| if (v.sqrMagnitude < flipThreshold * flipThreshold) | |
| { | |
| if (_currentTarget != null) | |
| { | |
| var dx = _currentTarget.position.x - transform.position.x; | |
| if (Mathf.Abs(dx) > flipThreshold) | |
| _lookDir = dx > 0f ? +1 : -1; | |
| } | |
| // иначе просто сохраняем предыдущий _lookDir | |
| } | |
| else | |
| { | |
| // есть движение — смотрим по направлению X | |
| if (Mathf.Abs(v.x) > flipThreshold) | |
| _lookDir = v.x > 0f ? +1 : -1; | |
| } | |
| // 3) Применяем флип | |
| return _lookDir < 0; | |
| } | |
| private void OnDeath() | |
| { | |
| if (_deathHandled) return; // защита от повторов | |
| _deathHandled = true; | |
| SwitchState(State.Death); | |
| if (_agent) | |
| { | |
| _agent.isStopped = true; | |
| _agent.enabled = false; | |
| } | |
| // 3. Вырубаем физику | |
| var rb2D = GetComponent<Rigidbody2D>(); | |
| if (rb2D) rb2D.simulated = false; | |
| var cols = GetComponentsInChildren<Collider2D>(includeInactive: true); | |
| foreach (var c in cols) c.enabled = false; | |
| DLog($"[{namePNS}] 💀 погиб — уничтожаю объект через 2 сек."); | |
| GetComponent<Collider2D>().enabled = false; // отключаем столкновения | |
| // Можно добавить задержку, чтобы успела проиграться анимация | |
| if (weapon) | |
| { | |
| weapon.gameObject.SetActive(false); | |
| } | |
| StopAllCoroutines(); | |
| if (_heroesBase != null) | |
| { | |
| _heroesBase.OnDeath -= HandleDeath; | |
| } | |
| Destroy(gameObject, 7f); | |
| } | |
| // Возможные состояния ИИ | |
| public enum State | |
| { | |
| Start, | |
| Idle, | |
| MovingToBoss, | |
| Chasing, | |
| Attacking, | |
| Death, | |
| Appear, | |
| Roaming, | |
| RoamingWait, | |
| ManualMove, | |
| } | |
| private void UpdateTargetVisualization() | |
| { | |
| #if UNITY_EDITOR | |
| // Создаем маркер если нужно | |
| if (showTargetDebug && targetMarkerPrefab != null && targetMarker == null) | |
| { | |
| targetMarker = Instantiate(targetMarkerPrefab); | |
| targetMarker.name = $"{gameObject.name}_TargetMarker"; | |
| } | |
| // Обновляем позицию маркера | |
| if (targetMarker != null) | |
| { | |
| if (_currentTarget != null) | |
| { | |
| // УБРАТЬ Vector3.up * 2f - использовать реальную позицию цели | |
| targetMarker.transform.position = _currentTarget.position; | |
| targetMarker.SetActive(true); | |
| // Логируем информацию о цели | |
| float distance = Vector3.Distance(transform.position, _currentTarget.position); | |
| Vector3 targetPos = _currentTarget.position; | |
| } | |
| else | |
| { | |
| targetMarker.SetActive(false); | |
| } | |
| } | |
| #endif | |
| } | |
| // Визуализация в Scene View | |
| // Визуализация в Scene View | |
| private void OnDrawGizmos() | |
| { | |
| if (!showTargetDebug || _currentTarget == null) return; | |
| Gizmos.color = targetColor; | |
| // Линия от центра к центру (без смещения) | |
| Gizmos.DrawLine(transform.position, _currentTarget.position); | |
| // Маркер на реальной позиции цели | |
| Gizmos.DrawWireSphere(_currentTarget.position, 0.3f); | |
| // Подпись с дистанцией | |
| #if UNITY_EDITOR | |
| float distance = Vector3.Distance(transform.position, _currentTarget.position); | |
| Vector3 targetPos = _currentTarget.position; | |
| // Подпись над целью | |
| UnityEditor.Handles.Label(_currentTarget.position + Vector3.up * 0.5f, | |
| $"Цель: {_currentTarget.name}\n" + | |
| $"Позиция: ({targetPos.x:F1}, {targetPos.y:F1})"); | |
| // Подпись над юнитом | |
| UnityEditor.Handles.Label(transform.position + Vector3.up * 1f, | |
| $"Дистанция: {distance:F2}"); | |
| #endif | |
| } | |
| [System.Diagnostics.Conditional("UNITY_EDITOR")] | |
| private void DLog(string msg) | |
| { | |
| if (debugAI) Debug.Log(msg); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment