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축) 값을 반환함

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에서 쉽게 사용 가능
쉐이더 그래프 & 쉐이더 코드
작성 예정
'Unity > Unity3D' 카테고리의 다른 글
| Unity3D :: 3D월드에서 InputSystem을 사용하여 캐릭터 이동 + 카메라 회전 , enum InputActionPhase (0) | 2024.10.25 |
|---|---|
| Unity3D :: Ray,RayCast,RayCastHit (0) | 2024.10.24 |
| Unity3D :: Skybox에 대해서 (0) | 2024.10.24 |























