나는 사립 4년제 대학교를 전기공학을 졸업하고 PLC프로그래머 및 전기 엔지니어로 2년 정도 작은 회사에서 일하다 

그만두고 개발자 공부를 시작했고 꽤 많은 시간이 흘렀다.

 

Unity 클라이언트 개발자 신입으로 이력서를 계속 넣다가 오랜만에 한 곳에서 코테 연락이 왔었다.

정말 얼마만에 서류 합격인지 봄날은 오나 했었다.

 

하지만 보통 큰 기업이 아닌 이상 과제전형이 많은걸로 알고 있어서 코테를 좀 멀리 하고 있었고

코테 언어도 C++로 준비 했었다, 최근에 같이 지내던 동료와 함께 인디게임 출시를 위해 Unity와 C#만 건드리고 있었다

 

그래도 기회가 왔으니 3일 동안 진짜 밤새가며 열심히 C++을 복기하고 코테에서 사용하던 알고리즘을 다시 익혔지만,

 

결과는 코테 탈락이였다.

프로그래머스를 통해 3문제가 나왔고 문제 수준은 어렵지 않았으나.. 결국 시간 부족으로 한문제 밖에 풀지 못했다.

코테를 풀면서도 IDE의 '자동완성' 에 너무 익숙해져있고 , 필요한게 있으면 무조건 구글링 +  AI로 원하는 답안을 찾아내는거에 습관이 들어져 버렸기 떄문이다. 


 

아무튼 취준이 잘되지도 않고 개발 공부 기간이 길어지니 "내가 진짜 이거 좋아해서 하는게 맞나??" 싶은 생각도 든다.

하지만 Unity를 켜고 이거저것 만들거나 자기개발 할때는 그 시간이 또 재밌고 시간이 빨리 가는것을 보면 내가 좋아한다고 느끼지만 회사의 입장으로 볼때는  "이 정도로는 좋아하는게 아니야" 소리를 듣는 느낌이다.

 

실제로 이것저것 만들어봤지만 실제로 출시,상용화 한것이 하나도 없다.

포트폴리오를 만들거나 예제 코드 끄적이기 및 알고리즘 구현 등... 

지금이라도 별거 아니더라도 구현하면 블로그에 포스팅 및 github 커밋 등 다양하게 기록을 남겨야 될 것 같다.


이 꿈을 아직 포기 못하고 잡고 할 수 있는건 해당 업계에서 뛰고 있는 동료가 함께 인디게임을 만들어서 출시해보자 제안을 해서 나도 마지막 기회다 하며 붙잡고 있다.

 

하지만 해당 프로젝트는 규모에 비해 기간이 터무니 없이 길어졌고 진행도 잘 되지 않는다.

(동료가 아트와 기획을 함께 하고 있고 , 나는 플밍과 기획 검토 위주로 진행 중)

 

변명하자면 동료도 현업이 있고 , 나는 기획이 된게 없으니 나름대로 구현을 하고 뒤엎고를 반복 하고 있다.

사실 알고있다.. 급한 놈이 움직이는게 아닌가? 내 쪽에서 기획서를 만들고 해당 프로젝트에서 필요한 구조를 준비하면 된다.

 

그러나 기간이라는게 길어질수록 사람이 무기력하게 바뀌는데.. 현재 내가 그런 상태인것 같다...

 


이번 년도 인턴을 하면서 만났던 개발자 사수분이 계시는데, 최근에 연락이 되서 생활비 때문에 택배 단기 알바를 조금 했다고 하니까 '그 시간에 차라리 개발 및 출시에 집중하고, 자존심 버려서라도 부모님께 좀만 더 기다려달라' 라고 말씀을 하셨었다. 당연히 이 말씀만을 하시진 않았고 여러가지 조언도 해주셨다.

 

하지만 저 말은 정말 옳은 말이다. 최대한 시간을 투자해서 빠르게 출시하고 그 경험으로 회사에 취업을 하거나 인디게임 개발 사업을 하는게 맞다고 생각한다.

 

하지만 부모님은 이미 나에게 많은 기회를 주셨고 난 그것을 놓쳐버렸다.

난 정말 이 일로 돈을 벌고 살고싶다. 그래서 내 선택은 부모님께 최대한 손을 덜 벌리면서 내 의지를 보이려면

생활비 조금이라도 내가 직접 벌어야 된다 생각을 했고 일을 하고 오면 개발을 이어서 할 수 있게 최대한 쉬운 일을 하기로 결정 했다.

 

아르바이트 기간은 3월 말까지로 우선 하기로 했다.

3월 말 아니면 4월 까지도 프로젝트 출시나 좋은 반응이 없으면

다른 직종을 찾아서 빠르게 자기 자리를 잡아야 할 것 같다.


최근에 알바하다가 여유있을때 C# 기초 참고서를 읽으며 복기하는데 문득 이런 생각이 들어서

생각을 정리할겸 적어봤다. 

 

별로 좋지 않은 감정이였지만, 또 한편은 후련하다.. 이런 감정을 표현할 곳이 제대로 없으니

 

1) 개발자로 취업 성공하기

=> 어느곳이라도 좋다 날 써줄수 있는 곳이라면...

2) 인디게임 출시하여 제대로 된 경험 쌓기

=> 출시하여 반응이 좋지 않아도 좋다... 이 경험이 큰 경험이 되고 취준에 큰 도움이 될거라고 믿는다.

3) 준비한 것을 정리하고 다른 직종으로 자리 잡기

 

이 세가지 유형을 내년에는 반드시 하나는 결정이 되야한다..

 

급하게 마무리하다 보니 그냥 내 강점 쓰레기통 글이 되서 좀 보기 안좋다.

그래도 지나가다 이 글을 보신 분들도 포기하지 말고 할 수 있는데까지 도전하시고 잘 풀리셨으면 좋겠다.

 

 

 

참고한 링크글

 

Heap

더보기

Heap : 여러 개의 값 중 최소값 또는 최대값을 빠르게 탐색하기 위한 완전이진트리 구조를 가진 자료구조

 

Heap의 특징

1.완전이진트리 형태

2.부모노드와 자식노드가 대소 관계가 성립된다.(반정렬상태)

3.이진탐색트리(BST)와 달리 중복된 값이 허용된다.

 

*완전이진트리 : 마지막 레벨을 제외한 모든 레벨이 모두 채워져 있고 마지막 레벨은 왼쪽부터 채워지는 트리

 

 

Heap의 종류
1.Min-Heap(최소힙)

부모노드의 키 값이 자식 노드보다 작은 구조

2.Max-Heap(최대힙)

부모노드의 키 값이 자식 노드보다 큰 구조

[중요] 종류에 상관없이 부모노드에 위치한 데이터가 가장 높은 우선순위를 가진다.

 

데이터의 삽입 

Min-Heap의 경우

1. 3의 우선순위를 가진 원소를 제일 마지막레벨에 삽입후, 대소 비교후 부모의 우선순위 값이 더 크기에 Swap 실행

2. 부모가 존재할경우 한번더 대소 비교 진행 , 부모의 우선순위 값이 더 크기에 한번더 Swap

3.  부모가 존재하기에 한번더 대소비교 진행 , 자식의 우선순위 값이 크기 때문에 루프 종료

 

?? : Min-Heap에서 우선순위가 9,10인 노드보다 8이 더 아래에 위치 해있는데요?

Heap 자료 구조는 "정렬된 트리" 아니라 "구조와 조건"을 만족하는 트리이기 때문 => 기본 구조가 완전이진트리

 

데이터의 삭제 

Min-Heap의 경우

1. 최상위 부모노드(루트노드) 삭제 , 레벨이 제일 낮은 말단의 노드를 옮김 , 그후 자식 노드들과 대소 비교 진행

8 과 3 비교시 8이 우선순위 값이 더 크기에 노드 교체 진행

2. 자식노드가 존재하는 경우 한번더 대소 비교 진행 , 값이 크기에 한번더 교체 진행

( 그림은 6으로 되어있지만 4와 교체가 되어야한다 )

 

Heap의 삭제에서는 이진 트리이기에 부모노드에서 대소 비교를 2번 진행해 줘야한다. 

자식 노드중 더 우선순위 값이 작은 값을 찾아 해당 노드와 비교를 진행한다.

(1)번에서도 3 과 5가 있으나 3이 우선순위가 더 작기에 3과 데이터 스왑이 진행된다. 

 

 

 

우선순위 큐(Priority Queue)

개요

큐에 있는 원소를 꺼낼때 넣은 순서가 아닌 우선순위가 높은(낮은) 순서로 꺼내지게 된다. 

 

특징

1. Heap 자료구조를 사용하여 효율적으로 구성 (다른 자료구조를 사용해도 됨)

2.우선순위가 같다면 일반적으로 삽입순서에 따라 처리되거나, 다른 기준을 적용 시켜 정렬 시킬 수 있다.

3.각 원소간의 중복을 허용한다.

 

 

해당 자료구조를 직접 구현하는 이유?

1.공부 목적 및 로직 파악

2.사용하는 .NET Framework 버전이 호환 안됨

https://learn.microsoft.com/ko-kr/dotnet/api/system.collections.generic.priorityqueue-2?view=net-8.0

 

PriorityQueue<TElement,TPriority> 클래스 (System.Collections.Generic)

값과 우선 순위가 있는 항목의 컬렉션을 나타냅니다. 큐에서 우선 순위가 가장 낮은 항목이 제거됩니다.

learn.microsoft.com

해당 링크를 참고하면 

(좌) 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;
      }

  }

 

 

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

 

Unity 클라이언트 개발 인턴십 회고

회사명: (주)아이디어 노트
기간: 1개월 (팀 스파르티 인턴십 프로그램)
직무: Unity 클라이언트 개발자 인턴
업무: 클라이언트와 서버 통신이 되는 환경에서 클라이언트 기능 구현


1. 인턴십 개요

  • 팀 스파르티의 지원으로 (주)아이디어 노트에서 Unity 클라이언트 개발자로 1개월간 인턴십 수행.
  • 서버-클라이언트간 프로토타입 기능 개발을 담당.
  • 기획서를 기반으로, 사수님이 작성한 기존 코드 위에서 새로운 기능을 개발.
  • 프로젝트 전반을 이해하고 협업하며 실무 개발 프로세스를 경험.

2. 주요 업무 경험

  • 서버와 클라이언트 간의 통신 기능 구현.
  • 기존 코드 흐름을 분석하고, 사수님의 피드백을 기반으로 기능 수정.
  • 기획서를 기반으로 UI 및 기능 구현.
  • Mermaid AI를 활용한 기능 플로우 차트 작성.

3. 인턴십을 통해 배운 점

3.1 코드 흐름 이해의 중요성

  • 기능만 동작하는 것이 아닌, 언제 호출되어야 하는지, 어떤 흐름 위에 있어야 하는지 이해하는 것이 중요함.
  • 기능이 작동한다고 해서 그것이 올바른 구조와 위치라는 보장은 없음.
  • 사수님의 조언에 따라, 포스트잇이나 Mermaid AI를 활용해 플로우 다이어그램을 그리는 습관을 기름.

3.2 모르는 것을 두려워하지 말고 적극적으로 소통하기

  • 일정이 촉박한 실무 환경에서는 혼자 끙끙대는 것보다 빠르게 질문하고 소통하는 것이 효율적임.
  • 모르는 것을 최소한으로 고민하고, 해결이 되지 않으면 즉시 팀원과 소통하여 일정 내 해결하는 것이 중요함.

3.3 기획서를 기준으로 개발하기

  • 개발 도중에도 기획서를 반복해서 확인하거나, 기획자에게 직접 질문하는 습관이 필요함.
  • 기획서와 실제 구현 사이의 불일치로 인해 발생하는 시간 낭비를 줄일 수 있음.

3.4 AI 및 개발 도구 적극 활용

  • AI(ChatGPT) 및 개발 도구를 적절히 활용하면, 작업 속도는 빨라지고 결과물의 품질도 향상됨.
  • 팀원들이 항상 여유가 있는 것은 아니기 때문에 도구를 통한 자가 해결 능력은 중요함.

4. 추천받은 학습 키워드 및 도구

분야키워드/도구설명
서버 UGS Unity Gaming Services - 유니티에서 제공하는 서버 기능
서버 RPC, Firebase, AWS, GCP 서버 및 클라우드 서비스 관련 용어
네트워크 OSI 7계층, TCP Segment 네트워크 기본 개념
알고리즘 잡 큐 시스템 하스스톤처럼 턴제 로직이 필요한 게임에 사용
동시성 Concurrency, 멀티스레딩 비동기 처리 및 스레드 관련 개념
성능 멀티코어 프로세싱 여러 CPU 코어를 사용하는 방식
데이터 처리 마셜링 서로 다른 시스템 간의 데이터 변환 방식
일정 관리 WBS, Slack, Confluence, Trello 협업 및 일정 관리 도구
코드 분석 DotPeek .NET 코드 디컴파일러 (하스스톤 코드 참고 가능)
 

5. 개인적으로 느낀 개선이 필요한 점

5.1 생각 정리 및 코드 흐름 이해 부족

  • 전체 플로우에 대한 인식 없이 일부분만 보고 기능을 구현함.
  • 팀 내 코드 컨벤션 및 정책을 제대로 따르지 않아 협업에서 마찰이 발생함.

5.2 C# 기본 개념 부족

  • 기본 문법이나 키워드에 대한 개념이 모호하여, 사수님의 설명을 즉시 이해하지 못하는 경우가 있었음.
  • 단순히 머릿속에 알고 있는 것이 아닌, 정확한 개념의 언어화가 필요함.

6. 향후 계획 및 다짐

  • C# 문법 및 기본 이론 학습에 집중하여, 용어에 대한 정확한 이해를 기반으로 대화와 구현 능력 향상.
  • 다양한 프로젝트 코드 분석 (Unity Learn, Asset Store 등)을 통해 구조적 사고 방식 습득.
  • 서버 개발자가 아니더라도, 기본적인 서버/네트워크 이론은 숙지할 예정.
  • Mermaid 등의 시각화 도구를 활용해 코드 플로우 시각화 능력을 꾸준히 훈련할 것.

 

해당 내용 정리하기 전의 글 원문

더보기

팀 스파르티의 지원을 받아 한달동안 인턴을 할 수 있는 시스템이 있어 

 

(주)아이디어 노트 Unity 클라이언트 개발자 인턴을 하였다.

 

회사에서는 클라<>서버를 연결하는 프로토타입의 기능 구현 업무를 맡았다.

 

기획서를 보고 판단하여 사수님이 작성하신 코드 위에서 기능들을 개발하는 작업을 하였다.

 

개발에서 잊지 않도록 인턴 생활을 하며 느끼고 배운점을 작성하려고 한다.


0.생각 정리 연습 부족 및 코드의 정책에 따르지 않음

내가 기능 개발을 할 때, 해당 코드의 전체적인 플로우를 이해한 줄 알았다.

하지만 사수님이 해당 기능을 추가하거나 수정이 필요하다고 말하면 나는 해당 과정을 이해하는데 너무 오래 걸렸고

심하면 왜 이 기능이 이 호출시점에 추가되야되는지 이해를 하지 못했다.

(왜냐면 이 호출시점에 없어도 내가 개발한 기능은 구현이 되기 때문)

 

이것이 반복되니 협업에서 마찰이 생기기 시작하였다.

그리고 사수님이 "ㅇㅇ씨, 해당 프로젝트의 전체 플로우 워크를 한번 그려보세요 "라고 지시를 받았고

플로우를 그려보려니 전체 플로우의 초기부분 마저 제대로 그리지 못하였다. 

 

그렇다. 나는 기능 개발을 하기위해 한 부분만 보고 "해당 기능이 이 호출시점에 추가되면 되겠구나"

이렇게 생각하고 위험하게 개발을 하고 있었던것이다. 

 

내가 A ~ Z 까지 다 작성한 코드면 내가 언제든지 작성하고 수정하면된다.

하지만 회사에서의 프로젝트 코드는 회사 사람들이 다같이 참여하며 첨삭한 코드이다.

 

팀 그라운드룰 , 코드컨벤션 및  정책을 짜는 이유는 해당 코드의 일관성을 지키기 위해서다.

하지만 내 방식은 그것을 완전 위반하는 개발 방식이였다.

 

사수님은 " ㅇㅇ씨는 역기회으로 플로우를 많이 그려보세요 , 코드 분석하실때 해당 플로우들을 포스트잇으로 하나하나 적고 그것을 순서대로 나열하는 연습을 하는게 좋을것같아요" 라고 내 생각을 정리하는 방법을 추천해주셨고 

 

https://www.mermaidchart.com/mermaid-ai

 

Mermaid Chart

A smarter way of creating diagrams.

www.mermaidchart.com

다이어그램 플로우차트를 그리는 웹 <머메이드 AI>를 추천받았고 실제로 해당 프로젝트에서 서버 <> 클라 통신을 하기에 

남은 시간에 해당 프로젝트의 플로우를 그리는 연습을 하였고 실제로 코드의 플로우가 생각이 정리가 되었다.

 

1. 모르는 것을 두려워 하지마라 + 소통하는것을 지향하자

개발자라면 코드 분석 및 기능 개발시 어떤 방법(자료구조,알고리즘등등..)을 이용해서 분석 및 구현할지 고민을 하게 되는경우가 정말 많다. 

 

하지만 현업 에서는 결국 데드라인이 존재하고, 해당 일정까지 완료를 해야된다.

나 또한 작업을 하면서 해당 코드에 대해 시간을 써서 코드를 더 분석하게 된 경우가 있었다.

그 날 결국 야근을 하게 되었고, 사수님의 작업에도 결국에 악영향을 끼쳤다.

 

사수님은 "생각하는 것은 개발자 소양이지만 이 생각이 일정에 문제가 생기면 안된다. 

만약 모르는 것이 있거나 해당 방법이 효율적인지 알고 싶으면 다른 팀원에게 한번 소통을 해봐라" 라고 제안해주셨다.

 

이후에 실제로 기능 개발중에 애매한 것이 있어 팀원에게 스몰토크 하는겸 말 걸었고 해당 정보에 대해 공유를 하자

바로 좋은 생각이 떠올라 해당 기능에 코드를 추가 하였던 경험이 있었다.

 

이 경험으로 모르는 것이 있으면 최소한으로 생각하고 그래도 결과가 나오지 않으면 해당 문제에 대해서 소통 및 질문을 통해서 빨리 해결하는것이 일정내에 프로젝트를 진행하는 실마리라는것을 알았다.

 

2. 개발을 하면서 기획서를 상시로 확인하거나 기획자에게 질문을 많이 하자

보통 포트폴리오 제작 및 팀/개인 작업 진행시 프로젝트에서 기획서를 내가 또는 팀원이 즉시 수정하고 작업하였다.

하지만 현업에서는 [기획자]라는 포지션이 존재 하였고 프로그래머는 기획서에 의해 해당 기능을 개발 및 구현을 한다.

 

내가 맡은 기능을 개발 중 기획서의 플로우만 확인하고 작업 하였고 , 사수님이 확인을 하시는 중

UI가 이렇게 동작하는 것이 맞냐고 물어보자 , 나는 맞다고 확신하였다.

 

하지만 기획서를 확인해보니 완전 다르게 구현을 하게 된것이다. 

내가 구현하면서 한번만이라고 기획서를 다시 보거나 기확자님에게 다시 물어봤으면 이렇게 시간을 오래 잡을 일이

아니였던것이었다.

 

3.작업시 AI 및 도구의 지원을 받을 수 있으면 적극적으로 활용하자

위에 말한 1번 2번 질문에는 결국 팀원과의 소통이 베이스로 깔려있다.

하지만 프로젝트 라는 것이 여유있는 사람은 없고 다들 바쁘기에 질문에 답 해주기 매우 곤란할 것이다. 

 

해당 문제를 극복하기 위해서는 AI(Chat Gpt)를 적극적으로 활용해야 된다.

회사 보안의 선을 잘 지키며 적극적으로 활용해서 작업시간을 줄이고 결과물은 퀄리티를 올려야된다.

 


 

또한 인턴생활을 하며 해당 키워드에 대해 공부를하면 개발시에 도움이 많이 될것이라고 알려주셨다.

 

1.UGS : 유니티에서 제공하는 서버

2.RPC , FireBase , AWS ,GCP  : 서버(네트워크) 용어

3.OSI 7계층, 전송 계층 , ICP Segment : 서버 용어

3.잡 큐 시스템 : 하스스톤과 같이 턴제 게임에서 사용되는 알고리즘

4.동시성( Concurrency ) -> 멀티스레딩

5.멀티코어 프로세싱

6.마셜링

7. WBS (Work Breakdown Structure)  -> 일정 조율 웹 페이지 :  slack , 컨플루언스 , 트렐로

9.DOT Peek : 해당 프로그램을 통해 하스스톤의 소스코드를 확인할 수 있다.

 

인턴 하면서 더욱 많은 것을 배웠지만 우선 메모 해놓은 것은 이정도가 된것같다. 

 


이 밑에 내용은 인턴 생활을 하며 개인적으로 느끼고 이 부분을 빨리 고쳐나가야된다고 생각한 점을 정리했다.

 

C# 기본 문법 및 코드 작성시 단어의 개념이 내가 많이 부족하다고 판단 했다.

사수님이 말하는 것을 듣고 바로 파악이 되지 않은 경우가 있었다.

이건 내가 머릿속으로는 아는데 다른 사람의 말을 듣고 해당 단어의 개념이 제대로 안박혀있기에 일어난 문제였다.

사수님도 인턴이 끝나면 코드 개발보다 이론을 공부하여 개념을 확립하는게 더 좋아보인다고 추천해주셨다.

또한 다른 프로젝트의 코드를 많이 모사하는 것을 권장하였다(Unity Learn 또는 AssetStore의 Asset 분석)

 

회사에서는 프로토타입에 서버를 연결하여 테스트하기를 원하였고

개인적으로 내가 서버에 대해서는 winSock으로 winAPI를 이용하여 만듬 체스게임 정도만 간단하게 서버를 구현 해봤고

그 이후로 서버를 따로 공부하지 않았는데 이번 기회에 서버에 대한 지식이 많이 부족하단것을 알았고 서버개발자가 되는것은 아니지만 서버에 대한 이론은 익혀야 된다고 판단하였다.

'잡담' 카테고리의 다른 글

[일상] 주저리 주저리  (0) 2025.12.06

결과

 

A* 알고리즘 구현을 해보기 위해 준비하다가 , 마우스 클릭으로 쉽게 장애물을 만들고 장애물 경로를 피해 찾아갔으면 좋겠다 싶어서 마우스 클릭으로 장애물을 만드는 기능을 구현했다.

 

RayCast를 활용하여 구현 하였고 해당 기능이 있으면 Play를 하여 내가 원하는 경로를 직접 만들어 줄 수 있는것이 장점이다.

 

 

코드는 생각나는데로 하드 코딩하여 썩 좋은 코드는 아니다.

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

public class GridMapGenerator : MonoBehaviour
{
    [SerializeField] private GameObject obstacle;
    [SerializeField] private GameObject selectAreaPrefab;

    public LayerMask Selectlayer;
    private GameObject selectArea;

    float delayClick = 0.1f;
    float curClickTimer;

    private void Awake()
    {
        selectArea = Instantiate(selectAreaPrefab);
    }

    private void Update()
    {
        curClickTimer += Time.deltaTime;

        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);


        if (Physics.Raycast(ray,out RaycastHit hit,1000.0f))
        {
            Debug.DrawLine(ray.origin, hit.point, Color.red);
            
            selectArea.transform.position = new Vector3(Mathf.Round(hit.point.x), 0.1f, Mathf.Round(hit.point.z));

            if (Input.GetMouseButton(0) && curClickTimer > delayClick && LayerMatchCheck(hit.transform.gameObject.layer, Selectlayer))
            {
                curClickTimer = 0;

                GameObject obj = Instantiate(obstacle);
                Vector3 pos = new Vector3(selectArea.transform.position.x, 0, selectArea.transform.position.z);
                obj.transform.position = pos;
            }
        }

       
    }

    private bool LayerMatchCheck(int target , LayerMask layer)
    {
        if(layer == (layer.value | 1 << target ))
        {
            return true;
        }

        return false;
    }
}

https://01149.tistory.com/178

 

Unity :: 셰이더 그래프

셰이더 그래프란?HLSL(High Level Shader Language) 코드 작성 없이 노드 기반으로 셰이더를 만들수 있는 기능입니다. 셰이더 그래프 사용 방법?1. Project 부분에서 해당 경로를 따라가서 셰이더 그래프를

01149.tistory.com


 

1. 포트폴리오 제작 중 

출시 목적으로 스토리 + 카드형 턴제 전투 게임을 개발 중이며 , 현재 대화시스템을 구글 스프레드 시트와 연동하여

테스트 중이다.

해당 작업이 완료되는데로 TIL에 따로 작성할 목적

 

2.내일배움캠프에서 진행되는 바로인턴10기 지원

Unity 클라이언트 프로그래머의 취업문이 좁다보니 , 한달동안 인턴 생활을 하는 제도가 있어

지원서를 작성하여 신청하였다. 지원서에 붙으면 서류 및 과제를 이틀동안 작성하여 제출하여야한다.

작성되어 크니브 스튜디오에 인턴이 되었으면 좋겠다. 

+ Recent posts