[ ]카드 내려고했다가 취소되면 원래 손패위치로 되돌아가게
<아마 추가적인 변수가 필요하겟지, 젤 마지막에 선택한 카드의 레이아웃 순서 번호 ?
📖 목차
1부. 카드 드래그의 기본 원리 (CardDraggable)
- 유니티 이벤트 시스템과 인터페이스: IBeginDragHandler 등 'I'로 시작하는 약속들
- MonoBehaviour의 역할: 클래스 상속과 컴포넌트의 이해
- 위치 기억 장치: originalParent와 parentToReturnTo로 구현하는 귀환 로직
- 드래그의 시작과 끝: 카드가 필드로 나갔다 다시 손패로 돌아오는 과정
2부. 자연스러운 손패 연출을 위한 가짜 칸 (Placeholder)
- 빈 공간의 마법: 왜 카드가 빠져나가도 옆 카드들이 휙 쏠리지 않는가?
- 공간 확보 전문가 LayoutElement: 투명하지만 존재감 있는 가짜 카드의 비밀
- 실시간 이사 로직: 손패와 필드 사이에서 placeholder의 부모가 바뀌는 이유
3부. 레이아웃 정렬과 순서 계산 알고리즘
- Horizontal Layout Group의 특징: 자식의 입퇴장에 따른 자동 정렬 원리
- 인덱스(Index) 탐색: 왼쪽(0번)부터 오른쪽으로 훑으며 내 자리 찾는 법
- 인덱스 보정의 핵심 newSiblingIndex--: 내가 내 뒤로 이사 갈 때 생기는 번호 꼬임 해결하기
4부. 카드 생성과 데이터 바인딩 (HandManager & CardDisplay)
- 실물 제작 Instantiate: 붕어빵 틀(Prefab)에서 카드 찍어내기
- 기능 연결 GetComponent: 덩어리(오브젝트)에서 두뇌(스크립트) 찾아오기
- 설계도 전달: cardData 매개변수 하나로 모든 정보가 주입되는 원리
5부. 유니티 데이터 구조의 이해
- ScriptableObject vs GameObject: '요리 레시피'와 '실제 요리'의 결정적 차이
- 데이터 관리의 효율성: 왜 수치 정보는 따로 파일(SO)로 관리해야 하는가?
- 인스펙터 연결의 의미: 드래그 앤 드롭으로 덱 리스트를 채우는 행위의 본질
📌 draggable .cs 해석[ ]
public class CardDraggable : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler,
IPointerEnterHandler, IPointerExitHandler
IBeginDragHandler, IDragHandler 등 I로 시작하는 이름들은 모두 **유니티의 이벤트 시스템(UnityEngine.EventSystems)**에서 제공하는 인터페이스입니다.
- 역할: 마우스 클릭, 드래그, 포인터 진입 등 '입력 이벤트'를 감지하기 위해 만들어진 규격입니다.
📌 implement했다는건
"구현하겠다는 약속"인가요?
C# 프로그래밍에서 인터페이스를 상속받는다는 것은 **"이 클래스는 해당 인터페이스에 정의된 메서드들을 반드시 구현(작성)하겠다"**라고 컴파일러와 약속하는 것입니다.
이 약속을 어기면(즉, 메서드를 작성하지 않으면) 유니티에서 에러를 발생시킵니다. 예를 들어:
- IBeginDragHandler를 썼다면 -> OnBeginDrag 메서드를 반드시 만들어야 함.
- IDragHandler를 썼다면 -> OnDrag 메서드를 반드시 만들어야 함.
📌 MonoBehaviour는 무엇인가요?
MonoBehaviour는 인터페이스가 아니라 **클래스(Base Class)**입니다.
- 상속의 의미: CardDraggable이라는 클래스가 유니티의 **'컴포넌트'**로서 동작할 수 있게 해줍니다.
- 기능: 이걸 상속받아야만 유니티 인스펙터 창에 스크립트를 드래그해서 넣을 수 있고, Start(), Update() 같은 유니티 생명주기 함수를 사용할 수 있습니다.
- private Transform originalParent
- 역할: 드래그를 시작한 순간의 부모(원래 내 손패 위치)를 기억합니다.
- public Transform parentToReturnTo
- 역할: 드래그가 끝났을 때 돌아갈 부모입니다.
- 특징: 드래그 중에 "필드" 위에 마우스를 올리면 이 변수가 "필드"로 바뀌고, 아무데도 안 올리면 다시 originalParent로 설정하여 제자리로 돌아가게 합니다.
- public Transform placeholderParent
- 역할: '빈자리 표시용 가짜 카드(Placeholder)'가 들어갈 부모입니다. 보통은 parentToReturnTo와 거의 같이 움직입니다.
필드에 내려고 했다가 다시 손패로 돌아오는 과정은 **"기억해둔 원래 집(originalParent) 주소를 다시 꺼내 쓰는 과정

📌 카드 내려고할때 빈공간 매우기에 관해
(빈공간 없는데)
카드 사이에 놓고싶을때, 카드 빠져나갈때 어떻게 자연스러운 이미지 [ 완료 ]

손패 존에 있으면 빈공간
(필드존에 드롭하지않았더라도, 필드존에 있으면 빈공간없음)
[CardDraggable.cs] 드래그 시작: "일단 손패에 예약석 만들기"
// CardDraggable.cs 의 OnBeginDrag 부분
originalParent = this.transform.parent; // 원래 부모(손패) 기억
placeholderParent = originalParent; // 가짜 칸의 부모도 일단 손패로 설정
placeholder.transform.SetParent(originalParent); // 실제로 손패에 가짜 칸 생성
[DropZone.cs] 마우스가 필드에 진입: "예약석 주소 옮기기"
// DropZone.cs (필드에 붙어있는 스크립트)
public void OnPointerEnter(PointerEventData eventData)
{
CardDraggable draggable = eventData.pointerDrag.GetComponent<CardDraggable>();
if (draggable != null)
{
// 핵심: "가짜 칸아, 이제 네 부모는 나(필드)야!" 라고 목적지를 바꿔버림
draggable.placeholderParent = this.transform;
}
}
[CardDraggable.cs] 드래그 중: "실제로 이사 가기"
OnDrag 함수는 매 프레임마다 "내 가짜 칸이 지금 엉뚱한 집에 있나?"를 감시합니다.
// CardDraggable.cs 의 OnDrag 부분
if (placeholder != null && placeholderParent != null)
{
// 만약 가짜 칸의 진짜 부모가, 지정된 목적지(placeholderParent)와 다르다면?
if (placeholder.transform.parent != placeholderParent)
{
// 실제로 부모를 옮겨라! (이사 실행)
placeholder.transform.SetParent(placeholderParent);
}
// ... 이후 순서(SiblingIndex) 정렬 로직 ...
}
왜 부모가 옮겨지는 게 상관있나요? (Layout Group의 역할)
손패(PlayerHand)와 필드(PlayerField) 오브젝트에는 보통 **Horizontal Layout Group**이라는 컴포넌트가 붙어 있습니다. 이 컴포넌트의 특징은 다음과 같습니다.
- "나의 자식(Child)들은 내가 무조건 가로로 정렬시킨다!"
- "자식이 새로 들어오면 자리를 비워주고, 나가면 그 자리를 메운다!"
그래서 placeholder(가짜 카드)의 부모가 손패가 되면 손패 레이아웃이 "어? 자식이 하나 늘었네?" 하고 자리를 비워주는 것이고, 부모를 필드로 바꾸면 손패는 "어? 자식이 나갔네?" 하고 자리를 메우는 것입니다.
우리는 왼쪽에서부터 오른쪽으로 차례대로 서 있는 카드들을 하나씩 검사할 겁니다.
- 내 마우스 좌표(X)를 0번 카드, 1번 카드, 2번 카드... 순서대로 비교합니다.
- 그러다가 **"내 마우스보다 오른쪽에 있는 카드"**를 처음 발견하는 순간, **"아! 여기가 내 자리구나!"**라고 결정하는 방식입니다.
유니티의 가로 정렬(Horizontal Layout Group) 시스템에서 인덱스 0은 가장 왼쪽입니다.
현재 손패 상황: [A(0번)] [B(1번)] [C(2번)]
내가 카드를 들고 A와 B 사이로 들어갔다고 칩시다.
- i = 0 일 때: 마우스가 A보다 왼쪽인가? -> No (A는 마우스보다 왼쪽에 있음)
- i = 1 일 때: 마우스가 B보다 왼쪽인가? -> Yes!
- 결정: "오케이, 내 자리는 1번이다!"
- 결과: 가짜 카드가 1번으로 쏙 들어감 -> [A] [가짜] [B] [C]
1. 상황 가정 (번호표를 들고 줄 서기)
지금 손패에 카드가 이렇게 4장 있다고 칩시다. (번호는 인덱스입니다)
- [A(0)] [가짜(1)] [B(2)] [C(3)]
지금 가짜 카드(Placeholder)는 1번에 서 있습니다.
이제 내가 마우스를 B와 C 사이로 옮겼다고 가정해 봅시다.
2. 반복문은 어떻게 계산할까요?
컴퓨터는 0번부터 차례대로 검사합니다.
- i=0 (A): 마우스가 A보다 왼쪽인가? → 아니오.
- i=1 (가짜): 마우스가 가짜보다 왼쪽인가? → 아니오.
- i=2 (B): 마우스가 B보다 왼쪽인가? → 아니오. (마우스는 B보다 오른쪽임)
- i=3 (C): 마우스가 C보다 왼쪽인가? → 네!
- 컴퓨터: "찾았다! 네 자리는 3번이야!" (newSiblingIndex = 3)
3. 문제 발생: 그냥 3번으로 가면 어떻게 될까?
컴퓨터가 알아낸 대로 가짜 카드를 3번으로 보내면 어떻게 될까요?
- 원래: [A(0)] [가짜(1)] [B(2)] [C(3)]
- 3번으로 이동 후: [A(0)] [B(1)] [C(2)] [가짜(3)]
어라? B와 C 사이(2번)로 가고 싶었는데, C의 뒤쪽(3번)으로 가버렸습니다!
4. 왜 이런 일이 생겼을까요?
가짜 카드가 1번에서 빠져나와서 뒤로 가는 순간, 뒤에 있던 B와 C가 앞으로 한 칸씩 당겨지기 때문입니다.
- 가짜가 나감: [A(0)] [B(1)] [C(2)] (B는 2번이었는데 1번이 됨, C는 3번이었는데 2번이 됨)
- 이 상태에서 3번으로 들어가니까 맨 뒤로 가게 된 것입니다.
5. 해결사: newSiblingIndex--
그래서 이 코드가 필요한 것입니다.
if (placeholder.transform.GetSiblingIndex() < newSiblingIndex)
newSiblingIndex--;
- 상황: 가짜 카드의 현재 위치(1) < 내가 가려는 위치(3)
- 해석: "아, 내가 지금 뒤로 가려고 하는구나? 그러면 내가 빠지면서 번호가 하나씩 당겨질 테니까, 목표 번호에서 하나를 빼야 정확하겠네!"
- 결과: 3번이 아니라 2번으로 가게 됩니다.
- 최종 상태: [A(0)] [B(1)] [가짜(2)] [C(3)] (정확히 B와 C 사이!)
왜 투명한데 자리를 차지하나요? (LayoutElement)
가짜 카드는 GameObject일 뿐인데 어떻게 자리를 차지할까요? OnBeginDrag의 이 코드 때문입니다.
LayoutElement le = placeholder.AddComponent<LayoutElement>();
le.preferredWidth = cardRT.rect.width;
le.preferredHeight = cardRT.rect.height;
- LayoutElement: "나는 투명하지만, 가로/세로 이만큼의 공간을 차지할 거야!"라고 레이아웃 그룹에게 알려주는 컴포넌트입니다.
- 이게 없으면 가짜 카드의 크기가 0이 되어, 부모가 바뀌어도 다른 카드들이 비켜주지 않습니다.
📌 손패 코드 해석[ ]
GameObject cardObj = Instantiate(cardPrefab, handParent);
CardDisplay display = cardObj.GetComponent<CardDisplay>();
붕어빵 틀(프리팹)만 찍어내면 다 똑같은 카드일 텐데, 어떻게 특정 카드의 정보를 집어넣느냐"**는 것이죠?
결론부터 말씀드리면, cardData라는 매개변수 하나에 이미 모든 정보가 다 들어있기 때문에 추가 매개변수가 필요 없는 것입니다.
1. 매개변수 cardData의 정체
AddCardToHand(Card cardData)를 호출할 때, 저 cardData 안에는 이미 다음과 같은 정보들이 통째로 들어있습니다. (이것이 ScriptableObject의 장점입니다.)
- 이름: "불꽃구슬"
- 마나코스트: 3
- 공격력: 5
- 카드 그림(Sprite) 등등...
즉, **cardData는 그 카드에 대한 모든 정보가 적힌 "설계도"**라고 보시면 됩니다.
2. 코드의 흐름 (실물과 데이터의 결합)
// 1. 빈 껍데기(프리팹) 복사하기
GameObject cardObj = Instantiate(cardPrefab, handParent);
- 여기서는 그냥 하얀색 빈 카드 오브젝트가 생성될 뿐입니다. (이름도 없고 그림도 없는 상태)
// 2. 그 빈 껍데기에 붙어있는 '그림 그리기 담당자(CardDisplay)' 찾기
CardDisplay display = cardObj.GetComponent<CardDisplay>();
- 새로 만든 카드 오브젝트에서 정보를 표시해 줄 스크립트를 가져옵니다.
// 3. 가장 중요한 순간: 설계도(cardData) 전달하기!
display.cardData = cardData;
- 여기서 데이터가 결합됩니다. 빈 껍데기(display)에게 "너는 이제부터 이 설계도(cardData)대로 변신해!"라고 정보를 주는 것입니다.
- 추가 매개변수가 필요 없는 이유는 cardData 객체 하나가 이미 덱에서 가져온 모든 정보를 품고 있기 때문입니다.
// 4. 실제로 그림 그리기
display.UpdateUI();
- 이제 display 스크립트는 방금 받은 설계도를 보고 "아, 마나가 3이구나? 마나 칸에 3이라고 써야지", "이름이 불꽃구슬이네? 텍스트를 바꿔야지" 하고 UI를 업데이트합니다.
3. 비유로 이해하기 (명함 만들기)
- cardPrefab: 인쇄소에 있는 **'빈 명함 종이'**입니다. (모두 똑같이 생겼음)
- cardData: 손님이 들고 온 **'이름과 전화번호가 적힌 메모지'**입니다. (덱에서 가져온 정보)
- Instantiate: 빈 종이 한 장을 꺼내는 행위입니다.
- display.cardData = cardData: 인쇄기(CardDisplay)에 손님의 메모지를 입력하는 행위입니다.
- UpdateUI(): 인쇄기가 실제로 종이에 글자를 찍어내는 행위입니다.
4. 덱에서 정보를 어떻게 가져오나요?
이 함수를 호출하는 쪽(예: 게임 매니저)에서 아마 이런 식으로 코드를 짰을 것입니다.
// 예시: 덱의 첫 번째 카드를 뽑아서 손패에 추가할 때
Card nextCard = deck[0]; // 덱 리스트에서 0번 카드(데이터)를 꺼냄
AddCardToHand(nextCard); // 그 데이터를 매개변수로 쏙 넣어줌!
코드의 구성 요소
CardDisplay display = cardObj.GetComponent<CardDisplay>();
- cardObj: 바로 윗줄에서 Instantiate로 막 생성한 따끈따끈한 새 카드 오브젝트입니다. (아직은 그냥 껍데기일 뿐입니다.)
- . (점): "~안에 있는", "~의" 라는 뜻입니다.
- GetComponent: "컴포넌트(기능 부품)를 가져와라"라는 명령입니다. 유니티에서 특정 스크립트의 기능을 쓰려면 그 스크립트를 찾아야 합니다.
- <CardDisplay>: "수많은 부품 중에 'CardDisplay'라는 이름의 스크립트를 찾아줘!"라고 대상을 정해주는 것입니다.
- CardDisplay display: 찾아낸 그 스크립트를 앞으로 display라는 이름으로 부르겠다는 뜻입니다.
1. 덱(DeckManager)은 '정보 창고'입니다.
사진을 보면 Deck List에 "게으른 투사(Card)", "고대의 토템(Card)" 등을 직접 넣어두셨죠?
- 이것들은 실제 카드 오브젝트가 아니라, **ScriptableObject(데이터 파일)**입니다.
- 즉, DeckManager는 **"내가 뽑아줄 수 있는 카드 설계도 8장을 가지고 있다"**는 상태입니다.
3. CardDisplay가 정보를 읽는 시점
CardDisplay.cs 코드를 보면 이 부분이 있습니다.
public void UpdateUI()
{
if (cardData == null) return; // 설계도가 없으면 아무것도 안 함
// 설계도(cardData)에 적힌 대로 글자를 씁니다.
nameText.text = cardData.cardName;
costText.text = cardData.cost.ToString();
// ... 등등
}
- 연결의 핵심: 유저님이 인스펙터 창(사진)에서 Deck List에 넣어둔 카드 파일 하나하나가 곧 cardData가 되는 것입니다.
- 유니티 인스펙터에서 카드를 드래그해서 넣는 행위 자체가 **"이 변수에 이 데이터를 연결하겠다"**라는 약속입니다.
1. 비유로 이해하기: 요리 레시피 vs 실제 요리
- ScriptableObject (레시피):
- 종이에 적힌 "제육볶음 만드는 법"입니다.
- 이 종이는 먹을 수 없습니다. 그냥 정보일 뿐이죠.
- 냉장고(프로젝트 폴더)에 보관해 둡니다.
- GameObject (실제 요리):
- 레시피를 보고 실제로 만든 접시 위의 제육볶음입니다.
- 이건 우리가 식탁(Scene)에 내놓을 수 있고, 손님이 먹을 수도(드래그) 있습니다.
2. 구체적인 차이점
| 구분 | ScriptableObject (Card 데이터) | GameObject (Card 프리팹) |
| 존재 위치 | 프로젝트 창(Project) (파일로 저장됨) | 하이어라키 창(Hierarchy) (씬에 배치됨) |
| 구성 요소 | 이름, 공격력, 체력, 이미지 파일 경로 등 값(Value) | 위치(Transform), 이미지 컴포넌트, 드래그 스크립트 등 기능 |
| 메모리 | 아주 가벼움 (텍스트와 참조 위주) | 무거움 (이미지 렌더러, UI 시스템, 물리 등 포함) |
| 개수 | 카드 종류가 100종이면 파일도 100개 | 화면에 보이는 카드가 5장이면 객체도 5개 |
3. 왜 이렇게 따로 쓰나요? (카드 게임에서의 장점)
만약 ScriptableObject를 안 쓰고 GameObject만 쓴다면 어떻게 될까요?
- "게으른 투사" 카드를 만들 때마다 공격력 4, 체력 10이라는 글자를 매번 수동으로 써넣어야 합니다.
- 만약 "게으른 투사"의 공격력을 5로 바꾸고 싶다면, 덱에 들어있는 모든 카드 오브젝트를 일일이 다 찾아가서 수정해야 합니다.
ScriptableObject를 쓰면:
- 재사용: "게으른 투사"라는 데이터 파일(SO) 딱 하나만 수정하면, 그 데이터를 쓰고 있는 화면상의 모든 카드(GameObject)들이 한꺼번에 바뀝니다.
- 효율성: 덱에 똑같은 카드가 2장 들어있어도, 데이터 파일은 1개만 있으면 됩니다. 두 개의 **실제 카드(GameObject)**가 같은 **레시피(SO)**를 쳐다보고 있을 뿐이니까요.
'개발 > 하스스톤+전장' 카테고리의 다른 글
| 하스+전장)minionAttack.cs/turnManager.cs 해석 (0) | 2026.01.28 |
|---|---|
| 하스+전장)하수인 로직 강화 (0) | 2026.01.27 |
| 하스+전장) 인터페이스,제네틱 쓰는 이유 (0) | 2026.01.26 |
| 하스+전장) 하수인 공격기능 추가 (0) | 2026.01.25 |
| 하스+전장) 성능개선(하이아키에서 찾기> list로 찾기) (0) | 2026.01.25 |