EerieVillage/Assets/Scripts/Mechanics/EnemyController.cs

415 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;
// BT12-Dev 2026-05-11 — 밟기 공격 기능 제거 (PD 지시): hitRangeX·hitRangeY·stompMinDy 폐기.
// Enemy는 Player 공격에만 피해를 받으므로 Player ↔ Enemy 충돌 감지 자체 불필요.
// 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;
}
}
// BT12-Dev 2026-05-11 — PlatformEffector2D 영역 IgnoreCollision (PD 지시: Enemy 발판 자유 통과)
// 발판 17개·Enemy 16개 — Awake 시점 일괄 IgnoreCollision 등록.
if (_collider != null)
{
var platforms = Object.FindObjectsByType<PlatformEffector2D>(FindObjectsSortMode.None);
foreach (var platform in platforms)
{
var platCol = platform.GetComponent<Collider2D>();
if (platCol != null) Physics2D.IgnoreCollision(_collider, platCol, true);
}
}
// BT12-Dev 2026-05-11 — Enemy ↔ Enemy IgnoreLayerCollision (PD 지시: 몬스터 영역 통과)
// Layer 14 (Enemy) ↔ Layer 14 collide X·전역 1회 적용 (Awake 영역 매번 호출 무관·idempotent).
Physics2D.IgnoreLayerCollision(14, 14, 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);
}
// BT12-Dev 2026-05-10 — Tilemap 부재 fallback (Level 비활성·AutoForeground 부재 환경).
// Tilemap 없이 GameObject 기반 Composite Collider (InfiniteHorizontalGround) 영역 patrol.
// patrolMaxRange 영역 좌우 왕복·cliffCheck Raycast (Layer 0 GameObject hit) 영역 절벽 검출 정합.
if (groundTilemaps.Count == 0) return patrolMaxRange;
// 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
// BT12-Dev 2026-05-10 — startTm 부재 fallback (Tilemap Count>0이나 Enemy 시작 cell HasTile X 환경).
// GameObject 기반 Composite Collider patrol 정합·patrolMaxRange 좌우 왕복.
if (startTm == null) return patrolMaxRange;
// 좌·우 연속 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;
}
// BT12-Dev 2026-05-11 — 밟기 공격 폐기 (PD 지시): PlayerEnemyCollision 발화 제거.
// Player ↔ Enemy IgnoreCollision은 Awake 시점에 이미 적용되어 물리 통과 정합.
//
// PD 지시 2026-05-11 — Player ↔ Enemy 닿음 시 Player 피해 (밟기는 X·통과).
// Player IsGrounded 상태에서만 피격·공중(점프) 상태는 통과.
if (_cachedPlayer == null)
{
var pgo = GameObject.FindGameObjectWithTag("Player");
if (pgo != null) _cachedPlayer = pgo.GetComponent<PlayerController>();
if (_cachedPlayer == null) _cachedPlayer = Object.FindFirstObjectByType<PlayerController>();
}
if (_cachedPlayer != null && _cachedPlayer.health != null && _cachedPlayer.health.IsAlive)
{
if (_cachedPlayer.IsGrounded && VisualBounds.Intersects(_cachedPlayer.Bounds))
{
if (!_cachedPlayer.health.IsInvulnerable)
{
_cachedPlayer.health.Decrement();
}
}
}
}
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);
}
}
}
}