Map은 키(key)와 값(value)의 쌍으로 데이터를 저장하는 자료구조이다.

주요 특징은 다음과 같다.

  • 각 요소는 <key, value> 형태로 저장
  • 각 key는 고유하며, 하나의 key에는 하나의 value만 대응
  • 서로 다른 키가 같은 값을 가질 수 있음
  • 저장 순서를 보장하지 않음


Map은 기본적으로 3가지 방식으로 구현된다.

HashMap

HashTable을 기반으로 한 가장 일반적으로 사용되는 Map의 구현체이다.
해싱을 통해 얻은 고유의 해시 값으로 버킷에 저장되며, 해시 충돌이 없는 한 최고의 연산 속도를 가진다.

TreeSet
TreeSet은 이진 트리 형태인 레드 블랙 트리 구조를 기반으로 하는 구현체이다.
데이터 저장 시 키값을 기준으로 오름차순을 기본으로 자동 정렬되어 저장된다.
HashMap에 비해 연산 속도는 느리지만 항상 O(log n)값을 가지므로 특정 상황에선 HashMap보다 연산적으로 이점을 가진다.

LinkedHashMap

LinkedHashMap은 HashMap에서 연결 리스트를 추가한 구현체이다.
따라서 해당 구현체는 순서가 존재한다.
기본적으로 이중 연결(Doubly Linked) 형태로 구현된다.
전체적인 연산은 HashMap보다 조금 느리다.

또한 순서 확인을 위해 유지해야하는 데이터가 가중되기에 메모리 사용량도 그만큼 많다.


Set은 중복을 허용하지 않고 순서가 없는 데이터를 저장하는 자료구조이며, 수학의 집합 개념과 동일하다.

주요 특징은 다음과 같다.

  • 중복을 허용하지 않음
  • 삽입된 순서가 보장되지 않으며, 인덱싱이 불가능


Set을 구현하는 방식은 대표적으로 3가지가 있다.

HashSet

HashSet은 자료구조 HashTable을 기반으로 구조가 형성되어있다.
해싱을 통해 고유의 해시값을 추출하여 버킷에 저장하기 때문에 삽입 및 삭제, 검색에서 빠른 연산을 수행할 수 있다.

하지만 HashTable의 단점인 해쉬 충돌이 일어나면 연산이 가중되어 속도가 느려지며, 또한 데이터 삽입 시 순서가 보장되지 않는다.

TreeSet

TreeSet은 레드 블랙 트리 기반으로 구조가 형성되어 있다.
이진 트리 형태로 데이터가 저장이 되기때문에 HashSet에 비해 속도는 좀 느리지만 모든 기본 연산에서 항상 O(log n)을 보장한다.

또한 모든 요소들은 오름차순을 기본으로 자동으로 정렬되어 저장된다.

LinkedHashSet

LinkedHashSet은 기존 HashSet에서 연결 리스트의 구조를 더한 방식이다.
기본적으로 이중 연결(Doubly Linked) 방식으로 구조를 이루며, 해당 구조로 인해 삽입에 대한 순서가 존재하게 된다.

삽입 및 삭제, 검색면에서 HashSet과 동일한 시간 복잡도를 가지지만 미세하게 느리다.
또한 순서를 유지하는만큼 더 많은 정보가 필요하기 때문에 기존 HashSet에 비해 메모리 사용량이 많은 편이다.


Set은 주로 중복 ID 필터링과 같은 기능 구현을 할 때 많이 사용된다.

큐(Queue)는 선입선출(FIFO) 형태의 선형 자료구조이며, 한쪽 끝에서 삽입 되고, 다른 한쪽 끝에서 삭제되는 구조이다.

각 끝은 앞쪽을 가리키는 포인터 front뒤쪽을 가리키는 포인터 rear를 통해 앞과 뒤를 가리킨다.

큐를 일상생활과 접목해보자면,
가게에서 음식을 먹기 위해 줄을 설때 먼저 온 손님으로부터 차례대로 줄을 서고 가게에 빈 자리가 생기게되면 먼저 온 손님부터 차례차례 받게 될 것이다.

큐는 기본적으로 두 가지 연산 방식으로 리스트를 관리한다

  • Enqueue : 리스트 젤 뒤 쪽 데이터를 삽입한다.
  • Dequeue : 리스트 질 앞 쪽 데이터를 삭제한다.


Enqueue와 Dequeue 연산을 통해 리스트를 관리하기 때문에 중간 위치에 있는 임의의 데이터에 접근 또는 그 위치에 있는 데이터를 삽입 및 삭제하는 부분에 대해 번거로움이 있다.


Queue의 종류

선형 큐 (Linear Queue)

선형 큐는 가장 기본 형태의 큐이다.
데이터가 들어온 순서대로 리스트에 저장과 삭제가 이루어진다.

선형 큐는 데이터가 항상 뒷 공간에 적재되고 앞 쪽 데이터만 삭제되기 때문에 크기가 유한하다는 가정하에 앞쪽 빈 공간이 생기면서 저장할 수 있는 크기가 점점 줄어든다.

원형 큐 (Circular Queue)

원형 큐는 선형 큐의 단점을 보완한 자료구조이다.

저장소의 앞쪽과 뒤쪽을 연결한 형태이기 때문에 앞쪽 빈 공간을 다시 사용할 수 있게 된다.
즉, 저장 공간에서 front와 rear가 저장 공간 크기를 벗어나면 다시 앞쪽으로 포인터가 이동 되는 것이다.

하지만 이런 원형 구조는 순회시 적절한 조건이 없다면 무한순회로 이어지는 문제가 생기기 때문에 유의해서 설계해야한다.

우선순위 큐 (Priority Queue)

우선순위 큐는 들어온 순서와 상관없이 우선순위에 따라 처리되는 구조이다.

예를 들어 크기가 큰 수가 우선이 되는 경우엔 데이터 삽입 시 내림차순으로 정렬 되어 리스트에 적재되며, 반환 시 우선순위에 따라 처리된다.

덱 (Deque, Double-Ended Queue)

덱은 front와 rear 양쪽 끝에서 모두 삽입과 삭제가 가능한 큐의 변형 자료구조이다.


큐를 이용한 대표적인 예시는 서버 접속 대기열 시스템이 있다.
서버에 수용 가능한 접속자수를 초과하는 경우 접속자를 대기 시킨 후 들어온 순서대로 접속 시킬 수 있도록 하는데 이때 사용되는 구조가 큐이다.

큐는 제약점이 있긴하지만 이러한 점이 기능을 구현하는데에 있어 복잡성을 줄여준다.

'Study' 카테고리의 다른 글

자료구조 - Stack  (0) 2026.09.07
자료구조 - 연결 리스트  (1) 2026.09.04
자료구조 - 배열 (Array)  (0) 2026.09.03
Sync 및 Async와 Blocking 및 Non-blocking  (2) 2024.10.08
Synchronous vs Asynchronous & Blocking vs Non-blocking  (0) 2024.10.07

Stack은 후입선출(LIFO) 형태의 선형 자료구조이며, 한쪽 끝에서만 데이터를 삽입하고 삭제할 수 있는 구조이다.

스택은 말그대로 “쌓는다”를 의미한다.
일상생활에서의 예를 들어 쉽게 이해해보자면,
뷔페에선 음식을 담을 수 있는 접시를 아래에서 차곡차곡 쌓는다. 이후 접시를 꺼낼땐 쌓여진 그릇을 위에서 순차적으로 꺼내서 쓴다.
이와 같은 방식은 스택의 구조와 똑같다.

스택은 기본적으로 두 가지 연산을 통해 리스트를 관리하게 된다.

  • Push(삽입) : 마지막 위치에 데이터를 적재한다.
  • Pop(삭제) : 마지막 위치에 있는 데이터 즉, 마지막에 삽입한 데이터를 삭제한다.


Push와 Pop을 통해 한쪽 끝에서만 리스트를 수정할 수 있어 중간에 있는 데이터에 접근 하거나 삽입 혹은 삭제 작업을 수행하려면 일부 데이터들을 빼고 다시 넣어야하는 번거로움이 발생한다.

위 내용들을 바탕으로 다음과 같이 특징을 정리할 수 있다.

  • 후입선출(LIFO)로 리스트를 관리한다.
  • 중간 데이터에 직접 접근이 불가능하다.


스택을 활용한 대표적인 예시는 함수호출이다.
함수를 정의할 땐 경우에 따라 함수안에 함수가 있는 구조를 띄는 경우가 많다.
이때 중앙처리장치자(CPU)는 마지막에 호출된 함수부터 순차적으로 처리하게 되는데, 이때 사용되는 게 스택이다.

스택은 제약점이 있긴하지만 이러한 점이 기능을 구현하는데에 있어 복잡성을 줄여준다.

'Study' 카테고리의 다른 글

자료구조 - Queue  (0) 2026.09.08
자료구조 - 연결 리스트  (1) 2026.09.04
자료구조 - 배열 (Array)  (0) 2026.09.03
Sync 및 Async와 Blocking 및 Non-blocking  (2) 2024.10.08
Synchronous vs Asynchronous & Blocking vs Non-blocking  (0) 2024.10.07

연결 리스트는 각 요소를 포인터로 연결하여 관리하는 데이터 집합체다.
기본적으로 노드(node)헤드(head)로 이루어져 있고, 경우에 따라 꼬리(tail)도 포함이 된 구조이다.

  • 노드(node) : 데이터와 다른 노드와 연결 짓는 포인터로 구성
  • 헤드(head) : 리스트의 첫 노드를 가리키는 포인터
  • 꼬리(tail) : 리스트의 마지막 노드를 가리키는 포인터


배열이  “1번, 2번, 3번…”처럼 index로 접근한다면, 연결 리스트는 “철수 다음 영희, 영희 다음 민수…“처럼 노드 참조로 순서를 유지한다


연결 리스트 종류 및 구조 형태


연결 리스트의 기본 구조는 포인터를 기점으로 리스트를 순회하는 구조이다.

이때 포인터는 1개 혹은 2개가 될 수 있으며, 각 노드들의 참조 방식에 따라 단방향 혹은 양방향으로 순회가 가능하다.

노드는 두 가지 참조 방식이 존재한다.
다음 노드만을 참조하는 방식과 이전 노드도 포함하여 참조하는 방식이 있다.

노드 간 참조 형식으로 인해 리스트를 수정할 땐 각 데이터의 위치를 수정할 필요 없이 참조하는 노드 정보만 변경해 주면 되기 때문에 노드의 위치를 알고 있다면 성능면으로 이점을 얻을 수 있다.

하지만 인덱스로 접근하는 방식은 헤드 포인터를 기점으로 순차적으로 순회하여 접근하기 때문에 성능면으로 불이점을 얻게 된다.

연결 리스트는 크게 세 분류로 구분된다.

  • 단일 연결 리스트 (Singly Linked List)
  • 이중 연결 리스트 (Doubly Linked List)
  • 원형 연결 리스트 (Circular Linked List)


단일 연결 리스트 (Singly Linked List)

단일 연결 리스트는 연결 리스트의 기본적인 형태라고 보면 된다.

단일 연결 리스트의 형태

위 그림을 참조하여 단일 연결 리스트의 구조적 특징을 살펴보면,
헤드 포인터 하나만을 가지고 순회하는 구조이다.
그림에서 볼 수 있듯이 한 방향으로만 순회하기 때문에 각 노드들은 데이터를 포함하여 다음 노드를 참조하는 형태가 되고, 마지막 노드의 경우 NULL을 참조하여 노드의 끝을 알리고 있다.

정리하면 다음과 같다.

  • 헤드 포인터를 기점으로 모든 노드들을 연결함
  • 각 노드들은 다음 노드만을 참조함
  • 마지막 노드는 NULL을 참조해 끝을 알림
  • 한 방향으로만 데이터를 조회할 수 있음


위 특징들을 바탕으로 삽입과 삭제 과정에 대해 알아보자.

변경전 : [A] -> [C] ( [B]를 삽입 하려는 상황 )
변경후 : [A] -> [B] -> [C]

B를 A와 C사이에 넣는 상황이라면 A 다음 노드 정보를 새로 생성된 B를 대입해 주고 B 다음 노드 정보에 C를 대입해 주면 모든 게 끝난다.
그렇기 때문에 삽입의 시간 복잡도는 O(1)이 된다.

변경전 : [A] -> [B] -> [C] ( [A]를 삭제 하려는 상황 )
변경후 : [B] -> [C]

변경전 : [A] -> [B] -> [C] ( [B]를 삭제 하려는 상황 )
변경후 : [A] -> [C]

삭제의 경우 리스트 첫 번째를 삭제한다면 헤드 포인터에 다음 노드를 대입해주기만 하면 끝나지만,

중간 삭제의 경우 A와 C를 이어 주기 위해 B 외로 A의 정보까지 필요하기 때문에 순회과정이 필요하다.

그렇기 때문에 삭제의 시간 복잡도는 첫 노드시 O(1) 그 외에는 O(n)이 된다.

이중 연결 리스트 (Doubly Linked List)

이중 연결 리스트는 단방향에서 양방향으로 바뀐 형태이다.

이중 연결 리스트의 형태

이중 연결 리스트의 구조적 특징은 헤드 포인터와 꼬리 포인터를 통해 순회하는 구조이다.
그림에서 볼 수 있듯이 양방향으로 순회하기 때문에 각 노드들은 다음 노드뿐만 아니라 이전 노드를 포함하여 참조하게 된다.
또한 리스트의 끝은 양방향 순회로 인해 헤드와 꼬리 모두 끝을 알리는 NULL을 참조하게 된다.

정리하면 다음과 같다.

  • 헤드 포인터와 꼬리 포인터를 기점으로 모든 노드들을 연결함
  • 각 노드들은 다음 및 이전 노드를 참조함
  • 양방향으로 데이터 조회가 가능
  • 헤드와 꼬리 모두 NULL을 참조하여 리스트의 끝을 알림


이중 연결 리스트의 삽입과 삭제는 단일 연결 리스트와 크게 다를거 없지만 삭제 과정이 좀 달라진다.

변경전 : [A] -> [B] -> [C] ( [B]를 삭제 하려는 상황 )
변경후 : [A] -> [C]

단방향의 경우 중간 삭제시 이전 노드의 정보를 얻기 위해 리스트를 순회해야하는 번거로움이 있었지만,
이중 연결 리스트의 경우 양방향 순회가 가능하므로 B 노드만으로 A와 C의 참조 관계를 수정할 수 있어 번거로움이 없어진다.

그로 인해 삭제의 시간 복잡도는 O(1)이 되게 된다.

처리 시간에 대해선 성능적 이점을 얻긴하지만, 그만큼 더 많은 정보를 가져야 되므로 메모리 사용량에 대해선 기존 보다 불이점을 얻게 된다.

원형 연결 리스트 (Circular Linked List)

원형 연결 리스트의 형태

원형 연결 리스트의 구조적 특징은 끝이 존재하지 않는 것이다.
위 그림을 보면 헤드 포인터를 기점으로 순회하며 리스트의 끝에 도달하면 다시 리스트의 첫 위치로 이동하는 것을 확인할 수 있다.
원형 연결 리스트의 구조는 끝이 존재하지 않다는 특징 외로 연결 리스트와 똑같으므로 상황에 맞게 단일 혹은 이중으로 설계하면 된다.

정리하면 다음과 같다.

  • 끝이 없는 순회구조
  • 단일 혹은 이중으로 설계


원형 연결 리스트를 순회할 땐 끝이 존재하지 않아 무한 순회를 하게 될 수 있어 설계시 순회 끝 조건을 잘 설계 해야한다.

'Study' 카테고리의 다른 글

자료구조 - Queue  (0) 2026.09.08
자료구조 - Stack  (0) 2026.09.07
자료구조 - 배열 (Array)  (0) 2026.09.03
Sync 및 Async와 Blocking 및 Non-blocking  (2) 2024.10.08
Synchronous vs Asynchronous & Blocking vs Non-blocking  (0) 2024.10.07

배열은 같은 타입의 변수들로 이루어진 데이터 집합체이며, 각 데이터들은 정해진 순서로 연속 저장되어있는 형태이다.

[10][20][30][40][50]
  0   1   2   3   4

위에 나와있는 데이터 집합에서 대괄호안에 있는 숫자는 저장된 데이터 값이며 배열의 요소(Element)라고 한다.
그 요소들 아래를 보면 번호가 지정되어 있는데 이는 각 데이터들의 위치를 가리키는 인덱스(index)라고 한다.


배열의 특징


배열의 특징은 다음과 같다.

  1. 동일한 데이터 유형을 가진다
  2. 각 요소들은 순차적으로 저장되어 순서가 존재한다
  3. 연속된 메모리에 단일 블록화하여 데이터를 저장한다
  4. 크기는 생성 최초에 정해진다


각 요소들은 데이터 유형이 같고 연속된 메모리에 저장되기 때문에 “주소 = 배열의 시작 주소 + (인덱스 * 요소 크기)”의 수식을 갖게된다.
그로 인해 접근이 용이하다. 예를 한번 들어보면,

메모리 주소
1000  1004  1008  1012
 ↓     ↓     ↓     ↓
[ 10 ][ 20 ][ 30 ][ 40 ]

위와같이 각 요소의 크기는 4, 배열의 시작 주소가 1000이라고 가정했을 때
2번째 요소에 접근하기 위해 0번, 1번 요소를 차례대로 확인할 필요없이 위치를 계산하여 한번에 접근할 수 있다.
즉, 특정 index에 접근할 때는 시간복잡도가 O(1)이 되는 것이다.

접근이 용이하지만 특정 요소를 찾거나 삽입 혹은 삭제할때는 이야기가 달라진다.

[검색]

[15, 42, 7, 91, 23]

위와 같은 배열이 있을 때 숫자 91을 찾는다고 가정해보자.
91이 속해있는 index를 안다면 O(1)의 시간복잡도로 접근이 가능하다.

하지만 index를 알지 못하면 다음과 같은 순회를 통해 찾아야 한다.

15 → 42 → 7 → 91

index가 0인 지점부터 시작해 순차적으로 접근하여 91이 맞는지 확인한다는 것을 알 수 있는데 이로 인해 O(n)의 시간복잡도를 가지게 된다.

[삽입 및 삭제]

삽입과 삭제는 위치가 끝이냐 중간이냐에 따라 다르다.

기존: [10][20][30][  ]
추가: [10][20][30][40]

기존: [10][20][30][40]
삭제: [10][20][30][  ]

끝 위치의 경우엔 논리적인 개수만 증감하는 구조라면 일반적으로 O(1)의 시간복잡도를 가지게 된다.

기존: [10][20][30][40]
삽입: [10][15][20][30][40]

기존: [10][15][20][30][40]
삭제: [10][20][30][40]

하지만 중간 위치의 경우엔 삽입 혹은 삭제 후 순서를 유지하기 위해선 앞이나 뒤 요소를 이동해야 하기때문에 O(n)의 시간복잡도를 가지게 된다.

접근성 다음으로 할 이야기는 메모리 공간이다.
각 데이터들은 단일 블록화하여 저장되기 때문에 메모리 공간에서 이점을 어느정도 가진다.

데이터가 단일화 되어있지 않고 흩뿌려져 있을 경우 데이터 사이사이 빈 공간이 발생하게 되고, 이러한 빈 공간을 제대로 활용하지 못해 메모리를 사용하지 못하는 손실이 발생할 수 있다.

하지만 단일 블록화 되어있더라도 실제 사용하는 크기보다 사용하지 않는 크기가 많다면 그 또한 메모리 손실이 발생하게 된다.
그러므로 초기 배열 사이즈를 결정할 때 적절한 설계가 필요하다.

오늘은 C프로젝트에서 Shader를 이용하여 오브젝트가 물 background 위에 있을 때 잠겨있는 듯한 모습을 연출하려고 한다.

 

유니티에서 Shader를 만드는 방법은 크게 두 가지 방식이 있다.

첫 번째론 셰이더 언어를 이용하여 직접 코드를 작성하여 만드는 방식이고,

두 번째 방법으론 유니티에서 제공하는 비주얼 툴인 Shader Graph를 이용하여 만드는 방식이다.

나는 두 가지 방법 중 후자를 선택하여 만들어보려고 한다.

 

시작에 앞서 나는 유니티 프로젝트를 진행하면서 한 번도 셰이더를 적용하여 결과물을 만들어본 적이 없다...

그렇다보니 막히는 부분이 너무 많았다... ㅠ

그렇기 때문에 먼저 깊게까진 아니더라도 Rendering Pipeline에 대해서 공부를 해보았다.

그 이후 한 예제를 들어 셰이더를 만드는 방법에 대한 유튜브 영상도 참고해 보았다.

 

아래의 개발 과정은 셰이더에 대한 기본적인 지식이 있다는 가정하에 설명이 되므로

혹시 보는 과정 중 설명이 부족한 부분이 있다면 직접 찾아서 궁금증을 해결하자!

 

이제 본격적으로 직접 셰이더 그래프를 이용하여 만들어 볼 시간이다!

그러기 위해선 먼저 셰이더 그래프를 먼저 생성해 준다.

셰이더 그래프를 만드는 종류는 여러 가지가 있는데 이 중 현재 필요한 형태의 셰이더 그래프를 생성해 주면 된다.

기본적으로 이 프로젝트는 지금 당장은 아니지만 URP를 적용하여

Lighting에 대한 처리를 하기 위해 "Sprite Lit Shader Graph"로 생성했다.

 

우선 아이디어가 필요했다.

어떤 방식을 이용하여 물에 잠겨있는 듯한 연출을 표현할지...

처음 든 생각은 float형으로 depth 필드를 만든 이후 depth의 크기만큼 texture 일부분의 투명도는 0으로 해주었다.

우선 Texture의 특정 y값을 기준으로 아랫부분은 투명도를 0으로 해주기 위해 UV 좌표에서 y를 받아왔다.

적용할 머테리얼에서도 특정 y값을 조절할 수 있게 하기 위해

float형으로 _Depth라는 프로퍼티를 만들어 해당 값과 y를 빼주었다.

이후 step 노드를 통해 예쁘게 다듬어준다.

 

이렇게 나온 결과와 Main Texture의 알파값을 곱해준다면 특정 y값을 기준으로 아랫부분은 투명도가 0이 될 것이고 윗부분은 정상적으로 렌더링 되는 결과가 나올 것이다.

완성된 그래프를 저장한 이후 새로운 머테리얼을 만들어 방금 전에 만들어준 셰이더 그래프를 드래그 앤 드롭하여 적용시켜 준다.

만들어진 머테리얼을 오브젝트에 적용하여 결과를 확인하면 위와 같이 잘 적용되는 모습을 볼 수 있다.

하지만 물 위에 있는 듯한 느낌이 들긴 하지만 약간 어색함이 남아있다. (배경은 아직 없어 임시로 적용...)

 

아무래도 이 어색함은 잠겨있는 부분이 잔상처럼 보이지 않기 때문에 어색해 보이는 느낌이 드는 것 같아

texture의 일부 투명도를 0으로 줄였지만 조금 올려 뒤에 있는 물 배경과 형체가 자연스럽게 보이도록 수정해 보았다.

기존 그래프에서 일부를 추가하여 수정해 주었다.

우선 아랫부분과 윗부분을 나누어 아랫부분만 투명도를 조절할 수 있도록 만들어주었고

두 결과 값을 더하여 Main Texture의 알파값과 곱해주면 된다.

완성된 그래프를 저장한 뒤 다시 오브젝트를 확인해 보면 잘 적용된 모습을 볼 수 있다.

확실히 이전 방식보단 자연스러운 느낌이 남아있다.

지금 적용한 방식도 동적인 느낌이 안나다 보니 어색한 느낌이 여전히 남아있는 거 같다.

하지만 연출적인 부분에서 완전히 정해진 부분이 없어 정해지는 대로 더 나은 방식으로 자연스럽게 적용시켜보자 한다.

 

현재는 이 방식까지 밖에 생각 못했다 보니 혹시 다른 좋은 의견이 있으시다면 댓글 남겨주시면 감사하겠습니다!

지난번 C프로젝트 관련 게시글을 작성할 때 Scriptable Object를 이용하여 상태 패턴을 만드는 글을 작성해 보았었다.

https://lms0408.tistory.com/14

 

유니티 프로젝트 개발 일지 - 1 (상태 패턴)

오늘은 프로젝트 C에서 오브젝트의 상태 패턴을 구현해 볼 것이다. 이전 개인 프로젝트에서 상태 패턴을 구현할 땐,Player와 Montser 오브젝트가 있을 때 각기 다른 상태를 정의하고 같은 오브젝트

lms0408.tistory.com

오늘은 오브젝트의 상태가 있는 만큼 상태에 따른 애니메이션을 적용하고자 한다.

 

이 프로젝트에서는 오브젝트가 8방향을 가지고 있기 때문에 Animator에서 모든 클립들을 들고 와 상태 로직을 만들다 보면 지저분해 보일뿐더러 관리하기가 매우 힘들 것이다.

그렇기 때문에 오늘은 유니티에서 제공하는 블랜드 트리(Blend Tree)를 이용해 구현해보고자 한다.

 

단순히 애니메이션 클립에 관리가 힘든 부분이라면 Sub-State Machine을 만들어 해결할 수도 있다.

하지만 이러한 장점 이외에도 애니메이션을 매끄럽게 전환하는 데에도 이점이 있기 때문에

블랜드 트리를 이용하여 구현해보고자 한다.

 

유니티 Animator에 있는 블랜드 트리(Blend Tree)란?

다양한 애니메이션 클립을 하나의 상태로 묶어 특정 입력 값에 따라 혼합하여 재생하는 구조이다.

주로 3D 애니메이션을 표현할 때 이용하는 방식이지만

앞서 장점을 말했다시피 코드를 작성하지 않더라도 애니메이션을 매끄럽게 전환할 수 있다는 이점으로 인해 해당 방식을 이용하는 것이다.

 

블랜드 트리는 애니메이터에서 마우스 우클릭 시에 나오는 태그들 중 "Create State" 태그 내에서 새롭게 생성할 수 있다.

생성된 블랜드 트리를 더블 클릭하여 내부로 들어가면 기본 블랜드 트리가 만들어져 있을 것이다.

해당 블랜드 트리를 클릭하여 Inspector창을 확인해 보면 위와 같이 되어있다.

블랜드 트리에 이름은 자유롭게 수정이 가능하다.

Blend Type은 쉽게 말하자면 파라미터의 개수를 결정짓는다.

8방향을 표현하기 위해선 x, y 두 파라미터가 필요하므로 2D Simple Directional을 지정해 주면 된다.

 

이후 파라미터를 보면 두 개의 파라미터를 대입할 수 있고 기본값으로 Blend가 있을 것이다.

애니메이터에 float형 파라미터가 따로 생성되어 있지 않다면 블랜드 트리를 생성할 때 자동으로 기본 파라미터를 생성하여 적용되어 있게 된다.

 

먼저 x와 y의 값을 전달받기 위해 float형 파라미터 두 개를 생성하여 아래와 같이 지정해 주자.

이후 Motion 리스트를 추가해주면 새로운 모션을 추가할 것 인지,

새로운 블랜드 트리를 추가할 것 인지에 대한 선택이 나오며

여기선 "Add Motion Field"를 선택하여 새로운 모션을 추가해 주면 된다.

선택하여 만들어진 모션을 보면 재생할 애니메이션 클립을 넣을 수 있는 필드와

x, y의 값을 넣을 수 있는 필드에 값을 삽입할 수 있게 된다.

기본적으로 0을 기준으로 x가 양수일 땐 오른쪽, 음수일 땐 왼쪽

y가 양수일 땐 위쪽, 음수일 땐 아래쪽을 바라보는 애니메이션 클립을 재생시켜 줄 수 있도록 하고

대각선도 마찬가지로 위의 기준을 통해 값들을 삽입해 주면 완성이 된다.

모션을 추가할 때마다 파란색 포인트가 추가되는데 이 포인트는 모션의 방향을 나타내는 것이다.

그리고 파란색 포인트 외로 빨간색 포인트도 있는데

빨간색 포인트현재 파라미터 값에 따라 나타내고 있는 방향을 알려준다.

위의 사진의 경우는 현재 x와 y가 -1 값이 대입되어 왼쪽 아래의 모션을 나타내주고 있는 것이다.

 

이렇게 완성된 결과를 바탕으로 별도의 코드 없이 파라미터 값만을 수정하여

방향에 따른 애니메이션 전환이 잘 이루어지는 확인해 보면 된다.

이렇게 확인해보면 잘 적용된 것을 볼 수 있다.

적용해 보면서 제일 크게 느낀 장점은 별도의 조건과 연산 필요 없이

자동으로 자연스럽게 애니메이션을 전환해 주는 것이 정말 마음에 들었다.


참고 문서 : https://docs.unity3d.com/kr/560/Manual/class-BlendTree.html

현재 프로젝트 B에선 프로토타입을 위한 전투씬 부분의 개발을 진행하고 있는 상태이다.

전투는 다대다로 이루어지며 전투 인원이 정해져 있는 상태가 아니다.

 

전투를 진행하는 오브젝트들은 필수적으로 초기화를 위한 초기 정보가 필요하며,

해당 정보는 각 오브젝트에 적용하려 했으나 초기화 순서를 정형화하기 위해서이기도 하고

다른 이유들로 인해 한 곳에 모아둔 상태이다.

 

만약 각 오브젝트에 적용이 되어있다면 오브젝트를 생성한 이후 바로 값을 대입하고

필요없다면 오브젝트 자체를 삭제하면 되지만,

현재는 직렬화되어 있는 정보 리스트에 요소를 추가하거나 삭제를 한 이후

별도로 오브젝트들을 추가해주어야 하는 번거로움이 발생한 상태이다...

 

그래서 이번 기회에 커스텀 에디터를 통해 정보 리스트를 추가 삭제함에 따라 자동으로 오브젝트가 생성되고 삭제되는

에디터 코드를 작성해보고자 한다.

에디터 관련 코딩은 접해본 적이 없다보니 먼저 웹 검색을 통해 몇 가지를 알아보았다.

 

우선 Inspector에서 직렬화된 정보 리스트의 편집 여부를 어떻게 판단할 것 인가에 대해서 찾아보았고

SerializedProperty라는 클래스를 통해 직렬화된 정보를 받아올 수 있었다는 것을 알게 되었다.

[CustomEditor(typeof(BattleGameInitializer))]
public class CharacterCreater : Editor
{
    private SerializedProperty pInfoList;
    private SerializedProperty eInfoList;
    
    private void OnEnable()
    {
        pInfoList = serializedObject.FindProperty("playerInfos");
        eInfoList = serializedObject.FindProperty("enemyInfos");
    }
}

[CustomEditor(typeof(...)]의 선언은 특정 스크립트 타입의 Inspector를 커스터마이징 하기 위한 속성이다.

그다음 직렬화된 속성을 저장할 필드 변수를 선언해 주었다.

나는 Player와 Enemy의 정보 리스트를 따로 관리해주고 있기 때문에 두 개의 필드 변수를 선언해 주었다.

 

이후 OnEnable 메서드에서 스크립트에 정의된 필드 변수 이름으로 직렬화된 속성을 찾아서 저장해 주면 된다.

여기서 OnEnable은 MonoBehaviour에서 정의할 수 있는 OnEnable과 같은 것이다.

해당 메서드는 Inspector뷰에서 보일 때 호출된다.

 

SerializedProperty에는 다양한 프로퍼티를 통해 정보를 받아올 수 있다.

많은 프로퍼티중 나는 배열 크기만을 이용해 기존 리스트의 크기가 변경되었다는 조건식을 만들고,

리스트의 데이터가 추가되거나 삭제되었을 때 오브젝트의 인스턴스를 생성하고 삭제하는 메서드를 작성해 보자.

[CustomEditor(typeof(BattleGameInitializer))]
public class CharacterCreater : Editor
{
    private SerializedProperty pInfoList;
    private SerializedProperty eInfoList;
    
    private int prevPSize;
    private int prevESize;
    
    private GameObject playerCharPref;
    private GameObject enemyCharPref;
    
    private void OnEnable()
    {
        playerCharPref = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Prefabs/Character/PC.prefab");
        enemyCharPref = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Prefabs/Character/Enemy.prefab");

        pInfoList = serializedObject.FindProperty("playerInfos");
        eInfoList = serializedObject.FindProperty("enemyInfos");

        prevPSize = pInfoList.arraySize;
        prevESize = eInfoList.arraySize;
    }
    
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        if (pInfoList.arraySize != prevPSize)
        {
            ModifyBattleCharInstance(pInfoList.arraySize > prevPSize);
            prevPSize = pInfoList.arraySize;
        }

        if (eInfoList.arraySize != prevESize)
        {
            ModifyBattleCharInstance(eInfoList.arraySize > prevESize, false);
            prevESize = eInfoList.arraySize;
        }
    }

    private void ModifyBattleCharInstance(bool set, bool isPlayer = true)
    {
        var _basedName = isPlayer ? "PC" : "Enemy";
        var _charParent = GameObject.Find($"{_basedName}List").transform;
    
        // 인스턴스 추가의 경우
        if (set)
        {
            Instantiate(isPlayer ? playerCharPref : enemyCharPref, _charParent).name = $"{_basedName}{_charParent.childCount}";
            return;
        }

        // 삭제의 경우
        Destroy(_charParent.GetChild(_charParent.childCount - 1).gameObject);
    }
}

우선 이전 배열의 크기를 저장할 수 있는 필드 변수들과

오브젝트 생성을 위해 필요한 프리팹을 저장할 필드 변수들을 선언해주었다.

 

해당 필드 변수들은 이전과 마찬가지로 OnEnable 메서드에서 대입해 주었다.

이전 배열 크기값은 현재 유지하고 있는 SerializedProperty의 현재 배열 크기를 대입해 주었고,

프리팹은 프리팹이 저장되어 있는 경로를 통해 반환받은 값을 대입해 주었다.

 

그리고 조건식에 만족했을 때 실행할 메서드를 정의해 주었다.

bool형 매개 변수 "set"은 데이터 추가인지 삭제인지를 나타내주고, "isPlayer"는 Player의 여부를 나타내준다.

인스턴스를 생성할 경우 특정 부모 Transform 하위에 생성되도록 지정해 주었고,

인스턴스를 삭제할 경우 부모 Transform에서 젤 하위에 있는 인스턴스를 삭제해 주도록 코드를 작성했다.

 

삭제를 할 땐 "Destroy"가 아닌 "DestroyImmediate"를 호출해주어야 한다.

Destroy의 경우 바로 삭제가 아닌 삭제 예약 즉, 한 프레임 이후 삭제를 시키는 메서드이고

DestroyImmediate바로 삭제를 해주는 메서드이다.

Editor 편집에서는 삭제를 예약해 주는 것이 아닌 바로 삭제를 해주어야 하기 때문에 DestroyImmediate를 호출해 주는 것이다.

 

조건식은 OnInspectorGUI 메서드에서 작성해 준다.

OnInspectorGUI는 Unity Editor에서 Insepctor 창이 새로고침 될 때 호출이 되고, 주로 GUI를 그려줄 때 정의하는 메서드이다.

 

조건식 역시 이전 코드들과 마찬가지로 간단하다.

이전 배열 사이즈와 현재 배열 사이즈를 비교하여 다르다면 정의해 준 메서드를 호출해 주면 의도한 대로 인스턴스가 생성되고 삭제될 것이다.

이렇게 잘 생성되고 삭제되는 것을 확인할 수 있다!

하지만 현재 오브젝트가 프리팹 오브젝트가 아닌 개별 오브젝트로 생성되기 때문에

현재 생성된 오브젝트들은 프리팹 수정에 영향을 받지 않는다.

그렇기 때문에 리팹 오브젝트로 생성하도록 코드를 수정해 주면 된다!

//Instantiate(isPlayer ? playerCharPref : enemyCharPref, _charParent).name = $"{_basedName}{_charParent.childCount}";
PrefabUtility.InstantiatePrefab(isPlayer ? playerCharPref : enemyCharPref, _charParent).name = $"{_basedName}{_charParent.childCount}";

수정 후 테스트 해보면 위와 같이 프리팹 오브젝트로 잘 생성되고 삭제되는 것을 알 수 있다.

이 글에서 나오는 방식보다 더 좋은 방식이 있다면 댓글로 추천 부탁드립니다 :)

오늘은 프로젝트 C에서 자동 공격을 하는 오브젝트를 만들어보려고 한다.

 

해당 오브젝트는 기본적으로 이전 포스팅을 통해 작업한 상태 패턴을 기반으로 동작한다.

https://lms0408.tistory.com/14

 

유니티 프로젝트 개발 일지 - 1 (상태 패턴)

오늘은 프로젝트 C에서 오브젝트의 상태 패턴을 구현해 볼 것이다. 이전 개인 프로젝트에서 상태 패턴을 구현할 땐,Player와 Montser 오브젝트가 있을 때 각기 다른 상태를 정의하고 같은 오브젝트

lms0408.tistory.com

상태는 총 3가지로 Idle(적을 탐색하는 상태), Attack, Dead를 가진다.

오브젝트가 Attack 상태일 때 공격을 할 수 있도록 코드를 작성해보고자 한다.

 

우선 무기의 기초적인 뼈대를 작성해 볼 것이다.

public interface IWeapon
{
    public bool IsAtk { get; }
    public void ReadyAttack(float attackingTime);
    public bool UpdateAttack(Vector2 pos, Vector2 dir, float attackingTime);
}

IsAtk 프로퍼티는 무기의 공격이 준비되어 공격이 가능한 상태를 알리고,

ReadyAttack 메서드는 특정 상태일 때 매 프레임마다 호출되어 공격이 가능하게끔 준비해 주는 메서드,

UpdateAttack도 특정 상태일 때 매 프레임마다 호출되어 공격을 진행해 주는 메서드로 구현할 예정이다.

 

UpdateAttack 메서드가 bool 타입 반환형으로 선언한 이유는

해당 메서드를 호출하는 오브젝트가 가지고 있는 무기가 공격을 했다는 신호를 받아 애니메이션 클립을 재생해주기 위함이다.

 

이젠 선언한 인터페이스를 바탕으로 Weapon 클래스를 구현해보려고 한다.

[System.Serializable]
public abstract class Weapon : IWeapon
{
    [SerializeField] private float atk; // 공격력

    [SerializeField] private int maxAtkCount; // 공격 횟수
    private int atkableCount; // 현재 공격 가능한 횟수

    [SerializeField] private bool isAtk;
    [SerializeField] private float atkElapsed; // 공격 경과 시간

    [SerializeField] private float coolTime = 0.5f; // 쿨타임
    [SerializeField] private float coolTimeElapsed; // 쿨타임 경과 시간
    
    public void ReadyAttack(float attackingTime)
    {
        if ((coolTimeElapsed += Time.deltaTime) < coolTime) return;

        isAtk = true;
        atkElapsed = attackingTime;
    }
    
    public bool UpdateAttack(Vector2 pos, Vector2 dir, float attckingTime)
    {
        if ((atkElapsed += Time.deltaTime) < attckingTime) return false;

        if (--atkableCount < 0)
        {
            coolTimeElapsed = 0f;
            atkableCount = atkCount;
            isAtk = false;
            return false;
        }
    
        atkElapsed = 0f;
        Attack(pos, dir);

        return true;
    }
    
    protected abstract void Attack(Vector2 pos, Vector2 dir);
}

ReadyAttack 메서드는 Idle 상태에서 호출된다.

오브젝트들은 타겟과의 거리가 공격 범위내에 존재해야 그 자리에서 공격을 할 수 있어

이동과 같은 특수한 행동 상태 중에는 호출하지 않는다.

 

내부 구현은 매우 간단하게 되어있다.

호출이 진행됨에 따라 쿨타임 경과 시간을 업데이트해주면서 공격 준비를 시켜준다.

쿨타임이 완료되었을 땐 해당 메서드를 호출한 오브젝트가 공격 상태가 되도록

isAtk true로 변경해준다.

 

ReadyAttack을 통해 오브젝트가 Attack 상태가 되었다면 UpdateAttack 메서드를 호출하여

공격을 진행해 준다.

UpdateAttack 메서드는 atkableCount(공격 가능한 횟수)가 모두 소진되기 전까지 매 프레임 호출되게 된다.

 

매개변수 attackingTime은 한 번의 공격이 진행되는 시간이며

나는 기본값으로 공격 애니메이션 클립의 Length를 대입해 줄 것이다.

(+ ReadyAttack 메서드에서 보면 공격 경과시간을 attackingTime을 대입해 주면서 공격 상태를 맞이하게 되는데

그 이유는 공격 상태가 되었을 때 곧바로 공격을 하기 위함이다.)

 

공격 진행 시간이 초과된 이후

공격 가능한 횟수가 남아있다면 다시 공격을 진행해 준다.

만약 공격 가능한 횟수가 남아있지 않다면 공격 준비 상태가 되도록 isAtkfalse로 변경해 주게 된다.

 

이 Weapon 클래스의 뼈대를 바탕으로 나는 근거리 무기와 원거리 무기로 나누어 상속을 해주었다.

현재는 원거리 무기를 테스트하고자 하니 간단하게 투사체를 발사하는 코드를 구현하면 다음과 같다.

[System.Serializable]
public class LongRangeWeapon : Weapon
{
    public Rigidbody2D pref;
    protected override void Attack(Vector2 pos, Vector2 dir)
    {
        var _obj = GameObject.Instantiate(pref, pos, 
            Quaternion.Euler(0f, 0f, Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg - 90f));
        _obj.velocity = dir * 5f * Time.fixedDeltaTime;
    }
}

 

이제 위의 Weapon 로직이 잘 돌아가는지 확인하기 위해 투사체를 발사하는 대포 오브젝트에 적용을 해보자.

public interface ICannonAction : IIdleAction, IAttackAction, IDeadAction { }

public class CannonController : MyObject, ICannonAction
{
    [SerializeField] private LongRangeWeapon myWeapon;
    [SerializeField] private Animator _animator;
    
    public Quaternion originRot;
    public Transform Target;
    
    // Something...
    
    public void Idle()
    {
        Quaternion _targetRot = Quaternion.identity;
        if (Target == null) _targetRot = originRot;
        else
        {
            // Cannon Position에서 타겟 Position까지의 벡터를 구해 각도를 변환
            var _dir = (weapon.Target.Position - (Vector2)weapon.Transform.position).normalized;
            var _axis = Mathf.Atan2(_dir.y, _dir.x) * Mathf.Rad2Deg;
            _targetRot = Quaternion.Euler(0f, 0f, _axis);

            myWeapon.ReadyAttack(1f);
        }
        
        var _rotSpeed = 10f;
        transform.rotation = Quaternion.Lerp(transform.rotation, _targetRot, Time.deltaTime * _rotSpeed);
    }
    
    public void Attack()
    {
        if (!myWeapon.UpdateAttack(transform.position, transform.up, 1f)) return;
        // Weapon이 공격을 했다면 애니메이션 재생
        _animator.SetTrigger("Attack");
    }
    
    // Something...
}

위 코드는 CannonController의 핵심적인 코드의 일부분이며, 위에서 설명한 설계대로 구현이 된 상태이다.

추가적인 부분은 타겟이 존재하지 않을땐 항상 처음에 셋팅한 rotation을 바라보고,

타겟이 존재하고 공격이 준비되고 있는 상태에선 타겟을 바라보도록 연산해보았다.

 

해당 스크립트 컴포넌트가 적용된 오브젝트를 적절하게 배치하여 테스트 해보면 문제없이 잘 동작하는 모습을 볼 수 있게 된다.

+ Recent posts