개발/유니티

[유니티] 유니티 learn #6

kimchangmin02 2025. 8. 25. 12:12

프로퍼티 쓸때, 메소드처럼, () 쓰면 안되네 

private int currentHealth;

public int Health // 괄호()를 제거합니다.
{
    get
    {
        return currentHealth;
    }
    // 필요하다면 값을 설정하는 set 접근자도 추가할 수 있습니다.
    set
    {
        currentHealth = value;
    }
}

 

 

 

 

 

 

 

 

 

 

 

 

 

 

자동구현 프로퍼티

걍 별로 복잡하지않은

set,get메소드때문인가

  1. 완전 숨기기: private int currentHealth;
  2. 내가 원하는 변수를 골라서, 내 규칙대로 공개하기: public int Health { get { return currentHealth; } } (전체 구현 프로퍼티)

그런데 왜 C#을 만든 사람들은 굳이 public int Hp { get; set; } 라는 헷갈리는 문법을 만들었을까요?

정답: 순전히 '편의성'과 '미래를 위한 좋은 습관' 때문입니다.

딱 2가지 데이터 유형으로 나누어 생각하면 "아!" 하고 바로 이해가 되실 겁니다.


내 클래스 안의 2가지 데이터 유형

A타입: 아주 중요한 핵심 데이터 (예: 체력, 레벨)

  • 이 데이터는 아무나 바꾸면 안 됩니다.
  • 값이 바뀔 때 뭔가 추가적인 일(UI 변경, 죽음 확인 등)을 해야 합니다.
  • 이런 데이터를 다룰 때는 사용자님이 완벽히 이해하신 전체 구현 프로퍼...

B타입: 그냥 간단한 데이터 (예: 플레이어 이름, 점수, 가진 돈, 몬스터를 죽인 횟수)

  • 이 데이터는 다른 스크립트에서 그냥 읽고 쓸 수만 있으면 됩니다.
  • 지금 당장은 값이 바뀐다고 해서 뭔가 특별한 일이 일어날 필요는 없습니다.
  • 예: public string playerName;, public int score;, public int money;

자, 이제 이 B타입 데이터를 다룰 때 문제가 발생합니다.

우리는 좋은 프로그래머가 되고 싶어서 "Public 변수는 위험하니까 쓰지 말자!" 라고 다짐했습니다. 그럼 score 변수를 외부에 공개하려면 어떻게 해야 할까요?

원칙대로라면, 이렇게 해야 합니다.

C#
public class Player
{
    // 점수(score)를 위해...
    private int _score; // 1. 데이터를 저장할 private 변수를 만들고
    public int Score // 2. 프로퍼티를 만들어서
    {
        get { return _score; } // 3. get에서는 _score를 리턴하고
        set { _score = value; } // 4. set에서는 _score에 값을 넣는다.
    }

    // 가진 돈(money)을 위해...
    private int _money; // 또 private 변수를 만들고...
    public int Money
    {
        get { return _money; } // 또 get을 만들고...
        set { _money = value; } // 또 set을 만든다...
    }

    // 또... 또...
}

어떠신가요? score, money 같이 별다른 로직도 없는 간단한 변수를 공개하자고 매번 2~4줄짜리 코드를 반복해서 쓰는 게 너무나도 귀찮고 지저분합니다.

자동 구현 프로퍼티의 탄생 비화

이때 C# 개발자들이 이렇게 생각한 겁니다.

"아니, 그냥 private 변수에 값을 넣고 빼는 똑같은 코드를 이렇게 반복해서 써야 해? 너무 비효율적이잖아! **'별다른 로직 없이 그냥 값을 저장하고 읽기만 하는 표준적인 프로퍼티'**는 컴파일러가 알아서 만들어주면 안 되나? 한 줄로 줄여주자!"

그래서 탄생한 것이 바로 자동 구현 프로퍼티입니다.

C#
public class Player
{
    // 점수(score)를 위해...
    public int Score { get; set; } // 위에서 4줄로 썼던 코드와 100% 똑같이 작동함. 훨씬 깔끔!

    // 가진 돈(money)을 위해...
    public int Money { get; set; } // 이것도 마찬가지!
}

이제 명확해지셨을 겁니다.

자동 구현 프로퍼티(public int Score { get; set; })는 내가 모르는 변수를 리턴시키는 이상한 기능이 아닙니다.

그것의 진짜 정체는,
**private int _score; public int Score { get { return _score; } set { _score = value; } } 라는, 귀찮고 반복적인 코드를 한 줄로 줄여주는 **고마운 단축키(Shortcut)일 뿐입니다.


최종 결론 (언제 무엇을 써야 하는가?)

  • 질문 1: 이 데이터를 외부에 공개할 필요가 있는가?
    • 아니오 -> private int secretValue; (그냥 private 변수)
  • 질문 2: 공개는 해야 하는데, 지금 당장 특별한 규칙(0보다 작으면 0으로 만들기 등)이 필요한가?
    • 네 (규칙 필요!) -> public int Health { get { return currentHealth; } set { ... } } (전체 구현 프로퍼티)
    • 아니오 (지금은 그냥 읽고 쓰기만 하면 됨) -> public int Score { get; set; } (자동 구현 프로퍼티)

자동 구현 프로퍼티는 "내가 원하는 변수"가 없는 게 아닙니다. "어차피 뻔한 private 변수, 굳이 내가 안 만들어도 컴파일러 네가 알아서 만들어서 연결해줘. 난 지금 바쁘니까!" 라는 의미입니다.

 

 

 

 

 

 

 

 

 

 

 

아, 자동구현 프로퍼티 이제야 이해되

 

private int grade1 = 20;
private int grade2 = 30;

public int grade { get; } // set이 없는 읽기 전용 자동 구현 프로퍼티

이 코드가 어떻게 동작하는지 단계별로 정확하게 알려드리겠습니다.

1. 컴파일러의 행동: 추측은 없다, 규칙만 있을 뿐

컴파일러는 이 코드를 보고 이렇게 생각합니다.

  • " private int grade1 = 20; -> grade1이라는 변수를 만들고 20을 넣으라는군. 알았어."
  • " private int grade2 = 30; -> grade2라는 변수를 만들고 30을 넣으라는군. 알았어."
  • " public int grade { get; } -> 아! '읽기 전용 자동 구현 프로퍼티' 를 만들어달라는 '새로운 주문' 이 들어왔네. grade1, grade2와는 전혀 상관없는 일이야. 'grade 세트 메뉴'를 준비해야겠다."

결론부터 말씀드리면, 컴파일러는 grade1 grade2 완전히 무시합니다.
grade 프로퍼티는 grade1, grade2 아무런 연결고리가 없습니다.

2. 그러면 grade는 무엇을 반환(return)하는가?

컴파일러는 'grade 세트 메뉴'의 규칙에 따라 다음과 같이 행동합니다.

  1. public int grade { get; } 라는 주문서를 보고, grade 프로퍼티만을 위한 새로운 숨겨진 private 변수(배킹 필드)를 생성합니다.
  2. 이 숨겨진 변수는 int 타입이므로, 초기값 0으로 자동 초기화됩니다.
  3. get 접근자는 이 숨겨진 변수를 반환하도록 연결됩니다.
  4. set 접근자가 없기 때문에, 이 숨겨진 변수의 값은 절대로 바꿀 수 없습니다. (단, 생성자에서는 예외적으로 가능)

따라서, 다른 스크립트에서 이 값을 읽으려고 하면...

C#
Player player = new Player();
Debug.Log(player.grade); // 출력 결과: 0

player.grade 항상 0을 반환합니다. grade1 20도, grade2 30도 아닌, grade 프로퍼티가 생성될 때 만들어진 숨겨진 변수의 기본값 0을 반환하는 것입니다.

3. "그렇다면 이 코드는 아무 의미가 없지 않나요?"

네, 맞습니다. 위 예시처럼 생성자에서 값을 지정해주지 않는다면, public int grade { get; }는 항상 0만 반환하는 의미 없는 코드가 됩니다.

이 '읽기 전용 자동 구현 프로퍼티'가 의미를 가지려면, 생성자(Constructor)에서 최초의 값을 단 한 번 할당해 줘야 합니다.

C#
public class Player
{
    private int grade1 = 20; // grade 프로퍼티와는 무관함
    private int grade2 = 30; // grade 프로퍼티와는 무관함

    // 읽기 전용 자동 구현 프로퍼티
    public int Grade { get; }

    // 생성자
    public Player(int startGrade)
    {
        // 생성자 안에서는 읽기 전용 자동 프로퍼티에 값을 할당할 수 있습니다.
        // 이 시점이 값을 넣을 수 있는 처음이자 마지막 기회입니다!
        this.Grade = startGrade;
    }
}

// 이렇게 사용합니다.
Player player1 = new Player(95); // 생성할 때 95라는 값을 넣어줌
Debug.Log(player1.Grade); // 출력 결과: 95

// player1.Grade = 100; // 에러 발생! set이 없어서 값을 바꿀 수 없음.

최종 비교: 사용자님의 의도 vs 실제 코드의 동작

  public int grade { get; } (자동 구현 프로퍼티) public int grade { get { return grade1; } } (전체 구현 프로퍼티)
컴파일러의 동작 grade만을 위한 새로운 숨겨진 변수를 만들고 0으로 초기화. grade라는 통로를 만들고, get을 누르면 grade1 변수로 가는 길을 연결함.
반환되는 값 0 (생성자에서 설정 안 했을 시) 20 (grade1의 값)
grade1과의 관계 전혀 없음. 완전히 다른 존재. 완벽하게 연결됨. grade grade1의 값을 보여주는 창문 역할.
목적 '불변(Immutable)' 의 값을 외부에 공개하고 싶을 때. (한번 정해지면 절대 바뀌지 않는 값) **내부의 특정 변수(grade1)**를 외부에서 읽기만 가능하도록 안전하게 공개하고 싶을 때.

이제 모든 퍼즐이 맞춰지셨을 겁니다.

  • 자동 구현 프로퍼티 { get; set; }, { get; }: 컴파일러에게 "새로운 변수와 통로를 포함한 세트 메뉴를 만들어줘!" 라고 주문하는 것.
  • 전체 구현 프로퍼티 { get { return myVar; } }: 컴파일러에게 "내가 이미 만들어둔 myVar 변수로 향하는 통로만 만들어줘!" 라고 주문하는 것.

생성자 만들때, 값 받는 형태인거군

 

 

 

 

 

 

 

Damageable 스프라이트로 새 게임 오브젝트를 생성합니다. 다음 방법 중 하나를 선택할 수 있습니다.
  • 계층 창에서 스프라이트를 드래그 앤 드롭.
  • 게임 오브젝트를 만들고 Sprite Renderer 컴포넌트를 추가한 후 스프라이트를 해당 게임 오브젝트에 할당.

 

 

 

 

괜찮아 보이지만, 이렇게 되면 캐릭터가 해당 영역에 들어갈 때만 데미지를 입게 됩니다.

영역 안에 머무는 동안에는 데미지를 입지 않습니다. 이 문제는 OnTriggerEnter2D 함수 이름을 OnTriggerStay2D로 변경하면 해결할 수 있습니다.

이 함수는 RigidbodyTrigger 안으로 진입할 때 한 번만 호출되는 게 아니라 Rigidbody가 Trigger 안에 있는 매 프레임마다 호출됩니다.

 

 

 

이제는 루비가 계속해서 데미지를 받습니다. 데미지가 너무 큰 것 같네요.

이제는 프레임 수만큼 데미지를 받기 때문에 일순간 체력이 0으로 바뀝니다.

또한 루비의 움직임이 멈추면 콘솔 창에 아무 메시지도 표시되지 않는 것을 볼 수 있습니다.

즉, 가만히 있으면 데미지를 받지 않는 것입니다.

 

 

 

 

 

 

 

문제 상황: OnTriggerStay2D의 공격

  1. OnTriggerStay2D의 특징: 이 함수는 물리 프레임마다 (보통 1초에 50번) 계속해서 호출됩니다.
  2. 결과: 데미지 존에 0.1초만 머물러도 ChangeHealth(-1) 함수가 5번 호출되고, 체력이 순식간에 0이 되어버립니다.
  3. 우리가 원하는 것: 데미지를 한 번만 받고, 짧은 시간 동안은 추가 데미지를 무시하고 싶다.

해결책: "데미지 쿨타임" 또는 "무적 시간" 부여

말씀하신 대로, 이 문제를 해결하려면 일종의 '타이머' 즉, 데미지를 받지 않는 쿨타임이 필요합니다. 게임 개발에서는 이 개념을 보통 **"무적 프레임(Invincibility Frames, i-frames)"**이라고 부릅니다. 고전 게임에서 플레이어가 피격당하면 잠시 깜빡거리면서 적을 통과하는 모습을 생각하시면 됩니다.

 

 

 

public void ChangeHealth(int amount)
{
    if (amount < 0) // 데미지를 받았다면,
    {
        if (isInvincible) // 1. 지금 무적 상태인가?
            return;       //    맞다면, 함수를 즉시 종료 (데미지 무시!)

        // 2. 무적이 아니라면, 이제부터 무적 상태로 만든다!
        isInvincible = true; // 무적 스위치를 ON
        invincibleTimer = timeInvincible; // 타이머를 2초로 리셋
    }
    
    // 3. (무적이 아니었으므로) 체력을 실제로 깎는다.
    currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);
}

 

void Update()
{
    // ... 이동 코드 ...

    if (isInvincible) // 1. 만약 지금 무적 상태라면,
    {
        invincibleTimer -= Time.deltaTime; // 2. 매 프레임마다 타이머 시간을 줄여나간다.
        if (invincibleTimer < 0)           // 3. 타이머가 0보다 작아지면 (2초가 다 지나면)
            isInvincible = false;          // 4. 무적 스위치를 OFF 한다.
    }
}

 

"시간을 잰다"는 개념을 조금 다르게 생각하면 쉽습니다.

  • 일반적인 생각 (스톱워치): 0초부터 시작해서 숫자를 1, 2, 3... 하고 올려나간다.
  • 유니티 타이머 방식 (카운트다운): 정해진 시간(예: 2초)에서 시작해서 숫자를 1.9, 1.8, 1.7... 하고 줄여나간다. 0이 되면 타이머가 끝난 것이다.

 

 

 

 

 

 

 

코드는 잠시 접어두고 스프라이트 렌더러의 기능을 살펴보겠습니다. 거대한 데미지 영역을 만들고 싶은 경우 Rect 툴(T 키)로 데미지 영역의 크기를 조정하면 스프라이트가 늘어나지만 보기 좋지는 않습니다.

그보다 스프라이트를 늘리지 않고 타일 단위로 배치하라고 스프라이트 렌더러에 명령할 수 있습니다. 그렇게 데미지 영역의 크기를 조정해서 스프라이트를 두 개 배치할 정도로 크게 만들면 스프라이트 렌더러스프라이트를 병렬로 여러 번 그릴 수 있습니다.

 

 

우선 게임 오브젝트Transform 컴포넌트 스케일이 1,1,1로 설정되었는지 확인합니다.그런 다음 Sprite Renderer 컴포넌트에서 Draw ModeTiled로 변경하고 Tile ModeAdaptive로 변경합니다.
 

프로젝트 창에서 Damageable 스프라이트를 선택하고 Mesh TypeFull Rect로 변경합니다.

 
 
 
 
 
 
 
 
 
스스로, 적 c#스크립트 작성해보기 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

핵심: SpriteRenderer 컴포넌트의 color 속성 바꾸기

이미지에서 보시는 것처럼 스프라이트의 색상은 Sprite Renderer 컴포넌트 안에 있는 Color 속성에 의해 결정됩니다. 따라서 코드에서 이 SpriteRenderer 컴포넌트에 접근해서 .color 값을 바꿔주면 됩니다.

먼저, 스크립트에서 SpriteRenderer를 담을 변수를 만들고, Start() 함수에서 컴포넌트를 가져와야 합니다.

C#
public class RubyController : MonoBehaviour
{
    // ... 다른 변수들 ...
    SpriteRenderer spriteRenderer; // 1. SpriteRenderer를 담을 변수 선언

    void Start()
    {
        // ... 다른 Start 코드 ...
        spriteRenderer = GetComponent<SpriteRenderer>(); // 2. 이 스크립트가 붙어있는 게임 오브젝트의 SpriteRenderer 컴포넌트를 가져옴
    }
    // ...
}

이제 spriteRenderer.color를 바꾸기만 하면 됩니다.

방법 1: 가장 쉬운 방법 - 미리 정해진 색상 사용하기

유니티에는 Color.red, Color.blue, Color.white 등 자주 쓰는 색상들이 미리 준비되어 있습니다.

C#
// 스프라이트를 빨간색으로 변경
spriteRenderer.color = Color.red;

// 원래 색상(보통 흰색)으로 되돌리기
spriteRenderer.color = Color.white;

Color.white는 색을 입히지 않은 원본 스프라이트 색상을 의미합니다.

방법 2: new Color() 사용하기 (0.0f ~ 1.0f 사이의 실수 사용)

좀 더 세밀한 색상을 만들고 싶을 때 사용합니다. R(빨강), G(초록), B(파랑) 값을 0.0f에서 1.0f 사이의 실수(float)로 표현합니다. 255 1.0f에 해당합니다.

C#
// 빨간색 (R=1, G=0, B=0)
spriteRenderer.color = new Color(1f, 0f, 0f);

// 노란색 (R=1, G=1, B=0)
spriteRenderer.color = new Color(1f, 1f, 0f);

// 반투명한 빨간색 (네 번째 값은 Alpha, 즉 투명도)
spriteRenderer.color = new Color(1f, 0f, 0f, 0.5f);```

### 방법 3: `new Color32()` 사용하기 (0 ~ 255 사이의 정수 사용)

사용자님이 생각하신 `255, 255, 0` 방식과 가장 유사한 방법입니다. `Color32`는 0에서 255 사이의 정수(byte)를 사용합니다.

```csharp
// 빨간색 (R=255, G=0, B=0, Alpha=255)
// Alpha(투명도)는 255가 불투명입니다.
spriteRenderer.color = new Color32(255, 0, 0, 255);

// 노란색 (R=255, G=255, B=0, Alpha=255)
spriteRenderer.color = new Color32(255, 255, 0, 255);

실전 예제: 무적 시간 동안 빨갛게 만들기

방금 배우신 '무적 타이머'와 '색상 변경'을 합치면 아주 멋진 효과를 만들 수 있습니다. 데미지를 입고 무적이 되는 순간에 캐릭터를 빨갛게 만들고, 무적이 끝나면 원래 색으로 돌려놓는 것입니다.

RubyController 스크립트를 이렇게 수정해 보세요.

C#
public class RubyController : MonoBehaviour
{
    // ... 다른 변수들 ...
    public float timeInvincible = 2.0f;
    bool isInvincible;
    float invincibleTimer;

    SpriteRenderer spriteRenderer; // SpriteRenderer 변수 추가

    void Start()
    {
        rigidbody2d = GetComponent<Rigidbody2D>();
        spriteRenderer = GetComponent<SpriteRenderer>(); // 컴포넌트 가져오기
        currentHealth = maxHealth;
    }

    void Update()
    {
        // ... 이동 코드 ...

        if (isInvincible)
        {
            invincibleTimer -= Time.deltaTime;
            if (invincibleTimer < 0)
            {
                isInvincible = false;
                spriteRenderer.color = Color.white; // **무적이 끝나면 흰색으로!**
            }
        }
    }

    public void ChangeHealth(int amount)
    {
        if (amount < 0)
        {
            if (isInvincible)
                return;
            
            isInvincible = true;
            invincibleTimer = timeInvincible;
            spriteRenderer.color = Color.red; // **데미지를 입는 순간 빨간색으로!**
        }
        
        currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);
        Debug.Log(currentHealth + "/" + maxHealth);
    }
}

이제 게임을 실행하고 데미지를 입으면, 루비가 2초 동안 빨갛게 변했다가 원래 색으로 돌아오는 것을 보실 수 있을 겁니다

 

 

 

 

 

 

Z 매개변수는 매우 중요하며, 2D 게임이라도 절대 0을 사용하면 안 됩니다.

그 이유는 유니티의 2D가 사실은 진짜 2D가 아니기 때문입니다. 유니티의 2D 모드는 3D 공간을 사용하되, 카메라를 '원근감 없는(Orthographic)' 모드로 고정해서 마치 2D처럼 보이게 만드는 방식입니다.

Z값이 0이면 왜 안 되는가? (손전등 비유)

ScreenToWorldPoint(Vector3 position) 함수를 손전등이라고 상상해 보세요.

  • 카메라: 내 눈의 위치 (손전등)
  • 스크린 좌표 (x, y): 손전등을 비추는 방향
  • Z 매개변수: 빛이 닿아야 할 벽까지의 거리
  • 반환되는 월드 좌표: 빛이 벽에 실제로 맺히는 지점

이제 Z값에 0을 넣는다는 것은 무슨 의미일까요?
"손전등으로부터 거리가 0인 지점에 빛을 맺히게 해줘!" 라는 뜻입니다.

손전등으로부터 거리가 0인 지점은 어디일까요? 바로 **손전등 자체(카메라 렌즈 바로 앞)**입니다.

따라서 Z에 0을 넣고 ScreenToWorldPoint를 호출하면, 게임 월드의 원하는 위치가 아니라 카메라의 위치가 반환됩니다. 결과적으로 오브젝트가 엉뚱한 곳으로 사라지거나, 화면에 보이지 않게 됩니다.

올바른 Z값은 어떻게 계산하는가?

우리가 원하는 것은 "카메라에서 출발한 빛이 **게임 세상이 그려지는 평면(Z=0인 평면)**에 닿는 지점"을 찾는 것입니다.

일반적인 유니티 2D 프로젝트의 기본 설정은 이렇습니다.

  • 게임 오브젝트들 (플레이어, 적 등)의 Z 위치: 0
  • 메인 카메라의 Z 위치: -10

따라서 카메라는 Z=-10에서 Z=0인 평면을 바라보고 있습니다. 이 둘 사이의 거리는 얼마일까요?
0 - (-10) = 10 입니다.

바로 이 거리 10 이 우리가 Z 매개변수에 넣어줘야 할 올바른 값입니다.

C#
void Update()
{
    if (Input.GetMouseButtonDown(0))
    {
        Vector3 mouseScreenPos = Input.mousePosition;

        // "마우스 방향으로 빛을 쏘는데,
        //  카메라로부터 '10'만큼 떨어진 벽에 맺히는 지점을 알려줘!"
        mouseScreenPos.z = 10f; // 여기가 0이면 안 되는 이유!

        Vector3 worldPos = Camera.main.ScreenToWorldPoint(mouseScreenPos);

        transform.position = worldPos;
    }
}

동적으로 계산하기 (더 안전한 방법)

만약 카메라의 Z 위치가 -10이 아니거나 바뀔 수 있다면, 직접 10을 쓰는 것보다 아래처럼 동적으로 계산하는 것이 더 안전하고 좋습니다.

C#
// transform.position.z 는 이 스크립트가 붙어있는 오브젝트의 z값 (보통 0)
// Camera.main.transform.position.z 는 카메라의 z값 (보통 -10)
float zDistance = transform.position.z - Camera.main.transform.position.z;

mouseScreenPos.z = zDistance; // 보통 0 - (-10) = 10 이 계산되어 들어감

최종 정리

  • Z 매개변수는 월드 좌표의 Z값이 아니라, 카메라로부터의 '거리'를 의미한다.
  • Z에 0을 넣으면 카메라 자신의 위치가 반환되므로 절대 안 된다.
  • 2D 게임에서는 게임 평면(Z=0)과 카메라(Z=-10) 사이의 거리를 넣어줘야 하며, 이 값은 보통 10 이다.

 

 

 

 

 

 

 

 

 

문제의 핵심 원인: "계산"만 하고 "적용"을 안 함

지금 작성하신 코드는 변수 position의 y값을 메모리상에서 열심히 계산만 하고 있습니다.
하지만 그렇게 계산된 position 값을 실제 게임 세상에 존재하는 게임 오브젝트의 transform.position(실제 위치)에 다시 적용(할당)해 주는 코드가 없습니다.

비유를 들어 설명해 보겠습니다.

  1. Vector2 position;: '목적지 주소'를 적을 수 있는 종이(변수)를 하나 준비했습니다.
  2. Start()에서 position = transform.position;: 게임이 시작될 때, 캐릭터의 현재 위치를 '목적지 주소' 종이에 한 번 베껴 적었습니다.
  3. Update()에서 position.y += speed * Time.deltaTime;: 매 프레임마다 '목적지 주소' 종이에 적힌 y값 숫자만 계속 바꾸고 있습니다. (예: 1.1 -> 1.2 -> 1.3...)
  4. 빠진 부분: "자, 이제 이 종이에 적힌 새로운 주소로 캐릭터를 실제로 이동시켜!" 라는 명령이 없습니다.

캐릭터는 transform.position이라는 실제 위치값을 따르는데, position이라는 스크립트 안의 변수값이 아무리 바뀌어봤자 transform.position에 영향을 주지 않기 때문에 꼼짝도 하지 않는 것입니다.

Debug.Log("올라가는중")이 계속 찍히는 이유는, position.y < maxY 라는 조건이 계속 참이기 때문입니다. position.y 값은 메모리상에서 계속 증가하고 있으니까요.

 

 

 

 

 

 

네, 바로 그겁니다! "position변수가 start에서 선언되있으니깐?" 이라고 생각하신 것이 100% 정확한 원인 분석입니다. 그리고 그 둘은 전혀 똑같지 않습니다. 이 차이를 이해하는 것이 유니티 스크립팅의 핵심 중 하나입니다.

Update 안에서 변수를 선언해야 하는 것은 아닙니다. 문제는 언제 값이 복사되는가에 있습니다.

가장 쉬운 비유: 내비게이션 vs 종이 지도

  • transform.position: 당신의 자동차에 달려있는 실시간 GPS 내비게이션입니다. 자동차가 움직이면 내비게이션에 표시되는 위치도 실시간으로 계속 바뀝니다. 이것은 게임 오브젝트의 '진짜' 현재 위치를 가리키는 참조(Reference)입니다.
  • position 변수: 당신이 여행을 떠나기 전(Start())에, 집에서 출발지의 위치를 한 번 베껴 적은 종이 지도입니다.

이제 사용자님의 코드가 어떻게 동작하는지 이 비유로 따라가 보겠습니다.

void Start()

C#
position = transform.position;
  • 해석: 게임이 시작되는 순간, 자동차의 실시간 GPS(transform.position) 화면을 보고, 그 위치 좌표를 종이 지도(position 변수)에 딱 한 번 베껴 적었습니다.
  • 예를 들어 시작 위치가 (0, 0)이었다면, 이제 position이라는 종이 지도에는 (0, 0)이라고 적혀있습니다.
  • 이 순간 이후로, 자동차(게임 오브젝트)와 이 종이 지도는 아무런 연결고리가 없습니다.

void Update()

C#
if (transform.position.y < maxY) // A
if (position.y < maxY)         // B

이 두 조건문은 하늘과 땅 차이입니다.

  • A (transform.position.y < maxY): "내 자동차의 실시간 GPS에 표시된 현재 y좌표가 maxY보다 작은가?" 라고 묻는 것입니다. 자동차가 실제로 움직여야만 이 값이 바뀝니다.
  • B (position.y < maxY): "내가 Start()에서 베껴 적었던 종이 지도에 적힌 y좌표가 maxY보다 작은가?" 라고 묻는 것입니다.

그리고 moveUpAndDown() 함수 안에서는 이 종이 지도의 내용만 계속 바꾸고 있습니다.

C#
position.y += speed * Time.deltaTime;
  • 해석: 종이 지도에 적힌 y좌표 위에 펜으로 숫자를 지우고 계속 새로 쓰고 있습니다. (0 -> 0.1 -> 0.2 ...)
  • 하지만 자동차의 실시간 GPS는 전혀 건드리지 않았습니다. 자동차는 여전히 출발지에 그대로 서 있습니다.

결론: 왜 다른가?

position = transform.position; 이 코드는 **값을 복사(Copy)**하는 행위이지, **연결(Link)**하는 행위가 아닙니다. Vector2 Vector3 같은 구조체(struct)들은 변수에 할당될 때 값이 통째로 복사됩니다.

  • Start()에서 position transform.position 값을 한 번 복사해 온 별개의 변수가 됩니다.
  • Update()에서 position.y를 바꾸는 것은 별개의 변수인 position의 내용만 바꾸는 것입니다.
  • 원본인 transform.position은 아무런 영향을 받지 않습니다.

따라서 문제를 해결하려면, 매 프레임마다 다음과 같은 순서로 작업해야 합니다.

  1. 읽기 (Read): (선택 사항) 현재 실제 위치를 가져온다.
  2. 수정 (Modify): 가져온 값을 바탕으로 새로운 위치를 계산한다. (사용자님의 position.y += ... 코드)
  3. 쓰기 (Write Back): 계산이 끝난 새로운 위치 값을 실제 위치(transform.position)에 다시 써준다. (transform.position = position;)

 읽기 -> 수정 -> 쓰기 사이클을 이해하는 것이 정말 중요합니다. 사용자님의 코드에서는 마지막 '쓰기' 단계가 빠져있었던 것입니다.

 

 

 

 

 

 

void moveUpAndDown()
{
    // ★★★ 이 한 줄이 핵심 ★★★
    Vector2 position = transform.position; // <--- 매 프레임마다 '현재 진짜 위치'를 새로 가져옴

    // if/else...
    // position 변수의 값을 다음 목표 위치로 계산함.

    // ★★★ 그리고 여기가 마무리 ★★★
    transform.position = position; // 계산된 목표 위치로 실제 위치를 업데이트함.
}

 

 

 

 

 

 

 

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

public class GameManager : MonoBehaviour
{   

    int level;
    float timer;
    // Start is called before the first frame update
    void Start()
    {
        timer=0;
    }

    // Update is called once per frame
    void Update()
    {
        timer += Time.deltaTime;
        if (timer >= 10)
        {
            Debug.Log("lv1");
        }
    }
}

더 좋은 방법 뭐지 

 

 

 

 

1. Update 방식 (현재 사용 중인 방식)

C#
void Update()
{
    timer += Time.deltaTime;
    if (timer >= 10)
    {
        Debug.Log("lv1");
        // timer = 0; // 만약 10초마다 반복하고 싶다면 리셋 필요
    }
}
  • 연산량에 대한 진실:
    • 현대 컴퓨터(CPU)에게 float 값을 더하고, if문으로 크기를 비교하는 연산은 먼지보다도 가벼운 작업입니다. 1초에 수천만 번 이상 할 수 있는 매우 저렴한 연산입니다.
    • 게임 성능에 영향을 주는 진짜 무거운 연산은 GetComponent, FindObjectOfType, 복잡한 물리 계산, 그래픽 렌더링 등입니다.
    • 따라서 타이머 한두 개를 Update에서 돌리는 것으로는 게임 성능에 아무런 영향을 주지 않으니 전혀 걱정하지 않으셔도 됩니다.
  • 장점:
    • 가장 직관적이고 간단합니다.
    • timer 변수를 직접 제어할 수 있어서 타이머를 일시정지하거나, 리셋하거나, 시간을 추가하는 등의 조작이 매우 쉽습니다.
  • 단점:
    • Update 함수 안에 타이머 관련 코드가 계속 남아있게 됩니다. 만약 체크해야 할 타이머가 10개가 된다면 Update 함수가 매우 길고 지저분해질 수 있습니다. (이것이 '더 좋은 방식'이 필요한 진짜 이유입니다!)

2. 코루틴 (Coroutine) 방식 (가장 추천하는 '더 좋은 방식')

코루틴은 "이 함수를 실행하다가, 여기서 잠시 멈추고 몇 초 뒤에 다시 돌아와서 마저 실행해!" 라고 명령할 수 있는 C#의 강력한 기능입니다.

C#
void Start()
{
    // LevelUpRoutine 이라는 코루틴을 시작시킨다.
    StartCoroutine(LevelUpRoutine());
}

IEnumerator LevelUpRoutine()
{
    Debug.Log("코루틴이 시작되었습니다. 10초를 기다립니다.");
    
    // 이 부분에서 10초 동안 '멈춤' (정확히는 대기)
    yield return new WaitForSeconds(10); 
    
    Debug.Log("10초가 지나서 레벨 1이 되었습니다!");
    // 만약 10초 뒤에 또 무언가를 하고 싶다면 여기에 코드를 추가하면 됨
    // yield return new WaitForSeconds(10);
    // Debug.Log("20초가 지나 레벨 2가 되었습니다!");
}

void Update()
{
    // Update 함수가 깨끗하게 비어있게 된다!
}
  • 연산량: 내부적으로는 유니티 엔진이 시간을 관리해주므로, 우리가 Update에서 직접 하는 것과 큰 차이가 없습니다. 걱정할 수준이 전혀 아닙니다.
  • 장점:
    • 가독성이 엄청나게 좋아집니다. 코드가 "10초를 기다린다 -> 그리고 레벨업한다" 처럼 이야기 흐름대로 작성됩니다.
    • 시간과 관련된 로직이 Update 함수에서 분리되어 코드가 매우 깔끔해지고 관리하기 쉬워집니다.
    • 복잡한 시간 순서(10초 뒤에 A하고, 5초 뒤에 B하고...)를 구현하기가 훨씬 쉽습니다.
  • 단점:
    • IEnumerator, yield return 같은 문법이 처음에는 약간 낯설 수 있습니다.

3. Invoke 방식 (간단한 일회성 타이머에 적합)

"이 함수를 지정된 시간 뒤에 딱 한 번만 실행해줘" 라는 간단한 명령입니다.

C#
void Start()
{
    // "LevelUp" 이라는 이름의 함수를 10초 뒤에 실행시킨다.
    Invoke("LevelUp", 10f);
}

void LevelUp()
{
    Debug.Log("lv1");
}

void Update()
{
    // 역시 Update 함수가 깨끗하다.
}
  • 장점:
    • 매우 간단하고 쓰기 편합니다. 딱 한 줄이면 됩니다.
  • 단점:
    • 함수 이름을 **문자열("LevelUp")**로 전달해야 해서, 만약 함수 이름을 바꾸거나 오타가 나면 에러를 찾기 어렵습니다. (가장 큰 단점)
    • 함수에 매개변수(파라미터)를 전달할 수 없습니다.
    • 코루틴만큼 유연하지는 않습니다.

최종 정리: 언제 무엇을 써야 할까?

방식 연산량 가독성 & 관리 유연성 추천 사용처
Update 매우 낮음 (걱정 불필요) 타이머가 많아지면 지저분해짐 가장 높음 (일시정지, 리셋 등 자유자재) 타이머와 함께 매 프레임 다른 조건도 체크해야 할 때
코루틴 매우 낮음 가장 좋음 (코드가 깔끔하게 분리됨) 높음 대부분의 시간 기반 로직, 순차적인 이벤트 처리
Invoke 매우 낮음 좋음 낮음 단순히 몇 초 뒤에 함수를 '한 번'만 실행하고 싶을 때

 

 

 

 

 

 

 

 

 

오히려 그 반대입니다! 코루틴은 시간이 지남에 따라 난이도를 점진적으로 증가시키는 로직을 만들 때 매우 강력하고 깔끔한 방법입니다. 사용자님께서 아주 좋은 활용 사례를 정확히 짚으셨습니다.

아마 yield return new WaitForSeconds(10); 처럼 "한 번만 기다리고 끝나는" 예제만 보셔서 그렇게 생각하실 수 있습니다. 하지만 코루틴의 진짜 힘은 반복문(while)과 결합될 때 나타납니다.

왜 난이도 조절에 코루틴이 더 좋은가?

  • 관심사의 분리 (Separation of Concerns): 난이도를 조절하는 로직을 Update() 함수로부터 완벽하게 분리할 수 있습니다. Update()는 플레이어의 입력, 움직임 등 매 프레임 정말로 필요한 것들에만 집중하게 되고, 코드는 훨씬 깨끗해집니다.
  • 가독성: 코드가 "5초마다 난이도를 올린다" 와 같이 이야기처럼 읽히게 되어 이해하기 쉽습니다.
  • 제어 용이성: 난이도 상승을 잠시 멈추거나(예: 보스 등장 컷신), 특정 조건에서 다시 시작하기가 매우 편리합니다.

패턴 1: 일정 시간마다 단계적으로 난이도 올리기 (가장 일반적인 방법)

"매 10초마다 적 생성 속도를 0.1초씩 줄이고, 적의 이동 속도를 5%씩 증가시킨다" 와 같은 로직을 구현할 때 완벽합니다.

C#
public class DifficultyManager : MonoBehaviour
{
    public float enemySpawnInterval = 2.0f; // 초기 적 생성 주기
    public float enemySpeedMultiplier = 1.0f; // 초기 적 속도 배율

    // 난이도가 얼마나 자주 증가할지 (초 단위)
    public float difficultyIncreaseInterval = 10.0f; 

    void Start()
    {
        // 게임이 시작되면 난이도 조절 코루틴을 실행시킨다.
        StartCoroutine(IncreaseDifficultyOverTime());
    }

    IEnumerator IncreaseDifficultyOverTime()
    {
        // 이 코루틴은 게임이 끝날 때까지 계속 실행된다.
        while (true) 
        {
            // 1. difficultyIncreaseInterval(10초) 만큼 기다린다.
            yield return new WaitForSeconds(difficultyIncreaseInterval);

            // 2. 10초가 지나면, 난이도를 올리는 로직을 실행한다.
            enemySpawnInterval = Mathf.Max(0.5f, enemySpawnInterval - 0.1f); // 생성 주기를 0.1초 줄임 (최소 0.5초)
            enemySpeedMultiplier += 0.05f; // 속도를 5% 증가

            Debug.Log($"난이도 상승! 현재 생성 주기: {enemySpawnInterval:F2}초, 속도 배율: {enemySpeedMultiplier:F2}");
        }
    }
}

Update와 비교:
이걸 Update로 만들려면 타이머 변수를 하나 두고, if (timer > 10) 인지 계속 체크하고, 조건이 맞으면 타이머를 리셋하는 코드를 직접 짜야 합니다. 코루틴을 쓰면 while(true) yield return이 그 역할을 대신해주므로 훨씬 깔끔합니다.


패턴 2: 매 프레임마다 아주 조금씩 부드럽게 난이도 올리기

"시간이 흐를수록 아주 미세하게, 끊김 없이 계속해서 어려워지게 만들고 싶다" 와 같은 경우에 사용합니다.

C#
public class DifficultyManager : MonoBehaviour
{
    // 1초당 난이도가 얼마나 오를지
    public float difficultyIncreaseRate = 0.01f; 
    
    // 현재 난이도 계수 (다른 스크립트에서 이 값을 참조하여 사용)
    public float currentDifficultyFactor = 1.0f;

    void Start()
    {
        StartCoroutine(IncreaseDifficultySmoothly());
    }

    IEnumerator IncreaseDifficultySmoothly()
    {
        while (true)
        {
            // 1. 딱 한 프레임만 기다린다.
            yield return null; 

            // 2. 매 프레임마다 난이도를 아주 조금씩 올린다.
            // Time.deltaTime을 곱해서 프레임 속도와 상관없이 일정하게 증가하도록 보정
            currentDifficultyFactor += difficultyIncreaseRate * Time.deltaTime;
        }
    }
}

yield return null; 은 "다음 프레임까지 기다려라" 라는 뜻으로, 코루틴 안에서 Update와 거의 똑같은 효과를 냅니다. 하지만 이 로직 역시 Update에서 분리되어 있기 때문에 코드가 훨씬 깨끗합니다.

결론: 어떤 방식을 선택해야 하는가?

상황 추천 방식 이유
"10초마다 레벨업!" 처럼 단계적이고 뚝뚝 끊어지는 난이도 상승 코루틴 + WaitForSeconds 로직이 명확하고 Update를 더럽히지 않는다.
"시간이 흐를수록 아주 서서히, 부드럽게 어려워지는" 난이도 상승 코루틴 + yield return null 또는 Update 두 방식 모두 가능하지만, 코루틴이 코드를 분리해주므로 더 깔끔하다.
시간 외에 플레이어의 점수, 특정 아이템 획득 등 다른 조건과 복합적으로 난이도를 조절해야 할 때 Update 매 프레임 다양한 조건을 확인해야 하므로 Update가 더 직관적일 수 있다.

따라서 "시간이 경과됨에 따라 난이도를 증가"시키는 목적이라면, 코루틴은 맞지 않는 것이 아니라 오히려 가장 이상적인 도구 중 하나입니다.

일단, 쉬운길을 걷자

 

 

 

 

 

 

 

 

A. Screen Space - Overlay 모드 (현재 상태)

  • 설명: 이 모드는 UI를 게임 세상과 완전히 분리된, 화면 가장 위에 덮어씌우는 별도의 레이어에 그립니다. TV 자막처럼 항상 모든 것 위에 나타납니다.
  • 편집 방식: 이 모드에서는 UI를 편집하기 쉽도록, Scene 뷰에 게임 화면 크기와 똑같은 거대한 가상의 '도화지(Canvas)'를 보여줍니다. 그래서 지금처럼 게임 월드는 작게 보이고, UI 캔버스는 거대하게 보이는 것입니다. 이것이 기본 설정이며 가장 많이 사용됩니다.

B. Screen Space - Camera 모드 (사용자님이 기억하는 방식)

  • 설명: 이 모드는 UI를 지정된 카메라로부터 특정 거리만큼 떨어진 3D 공간에 그립니다. 마치 유리판에 UI를 그려서 카메라 렌즈 앞에 붙여놓는 것과 같습니다.
  • 편집 방식: UI가 게임 세상과 같은 공간에 존재하기 때문에, Scene 뷰에서 게임 오브젝트와 UI를 함께 보면서 편집할 수 있습니다. 바로 이 방식이 예전에 사용하셨던 방식일 겁니다.

 

 

 

최종 요약

  • Scene 뷰에서 카메라가 작아 보이는 이유: Canvas Plane Distance가 너무 커서, 카메라의 시야가 아주 멀리 있는 캔버스에 거대하게 확대 투사되고 있기 때문입니다.
  • 해결책: Canvas Plane Distance 값을 10 ~ 20 사이의 작은 값으로 줄이면, Scene 뷰에서 카메라와 캔버스의 크기가 비슷해져서 작업하기 편해집니다.

 

 

 

 

 

 

 

TextMesh Pro의 두 종류: TextMeshPro vs TextMeshProUGUI

TextMesh Pro에는 텍스트를 표시하는 컴포넌트가 크게 두 종류가 있습니다.

  1. TextMeshPro: 3D 공간에 떠 있는 텍스트를 만들 때 사용합니다. 예를 들어, 게임 월드에 있는 간판이나 벽에 쓰인 글씨처럼 3D 오브젝트와 동일하게 취급됩니다.
  2. TextMeshProUGUI: UI 캔버스(Canvas) 위에 텍스트를 표시할 때 사용합니다. 점수, 체력, 대화창 등 화면에 고정된 UI 요소를 위한 전용 컴포넌트입니다. 이름의 UGUI는 Unity GUI를 의미합니다.

문제의 원인

사용자님의 상황을 정확히 분석해 보겠습니다.

  1. Hierarchy 창: Text (TMP) 오브젝트를 Canvas 아래에 만드셨습니다.
  2. 결과: Canvas 아래에 UI 요소를 만들면, 유니티는 자동으로 UI 전용 컴포넌트인 TextMeshProUGUI 를 붙여줍니다.
  3. GameManager 스크립트: 코드를 보면 변수를 이렇게 선언하셨습니다.
  4. C#
    public TextMeshPro levelText; // <--- 문제의 원인!

이제 보이시나요?

  • 오브젝트가 실제로 가진 컴포넌트는 UI용인 TextMeshProUGUI 입니다.
  • 하지만 스크립트에서는 3D 월드용인 TextMeshPro 타입의 컴포넌트를 넣어달라고 요청하고 있습니다.

이 둘은 서로 다른 타입이기 때문에, 유니티의 인스펙터 창은 "타입이 맞지 않습니다. 이 오브젝트는 넣을 수 없습니다." 라며 드래그 앤 드롭을 거부하는 것입니다.

해결 방법

해결책은 아주 간단합니다. 스크립트의 변수 타입을 실제 컴포넌트 타입에 맞게 고쳐주기만 하면 됩니다.

  1. GameManager.cs 스크립트를 엽니다.
  2. 아래와 같이 변수 선언 부분을 수정하세요.
    C#
    public TextMeshPro levelText;
    수정 후:
  3. C#
    public TextMeshProUGUI levelText; // 그냥 뒤에 UGUI만 붙여주면 끝!
  4. 수정 전:
  5. 스크립트 맨 위에 using TMPro;가 있는지 확인합니다. (이미 있으시네요!)
  6. 스크립트를 저장하고 유니티 에디터로 돌아옵니다.