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

 


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

 

 

 

1. 패키지 매니저에서 필요한 패키지 다운로드 및 임포트

 

- Netcode for GameObjects : 기존 GameObject/MonoBehaviour 워크플로에 멀티플레이어
기능을 추가하는 기본 네트워킹 라이브러리

 

- Multiplayer Tools : 멀티플레이어 개발 워크플로를 개선

https://docs.unity3d.com/Packages/com.unity.multiplayer.tools@2.2/manual/index.html

Multiplayer Tools에 대한 Docs

 

- Multiplayer Play Mode: Unity 6 패키지를 사용하면 Unity 에디터를 벗어나지 않아도 멀티플레이어
기능을 테스트 가능, 최대 4명의 플레이어(메인 에디터 플레이어와 3명의 가상 플레이어)를
시뮬레이션

 

 

2. NetworkManager 추가

각 프로젝트에서 네트워크 멀티플레이어를 지원하려면 NetworkManager 컴포넌트가 필요
이 필수 컴포넌트는 프로젝트의 네트워크 상태를 관리하고, 연결 및 네트워크 설정을 처리

빈 오브젝트를 NetworkManager 컴포넌트 추가후 Transport를 UnityTransprot로 지정

UnityTransport 컴포넌트가 게임 오브젝트에 추가됨

전송 레이어는 연결 관리, 데이터 전송, 패킷 암호화 등의 저수준 네트워킹 작업을 담당함

 

3.NetworkObject 추가

NetworkObject는 멀티플레이어 게임에서 서로 다른 클라이언트 간에 네트워크로 연동되거나 동기화되어야
하는 모든 게임 오브젝트에 필요한 컴포넌트

 

NetworkObject 컴포넌트를 게임 오브젝트에 추가하면
해당 컴포넌트는 ‘네트워크 연동 가능’ 상태가 되어, 상태와 동작을 네트워크를 통해 공유하고 업데이트 가능

Network Object 컴포넌트의 인스펙터

— GlobalObjectIdHash는 프로젝트 내의 프리팹 에셋을 식별
— NetworkObjectId는 동일한 프리팹 에셋의 인스턴스를 구분하는 고유 식별자
— OwnerClientId는 오브젝트를 ‘소유’한 클라이언트를 의미

 

이 식별자는 NetworkManager가 해당 클라이언트를 추적하고 연결된 모든 클라이언트에서 오브젝트의
상태를 일관되게 유지할 수 있도록 도와줍니다. 

NetworkObjects는 게임플레이 중에 동적으로 생성(스폰) 되거나 파괴될 수 있습니다.

NetworkObject를 생성하면 연결된 모든 클라이언트에 이 오브젝트가 표시됩니다.

각 NetworkObject에는 소유자가 있으며, 이 소유자는 일반적으로 해당 오브젝트의 동작과 상태를 제어하는 클라이언트입니다.

 

NetworkObject 컴포넌트가 추가된 플레이어 객체 / NetworkManager의 해당 클라이언트의 플레이어 객체 Prefab을 설정

플레이어 NetworkObject는 주로 플레이어의 이름, 점수, 인벤토리 또는 기타 관련 정보를 비롯한 플레이어
관련 데이터를 저장하고 동기화합니다. 이 데이터는 네트워크에 걸쳐 동기화되므로 연결된 모든 플레이어가
일관된 게임 상태를 경험할 수 있습니다.

 

클라이언트가 연결되면 NetworkManager는 해당 플레이어가 ‘소유’하는 플레이어 NetworkObject를.
생성합니다. 즉, 플레이어는 자신의 PlayerObject에 대한 권한을 가지고, 그 행동과 상태를 제어할 수 있습니다.

 

NetCode 컴포넌트 리스트

- NetworkObject: 네트워크로 연동할 오브젝트마다 NetworkObject 컴포넌트가 필요합니다..
이 컴포넌트는 생성, 제거, 소유권과 관련된 프로퍼티와 이벤트를 포함합니다.
- NetworkBehaviour: 이 스크립트는 MonoBehaviour 기본 클래스에 네트워킹 동작을 추가합니다. NetworkBehaviour에는 네트워크 변수, RPC(원격 프로시저 호출), 네트워크 콜백이 포함됩니다.
- NetworkAnimator: 이 컴포넌트는 애니메이션 상태와 파라미터를 클라이언트 간에 동기화합니다.
- NetworkTransform: 이 컴포넌트는 실시간으로 플레이어의 위치, 회전, 스케일을 서버에서 연결된
모든 클라이언트로 복제합니다.

 

4. Multyplayer Play Mode 실행시키기

Multyplayer Play Mode 패키지를 통해 멀티 환경을 간단히 테스트 가능

에디터에서 Run 후 , 필요한 가상 플레이어를 추가하면 새로운 가상 씬을 만들어 테스트 할 수 있다.

 

Player 2를 추가하여 가상의 Game Scene을 열어서 체크

 

이런식으로 네트워크 환경을 설정할 수 있다.

다음 포스팅에는 직접 플레이어 객체의 위치 동기화 및 NetCode 스크립트 구성을 작성

 


https://unitysquare.co.kr/growwith/resource/form?id=704

 

Unity Square

고급 Unity 개발자를 위한 멀티플레이어 네트워킹을 위한 최고의 가이드

unitysquare.co.kr

유니티 코리아에서 제공한 멀티플레이어 네트워킹 전자책을 참고하여 공부한 포스팅입니다.

픽셀아트를 사용하기 전 사전 설정

사전설정 이유

-일반적인 이미지에 비해 해상도가 매우 작음

-유니티 에디터에서 지정하는 기본 설정 값으로는 픽셀아트 고유의 느낌을 살릴 수 없음

 

사전설정 요소 : 

2D 스프라이트의 인스펙터

속성 설정 값 설명
Pixel Per Unit 개발자 혹은 기획에서 정한 픽셀 크기
(ex: 16 - 1
칸에 16개 픽셀)
n Pixel  / 1 Unit (1 유닛 당 n pixel)
: Unit유니티의 1칸의 격자 Transform Scale 1기준에 지정된 픽셀 개수
 
Ex) PPU 100 일경우, 1 Unit(격자) 100개의 픽셀 지정
Ex2)  m/s(시간() 분의 속도)는 meter per second라고 읽음,
비슷한 개념
Filter Mode Point (not filter) 2D Pixel 사용시 Point(no filter)로 설정이 필수
 
카메라와 거리가 가까울수록 어떻게 보이게 할지 결정
(3D 텍스쳐와 관련된 속성)

기본 값은 Bilinear
(가까울수록 번져 보이게 됨, Scene 위에 배치시 번져 보임)
Comporession None None으로 설정하여 색상 손상 방지
 
색상을 압축하는 속성

기본값은 Normal
(큰 해상도의 일러스트 형식 스프라이트는 큰 차이 X,
해상도와 색상 수가 비교적 적은 픽셀 아트에서는 색상 손상이 심함)

 

Global Light 2D (전역 조명) 속성 알아보기

Light 2D 컴포넌트를 추가

Scene에 전역조명으로 사용할 오브젝트에 Light 2D 컴포넌트 추가

 

Light2D의 각종 속성 정리

속성 하위 속성 설명  
Light Type Freedom 정점을 자유롭게 편집하여 Light 영역 설정
Sprite 스프라이트 모양을 그대로 Light 영역 설정
Spot 원형 혹은 호 형태로 퍼지는 Light 영역 설정
Global 장면 전체를 Light 영역 설정
Color Light 적용되는 색상
Intensity Light 적용되는 밝기

 

Light Pass 설명

알아야 되는 이유  :

빛을 렌더링하기 위해서는 CPU GPU의 처리비용이 매우 높다.

사양이 낮은 기기에서 최적화를 진행하기 위해 필요한 작업이다.

 

Light Pass를 활용하여 사용하지 않는 빛 오브젝트가 존재 할 경우 모니터링하여 최적화를 진행할 수 있다.

 

설정 방법 :

Windows > Analysis > Render Graph Viewer로 확인 가능

 

해당 이미지는 Light2D Pass의 하나의 단계를 사용하는 과정을 없앰으로, 최적화 하는 예시
Light2D Pass 는 Global Light 2D 오브젝트가 Scene에 존재함으로 추가된 단계

 

정렬 레이어를 통한 부분 라이팅

Sorting Layer (정렬만을 위한 전용 레이어, 순위가 높을수록 나중에 그려짐)을 이용하여 부분 라이팅을 할 수 있다.

전역 조명 오브젝트 2개를 만들고 Target Sorting Layer 속성 값을 변경해주면 이런 연출이 가능해진다.

<주의>여러 개의 Global Light 2D가 동일한 Target Sorting Layer를 가지면 에러가 발생

 

Spot Light 2D 배치

배치 방법 :

-하이어라키 창에서 우클릭 -> Light > Spot Light 2D 클릭

해당 방법으로 생성된 SpotLight2D 오브젝트 인스펙터

속성 하위 속성 설명
Color 빛의 색상
Intensity 빛의 밝기
Radius Inner 정점 중심으로부터 비추는 빛의 세기
Outer 정점 중심으로부터 비출수 있는 빛의 거리
Inner / Outer Spot Angle 정점 중심으로부터 0~360도의 빛의 범위(각도)를 설정
(
두 값의 차이가 적을수록 조명의 경계가 더욱 선명해짐)
Falloff Strength 정점 중심으로부터 빛의 거리에 따른 밝기

 

SpotLight2D 빛 조절 예시

Inner / Outer Spot Angle 속성 수치 조절
Falloff Strength 속성 수치 조절

 

활용 예시 :: 맵의 환경등 배치

 

Pixel Perfect Camera

사용 목적 : 유니티에서 2D 조명을 픽셀화 하는 방법 중 하나인 Pixel Perfect Camera 컴포넌트 활용

이유 : 픽셀이 두드러지는 픽셀 아트 스타일은 스크린에 따라서 픽셀의 크기가 일정하지 않음

(좌) 적용 전 (우) 적용 후

적용 방법 : Camera 오브젝트에 PixelPerfectCamera 컴포넌트 추가

Pixel Perfect Camera 컴포넌트

1.더욱 정확한 픽셀 퍼펙트를 위해 에셋의 Pixels Per Unit 정보를 필요

이 속성에는 픽셀 아트 스프라이트 에셋들이 사용하는 Pixel Per Unit과 동일한 값이 필요

2. 이후 Reference Resolution을 조정하여 알맞은 화면 크기를 지정

3.Grid Snapping을 클릭하고 Upscale Render Texture로 설정하면 객체의 이동 및 회전은 물론 Spot Light 2D의 경계가 픽셀화됨

 

<주의 - 오류>

Game창 상단에 해상도가 잘못되었다는 에러 메세지가 표시 => Game창의 해상도가 짝수가 되도록 마우스로 창의 크기를 조절

 

Sprite Asset을 활용한 라이트 제작

보석에서 빛나는 빛 모양(3개의 원 형태)로 빛이 구현됨

Sprite Asset을 활용하여 라이트를 사용할 경우 간단하게 원하는 모양의 빛을 구현 할수 있음, 단점으로는 그만큼의 리소스(에셋) 양이 증가함

 

Light2D의 Sprite의 원하는 모양의 에셋을 넣으면 원하는 모양의 라이트를 얻을 수 있음

 

스프라이트를 이용해서 여러방법으로 빛을 구현

 

그림자 생성하기

Shadow Caster 2D 컴포넌트 구성

속성 하위 속성 설명
Castring Source
(
하위 속성을 토대로 그림자 영역을 만드는 방식을 설정)
SpriteRender 스프라이트 에셋이 가진 고유의 커스텀 아웃라인으로 영역 생성
Collider2D 동일한 객체가 가진 Collider 2D의 모양대로 영역 생성
(물리적인 모양을 그대로 활용할 때)
Shape Editor Scene에서 직접 편집 가능한 모양으로 영역 생성
(세밀한 그림자가 필요할때)
None 그림자를 생성하지 않음
Casting Option
(그림자가 어떤 형태로 드리우는지 결정)
Self Shadow 영역 및 자신의 스프라이트 내부에 그림자 생성
 
Cast Shadow 조명의 방향을 기준으로 방사형 그림자 생성
Cast and Self Shadow 두터운 벽,사물 같이 빛이 도달 못하는 객체에 적용

 

Shadow Caster 2D 또한 Light2D와 같이 대상 정렬 레이어(sorting Layer)를 지정해서 부분적 출력 가능

 

그림자 생성하기 - SpriteRender

(좌) 그림자 적용 전 (우) 그림자 적용 후 (오브젝트의 그림자에 가려 빛이 약해짐)

사용 방법 :

그림자를 적용하려는 오브젝트에 ShadowCaster2D 컴포넌트 추가

SpriteRenderer  영역의 모습이 초기에 직사각형으로 표기됨

원인 : Mineral 스프라이트는 아직 Custom Outline가 설정되어 있지 않기 때문

해결 방법 : SpriteEditor에서 Custom Outline 설정하기

 

1.해당 리소스 에셋의 SpriteEditor 열기

2. CustomOutLine 모드로 변경

(2) CustomOutLine 모드로 변경

3. Generate를 눌러 대략적인 정점을 생성

(3) Generate를 눌러 대략적인 정점을 생성

4.필요한 부분에 정점을 찍어 Sprite 모양의 OutLine 생성 후 Apply로 적용
(설명 : 픽셀 아트는 해상도가 작기 때문에 세밀한 모양을 얻기가 어려움
그래서 Outline이 형성된 이후 마우스로 직접 정점을 옮기거나 선 가운데를 클릭하여 정점을 추가해서
스프라이트와 어느정도 일치하는 Outline으로 편집하는 작업이 필요)

(4) 필요한 부분에 정점을 찍어 Sprite 모양의 OutLine 생성 후 Apply로 적용

5. 적용완료

(5) 적용완료

p.s
Shape Editor 방식은 스프라이트와 상관없이 Shadow Caster 2D 컴포넌트 자체적으로 Scene에서 자유롭게 편집한 모양 데이터를 활용 가능 => 현재 방식과 유사하다는 뜻

 

그림자 생성하기 – Collider2D

(좌) 그림자 적용 전 (우) 그림자 적용 후 (플랫폼의 박스 콜라이더 부분이 가려 그림자 생성)
플랫폼의 발판의 BoxCollider2D 컴포넌트를 가지고 있기에 사용할 수 있음

 

ShodoeCaster2D 컴포넌트 수치 조절 - Trim Edge

Trim Edge 속성의 값이 0.09로 되어 있기 때문인데, 0에 가까울수록 Casting Source에서 활용하는 모양과 가까워짐.

Offset과 같은 개념이므로 프로젝트 취향껏 조절

ShodoeCaster2D 컴포넌트 구성

 

(좌) Trim Edge = 0.00 (우) Trim Edge = 0.09

 

<중요>**Light Render Scale 조절하기**

이유 : 텍스쳐 크기는 더욱 커지고 그만큼 정교한 Light 2D 그래픽을 얻기 위해

사용 방법 :

1. Universial 2D 프로젝트를 새롭게 시작하면 렌더링 설정 데이터를 담고 있는 Renderer2D 에셋에서 Light Render Scale 값이 0.5가 기본값으로 설정되어 있음

2. Light Render Scale = 1로 수정

(좌) Light Render Scale = 0.5 (우) Light Render Scale = 1

Light Render Scale을 수정하지 않으면 발생하는 문제점들

1)     Light 2D의 경계가 번짐 현상

2)     그림자와 실제 영역 사이에 틈새 발생

해당 문제점의 원인 : 2D 조명 그래픽을 위해 내부적으로 제작된 텍스쳐의 크기가 화면의 절반 밖에 되지 않음

=> 작은 텍스쳐를 다시 원래 화면 크기로 늘려서 사용하다보니 번져보이고 그림자 영역도 일치하지 않게 됨

 

FrameDebugger를 이용하여 Light2DPass에 접근해서 문제점 원인 분석

(좌) Light Render Scale = 0.5 (우) Light Render Scale = 1

 

Game View에 보이는것처럼 텍스쳐 크기는 더욱 커지고 그만큼 정교한 Light 2D 그래픽 획득 가능

크기가 커진 만큼 사용해야할 메모리도 늘어나지만, 픽셀 아트 스타일 특성상 참조 화면 해상도가 작기 때문에 성능에 영향을 거의 주지 않음

 

스스로 빛을 내는 스프라이트 제작 :: Emission 적용하기

(좌) 적용 전 (우) 적용 후

Universial 2D의 기본 셰이더는 Sprite-Lit-Default 이며 2D Renderer 컴포넌트에 할당

 

해당 기능을 사용하기 위해서는 Shader 제작이 필요

유니티의 쉐이더그래프 기능을 사용해서 제작 할 예정

쉐이더그래프를 통해 쉐이더를 제작후 머터리얼로 생성 후 해당 스프라이트에 머터리얼 적용

이 셰이더 그래프는 Sprite Lit 기반이며 Main, Mask, Normal, Emission 각각 텍스쳐 정보를 샘플링해서 사용하고 있는 매우 간단한 구조, Emmison을 사용하기 위해서는 EmissionMap EmissionColor 두 속성을 사용할 예정

  • EmissionMap : 빛을 내는 부분을 가라키는 텍스쳐
  • EmissionColor : 빛의 색상 및 밝기

**Secondary Texture 소개**

유니티에서는 스프라이트가 자신과 연계된 추가 정보를 가진 스프라이트와 묶을 수 있는 Secondary Texture 기능을 지원

 

해당 샘플의 EmissionMap 또한 Secondarty Texture 중 하나이며 이것은 Sprite Editor에서 설정된 상태

SpriteEditor -> Secondary Textures 로 변경
Secondary Textures를 설정

1. _EmissionMap 이라는 이름으로 Mineral_Emission이라는 스프라이트가 추가되어 있는데 이런 것처럼 셰이더 활용을 위한 다른 텍스쳐를 연결

2. Name에 적어야 할 이름은 2D Renderer가 사용하는 셰이더에 명시된 Reference와 일치

3. 일치해두면 머티리얼에서 직접 텍스쳐를 할당하지 않아도 2D Renderer에서 자동으로 Secondary Texture에 있는 텍스쳐를 활용가능

 

 

<중요한 팁> 셀 애니메이션이 필요한 객체일 경우 여러 스프라이트가 하나로 합쳐진 형태의 아틀라스로 이러한 Secondary Texture를 적용 가능

스프라이트 아틀라스를 사용할 때는 Slice가 동일하게 적용될 수 있도록 이미지 크기 및 각 스프라이트의 위치가 정확하게 일치하도록 아트 리소스를 제작하는 것이 중요

 

Emission 텍스쳐 제작 방법  외부프로그램 : aseprite 사용

더보기

 

Emission을 위해서 원본 레이어를 복사하고 순서를 위로 올리는 것으로 시작합니다. 그리고 명도를 최대한 낮추어 모두 검은색으로 만들고 레이어 속성의 투명도를 80%로 하여 원본이 살짝 보이게 합니다. 이후 빛을 내야 하는 부분만 흰색으로 그리면 됩니다.

 

물론 흰색 뿐만 아니라 빛의 세기에 따라 회색 및 어두운 회색으로 바꾸어 칠하여 세밀한 EmissionMap을 제작할 수 있으니 참고하시길 바랍니다.

 

포스트 프로세싱 사용하기

사용 목적 : 사용자에게 화려하고 풍부한 그래픽 제공을 위한 목적

(좌) 사용 전 (우) 사용 후

사용 방법 : 

(좌) Camera 컴포넌트의 PostProcessing 체크 (우) 포스트프로세싱에서 사용할 Volume 프로필 생성

1. 카메라의 Post Processing 체크

2. 하이어라키 창에서 Volume 오브젝트 생성

3. Volume profile 생성 후 각종 효과 설정

4. Bloom(번짐)을 추가하여 Intetsity Scartter 체크 후 수치 조절

( 2개만 설정해도 풍부한 효과 추가 가능) ( Bloom 추가 이유 -> Emission의 강조할 빛 번짐효과 추가)

속성 설명
Intetsity 번짐의 밝기
Scatter                 번짐의 세기값
Threshold 임계값

수치가 적용된 Game View 화면

 

노멀맵

사용 목적 : 입체갑 있는 표면을 사용하기 위한 목적 + 폴리곤만으로 표현할시 GPU,CPU 처리비용이 높음

노멀맵 예시

일반적으로 텍스쳐는 해상도가 크기 때문에 노멀맵 또한 이에 맞추어 큰 해상도에서 제작

하지만 픽셀아트는 매우 작기 때문에 기존 방식을 그대로 사용하면 상당히 어긋나게 됨

 

Aseprite 프로그램 기준으로 자동으로 노멀맵을 생성하는 플러그인

( #mooosik's normal-toolkit , #securas's Edge Normals ) 이 존재

(좌)Pixel Art 노말맵 샘플 (우) 기본 오브젝트와 해당오브젝트의 노말맵 구성

평면과도 같은 부분은 한가운데의 연보라색 색상을 배치

 

사용 방법 :

사용하려는 스프라이트 에셋의 SecondaryTexture에서 _NormalMap 연결하여 사용 (_NormalMap은 쉐이더그래프로 만든 커스텀 쉐이더에 있는 레퍼런스)

 

해당 효과를 적용한다고 바로 입체감이 드러나지 않음,

왜냐면 노말맵은 빛의 반사(법선 방향)를 조정하는 그래픽 최적화 기법이기 때문

Light 2D Normal Map을 반영하도록 추가 설정이 필요하기 때문

 

모든 Spot, Sprite Light 2D에서 Normal Maps > Quality Accurate로 설정

적용된 화면

캐릭터가 노멀맵을 따라 경계에 명암이 바뀐 것을 확인, 조명의 기능이 고장나 버림 ( 어두워짐 )

 

문제점 : 조명의 기능이 고장나 버림 ( 어두워짐 ) => Rim Light로 전환하기

RimLight ? : 피사체 뒤에서 강한 조명을 주어 테두리에 빛나는 외곽선을 만드는 기법 -> 노말맵이 외곽 경계선의 반사광을 이용하여 입체감을 나타내는 표현이기에 사용됨

 

사용 목적 : 일부 고해상도 2D 프로젝트는 이러한 볼륨감을 위해 노멀맵을 사용하지만 픽셀 아트는 이질감 발생 가능

반대로 화려한 2D 라이팅을 픽셀 아트 객체에 잘 반영할 수 있도록 외곽을 따라 테두리 광원이 나타나는 Rim Light를 사용

 

사용 방법 :

1.     기존 Sprite Light 2D 객체의 Normal Maps > Quality None으로 원상복구

2.     해당 객체를 Ctrl+D로 복사 및 Rim Light로 이름 변경 (기존 객체는 비활성화)

3.     Light Type Spot으로 변경하고 Radius > Outer를 기존 조명 영역보다 더 크게 확장

4.     Intensity 10 이상 높게 설정

5.     Normal Maps > Quality Accurate로 변경

6.     Normal Maps > Distance 1 이하로 설정

캐릭터의 노말맵 경계가 더욱 뚜렷해짐 , 하지만 아직 빛의 기능을 제대로 복구하지 못함

Target Sorting Layer를 활용하여 복구

1.     Rim Light Target Sorting Layer Default만 설정

2.     기존 Sprite Light 2D를 다시 활성화

3.     Rim Light Sprite Light 2D의 자식 객체로 등록하기

Target Sorting Layer 로 복구 완료

 

보통 강조하려는 동적인 대상에 부여하기 때문에 이러한 객체들이 사용하는 정렬 레이어만 대상으로 삼음

(동적인 대상 : 플레이어, 움직이는 오브젝트 등등)

기존 Sprite Light 2D는 그대로 배경과 전경 모두 적용하거나 분위기, 상황에 따라서 배경만 대상으로 적용

(정적 : 배경, 가만히 있는 오브젝트 등등)

 

, 림라이트를 생성한 목적은 노말맵을 이용하기 위해 전용 빛(조명)을 추가했다는 뜻

(해당 조명은 그림자 렌더링시 연산에 되지 않게 정렬 레이어에서 제외해야됨)

 

불필요한 배치를 줄여 최적화

이유 : 빛은 그래픽스에서도 엄청난 처리비용을 가지고 있다. 그러기에 2D Pixel에서도 최적화 처리를 해주어야만한다.

FrameDebugger를 살펴보면 한 프레임에 134 Batch수를 가지고 있다.

해당 Batch수가 많을수록 CPUGPU에게 드로우콜을 요청하게 된다. => 처리비용의 증가 ( CPU 병목의 주 원인)

 

현재 상황은 모든 Light 2D가 그림자를 생성하므로 조명이 겹치는 영역에 Shadow Caster 2D를 가진 객체가 진입하면 필요한 연산이 곱셈으로 증폭되는 상황 => 그림자를 발생시키는 조명의 정렬레이어만 적용 시키며 사용하지 않거나 중요도가 낮은 오브젝트들은 그림자를 비활성화 시킨다.

 

원하지 않는 부분의 조명 처리

빛이 있는곳에 오브젝트들이 겹치자 빛샘효과가 발생 => 마스크 활용

 

노멀맵과 비슷한 형태로 빛에 반응하는 부분을 붉은색으로 표현,

빛에 반응하지 않는 부분은 Emission과 같이 검은색, 빛을 받는 부분을 붉은색 계통으로 표시해주는 것이 특징

( 제작 방법이 EmissoionMap과 유사 )

이 또한 SecondaryTexture를 통하여 쉽게 적용이 가능함

 

마스크 사용을 위한 Light 2D의 Blending 옵션

Blending  : 스프라이트 색상과 조명의 색상을 섞는 (블렌딩) 방식

  • Multiply : 색을 곱하는 연산 (회색 * 회색 = 더 어두운 회색)
  • Additive : 색을 더하는 연산 (회색 + 회색 = 밝은 회색)
  • Multiply with Mask (R) : Multiply 와 같음 ,마스크 텍스쳐의 빨강 채널 R만 사용하는 옵션
  • Additive with Mask (R) : Additive 와 같음 ,마스크 텍스쳐의 빨강 채널 R만 사용하는 옵션

외곽 경계선 즉, 노말맵을 비추고 있는 Rim Light 값을 수정하여 빛샘 현상 방지 => Multiply with Mask (R) 사용

마스크맵이 적용이 잘되는 모습

 

<> 마스크는 그림자에 비해 연산이 매우 가벼우므로 마스크 맵 리소스 제작이 가능한경우 , Shadow Caster 2D를 대체하여 마스크로 그림자를 표현하는 방법으로 최적화 가능


 

참고한 링크
https://learn.unity.com/project/pixelartlight2d?uv=6

 

픽셀 아트를 위한 2D 라이팅 - Unity Learn

유니티 엔진에서 픽셀 아트 스타일의 게임 콘텐츠를 제작하기 위해 2D 라이트를 생성하고 편집하는 방법을 예제 프로젝트와 함께 실습하는 튜토리얼입니다.

learn.unity.com

 

 

Secondary Texture 참고 영상

https://youtu.be/InNZsUWNb8k?si=qRfV6PhdEd7AJiJE

 

 

 

셰이더 그래프

밝기 조절 때문에 노드를 좀 많이 사용 하였음

 

셰이더 그래프
결과화면

셰이더 그래프

 

결과

 

셰이더 그래프란?

HLSL(High Level Shader Language) 코드 작성 없이 노드 기반으로 셰이더를 만들수 있는 기능입니다. 


셰이더 그래프 사용 방법?

URP 코어 사용시

1. Project 부분에서 해당 경로를 따라가서 셰이더 그래프를 만들 수 있다.

셰이더 그래프의 UI 기능

2.우클릭 - Create Node를 사용하여 노드를 만들고 해당 Input에 기능들을 넣어 셰이더를 만들수 있다.

Node 생성

3.생성한 셰이더 그래프로 Material를 생성한다. 

만들어진 셰이더 그래프에 우클릭 후 해당 경로를 따라가면 Material를 생성 할 수 있다.
셰이더그래프를 통해 만들어진 Material

 

4.해당 Material를 사용할 스크립트 코드 작성
//Scene 전환시 사용되는 이펙트 효과로 간단하게 만든 스크립트 입니다.
//나중에 사용할시 Material 호출하여 사용하면 됨 . 

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

public class TransitionScreen : MonoBehaviour
{
    [SerializeField] private Material screenTransitionMaterial;
    [SerializeField] private float transitionTime = 1f;
    [SerializeField] private string prppertyName = "_Scrool";

    public UnityAction OnTransitonDone;

    public void OnTransitionScreen()
    {
        StartCoroutine(TransitionCoroutine());
    }

    private void Start()
    {
        StartCoroutine(TransitionCoroutine());
    }

    private IEnumerator TransitionCoroutine()
    {
        float curretnTime = 0;
        while (curretnTime < transitionTime)
        {
            curretnTime += Time.deltaTime;
            screenTransitionMaterial.SetFloat(prppertyName, Mathf.Lerp(0f, transitionTime, curretnTime));
            yield return null;
        }
        OnTransitonDone?.Invoke();
    }
}

간단하게 씬 전환시 사용되는 이펙트를 만들기위해 스크립트를 만들어 보았다. 

 

5.Unity Scene에 오브젝트 및 컴포넌트 생성

하이어라키에 Scene 전환용 오브젝트(SceneTransition) , 이펙트가 적용될 오브젝트(Quad) 하나 생성

Material을 잘 적용했는지 체크 할 것

 

6.체크

화면 전환


이번 포트폴리오 중 대화시스템을 구현 중에 화면 전환이 필요해서 셰이더 그래프를 익혀보았고

다른 예제들을 찾아보며 적용 중이다. 

+ Recent posts