2026-05-15 공격과 피격
This commit is contained in:
@@ -1,29 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
|
||||
public class PlayerController : MonoBehaviour
|
||||
{
|
||||
[Header("Movement")]
|
||||
[SerializeField] private float moveSpeed = 5f;
|
||||
[SerializeField] private float _moveSpeed = 5f;
|
||||
private float _moveInputX = 0f;
|
||||
|
||||
[Header("Jump")]
|
||||
[SerializeField] private float jumpForce = 12f;
|
||||
[SerializeField] private Transform groundCheck;
|
||||
[SerializeField] private float groundCheckRadius = 0.1f;
|
||||
[SerializeField] private LayerMask groundLayer;
|
||||
[SerializeField] private float _jumpForce = 8f;
|
||||
[SerializeField] private Transform _groundCheck;
|
||||
[SerializeField] private float _groundCheckRadius = 0.1f;
|
||||
[SerializeField] private LayerMask _groundLayer;
|
||||
private bool _isGrounded;
|
||||
|
||||
private Rigidbody2D rb;
|
||||
private float moveInputX = 0f;
|
||||
private bool isGrounded;
|
||||
[Header("WallSlide")]
|
||||
[SerializeField] private Transform _wallCheckLeft;
|
||||
[SerializeField] private Transform _wallCheckRight;
|
||||
[SerializeField] private float _wallCheckRadius = 0.1f;
|
||||
[SerializeField] private float _wallSlideSpeed = 2f;
|
||||
[SerializeField] private Vector2 _wallJumpForce = new Vector2(4f, 5f);
|
||||
[SerializeField] private float _wallJumpInputLockDuration = 0.15f;
|
||||
private bool _isTouchingLeftWall;
|
||||
private bool _isTouchingRightWall;
|
||||
private bool IsTouchingWall => _isTouchingLeftWall || _isTouchingRightWall;
|
||||
private int _wallDirection;
|
||||
private float _inputLockTimer;
|
||||
|
||||
[Header("Attack")]
|
||||
[SerializeField] private ComboNode _punchRootNode;
|
||||
[SerializeField] private ComboNode _kickRootNode;
|
||||
[SerializeField] private LayerMask _enemyLayer;
|
||||
[SerializeField] private string _idleAnimationState = "Idle";
|
||||
[SerializeField] private float _bufferOpenTime = 0.1f;
|
||||
[SerializeField] private float _bufferLifetime = 0.5f;
|
||||
private ComboInputType? _pendingInput;
|
||||
private float _pendingInputTime = -1f;
|
||||
private float _attackCooldownTimer;
|
||||
private ComboNode _currentNode;
|
||||
private float _comboWindowTimer;
|
||||
private CancellationTokenSource _attackCts;
|
||||
private AttackData _lastAttackGizmoData;
|
||||
private float _lastAttackGizmoTime = -1f;
|
||||
[SerializeField] private float _hitGizmoFadeDuration = 0.5f;
|
||||
private AttackData _lastHitData;
|
||||
private Vector2 _lastHitCenter;
|
||||
private float _lastHitTime = -1f;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool _showAttackDebug = true;
|
||||
private float _attackStartTime = -1f;
|
||||
private bool _hitFired;
|
||||
|
||||
private Rigidbody2D _rb;
|
||||
private Animator _anim;
|
||||
private SpriteRenderer _spriteRenderer;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
rb = GetComponent<Rigidbody2D>();
|
||||
_rb = GetComponent<Rigidbody2D>();
|
||||
_anim = GetComponent<Animator>();
|
||||
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
InputManager.Instance.OnMove_Event += OnMoveInput;
|
||||
InputManager.Instance.OnJump_Event += OnJumpInput;
|
||||
|
||||
InputManager.Instance.OnPunch_Event += OnPunchInput;
|
||||
InputManager.Instance.OnKick_Event += OnKickInput;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
@@ -32,30 +79,356 @@ private void OnDestroy()
|
||||
{
|
||||
InputManager.Instance.OnMove_Event -= OnMoveInput;
|
||||
InputManager.Instance.OnJump_Event -= OnJumpInput;
|
||||
InputManager.Instance.OnPunch_Event -= OnPunchInput;
|
||||
InputManager.Instance.OnKick_Event -= OnKickInput;
|
||||
}
|
||||
|
||||
_attackCts?.Cancel();
|
||||
_attackCts?.Dispose();
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
{
|
||||
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
|
||||
rb.linearVelocity = new Vector2(moveInputX * moveSpeed, rb.linearVelocity.y);
|
||||
_isGrounded = Physics2D.OverlapCircle(_groundCheck.position, _groundCheckRadius, _groundLayer);
|
||||
_isTouchingLeftWall = Physics2D.OverlapCircle(_wallCheckLeft.position, _wallCheckRadius, _groundLayer);
|
||||
_isTouchingRightWall = Physics2D.OverlapCircle(_wallCheckRight.position, _wallCheckRadius, _groundLayer);
|
||||
_wallDirection = _isTouchingRightWall ? 1 : (_isTouchingLeftWall ? -1 : 0);
|
||||
|
||||
if (_attackCooldownTimer > 0f)
|
||||
_attackCooldownTimer -= Time.fixedDeltaTime;
|
||||
|
||||
if (_attackCooldownTimer <= 0f && _pendingInput.HasValue)
|
||||
{
|
||||
ComboInputType buffered = _pendingInput.Value;
|
||||
bool stillValid = Time.time - _pendingInputTime <= _bufferLifetime;
|
||||
_pendingInput = null;
|
||||
if (stillValid) ExecuteComboInput(buffered);
|
||||
}
|
||||
|
||||
if (_comboWindowTimer > 0f)
|
||||
{
|
||||
_comboWindowTimer -= Time.fixedDeltaTime;
|
||||
if (_comboWindowTimer <= 0f)
|
||||
_currentNode = null;
|
||||
}
|
||||
|
||||
if (_inputLockTimer > 0f)
|
||||
_inputLockTimer -= Time.fixedDeltaTime;
|
||||
else
|
||||
_rb.linearVelocity = new Vector2(_moveInputX * _moveSpeed, _rb.linearVelocity.y);
|
||||
|
||||
if (IsTouchingWall && !_isGrounded && _rb.linearVelocity.y < -_wallSlideSpeed)
|
||||
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, -_wallSlideSpeed);
|
||||
}
|
||||
|
||||
private void OnMoveInput(Vector2 value)
|
||||
{
|
||||
moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x);
|
||||
_moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x);
|
||||
|
||||
if (_moveInputX != 0f && _spriteRenderer != null)
|
||||
_spriteRenderer.flipX = _moveInputX < 0f;
|
||||
}
|
||||
|
||||
private void OnJumpInput()
|
||||
{
|
||||
if (isGrounded)
|
||||
rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
|
||||
if (_isGrounded)
|
||||
{
|
||||
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, _jumpForce);
|
||||
}
|
||||
else if (IsTouchingWall)
|
||||
{
|
||||
_rb.linearVelocity = new Vector2(-_wallDirection * _wallJumpForce.x, _wallJumpForce.y);
|
||||
_inputLockTimer = _wallJumpInputLockDuration;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPunchInput() => HandleComboInput(ComboInputType.Punch);
|
||||
private void OnKickInput() => HandleComboInput(ComboInputType.Kick);
|
||||
|
||||
private void HandleComboInput(ComboInputType input)
|
||||
{
|
||||
if (_attackCooldownTimer > 0f)
|
||||
{
|
||||
float elapsed = Time.time - _attackStartTime;
|
||||
if (_lastAttackGizmoData != null && elapsed >= _bufferOpenTime)
|
||||
{
|
||||
_pendingInput = input;
|
||||
_pendingInputTime = Time.time;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ExecuteComboInput(input);
|
||||
}
|
||||
|
||||
private void ExecuteComboInput(ComboInputType input)
|
||||
{
|
||||
if (_comboWindowTimer > 0f && _currentNode != null)
|
||||
{
|
||||
foreach (var transition in _currentNode.Transitions)
|
||||
{
|
||||
if (transition.Trigger != input) continue;
|
||||
if (transition.Next == null || transition.Next.Attack == null) continue;
|
||||
|
||||
ApplyForwardStep(transition.ForwardStep, transition.ForwardStepDuration);
|
||||
PerformAttack(transition.Next.Attack);
|
||||
_currentNode = transition.Next;
|
||||
_comboWindowTimer = transition.Next.ComboWindow;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ComboNode root = input switch
|
||||
{
|
||||
ComboInputType.Punch => _punchRootNode,
|
||||
ComboInputType.Kick => _kickRootNode,
|
||||
_ => null
|
||||
};
|
||||
if (root == null || root.Attack == null) return;
|
||||
|
||||
PerformAttack(root.Attack);
|
||||
_currentNode = root;
|
||||
_comboWindowTimer = root.ComboWindow;
|
||||
}
|
||||
|
||||
private async void PerformAttack(AttackData data)
|
||||
{
|
||||
_attackCts?.Cancel();
|
||||
_attackCts?.Dispose();
|
||||
_attackCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
||||
CancellationToken token = _attackCts.Token;
|
||||
|
||||
_attackCooldownTimer = data.Cooldown;
|
||||
_lastAttackGizmoData = data;
|
||||
_attackStartTime = Time.time;
|
||||
_hitFired = false;
|
||||
|
||||
if (_anim != null && !string.IsNullOrEmpty(data.AnimationState))
|
||||
_anim.Play(data.AnimationState);
|
||||
|
||||
try
|
||||
{
|
||||
await HitRoutine(data, token);
|
||||
}
|
||||
catch (System.OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private async Awaitable HitRoutine(AttackData data, CancellationToken token)
|
||||
{
|
||||
float attackStartTime = Time.time;
|
||||
|
||||
if (data.HitTiming > 0f)
|
||||
await Awaitable.WaitForSecondsAsync(data.HitTiming, token);
|
||||
|
||||
_lastAttackGizmoTime = Time.time;
|
||||
_lastHitData = data;
|
||||
_hitFired = true;
|
||||
|
||||
if (data.HitDuration <= 0f)
|
||||
{
|
||||
ApplyDamageInArea(data, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
var alreadyHit = new HashSet<IDamageable>();
|
||||
float elapsed = 0f;
|
||||
while (elapsed < data.HitDuration)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
ApplyDamageInArea(data, alreadyHit);
|
||||
await Awaitable.NextFrameAsync(token);
|
||||
elapsed += Time.deltaTime;
|
||||
}
|
||||
}
|
||||
|
||||
float remaining = data.MotionDuration - (Time.time - attackStartTime);
|
||||
if (remaining > 0f)
|
||||
await Awaitable.WaitForSecondsAsync(remaining, token);
|
||||
|
||||
if (_anim != null && !string.IsNullOrEmpty(_idleAnimationState))
|
||||
_anim.Play(_idleAnimationState);
|
||||
}
|
||||
|
||||
private void ApplyDamageInArea(AttackData data, HashSet<IDamageable> alreadyHit)
|
||||
{
|
||||
Vector2 center = GetAttackCenter(data.Offset);
|
||||
|
||||
_lastHitData = data;
|
||||
_lastHitCenter = center;
|
||||
_lastHitTime = Time.time;
|
||||
|
||||
Collider2D[] hits = Physics2D.OverlapCircleAll(center, data.Radius, _enemyLayer);
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
if (!hit.TryGetComponent<IDamageable>(out var target)) continue;
|
||||
|
||||
if (alreadyHit != null)
|
||||
{
|
||||
if (alreadyHit.Contains(target)) continue;
|
||||
alreadyHit.Add(target);
|
||||
}
|
||||
|
||||
target.TakeDamage(data.Damage);
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 GetAttackCenter(Vector2 offset)
|
||||
{
|
||||
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
||||
offset.x *= facing;
|
||||
return (Vector2)transform.position + offset;
|
||||
}
|
||||
|
||||
private void ApplyForwardStep(float distance, float duration)
|
||||
{
|
||||
if (distance <= 0f) return;
|
||||
|
||||
float safeDuration = Mathf.Max(duration, 0.02f);
|
||||
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
||||
float vx = facing * (distance / safeDuration);
|
||||
|
||||
_rb.linearVelocity = new Vector2(vx, _rb.linearVelocity.y);
|
||||
_inputLockTimer = safeDuration;
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (_lastHitData == null || _lastHitTime < 0f) return;
|
||||
|
||||
float since = Time.time - _lastHitTime;
|
||||
if (since < 0f || since > _hitGizmoFadeDuration) return;
|
||||
|
||||
float t = Mathf.Clamp01(since / _hitGizmoFadeDuration);
|
||||
float alpha = Mathf.Lerp(0.85f, 0f, t);
|
||||
float radius = _lastHitData.Radius;
|
||||
|
||||
Gizmos.color = new Color(1f, 0.2f, 0.2f, alpha * 0.35f);
|
||||
Gizmos.DrawSphere(_lastHitCenter, radius);
|
||||
|
||||
Gizmos.color = new Color(1f, 0f, 0f, alpha);
|
||||
Gizmos.DrawWireSphere(_lastHitCenter, radius);
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (groundCheck == null) return;
|
||||
Gizmos.color = isGrounded ? Color.green : Color.red;
|
||||
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
|
||||
if (_groundCheck != null)
|
||||
{
|
||||
Gizmos.color = _isGrounded ? Color.green : Color.red;
|
||||
Gizmos.DrawWireSphere(_groundCheck.position, _groundCheckRadius);
|
||||
}
|
||||
if (_wallCheckLeft != null)
|
||||
{
|
||||
Gizmos.color = _isTouchingLeftWall ? Color.green : Color.red;
|
||||
Gizmos.DrawWireSphere(_wallCheckLeft.position, _wallCheckRadius);
|
||||
}
|
||||
if (_wallCheckRight != null)
|
||||
{
|
||||
Gizmos.color = _isTouchingRightWall ? Color.green : Color.red;
|
||||
Gizmos.DrawWireSphere(_wallCheckRight.position, _wallCheckRadius);
|
||||
}
|
||||
|
||||
DrawLastAttackGizmo();
|
||||
}
|
||||
|
||||
private void DrawLastAttackGizmo()
|
||||
{
|
||||
if (_lastAttackGizmoData == null) return;
|
||||
if (!Application.isPlaying) return;
|
||||
|
||||
float elapsed = Time.time - _lastAttackGizmoTime;
|
||||
if (elapsed < 0f) return;
|
||||
|
||||
AttackData data = _lastAttackGizmoData;
|
||||
float activeDuration = Mathf.Max(data.HitDuration, 0.05f);
|
||||
float fadeDuration = 0.4f;
|
||||
float total = activeDuration + fadeDuration;
|
||||
if (elapsed > total) return;
|
||||
|
||||
float alpha = elapsed < activeDuration
|
||||
? 1f
|
||||
: 1f - (elapsed - activeDuration) / fadeDuration;
|
||||
alpha = Mathf.Clamp01(alpha);
|
||||
|
||||
Vector2 center = GetAttackCenter(data.Offset);
|
||||
Gizmos.color = new Color(1f, 0.3f, 0.3f, alpha * 0.35f);
|
||||
Gizmos.DrawSphere(center, data.Radius);
|
||||
Gizmos.color = new Color(1f, 0f, 0f, alpha);
|
||||
Gizmos.DrawWireSphere(center, data.Radius);
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (!_showAttackDebug || _lastAttackGizmoData == null) return;
|
||||
|
||||
AttackData data = _lastAttackGizmoData;
|
||||
float elapsed = Time.time - _attackStartTime;
|
||||
|
||||
string status = _hitFired
|
||||
? (elapsed < data.HitTiming + Mathf.Max(data.HitDuration, 0.01f) ? "HIT" : "DONE")
|
||||
: "WINDUP";
|
||||
string cooldownText = _attackCooldownTimer > 0f
|
||||
? $"{_attackCooldownTimer:F3}s left"
|
||||
: "READY";
|
||||
|
||||
string bufferText = _pendingInput.HasValue
|
||||
? $"<color=#ffcc00>BUFFERED: {_pendingInput.Value}</color>"
|
||||
: (elapsed >= _bufferOpenTime && _attackCooldownTimer > 0f ? "ready to buffer" : "-");
|
||||
|
||||
string info =
|
||||
$"<b>{(string.IsNullOrEmpty(data.AttackName) ? data.name : data.AttackName)}</b> [{status}]\n" +
|
||||
$"Elapsed : {elapsed:F3} s\n" +
|
||||
$"HitTiming : {data.HitTiming:F3} s\n" +
|
||||
$"HitDuration : {data.HitDuration:F3} s\n" +
|
||||
$"Cooldown : {data.Cooldown:F3} s ({cooldownText})\n" +
|
||||
$"ComboWindow : {_comboWindowTimer:F3} s\n" +
|
||||
$"Buffer : {bufferText}";
|
||||
|
||||
GUIStyle style = new GUIStyle(GUI.skin.box)
|
||||
{
|
||||
fontSize = 26,
|
||||
alignment = TextAnchor.UpperLeft,
|
||||
richText = true,
|
||||
padding = new RectOffset(16, 16, 12, 12),
|
||||
normal = { textColor = Color.white }
|
||||
};
|
||||
|
||||
GUI.Box(new Rect(10, 10, 520, 282), info, style);
|
||||
|
||||
DrawTimelineBar(data, elapsed);
|
||||
}
|
||||
|
||||
private void DrawTimelineBar(AttackData data, float elapsed)
|
||||
{
|
||||
float barX = 10f;
|
||||
float barY = 302f;
|
||||
float barW = 520f;
|
||||
float barH = 36f;
|
||||
float totalTime = Mathf.Max(
|
||||
data.HitTiming + Mathf.Max(data.HitDuration, 0.05f),
|
||||
data.Cooldown,
|
||||
data.MotionDuration,
|
||||
0.3f);
|
||||
|
||||
GUI.color = new Color(0f, 0f, 0f, 0.6f);
|
||||
GUI.DrawTexture(new Rect(barX, barY, barW, barH), Texture2D.whiteTexture);
|
||||
|
||||
float hitX = barX + (data.HitTiming / totalTime) * barW;
|
||||
float hitEndX = barX + ((data.HitTiming + data.HitDuration) / totalTime) * barW;
|
||||
GUI.color = new Color(1f, 0.3f, 0.3f, 0.5f);
|
||||
GUI.DrawTexture(new Rect(hitX, barY, Mathf.Max(hitEndX - hitX, 4f), barH), Texture2D.whiteTexture);
|
||||
|
||||
float bufferX = barX + Mathf.Clamp01(_bufferOpenTime / totalTime) * barW;
|
||||
GUI.color = new Color(0.2f, 1f, 0.4f, 0.9f);
|
||||
GUI.DrawTexture(new Rect(bufferX - 2f, barY, 4f, barH), Texture2D.whiteTexture);
|
||||
|
||||
float cdEndX = barX + Mathf.Clamp01(data.Cooldown / totalTime) * barW;
|
||||
GUI.color = new Color(0.3f, 0.7f, 1f, 0.9f);
|
||||
GUI.DrawTexture(new Rect(cdEndX - 2f, barY, 4f, barH), Texture2D.whiteTexture);
|
||||
|
||||
float cursorX = barX + Mathf.Clamp01(elapsed / totalTime) * barW;
|
||||
GUI.color = Color.yellow;
|
||||
GUI.DrawTexture(new Rect(cursorX - 2f, barY - 4f, 4f, barH + 8f), Texture2D.whiteTexture);
|
||||
|
||||
GUI.color = Color.white;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user