2026-05-18 버그 수정

This commit is contained in:
2026-05-18 17:59:13 +09:00
parent 67cedd8ad2
commit 80cf41af2a
19 changed files with 596 additions and 17 deletions

Binary file not shown.

View File

@@ -75,6 +75,7 @@ private void TryDamage(Collider2D other)
if (_alreadyHit.Contains(target)) return;
_alreadyHit.Add(target);
Debug.Log($"[Hitbox] t={Time.time:F3} damage={_damage} → {other.name} (parent={(target as MonoBehaviour)?.gameObject.name})");
Vector2? targetPosition = GetCorrectionTargetPosition(other);
target.TakeDamage(_damage, _hitVelocity, _hitReactionState, targetPosition, _correctHitTargetY, _hitPositionSolidMask, _hitPositionCorrectionDuration);
OnHit?.Invoke(target);

View File

@@ -19,6 +19,12 @@ public class Enemy : MonoBehaviour, IDamageable
[SerializeField] private float _wallBounceMinXVelocity = 1f;
[SerializeField] private float _wallBounceUpwardVelocity = 1.5f;
[Header("Separation")]
[SerializeField] private float _separationRadius = 0.6f;
[SerializeField] private float _separationStrength = 2f;
[SerializeField] private LayerMask _separationLayer;
private static readonly Collider2D[] _separationBuffer = new Collider2D[16];
private int _currentHealth;
private Rigidbody2D _rb;
private Animator _anim;
@@ -58,7 +64,10 @@ private void Update()
{
_flashTimer -= Time.deltaTime;
if (_flashTimer <= 0f && _spriteRenderer != null)
{
Debug.Log($"[Flash END] t={Time.time:F3} → revert to {_originalColor}");
_spriteRenderer.color = _originalColor;
}
}
if (_hitReactionTimer > 0f)
@@ -78,10 +87,60 @@ private void FixedUpdate()
}
ApplySmoothHitPositionCorrection();
ApplySeparation();
_lastVelocity = _rb.linearVelocity;
}
}
private void ApplySeparation()
{
if (_separationRadius <= 0f || _separationStrength <= 0f) return;
if (_separationLayer.value == 0) return;
ContactFilter2D filter = new ContactFilter2D
{
useLayerMask = true,
layerMask = _separationLayer,
useTriggers = false
};
int count = Physics2D.OverlapCircle(_rb.position, _separationRadius, filter, _separationBuffer);
Vector2 push = Vector2.zero;
int contributors = 0;
for (int i = 0; i < count; i++)
{
Collider2D other = _separationBuffer[i];
if (other == null) continue;
if (other.attachedRigidbody == _rb) continue;
Vector2 away = _rb.position - (Vector2)other.transform.position;
float dist = away.magnitude;
if (dist >= _separationRadius) continue;
Vector2 dir;
if (dist < 0.001f)
{
// 완전히 같은 위치인 경우 무작위 수평 방향으로 분리
dir = UnityEngine.Random.value < 0.5f ? Vector2.left : Vector2.right;
}
else
{
dir = away / dist;
}
float strength = 1f - (dist / _separationRadius);
push += dir * strength;
contributors++;
}
if (contributors == 0) return;
push /= contributors;
// X축으로만 분리. Y는 중력에 맡겨서 바운스/공중부양 방지.
_rb.position += new Vector2(push.x, 0f) * (_separationStrength * Time.fixedDeltaTime);
}
public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0f)
{
if (_currentHealth <= 0) return;
@@ -93,6 +152,7 @@ public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReac
if (_spriteRenderer != null)
{
Debug.Log($"[Flash START] t={Time.time:F3} duration={_hitFlashDuration:F3} (current color was {_spriteRenderer.color})");
_spriteRenderer.color = _hitFlashColor;
_flashTimer = _hitFlashDuration;
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Generic;
using UnityEngine;
public class EnemySpawner : MonoBehaviour
{
[Header("Spawn Configuration")]
[SerializeField] private Enemy _enemyPrefab;
[SerializeField] private int _maxAliveCount = 3;
[SerializeField] private float _spawnInterval = 2f;
[SerializeField] private float _initialDelay = 0f;
[Header("Spawn Position")]
[SerializeField] private Transform[] _spawnPoints;
[SerializeField] private float _spawnRadius = 0f;
[SerializeField] private Transform _enemyParent;
[Header("Behavior")]
[SerializeField] private bool _spawnOnStart = true;
[SerializeField] private bool _respawnOnDeath = true;
[SerializeField] private int _totalSpawnLimit = 0;
private readonly List<Enemy> _aliveEnemies = new();
private float _nextSpawnTime;
private int _totalSpawned;
private bool _active;
private void Start()
{
if (_spawnOnStart) BeginSpawning();
}
public void BeginSpawning()
{
_active = true;
_nextSpawnTime = Time.time + _initialDelay;
}
public void StopSpawning() => _active = false;
public void ResetSpawner()
{
for (int i = _aliveEnemies.Count - 1; i >= 0; i--)
{
if (_aliveEnemies[i] != null)
Destroy(_aliveEnemies[i].gameObject);
}
_aliveEnemies.Clear();
_totalSpawned = 0;
_nextSpawnTime = Time.time + _initialDelay;
}
private void Update()
{
if (!_active) return;
_aliveEnemies.RemoveAll(e => e == null);
if (!_respawnOnDeath && _aliveEnemies.Count == 0 && _totalSpawned > 0) return;
if (_totalSpawnLimit > 0 && _totalSpawned >= _totalSpawnLimit) return;
if (_aliveEnemies.Count >= _maxAliveCount) return;
if (Time.time < _nextSpawnTime) return;
if (_enemyPrefab == null) return;
SpawnOne();
_nextSpawnTime = Time.time + _spawnInterval;
}
public Enemy SpawnOne()
{
if (_enemyPrefab == null) return null;
Vector3 spawnPos = GetSpawnPosition();
Enemy enemy = Instantiate(_enemyPrefab, spawnPos, Quaternion.identity, _enemyParent);
_aliveEnemies.Add(enemy);
_totalSpawned++;
return enemy;
}
private Vector3 GetSpawnPosition()
{
Vector3 basePos;
if (_spawnPoints != null && _spawnPoints.Length > 0)
{
Transform pick = _spawnPoints[Random.Range(0, _spawnPoints.Length)];
basePos = pick != null ? pick.position : transform.position;
}
else
{
basePos = transform.position;
}
if (_spawnRadius > 0f)
{
Vector2 offset = Random.insideUnitCircle * _spawnRadius;
basePos.x += offset.x;
basePos.y += offset.y;
}
return basePos;
}
private void OnDrawGizmosSelected()
{
Color pointColor = Color.cyan;
Color radiusColor = new Color(0f, 1f, 1f, 0.25f);
if (_spawnPoints != null && _spawnPoints.Length > 0)
{
foreach (var p in _spawnPoints)
{
if (p == null) continue;
Gizmos.color = pointColor;
Gizmos.DrawWireSphere(p.position, 0.3f);
if (_spawnRadius > 0f)
{
Gizmos.color = radiusColor;
Gizmos.DrawWireSphere(p.position, _spawnRadius);
}
}
}
else
{
Gizmos.color = pointColor;
Gizmos.DrawWireSphere(transform.position, 0.3f);
if (_spawnRadius > 0f)
{
Gizmos.color = radiusColor;
Gizmos.DrawWireSphere(transform.position, _spawnRadius);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3ce7a44315e1034479529c22b51f84e7

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "WaveData", menuName = "Combat/WaveData")]
public class WaveData : ScriptableObject
{
public string WaveName;
public List<SpawnEntry> Spawns = new();
public float TimeLimit = 30f;
public float SpawnInterval = 0.3f;
public float StartDelay = 0f;
}
[Serializable]
public class SpawnEntry
{
public Enemy EnemyPrefab;
public int Count = 1;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 513c314684a222240a8b3d7f3e60b829

View File

@@ -0,0 +1,276 @@
using System;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
public class WaveManager : MonoBehaviour
{
[Header("Waves")]
[SerializeField] private List<WaveData> _waves = new();
[SerializeField] private float _intermissionDuration = 2f;
[SerializeField] private bool _startOnAwake = true;
[Header("Spawn Position")]
[SerializeField] private Transform[] _spawnPoints;
[SerializeField] private float _spawnRadius = 0f;
[SerializeField] private Transform _enemyParent;
[Header("On Defeat")]
[SerializeField] private bool _destroyAliveEnemiesOnDefeat = true;
public event Action<int, WaveData> OnWaveStart;
public event Action<int> OnWaveCleared;
public event Action OnAllWavesCleared;
public event Action<int> OnDefeat;
public int CurrentWaveIndex { get; private set; }
public int TotalWaveCount => _waves != null ? _waves.Count : 0;
public WaveData CurrentWave =>
_waves != null && CurrentWaveIndex >= 0 && CurrentWaveIndex < _waves.Count
? _waves[CurrentWaveIndex]
: null;
public float TimeRemaining { get; private set; }
public int AliveCount => _aliveEnemies.Count;
public int RemainingToSpawn { get; private set; }
public bool IsRunning { get; private set; }
public bool IsDefeated { get; private set; }
public bool IsVictory { get; private set; }
private readonly List<Enemy> _aliveEnemies = new();
private CancellationTokenSource _waveCts;
private void Start()
{
if (_startOnAwake) StartWaves();
}
private void OnDestroy()
{
_waveCts?.Cancel();
_waveCts?.Dispose();
}
public void StartWaves()
{
if (_waves == null || _waves.Count == 0) return;
RunAllWaves();
}
public void StopWaves()
{
_waveCts?.Cancel();
IsRunning = false;
}
private async void RunAllWaves()
{
_waveCts?.Cancel();
_waveCts?.Dispose();
_waveCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
CancellationToken token = _waveCts.Token;
IsDefeated = false;
IsVictory = false;
try
{
for (CurrentWaveIndex = 0; CurrentWaveIndex < _waves.Count; CurrentWaveIndex++)
{
WaveData wave = _waves[CurrentWaveIndex];
if (wave == null) continue;
if (wave.StartDelay > 0f)
await Awaitable.WaitForSecondsAsync(wave.StartDelay, token);
OnWaveStart?.Invoke(CurrentWaveIndex, wave);
bool cleared = await RunWave(wave, token);
if (!cleared)
{
IsDefeated = true;
OnDefeat?.Invoke(CurrentWaveIndex);
if (_destroyAliveEnemiesOnDefeat)
DestroyAliveEnemies();
return;
}
OnWaveCleared?.Invoke(CurrentWaveIndex);
if (CurrentWaveIndex < _waves.Count - 1 && _intermissionDuration > 0f)
await Awaitable.WaitForSecondsAsync(_intermissionDuration, token);
}
IsVictory = true;
OnAllWavesCleared?.Invoke();
}
catch (OperationCanceledException) { }
}
private async Awaitable<bool> RunWave(WaveData wave, CancellationToken token)
{
IsRunning = true;
TimeRemaining = wave.TimeLimit;
_aliveEnemies.Clear();
int totalToSpawn = 0;
foreach (var entry in wave.Spawns)
if (entry != null) totalToSpawn += Mathf.Max(entry.Count, 0);
RemainingToSpawn = totalToSpawn;
// 적 스폰은 백그라운드로 흘려보내고, 본 루프는 시간/클리어만 체크.
SpawnWaveEnemiesAsync(wave, token);
while (true)
{
token.ThrowIfCancellationRequested();
_aliveEnemies.RemoveAll(e => e == null);
if (RemainingToSpawn == 0 && _aliveEnemies.Count == 0)
{
IsRunning = false;
return true;
}
TimeRemaining -= Time.deltaTime;
if (TimeRemaining <= 0f)
{
IsRunning = false;
return false;
}
await Awaitable.NextFrameAsync(token);
}
}
private async void SpawnWaveEnemiesAsync(WaveData wave, CancellationToken token)
{
try
{
foreach (var entry in wave.Spawns)
{
if (entry == null || entry.EnemyPrefab == null)
{
RemainingToSpawn -= entry != null ? entry.Count : 0;
continue;
}
for (int i = 0; i < entry.Count; i++)
{
token.ThrowIfCancellationRequested();
SpawnEnemy(entry.EnemyPrefab);
RemainingToSpawn--;
if (wave.SpawnInterval > 0f)
await Awaitable.WaitForSecondsAsync(wave.SpawnInterval, token);
}
}
}
catch (OperationCanceledException) { }
}
private void SpawnEnemy(Enemy prefab)
{
Vector3 position = GetSpawnPosition();
Enemy enemy = Instantiate(prefab, position, Quaternion.identity, _enemyParent);
_aliveEnemies.Add(enemy);
}
private Vector3 GetSpawnPosition()
{
Vector3 basePos;
if (_spawnPoints != null && _spawnPoints.Length > 0)
{
Transform pick = _spawnPoints[UnityEngine.Random.Range(0, _spawnPoints.Length)];
basePos = pick != null ? pick.position : transform.position;
}
else
{
basePos = transform.position;
}
if (_spawnRadius > 0f)
{
Vector2 offset = UnityEngine.Random.insideUnitCircle * _spawnRadius;
basePos.x += offset.x;
basePos.y += offset.y;
}
return basePos;
}
private void DestroyAliveEnemies()
{
for (int i = _aliveEnemies.Count - 1; i >= 0; i--)
{
if (_aliveEnemies[i] != null)
Destroy(_aliveEnemies[i].gameObject);
}
_aliveEnemies.Clear();
}
private void OnGUI()
{
GUIStyle style = new GUIStyle(GUI.skin.box)
{
fontSize = 22,
alignment = TextAnchor.MiddleCenter,
richText = true,
normal = { textColor = Color.white }
};
string text;
if (IsVictory)
{
text = "<color=#88ff88><b>VICTORY</b></color>";
}
else if (IsDefeated)
{
text = $"<color=#ff8888><b>DEFEAT</b></color>\nWave {CurrentWaveIndex + 1} timed out";
}
else if (IsRunning && CurrentWave != null)
{
string name = string.IsNullOrEmpty(CurrentWave.WaveName)
? $"Wave {CurrentWaveIndex + 1}/{TotalWaveCount}"
: CurrentWave.WaveName;
text = $"<b>{name}</b>\nTime: {TimeRemaining:F1}s\nEnemies: {AliveCount} (+{RemainingToSpawn} 대기)";
}
else
{
return;
}
GUI.Box(new Rect(Screen.width / 2f - 180f, 20f, 360f, 110f), text, style);
}
private void OnDrawGizmosSelected()
{
Color pointColor = Color.cyan;
Color radiusColor = new Color(0f, 1f, 1f, 0.25f);
if (_spawnPoints != null && _spawnPoints.Length > 0)
{
foreach (var p in _spawnPoints)
{
if (p == null) continue;
Gizmos.color = pointColor;
Gizmos.DrawWireSphere(p.position, 0.3f);
if (_spawnRadius > 0f)
{
Gizmos.color = radiusColor;
Gizmos.DrawWireSphere(p.position, _spawnRadius);
}
}
}
else
{
Gizmos.color = pointColor;
Gizmos.DrawWireSphere(transform.position, 0.3f);
if (_spawnRadius > 0f)
{
Gizmos.color = radiusColor;
Gizmos.DrawWireSphere(transform.position, _spawnRadius);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0b4b77a885491744e8fc2316f85d4edc

View File

@@ -82,7 +82,9 @@ public class PlayerController : MonoBehaviour
private CancellationTokenSource _motionCts;
private CancellationTokenSource _animationSpeedCts;
private CancellationTokenSource _actionVelocityCts;
private bool _isAttackActive;
private bool _isMotionActive;
private float _actionDirection = 1f;
private ActionData _lastAttackGizmoData;
[SerializeField] private float _hitGizmoFadeDuration = 0.5f;
private ActionData _lastHitData;
@@ -161,10 +163,10 @@ private void FixedUpdate()
ExecuteBufferedInputIfReady();
TickComboWindow();
if (!IsMovementLocked())
if (!IsMovementLocked() && !IsActionActive())
_rb.linearVelocity = new Vector2(_moveInputX * _moveSpeed, _rb.linearVelocity.y);
if (!IsFacingLocked())
if (!IsFacingLocked() && !IsActionActive())
UpdateFacingFromMoveInput();
ApplyGravity();
@@ -201,7 +203,7 @@ private void TickComboWindow()
private void OnMoveInput(Vector2 value)
{
_moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x);
if (_facingLockTimer <= 0f)
if (_facingLockTimer <= 0f && !IsActionActive())
UpdateFacingFromMoveInput();
}
@@ -268,6 +270,7 @@ private void ExecuteComboInput(ComboInputType input)
{
if (transition.Trigger != input) continue;
if (transition.Next == null || transition.Next.Action == null) continue;
if (!CanPerformAction(transition.Next.Action)) continue;
ApplyForwardStep(transition.ForwardStep, transition.ForwardStepDuration);
PerformAttack(transition.Next.Action, transition.ForwardStep > 0f);
@@ -286,12 +289,21 @@ private void ExecuteComboInput(ComboInputType input)
_ => null
};
if (root == null || root.Action == null) return;
if (!CanPerformAction(root.Action)) return;
PerformAttack(root.Action);
_currentNode = root;
_comboWindowTimer = root.ComboWindow;
}
private bool CanPerformAction(ActionData data)
{
if (data == null) return false;
// 잡기 액션은 사정 거리 안에 적이 있어야만 실행. 없으면 입력 자체를 캔슬.
if (data.IsGrab && FindGrabTarget(data) == null) return false;
return true;
}
private void ExecuteMotionNode(ComboNode root)
{
if (root == null || root.Action == null) return;
@@ -336,14 +348,17 @@ private async void PerformAttack(ActionData data, bool preserveHorizontalVelocit
CancelMotion();
CancelAndDispose(ref _attackCts);
_attackCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
CancellationTokenSource currentAttackCts = _attackCts;
CancellationToken token = _attackCts.Token;
_attackCooldownTimer = data.Cooldown;
_isAttackActive = true;
_lastAttackGizmoData = data;
_attackStartTime = Time.time;
_hitFired = false;
ClearActionLocks();
CaptureActionDirection(data);
PlayActionAnimation(data);
PlayActionVelocity(data);
@@ -362,6 +377,9 @@ private async void PerformAttack(ActionData data, bool preserveHorizontalVelocit
{
_attackHitbox?.Deactivate();
}
if (_attackCts == currentAttackCts)
_isAttackActive = false;
}
private async void PerformMotion(ActionData data)
@@ -375,12 +393,13 @@ private async void PerformMotion(ActionData data)
CancellationToken token = _motionCts.Token;
SetMotionCooldown(data);
_isMotionActive = true;
ClearActionLocks();
CaptureActionDirection(data);
_isMotionActive = true;
FaceMotionDirection(data);
PlayActionAnimation(data);
PlayActionVelocity(data);
LockMovementIfNeeded(data);
LockMovementIfNeeded(data, false, true);
LockFacingIfNeeded(data);
bool completed = false;
@@ -432,6 +451,7 @@ private async void PerformGroundPound()
_hitFired = false;
ClearActionLocks();
CaptureActionDirection(_groundPoundData);
FaceMotionDirection(_groundPoundData);
PlayActionAnimation(_groundPoundData);
LockMovementIfNeeded(_groundPoundData);
@@ -512,6 +532,7 @@ private async void PerformGroundPound()
private void CancelAttack()
{
_isAttackActive = false;
CancelAndDispose(ref _attackCts);
CancelAndDispose(ref _actionVelocityCts);
_attackCooldownTimer = 0f;
@@ -527,6 +548,11 @@ private void CancelMotion()
CancelAndDispose(ref _actionVelocityCts);
}
private bool IsActionActive()
{
return _isAttackActive || _isMotionActive || _isGroundPounding;
}
private bool CanTransitionFromCurrentNode(ComboInputType input)
{
if (_comboWindowTimer <= 0f || _currentNode == null) return false;
@@ -672,6 +698,7 @@ private void ActivateAttackHitbox(ActionData data)
_lastHitTime = Time.time;
_hitFired = true;
Debug.Log($"[Activate] t={Time.time:F3} action={GetActionName(data)} hitDuration={data.HitDuration:F3} motionActive={_isMotionActive} groundPounding={_isGroundPounding}");
Vector2 sourcePosition = _rb != null ? _rb.position : (Vector2)transform.position;
_attackHitbox.Activate(data, localPosition, hitVelocity, sourcePosition, hitTargetPosition, data.CorrectHitTargetY, _groundLayer.value, _enemyLayer);
}
@@ -737,8 +764,17 @@ private void LockMovementIfNeeded(ActionData data, bool preserveHorizontalVeloci
if (!preserveHorizontalVelocity && data.Velocity == Vector2.zero)
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
_inputLockTimer = GetActionLockDuration(data);
_movementLockAction = ShouldUseAnimationLength(data) ? data : null;
if (forceLock)
{
// 액션 길이 전체를 보장: 애니메이션 길이와 무관하게 MotionDuration만큼 잠금.
_inputLockTimer = Mathf.Max(data.MotionDuration, 0.02f);
_movementLockAction = null;
}
else
{
_inputLockTimer = GetActionLockDuration(data);
_movementLockAction = ShouldUseAnimationLength(data) ? data : null;
}
}
private void LockFacingIfNeeded(ActionData data)
@@ -889,21 +925,25 @@ private float GetAnimationSpeed(ActionData data, float normalizedTime)
private float GetMotionDirection(ActionData data)
{
if (data.UseInputDirection && _moveInputX != 0f)
return _moveInputX;
return _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
return _actionDirection;
}
private void FaceMotionDirection(ActionData data)
{
if (_spriteRenderer == null) return;
if (!data.UseInputDirection || _moveInputX == 0f) return;
_spriteRenderer.flipX = _moveInputX < 0f;
_spriteRenderer.flipX = _actionDirection < 0f;
_facingLockTimer = 0f;
}
private void CaptureActionDirection(ActionData data)
{
if (data != null && data.UseInputDirection && _moveInputX != 0f)
_actionDirection = _moveInputX;
else
_actionDirection = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
}
private Vector2 GetHitVelocity(Vector2 hitVelocity)
{
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;

Binary file not shown.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a77af3b953d552941bac145ebbc388db
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

8
Assets/05_Data/Wave.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 127fb30e48b03af45b7851d8686d2867
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 159b6a709b35a3848a702dd8a9510a54
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: aabcb1f415e9ebe44a452489a6327f1f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant: