나는 사립 4년제 대학교를 전기공학을 졸업하고 PLC프로그래머 및 전기 엔지니어로 2년 정도 작은 회사에서 일하다
그만두고 개발자 공부를 시작했고 꽤 많은 시간이 흘렀다.
Unity 클라이언트 개발자 신입으로 이력서를 계속 넣다가 오랜만에 한 곳에서 코테 연락이 왔었다.
정말 얼마만에 서류 합격인지 봄날은 오나 했었다.
하지만 보통 큰 기업이 아닌 이상 과제전형이 많은걸로 알고 있어서 코테를 좀 멀리 하고 있었고
코테 언어도 C++로 준비 했었다, 최근에 같이 지내던 동료와 함께 인디게임 출시를 위해 Unity와 C#만 건드리고 있었다
그래도 기회가 왔으니 3일 동안 진짜 밤새가며 열심히 C++을 복기하고 코테에서 사용하던 알고리즘을 다시 익혔지만,
결과는 코테 탈락이였다.
프로그래머스를 통해 3문제가 나왔고 문제 수준은 어렵지 않았으나.. 결국 시간 부족으로 한문제 밖에 풀지 못했다.
코테를 풀면서도 IDE의 '자동완성' 에 너무 익숙해져있고 , 필요한게 있으면 무조건 구글링 + AI로 원하는 답안을 찾아내는거에 습관이 들어져 버렸기 떄문이다.
아무튼 취준이 잘되지도 않고 개발 공부 기간이 길어지니 "내가 진짜 이거 좋아해서 하는게 맞나??" 싶은 생각도 든다.
하지만 Unity를 켜고 이거저것 만들거나 자기개발 할때는 그 시간이 또 재밌고 시간이 빨리 가는것을 보면 내가 좋아한다고 느끼지만 회사의 입장으로 볼때는 "이 정도로는 좋아하는게 아니야" 소리를 듣는 느낌이다.
실제로 이것저것 만들어봤지만 실제로 출시,상용화 한것이 하나도 없다.
포트폴리오를 만들거나 예제 코드 끄적이기 및 알고리즘 구현 등...
지금이라도 별거 아니더라도 구현하면 블로그에 포스팅 및 github 커밋 등 다양하게 기록을 남겨야 될 것 같다.
이 꿈을 아직 포기 못하고 잡고 할 수 있는건 해당 업계에서 뛰고 있는 동료가 함께 인디게임을 만들어서 출시해보자 제안을 해서 나도 마지막 기회다 하며 붙잡고 있다.
하지만 해당 프로젝트는 규모에 비해 기간이 터무니 없이 길어졌고 진행도 잘 되지 않는다.
(동료가 아트와 기획을 함께 하고 있고 , 나는 플밍과 기획 검토 위주로 진행 중)
변명하자면 동료도 현업이 있고 , 나는 기획이 된게 없으니 나름대로 구현을 하고 뒤엎고를 반복 하고 있다.
사실 알고있다.. 급한 놈이 움직이는게 아닌가? 내 쪽에서 기획서를 만들고 해당 프로젝트에서 필요한 구조를 준비하면 된다.
그러나 기간이라는게 길어질수록 사람이 무기력하게 바뀌는데.. 현재 내가 그런 상태인것 같다...
이번 년도 인턴을 하면서 만났던 개발자 사수분이 계시는데, 최근에 연락이 되서 생활비 때문에 택배 단기 알바를 조금 했다고 하니까 '그 시간에 차라리 개발 및 출시에 집중하고, 자존심 버려서라도 부모님께 좀만 더 기다려달라' 라고 말씀을 하셨었다. 당연히 이 말씀만을 하시진 않았고 여러가지 조언도 해주셨다.
하지만 저 말은 정말 옳은 말이다. 최대한 시간을 투자해서 빠르게 출시하고 그 경험으로 회사에 취업을 하거나 인디게임 개발 사업을 하는게 맞다고 생각한다.
하지만 부모님은 이미 나에게 많은 기회를 주셨고 난 그것을 놓쳐버렸다.
난 정말 이 일로 돈을 벌고 살고싶다. 그래서 내 선택은 부모님께 최대한 손을 덜 벌리면서 내 의지를 보이려면
생활비 조금이라도 내가 직접 벌어야 된다 생각을 했고 일을 하고 오면 개발을 이어서 할 수 있게 최대한 쉬운 일을 하기로 결정 했다.
아르바이트 기간은 3월 말까지로 우선 하기로 했다.
3월 말 아니면 4월 까지도 프로젝트 출시나 좋은 반응이 없으면
다른 직종을 찾아서 빠르게 자기 자리를 잡아야 할 것 같다.
최근에 알바하다가 여유있을때 C# 기초 참고서를 읽으며 복기하는데 문득 이런 생각이 들어서
생각을 정리할겸 적어봤다.
별로 좋지 않은 감정이였지만, 또 한편은 후련하다.. 이런 감정을 표현할 곳이 제대로 없으니
1) 개발자로 취업 성공하기
=> 어느곳이라도 좋다 날 써줄수 있는 곳이라면...
2) 인디게임 출시하여 제대로 된 경험 쌓기
=> 출시하여 반응이 좋지 않아도 좋다... 이 경험이 큰 경험이 되고 취준에 큰 도움이 될거라고 믿는다.
3) 준비한 것을 정리하고 다른 직종으로 자리 잡기
이 세가지 유형을 내년에는 반드시 하나는 결정이 되야한다..
급하게 마무리하다 보니 그냥 내 강점 쓰레기통 글이 되서 좀 보기 안좋다.
그래도 지나가다 이 글을 보신 분들도 포기하지 말고 할 수 있는데까지 도전하시고 잘 풀리셨으면 좋겠다.
(좌) PriorityQueue를 사용 할 수 있는 버전 (우) 현재 사용자의 .NET 버전
우선순위 큐의 장점
1. 빠른 최대/최소 접근 : Heap의 루트노드에서 최대값/최소값 O(1)로 확인 가능
2. 삽입/삭제 효율적 : 삽입/삭제시 재정렬 작업이 푤이하지만 시간 복잡도가 O(log N) 임
3. 우선순위 기준 처리 용이 : 특정 작업을 순서 없이 넣어도 중요도 순으로 꺼낼 수 있음
우선순위 큐의 단점
1. 전체 정렬에 부적합 : Heap은 전체 정렬이 되어있지 않음 정렬이 필요한 상황에는 비효율적
2. 중간 요소 탐색 불리 : 일반 배열처럼 임의 접근 효율이 떨어짐
3. 구조 유지 작업 필요 : 삽입/삭제시 tree구조를 유지해야하므로 재구성 비용이 존재함
우선순위 큐 시간 복잡도
연산
시간복잡도
설명
Enqueue
O(log n)
가장 끝에 삽입 후 ,Heapify Up
Dequeue
O(log n)
루트를 제거하고 , Heapify Down
Peek
O(1)
루트를 바로 반환
사용 예시
1. 턴 기반 게임의 행동 우선순위 처리 ex) 포켓몬 턴제 전투
2. 길찾기 알고리즘에 사용 ex) A*알고리즘
구현 방법 (Min-Heap 기준)
1. 우선순위 큐에서 필요한 기능 기획
-원소(데이터)를 담을 컨테이너 필요 => List 사용
우선순위 큐에는 2개의 변수가 필요함 , 데이터를 보관할 변수 , 우선순위를 표시할 변수 => 튜플 자료형 사용
using System;
using System.Collections.Generic;
public class PriorityQueue<TElement, TPriority> where TPriority : IComparable<TPriority>
{
//프로퍼티
public List<(TElement element, TPriority priority)> Heap => _heap;
public int Count => _heap.Count;
private readonly List<(TElement element, TPriority priority)> _heap = new List<(TElement, TPriority)>();
}
우선 자료구조이기에 제네릭 클래스로 생성하고 2개의 T를 생성한다.
TElement : 실제 저장할 데이터 단위
TPriority : 해당 데이터의 우선순위
TPrioirty는 대소 비교가 가능하기에 제약 조건으로 IComparaable<TPriority> 걸음
우선순위 큐를 List로 생성하였기에 배열 기반의 완전 이진트리에서 부모/자식 인덱스를 찾는 공식이 필요하다.
관계
계산식
부모 -> 왼쪽 자식
left = n * 2 +1
부모 -> 오른쪽 자식
right = n * 2 + 2
자식 -> 부모
parent = (n-1) / 2
여기서 n은 자기 자신의 Index를 말하는 것이다.
-우선순위 6번의 부모의 인덱스 = ( 3 -1 ) / 2 = 1
-우선순위 3번 노드의 왼쪽자식와 오른쪽 자식의 인덱스
왼쪽자식 = 1 * 2 + 1 = 3
오른쪽자식 = 1 * 2 + 2 = 4
2.요소 삽입 구현(Enqueue)
public bool EnQueue(TElement element, TPriority priority)
{
// 마지막 위치에 원소 추가
_heap.Add((element, priority));
var index = _heap.Count - 1;
while (index > 0)
{
//부모 노드의 인덱스 구해오기
var parent = (index - 1) / 2;
// 현재 노드의 우선순위가 부모보다 크거나 같으면 더이상 위로 올릴 필요 없음
// Min-Heap 기준
if (_heap[index].priority.CompareTo(_heap[parent].priority) >= 0)
{
return true;
}
// 아니라면 부모와 자식을 스왑
(_heap[parent], _heap[index]) = (_heap[index], _heap[parent]);
index = parent;
}
return false;
}
3.요소 삭제 구현(DeQueue)
public bool TryDequeue(out TElement element, out TPriority priority)
{
if (_heap.Count <= 0)
{
element = default;
priority = default;
return false;
}
// 루트 요소 반환(최상위 부모노드 삭제)
element = _heap[0].element;
priority = _heap[0].priority;
// 마지막 요소를 루트(취상위 부모)에 위치시키고, 힙 크기를 줄임
var lastElement = _heap[^1];//제일 마지막 노드를 복사
_heap[0] = lastElement;
_heap.RemoveAt(_heap.Count - 1);
//정렬 시작
var index = 0;
var count = _heap.Count;
while (true)
{
//자식의 인덱스를 구함
var left = index * 2 + 1;
var right = index * 2 + 2;
var current = index;
//여기서 왼쪽과 오른쪽 두 자식과 모두 비교하여 더 “우선순위가 높은”자식과 스왑해야 하므로, 두 자식과 모두 비교
// 좌측 자식의 우선순위가 현재 우선순위보다 낮다면
if (left < count && _heap[left].priority.CompareTo(_heap[current].priority) < 0)
{
current = left;
}
// 우측 자식의 우선순위가 현재 우선순위보다 낮다면
if (right < count && _heap[right].priority.CompareTo(_heap[current].priority) < 0)
{
current = right;
}
//두 조건 다 만족하지 못한다면
if (current == index)
{
return true;
}
// Swap
(_heap[current], _heap[index]) = (_heap[index], _heap[current]);
index = current;
}
}
영상과 해당 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을 사용해야함
- 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 후 , 필요한 가상 플레이어를 추가하면 새로운 가상 씬을 만들어 테스트 할 수 있다.
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도의 빛의 범위(각도)를 설정 (두 값의 차이가 적을수록 조명의 경계가 더욱 선명해짐)
사용 목적 : 유니티에서 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가 기본값으로 설정되어 있음
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가 동일하게 적용될 수 있도록 이미지 크기 및 각 스프라이트의 위치가 정확하게 일치하도록 아트 리소스를 제작하는 것이 중요
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수가 많을수록 CPU가 GPU에게 드로우콜을 요청하게 된다. => 처리비용의 증가 ( 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를 대체하여 마스크로 그림자를 표현하는 방법으로 최적화 가능