diff --git a/Assets/01_Scenes/GameScene.unity b/Assets/01_Scenes/GameScene.unity index 648d10e..a28ebee 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:6d35159662b78e48b0c9a66e21d86799b03a3b5c8f457ae08337e8c2ffd8b841 -size 54900 +oid sha256:c50f5ed0cb3d1c065c2ad494e297c2bf513e712a370c1adeeb7e7c6c94afd3b1 +size 55355 diff --git a/Assets/02_Scripts/Combat/Health.cs b/Assets/02_Scripts/Combat/Health.cs new file mode 100644 index 0000000..eca86ee --- /dev/null +++ b/Assets/02_Scripts/Combat/Health.cs @@ -0,0 +1,55 @@ +using System; +using UnityEngine; + +public class Health : MonoBehaviour +{ + [SerializeField] private int _maxHealth = 30; + private int _currentHealth; + + public int MaxHealth => _maxHealth; + public int CurrentHealth => _currentHealth; + public float Ratio => _maxHealth > 0 ? (float)_currentHealth / _maxHealth : 0f; + public bool IsDead => _currentHealth <= 0; + + public event Action OnHealthChanged; + public event Action OnDied; + + private void Awake() + { + _currentHealth = _maxHealth; + } + + public void TakeDamage(int amount) + { + if (amount <= 0 || IsDead) return; + + int previous = _currentHealth; + _currentHealth = Mathf.Max(_currentHealth - amount, 0); + OnHealthChanged?.Invoke(_currentHealth, _maxHealth); + + if (_currentHealth <= 0 && previous > 0) + OnDied?.Invoke(); + } + + public void Heal(int amount) + { + if (amount <= 0 || IsDead) return; + + _currentHealth = Mathf.Min(_currentHealth + amount, _maxHealth); + OnHealthChanged?.Invoke(_currentHealth, _maxHealth); + } + + public void ResetHealth() + { + _currentHealth = _maxHealth; + OnHealthChanged?.Invoke(_currentHealth, _maxHealth); + } + + public void SetMaxHealth(int newMax, bool fill = true) + { + _maxHealth = Mathf.Max(newMax, 1); + if (fill) _currentHealth = _maxHealth; + else _currentHealth = Mathf.Min(_currentHealth, _maxHealth); + OnHealthChanged?.Invoke(_currentHealth, _maxHealth); + } +} diff --git a/Assets/02_Scripts/Combat/Health.cs.meta b/Assets/02_Scripts/Combat/Health.cs.meta new file mode 100644 index 0000000..d505f62 --- /dev/null +++ b/Assets/02_Scripts/Combat/Health.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bcfe85aa11323ce47923dabc579cab00 \ No newline at end of file diff --git a/Assets/02_Scripts/Enemy/Enemy.cs b/Assets/02_Scripts/Enemy/Enemy.cs index 56ae538..4104570 100644 --- a/Assets/02_Scripts/Enemy/Enemy.cs +++ b/Assets/02_Scripts/Enemy/Enemy.cs @@ -3,11 +3,9 @@ [RequireComponent(typeof(Collider2D))] [RequireComponent(typeof(Rigidbody2D))] +[RequireComponent(typeof(Health))] public class Enemy : MonoBehaviour, IDamageable { - [Header("Stats")] - [SerializeField] private int _maxHealth = 30; - [Header("Hit Feedback")] [SerializeField] private float _hitFlashDuration = 0.1f; [SerializeField] private Color _hitFlashColor = Color.red; @@ -25,7 +23,7 @@ public class Enemy : MonoBehaviour, IDamageable [SerializeField] private LayerMask _separationLayer; private static readonly Collider2D[] _separationBuffer = new Collider2D[16]; - private int _currentHealth; + private Health _health; private Rigidbody2D _rb; private Animator _anim; private SpriteRenderer _spriteRenderer; @@ -49,7 +47,8 @@ public class Enemy : MonoBehaviour, IDamageable private void Awake() { - _currentHealth = _maxHealth; + _health = GetComponent(); + _health.OnDied += HandleDeath; _rb = GetComponent(); _anim = GetComponentInChildren(); _spriteRenderer = GetComponentInChildren(); @@ -58,6 +57,12 @@ private void Awake() _originalColor = _spriteRenderer.color; } + private void OnDestroy() + { + if (_health != null) + _health.OnDied -= HandleDeath; + } + private void Update() { if (_flashTimer > 0f) @@ -143,13 +148,10 @@ private void ApplySeparation() 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 (_health == null || _health.IsDead) return; _isGrabbed = false; - _currentHealth -= amount; - Debug.Log($"{name} 피격: -{amount} (HP: {_currentHealth}/{_maxHealth})"); - if (_spriteRenderer != null) { Debug.Log($"[Flash START] t={Time.time:F3} duration={_hitFlashDuration:F3} (current color was {_spriteRenderer.color})"); @@ -178,13 +180,14 @@ public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReac } } - if (_currentHealth <= 0) - Die(); + // 시각/반응 처리가 끝난 뒤 HP를 감산해서 OnDied 이벤트가 마지막에 발화되게 한다. + _health.TakeDamage(amount); + Debug.Log($"{name} 피격: -{amount} (HP: {_health.CurrentHealth}/{_health.MaxHealth})"); } public void BeginGrab(string grabbedAnimationState, int solidMask) { - if (_currentHealth <= 0) return; + if (_health == null || _health.IsDead) return; _isGrabbed = true; _grabSolidMask = solidMask; @@ -373,7 +376,7 @@ private void BounceOffWall(Vector2 wallNormal) _hitReactionTimer = _hitReactionDuration; } - private void Die() + private void HandleDeath() { Debug.Log($"{name} 사망"); Destroy(gameObject); diff --git a/Assets/02_Scripts/Player/PlayerController.cs b/Assets/02_Scripts/Player/PlayerController.cs index 73e619f..b0ac302 100644 --- a/Assets/02_Scripts/Player/PlayerController.cs +++ b/Assets/02_Scripts/Player/PlayerController.cs @@ -2,7 +2,7 @@ using System.Threading; using UnityEngine; -public class PlayerController : MonoBehaviour +public class PlayerController : MonoBehaviour,IDamageable { [Header("Movement")] [SerializeField] private float _moveSpeed = 5f; @@ -23,10 +23,12 @@ public class PlayerController : MonoBehaviour [Header("Jump")] [SerializeField] private float _jumpForce = 8f; + [SerializeField] private int _maxJumpCount = 2; [SerializeField] private Transform _groundCheck; [SerializeField] private float _groundCheckRadius = 0.1f; [SerializeField] private LayerMask _groundLayer; private bool _isGrounded; + private int _jumpsUsed; [Header("WallSlide")] [SerializeField] private Transform _wallCheckLeft; @@ -156,6 +158,10 @@ private void FixedUpdate() _isTouchingRightWall = Physics2D.OverlapCircle(_wallCheckRight.position, _wallCheckRadius, _groundLayer); _wallDirection = _isTouchingRightWall ? 1 : (_isTouchingLeftWall ? -1 : 0); + // 지면에 닿고 점프 중이 아니면(상승 중 아님) 점프 카운트 리셋. + if (_isGrounded && _rb.linearVelocity.y <= 0f) + _jumpsUsed = 0; + if (_attackCooldownTimer > 0f) _attackCooldownTimer -= Time.fixedDeltaTime; @@ -217,13 +223,23 @@ private void OnJumpInput() { if (_isGrounded) { - _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, _jumpForce); + PerformJump(); } else if (IsTouchingWall) { _rb.linearVelocity = new Vector2(-_wallDirection * _wallJumpForce.x, _wallJumpForce.y); _inputLockTimer = _wallJumpInputLockDuration; } + else if (_jumpsUsed < _maxJumpCount) + { + PerformJump(); + } + } + + private void PerformJump() + { + _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, _jumpForce); + _jumpsUsed++; } private void OnPunchInput() => HandleComboInput(ComboInputType.Punch); @@ -1343,4 +1359,9 @@ private void DrawTimelineBar(ActionData data, float elapsed) GUI.color = Color.white; } + + public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0) + { + Debug.Log("플레이어 타격받음"); + } } diff --git a/Assets/02_Scripts/UI.meta b/Assets/02_Scripts/UI.meta new file mode 100644 index 0000000..2b5e761 --- /dev/null +++ b/Assets/02_Scripts/UI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4b4a28598a934ef439e38f81568625cb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/02_Scripts/UI/HpBar.cs b/Assets/02_Scripts/UI/HpBar.cs new file mode 100644 index 0000000..e74aeec --- /dev/null +++ b/Assets/02_Scripts/UI/HpBar.cs @@ -0,0 +1,101 @@ +using UnityEngine; + +public class HpBar : MonoBehaviour +{ + [SerializeField] private Health _health; + [SerializeField] private Transform _fill; + [SerializeField] private bool _autoFindHealthInParent = true; + [SerializeField] private bool _hideWhenFull = true; + [SerializeField] private float _smoothSpeed = 0f; + + [Header("Color Thresholds")] + [SerializeField] private Color _highColor = new Color(0.2f, 0.9f, 0.3f, 1f); + [SerializeField] private Color _midColor = new Color(1f, 0.85f, 0.2f, 1f); + [SerializeField] private Color _lowColor = new Color(0.95f, 0.25f, 0.25f, 1f); + [SerializeField, Range(0f, 1f)] private float _midThreshold = 0.5f; + [SerializeField, Range(0f, 1f)] private float _lowThreshold = 0.2f; + + private Vector3 _baseFillScale; + private SpriteRenderer _fillRenderer; + private float _currentRatio = 1f; + private float _targetRatio = 1f; + + private void Awake() + { + if (_health == null && _autoFindHealthInParent) + _health = GetComponentInParent(); + + if (_fill != null) + { + _baseFillScale = _fill.localScale; + _fillRenderer = _fill.GetComponent(); + } + } + + private void OnEnable() + { + if (_health == null) return; + + _health.OnHealthChanged += HandleHealthChanged; + HandleHealthChanged(_health.CurrentHealth, _health.MaxHealth); + } + + private void OnDisable() + { + if (_health != null) + _health.OnHealthChanged -= HandleHealthChanged; + } + + private void Update() + { + if (_smoothSpeed <= 0f) return; + if (Mathf.Approximately(_currentRatio, _targetRatio)) return; + + _currentRatio = Mathf.MoveTowards(_currentRatio, _targetRatio, _smoothSpeed * Time.deltaTime); + ApplyScale(); + } + + private void HandleHealthChanged(int current, int max) + { + _targetRatio = max > 0 ? (float)current / max : 0f; + + if (_smoothSpeed <= 0f) + { + _currentRatio = _targetRatio; + ApplyScale(); + } + + ApplyColor(_targetRatio); + + if (_hideWhenFull && _fill != null) + { + bool shouldShow = _targetRatio < 1f && _targetRatio > 0f; + if (gameObject.activeSelf != shouldShow) + gameObject.SetActive(shouldShow); + } + } + + private void ApplyScale() + { + if (_fill == null) return; + + Vector3 scale = _baseFillScale; + scale.x = _baseFillScale.x * Mathf.Clamp01(_currentRatio); + _fill.localScale = scale; + } + + private void ApplyColor(float ratio) + { + if (_fillRenderer == null) return; + + Color color; + if (ratio <= _lowThreshold) + color = _lowColor; + else if (ratio <= _midThreshold) + color = _midColor; + else + color = _highColor; + + _fillRenderer.color = color; + } +} diff --git a/Assets/02_Scripts/UI/HpBar.cs.meta b/Assets/02_Scripts/UI/HpBar.cs.meta new file mode 100644 index 0000000..22d0102 --- /dev/null +++ b/Assets/02_Scripts/UI/HpBar.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b4c4406a1fa1084dbce1fadbeb93104 \ No newline at end of file diff --git a/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab index 3592842..b0e01a1 100644 --- a/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab +++ b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62a69515a450783082d67bd6b7e3e1d28d28d2e0d73e18476eab71713904663a -size 4973 +oid sha256:be0a74aae046fa8561a2265073bde82fc2713673fde7bc11a3237d96e834000a +size 12263 diff --git a/Assets/06_Textures.meta b/Assets/06_Textures.meta new file mode 100644 index 0000000..ab7efea --- /dev/null +++ b/Assets/06_Textures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4e93b87e907f0ac43ab0af53acaf0091 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/06_Textures/Base.meta b/Assets/06_Textures/Base.meta new file mode 100644 index 0000000..6020bd8 --- /dev/null +++ b/Assets/06_Textures/Base.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f3e9790e7db6ae043acd3184ec10eace +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/06_Textures/Base/LongSquare.png b/Assets/06_Textures/Base/LongSquare.png new file mode 100644 index 0000000..2ce28cd --- /dev/null +++ b/Assets/06_Textures/Base/LongSquare.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c655f7924d4be363962613b88c50facd02647c0549fcedc272b27ba31587f5e +size 1136 diff --git a/Assets/06_Textures/Base/LongSquare.png.meta b/Assets/06_Textures/Base/LongSquare.png.meta new file mode 100644 index 0000000..bc1ecfb --- /dev/null +++ b/Assets/06_Textures/Base/LongSquare.png.meta @@ -0,0 +1,169 @@ +fileFormatVersion: 2 +guid: cf4b69be35d34f342b1c6fbdb443167e +TextureImporter: + internalIDToNameTable: + - first: + 213: 2581929779970265829 + second: LongSqure_0 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 0 + alignment: 9 + spritePivot: {x: 0, y: 0.5} + spritePixelsToUnits: 2500 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: iOS + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: LongSqure_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 2225 + height: 139 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: 5e2512a847bd4d320800000000000000 + internalID: 2581929779970265829 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + LongSqure_0: 2581929779970265829 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: