2026-05-18 버그 수정
This commit is contained in:
@@ -55,8 +55,12 @@ Assets/02_Scripts/Combat/
|
|||||||
AttackHitbox.cs
|
AttackHitbox.cs
|
||||||
ComboNode.cs
|
ComboNode.cs
|
||||||
IDamageable.cs
|
IDamageable.cs
|
||||||
|
Assets/02_Scripts/Enemy/
|
||||||
|
Enemy.cs
|
||||||
|
EnemySpawner.cs
|
||||||
|
WaveData.cs
|
||||||
|
WaveManager.cs
|
||||||
Assets/02_Scripts/
|
Assets/02_Scripts/
|
||||||
Enemy/Enemy.cs
|
|
||||||
GlobalObject.cs
|
GlobalObject.cs
|
||||||
Input/GameInput.cs
|
Input/GameInput.cs
|
||||||
Managers/InputManager.cs
|
Managers/InputManager.cs
|
||||||
|
|||||||
BIN
Assets/01_Scenes/GameScene.unity
LFS
BIN
Assets/01_Scenes/GameScene.unity
LFS
Binary file not shown.
@@ -75,6 +75,7 @@ private void TryDamage(Collider2D other)
|
|||||||
if (_alreadyHit.Contains(target)) return;
|
if (_alreadyHit.Contains(target)) return;
|
||||||
|
|
||||||
_alreadyHit.Add(target);
|
_alreadyHit.Add(target);
|
||||||
|
Debug.Log($"[Hitbox] t={Time.time:F3} damage={_damage} → {other.name} (parent={(target as MonoBehaviour)?.gameObject.name})");
|
||||||
Vector2? targetPosition = GetCorrectionTargetPosition(other);
|
Vector2? targetPosition = GetCorrectionTargetPosition(other);
|
||||||
target.TakeDamage(_damage, _hitVelocity, _hitReactionState, targetPosition, _correctHitTargetY, _hitPositionSolidMask, _hitPositionCorrectionDuration);
|
target.TakeDamage(_damage, _hitVelocity, _hitReactionState, targetPosition, _correctHitTargetY, _hitPositionSolidMask, _hitPositionCorrectionDuration);
|
||||||
OnHit?.Invoke(target);
|
OnHit?.Invoke(target);
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ public class Enemy : MonoBehaviour, IDamageable
|
|||||||
[SerializeField] private float _wallBounceMinXVelocity = 1f;
|
[SerializeField] private float _wallBounceMinXVelocity = 1f;
|
||||||
[SerializeField] private float _wallBounceUpwardVelocity = 1.5f;
|
[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 int _currentHealth;
|
||||||
private Rigidbody2D _rb;
|
private Rigidbody2D _rb;
|
||||||
private Animator _anim;
|
private Animator _anim;
|
||||||
@@ -58,7 +64,10 @@ private void Update()
|
|||||||
{
|
{
|
||||||
_flashTimer -= Time.deltaTime;
|
_flashTimer -= Time.deltaTime;
|
||||||
if (_flashTimer <= 0f && _spriteRenderer != null)
|
if (_flashTimer <= 0f && _spriteRenderer != null)
|
||||||
|
{
|
||||||
|
Debug.Log($"[Flash END] t={Time.time:F3} → revert to {_originalColor}");
|
||||||
_spriteRenderer.color = _originalColor;
|
_spriteRenderer.color = _originalColor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_hitReactionTimer > 0f)
|
if (_hitReactionTimer > 0f)
|
||||||
@@ -78,10 +87,60 @@ private void FixedUpdate()
|
|||||||
}
|
}
|
||||||
|
|
||||||
ApplySmoothHitPositionCorrection();
|
ApplySmoothHitPositionCorrection();
|
||||||
|
ApplySeparation();
|
||||||
_lastVelocity = _rb.linearVelocity;
|
_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)
|
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;
|
if (_currentHealth <= 0) return;
|
||||||
@@ -93,6 +152,7 @@ public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReac
|
|||||||
|
|
||||||
if (_spriteRenderer != null)
|
if (_spriteRenderer != null)
|
||||||
{
|
{
|
||||||
|
Debug.Log($"[Flash START] t={Time.time:F3} duration={_hitFlashDuration:F3} (current color was {_spriteRenderer.color})");
|
||||||
_spriteRenderer.color = _hitFlashColor;
|
_spriteRenderer.color = _hitFlashColor;
|
||||||
_flashTimer = _hitFlashDuration;
|
_flashTimer = _hitFlashDuration;
|
||||||
}
|
}
|
||||||
|
|||||||
132
Assets/02_Scripts/Enemy/EnemySpawner.cs
Normal file
132
Assets/02_Scripts/Enemy/EnemySpawner.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/02_Scripts/Enemy/EnemySpawner.cs.meta
Normal file
2
Assets/02_Scripts/Enemy/EnemySpawner.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3ce7a44315e1034479529c22b51f84e7
|
||||||
20
Assets/02_Scripts/Enemy/WaveData.cs
Normal file
20
Assets/02_Scripts/Enemy/WaveData.cs
Normal 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;
|
||||||
|
}
|
||||||
2
Assets/02_Scripts/Enemy/WaveData.cs.meta
Normal file
2
Assets/02_Scripts/Enemy/WaveData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 513c314684a222240a8b3d7f3e60b829
|
||||||
276
Assets/02_Scripts/Enemy/WaveManager.cs
Normal file
276
Assets/02_Scripts/Enemy/WaveManager.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/02_Scripts/Enemy/WaveManager.cs.meta
Normal file
2
Assets/02_Scripts/Enemy/WaveManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0b4b77a885491744e8fc2316f85d4edc
|
||||||
@@ -82,7 +82,9 @@ public class PlayerController : MonoBehaviour
|
|||||||
private CancellationTokenSource _motionCts;
|
private CancellationTokenSource _motionCts;
|
||||||
private CancellationTokenSource _animationSpeedCts;
|
private CancellationTokenSource _animationSpeedCts;
|
||||||
private CancellationTokenSource _actionVelocityCts;
|
private CancellationTokenSource _actionVelocityCts;
|
||||||
|
private bool _isAttackActive;
|
||||||
private bool _isMotionActive;
|
private bool _isMotionActive;
|
||||||
|
private float _actionDirection = 1f;
|
||||||
private ActionData _lastAttackGizmoData;
|
private ActionData _lastAttackGizmoData;
|
||||||
[SerializeField] private float _hitGizmoFadeDuration = 0.5f;
|
[SerializeField] private float _hitGizmoFadeDuration = 0.5f;
|
||||||
private ActionData _lastHitData;
|
private ActionData _lastHitData;
|
||||||
@@ -161,10 +163,10 @@ private void FixedUpdate()
|
|||||||
ExecuteBufferedInputIfReady();
|
ExecuteBufferedInputIfReady();
|
||||||
TickComboWindow();
|
TickComboWindow();
|
||||||
|
|
||||||
if (!IsMovementLocked())
|
if (!IsMovementLocked() && !IsActionActive())
|
||||||
_rb.linearVelocity = new Vector2(_moveInputX * _moveSpeed, _rb.linearVelocity.y);
|
_rb.linearVelocity = new Vector2(_moveInputX * _moveSpeed, _rb.linearVelocity.y);
|
||||||
|
|
||||||
if (!IsFacingLocked())
|
if (!IsFacingLocked() && !IsActionActive())
|
||||||
UpdateFacingFromMoveInput();
|
UpdateFacingFromMoveInput();
|
||||||
|
|
||||||
ApplyGravity();
|
ApplyGravity();
|
||||||
@@ -201,7 +203,7 @@ private void TickComboWindow()
|
|||||||
private void OnMoveInput(Vector2 value)
|
private void OnMoveInput(Vector2 value)
|
||||||
{
|
{
|
||||||
_moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x);
|
_moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x);
|
||||||
if (_facingLockTimer <= 0f)
|
if (_facingLockTimer <= 0f && !IsActionActive())
|
||||||
UpdateFacingFromMoveInput();
|
UpdateFacingFromMoveInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +270,7 @@ private void ExecuteComboInput(ComboInputType input)
|
|||||||
{
|
{
|
||||||
if (transition.Trigger != input) continue;
|
if (transition.Trigger != input) continue;
|
||||||
if (transition.Next == null || transition.Next.Action == null) continue;
|
if (transition.Next == null || transition.Next.Action == null) continue;
|
||||||
|
if (!CanPerformAction(transition.Next.Action)) continue;
|
||||||
|
|
||||||
ApplyForwardStep(transition.ForwardStep, transition.ForwardStepDuration);
|
ApplyForwardStep(transition.ForwardStep, transition.ForwardStepDuration);
|
||||||
PerformAttack(transition.Next.Action, transition.ForwardStep > 0f);
|
PerformAttack(transition.Next.Action, transition.ForwardStep > 0f);
|
||||||
@@ -286,12 +289,21 @@ private void ExecuteComboInput(ComboInputType input)
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
if (root == null || root.Action == null) return;
|
if (root == null || root.Action == null) return;
|
||||||
|
if (!CanPerformAction(root.Action)) return;
|
||||||
|
|
||||||
PerformAttack(root.Action);
|
PerformAttack(root.Action);
|
||||||
_currentNode = root;
|
_currentNode = root;
|
||||||
_comboWindowTimer = root.ComboWindow;
|
_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)
|
private void ExecuteMotionNode(ComboNode root)
|
||||||
{
|
{
|
||||||
if (root == null || root.Action == null) return;
|
if (root == null || root.Action == null) return;
|
||||||
@@ -336,14 +348,17 @@ private async void PerformAttack(ActionData data, bool preserveHorizontalVelocit
|
|||||||
CancelMotion();
|
CancelMotion();
|
||||||
CancelAndDispose(ref _attackCts);
|
CancelAndDispose(ref _attackCts);
|
||||||
_attackCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
_attackCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
||||||
|
CancellationTokenSource currentAttackCts = _attackCts;
|
||||||
CancellationToken token = _attackCts.Token;
|
CancellationToken token = _attackCts.Token;
|
||||||
|
|
||||||
_attackCooldownTimer = data.Cooldown;
|
_attackCooldownTimer = data.Cooldown;
|
||||||
|
_isAttackActive = true;
|
||||||
_lastAttackGizmoData = data;
|
_lastAttackGizmoData = data;
|
||||||
_attackStartTime = Time.time;
|
_attackStartTime = Time.time;
|
||||||
_hitFired = false;
|
_hitFired = false;
|
||||||
|
|
||||||
ClearActionLocks();
|
ClearActionLocks();
|
||||||
|
CaptureActionDirection(data);
|
||||||
PlayActionAnimation(data);
|
PlayActionAnimation(data);
|
||||||
|
|
||||||
PlayActionVelocity(data);
|
PlayActionVelocity(data);
|
||||||
@@ -362,6 +377,9 @@ private async void PerformAttack(ActionData data, bool preserveHorizontalVelocit
|
|||||||
{
|
{
|
||||||
_attackHitbox?.Deactivate();
|
_attackHitbox?.Deactivate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_attackCts == currentAttackCts)
|
||||||
|
_isAttackActive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void PerformMotion(ActionData data)
|
private async void PerformMotion(ActionData data)
|
||||||
@@ -375,12 +393,13 @@ private async void PerformMotion(ActionData data)
|
|||||||
CancellationToken token = _motionCts.Token;
|
CancellationToken token = _motionCts.Token;
|
||||||
|
|
||||||
SetMotionCooldown(data);
|
SetMotionCooldown(data);
|
||||||
_isMotionActive = true;
|
|
||||||
ClearActionLocks();
|
ClearActionLocks();
|
||||||
|
CaptureActionDirection(data);
|
||||||
|
_isMotionActive = true;
|
||||||
FaceMotionDirection(data);
|
FaceMotionDirection(data);
|
||||||
PlayActionAnimation(data);
|
PlayActionAnimation(data);
|
||||||
PlayActionVelocity(data);
|
PlayActionVelocity(data);
|
||||||
LockMovementIfNeeded(data);
|
LockMovementIfNeeded(data, false, true);
|
||||||
LockFacingIfNeeded(data);
|
LockFacingIfNeeded(data);
|
||||||
|
|
||||||
bool completed = false;
|
bool completed = false;
|
||||||
@@ -432,6 +451,7 @@ private async void PerformGroundPound()
|
|||||||
_hitFired = false;
|
_hitFired = false;
|
||||||
|
|
||||||
ClearActionLocks();
|
ClearActionLocks();
|
||||||
|
CaptureActionDirection(_groundPoundData);
|
||||||
FaceMotionDirection(_groundPoundData);
|
FaceMotionDirection(_groundPoundData);
|
||||||
PlayActionAnimation(_groundPoundData);
|
PlayActionAnimation(_groundPoundData);
|
||||||
LockMovementIfNeeded(_groundPoundData);
|
LockMovementIfNeeded(_groundPoundData);
|
||||||
@@ -512,6 +532,7 @@ private async void PerformGroundPound()
|
|||||||
|
|
||||||
private void CancelAttack()
|
private void CancelAttack()
|
||||||
{
|
{
|
||||||
|
_isAttackActive = false;
|
||||||
CancelAndDispose(ref _attackCts);
|
CancelAndDispose(ref _attackCts);
|
||||||
CancelAndDispose(ref _actionVelocityCts);
|
CancelAndDispose(ref _actionVelocityCts);
|
||||||
_attackCooldownTimer = 0f;
|
_attackCooldownTimer = 0f;
|
||||||
@@ -527,6 +548,11 @@ private void CancelMotion()
|
|||||||
CancelAndDispose(ref _actionVelocityCts);
|
CancelAndDispose(ref _actionVelocityCts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsActionActive()
|
||||||
|
{
|
||||||
|
return _isAttackActive || _isMotionActive || _isGroundPounding;
|
||||||
|
}
|
||||||
|
|
||||||
private bool CanTransitionFromCurrentNode(ComboInputType input)
|
private bool CanTransitionFromCurrentNode(ComboInputType input)
|
||||||
{
|
{
|
||||||
if (_comboWindowTimer <= 0f || _currentNode == null) return false;
|
if (_comboWindowTimer <= 0f || _currentNode == null) return false;
|
||||||
@@ -672,6 +698,7 @@ private void ActivateAttackHitbox(ActionData data)
|
|||||||
_lastHitTime = Time.time;
|
_lastHitTime = Time.time;
|
||||||
_hitFired = true;
|
_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;
|
Vector2 sourcePosition = _rb != null ? _rb.position : (Vector2)transform.position;
|
||||||
_attackHitbox.Activate(data, localPosition, hitVelocity, sourcePosition, hitTargetPosition, data.CorrectHitTargetY, _groundLayer.value, _enemyLayer);
|
_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)
|
if (!preserveHorizontalVelocity && data.Velocity == Vector2.zero)
|
||||||
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
|
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
|
||||||
|
|
||||||
_inputLockTimer = GetActionLockDuration(data);
|
if (forceLock)
|
||||||
_movementLockAction = ShouldUseAnimationLength(data) ? data : null;
|
{
|
||||||
|
// 액션 길이 전체를 보장: 애니메이션 길이와 무관하게 MotionDuration만큼 잠금.
|
||||||
|
_inputLockTimer = Mathf.Max(data.MotionDuration, 0.02f);
|
||||||
|
_movementLockAction = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_inputLockTimer = GetActionLockDuration(data);
|
||||||
|
_movementLockAction = ShouldUseAnimationLength(data) ? data : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LockFacingIfNeeded(ActionData data)
|
private void LockFacingIfNeeded(ActionData data)
|
||||||
@@ -889,21 +925,25 @@ private float GetAnimationSpeed(ActionData data, float normalizedTime)
|
|||||||
|
|
||||||
private float GetMotionDirection(ActionData data)
|
private float GetMotionDirection(ActionData data)
|
||||||
{
|
{
|
||||||
if (data.UseInputDirection && _moveInputX != 0f)
|
return _actionDirection;
|
||||||
return _moveInputX;
|
|
||||||
|
|
||||||
return _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FaceMotionDirection(ActionData data)
|
private void FaceMotionDirection(ActionData data)
|
||||||
{
|
{
|
||||||
if (_spriteRenderer == null) return;
|
if (_spriteRenderer == null) return;
|
||||||
if (!data.UseInputDirection || _moveInputX == 0f) return;
|
|
||||||
|
|
||||||
_spriteRenderer.flipX = _moveInputX < 0f;
|
_spriteRenderer.flipX = _actionDirection < 0f;
|
||||||
_facingLockTimer = 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)
|
private Vector2 GetHitVelocity(Vector2 hitVelocity)
|
||||||
{
|
{
|
||||||
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
||||||
|
|||||||
BIN
Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab
LFS
Normal file
BIN
Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab
LFS
Normal file
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a77af3b953d552941bac145ebbc388db
|
||||||
|
PrefabImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Assets/05_Data/Wave.meta
Normal file
8
Assets/05_Data/Wave.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 127fb30e48b03af45b7851d8686d2867
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
Assets/05_Data/Wave/WaveData1.asset
LFS
Normal file
BIN
Assets/05_Data/Wave/WaveData1.asset
LFS
Normal file
Binary file not shown.
8
Assets/05_Data/Wave/WaveData1.asset.meta
Normal file
8
Assets/05_Data/Wave/WaveData1.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 159b6a709b35a3848a702dd8a9510a54
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
Assets/05_Data/Wave/WaveData2.asset
LFS
Normal file
BIN
Assets/05_Data/Wave/WaveData2.asset
LFS
Normal file
Binary file not shown.
8
Assets/05_Data/Wave/WaveData2.asset.meta
Normal file
8
Assets/05_Data/Wave/WaveData2.asset.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: aabcb1f415e9ebe44a452489a6327f1f
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Binary file not shown.
Reference in New Issue
Block a user