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에서 쉽게 사용 가능

 


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

Q. Unity2D랑 Unity3D랑 나눌 정도로 InpuySystem이 많이 바껴?

A.우선 Vector2 -> Vector3로 바뀌는 정도가 있긴하지만

이동은 x축과 z축으로 이동하기에 비슷하게 동작한다.

 

내 개인 얘기가 들어가기에 이 질문을 적었다.

나는 보통 3D 프로젝트 개발로 캐릭터 이동 구현을 할때 InputManager와 비슷하게 구성을 했다.

InpuiManager의 이동을 Vertical과 Horizantal로 처리하는것을 InputSystem에서 비슷하게 구현

InputActionMap에서도 Type을 value - Axis로 해서 축 데이터를 받아 사용을 했었다.

 

하지만 오늘 Unity3D 강의를 듣는 중 튜터님의 방식이 직관성도 좋고 쉬워보여서 이 방법을 적기 위해 적었다.

Value 값으로 Vector2 값을 받음

 

PlayerInput 동작 방식을 InvokeUnityEvents로 구현하였음

 

위의 방식으로 PlayerInput컴포넌트를 이용해서 이동을 제어하게 구현 하였다.

 

   private Vector2 curMovementInput;
 
 public void OnMove(InputAction.CallbackContext context)
 {
     //InputActionPhase : 해당 키의 입력 상태를 나타낸 열거형 변수
     
     //키가 눌리고 있을때 해당 Input값을 Vector2로 변환하여 반환하고 있다.
     if (context.phase == InputActionPhase.Performed)
     {
         curMovementInput = context.ReadValue<Vector2>();
     }
      //키를 손에서 땟을때 해당 Input값을 (0,0) 으로 초기화 하고있다.
     else if (context.phase == InputActionPhase.Canceled)
     {
         curMovementInput = Vector2.zero;
     }
 }

 

OnMove 함수에서 주의 깊게 볼건 InputActionPhase 라는 enum형 변수이다.

원문을 가져오려헀으나 원문 주석이 꽤 길다.

보통 나는 해당 키 상태를 하나하나 메서드로 나눠서 event 형태로 사용했었다.(그 방법밖에 몰랐기 때문에)

 

p.s Event 형태로 사용하는 InputSystem 예제

더보기
using UnityEngine;
using UnityEngine.InputSystem;

public class ActionMapSwitcher : MonoBehaviour
{
    // InputActionAsset은 Unity의 Input Actions Asset 파일을 연결할 때 사용됩니다.
    public InputActionAsset inputActions;

    // Action Maps
    private InputActionMap playerActionMap;

    private void Awake()
    {
        // InputActionAsset에서 각 Action Map을 가져옴
        playerActionMap = inputActions.FindActionMap("Player");

        // Player Action Map에서 'Move' 액션을 가져와 이벤트 등록
        InputAction moveAction = playerActionMap.FindAction("Move");
        moveAction.started += OnMoveStarted;
        moveAction.performed += OnMovePerformed;
        moveAction.canceled += OnMoveCanceled;
    }

    private void OnEnable()
    {
        // 기본적으로 Player Action Map 활성화
        playerActionMap.Enable();
    }

    private void OnDisable()
    {
        // 비활성화 시 모든 액션맵을 비활성화
        playerActionMap.Disable();
    }

    // 'Move' 액션 관련 콜백 함수들
    private void OnMoveStarted(InputAction.CallbackContext context)
    {
        Debug.Log("Move started");
    }

    private void OnMovePerformed(InputAction.CallbackContext context)
    {
        Vector2 moveInput = context.ReadValue<Vector2>();
        Debug.Log($"Move performed: {moveInput}");
    }

    private void OnMoveCanceled(InputAction.CallbackContext context)
    {
        Debug.Log("Move canceled");
    }

}

 

사실 해당 코드는 비활성화 되거나 파괴될때 event에 등록된 메서드들을 제외해야되는데

해당 코드가 없어 좋지 않은 코드다.

하지만 프로젝트 규모가 크지 않거나 간단한거면  InputActionPhase 를 이용하면 훨씬 가독성이 좋다는것을 느꼇기에 글을 적어보았다.

 


카메라 회전 

나는 오브젝트를 따라다니는 카메라를 만들거나 쳐다보는 카메라를 만들때 Cinemachine을 자주 사용한다. 

하지만 시네머신을 활용하지 않고 직접 구현을 해봄으로 유니티엔진의 좌표계를 이해하려고 한다.

 

유니티는 왼손 좌표계를 사용한다,

Unity3D에서는 왼손 좌표계를 사용한다.

D3D(DirectX3D)에서 사용되는 좌표계라고 생각하면된다. OpneGL은 오른손 좌표계를 사용한다.

왼손의 엄지가 축방향이라고 가정할때 말아쥔 손가락이 회전방향(Inspector에 양수를 적용시키면 적용되는 회전방향)을 의미한다. 

 

Q.그럼 이 좌표계가 무슨 의미가 있어?

A. 오브젝트의 이동에따라 카메라가 회전할때 이 축방향을 정보로 회전하기 때문이다. 

 

캐릭터의 시야가 좌우로 이동시 카메라는 Y축 회전을 해야되고

캐릭터의 시야가 위아래로 이동시 카메라는 X축 회전을 해야된다.

 

우선 마우스에 의해 캐릭터의 회전(시야)를 조절하기 위해 InputAction을 통해 키 바인딩을 한다.

Mouse의 Delta값을 이용

Delta값은 컴퓨터의 성능에 따라 동작이 다르게 보이는것을 방지하기 위해 Time.deltaTime 사용했을때 봤을것이다.

즉 , Mouse의 Delta는 두 프레임 사이에 마우스가 이동한 < 변화량 >으로 이해하면된다. 

변화량이라고 어필하는 이유는 가끔 이것을 좌표값이나 위치의 데이터 값으로 이해 할 수도 있어서 그렇다.

 

그럼 이 Input값을 통해서 우리는 마우스가 이동한 변화량을 반환 받아 카메라 회전에 사용 할 수 있다.

 

    [Header("Look")] //카메라 회전 관련 필드
    public Transform cameraContainer; //플레이어 자식 오브젝트의 메인카메라를 저장할 변수
    public float minXLook; //x의 회전범위 최솟값
    public float maxXLook; //x의 회전범위 최대값
    private float camCurXRot; //현재 카메라의 X축 각도 
    public float lookSensitivy; //카메라 민감도 
    private Vector2 mouseDelta;//마우스 델타값을 저장할 변수

  void CameraLook()
  {
      //위 아래 카메라 회전
      //mouseDelta : 두 프레임 사이의 마우스가 이동한 <변화량>을 Vector2로 반환받음
      //카메라 위,아래 회전시 X축이 회전 되야함 -> Mouse.delta는 Vector2로 x,y 이동한 변화량을 반환
      //위 아래 회전시 X축으로 회전 , 2D에서 위,아래 관련 이동 데이터는 y축에 있기에
      //회전 각도로 사용하려면 Delta.y값을 사용 해야한다. 
      camCurXRot += mouseDelta.y * lookSensitivy;
      camCurXRot = Mathf.Clamp(camCurXRot, minXLook, maxXLook);
      //유니티는 왼손 좌표계이기에 x축의 정회전방향은 아래(아래로 회전시 양수)이다.
      //즉, 반대로 움직이기 때문에 -를 곱해서 정상적으로 움직이게 구현한것 
      cameraContainer.localEulerAngles = new Vector3(-camCurXRot, 0, 0);

      //좌 우 카메라 회전
      transform.eulerAngles += new Vector3(0, mouseDelta.x * lookSensitivy, 0);
  }

 

위,아래 회전은 cameraContatiner를 회전하였고 좌,우 회전은 오브젝트 자체를 회전했다.

이것도 중요한 내용이다, 3D 게임에서 만약 위아래 회전을 카메라가 아닌 오브젝트에 주게 된다면 

카메라가 위 아래로 이동할때마다 오브젝트 미친듯이 회전될거다.

왼쪽 이미지를 위아래로 회전시키면 오브젝트는 저렇게 말도 안되게 x축 회전을 하게된다.

 

이러한 현상 때문에 시야를 위,아래로 회전시키는 x축 회전을 카메라 에서 동작하게 한 것이고

좌,우 회전은 오브젝트가 그방향으로 바라볼때 몸도 그 방향으로 움직이는게 자연스럽기에 직접 회전을 시킨 것이다. 

자식으로 MainCamera를 가지고있는 cameraContainer를 참조

 

'Unity > Unity3D' 카테고리의 다른 글

Unity3D :: 발라트로 카드 연출 , 코드리뷰  (0) 2025.11.04
Unity3D :: Ray,RayCast,RayCastHit  (0) 2024.10.24
Unity3D :: Skybox에 대해서  (0) 2024.10.24

https://01149.tistory.com/102

 

Unity :: 비트연산자와 레이어마스크

Q.비트연산자는 뭐야?A.컴퓨터가 나타내는 제일 작은 단위인 비트(bit = 0 또는 1)비트 연산자는 bit(0,1)을 이동하거나 논리 연산을 할떄 사용하는 것이다.논린 연산을 사용하면 이런식의 결과값을

01149.tistory.com

RayCast를 이용하여 충돌체크시 레이어를 사용하여 최적화 및 가독성을 올릴 수있다.

 

위에 참고한글은 Unity2D,3D 가릴것 없이 RayCast를 사용하여 레이어를 기준으로 충돌 처리를 적용하는 글이다.

나중에 찾아보게 될때 연관이 있어 링크를 달아놨다.

 


 

Q.Ray,RayCast,RayCastHit 가 뭐야?

A.

Ray : 레이저 총 쏠때 그 레이저를 의미하며 , Ray라는 자료형 타입을 이용하여 원하는 방향으로 쏠 수있다.

Unity 내부 코드의 Ray 원문

origin은 레이저의 시작점 , direction은 레이저가 발사되는 방향을 의미한다. 

Ray ray = new Ray(transfrom.position,transform.forward); //오브젝트에서 발사
Ray ray = Camera.main.ViewportPointToRay(new Vector3(0.5f,0.5f,0)); //카메라 중심에서 발사
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); //마우스에서 발사

 

위의 예제로 적은 Camera.main 메서드들은 전부 Ray를 반환하고 있다.

 


RayCast : 눈에 보이지 않는 레이저(Ray)에 맞은 오브젝트가 무엇인지 판단할때 사용되는 메서드이다.

RayCast() 메서드의 원문

RayCast는 Physics 포함되어있는 메서드이며 함수오버로딩도 15개나 있는 메서드이다.

 

 //지상에 붙어있는지 확인하는 메서드
 bool IsGrounded()
 {
     //오브젝트를 기준으로 동,서,남,북 방향에서 아랫방향으로 Ray를 발사하는 코드
     Ray[] rays = new Ray[4]
     {
         new Ray(transform.position + (transform.forward * 0.2f) + (transform.up *0.01f), Vector3.down),
         new Ray(transform.position + (-transform.forward * 0.2f) + (transform.up *0.01f), Vector3.down),
         new Ray(transform.position + (transform.right * 0.2f) + (transform.up *0.01f), Vector3.down),
         new Ray(transform.position + (-transform.forward * 0.2f) + (transform.up *0.01f), Vector3.down),
     };

     //발사된 4개의 Ray를 반복문으로 체크하여 조사
     for (int i = 0; i < rays.Length; i++)
     {
         //Physics.Raycast(탐색하고 싶은 Ray,Ray의 길이,해당 Ray와 충돌시 비교할 레이어마스크)
         if (Physics.Raycast(rays[i], 0.1f, groundLayerMask))
         {
             return true;
         }
     }

     return false;
 }

해당 예제에서 사용된 RayCast 메서드로 bool값을 반환하고 있다.

 

해당 예제 코드는 점프할때 캐릭터가 지상에 붙어있는지 체크하는 메서드이며 동,서,남,북으로 아랫방향으로 Ray를 쏴서

하나라도 Player를 제외한 레이어와 Ray가 충돌시 점프 가능이라고 true를 반환하는 메서드이다.

 

p.s 레이어마스크를 Inspector로 간단하게 설정하기

더보기

위에 참고글을 사용하면 스크립트로도 레이어의 충돌 체크 유무 판단을 내릴 수 있다.

하지만 Inspector로도 간단하게 되기에 적어보았다.

 

예시) 점프시에 자기 자신 레이어와 겹쳐서 점프가 무조건 된다는 판단을 내리면 안됨

Player 레이어를 제외하고 다른 레이어와 충돌시 점프가 가능하다고 판단

 

레이어 마스크 변수 선언
Inspector에서 해당 레이어를 이렇게도 조절이 가능하다.

이렇게도 설정이 가능하다. 

 

해당 밑에 글을 참고하면 더욱 쉽게 Raycast와 LayerMask를 쉽게 사용 할 수 있을것이다.

https://dallcom-forever2620.tistory.com/18

 

[Unity] Raycast Layermask 설정

Raycast 를 사용하여 타겟에게 ray 를 쏴서 처리하고 있을때 자신과 타겟사이에 오브젝트가 끼어들면 ray를 쏘지 못하게 되죠. 당연한 말이지만, raycast 사용중 자신과 타겟사이에 오브젝트가 끼어들

dallcom-forever2620.tistory.com

 


RayCastHit : Physics.RayCast() 메서드를 사용하여 Ray에 검출된 오브젝트의 충돌 정보만 아닌 충돌된 오브젝트의 정보가 필요할때 사용되는 [ 구조체 ]이다. 

RayCastHit의 구조체 원문 , 정보를 담고 있는 구조체라 정말 필드값이 많다. 직접 찾아보는것을 추천

 

Ray ray;
RayCastHit hit;

void Update()
{
	ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    
    //true 면 RaycastHit에 데이터를 가지고 나옴
    if(Physics.Raycast(ray,out hit))
    {
        Debug.Log("hit.distance"); //Ray의 원점에서 충돌한 객체와의 거리
        Debug.Log("hit.point"); //RayCas가 감지된 위치
        Debug.Log("hit.transform"); // 충돌객체의 transform에 대한 참조 
        //충돌한 객체의 콜라이더 컴포넌트에 접근하여 이름 호출
        Debug.Log("hit.collider.gameObject.name"); 
    }
}

해당 예제에서 사용된 Physics.RayCast 메서드

 

Q.SkyBox가 뭐야?

A.월드스페이스의 배경을 둘러싸는 환경 매핑 기술 이다.

 

Unity에서는 Scene의 BackGround로 주로 사용되며 보통 자연적인 환경(하늘,구름,산)을 표현할때 많이 사용

미리 구성된 스카이박스를 가져와서 사용 할수 있어 Asset으로도 활용도가 높음

 

이 SkyBox를 동적으로 변경하여 낮과 밤 등의 시간대나 특정 이벤트 연출을 줄 수있지만

단점으로는 아무래도 텍스쳐로 맵핑하는 그래픽렌더링이 들어가니 최적화는 좋지 않다.

 

Q.SkyBox를 그럼 어떡해 써??

A.SkyBox는 Material로 구성되어있으면 Material의 Shader를 변경하여 사용하면된다.

 

Skybox는 Material로 구성되어있으며 Shader의 Type을 변경하여 사용하면된다.
Skybox-Procedural Type으로 구성한 배치

 

어떤 Type을 선택하든 공통적으로 Tint Color(Procedural은 Sky와 Ground 2개로 설정가능) , Exposure를 제어 할 수 있다.

기능 이름 설명
Tint Color Skybox의 색상을 결정 합니다. 
Exposure 스카이박스의 밝기를 조정합니다.

 

Skybox를 선택하면 4가지 타입에 대한 설명입니다. 

Skybox의 Type 설명
6-Sided 여섯개의 텍스처를 사용하여 위,아래,왼쪽,오른쪽,앞,뒤에 배치하여 사용됩니다.

하지만 이 Type의 단점은 6면의 리소스(이미지)를 전부 구해야하니 관리 측면에서는 힘들수가 있다.
CubeMap 여섯개의 텍스처를 사용하여 위,아래,왼쪽,오른쪽,앞,뒤에 배치하여 사용됩니다.

그대신 하나의 리소스(이미지)로 6면을 전부 맵핑 할 수 있습니다.
Panoramic(파노라마) 파노라마가 구체형 스카이박스리소스(이미지)를 구체형으로 맵핑하여 사용 할 수있습니다.

360도(수평) ,180도(수직) 이 두종류중 하나를 골라 사용합니다.
Procedural(절차적) 미리 정의된 텍스처가 아닌 유니티의 절차적 알고리즘을 통해 하늘을 동적으로 생성하는 방식으로 시간의 흐름을 나타내기 좋습니다.

하늘의 색상,태양 및 달의 위치, 대기현상 , 낮과 밤 등 다양한 시간적 흐름 연출을 나타낼 수 있습니다.

 

Q.Skybox Material도 만들고 Shader도 선택해서 inspector에 속성값도 다  조절했어 어떡해 적용해야돼?

A. Window - Rendering - Lighting → Lighting(Environment) → Material을 변경하면 적용 됩니다.

Lighting - Environment 창에서 Material을 변경해주면 된다.

 

Skybox가 적용된 월드

+ Recent posts