EerieVillage/Assets/Scripts/Mechanics/EnemyController.cs

443 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections;
using System.Collections.Generic;
using Platformer.Gameplay;
using UnityEngine;
using static Platformer.Core.Simulation;
namespace Platformer.Mechanics
{
/// <summary>
/// A simple controller for enemies. Provides movement control over a patrol path.
/// </summary>
[RequireComponent(typeof(AnimationController), typeof(Collider2D))]
public class EnemyController : MonoBehaviour
{
public AudioClip ouch;
/// <summary>BT5-Dev #16 — Distance 기반 감지 영역 X 임계값 (전체 폭). 표준 platformer Enemy 옆 닿음 영역 0.6~0.8.</summary>
public float hitRangeX = 0.7f;
/// <summary>Y 임계값. 위/아래 둘 다 인정.</summary>
public float hitRangeY = 1.0f;
/// <summary>밟기 판정 — Player가 Enemy보다 위 거리. 발 닿는 느낌 영역(0.05~0.15).</summary>
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 AnimationController control;
internal Collider2D _collider;
internal AudioSource _audio;
SpriteRenderer spriteRenderer;
Rigidbody2D _body; // BT96: KinematicObject body 영역 직접 set 위해 캐싱
public Bounds Bounds => _collider.bounds;
/// <summary>
/// PD 지시 2026-05-07 — Enemy 시각(SpriteRenderer) 영역 기반 충돌 감지용. CapsuleCollider2D는 작은 ground sensor 영역(0.45×0.09)이라 감지에 부적합.
/// SpriteRenderer 부재 시 Collider Bounds로 fallback.
/// </summary>
public Bounds VisualBounds => spriteRenderer != null ? spriteRenderer.bounds : _collider.bounds;
PlayerController _cachedPlayer;
bool _ignoreCollisionApplied;
void Awake()
{
control = GetComponent<AnimationController>();
_collider = GetComponent<Collider2D>();
_audio = GetComponent<AudioSource>();
spriteRenderer = GetComponent<SpriteRenderer>();
_body = GetComponent<Rigidbody2D>();
// BT5-Dev #21 — Awake 시점 fallback 추가 (Player tag 영역 미설정 영역 대비)
var playerObj = GameObject.FindGameObjectWithTag("Player");
if (playerObj == null)
{
var pcfb = Object.FindFirstObjectByType<PlayerController>();
if (pcfb != null) playerObj = pcfb.gameObject;
}
if (playerObj != null && _collider != null)
{
var pc = playerObj.GetComponent<Collider2D>();
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()
{
// BT109 — Raycast 영역 폐기 (BT104 영역 부정확). Tilemap cell 기반 시작 위치 발판 검증.
_startY = transform.position.y;
_maxRightRange = MeasureSafeWalkDistance(1f);
_maxLeftRange = MeasureSafeWalkDistance(-1f);
#if UNITY_EDITOR && ENEMY_DIAG_VERBOSE
Debug.Log($"[Enemy@{name}] startX={_startX:F2} startY={_startY:F2} maxR={_maxRightRange:F2} maxL={_maxLeftRange:F2}");
#endif
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;
}
// BT107 — Tilemap 영역 cell 기반 측정 (PD 근본 진단: Raycast 영역 자체 부정확)
// Enemy 시작 위치 cell → 좌·우 연속 Tile 영역 끝 영역까지 거리 = 안전 patrol 거리
// BT110 (2026-05-08) — 다중 footPos offset fallback (PD 보고: 16건 중 8~9건 이동 X)
// 근본: Enemy 인스턴스마다 collider bounds·sprite bounds·transform.y 영역 변동
// → 다단 fallback (sprite/collider/transform × 다중 offset) 첫 HasTile cell 채택
float MeasureSafeWalkDistance(float dir)
{
if (_collider == null) return 0f;
var groundTilemaps = new System.Collections.Generic.List<UnityEngine.Tilemaps.Tilemap>();
var levelGo = GameObject.Find("Level");
if (levelGo != null)
{
var t = levelGo.GetComponent<UnityEngine.Tilemaps.Tilemap>();
if (t != null) groundTilemaps.Add(t);
}
var autoFgGo = GameObject.Find("AutoForeground");
if (autoFgGo != null)
{
var t = autoFgGo.GetComponent<UnityEngine.Tilemaps.Tilemap>();
if (t != null) groundTilemaps.Add(t);
}
if (groundTilemaps.Count == 0) return 0f;
// BT110 — 다중 footPos offset fallback
// sprite·collider·transform 각 영역 발 후보 + 다중 offset (-0.1·-0.3·-0.5·-0.7·-1.0)
// 각 후보 → 모든 groundTilemap HasTile 검사 → 첫 HasTile cell 채택
float colliderFootY = _collider.bounds.min.y;
float spriteFootY = spriteRenderer != null ? spriteRenderer.bounds.min.y : colliderFootY;
float transformY = transform.position.y;
float[] candidateYs = new float[] {
spriteFootY - 0.1f, spriteFootY - 0.3f, spriteFootY - 0.5f, spriteFootY - 0.7f, spriteFootY - 1.0f,
colliderFootY - 0.1f, colliderFootY - 0.3f, colliderFootY - 0.5f, colliderFootY - 0.7f, colliderFootY - 1.0f,
transformY - 0.1f, transformY - 0.3f, transformY - 0.5f, transformY - 0.7f, transformY - 1.0f
};
UnityEngine.Tilemaps.Tilemap startTm = null;
Vector3Int startCell = Vector3Int.zero;
float chosenFootY = 0f;
foreach (float candY in candidateYs)
{
Vector3 footPos = new Vector3(_startX, candY, 0f);
foreach (var tm in groundTilemaps)
{
Vector3Int cell = tm.WorldToCell(footPos);
if (tm.HasTile(cell))
{
startTm = tm;
startCell = cell;
chosenFootY = candY;
break;
}
}
if (startTm != null) break;
}
#if UNITY_EDITOR && ENEMY_DIAG_VERBOSE
Debug.Log($"[Enemy@{name}] dir={dir} startY={transformY:F2} colliderFoot={colliderFootY:F2} spriteFoot={spriteFootY:F2} chosenFootY={chosenFootY:F2} startTm={(startTm!=null?startTm.name:"NULL")} startCell={startCell}");
#endif
if (startTm == null) return 0f;
// 좌·우 연속 Tile 영역 끝 영역 검색
// BT111 (2026-05-08) — 위·아래 1 cell 인접 cell도 검색 (계단 영역 발판 연속 정합)
// PD 보고: 좁은 영역 1 cell 폭 발판 위 Enemy patrol 거리 0
// 근본: 좌·우 same-y cell 즉시 break → 계단·1 cell 폭 영역 발판 연속 미감지
// 정정: 다음 same-y / +1 / -1 중 어느 곳이라도 HasTile = 발판 연속
int dirCell = (int)Mathf.Sign(dir);
Vector3Int cellIter = startCell;
for (int steps = 0; steps < 500; steps++)
{
Vector3Int nextSame = cellIter + new Vector3Int(dirCell, 0, 0);
Vector3Int nextUp = nextSame + Vector3Int.up;
Vector3Int nextDown = nextSame + Vector3Int.down;
if (HasTileInAnyTilemap(groundTilemaps, nextSame)) cellIter = nextSame;
else if (HasTileInAnyTilemap(groundTilemaps, nextUp)) cellIter = nextUp;
else if (HasTileInAnyTilemap(groundTilemaps, nextDown)) cellIter = nextDown;
else break;
}
// 마지막 Tile cell 영역 center 영역까지 거리
Vector3 lastTileWorld = startTm.CellToWorld(cellIter) + new Vector3(0.5f, 0f, 0f);
float distance = Mathf.Abs(lastTileWorld.x - _startX);
return Mathf.Max(0f, distance - 0.5f); // 안전 margin 0.5m (Tile cell 영역 안)
}
static bool HasTileInAnyTilemap(System.Collections.Generic.List<UnityEngine.Tilemaps.Tilemap> tms, Vector3Int cell)
{
foreach (var tm in tms) if (tm != null && tm.HasTile(cell)) return true;
return false;
}
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;
}
// BT107 — BT106 y 강제 고정 영역 폐기 (PD 보고: 공중 부유 영역 원인)
// 대기 영역 — 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<PlayerController>();
// 2차 fallback: tag 영역 미설정 영역에 대비해 PlayerController 영역 직접 검색
if (_cachedPlayer == null)
{
_cachedPlayer = Object.FindFirstObjectByType<PlayerController>();
}
#if UNITY_EDITOR && ENEMY_DIAG_VERBOSE
if (Time.frameCount % 60 == 0)
{
int allCount = Object.FindObjectsByType<PlayerController>(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}");
}
#endif
}
// BT20 — IgnoreCollision 영역 Awake 시점 Player 발견 X 영역 fallback. Update 영역에서 발견 직후 1회 영역 호출.
if (_cachedPlayer != null && !_ignoreCollisionApplied && _collider != null)
{
var pc = _cachedPlayer.GetComponent<Collider2D>();
if (pc != null)
{
Physics2D.IgnoreCollision(_collider, pc, true);
_ignoreCollisionApplied = true;
#if UNITY_EDITOR && ENEMY_DIAG_VERBOSE
Debug.Log($"[BT20-Ignore@{name}] Player↔Enemy IgnoreCollision applied");
#endif
}
}
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)
{
#if UNITY_EDITOR && ENEMY_DIAG_VERBOSE
Debug.Log($"[EnemyDiag@{name}] inRange={inRange} footHeadDelta={footHeadDelta:F2} VB={VisualBounds.size} PB={_cachedPlayer.Bounds.size}");
#endif
_diagWasIntersecting = inRange;
}
if (inRange)
{
var ev = Schedule<PlayerEnemyCollision>();
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);
}
}
}
}