diff --git a/Assembly-CSharp.csproj.lscache b/Assembly-CSharp.csproj.lscache index 2d94f52..9f7ae1e 100644 --- a/Assembly-CSharp.csproj.lscache +++ b/Assembly-CSharp.csproj.lscache @@ -55,8 +55,12 @@ Assets/02_Scripts/Combat/ AttackHitbox.cs ComboNode.cs IDamageable.cs +Assets/02_Scripts/Enemy/ + Enemy.cs + EnemySpawner.cs + WaveData.cs + WaveManager.cs Assets/02_Scripts/ - Enemy/Enemy.cs GlobalObject.cs Input/GameInput.cs Managers/InputManager.cs diff --git a/Assets/01_Scenes/GameScene.unity b/Assets/01_Scenes/GameScene.unity index 5e95001..648d10e 100644 --- a/Assets/01_Scenes/GameScene.unity +++ b/Assets/01_Scenes/GameScene.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6c714d7630d0b521ecca352e0869abcd9e55b1418e02e4d6b03ab328e2dc0570 -size 52802 +oid sha256:6d35159662b78e48b0c9a66e21d86799b03a3b5c8f457ae08337e8c2ffd8b841 +size 54900 diff --git a/Assets/02_Scripts/Combat/AttackHitbox.cs b/Assets/02_Scripts/Combat/AttackHitbox.cs index 4d81bc8..00bb6cf 100644 --- a/Assets/02_Scripts/Combat/AttackHitbox.cs +++ b/Assets/02_Scripts/Combat/AttackHitbox.cs @@ -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); diff --git a/Assets/02_Scripts/Enemy/Enemy.cs b/Assets/02_Scripts/Enemy/Enemy.cs index 86e6f73..56ae538 100644 --- a/Assets/02_Scripts/Enemy/Enemy.cs +++ b/Assets/02_Scripts/Enemy/Enemy.cs @@ -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; } diff --git a/Assets/02_Scripts/Enemy/EnemySpawner.cs b/Assets/02_Scripts/Enemy/EnemySpawner.cs new file mode 100644 index 0000000..307c9bd --- /dev/null +++ b/Assets/02_Scripts/Enemy/EnemySpawner.cs @@ -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 _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); + } + } + } +} diff --git a/Assets/02_Scripts/Enemy/EnemySpawner.cs.meta b/Assets/02_Scripts/Enemy/EnemySpawner.cs.meta new file mode 100644 index 0000000..c9947ae --- /dev/null +++ b/Assets/02_Scripts/Enemy/EnemySpawner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3ce7a44315e1034479529c22b51f84e7 \ No newline at end of file diff --git a/Assets/02_Scripts/Enemy/WaveData.cs b/Assets/02_Scripts/Enemy/WaveData.cs new file mode 100644 index 0000000..efd9abb --- /dev/null +++ b/Assets/02_Scripts/Enemy/WaveData.cs @@ -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 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; +} diff --git a/Assets/02_Scripts/Enemy/WaveData.cs.meta b/Assets/02_Scripts/Enemy/WaveData.cs.meta new file mode 100644 index 0000000..cb09dc8 --- /dev/null +++ b/Assets/02_Scripts/Enemy/WaveData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 513c314684a222240a8b3d7f3e60b829 \ No newline at end of file diff --git a/Assets/02_Scripts/Enemy/WaveManager.cs b/Assets/02_Scripts/Enemy/WaveManager.cs new file mode 100644 index 0000000..c3fc2db --- /dev/null +++ b/Assets/02_Scripts/Enemy/WaveManager.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using UnityEngine; + +public class WaveManager : MonoBehaviour +{ + [Header("Waves")] + [SerializeField] private List _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 OnWaveStart; + public event Action OnWaveCleared; + public event Action OnAllWavesCleared; + public event Action 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 _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 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 = "VICTORY"; + } + else if (IsDefeated) + { + text = $"DEFEAT\nWave {CurrentWaveIndex + 1} timed out"; + } + else if (IsRunning && CurrentWave != null) + { + string name = string.IsNullOrEmpty(CurrentWave.WaveName) + ? $"Wave {CurrentWaveIndex + 1}/{TotalWaveCount}" + : CurrentWave.WaveName; + + text = $"{name}\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); + } + } + } +} diff --git a/Assets/02_Scripts/Enemy/WaveManager.cs.meta b/Assets/02_Scripts/Enemy/WaveManager.cs.meta new file mode 100644 index 0000000..889121d --- /dev/null +++ b/Assets/02_Scripts/Enemy/WaveManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0b4b77a885491744e8fc2316f85d4edc \ No newline at end of file diff --git a/Assets/02_Scripts/Player/PlayerController.cs b/Assets/02_Scripts/Player/PlayerController.cs index c740760..73e619f 100644 --- a/Assets/02_Scripts/Player/PlayerController.cs +++ b/Assets/02_Scripts/Player/PlayerController.cs @@ -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; diff --git a/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab new file mode 100644 index 0000000..3592842 --- /dev/null +++ b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62a69515a450783082d67bd6b7e3e1d28d28d2e0d73e18476eab71713904663a +size 4973 diff --git a/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab.meta b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab.meta new file mode 100644 index 0000000..03b1e93 --- /dev/null +++ b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a77af3b953d552941bac145ebbc388db +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/05_Data/Wave.meta b/Assets/05_Data/Wave.meta new file mode 100644 index 0000000..13a158a --- /dev/null +++ b/Assets/05_Data/Wave.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 127fb30e48b03af45b7851d8686d2867 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/05_Data/Wave/WaveData1.asset b/Assets/05_Data/Wave/WaveData1.asset new file mode 100644 index 0000000..683b0de --- /dev/null +++ b/Assets/05_Data/Wave/WaveData1.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86a00eac8396aa59ccc299b3b99d88089a6e2bdcf8f83ef85b4edeadae966edb +size 617 diff --git a/Assets/05_Data/Wave/WaveData1.asset.meta b/Assets/05_Data/Wave/WaveData1.asset.meta new file mode 100644 index 0000000..b6f332b --- /dev/null +++ b/Assets/05_Data/Wave/WaveData1.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 159b6a709b35a3848a702dd8a9510a54 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/05_Data/Wave/WaveData2.asset b/Assets/05_Data/Wave/WaveData2.asset new file mode 100644 index 0000000..a14d972 --- /dev/null +++ b/Assets/05_Data/Wave/WaveData2.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04081ed018c3dc72a912957f5ec8288fd96574b31b5b287cf0652f825eb0a96f +size 619 diff --git a/Assets/05_Data/Wave/WaveData2.asset.meta b/Assets/05_Data/Wave/WaveData2.asset.meta new file mode 100644 index 0000000..996c154 --- /dev/null +++ b/Assets/05_Data/Wave/WaveData2.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aabcb1f415e9ebe44a452489a6327f1f +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/ProjectSettings/Physics2DSettings.asset b/ProjectSettings/Physics2DSettings.asset index 691d905..57086ac 100644 --- a/ProjectSettings/Physics2DSettings.asset +++ b/ProjectSettings/Physics2DSettings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f183f72ae5340e621daed51b23ea478a8e424786b431033d6886c5a3d2c283c +oid sha256:502eb4648398709e214186787fcc47c7ad4b40c07bfe1d6b8706d67dcf172c4f size 1862