개발/하스스톤+전장

하스+전장)minionAttack.cs/turnManager.cs 해석

kimchangmin02 2026. 1. 28. 11:48

📂목차

Part 1. [UI 및 수학] 화살표가 마우스를 따라가는 원리

  1. 벡터의 뺄셈: 마우스 방향을 향하는 화살표 벡터 구하기
  2. Mathf.Atan2와 라디안: 좌표를 각도(Degree)로 변환하는 마법
  3. Z축 회전과 쿼터니언: 2D UI 회전의 핵심 원리
  4. Canvas Scaler 보정: 해상도가 바뀌어도 화살표 길이가 정확한 이유 (lossyScale)
  5. sizeDelta 활용: 이미지 두께는 유지하고 길이만 늘리는 법

Part 2. [입력 시스템] 클릭 이벤트와 공격 시퀀스

  1. Input ID 0, 1, 2: 마우스 왼쪽, 오른쪽, 휠 버튼의 구분
  2. OnPointerClick vs Update: 클릭 시점과 매 프레임 감시의 역할 분담
  3. 가드 절(Guard Clause): 공격 불가능한 상황을 입구에서 컷(Cut)하기
  4. UI 레이캐스트(RaycastAll): 마우스 아래에 있는 타겟 하수인 식별하기

Part 3. [구조 설계] 유니티 싱글톤(Singleton) 완전 정복

  1. Static과 Instance의 차이: 왜 ClassName.Method() 대신 Instance를 쓰는가?
  2. 인스펙터 연결의 한계: static 함수가 유니티 변수(UI)를 건드리지 못하는 이유
  3. Awake와 this: 유니티 엔진이 생성한 객체의 주소를 낚아채는 법
  4. 매니저 클래스 활용: 마나(Mana)와 턴(Turn)을 중앙에서 관리하는 법
  5. 상태 기반 로직: 매개변수(bool) 하나로 플레이어와 적을 구분하는 법

Part 4. [필드 관리] 하이어라키와 컴포넌트 순회

  1. Transform 순회: 부모(Field) 아래의 모든 자식 오브젝트를 훑는 법
  2. GetComponent와 Null 체크: 장식물 사이에서 진짜 하수인만 골라내는 법
  3. 좌표 계산 vs 레이아웃: 수학 공식보다 유니티 UI 시스템이 강력한 이유
  4. 메모리 안전성: 리스트(List) 관리보다 하이어라키 순회가 안전한 상황

 

 

 


 

📌 minion.cs 해석

 

1. 마우스 방향 계산 (방향 벡터)

Vector3 direction = Input.mousePosition - uiArrow.position;
  • 해석: 마우스의 현재 위치(Input.mousePosition)에서 화살표의 시작 위치(uiArrow.position)를 뺍니다.
  • 의미: 벡터의 뺄셈을 통해 **"화살표에서 마우스를 향하는 화살표(벡터)"**를 구하는 것입니다. 이 direction 변수에는 마우스가 어느 방향에 있는지, 얼마나 멀리 있는지가 담깁니다.

2. 회전 각도 계산 (삼각함수)

float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
  • 해석: Mathf.Atan2는 직교 좌표(x, y)를 사용하여 각도(라디안)를 구해주는 함수입니다. 여기에 Mathf.Rad2Deg를 곱해 우리가 흔히 쓰는 **각도(Degree, 0~360도)**로 변환합니다.
  • 의미: 화살표 이미지가 마우스를 똑바로 바라보게 하기 위해 몇 도를 회전시켜야 하는지 계산하는 과정입니다.

3. 회전 적용 (Quaternion)

uiArrow.rotation = Quaternion.Euler(0, 0, angle);
  • 해석: 유니티에서 회전은 Quaternion이라는 단위를 사용합니다. 우리가 구한 angle을 Z축 회전 값으로 넣어줍니다.
  • 의미: 계산된 각도를 실제로 화살표의 회전 값에 적용합니다. 이제 화살표 머리가 마우스를 따라 회전합니다.

4. 거리 계산 (피타고라스 정리)

float distance = direction.magnitude;
  • 해석: direction.magnitude는 위에서 구한 방향 벡터의 **길이(크기)**를 가져옵니다.
  • 의미: 하수인과 마우스 사이의 실제 화면 거리(픽셀 단위)가 얼마인지 구합니다.

5. 스케일 보정 (중요!)

float s = uiArrow.lossyScale.x;
  • 해석: lossyScale은 부모 오브젝트(Canvas 등)의 크기 변화까지 모두 반영된 최종적인 전역 스케일 값입니다.
  • 의미: UI는 화면 해상도에 따라 자동으로 크기가 커지거나 작아질 수 있습니다(Canvas Scaler). 만약 캔버스가 2배 커져 있다면, 화살표의 길이 값도 그만큼 나누어서 보정해줘야 정확한 위치까지 도달합니다.

6. 길이 조절 (SizeDelta)

uiArrow.sizeDelta = new Vector2(distance / s, uiArrow.sizeDelta.y);
  • 해석: sizeDelta는 UI의 가로(Width), 세로(Height) 크기를 의미합니다.
  • 계산: distance / s를 통해 보정된 거리만큼 가로 길이를 설정하고, 세로 길이(y)는 원래 길이를 유지합니다.
  • 의미: 화살표 이미지의 너비를 조절하여, 화살표 끝이 마우스 커서 위치에 딱 닿게 늘려주는 역할을 합니다.

 

 

 

 

 

2번 과정: Mathf.Atan2와 각도 구하기

float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
  •  Atan2를 쓰나요?
    우리는 지금 마우스가 하수인으로부터 '오른쪽으로 얼마(x)', '위쪽으로 얼마(y)' 떨어져 있는지 알고 있습니다. 하지만 유니티가 궁금한 건 **"그래서 그게 몇 도(Angle)야?"**입니다.
    • Mathf.Atan2(y, x): "오른쪽으로 10, 위로 10만큼 갔으면 그건 45도야!"라고 좌표를 각도로 바꿔주는 계산기라고 생각하시면 됩니다.
  •  Mathf.Rad2Deg를 곱하나요?
    • 이 계산기(Atan2)는 결과를 '라디안(Radian)'이라는 생소한 단위로 알려줍니다 (예: 45도라고 안 하고 0.78... 이라고 함).
    • 하지만 우리는 0도~360도 같은 '디그리(Degree)' 단위가 필요합니다.
    • Rad2Deg: "라디안을 디그리로 바꿔줘(Radian to Degree)"라는 뜻입니다. 이걸 곱해야 우리가 아는 45라는 숫자가 나옵니다.

3번 과정: Quaternion.Euler와 회전 적용

uiArrow.rotation = Quaternion.Euler(0, 0, angle);
  • Quaternion(쿼터니언)이란?
    유니티에서 회전값은 우리가 아는 숫자 하나(예: 90도)가 아니라, 매우 복잡한 4개의 숫자 덩어리(Quaternion)로 처리됩니다. 우리가 직접 이 숫자를 건드리기는 너무 어렵습니다.
  • Quaternion.Euler는 무엇인가요?
    우리가 이해하기 쉬운 (X, Y, Z) 각도를 넣으면, 유니티가 이해할 수 있는 복잡한 **Quaternion**으로 자동 변환해주는 도구입니다.
    • Euler(0, 0, angle): "X축으로 0도, Y축으로 0도, Z축(화면 안쪽 방향)으로 계산한 각도만큼 회전하는 값을 만들어줘!"라는 뜻입니다.
  • 왜 Z축인가요?
    2D 게임이나 UI에서 종이 위에 놓인 화살표를 돌릴 때는 바닥에 못을 박고 돌리는 것과 같습니다. 그 못의 방향이 바로 Z축입니다. 그래서 Z값을 바꿔야 화살표가 시계 방향이나 반시계 방향으로 회전합니다.

 

 

 

 

5번: float s = uiArrow.lossyScale.x; (스케일 보정)

"우리 눈에 보이는 크기와 유니티 내부 숫자의 차이를 맞추기 위해서" 합니다.

  • 배경 설명 (Canvas Scaler):
    유니티 UI는 화면 해상도(FHD, 4K 등)에 따라 자동으로 크기가 커지거나 작아지도록 설정되어 있는 경우가 많습니다(Canvas Scaler 때문).
    이때 캔버스의 스케일이 1.5배나 2배로 커져 있을 수 있습니다.
  • 문제 발생:
    마우스 거리(distance)는 실제 화면의 픽셀(Pixel) 거리입니다.
    만약 마우스까지 거리가 200픽셀인데, UI가 2배로 확대된 상태(scale = 2)라면, 화살표 길이를 200으로 설정하는 순간 화면에서는 400픽셀만큼 길게 그려집니다. (200 x 2배 확대니까요!)
  • 해결:
    그래서 현재 화살표가 속한 UI 시스템이 몇 배로 확대되어 있는지(lossyScale) 알아낸 다음, 나중에 거리를 이 숫자로 나눠주는 것입니다.

6번: uiArrow.sizeDelta = new Vector2(distance / s, ...); (길이 조절)

**"화살표 이미지의 가로(Width) 길이를 마우스까지의 거리로 바꾼다"**는 뜻입니다.

  • distance / s:
    마우스까지의 거리(distance)를 아까 구한 배율(s)로 나눕니다.
    예를 들어, 거리가 200픽셀이고 배율이 2배라면, 화살표 자체의 길이는 100만 되어도 화면에서는 200픽셀로 보입니다. 이렇게 해야 화살표 끝이 마우스에 자석처럼 딱 붙습니다.
  • uiArrow.sizeDelta.y:
    이건 화살표의 **두께(높이)**입니다.
    우리는 화살표를 길게 늘리고 싶은 거지, 위아래로 뚱뚱하게 만들고 싶은 게 아니죠? 그래서 원래 가지고 있던 높이값(y)은 그대로 유지하라고 넣어주는 것입니다.

 

 

유니티에서 Input.GetMouseButtonDown(숫자) 숫자는 마우스의 어떤 버튼을 눌렀는지를 식별하는 ID입니다.

약속된 번호는 다음과 같습니다.

1. 번호의 의미

  • 0 : 마우스 왼쪽 버튼 (Left Click)
    • 가장 많이 쓰이며 보통 '선택', '공격', '확인' 등의 메인 상호작용에 사용됩니다.
  • 1 : 마우스 오른쪽 버튼 (Right Click)
    • 보통 '취소', '특수 기능', '메뉴 보기' 등에 사용됩니다.
  • 2 : 마우스 가운데 휠 버튼 (Middle Click)
    • 마우스 휠을 스크롤하는 게 아니라, 휠 자체를 '꾹' 눌렀을 때 발생합니다.

 

  1. GetMouseButtonDown(0): 버튼을 누르는 순간 딱 한 번 실행 (공격, 점프 등)
  2. GetMouseButton(0): 버튼을 누르고 있는 동안 계속 실행 (연사 총 쏘기, 이동 등)
  3. GetMouseButtonUp(0): 버튼을 누르다가 떼는 순간 딱 한 번 실행 (기 모아서 쏘기 등)

 

 

 

1. OnPointerClick은 언제 실행되는가?

OnPointerClick은 유니티의 **이벤트 시스템(Event System)**에 의해 실행됩니다.

  • 실행 시점: 마우스 버튼을 해당 오브젝트 위에서 눌렀다가 뗐을 때(Click) 즉시 호출됩니다.
  • 작동 조건:
    1. 씬에 EventSystem 오브젝트가 있어야 합니다.
    2. 카메라에 PhysicsRaycaster 혹은 UI인 경우 Canvas에 GraphicRaycaster가 있어야 합니다.
    3. 이 스크립트가 붙은 오브젝트에 Collider Raycast Target(UI) 속성이 켜져 있어야 합니다.

2. if (!isOnField || !canAttack) return; 이 왜 필요한가? (불필요해 보이는 이유와 반전)

질문하신 대로 "필드에 없으면 어차피 공격을 못 하는데 왜 굳이 체크하나?"라는 생각은 매우 합리적입니다. 하지만 이 조건문이 들어가는 이유는 보통 카드의 '재사용성'과 '예외 방지' 때문입니다.

① 카드는 '손패'에도 있고 '필드'에도 있습니다.

보통 카드 게임을 만들 때, **[손패에 있는 카드]**와 **[필드에 나간 카드]**는 똑같은 프리팹(오브젝트)을 사용합니다. 즉, 이 MinionAttack 스크립트가 손패에 있는 카드에도 붙어 있을 가능성이 높습니다.

  • 만약 !isOnField 체크가 없다면? : 내가 손패에 있는 카드를 내려고 클릭했는데, 갑자기 공격 화살표가 튀어나올 수 있습니다. 이를 방지하기 위해 "너 지금 필드 위에 놓인 상태니?"라고 물어보는 것입니다.

② 클릭 이벤트는 '언제나' 발생할 수 있습니다.

유니티는 이 오브젝트가 필드에 있든, 손패에 있든, 혹은 묘지에 있든 클릭만 되면 일단 OnPointerClick을 실행해 버립니다.

  • 따라서 코드의 첫 줄에서 "지금 너의 상태(필드 위인가? 공격 가능한가?)가 공격할 상황이 아니면 아래 복잡한 코드는 읽지도 말고 바로 종료해!"라고 입구를 컷(Cut)하는 것입니다. 이를 **'가드 절(Guard Clause)'**이라고 부르며 버그를 줄이는 좋은 습관입니다.

 

 

 

 

  • 1단계: 내 하수인(아군) 클릭
    • 메소드: OnPointerClick이 실행됩니다.
    • 이때 isTargeting은 처음엔 false입니다.
    • 조건문을 통과하면 ToggleAttackArrow(true)가 실행되면서 **isTargeting = true**가 됩니다.
    • 결과: 이제 화면에 화살표가 나오기 시작하고, 시스템은 "공격 준비 상태"가 됩니다.
  • 2단계: 타겟(적군) 클릭
    • 메소드: 이제부터는 Update 함수가 매 프레임 감시를 합니다.
    • isTargeting true이므로 Update 내부의 Input.GetMouseButtonDown(0) 코드가 작동할 준비가 된 상태입니다.
    • 마우스를 클릭하면 ExecuteAttack()이 실행됩니다.
    • 결과: 이때 마우스 아래에 있는 대상을 찾아 데미지를 줍니다.

 

 

 

 

 PointerEventData pointerData (자료형: 클래스 객체)

  • 정체: 유니티 이벤트 시스템이 사용하는 **"설정 문서"**입니다.
  • 역할: "내가 지금 마우스 왼쪽 클릭을 했어", "위치는 여기야(Input.mousePosition)" 같은 정보를 담아 유니티에게 전달하기 위해 만듭니다.
  • 단순 자료형이라기보다, "어디를, 어떤 방식으로 조사할지" 적혀 있는 정보 뭉치라고 보시면 됩니다.

 List<RaycastResult> results (자료형: 리스트 컬렉션)

  • 정체: RaycastResult라는 구조체를 담는 **장바구니(배열)**입니다.
  • 역할: 유니티가 레이저를 쐈을 때 맞은 물체가 하나가 아닐 수 있습니다 (예: 적 하수인, 하수인 뒤의 배경, UI 패널 등).
  • 맞은 모든 물체의 정보를 순서대로(보통 카메라와 가까운 순) 담아두는 저장소입니다.

 EventSystem.current.RaycastAll (자료형: 메소드/함수)

  • 정체: 유니티의 UI 전용 충돌 검색 엔진입니다.
  • 역할:
    1. pointerData를 읽어서 "아, 마우스 위치가 여기구나?"라고 파악합니다.
    2. 그 지점으로 투명한 레이저를 화면 깊숙이 쏩니다.
    3. 레이저에 닿은 모든 UI 요소를 찾아내서 results 리스트에 차곡차곡 넣어줍니다.

 


 

📌tuenmanager.cs 해석

(상대도 나를 공격할수있게 하려면)

 

    public Sprite yellowSprite; // 내 턴
    public Sprite whiteSprite;  // 상대 턴

 

 

 

 

   📌  void Awake() => Instance = this;


이 코드는 유니티에서 매우 자주 사용되는 **'싱글톤(Singleton) 패턴'**의 핵심 코드입니다.

1. 문법적 의미 (C# 문법)

  • void Awake(): 유니티 생명주기 메서드로, 스크립트가 실행될 때 Start보다도 먼저, 게임 오브젝트가 생성되자마자 가장 처음 호출됩니다. "태어나자마자 실행되는 코드"라고 보시면 됩니다.
  • => (람다 식 / 식 본문 멤버): 중괄호 { }를 생략하기 위한 줄임표입니다. 아래 두 코드는 완벽히 같은 코드입니다.
    // 줄인 표현
    void Awake() => Instance = this;
    
    // 원래 표현
    void Awake() 
    {
        Instance = this;
    }
  • Instance = this: 여기서 this는 **"나 자신(이 스크립트가 붙어있는 바로 그 객체)"**을 의미합니다. 즉, 클래스 상단에 선언되어 있을 static 변수인 Instance에 나 자신을 집어넣겠다는 뜻입니다.

2. 기능적 목적 (왜 쓰는가?)

이 코드를 쓰는 목적은 **"어디서든 나(이 클래스)를 쉽게 부를 수 있게 하기 위해서"**입니다. 이를 **싱글톤(Singleton)**이라고 합니다.

비유: 전용 핫라인 만들기

보통 다른 스크립트에서 MinionAttack에 접근하려면 GameObject.Find를 쓰거나 GetComponent를 써서 복잡하게 찾아야 합니다. 하지만 이 코드를 써두면 다음과 같이 편해집니다.

  • 설정 전 (복잡함):
    "저기... 이름이 'Minion'인 오브젝트를 찾아서, 그 안에 있는 MinionAttack 컴포넌트를 좀 가져와 줄래?"
  • 설정 후 (단순함):
    "MinionAttack.Instance!" (끝)

실제 사용 예시

만약 GameManager라는 클래스에 이 코드가 있다면, 다른 어떤 스크립트에서도 GameManager.Instance.score += 10; 같은 식으로 즉시 접근이 가능해집니다.

 

 

 

 

음, 싱글톤 패턴 사용하는 이유는, 

ManaManager.Instance.RefillAndIncreaseMax(true);

다른 스크립트의 함수 이용하려고 그런거네 

(다른 스크립트들과의 상호작용<싱글톤 패턴으로 하는거구나) 

 

 

 

 

 

1. "누구 거?"라고 되물어봐야 한다면 안 되는 것 (Static → Non-static)

  • 상황: static 메소드(공통 기능) 안에서 일반 변수(개인 데이터)를 쓰려고 할 때.
  • 컴퓨터의 생각: "자, Attack()이라는 공통 기능을 실행하자. 어? 그런데 여기 damage라는 변수를 쓰네? 잠깐, 누구의 damage를 말하는 거야? 필드에 하수인이 5마리나 있는데, 1번 하수인 거야? 5번 하수인 거야?"
  • 결론: 대상을 특정할 수 없기 때문에 에러!

💡 안 헷갈리는 법: static 입장에서 일반 변수를 부를 때 **"어떤 놈 건지 알 수 있나?"**라고 자문해 보세요. 대답할 수 없으면 안 되는 겁니다

2. "항상 그 자리에 있는 것"은 언제든 부를 수 있는 것 (Non-static → Static)

  • 상황: 일반 메소드(개인 기능) 안에서 static 변수(공통 데이터)를 쓰려고 할 때.
  • 컴퓨터의 생각: "자, 3번 하수인이 공격을 한다. 어? ManaManager.Instance를 참조하네? 이건 세상에 딱 하나밖에 없는 공용 보관함이니까 주소 찾기 쉽지! 오케이, 통과!"
  • 결론: 공통 데이터는 언제나 그 자리에 단 하나만 존재하므로, 누가 불러도 헷갈릴 일이 없어서 가능!

3. "존재하는 시간"으로 이해하기 (우선순위)

이게 가장 확실한 방법입니다.

  1. Static (형님): 게임이 켜지자마자(메모리에 올라가자마자) 태어납니다. (객체가 있든 없든 존재)
  2. Non-static (동생): new 하거나 오브젝트가 생성될 때 태어납니다.
  • 동생(Non-static)이 형님(Static)을 부르는 것:
    동생이 태어났을 땐 이미 형님이 태어나서 앉아 있습니다. 그러니까 당연히 부를 수 있습니다.
  • 형님(Static)이 동생(Non-static)을 부르는 것:
    형님이 태어났을 땐 동생들이 아직 태어났는지, 몇 명이나 있는지 알 수가 없습니다. 태어나지도 않은 동생의 이름을 부를 수는 없죠? 그래서 직접 부르는 건 안 됩니다.

 

 

 

 

 

 

[   ]

🛠 싱글톤(Instance)은 이 문제를 어떻게 해결한 건가요?

위의 **형님(Static)**이 자기 방에 동생(Non-static) 한 명을 아예 입주시킨 것입니다.

  • 원래 static은 일반 변수를 못 쓰지만,
  • public static MinionAttack Instance; 라고 해서 **자기 자신(객체)**을 static이라는 공용 보관함에 딱 넣어버리면,
  • 다른 놈들이 MinionAttack.Instance라고 부르는 순간, **"보관함(Static)을 통해 그 안에 있는 동생(Non-static)에게 접근"**하는 우회로가 생기는 것입니다.

 

 

 

ClassName.Method()를 쓰려면 그 메소드도 static이어야 합니다.

자바에서 Math.abs()를 쓸 수 있는 이유는 abs 메소드가 static으로 선언되어 있기 때문입니다. 그런데 유니티에서 매니저의 함수를 static으로 만들면 치명적인 문제가 생깁니다.

public class ManaManager : MonoBehaviour {
    public int currentMana; // 일반 변수 (인스펙터 노출)

    // 만약 자바처럼 호출하려고 static을 붙인다면?
    public static void Refill() {
        currentMana = 10; // 에러 발생! (static은 일반 변수 currentMana를 모름)
    }
}

위처럼 static 메소드는 일반 변수를 건드릴 수 없습니다. 게임 매니저들은 "현재 점수", "현재 마나", "내 체력" 같은 데이터를 계속 바꿔야 하는데, static 메소드만으로는 이 데이터를 다룰 수가 없습니다.

 

 

 

 

"하나의 실체"를 보장하기 위해서입니다.

자바에서 static만 쓰면 그건 그냥 "기능"의 모음일 뿐입니다. 하지만 유니티는 **오브젝트(컴포넌트)**가 실제로 씬(Scene)에 존재해야만 작동합니다.

  • Instance를 만드는 행위는: "이 게임 안에 ManaManager 오직 이 오브젝트 하나뿐이야. 다들 딴데 가서 찾지 말고, 내가 이 변수(Instance)에 내 주소를 적어놓을 테니 여기로만 연락해!" 라고 약속을 하는 것입니다.

 

 

 

 

 

 

 

 

질문하신 부분은 아주 예리합니다! 언뜻 보면 "코드(싱글톤)가 좋다더니, 왜 이번엔 오브젝트(레이아웃)가 좋다고 하지?"라고 모순처럼 느껴질 수 있습니다.

하지만 이 두 가지는 **"대상을 찾는 방법"**과 **"배치하는 방법"**이라는 서로 다른 차원의 이야기입니다. 헷갈리지 않게 명확히 정리해 드릴게요.

1. 싱글톤 vs GameObject.Find (대상을 찾는 방법)

이건 **"내가 원하는 물건을 어떻게 손에 넣을 것인가?"**의 문제입니다.

  • GameObject.Find (나쁨): 수천 개의 물건이 쌓인 창고에서 이름을 대조하며 물건을 찾아다니는 행위입니다. (느림)
  • Singleton.Instance (좋음): 내 주머니에 물건의 주소를 바로 가지고 있는 행위입니다. (빠름)
  • 결론: 대상을 찾을 때는 무조건 **코드로 직접 참조(싱글톤)**하는 것이 성능상 유리합니다.

2. 좌표 계산 vs 하이어라키 기반 (배치하는 방법)

이건 **"찾은 물건을 화면 어디에 둘 것인가?"**의 문제입니다.

  • 좌표 계산 (수학 기반):
    개발자가 매 프레임마다 카드1.x = 100, 카드2.x = 200 처럼 숫자를 직접 계산해서 입력하는 방식입니다.
    • 카드 수가 변하거나 해상도가 바뀌면 계산식이 매우 복잡해지고 버그가 생기기 쉽습니다.
  • 하이어라키 기반 (유니티 UI 시스템 활용):
    유니티가 미리 만들어둔 **Horizontal Layout Group**이라는 엔진 기능을 사용하는 방식입니다.
    • 우리는 그냥 "카드를 손패(부모) 안에 넣어줘"라고만 명령하고, 순서(SiblingIndex)만 정해줍니다.
    • 그러면 실제 좌표 계산은 유니티 엔진(C++로 작성된 핵심 최적화 코드)이 처리합니다.

3. 왜 이번엔 "오브젝트 기반"이 성능이 좋다고 하나요?

여기서 말하는 "오브젝트 기반"은 Find 함수를 쓰라는 뜻이 아니라, **"유니티의 내장 UI 시스템(Layout Group)을 활용하라"**는 뜻입니다.

  1. 개발 효율: 카드가 5장에서 6장이 될 때, 간격을 벌리는 수학 공식을 직접 짜는 것보다, 유니티의 레이아웃 그룹에 카드 하나를 더 넣는 게 훨씬 쉽습니다.
  2. 검증된 최적화: 유니티의 Layout Group은 수많은 기기에서 테스트된 최적화된 엔진 코드입니다. 우리가 C# 스크립트에서 Update마다 transform.position을 수동으로 계산해서 넣는 것보다 엔진 내부에서 처리하는 것이 훨씬 안정적이고 빠릅니다.
  3. 유연함: 드래그 중에 Placeholder만 이동시키면 나머지 카드들이 자연스럽게 밀려나는 연출은, 좌표로 계산하려면 수십 줄의 코드가 필요하지만 레이아웃 그룹을 쓰면 순서(Sibling Index) 한 줄로 해결됩니다.

최종 요약 (이것만 기억하세요!)

  • 접근할 때(Access): 코드로 직접(싱글톤) 하는 게 성능이 좋습니다. (Find 쓰지 마세요!)
  • 배치할 때(Layout): **유니티 시스템(하이어라키/레이아웃)**을 이용하는 게 성능과 정신 건강에 좋습니다. (일일이 x, y 계산하지 마세요!)

즉, 싱글톤으로 HandManager를 빠르게 찾고(코드의 승리), 그 HandManagerLayout Group을 이용해 카드를 정렬하게(시스템의 승리) 만드는 것이 최고의 조합입니다.

 

 

 

 

 

 

자바에서 배우신 클래스이름.메소드() 방식은 보통 **"상태가 없는 순수 기능(Utility)"**을 쓸 때 사용합니다. 예를 들어 Math.abs(-10) 같은 것들이죠.

하지만 유니티에서 Instance라는 변수를 굳이 만드는 이유는 유니티가 컴포넌트 기반의 엔진이기 때문입니다. 여기에는 3가지 결정적인 이유가 있습니다.


1. 인스펙터(Inspector) 때문입니다 (가장 큰 이유)

유니티는 코드로만 짜는 게 아니라, 에디터 화면에서 마우스로 UI나 게임 오브젝트를 드래그해서 연결합니다.

  • 자바식 Static: public static Text manaText; 라고 선언하면 유니티 인스펙터 창에 나타나지 않습니다. 즉, 마우스로 UI를 연결할 수 없습니다.
  • 싱글톤 방식: 변수들을 일반(non-static)으로 만들면 인스펙터에 나타납니다. 우리는 거기에 ManaText 오브젝트를 드래그해서 넣습니다. 그리고 Instance를 통해 이 연결된 정보를 가진 단 하나의 실제 객체에 접근하는 것입니다.

2. MonoBehaviour의 기능을 써야 하기 때문입니다

유니티의 핵심 기능들(Update, Start, 코루틴 등)은 **실제 게임 세상에 존재하는 오브젝트(MonoBehaviour)**만 사용할 수 있습니다.

  • 자바식 Static 클래스: Update()를 사용할 수 없습니다. 매 프레임 마나를 체크하거나 시간을 잴 수 없습니다.
  • 싱글톤 방식: TurnManager가 게임 세상에 실제로 존재하면서 매 프레임 시간을 재고(Update), 그 결과를 다른 스크립트들이 Instance를 통해 확인할 수 있게 해줍니다.

3. "데이터 보존"과 "메모리" 문제 때문입니다

자바에서 static 메소드만 쓰면, 그 안의 모든 변수도 static이어야 합니다. 하지만 게임에서는 하수인이 죽으면 데이터가 사라져야 하고, 새로운 게임을 시작하면 초기화되어야 합니다.

  • 전부 Static일 때: 게임을 껐다 켜지 않는 이상 데이터가 메모리에 계속 박혀 있어서 초기화하기가 매우 번거롭습니다.
  • 싱글톤일 때: Instance가 가리키는 오브젝트만 파괴하거나 새로 만들면 모든 데이터가 깔끔하게 관리됩니다.

쉽게 비교해볼까요?

① 자바식 Static (도구상자)

Math.Max(a, b)

  • 이 계산기는 세상 어디에 있든 똑같습니다.
  • "누가" 계산하는지는 중요하지 않고 결과만 중요합니다.

② 유니티 싱글톤 (반장)

TurnManager.Instance.StartTurn()

  • 이건 그냥 '계산'이 아니라, **실제 우리 반에 있는 '반장'**에게 일을 시키는 겁니다.
  • 반장은 출석부(데이터)도 가지고 있고, 시계(Update)도 보고 있습니다.
  • 우리는 **공용 게시판(static Instance)**에 적힌 "이번 학기 반장은 '철수'다"라는 정보를 보고 철수에게 가서 말을 거는 것입니다.

결론: 왜 Instance를 거치나요?

자바처럼 TurnManager.StartTurn()이라고 바로 부르려면 StartTurnstatic이어야 합니다. 하지만 static 함수는 인스펙터에 끌어다 놓은 UI 변수들을 건드릴 수 없습니다. (규칙 A: 스태틱은 넌-스태틱을 모른다!)

그래서 **"나(객체)한테 올 수 있는 지름길(Instance)만 static으로 열어줄 테니, 일단 여기로 들어와서 내 일반 함수들을 실행해!"**라고 판을 짜는 것입니다. 이것이 유니티에서 싱글톤을 쓰는 핵심 이유입니다.

 

 


📌ternmanager.cs해석

 

1. 왜 CardDrawer는 싱글톤으로 만들지 않았나요?

가장 큰 이유는 **"똑같은 클래스가 두 개 존재하기 때문"**입니다.

  • 싱글톤(TurnManager): 게임 내에 단 하나만 존재합니다. "누구의 턴인가?"라는 규칙은 게임 전체에 하나면 충분하니까요.
  • 일반 변수(CardDrawer): playerCardDrawer enemyCardDrawer 두 개가 있습니다.
    • 만약 CardDrawer를 싱글톤(Instance)으로 만들면, 컴퓨터는 "플레이어의 덱"과 "적의 덱" 중 누구를 Instance라고 불러야 할지 몰라 대혼란에 빠집니다.
    • 그래서 싱글톤이 아니라, TurnManager가 두 개의 CardDrawer를 각각 자기 변수에 담아서 관리하는 방식을 쓰는 것입니다.

2. "유니티에서 연결해주는 것"과 "싱글톤"의 차이

네, 질문하신 대로 유니티 인스펙터 창에서 드래그해서 연결해주는 방식입니다. 역할은 **"객체의 주소를 가져온다"**는 점에서 똑같지만, 과정이 다릅니다.

방식 어떻게 주소를 알아내는가? 특징
싱글톤 (Instance) "공용 게시판"에서 주소를 직접 찾아감. 어디서든 TurnManager.Instance라고 부르면 즉시 연결됨. (매우 편함)
인스펙터 연결 (public) 유니티 에디터에서 마우스로 미리 선을 연결해둠. TurnManager만이 이 CardDrawer들이 어디 있는지 정확히 알고 있음. (두 개 이상일 때 필수)

 

 

 

  • 싱글톤을 쓰는 경우:
    • 게임에 딱 하나만 있는 '관리자' (턴 관리자, 마나 관리자, 사운드 관리자 등)
    • 누구나 이 관리자를 수시로 불러서 질문해야 할 때.
  • 인스펙터 연결(변수)을 쓰는 경우:
    • 똑같은 기능을 하는 물체가 여러 개 있을 때 (플레이어 덱 vs 적 덱, 왼쪽 눈 vs 오른쪽 눈 등)
    • 어떤 특정 관리자만 알고 있으면 되는 부품 같은 존재일 때.

 

 

 

 

1. "누가" 실행하는가의 차이 (Static vs Instance)

  • ManaManager.UpdateManaUI(); (자바에서 자주 보던 방식)
    • 이게 가능하려면 UpdateManaUI 함수 앞에 **static**이 붙어있어야 합니다.
    • static 함수는 "설계도(클래스)에 박혀있는 기능"입니다.
    • 하지만 이 함수는 "현재 게임 화면에 떠 있는 마나 텍스트 UI"가 무엇인지 알 길이 없습니다. (스태틱은 넌-스태틱을 모른다!)
  • ManaManager.Instance.UpdateManaUI(); (유니티 싱글톤 방식)
    • UpdateManaUI 함수는 **일반 함수(non-static)**입니다.
    • 이 함수는 "실제 게임 세상에 존재하는 마나매니저 오브젝트"가 실행하는 기능입니다.
    • Instance라는 지름길을 통해 **"지금 씬에 배치된 바로 그 마나매니저"**를 찾아가서 일을 시키는 것이기 때문에, 그 매니저가 가지고 있는 **UI 변수들(Text, Slider 등)**을 마음껏 수정할 수 있습니다.

2. "인스펙터(Inspector) 연결"의 유무

이게 유니티에서 가장 중요한 이유입니다.

public class ManaManager : MonoBehaviour {
    public Text manaText; // 유니티 화면에서 드래그해서 넣어준 글자 UI

    // 1. 만약 static 함수라면? (자바 방식)
    public static void UpdateManaUI() {
        manaText.text = "10"; // 에러! static 함수는 manaText가 뭔지 모름
    }

    // 2. 일반 함수라면? (싱글톤으로 호출하는 방식)
    public void UpdateManaUI() {
        manaText.text = "10"; // 성공! 내(Instance)가 가지고 있는 변수니까!
    }
}

자바에서는 보통 모든 데이터를 코드로 생성하지만, 유니티는 **"화면에 있는 UI를 코드에 연결"**해서 씁니다. static 함수는 이 연결된 UI들을 인식할 수 없기 때문에, 반드시 Instance라는 실제 객체를 거쳐서 일반 함수를 실행해야 합니다.

 

 

 

 

UpdateManaUI() 함수 자체에는 static이 붙어 있지 않습니다

 

  • 1단계: ManaManager.Instance
    • Instance 변수는 **static**이기 때문에, 클래스 이름만으로 바로 접근이 가능합니다.
    • 이 안에는 무엇이 들어있죠? Awake()에서 넣어둔 **실제 마나매니저 객체(Instance)**가 들어있습니다.
  • 2단계: ... .UpdateManaUI();
    • 자, 이제 1단계를 통해 **"실제 마나매니저 일꾼"**을 찾아냈습니다.
    • 그 일꾼은 UpdateManaUI()라는 기술을 가지고 있는 상태입니다.
    • 비록 그 기술(UpdateManaUI)이 static 기술은 아니지만, 일꾼 본체를 직접 찾아왔기 때문에 그 일꾼의 개인적인 기술을 실행하라고 명령할 수 있는 것입니다.

 

 

 

자바에서도 특정 상황에서는 싱글톤 패턴이 일반 스태틱 함수보다 훨씬 유리합니다.

자바 개발(특히 백엔드나 안드로이드)에서도 Math.abs() 같은 단순 계산이 아니라면, 대부분 **싱글톤(또는 그와 유사한 방식)**을 사용합니다. 왜 그런지 이유를 3가지로 딱 정리해 드릴게요.


1. "상태(데이터)"를 가질 수 있기 때문입니다. (Stateful vs Stateless)

  • 스태틱 함수 (Math.abs 등):
    입력값을 주면 결과값만 내뱉고 끝납니다. 자기가 예전에 뭘 했는지 기억하지 못합니다. (상태가 없음)
  • 싱글톤 (매니저 객체 등):
    "현재 로그인한 유저 정보", "데이터베이스 연결 상태", "게임의 현재 마나" 등을 변수에 저장하고 유지할 수 있습니다. (상태가 있음)

만약 자바에서 static 변수만 써서 상태를 유지하려고 하면, 프로그램 전체에서 공유되는 거대한 전역 변수가 되어버려 나중에 어디서 데이터가 꼬였는지 찾기가 매우 힘들어집니다. (이것을 '스파게티 코드'의 원인이라고 합니다.)


2. 객체지향의 꽃 "다형성(Polymorphism)"을 쓸 수 있습니다.

이게 가장 결정적인 차이입니다.

  • 스태틱 함수:
    오버라이딩(재정의)이 안 됩니다. Math.abs()의 기능을 내가 살짝 바꿔서 쓰고 싶어도 바꿀 방법이 없습니다. 설계도에 박혀있으니까요.
  • 싱글톤:
    싱글톤은 **'객체'**입니다. 즉, 인터페이스를 상속받을 수 있습니다.
    • 예를 들어, DatabaseManager라는 인터페이스를 만들고, 실제 게임에서는 MySQLManager 싱글톤을 넣었다가, 테스트할 때는 MockDatabaseManager(가짜) 싱글톤으로 갈아끼우는 것이 가능합니다. 스태틱 함수는 이게 절대 안 됩니다.

3. 메모리 관리와 "Lazy Loading" (게으른 로딩)

  • 스태틱:
    프로그램이 실행될 때 무조건 메모리에 올라가서 프로그램이 끝날 때까지 자리를 차지합니다. 쓰지도 않는데 메모리만 먹을 수 있죠.
  • 싱글톤:
    getInstance()를 처음 호출하는 순간에만 객체를 만들게 할 수 있습니다. (필요할 때만 메모리 사용) 또한, 필요 없으면 객체를 파괴하고 새로 만드는 식으로 관리가 가능합니다.

💡 그럼 자바에서는 왜 클래스.함수()를 많이 쓴 것 같을까요?

자바 공부 초반에는 주로 '유틸리티(Utility)' 성격의 코드를 많이 짜기 때문입니다.

  • "문자열을 숫자로 바꾸기", "날짜 포맷 변경하기", "수학 계산하기" 등은 굳이 객체를 만들 필요가 없는 단순 노동들이라 static이 편합니다.

하지만 실제 서비스에서는:

  • 스프링 프레임워크(Java Spring): 자바 백엔드 표준인 스프링의 핵심이 바로 모든 서비스 객체를 싱글톤으로 관리하는 것입니다. (우리가 직접 Instance 변수를 안 만들어도 스프링이 자동으로 관리해줍니다.)
  • 안드로이드: LocationManager, NotificationManager 등 시스템 서비스들이 전부 싱글톤 방식으로 작동합니다.

결론: 무엇이 더 좋은가?

  1. 단순 계산, 변하지 않는 공식 → 자바식 Static 함수가 유리 (Math.abs)
  2. 데이터를 저장해야 함, UI를 바꿔야 함, 다른 객체와 협력해야 함 → **싱글톤(Instance)**이 압도적으로 유리

 

 

 

 

1. Instance.메소드객체.메소드가 맞는가?

네, 100% 맞습니다.
ManaManager.Instance라고 쓰는 순간, 그것은 메모리 어딘가에 살고 있는 **"실제 ManaManager 객체"**를 가리키는 것입니다. 따라서 그 뒤에 메소드를 붙이는 것은 자바에서 manager.update()라고 부르는 것과 완전히 똑같은 원리입니다.

2. Awake에서 Instance = this라고 한 게 new인가?

엄밀히 말하면 new는 아니지만, 결과적으로는 new된 객체를 잡는 것과 같습니다.

  • 자바 (표준 C#): 개발자가 직접 ManaManager manager = new ManaManager();라고 타이핑해서 객체를 만듭니다.
  • 유니티: 개발자가 new를 쓰지 않습니다. 대신 하이어라키(Hierarchy) 창에 스크립트를 드래그해서 오브젝트에 붙이는 순간, 유니티 엔진이 게임을 시작할 때 내부적으로 new와 유사한 과정을 거쳐 객체를 생성해 줍니다.

여기서 this의 의미:

이미 유니티 엔진에 의해 만들어진 **"나 자신(객체)"**을 의미합니다.

  • 유니티: "자, 내가 ManaManager 객체를 하나 만들어서 게임 월드에 배치했다!"
  • 객체(나): "오, 내가 태어났네? 그럼 내 주소(this)를 공용 보관함(Instance)에 적어둬야지!" -> Instance = this;

3. 왜 new를 직접 안 쓰고 이런 복잡한 과정을 거치나요?

유니티의 MonoBehaviour를 상속받은 클래스들은 일반적인 new를 쓸 수 없도록 설계되어 있습니다. 그 이유는 이 객체들이 **"게임 월드 안에서의 육체(GameObject)"**를 가져야 하기 때문입니다.

  1. 육체(GameObject) 생성: 유니티 엔진이 담당.
  2. 영혼(Script/객체) 생성: 유니티 엔진이 담당.
  3. 지름길 연결 (Instance = this): 개발자가 코드로 담당.

💡 정리하자면 이렇습니다.

  • 자바에서의 호출:
    ManaManager m = new ManaManager(); // 내가 직접 만듦
    m.update(); // 객체.메소드
    1. 에디터에서 스크립트를 오브젝트에 붙임 (유니티가 대신 객체를 만들어줌)
    2. Awake() 실행: Instance = this; (만들어진 객체의 주소를 보관함에 넣음)
    3. 다른 곳에서 호출:유니티에서의 호출:
    ManaManager.Instance.update(); // 보관함에 있는 "그 객체".메소드

최종 결론

ManaManager.Instance는 **유니티 엔진이 new 해준 객체의 "실시간 주소"**입니다.

그래서 ManaManager.Instance.UpdateManaUI()는 자바에서 객체를 생성해 변수에 담아놓고 obj.method()를 호출하는 것과 **완전히 동일한 "객체 지향적 호출"**입니다. 다만 그 객체를 내가 new로 만들었느냐, 유니티 엔진이 만들어준 걸 this로 낚아챘느냐의 차이일 뿐입니다!

 

 

 

 

"매니저는 하나인데, 적의 마나를 채우라고 명령했을 때 내 마나가 차버리면 어떡하지?"라는 걱정은 멀티플레이어나 복잡한 게임 로직을 짤 때 반드시 해야 하는 고민입니다.

결론부터 말씀드리면, ManaManager 클래스 내부에서 bool 값(isPlayer)에 따라 변수를 따로 관리하고 있기 때문에 안전합니다.

 

 

public void RefillAndIncreaseMax(bool isPlayer)
    {
        if (isPlayer)
        {
            // true가 들어오면 플레이어 변수들만 건드림
            playerMaxMana++;
            playerCurrentMana = playerMaxMana;
        }

아, 그래서 역할별로, 마나관련은, 마나 스크립트에서 관리하는거구나 

 

싱글톤이라도, 

메소드에서 매개변수로, 누구의 것인지 구분할수있네 

3명이라면 뭐 int 1,2,3 swoch문을 하던가 

 

프로그래밍에서는 실수를 줄이기 위해 **Enum(열거형)**을 많이 사용합니다.

 

 

 

 

1. foreach (Transform child in playerField)

  • 의미: "playerField(내 필드)라는 박스 안에 들어있는 모든 자식(child) 오브젝트들을 하나씩 꺼내서 확인하겠다"는 뜻입니다.
  • 비유: 전장에 하수인이 3마리 있다면, 첫 번째 하수인 꺼내기 → 두 번째 하수인 꺼내기 → 세 번째 하수인 꺼내기... 순서대로 반복하는 것입니다.

2. MinionAttack ma = child.GetComponent<MinionAttack>();

  • 의미: "꺼낸 자식 오브젝트(child)에게 MinionAttack이라는 스크립트(공격 기능)가 붙어 있는지 확인하고, 있다면 ma라는 이름으로 부르겠다"는 뜻입니다.
  • 왜 하나요? 필드에는 하수인뿐만 아니라 이펙트나 다른 장식물이 있을 수도 있습니다. 그래서 "공격 기능이 있는 진짜 하수인"만 골라내려는 것입니다.

3. if (ma != null)

  • 의미: "만약 MinionAttack 기능을 찾았다면! (null이 아니라면)"
  • 해석: "방금 꺼낸 놈이 공격할 수 있는 하수인이 맞구나!"라고 확신하는 단계입니다.

 

 

Transform 컴포넌트는 그 자체가 "자식들의 리스트"처럼 동작하도록 설계되어 있습니다.

따라서 별도의 리스트를 만들지 않아도 부모 객체인 playerField foreach로 돌리면 그 안에 있는 자식들을 하나씩 꺼낼 수 있습니다

 

 

 

1. 원리: Transform은 "폴더"와 같습니다.

유니티 하이어라키(Hierarchy) 창을 보면 부모 아래에 자식들이 계단식으로 들어가 있죠? 유니티 내부적으로 부모의 Transform은 자기 아래에 붙어 있는 자식들의 Transform 주소를 다 가지고 있습니다.

  • foreach (Transform child in playerField)라고 쓰면:
    1. playerField의 첫 번째 자식을 꺼내서 child에 넣고 코드 실행.
    2. 두 번째 자식을 꺼내서 child에 넣고 코드 실행.
    3. 마지막 자식까지 반복.

2. 특징 (꼭 알아야 할 점)

  1. 바로 아래 자식(1대 자식)만 순회합니다.
    • 자식의 자식(손자)까지는 자동으로 들어가지 않습니다. 딱 부모 바로 밑에 매달려 있는 놈들만 꺼냅니다.
  2. 하이어라키 순서대로 꺼냅니다.
    • 유니티 에디터 창에서 위에서 아래로 배치된 순서(Sibling Index)대로 가져옵니다.
  3. 꺼내지는 타입은 항상 Transform입니다.
    • 그래서 하수인 기능을 쓰려면 child.GetComponent<MinionAttack>()처럼 컴포넌트를 다시 찾아야 합니다.

 

 

 

 

 

 

왜 이렇게 쓰나요? (실전 이유)

카드 게임에서 PlayerField에 카드가 0장일 수도 있고, 7장일 수도 있습니다.

  • 만약 리스트(List<Card>)를 따로 만들어서 관리한다면, 카드가 죽어서 파괴될 때마다 리스트에서도 지워줘야 합니다. (안 지우면 에러 납니다.)
  • 하지만 foreach (Transform child in playerField) 방식을 쓰면, 그냥 실제 눈에 보이는 필드 상황을 그대로 훑는 것이기 때문에 데이터가 꼬일 일이 없습니다. 오브젝트가 파괴되면 자동으로 순회 대상에서 빠지니까요.

 

 

 

1. 에러가 나지 않는 이유: "검색 결과 없음"의 상태

GetComponent<MinionAttack>()은 유니티에게 이렇게 물어보는 것과 같습니다.

"이 자식 오브젝트에 MinionAttack이라는 부품이 붙어있니? 있으면 가져오고, 없으면 그냥 없다고 말해줘."

  • 컴포넌트가 있을 때: 해당 컴포넌트의 주소값을 반환합니다.
  • 컴포넌트가 없을 때: null (아무것도 없음)을 반환합니다.

여기서 중요한 건, null을 반환하는 것 자체는 에러가 아니라는 점입니다. 그냥 "찾아봤는데 없더라"라는 정보를 주는 것이죠.


2. 진짜 에러는 '그 다음'에 발생합니다. (NullReferenceException)

에러는 GetComponent 줄이 아니라, null이 담긴 변수를 사용하려고 할 때 터집니다.

MinionAttack ma = child.GetComponent<MinionAttack>(); // (1) 여기서 없으면 ma는 null이 됨 (에러 X)

ma.canAttack = true; // (2) 만약 ma가 null인데 이걸 실행하면 여기서 에러(Crash)!

그래서 우리가 코드에서 항상 if (ma != null) 이라는 검사 로직을 붙이는 것입니다.

 

 

 

 

 

 

 

 

Transform과 MinionAttack의 관계 (상속 vs 포함)

자바나 C#에서 보통 자료형 변환(casting)은 상속 관계(부모-자식 클래스)일 때 사용합니다. 하지만 Transform MinionAttack은 부모-자식 관계가 아닙니다.

  • GameObject (박스): 이 박스 안에 여러 가지 부품들이 들어있습니다.
    • 부품 1: Transform (위치, 회전, 크기 정보) -> 모든 오브젝트가 가짐
    • 부품 2: MinionAttack (우리가 만든 공격 기능 스크립트)
    • 부품 3: Image (그림 표시 부품)

유니티 하이어라키에서 foreach를 돌리면 유니티는 우리에게 **"자식 오브젝트들의 부품 1(Transform)"**을 꺼내서 줍니다.

우리는 부품 1(Transform)을 가지고 부품 2(MinionAttack)로 변신시킬 수는 없습니다. (둘은 아예 다른 종류의 부품이니까요.) 대신, 부품 1에게 **"너랑 같은 박스 안에 있는 부품 2 좀 찾아봐!"**라고 시켜야 합니다. 그게 바로 GetComponent입니다.