https://youtu.be/I1dAZuWurw4?list=PL2p3TYDZ7RP9XPqs-9Jf4gahwJ0sk9uom

https://github.com/mixandjam/balatro-feel

 

GitHub - mixandjam/Balatro-Feel: Recreating the basic Game Feel from Balatro

Recreating the basic Game Feel from Balatro. Contribute to mixandjam/Balatro-Feel development by creating an account on GitHub.

github.com


영상과 해당 GitHub를참고하여 공부하였습니다. 
카드게임 연출에 관심 있으신분들은 한번 살펴봐도 좋을것 같습니다.

코드의 구조보다 [발라트로]의 게임 연출을 하기위해서는 어떤식으로 코드를 작성했는지에 중점을 두었습니다. 


UML 다이어그램


구조는 이런식으로 되어있습니다.

 


밑으로는 코드리뷰를 하며 주석으로 어떤 기능들이 구현 했는지 정리하였습니다.

 

Card.cs

using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using System.Collections.Generic;
using System.Collections;
using UnityEngine.UI;

/*
 조작하는 card의 영역 및 스크립트가 따로있고
cardVisual로 눈에 직접보이는 부분을 영역 및 스크립트로 따로 제어

즉 카드의 이미지,쉐이더 등등 연출은 전부 cardVisual로 처리 
card 오브젝트는 마우스 조작시 기능적으로 구현되는것들(위치 보정, 이벤트를 활용하여 해당 동작 활동)
cardVisual은 카드의 시각적 관련하여 모든것을 관리(Dotween애니메이션 및 애니메이션에서 사용되는 수치들)

card오브젝트는 레퍼런스로 cardVisual 프리팹을 참조해서 가지고있음
 */

public class Card : MonoBehaviour, IDragHandler, IBeginDragHandler, IEndDragHandler, IPointerEnterHandler, IPointerExitHandler, IPointerUpHandler, IPointerDownHandler
{
    /// <summary>
    /// 카드 오브젝트의 부모 캔버스
    /// </summary>
    private Canvas canvas;
    /// <summary>
    /// 카드의 이미지 컴포넌트 -> RaycastTarget 을 사용하기 위한 참조
    /// 시각적 연출인 카드의 이미지,쉐이더는 cardVisual이 담당
    /// </summary>
    private Image imageComponent;
    /// <summary>
    /// cardVisual을 이미 생성 했는지 체크하는 변수 
    /// </summary>
    [SerializeField] private bool instantiateVisual = true;
    /// <summary>
    /// 카드의 시각적 연출을 담당하는 영역 
    /// </summary>
    private VisualCardsHandler visualHandler;
    /// <summary>
    /// 카드를 드래그시 사용되는 좌표 보정값
    /// </summary>
    private Vector3 offset;

    /// <summary>
    /// 카드를 드래그시 사용되는 속도 리미트 값
    /// </summary>
    [Header("Movement")]
    [SerializeField] private float moveSpeedLimit = 50;

    /// <summary>
    /// 해당 카드가 선택 여부를 체크하는 변수
    /// </summary>
    [Header("Selection")]
    public bool selected;
    /// <summary>
    /// 카드 선택시, 해당 카드의 지역(Local)좌표 Offset
    /// </summary>
    public float selectionOffset = 50;
    /// <summary>
    /// 카드를 클릭을 했을때의 시간대 -> 카드 선택 및 카드 드래그 선택 여부 파악
    /// </summary>
    private float pointerDownTime;
    /// <summary>
    /// 카드에서 클릭후 놓는 시간대 -> 카드 선택 및 카드 드래그 선택 여부 파악
    /// </summary>
    private float pointerUpTime;

    /// <summary>
    /// 시각적 연출을 담당하는 프리팹
    /// </summary>
    [Header("Visual")]
    [SerializeField] private GameObject cardVisualPrefab;
    /// <summary>
    /// 시각적 연출을 하기위해 생성된 cardVisual오브젝트
    /// </summary>
    [HideInInspector] public CardVisual cardVisual;

    /// <summary>
    /// 카드가 현재 공중에 있다는것을 체크하는 변수
    /// </summary>
    [Header("States")]
    public bool isHovering;
    /// <summary>
    /// 카드가 현재 드래그 상태라는것을 체크하는 변수
    /// </summary>
    public bool isDragging;
    /// <summary>
    /// 아직 드래그 상태임을 체크하는 변수 
    /// </summary>
    [HideInInspector] public bool wasDragged;

    /// <summary>
    /// 마우스 조작시 각 기능들을 추가하기위한 이벤트 리스트 
    /// </summary>
    [Header("Events")]
    [HideInInspector] public UnityEvent<Card> PointerEnterEvent;
    [HideInInspector] public UnityEvent<Card> PointerExitEvent;
    [HideInInspector] public UnityEvent<Card, bool> PointerUpEvent;
    [HideInInspector] public UnityEvent<Card> PointerDownEvent;
    [HideInInspector] public UnityEvent<Card> BeginDragEvent;
    [HideInInspector] public UnityEvent<Card> EndDragEvent;
    [HideInInspector] public UnityEvent<Card, bool> SelectEvent;

    void Start()
    {
        canvas = GetComponentInParent<Canvas>();
        imageComponent = GetComponent<Image>();

        if (!instantiateVisual)
            return;

        // Find함수 지양할것 , 매니저에서 전달 할수 있게 변경
        visualHandler = FindObjectOfType<VisualCardsHandler>();

        //visualHandler ? visualHandler.transform : canvas.transform => visualHandler의 예외처리
        cardVisual = Instantiate(cardVisualPrefab, visualHandler ? visualHandler.transform : canvas.transform).GetComponent<CardVisual>();
        cardVisual.Initialize(this);
    }

    void Update()
    {
        ClampPosition();

        ///드래그 상태일때 위치 좌표 보정
        if (isDragging)
        {
            //현재 마우스 위치 에서 offset 만큼 뺴주면 카드가 위치해야되는 좌표가 계산됨
            Vector2 targetPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition) - offset;
            
            //벡터의 뺼셈 : 방향벡터 계산하기 위함 , 프레임마다 계산하기에 방향벡터를 구할수 있음 
            Vector2 direction = (targetPosition - (Vector2)transform.position).normalized;
            Vector2 velocity = direction * Mathf.Min(moveSpeedLimit, Vector2.Distance(transform.position, targetPosition) / Time.deltaTime);

            //transform.Translate()는 오브젝트의 로컬 좌표계(local space) 기준 벡터 방향으로 이동시키는 함수
            transform.Translate(velocity * Time.deltaTime);
        }
    }

    /// <summary>
    /// Scene 화면 밖으로 나가지 않게 하는 코드
    /// </summary>
    void ClampPosition()
    {
        
        Vector2 screenBounds = Camera.main.ScreenToWorldPoint(new Vector3(Screen.width, Screen.height, Camera.main.transform.position.z));
        Vector3 clampedPosition = transform.position;

        ///Mathf.Clamp (현재 카드의 좌표(x or y) , 경계의 음수 좌표 , 경계의 양수 좌표))
        ///-screenBounds.x (-) 를 쓴 이유 , viewPort -> World 좌표로 바뀌었기에 0,0은 왼쪽 구석이 아닌 중심임
        clampedPosition.x = Mathf.Clamp(clampedPosition.x, -screenBounds.x, screenBounds.x);
        clampedPosition.y = Mathf.Clamp(clampedPosition.y, -screenBounds.y, screenBounds.y);
        transform.position = new Vector3(clampedPosition.x, clampedPosition.y, 0);
    }

    /// <summary>
    /// 카드가 드래그 시작시 동작
    /// </summary>
    /// <param name="eventData"></param>
    public void OnBeginDrag(PointerEventData eventData)
    {
        BeginDragEvent.Invoke(this);
        Vector2 mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
        
        //드래그 시작시 offset 보정값 계산
        //카드의 모서리를 잡았을때 카드가 제대로 따라올 수 있게 offset 보정값 게산
        offset = mousePosition - (Vector2)transform.position;
        isDragging = true;
        
        //드래그 중에 레이캐스트를 비활성화 시켜 충돌문제 방지
        canvas.GetComponent<GraphicRaycaster>().enabled = false;
        imageComponent.raycastTarget = false;

        wasDragged = true;
    }

    public void OnDrag(PointerEventData eventData)
    {
    }

    /// <summary>
    /// 카드의 드래그가 종료될때
    /// </summary>
    /// <param name="eventData"></param>
    public void OnEndDrag(PointerEventData eventData)
    {
        EndDragEvent.Invoke(this);
        isDragging = false;
        canvas.GetComponent<GraphicRaycaster>().enabled = true;
        imageComponent.raycastTarget = true;

        //함수안에 로컬(지역)으로 코루틴을 사용하는 경우
        //1.짧은 “1프레임 대기 후 처리”만 필요
        //2.해당 코루틴이 “이 함수에서만” 사용
        //3.Unity의 이벤트 콜백 안에서, 직관적으로 흐름 확인

        StartCoroutine(FrameWait());

        IEnumerator FrameWait()
        {
            /*
            짧은 “1프레임 대기 후 처리
            짧고 지역적인 코루틴을 깔끔하게 한 곳에서 관리
            wasDragged를 즉시 false로 하면 UI 이벤트 충돌이 날 수 있음
            한 프레임 뒤에 false로 만들어 안정적으로 이벤트 처리를 끝내는 용도
            */
            yield return new WaitForEndOfFrame();
            wasDragged = false;
        }
    }

    /// <summary>
    /// 카드의 영역에 마우스가 올라갔을때 (raycastTarget이 활성화일때 사용 가능)
    /// </summary>
    /// <param name="eventData"></param>
    public void OnPointerEnter(PointerEventData eventData)
    {
        PointerEnterEvent.Invoke(this);
        isHovering = true;
    }
    /// <summary>
    /// 카드의 영역에 마우스가 나갈때(raycastTarget이 활성화일때 사용 가능)
    /// </summary>
    /// <param name="eventData"></param>
    public void OnPointerExit(PointerEventData eventData)
    {
        PointerExitEvent.Invoke(this);
        isHovering = false;
    }

    /// <summary>
    /// 카드를 클릭 했을때(누르는 첫 시점)
    /// </summary>
    /// <param name="eventData"></param>
    public void OnPointerDown(PointerEventData eventData)
    {
        //마우스 왼쪽 클릭 맞는지 체크 
        if (eventData.button != PointerEventData.InputButton.Left)
            return;

        PointerDownEvent.Invoke(this);

        //마우스가 눌린 시간대 저장
        pointerDownTime = Time.time;
    }
    /// <summary>
    /// 카드를 클릭 했을때(때는 첫 시점)
    /// </summary>
    /// <param name="eventData"></param>
    public void OnPointerUp(PointerEventData eventData)
    {
        //마우스 왼쪽 클릭 맞는지 체크 
        if (eventData.button != PointerEventData.InputButton.Left)
            return;

        //마우스가 땔때 시간대 저장
        pointerUpTime = Time.time;

        //마우스 땔때 시간 - 놓는 시간 = > 0.2초보다 큰 경우 true 아니면 false 반환
        //bool 변수 보내는 이유 : 놓는 시점에 따라 드래그 or 선택 동작을 다르게 하기 위함
        PointerUpEvent.Invoke(this, pointerUpTime - pointerDownTime > .2f);

        //0.2초 지남 => 드래그 상태(OnBeginDrag) 변경해야하기에 해당 함수 탈출
        if (pointerUpTime - pointerDownTime > .2f)
            return;

        //드래그 중일 경우 탈출 
        if (wasDragged)
            return;

        selected = !selected;
        SelectEvent.Invoke(this, selected);

        //카드 선택 여부에 의해 다르게 동작
        if (selected)
            transform.localPosition += (cardVisual.transform.up * selectionOffset);
        else
            transform.localPosition = Vector3.zero;
    }

    /// <summary>
    /// 카드의 선택여부를 확인하여 모든 selected을  false로 전환하는 함수
    /// </summary>
    public void Deselect()
    {
        if (selected)
        {
            selected = false;
            if (selected)
                transform.localPosition += (cardVisual.transform.up * selectionOffset);
            else
                transform.localPosition = Vector3.zero;
        }
    }

    /// <summary>
    /// 해당 카드가 소지된 카드홀더의 Slot(자식객체)의 갯수를 반환하는 함수
    /// </summary>
    /// <returns> 카드 홀더의 자식(Slot) 갯수 </returns>
    public int SiblingAmount()
    {
        //카드의 부모객체의 태그가 Slot인지 체크, 카드의 부모 = Slot , Slot의 부모 = 카드 홀더
        //카드홀더 자식갯수 = Slot 갯수 , -1 : 자기 자신을 제외한 갯수
        return transform.parent.CompareTag("Slot") ? transform.parent.parent.childCount - 1 : 0;
    }

    /// <summary>
    /// 카드의 부모객체(Slot)가 카드홀더(Slot의 부모객체)의 몇번 인덱스인지 반환하는 함수
    /// </summary>
    /// <returns> 카드홀더에서 Slot Index </returns>
    public int ParentIndex()
    {
        return transform.parent.CompareTag("Slot") ? transform.parent.GetSiblingIndex() : 0;
    }

    /// <summary>
    /// A범위 ( 0 ~ 카드홀더의 자식객체 갯수(Slot) - 1) => B범위(0 ,1) 비례 변환 하여 float 반환하는 함수
    /// </summary>
    /// <returns>비례 변환된 value값 </returns>
    public float NormalizedPosition()
    {
        return transform.parent.CompareTag("Slot") ? ExtensionMethods.Remap((float)ParentIndex(), 0, (float)(transform.parent.parent.childCount - 1), 0, 1) : 0;
    }

    private void OnDestroy()
    {
        if(cardVisual != null)
        Destroy(cardVisual.gameObject);
    }
}

 

CardVisual.cs

using System;
using UnityEngine;
using DG.Tweening;
using System.Collections;
using UnityEngine.EventSystems;
using Unity.Collections;
using UnityEngine.UI;
using Unity.VisualScripting;

public class CardVisual : MonoBehaviour
{
    /// <summary>
    /// 초기화 여부 변수
    /// </summary>
    private bool initalize = false;

    /// <summary>
    /// 부모객체
    /// </summary>
    [Header("Card")]
    public Card parentCard;
    /// <summary>
    /// 부모객체의 TR
    /// </summary>
    private Transform cardTransform;
    /// <summary>
    /// 회전 변화량 (보간에서 사용됨)
    /// </summary>
    private Vector3 rotationDelta;
    /// <summary>
    /// 카드홀더에 위치해있는 카드의 Index(순번)
    /// </summary>
    private int savedIndex;
    /// <summary>
    /// 이동 변화량 (보간에서 사용됨)
    /// </summary>
    Vector3 movementDelta;
    /// <summary>
    /// 자기 자신이 가지고 있는 canvas => 시각적 연출이기에 Casvas를 각자가 가지고 있음
    /// </summary>
    private Canvas canvas;

    /// <summary>
    /// 그림자 TR (Rotation Constraint 컴포넌트가 부착되어있음)
    /// </summary>
    [Header("References")]
    public Transform visualShadow;
    /// <summary>
    /// 그림자 보정값
    /// </summary>
    private float shadowOffset = 20;
    /// <summary>
    /// 그림자와 카드의 거리 
    /// </summary>
    private Vector2 shadowDistance;
    /// <summary>
    /// 그림자를 출력한 Canvas
    /// </summary>
    private Canvas shadowCanvas;
    /// <summary>
    /// 카드의 흔들림 연출을 위한 TR
    /// </summary>
    [SerializeField] private Transform shakeParent;
    /// <summary>
    /// 카드의 움직임 연출을 위한 TR
    /// tilt : 촬영할 때, 카메라의 위치를 고정시킨 상태에서 앞부분만 위 또는 아래로 움직이는 일
    /// </summary>
    [SerializeField] private Transform tiltParent;
    /// <summary>
    /// 카드에 출력되는 이미지
    /// </summary>
    [SerializeField] private Image cardImage;

    /// <summary>
    /// 카드의 이동속도 파라미터 (드래그 및 선택시 움직이는 애니메이션 속도)
    /// </summary>
    [Header("Follow Parameters")]
    [SerializeField] private float followSpeed = 30;

    /// <summary>
    /// 카드의 회전 관련 파라미터
    /// </summary>
    [Header("Rotation Parameters")]
    [SerializeField] private float rotationAmount = 20;
    [SerializeField] private float rotationSpeed = 20;
    [SerializeField] private float autoTiltAmount = 30;
    [SerializeField] private float manualTiltAmount = 20;
    [SerializeField] private float tiltSpeed = 20;

    /// <summary>
    /// 카드의 크기 관련 파라미터
    /// </summary>
    [Header("Scale Parameters")]
    [SerializeField] private bool scaleAnimations = true;
    [SerializeField] private float scaleOnHover = 1.15f;
    [SerializeField] private float scaleOnSelect = 1.25f;
    [SerializeField] private float scaleTransition = .15f;
    [SerializeField] private Ease scaleEase = Ease.OutBack;

    /// <summary>
    /// 카드 선택(isSelected) 관련 파라미터
    /// </summary>
    [Header("Select Parameters")]
    [SerializeField] private float selectPunchAmount = 20;

    /// <summary>
    /// 카드가 공중(isHovering) 관련 파라미터
    /// </summary>
    [Header("Hober Parameters")]
    [SerializeField] private float hoverPunchAngle = 5;
    [SerializeField] private float hoverTransition = .15f;

    /// <summary>
    /// 카드 스왑(교체) 관련 파라미터 
    /// </summary>
    [Header("Swap Parameters")]
    [SerializeField] private bool swapAnimations = true;
    [SerializeField] private float swapRotationAngle = 30;
    [SerializeField] private float swapTransition = .15f;
    [SerializeField] private int swapVibrato = 5;

    /// <summary>
    /// 회전 및 카드홀더의 카드 포물선 유지에 사용되는 커브 관련 SO
    /// </summary>
    [Header("Curve")]
    [SerializeField] private CurveParameters curve;

    /// <summary>
    /// 커브 관련 파라미터
    /// </summary>
    private float curveYOffset;
    private float curveRotationOffset;
    private Coroutine pressCoroutine;

    private void Start()
    {
        //그림자 위치 초기화 :
        shadowDistance = visualShadow.localPosition;
    }

    public void Initialize(Card target, int index = 0)
    {
        //Declarations : 초기화(선언)
        parentCard = target;
        cardTransform = target.transform;
        canvas = GetComponent<Canvas>();
        shadowCanvas = visualShadow.GetComponent<Canvas>();

        //Event Listening
        parentCard.PointerEnterEvent.AddListener(PointerEnter);
        parentCard.PointerExitEvent.AddListener(PointerExit);
        parentCard.BeginDragEvent.AddListener(BeginDrag);
        parentCard.EndDragEvent.AddListener(EndDrag);
        parentCard.PointerDownEvent.AddListener(PointerDown);
        parentCard.PointerUpEvent.AddListener(PointerUp);
        parentCard.SelectEvent.AddListener(Select);

        //Initialization
        initalize = true;
    }

    /// <summary>
    /// 현재 CardVisual의 Index 갱신
    /// </summary>
    /// <param name="length">카드 홀더의 카드 갯수</param>
    public void UpdateIndex(int length)
    {
        //cardVisual을 참조하고 있는 card의 부모 객체(Slot)의 index를 받아와 현재 UI 출력을 정렬한다.
        transform.SetSiblingIndex(parentCard.transform.parent.GetSiblingIndex());
    }

    void Update()
    {
        if (!initalize || parentCard == null) return;

        HandPositioning();
        SmoothFollow();
        FollowRotation();
        CardTilt();

    }

    /// <summary>
    /// 손패의 카드 위치(좌표)에 사용될 curveYOffset , curveRotationOffset 연산
    /// </summary>
    private void HandPositioning()
    {
        //parentCard.NormalizedPosition() : 카들 홀더에 있는 카드 갯수의 범위( 0~n개)의 범위를 0~1 범위로 변환후 card의 위치 비례값을 반환
        //* curve.positioningInfluence : 커브 계수 만큼 곱하여 위치 좌표 보정 
        //* parentCard.SiblingAmount() : Slot 개수에 비례해 Y 보정의 전체 스케일(화면 내 배치 범위)을 맞추기 위해서입니다.
        //예를 들어,Slot이 3개일 때와 10개일 때는 곡선의 “폭”이 달라야 합니다.
        curveYOffset = (curve.positioning.Evaluate(parentCard.NormalizedPosition()) * curve.positioningInfluence) * parentCard.SiblingAmount();

        //5개 이하인 경우 offset은 0 , 5개 초과한 경우 curveYoffset 초기화
        curveYOffset = parentCard.SiblingAmount() < 5 ? 0 : curveYOffset;

        //커브의 각도 offset 선언
        curveRotationOffset = curve.rotation.Evaluate(parentCard.NormalizedPosition());
    }

    /// <summary>
    /// 카드 이동시 부드럽게 움직이는 강도 적용
    /// </summary>
    private void SmoothFollow()
    {
        //카드가 드래그 중이면 0 , 드래그 중이 아니면 HandPositioning()에서 계산한 curveYOffset 적용
        Vector3 verticalOffset = (Vector3.up * (parentCard.isDragging ? 0 : curveYOffset));

        //현재 cardVisual의 위치 -> 카드객체의 위치 + 보정값을 보간으로 이동
        //cardTransform.position , transform.position 같은 객체인데 왜 다르게 적용 시키는가?
        //cardTransform.position : 기능적으로 카드의 좌표가 먼저 이동 -> data
        // transform.position : cardVisual이 먼저 이동한 카드객체의 좌표를 따라가면서 연출 -> 시각적
        transform.position = Vector3.Lerp(transform.position, cardTransform.position + verticalOffset, followSpeed * Time.deltaTime);
    }

    /// <summary>
    /// 카드 이동시 카드의 회전(각도) 연출 적용
    /// </summary>
    private void FollowRotation()
    {
        //movement : 이동한 거리
        Vector3 movement = (transform.position - cardTransform.position);
        //Lerp를 이용하여 이동 변화량 갱신
        movementDelta = Vector3.Lerp(movementDelta, movement, 25 * Time.deltaTime);

        //movementRotation : 드래그 중일때는 이동 변화량을 계산하여 각도 변화 , 드래그 중이 아닌경우 0
        //드래그 중이 아니라면 이동시 카드 회적각도 적용 X
        Vector3 movementRotation = (parentCard.isDragging ? movementDelta : movement) * rotationAmount;
        rotationDelta = Vector3.Lerp(rotationDelta, movementRotation, rotationSpeed * Time.deltaTime);
        transform.eulerAngles = new Vector3(transform.eulerAngles.x, transform.eulerAngles.y, Mathf.Clamp(rotationDelta.x, -60, 60));
    }

    /// <summary>
    /// 카드 각도 회전(흔들림) 애니메이션 연출 함수 (카드가 스스로 조금씩 회전하는 함수)
    /// </summary>
    private void CardTilt()
    {
        //카드가 각기 다르게 회전을 해야되기에 Index를 이용하여 값을 다르게 변경
        savedIndex = parentCard.isDragging ? savedIndex : parentCard.ParentIndex();

        //삼각함수에 라디안값(시간+index) 
        //(parentCard.isHovering ? .2f : 1); : isHovering 상태일때 덜 회전되는 파형 선언
        float sine = Mathf.Sin(Time.time + savedIndex) * (parentCard.isHovering ? .2f : 1);
        float cosine = Mathf.Cos(Time.time + savedIndex) * (parentCard.isHovering ? .2f : 1);

        //- Camera.main.ScreenToWorldPoint(Input.mousePosition) 를 해야되는 이유 : 마우스가 화면 어디 있든 “카드가 있는 그 평면”에서의 정확한 차이를 얻기 위해
        //Input.mousePosition은 (x, y, z=0)을 반환 , 즉 cardVisual Pivot에서 마우스 좌표(스크린->월드좌표)의 거리를 계산해서 보정
        Vector3 offset = transform.position - Camera.main.ScreenToWorldPoint(Input.mousePosition);
        //마우스가 카드 위쪽이면 offset.y가 양수 → -1 곱해 카메라 기준 아래로 기울여 자연스러운 기울기.
        //마우스가 카드 오른쪽이면 offset.x가 양수 → Y축을 오른쪽으로 기울임.
        float tiltX = parentCard.isHovering ? ((offset.y * -1) * manualTiltAmount) : 0;
        float tiltY = parentCard.isHovering ? ((offset.x) * manualTiltAmount) : 0;
        //드래그 중엔 Z 회전을 고정(현재 값 유지).
        //드래그 아닐 때는 곡선 배치 영향(슬롯 수 × 영향도 × 오프셋) 으로 Z 회전 부여 → 카드 군집이 원호 / 부채꼴 느낌을 가짐.
        float tiltZ = parentCard.isDragging ? tiltParent.eulerAngles.z : (curveRotationOffset * (curve.rotationInfluence * parentCard.SiblingAmount()));

        //tiltX/tiltY(마우스 기반 수동 기울기)에 sin/cos * autoTiltAmount(자동 흔들림) 합성.
        float lerpX = Mathf.LerpAngle(tiltParent.eulerAngles.x, tiltX + (sine * autoTiltAmount), tiltSpeed * Time.deltaTime);
        float lerpY = Mathf.LerpAngle(tiltParent.eulerAngles.y, tiltY + (cosine * autoTiltAmount), tiltSpeed * Time.deltaTime);
        //Z는 절반 속도로 더 느리게(안정감).
        float lerpZ = Mathf.LerpAngle(tiltParent.eulerAngles.z, tiltZ, tiltSpeed / 2 * Time.deltaTime);

        tiltParent.eulerAngles = new Vector3(lerpX, lerpY, lerpZ);
    }

    /// <summary>
    /// 선택됬을때
    /// </summary>
    /// <param name="card"> CardVisual을 참조하고있는 카드 객체 </param>
    /// <param name="state"> 카드의 선택 여부 </param>
    private void Select(Card card, bool state)
    {
        DOTween.Kill(2, true);
        float dir = state ? 1 : 0;
        shakeParent.DOPunchPosition(shakeParent.up * selectPunchAmount * dir, scaleTransition, 10, 1);
        shakeParent.DOPunchRotation(Vector3.forward * (hoverPunchAngle / 2), hoverTransition, 20, 1).SetId(2);

        if (scaleAnimations)
            transform.DOScale(scaleOnHover, scaleTransition).SetEase(scaleEase);

    }

    /// <summary>
    /// 카드 교체시 동작하는 애니메이션 함수 
    /// </summary>
    /// <param name="dir"></param>
    public void Swap(float dir = 1)
    {
        if (!swapAnimations)
            return;

        DOTween.Kill(2, true);
        shakeParent.DOPunchRotation((Vector3.forward * swapRotationAngle) * dir, swapTransition, swapVibrato, 1).SetId(3);
    }

    /// <summary>
    /// 카드 드래그 시작시 동작하는 함수 
    /// </summary>
    /// <param name="card"></param>
    private void BeginDrag(Card card)
    {
        if (scaleAnimations)
            transform.DOScale(scaleOnSelect, scaleTransition).SetEase(scaleEase);

        //카드 드래그시 드래그 하고 있는 카드는 부모 캔버스의 정렬에 종속되면 안되기에 true로 설정하면
        //드래그시 제일 위로 올라옴 
        canvas.overrideSorting = true;
    }

    /// <summary>
    /// 카드 드래그 종료시 동작하는 함수 
    /// </summary>
    /// <param name="card"></param>
    private void EndDrag(Card card)
    {
        canvas.overrideSorting = false;
        transform.DOScale(1, scaleTransition).SetEase(scaleEase);
    }

    private void PointerEnter(Card card)
    {
        if (scaleAnimations)
            transform.DOScale(scaleOnHover, scaleTransition).SetEase(scaleEase);

        DOTween.Kill(2, true);
        shakeParent.DOPunchRotation(Vector3.forward * hoverPunchAngle, hoverTransition, 20, 1).SetId(2);
    }

    private void PointerExit(Card card)
    {
        if (!parentCard.wasDragged)
            transform.DOScale(1, scaleTransition).SetEase(scaleEase);
    }

    /// <summary>
    /// 카드 클릭 해제시 동작하는 함수
    /// </summary>
    /// <param name="card"></param>
    /// <param name="longPress"></param>
    private void PointerUp(Card card, bool longPress)
    {
        if (scaleAnimations)
            transform.DOScale(longPress ? scaleOnHover : scaleOnSelect, scaleTransition).SetEase(scaleEase);
        canvas.overrideSorting = false;

        visualShadow.localPosition = shadowDistance;
        shadowCanvas.overrideSorting = true;
    }

    /// <summary>
    /// 카드 클릭 시작시 동작하는 함수 
    /// </summary>
    /// <param name="card"></param>
    private void PointerDown(Card card)
    {
        if (scaleAnimations)
            transform.DOScale(scaleOnSelect, scaleTransition).SetEase(scaleEase);

        //카드 클릭시 그림자의 지역좌표를 아래로 내려서 카드가 위로 뜬 느낌으로 연출
        visualShadow.localPosition += (-Vector3.up * shadowOffset);
        //cardVisual 캔버스의 종속정렬을 false로하여 종속되게 변경 
        shadowCanvas.overrideSorting = false;
    }

}

 

HorizontalCardHolder.cs

using System;
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using DG.Tweening;
using System.Linq;

public class HorizontalCardHolder : MonoBehaviour
{
    /// <summary>
    /// 선택된 카드 변수 (마우스 클릭)
    /// </summary>
    [SerializeField] private Card selectedCard;
    /// <summary>
    /// 공중에 뜨고 있는 카드 변수 (마우스 올림)
    /// </summary>
    [SerializeReference] private Card hoveredCard;

    /// <summary>
    /// 카드홀더에 사용될 SlotPrefab (Slot 안에 CardPrefab이 들어가있음) 참고
    /// </summary>
    [SerializeField] private GameObject slotPrefab;
    private RectTransform rect;

    /// <summary>
    /// 런타임시 생성되는 카드 초기값 파라미터
    /// </summary>
    [Header("Spawn Settings")]
    [SerializeField] private int cardsToSpawn = 7;
    /// <summary>
    /// 카드홀더에 소지되고있는 카드 리스트
    /// </summary>
    public List<Card> cards;

    /// <summary>
    /// Swap 메서드에서 사용되는 함수이며 , 카드 위치가 서로 변경시 변경중을 알리는 변수
    /// </summary>
    bool isCrossing = false;
    /// <summary>
    /// 돌아오는 카드 연출 여부
    /// </summary>
    [SerializeField] private bool tweenCardReturn = true;

    void Start()
    {
        for (int i = 0; i < cardsToSpawn; i++)
        {
            Instantiate(slotPrefab, transform);
        }

        rect = GetComponent<RectTransform>();
        cards = GetComponentsInChildren<Card>().ToList();

        int cardCount = 0;

        foreach (Card card in cards)
        {
            card.PointerEnterEvent.AddListener(CardPointerEnter);
            card.PointerExitEvent.AddListener(CardPointerExit);
            card.BeginDragEvent.AddListener(BeginDrag);
            card.EndDragEvent.AddListener(EndDrag);
            card.name = cardCount.ToString();
            cardCount++;
        }

        StartCoroutine(Frame());

        //UI나 GameObject 계층 변경이 끝난 후, 한 프레임 뒤에 카드들의 인덱스를 다시 계산
        IEnumerator Frame()
        {
            yield return new WaitForSecondsRealtime(.1f);
            for (int i = 0; i < cards.Count; i++)
            {
                if (cards[i].cardVisual != null)
                    cards[i].cardVisual.UpdateIndex(transform.childCount);
            }
        }
    }

    private void BeginDrag(Card card)
    {
        selectedCard = card;
    }


    void EndDrag(Card card)
    {
        if (selectedCard == null)
            return;

        //드래그가 종료될때 카드의 원래 위치로 돌아가는 코드
        selectedCard.transform.DOLocalMove(selectedCard.selected ? new Vector3(0,selectedCard.selectionOffset,0) : Vector3.zero, tweenCardReturn ? .15f : 0).SetEase(Ease.OutBack);

        //RectTransform.sizeDelta를 미세하게 건드렸다가 되돌리는 ‘더티 마킹’ 트릭
        //레이아웃 시스템(Horizontal/Vertical Layout Group, Content Size Fitter)이 강제로 다시 계산하도록 유도.
        //드래그 중 부모가 바뀌거나 형제 순서가 바뀐 뒤, 즉시 렌더/배치가 어긋나는 현상을 방지
        rect.sizeDelta += Vector2.right;
        rect.sizeDelta -= Vector2.right;

        selectedCard = null;

    }

    void CardPointerEnter(Card card)
    {
        hoveredCard = card;
    }

    void CardPointerExit(Card card)
    {
        hoveredCard = null;
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Delete))
        {
            if (hoveredCard != null)
            {
                Destroy(hoveredCard.transform.parent.gameObject);
                cards.Remove(hoveredCard);

            }
        }

        //마우스 오른쪽 버튼 클릭 
        if (Input.GetMouseButtonDown(1))
        {
            foreach (Card card in cards)
            {
                card.Deselect();
            }
        }

        if (selectedCard == null)
            return;

        if (isCrossing)
            return;

        //카드홀더에 있는 카드List를 순회하여 정렬을 다시하는 반복문
        for (int i = 0; i < cards.Count; i++)
        {
            //선택된 카드의 x좌표값과 카드 리스트들의 x 좌표값을 비교
            if (selectedCard.transform.position.x > cards[i].transform.position.x)
            {
                //비교후 .ParentIndex() 메서드를 통해 카드의 부모객체인 Slot의 index를 반환받아 비교
                //0번쨰 인덱스 슬릇 < 1번쨰 인덱스 슬릇 => true : swap
                if (selectedCard.ParentIndex() < cards[i].ParentIndex())
                {
                    Swap(i);
                    break;
                }
            }

            if (selectedCard.transform.position.x < cards[i].transform.position.x)
            {
                if (selectedCard.ParentIndex() > cards[i].ParentIndex())
                {
                    Swap(i);
                    break;
                }
            }
        }
    }

    /// <summary>
    /// 카드홀더에 있는 카드끼리 서로 위치를 바꾸는 함수
    /// </summary>
    /// <param name="index"> 카드 홀더의 변경되야하는 index Slot </param>
    void Swap(int index)
    {
        isCrossing = true;

        //선택된 카드의 Slot TR
        Transform focusedParent = selectedCard.transform.parent;
        //카드홀더에 변경되야 하는 index Slot의 TR
        Transform crossedParent = cards[index].transform.parent;

        //TR의 부모(Slot) 변경 , 카드홀더의 변경되는 자리의 Slot 로컬좌표 변경 
        cards[index].transform.SetParent(focusedParent);
        cards[index].transform.localPosition = cards[index].selected ? new Vector3(0, cards[index].selectionOffset, 0) : Vector3.zero;
        selectedCard.transform.SetParent(crossedParent);

        isCrossing = false;

        if (cards[index].cardVisual == null)
            return;

        //Swap이 오른쪾으로 일어나야 하는지 체크(true = 왼쪽방향으로 이동, false = 오른쪽 방향으로 이동) 
        bool swapIsRight = cards[index].ParentIndex() > selectedCard.ParentIndex();
        cards[index].cardVisual.Swap(swapIsRight ? -1 : 1);

        //Updated Visual Indexes
        foreach (Card card in cards)
        {
            card.cardVisual.UpdateIndex(transform.childCount);
        }
    }

}

 

ShaderCode.cs나 VisualCardsHandler.cs는 따로 주석을 작성하지 않아 표기를 하지 않았습니다.


 

코드 리뷰하면서 못보던 메서드 및 프로퍼티들이 있어서 정리 했습니다.

 

 public void SetSiblingIndex(int index)

=======================================================

using UnityEngine;
using UnityEngine.UI;

public class SiblingExample : MonoBehaviour
{
    public Button buttonA;
    public Button buttonB;

    void Start()
    {
	//하이어라키 제일 앞으로 이동
        buttonB.transform.SetSiblingIndex(0);
	//하이어라키 제일 뒤로 이동
        buttonA.transform.SetSiblingIndex(transform.childCount - 1);
    }


}


현재 오브젝트의 형제(계층구조) 순서를 변경하는 메서드
즉, 부모의 자식 목록 내에서 위치를 바꿔는 것이 주 기능
인덱스 값은 0이 제일 앞, 지정한 값보다 큰 값은 자동으로 맨 마지막 자식으로 이동

 

public int GetSiblingIndex()

========================================================

using UnityEngine;
using UnityEngine.UI;

public class SiblingIndexExample : MonoBehaviour
{
    public Button buttonA;
    public Button buttonB;


    void Start()
    {
        Debug.Log($"ButtonA index: {buttonA.transform.GetSiblingIndex()}");
        Debug.Log($"ButtonB index: {buttonB.transform.GetSiblingIndex()}");
        Debug.Log($"ButtonC index: {buttonC.transform.GetSiblingIndex()}");

        // 버튼 B를 맨 앞으로 옮기고 다시 확인
        buttonB.transform.SetSiblingIndex(0);

        Debug.Log("=== 순서 변경 후 ===");
        Debug.Log($"ButtonA index: {buttonA.transform.GetSiblingIndex()}");
        Debug.Log($"ButtonB index: {buttonB.transform.GetSiblingIndex()}");
        Debug.Log($"ButtonC index: {buttonC.transform.GetSiblingIndex()}");
    }
}

 

현재 오브젝트가 부모의 자식 목록에서 몇 번째 위치에 있는지를 int값으로 반환

 

 public float Evaluate(float time)
 
 =================================================
 
 using UnityEngine;

public class CurveExample : MonoBehaviour
{
   
    public AnimationCurve jumpCurve = new AnimationCurve(
        new Keyframe(0f, 0f),   // 시작: y=0
        new Keyframe(0.5f, 1f), // 중간: y=1 (최고점)
        new Keyframe(1f, 0f)    // 끝: y=0 (착지)
    );

    public float duration = 2f; // 점프 한 번에 걸리는 시간(초)
    private float timer = 0f;

    private Vector3 startPos;

    void Start()
    {
        startPos = transform.position;
    }

    void Update()
    {
        // 시간 진행
        timer += Time.deltaTime;

        // 커브 평가
        float t = timer / duration;           // 0 ~ 1 사이 비율
        float height = jumpCurve.Evaluate(t); // 👈 커브에서 현재 높이(y) 가져오기

        // 오브젝트 위치 변경
        transform.position = startPos + Vector3.up * height * 3f;

        // 반복
        if (timer >= duration)
            timer = 0f;
    }
}


애니메이션 커브(AnimationCurve) 안에 있는 곡선 값을 시간(t) 을 기준으로 계산해서 반환
Evaluate의 매개변수(x 축)을 넣으면 반환값(y축) 값을 반환함

 

Unity의 AnimationCurve

 

 

Tweener DOPunchPosition(this Transform target, Vector3 punch, float duration, int vibrato = 10, float elasticity = 1f, bool snapping = false)

===============================================================

using UnityEngine;
using DG.Tweening;

public class DOPunchPositionExample : MonoBehaviour
{
    public Vector3 punchDirection = new Vector3(0, 1, 0); // 위로 튀는 방향
    public float punchPower = 1f;      // 튀는 세기
    public float duration = 0.5f;      // 전체 애니메이션 지속 시간
    public int vibrato = 10;           // 진동 횟수
    public float elasticity = 1f;      // 얼마나 탄성 있게 돌아올지

    void Start()
    {
        // 오브젝트가 위로 툭 튀었다가 흔들리며 원래 자리로 돌아옴
        transform.DOPunchPosition(
            punchDirection * punchPower, // 튈 방향과 크기
            duration,                    // 지속 시간
            vibrato,                     // 진동 횟수
            elasticity                   // 탄성 정도
        ).SetEase(Ease.OutQuad);
    }
}

DoTween Asset에 있는 메서드

한 번 툭 튀었다가 원래 자리로 돌아오는 ‘탄성 있는 모션’을 만드는 메서드 or
제자리에서 짧게 튀는(진동) 애니메이션

 

 

// true = 부모 Canvas의 정렬을 무시하고 독립적인 정렬 사용
// false = 기본 default 값 , 부모 Canvas의 정렬을 따라감 
canvas.overrideSorting = true;

====================================================

using UnityEngine;

public class CanvasOverrideExample : MonoBehaviour
{
    public Canvas popupCanvas; // 팝업용 Canvas
    public int popupOrder = 10;

    void Start()
    {
        popupCanvas.overrideSorting = true;

        // sortingOrder는 값이 클수록 앞(위)에 표시됨
        popupCanvas.sortingOrder = popupOrder;

        Debug.Log($"Popup Canvas sorting order: {popupCanvas.sortingOrder}");
    }
}


해당 캔버스가 부모 캔버스의 정렬 순서를 그대로 따를지, 아니면 독립적으로 관리 할지 결정하는 프로퍼티

 

[SerializeReference]

================================================================

using UnityEngine;
using System.Collections.Generic;

[System.Serializable] public class EventBase { public string title; }
[System.Serializable] public class SpawnEnemy : EventBase { public int count; }
[System.Serializable] public class PlaySound : EventBase { public AudioClip clip; }

public class EventSequence : MonoBehaviour
{
    [SerializeReference]
    public List<EventBase> events = new List<EventBase>();

    void Start()
    {
        // 다양한 타입을 한 리스트에 저장 가능
        events.Add(new SpawnEnemy { title = "적 스폰", count = 3 });
        events.Add(new PlaySound { title = "폭발음 재생" });

        foreach (var e in events)
            Debug.Log($"{e.title} : {e.GetType().Name}");
    }
}

 

[SerializeField]는 
-기본 데이터 유형 (int, float, bool, string 등)
-Enum 유형
-UnityEngine.Object에서 파생된 유형 (GameObject, Component, AudioClip, Material 등)
-직렬화 가능한 유형의 1차원 배열 또는 List 

를 직렬화 할수 있다.


[SerializeReference]는 참조 타입들의 필드(변수)를 직렬화 해줄수 있는 어트리뷰트다.
참조 타입(클래스) 필드를 직렬화할 때, 그 실제 런타임 타입을 함께 저장이 가능해진다.


- List<EventBase> 안에 SpawnEnemy, PlaySound 같은 다른 타입의 객체들이 섞여서 저장
- 일반 Unity 직렬화로는 불가능하지만, [SerializeReference]로 해결

 

[주의]

Unity 기본 인스펙터는 [SerializeReference]로 만든 필드를
“무슨 타입인지” 선택하는 UI를 제공하지 않음, 다른 플러그인이나 Asset을 사용해야함 


https://github.com/mackysoft/Unity-SerializeReferenceExtensions

 

GitHub - mackysoft/Unity-SerializeReferenceExtensions: Provide popup to specify the type of the field serialized by the [Seriali

Provide popup to specify the type of the field serialized by the [SerializeReference] attribute in the inspector. - mackysoft/Unity-SerializeReferenceExtensions

github.com

해당 플러그인 사용시 Inpsector에서 쉽게 사용 가능

 


쉐이더 그래프 & 쉐이더 코드
작성 예정

+ Recent posts