using System.Collections; using System.Collections.Generic; using Platformer.Gameplay; using UnityEngine; using static Platformer.Core.Simulation; namespace Platformer.Mechanics { /// /// A simple controller for enemies. Provides movement control over a patrol path. /// [RequireComponent(typeof(AnimationController), typeof(Collider2D))] public class EnemyController : MonoBehaviour { public PatrolPath path; // legacy 호환·자동 patrol 영역 도입 후 미사용 public AudioClip ouch; /// BT5-Dev #16 — Distance 기반 감지 영역 X 임계값 (전체 폭). 표준 platformer Enemy 옆 닿음 영역 0.6~0.8. public float hitRangeX = 0.7f; /// Y 임계값. 위/아래 둘 다 인정. public float hitRangeY = 1.0f; /// 밟기 판정 — Player가 Enemy보다 위 거리. 발 닿는 느낌 영역(0.05~0.15). public float stompMinDy = 0.1f; // PD 명시 2026-05-08 — 자동 patrol (생성 위치 기준 좌/우 random 50~75 왕복·BT87 절반 정정) public float patrolMinRange = 50f; public float patrolMaxRange = 75f; public float patrolArriveThreshold = 0.5f; public float cliffCheckDistance = 1.0f; // BT92: 0.8→1.0 — 더 일찍 검출 public float cliffCheckDepth = 2.0f; public LayerMask groundLayerMask = (1 << 0) | (1 << 16); // Layer 0 (지면) + Layer 16 (발판) public float stuckThresholdTime = 0.15f; // BT93: 0.3→0.15 (150ms·밀림 누적 차단) public float stuckMoveThreshold = 0.02f; // 정지 판정 거리 임계값 public float waitMinTime = 1f; // patrol arrive·벽·절벽 후 대기 random 영역 public float waitMaxTime = 3f; private float _startX; private float _startY; // BT102: 시작 시 y 위치 — 떨어짐 검출 기준 public float fallThreshold = 1.0f; // BT102: _startY - fallThreshold 영역 미만 시 시작 위치 텔레포트 private float _targetX; private int _patrolPhase; // 0: right out / 1: right back / 2: left out / 3: left back private float _lastX; // 벽 정지 검출용 private float _stuckTimer; // 벽 정지 누적 시간 private float _waitTimer; // arrive 후 대기 누적 시간 (1~3초 random) private float _phaseCooldown; // phase 전환 직후 절벽·벽 검출 비활성 시간 private const float PHASE_COOLDOWN = 1.0f; // BT95: 0.5→1.0 — 긴 영역 cooldown (좌우 반복 영구 차단) private float _maxRightRange; // 시작 시 측정 — 우측 안전 patrol 거리 private float _maxLeftRange; // 시작 시 측정 — 좌측 안전 patrol 거리 private bool _isInitialized; // BT97: Start 측정 완료 영역 (Awake 이후 활성) internal PatrolPath.Mover mover; // legacy 호환·미사용 internal AnimationController control; internal Collider2D _collider; internal AudioSource _audio; SpriteRenderer spriteRenderer; Rigidbody2D _body; // BT96: KinematicObject body 영역 직접 set 위해 캐싱 public Bounds Bounds => _collider.bounds; /// /// PD 지시 2026-05-07 — Enemy 시각(SpriteRenderer) 영역 기반 충돌 감지용. CapsuleCollider2D는 작은 ground sensor 영역(0.45×0.09)이라 감지에 부적합. /// SpriteRenderer 부재 시 Collider Bounds로 fallback. /// public Bounds VisualBounds => spriteRenderer != null ? spriteRenderer.bounds : _collider.bounds; PlayerController _cachedPlayer; bool _ignoreCollisionApplied; void Awake() { control = GetComponent(); _collider = GetComponent(); _audio = GetComponent(); spriteRenderer = GetComponent(); _body = GetComponent(); // BT5-Dev #21 — Awake 시점 fallback 추가 (Player tag 영역 미설정 영역 대비) var playerObj = GameObject.FindGameObjectWithTag("Player"); if (playerObj == null) { var pcfb = Object.FindFirstObjectByType(); if (pcfb != null) playerObj = pcfb.gameObject; } if (playerObj != null && _collider != null) { var pc = playerObj.GetComponent(); if (pc != null) { Physics2D.IgnoreCollision(_collider, pc, true); _ignoreCollisionApplied = true; } } // PD 명시 2026-05-08 — 자동 patrol 시작 위치 저장 (측정·target은 Start 시점) _startX = transform.position.x; _startY = transform.position.y; // BT102: 떨어짐 검출 기준 _lastX = _startX; _stuckTimer = 0f; _phaseCooldown = 0f; _patrolPhase = 0; _isInitialized = false; } // BT97 — Start 시점 안전 거리 측정 (AutoForeground Tile data 활성 후·AfterSceneLoad 이후) // BT104 — 시작 위치 발판 검증·자동 재배치 (PD가 PD Foreground·빈 영역 배치 시 가까운 발판 영역으로 이동) void Start() { if (_collider != null) { Vector2 footHere = new Vector2(_collider.bounds.center.x, _collider.bounds.min.y + 0.05f); RaycastHit2D groundUnder = Physics2D.Raycast(footHere, Vector2.down, cliffCheckDepth, groundLayerMask); if (groundUnder.collider == null) { // 시작 위치 발판 X — 가까운 좌·우 발판 자동 검색 for (float d = 0.5f; d <= 50f; d += 0.5f) { Vector2 rightProbe = new Vector2(_startX + d, footHere.y); if (Physics2D.Raycast(rightProbe, Vector2.down, cliffCheckDepth, groundLayerMask).collider != null) { _startX += d; Vector3 newPos = new Vector3(_startX, transform.position.y, transform.position.z); transform.position = newPos; if (_body != null) _body.position = newPos; break; } Vector2 leftProbe = new Vector2(_startX - d, footHere.y); if (Physics2D.Raycast(leftProbe, Vector2.down, cliffCheckDepth, groundLayerMask).collider != null) { _startX -= d; Vector3 newPos = new Vector3(_startX, transform.position.y, transform.position.z); transform.position = newPos; if (_body != null) _body.position = newPos; break; } } } } _startY = transform.position.y; // 재배치 후 _startY 갱신 _maxRightRange = MeasureSafeWalkDistance(1f); _maxLeftRange = MeasureSafeWalkDistance(-1f); SetNextPatrolTarget(); _isInitialized = true; } // BT96 — 절벽·벽 검출 시 즉시 반대 방향 이동 (transform + body 동시 push·KinematicObject 정합) void TriggerReverse(float moveDir, float pushDistance) { _patrolPhase = (_patrolPhase + 2) % 4; SetNextPatrolTarget(); // transform + body 동시 push (KinematicObject body.position 영역 동기화) Vector3 newPos = transform.position + new Vector3(-moveDir * pushDistance, 0f, 0f); transform.position = newPos; if (_body != null) _body.position = newPos; // velocity.x 즉시 반대 방향 + move.x 반대 (다음 frame 안정 이동) if (control != null) { control.velocity = new Vector2(-moveDir * control.maxSpeed, control.velocity.y); control.move.x = -moveDir; } _stuckTimer = 0f; _phaseCooldown = PHASE_COOLDOWN; _lastX = transform.position.x; } // BT103 — 정확 측정 (PD 명시 2026-05-08): Start 시점 1회 측정·간격 0.1m·Capsule 발 영역 정확 float MeasureSafeWalkDistance(float dir) { if (_collider == null) return patrolMaxRange; float groundY = _collider.bounds.min.y + 0.05f; // Capsule 발 영역 위 0.05m for (float d = 0.1f; d <= patrolMaxRange; d += 0.1f) { Vector2 origin = new Vector2(_startX + dir * d, groundY); RaycastHit2D hit = Physics2D.Raycast(origin, Vector2.down, cliffCheckDepth, groundLayerMask); if (hit.collider == null) return Mathf.Max(0f, d - 0.5f); // 안전 margin 0.5m } return patrolMaxRange; } void SetNextPatrolTarget() { float range = Random.Range(patrolMinRange, patrolMaxRange); switch (_patrolPhase) { case 0: // 우측 random — 안전 거리 cap _targetX = _startX + Mathf.Min(range, _maxRightRange); break; case 1: _targetX = _startX; break; case 2: // 좌측 random — 안전 거리 cap _targetX = _startX - Mathf.Min(range, _maxLeftRange); break; case 3: _targetX = _startX; break; } } // BT89 — patrol·벽·절벽 검출·대기 영역 통합 처리 void UpdatePatrol() { // BT97 — Start 측정 완료 전까지 patrol 비활성 (AutoForeground Tile data 영역 활성 대기) if (!_isInitialized) { if (control != null) control.move.x = 0f; return; } // 대기 영역 — control.move.x = 0 + Timer 감소 if (_waitTimer > 0f) { _waitTimer -= Time.deltaTime; if (control != null) control.move.x = 0f; _lastX = transform.position.x; _stuckTimer = 0f; return; } // BT94 — phase cooldown 감소 (절벽·벽 검출 비활성 시간) if (_phaseCooldown > 0f) _phaseCooldown -= Time.deltaTime; float dx = _targetX - transform.position.x; // patrol arrive — 1~3초 대기 후 다음 phase if (Mathf.Abs(dx) < patrolArriveThreshold) { _patrolPhase = (_patrolPhase + 1) % 4; SetNextPatrolTarget(); _waitTimer = Random.Range(waitMinTime, waitMaxTime); if (control != null) control.move.x = 0f; _lastX = transform.position.x; _stuckTimer = 0f; return; } float moveDir = Mathf.Sign(dx); // BT90 — 수평 Raycast 영역 폐기 (BT89 거짓 양성 — 같은 Tile cell 영역 검출) // 벽 영역 = stuckTimer 영역 (50ms 정지 시 즉시 phase+2)으로 처리 // BT94 — 절벽·벽 검출은 phase cooldown 영역 끝난 후 활성 (좌우 반복 차단) if (_phaseCooldown <= 0f) { // BT96 — 절벽·벽 검출: transform + body 동시 push (가장자리에서 안전 영역으로 즉시 이동) if (_collider != null) { Vector2 footAhead = new Vector2( _collider.bounds.center.x + moveDir * cliffCheckDistance, _collider.bounds.min.y + 0.05f ); RaycastHit2D groundHit = Physics2D.Raycast(footAhead, Vector2.down, cliffCheckDepth, groundLayerMask); if (groundHit.collider == null) { TriggerReverse(moveDir, 0.3f); return; } } if (Mathf.Abs(transform.position.x - _lastX) < stuckMoveThreshold) { _stuckTimer += Time.deltaTime; if (_stuckTimer > stuckThresholdTime) { TriggerReverse(moveDir, 0.2f); return; } } else { _stuckTimer = 0f; } } else { _stuckTimer = 0f; } _lastX = transform.position.x; if (control != null) control.move.x = Mathf.Clamp(dx, -1, 1); } void Update() { // BT89 — 자동 patrol + 즉시 벽 검출 + 1~3초 대기 영역 UpdatePatrol(); // BT102 — 떨어짐 검출 영역: y < _startY - fallThreshold 시 = 시작 위치 텔레포트 복귀 // PD 명시 (2026-05-08): 투명벽 폐기·떨어짐 차단 다른 방법 // 단순·근본 방법 — 떨어진 후 검출 영역 즉시 복귀 (영구 떨어짐 X) if (_isInitialized && transform.position.y < _startY - fallThreshold) { Vector3 safe = new Vector3(_startX, _startY, transform.position.z); transform.position = safe; if (_body != null) _body.position = safe; if (control != null) control.velocity = Vector2.zero; _patrolPhase = 0; SetNextPatrolTarget(); _phaseCooldown = PHASE_COOLDOWN; _stuckTimer = 0f; _lastX = _startX; _waitTimer = Random.Range(waitMinTime, waitMaxTime); if (control != null) control.move.x = 0f; } // 이하 — Player 충돌 검출 영역 (patrol 영역과 분리) // PD 지시 2026-05-07 — Player ↔ Enemy 통과 가능이지만 Bounds.Intersects로 매 프레임 감지 if (_cachedPlayer == null) { // 1차: tag 영역 발견 var pgo = GameObject.FindGameObjectWithTag("Player"); if (pgo != null) _cachedPlayer = pgo.GetComponent(); // 2차 fallback: tag 영역 미설정 영역에 대비해 PlayerController 영역 직접 검색 if (_cachedPlayer == null) { _cachedPlayer = Object.FindFirstObjectByType(); } if (Time.frameCount % 60 == 0) { int allCount = Object.FindObjectsByType(FindObjectsSortMode.None).Length; Debug.Log($"[BT17-Update@{name}] f={Time.frameCount} cached={(_cachedPlayer != null ? _cachedPlayer.name : "NULL")} pgoTag={(pgo != null ? pgo.name : "NULL")} allPCcount={allCount}"); } } // BT20 — IgnoreCollision 영역 Awake 시점 Player 발견 X 영역 fallback. Update 영역에서 발견 직후 1회 영역 호출. if (_cachedPlayer != null && !_ignoreCollisionApplied && _collider != null) { var pc = _cachedPlayer.GetComponent(); if (pc != null) { Physics2D.IgnoreCollision(_collider, pc, true); _ignoreCollisionApplied = true; Debug.Log($"[BT20-Ignore@{name}] Player↔Enemy IgnoreCollision applied"); } } if (_cachedPlayer != null && _cachedPlayer.health != null && _cachedPlayer.health.IsAlive) { // BT5-Dev #36 — Distance 영역 폐기 → Bounds.Intersects 표준 영역 (Unity Physics AABB 정확) // Player 빠른 통과 시 한 프레임 catch 정합. Layer 13↔14 OFF 영역 통과 가능 영역 그대로. bool inRange = VisualBounds.Intersects(_cachedPlayer.Bounds); const float PLAYER_COLLIDER_TO_VISUAL_FOOT = -0.17f; float footY = _cachedPlayer.Bounds.min.y + PLAYER_COLLIDER_TO_VISUAL_FOOT; float headY = VisualBounds.max.y; float footHeadDelta = footY - headY; if (inRange != _diagWasIntersecting) { Debug.Log($"[EnemyDiag@{name}] inRange={inRange} footHeadDelta={footHeadDelta:F2} VB={VisualBounds.size} PB={_cachedPlayer.Bounds.size}"); _diagWasIntersecting = inRange; } if (inRange) { var ev = Schedule(); ev.player = _cachedPlayer; ev.enemy = this; ev.dyAtCollision = footHeadDelta; } } } bool _diagWasIntersecting; void OnDrawGizmos() { // BT5-Dev #15 진단 — Scene 영역 시각화 (Editor에서만 표시) if (Application.isPlaying && spriteRenderer != null) { Gizmos.color = Color.red; Gizmos.DrawWireCube(VisualBounds.center, VisualBounds.size); } if (Application.isPlaying && _collider != null) { Gizmos.color = Color.yellow; Gizmos.DrawWireCube(_collider.bounds.center, _collider.bounds.size); } } } }