개발/유니티

[유니티] 원본변경: 프로젝트(Project) 창인가, 아니면 씬(Scene)인가

kimchangmin02 2025. 8. 10. 18:11

근데 레벨업할때마다 적용되는 강화효과도 있는데 이거를 구현하려면 일단 저장을 해둬야할것같은데
근데 리스트 형태로 저장하나
또한 어떤 스크립트에 보통 저장하는지
또한 저장을 문자열로 하는지

 

1. 어디에 저장해야 하나요? (어떤 스크립트?)

정답: PlayerStats.cs 입니다.

이유는 간단합니다. 획득한 보상은 플레이어의 현재 상태(State)의 일부이기 때문입니다. 플레이어의 체력, 레벨, 경험치와 마찬가지로 "플레이어가 어떤 패시브 능력을 가지고 있는가"는 PlayerStats가 관리하는 것이 가장 논리적이고 자연스럽습니다.

2. 무엇으로 저장해야 하나요? (리스트? 문자열?)

정답: List<RewardData> 형태로, 실제 RewardData 객체를 저장해야 합니다.

문자열(string)로 저장하면 안 되는 이유:

  • 오타에 취약합니다: "maxHpPlus"라고 써야 하는데 "maxHp_Plus"라고 쓰면 버그가 나고 찾기도 어렵습니다.
  • 비효율적입니다: 문자열로 저장하면, 나중에 그 효과를 쓰기 위해 "이름이 "maxHpPlus"인 보상이 뭐였지?" 하고 전체 보상 목록을 다시 검색해야 합니다.
  • 유연하지 않습니다: 보상의 세부 데이터(예: 회복량 healAmount)에 접근하기가 번거롭습니다.

List<RewardData>로 저장해야 하는 이유:

  • 안전하고 명확합니다: 실제 보상 데이터를 직접 들고 있으므로 오타가 날 일이 없고, 어떤 데이터인지 명확합니다.
  • 효율적입니다: 필요할 때마다 바로 그 보상 객체의 데이터나 함수(Apply, OnLevelUp 등)를 직접 호출할 수 있습니다. 검색이 필요 없습니다.
  • 객체 지향적입니다: 보상이라는 '객체'를 그대로 다루므로 코드가 훨씬 깔끔해집니다.

구현 방법: 패시브 스킬 시스템 만들기

패시브 스킬을 구현하는 가장 깔끔한 방법은 **인터페이스(Interface)**를 사용하는 것입니다. 인터페이스는 "자격증"이나 "계약서" 같은 개념입니다. "이 보상은 레벨업할 때마다 무언가를 할 수 있는 자격이 있습니다" 라고 표시해주는 것입니다.

단계 1: "레벨업 패시브 자격증" 만들기 (Interface 생성)

새로운 C# 스크립트를 하나 만들고, 아래 코드를 붙여넣으세요.

파일 이름: IPassiveOnLevelUp.cs

C#
/// <summary>
/// 이 인터페이스를 구현하는 클래스는
/// 레벨업 시마다 OnLevelUp() 메서드가 호출될 자격이 있음을 의미합니다.
/// </summary>
public interface IPassiveOnLevelUp
{
    void OnLevelUp(PlayerStats playerStats);
}

단계 2: 패시브 스킬 스크립트 작성하기

이제 "레벨업마다 체력 5 회복"이라는 새로운 패시브 스킬을 만든다고 가정해봅시다. 이 스킬은 RewardData를 상속받으면서, 동시에 IPassiveOnLevelUp 자격증도 갖도록 만듭니다.

파일 이름: HealOnLevelUp.cs (예시)

C#
using UnityEngine;

[CreateAssetMenu(fileName = "HealOnLevelUp", menuName = "Rewards/HealOnLevelUp")]
public class HealOnLevelUp : RewardData, IPassiveOnLevelUp // RewardData이면서, IPassiveOnLevelUp 자격도 취득!
{
    public int healAmount = 5;

    // 이 보상을 '처음 선택했을 때' 딱 한 번 호출되는 함수
    public override void Apply(PlayerStats playerStats)
    {
        // 1. 이 보상은 이제 플레이어의 소유임을 등록합니다.
        playerStats.AddAcquiredReward(this);

        // 2. 최초 획득 기념으로 즉시 체력을 한 번 회복시켜줄 수도 있습니다.
        playerStats.currentHealth += healAmount;
        Debug.Log(rewardName + " 획득! 이제 레벨업마다 체력을 " + healAmount + " 회복합니다.");
    }

    // 'IPassiveOnLevelUp' 자격증이 있기 때문에 반드시 만들어야 하는 함수.
    // 이 함수는 PlayerStats의 LevelUp() 메서드에서 호출될 것입니다.
    public void OnLevelUp(PlayerStats playerStats)
    {
        playerStats.currentHealth += healAmount;
        Debug.Log("레벨업 패시브 효과! 체력을 " + healAmount + " 회복했습니다.");
    }
}

단계 3: PlayerStats.cs 수정하기

이제 PlayerStats가 획득한 보상을 저장할 리스트를 갖고, 레벨업할 때마다 그 리스트를 확인하여 패시브 효과를 발동시키도록 수정합니다.

파일 이름: PlayerStats.cs (수정)

C#
using UnityEngine;
using System.Collections.Generic; // List를 사용하기 위해 추가

public class PlayerStats : MonoBehaviour
{
    // ... 기존 변수들 ...

    // ▼▼▼ 이 부분이 추가됩니다 ▼▼▼
    // 플레이어가 획득한 모든 보상(패시브 포함)을 저장하는 리스트
    private List<RewardData> acquiredRewards = new List<RewardData>();

    // 보상을 획득 리스트에 추가하는 함수
    public void AddAcquiredReward(RewardData reward)
    {
        acquiredRewards.Add(reward);
    }
    // ▲▲▲ 여기까지 추가 ▲▲▲


    // ... GainExperience() 등 기존 함수들 ...


    // 레벨업 처리 함수 수정
    void LevelUp()
    {
        level++;
        currentExperience -= experienceToNextLevel; 
        experienceToNextLevel += experienceIncreasePerLevel;

        Debug.Log("레벨 업! 현재 레벨: " + level);

        // ▼▼▼ 레벨업 시 패시브 효과 발동 로직 추가 ▼▼▼
        foreach (var reward in acquiredRewards)
        {
            // 획득한 보상 중에 'IPassiveOnLevelUp' 자격증을 가진 보상이 있다면
            if (reward is IPassiveOnLevelUp passiveReward)
            {
                // 그 보상의 OnLevelUp 함수를 호출!
                passiveReward.OnLevelUp(this);
            }
        }
        // ▲▲▲ 여기까지 추가 ▲▲▲

        UIManager.Instance.UpdateLevelText(level);
        // ... 나머지 UI 업데이트 코드 ...

        RewardManager.Instance.ShowRewardScreen();
    }

    // ... TakeDamage() 등 나머지 함수들 ...
}

전체 흐름 요약

  1. 플레이어가 "레벨업 시 체력 회복" 보상을 선택합니다.
  2. RewardManager HealOnLevelUp Apply()를 호출합니다.
  3. Apply() 함수는 자기 자신(this)을 playerStats.AddAcquiredReward()를 통해 플레이어의 획득 목록에 등록합니다.
  4. 플레이어가 게임을 계속하다가 다시 레벨업을 합니다.
  5. PlayerStats LevelUp() 함수가 실행됩니다.
  6. LevelUp() 함수는 acquiredRewards 목록을 쭉 훑어봅니다.
  7. 목록에 있는 HealOnLevelUp 보상이 IPassiveOnLevelUp 자격증을 가진 것을 확인하고, 그 보상의 OnLevelUp() 함수를 호출합니다.
  8. 플레이어의 체력이 회복됩니다.

 


근데 유니티에서 원본 스크립트 변경하면 안된다고, 뭔 복잡한게 있엇는데 이렇게 코드 짜도 되나?

 

 


using UnityEngine;

[CreateAssetMenu(fileName = "HealOnLevelUp", menuName = "Rewards/HealOnLevelUp")]
public class Heal5whenLvUp : RewardData, IPassiveOnLevelUp // RewardData이면서, IPassiveOnLevelUp 자격도 취득!
{
    public int healAmount = 5;

    // 이 보상을 '처음 선택했을 때' 딱 한 번 호출되는 함수
    public override void Apply(PlayerStats playerStats)
    {
        // 1. 이 보상은 이제 플레이어의 소유임을 등록합니다.
        playerStats.AddAcquiredReward(this);

        // 2. 최초 획득 기념으로 즉시 체력을 한 번 회복시켜줄 수도 있습니다.
        playerStats.currentHealth += healAmount;
        if (playerStats.currentHealth >= playerStats.maxHealth)
        {
            playerStats.currentHealth = playerStats.maxHealth;
        }
        Debug.Log(rewardName + " 획득! 이제 레벨업마다 체력을 " + healAmount + " 회복합니다.");
    }

    // 'IPassiveOnLevelUp' 자격증이 있기 때문에 반드시 만들어야 하는 함수.
    // 이 함수는 PlayerStats의 LevelUp() 메서드에서 호출될 것입니다.
    public void OnLevelUp(PlayerStats playerStats)
    {
        playerStats.currentHealth += healAmount;
        if (playerStats.currentHealth >= playerStats.maxHealth)
        {
            playerStats.currentHealth = playerStats.maxHealth;
        }
        Debug.Log("레벨업 패시브 효과! 체력을 " + healAmount + " 회복했습니다.");
    }
}





이건 왜 이렇게 짠거지

 

 



// 파일 이름: MaxHp_ver2.cs
using UnityEngine;
using System.Collections.Generic;

[CreateAssetMenu(fileName = "maxHpPlus", menuName = "Rewards/maxHpPlus")]
public class MaxHp_ver2 : RewardData
{
    public int defaultAmount;
    public int increaseAmount;

    private static Dictionary<string, int> rewardLevels = new Dictionary<string, int>();

    // ▼▼▼ 이 부분을 추가해주세요 ▼▼▲
    /// <summary>
    /// DynamicDescription을 재정의하여 현재 레벨에 맞는 설명문을 생성합니다.
    /// </summary>
    public override string DynamicDescription
    {
        get
        {
            // 현재 레벨을 가져옵니다.
            rewardLevels.TryGetValue(this.name, out int currentLevel);

            // 다음 획득 시 적용될 값을 미리 계산합니다.
            int amountToApply = defaultAmount + (currentLevel * increaseAmount);

            // 계산된 값을 포함한 새로운 설명문을 만들어서 반환합니다.
            // 원하는 형식으로 자유롭게 문장을 만드시면 됩니다.
            return $"선택 시 최대 체력이 {amountToApply} 증가합니다.";
        }
    }
    // ▲▲▲ 여기까지 추가 ▲▲▲

    public static void ResetAllRewardStates()
    {
        rewardLevels.Clear();
    }

    public override void Apply(PlayerStats playerStats)
    {
        rewardLevels.TryGetValue(this.name, out int currentLevel);
        int amountToApply = defaultAmount + (currentLevel * increaseAmount);
        playerStats.maxHealth += amountToApply;
        Debug.Log(rewardName + " 효과 적용! 최대 체력이 " + amountToApply + " 증가했습니다!");
        rewardLevels[this.name] = currentLevel + 1;
    }
}

 

보상의 두 종류: "상태가 변하지 않는 보상" vs "스스로 성장하는 보상"

1. Heal5whenLvUp (간단한 코드) - 상태가 변하지 않는 보상 (Stateless)

이 보상의 특징을 분석해 보겠습니다.

  • 효과: "레벨업마다 체력을 5 회복한다."
  • 핵심: 이 '5'라는 값은 절대 변하지 않습니다. 10번을 레벨업해도 회복량은 항상 5입니다.
  • 코드 분석:
    • playerStats.AddAcquiredReward(this);: 이 코드는 "플레이어의 획득 목록에 나(Heal5whenLvUp 에셋)를 추가해줘" 라는 뜻입니다. Heal5whenLvUp 자신의 데이터를 바꾸는 것이 아니라, PlayerStats의 리스트를 변경합니다.
    • playerStats.currentHealth += healAmount;: 이 코드도 PlayerStats의 체력을 바꿀 뿐, Heal5whenLvUp 자신의 데이터(healAmount)를 바꾸지 않습니다.

결론: 이 스크립트는 자신의 변수(예: healAmount)를 읽기만 할 뿐, 절대 수정하지 않습니다.
healAmount의 값을 바꾸는 코드가 없으니, 원본 에셋 파일이 변경될 위험이 전혀 없습니다. 따라서 복잡한 static 장치가 필요 없는 것입니다. 이 보상의 유일한 '상태'는 "획득했는가, 안 했는가?" 이며, 그 상태는 PlayerStats의 리스트가 대신 관리해줍니다.

2. MaxHp_ver2 (복잡한 코드) - 스스로 성장하는 보상 (Stateful)

이 보상의 특징을 분석해 보겠습니다.

  • 효과: "선택할 때마다 효과가 점점 강해진다." (3 -> 6 -> 9...)
  • 핵심: 이 보상은 자기 자신의 상태(레벨)를 기억하고, 그 상태를 계속 변경해야 합니다.
  • 코드 분석:
    • rewardLevels[this.name] = currentLevel + 1;: 바로 이 코드가 결정적인 차이입니다. 이 코드는 "이 보상의 레벨을 1 올려라"는, 자기 자신의 상태를 직접 변경하는 코드입니다.
    • 만약 이 레벨 데이터를 static이 아닌 일반 변수(public int currentLevel;)에 저장했다면, 이 라인은 currentLevel++가 되었을 것이고, 이는 원본 .asset 파일을 직접 수정하는 위험한 작업이 됩니다.

결론: 이 스크립트는 자신의 상태(currentLevel)를 계속해서 바꿔야 하는 임무가 있습니다.
원본 파일을 보호하면서 이 임무를 안전하게 수행하기 위해, 우리는 **데이터를 원본 파일이 아닌 임시 메모리 공간에 저장하는 특별한 장치(static Dictionary)**를 사용해야만 했던 것입니다.


최종 요약 비교

특징 Heal5whenLvUp (간단한 코드) MaxHp_ver2 (복잡한 코드)
보상의 종류 상태가 변하지 않는 패시브 (Stateless) 스스로 성장하는 강화형 (Stateful)
핵심 동작 "획득했는가?" (Yes/No) "몇 번 획득했는가?" (Level 0, 1, 2...)
상태 저장 위치 외부 (PlayerStats의 리스트) 내부 (static 변수)
원본 파일 위험 없음 (자신의 변수를 수정하지 않으므로) 있음 (static을 안 쓰면 위험)
왜 이렇게 짰나? 자신의 상태를 바꿀 필요가 없으므로 간단하게 구현 자신의 상태를 안전하게 바꿔야 하므로 static 장치가 필요

 


int bonusHp = playerStats.currentHealth * amount;

 

Cannot implicitly convert type 'float' to 'int'. An explicit conversion exists (are you missing a cast?)
자동 캐스팅 안되나

결론부터 말씀드리면, float(실수)에서 int(정수)로는 자동 형 변환(캐스팅)이 절대로 되지 않습니다.

왜 자동 캐스팅이 안 될까요?

"데이터 손실"의 위험 때문입니다.

  • int에서 float로의 변환: int 10은 float 10.0f 가 될 수 있습니다. 이 과정에서는 어떤 정보도 잃어버리지 않습니다. 그래서 이건 자동으로 해줍니다. (int a = 10; float b = a; // 가능)
  • float에서 int로의 변환: float 10.7f를 int로 바꾸려고 하면 어떻게 해야 할까요?
    • 소수점을 그냥 버려서 10으로 만들어야 할까요? (0.7의 데이터 손실)
    • 반올림해서 11로 만들어야 할까요?
    • 올림해서 11로 만들어야 할까요?
    • 내림(버림)해서 10으로 만들어야 할까요?

컴파일러는 개발자의 의도를 알 수 없기 때문에, "데이터 손실이 발생할 수 있는 중요한 결정은 내가 마음대로 하지 않겠다. 네가 직접 어떻게 바꿀지 명시해라!" 라고 요구하며 오류를 발생시키는 것입니다. 이것이 바로 "An explicit conversion exists (are you missing a cast?)" 오류의 의미입니다.

 


 


CreateAssetMenu attribute on HpPercentUpgrade will be ignored as HpPercentUpgrade is not derived from ScriptableObject.
UnityEditor.AttributeHelper:ExtractCreateAssetMenuItems ()

 

왜 또 무시한다고 하는거지

이번에는 monobehavior말고, reward제대로 상속햇는데도

 

using UnityEngine;

// [CreateAssetMenu] 속성을 사용했지만...
[CreateAssetMenu(fileName = "HpPercentUpgrade", menuName = "Rewards/HpPercentUpgrade")]
public class HpPercentUpgrade // <- 아무것도 상속받지 않거나, MonoBehaviour 등을 상속받고 있음
{
    // ... 클래스 내용 ...
}

 


// 파일 이름: PlayerController.cs
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [Header("Movement")]
    public float moveSpeed = 5f; // 이동 속도

    [Header("Directional Sprites")]
    // public으로 선언해야 유니티 에디터의 인스펙터 창에 노출되어 이미지를 드래그할 수 있습니다.
    public Sprite spriteUp;
    public Sprite spriteDown;
    public Sprite spriteLeft;
    public Sprite spriteRight;

    // --- Private 변수들 ---
    private Vector2 moveInput; // 이동 입력 값을 저장할 변수
    private SpriteRenderer spriteRenderer; // 이미지를 실제로 교체할 Sprite Renderer 컴포넌트를 담을 변수
    private Vector2 lastDirection = Vector2.down; // 마지막 이동 방향을 저장할 변수 (기본값은 아래쪽)


    // Start() 보다 먼저 호출되는 Awake()에서 초기 설정을 해주는 것이 좋습니다.
    void Awake()
    {
        // 이 스크립트가 붙어있는 게임 오브젝트의 SpriteRenderer 컴포넌트를 찾아서 변수에 저장합니다.
        // 게임 시작 시 단 한번만 실행되어 효율적입니다.
        spriteRenderer = GetComponent<SpriteRenderer>();
    }

    // 매 프레임마다 호출되는 함수
    void Update()
    {
        // 1. 키보드 입력 감지
        moveInput.x = Input.GetAxisRaw("Horizontal"); // 좌/우 입력 (-1, 0, 1)
        moveInput.y = Input.GetAxisRaw("Vertical");   // 상/하 입력 (-1, 0, 1)

        // 2. 입력에 따라 방향 스프라이트와 마지막 이동 방향 변수를 업데이트
        UpdateDirectionalSpriteAndDirection();

        // 3. 스페이스바를 누르면 총알 발사
        if (Input.GetKeyDown(KeyCode.Space))
        {
            Fire();
        }
    }

    // 고정된 시간 간격으로 호출되는 물리 업데이트 함수
    void FixedUpdate()
    {
        // Rigidbody를 직접 제어하는 대신, Transform을 직접 움직여 간단하게 구현합니다.
        // moveInput.normalized는 대각선으로 이동할 때 속도가 빨라지는 것을 방지합니다.
        transform.Translate(moveInput.normalized * moveSpeed * Time.fixedDeltaTime);
    }

    // 방향 스프라이트와 마지막 이동 방향을 함께 업데이트하는 함수
    void UpdateDirectionalSpriteAndDirection()
    {
        // 키 입력 값에 따라 어떤 이미지를 보여줄지, 그리고 마지막 방향을 어디로 저장할지 결정합니다.
        if (moveInput.x > 0) // 오른쪽으로 이동
        {
            spriteRenderer.sprite = spriteRight;
            lastDirection = Vector2.right; // 방향 저장
        }
        else if (moveInput.x < 0) // 왼쪽으로 이동
        {
            spriteRenderer.sprite = spriteLeft;
            lastDirection = Vector2.left; // 방향 저장
        }
        else if (moveInput.y > 0) // 위로 이동
        {
            spriteRenderer.sprite = spriteUp;
            lastDirection = Vector2.up; // 방향 저장
        }
        else if (moveInput.y < 0) // 아래로 이동
        {
            spriteRenderer.sprite = spriteDown;
            lastDirection = Vector2.down; // 방향 저장
        }
        // 키 입력이 없으면 마지막 방향의 이미지를 그대로 유지합니다.
    }

    // 총알을 발사하는 함수
    void Fire()
    {
        // 1. ObjectPooler에게 "PlayerShot" 태그를 가진 오브젝트를 내 위치에 생성해달라고 요청하고,
        //    그렇게 생성된 오브젝트를 shotObject 변수에 저장합니다.
        GameObject shotObject = ObjectPooler.Instance.SpawnFromPool("PlayerShot", transform.position, Quaternion.identity);

        // 2. 방금 생성된 총알 오브젝트에서 Shot.cs 스크립트 컴포넌트를 가져옵니다.
        Shot shotScript = shotObject.GetComponent<Shot>();

        // 3. 만약 스크립트를 성공적으로 가져왔다면 (null이 아니라면),
        //    그 스크립트의 SetDirection 함수를 호출하여 마지막으로 기억해 둔 방향(lastDirection)을 전달합니다.
        if (shotScript != null)
        {
            shotScript.SetDirection(lastDirection);
        }
    }
}

 



근데 이동속도를 증가시키는, 레벨업 선택지를 만들려고 하는데
이것도 원본 뭐 복잡한 그런거 생각해야하나
그냥 단순히 moveSpeed+=1f;할건데


이게 원본을 변경시키는것인지 아닌지 매번 생각해야하나
좀 더 쉬운 방법없나

이걸 판단하는

 


결론부터 말씀드리면, PlayerController의 moveSpeed를 바꾸는 것은 원본을 바꾸는 것이 아닙니다. 따라서 아무 걱정 없이 moveSpeed += 1f; 를 사용하셔도 됩니다.

왜 이번에는 괜찮은지, 그리고 "언제 걱정해야 하고 언제 안 해도 되는지"를 판단하는 쉬운 방법을 알려드리겠습니다.


원본 변경을 걱정해야 할 때 vs 안 해도 될 때 (가장 쉬운 판단법)

이것만 기억하시면 됩니다.

"이 스크립트가 붙어있는 대상이 프로젝트(Project) 창에 있는가, 아니면 씬(Scene)에 있는가?"

  • 프로젝트(Project) 창에 있는 대상 (ScriptableObject, 프리팹 원본 등):
    • 이것들은 '원본 에셋(Blueprint)' 입니다.
    • 코드로 이 대상의 변수를 직접 수정하면, 원본 파일이 변경될 위험이 매우 큽니다.
    • => 이 경우에만 static 같은 특별한 처리를 고민해야 합니다. (MaxHp_ver2가 여기에 해당)
  • 씬(Scene)에 있는 대상 (Player, Enemy, GameManager 등):
    • 이것들은 '원본 에셋'을 씬에 배치한 '인스턴스(Instance)' 또는 '복제품' 입니다.
    • 코드로 이 대상의 변수를 수정하는 것은, 씬에 있는 그 복제품의 상태를 바꾸는 것일 뿐입니다.
    • 게임이 종료되면 씬에 있던 모든 변경사항은 사라지고, 다음 실행 시에는 원본 프리팹의 깨끗한 상태로 다시 시작됩니다.
    • => 이 경우에는 아무 걱정 없이 변수를 직접 수정해도 됩니다. (PlayerController가 여기에 해당)

PlayerController는 왜 걱정 안 해도 될까요?

  1. PlayerController.cs 스크립트는 어디에 붙어있나요?
    => 씬(Scene)에 있는 'Player' 게임 오브젝트에 붙어있습니다.
  2. 이 'Player' 오브젝트는 무엇인가요?
    => 프로젝트 창에 있는 'Player 프리팹'의 복제품이거나, 그냥 씬에 직접 만든 게임 오브젝트입니다. 어느 쪽이든 '인스턴스' 입니다.
  3. 따라서...
    PlayerController moveSpeed를 증가시키는 것은, 현재 씬에 있는 **'플레이어 복제품'**의 이동 속도를 일시적으로 빠르게 만드는 것뿐입니다. 게임을 끄고 다시 켜면, 이 복제품은 사라지고 원본의 설정값(moveSpeed = 5f)을 가진 새로운 복제품이 씬에 나타납니다.
  4. 원본인 'Player 프리팹'이나 PlayerController.cs 파일 자체가 변경되는 일은 절대 없습니다.

비유: 레고(LEGO)로 성 만들기

유니티 개발을 레고로 성을 만드는 것에 비유해 보겠습니다.

1. "프로젝트(Project) 창" = 레고 설명서와 부품 상자

  • ScriptableObject (.asset 파일): 이것은 성의 설계도, 즉 **'레고 설명서'**입니다. 설명서에는 "기본 속도: 5", "기본 체력: 100" 같은 정보가 적혀 있습니다.
  • 프리팹(Prefab): 이것은 자주 사용하는 부품 조합, 예를 들어 '기본 병사' 레고를 미리 조립해서 **'부품 상자'**에 넣어둔 것입니다.

핵심: 설명서와 부품 상자에 있는 것들은 **'원본(Original)'**입니다. 여기에 직접 낙서를 하거나 부품을 부숴버리면, 나중에 똑같은 것을 만들 때 문제가 생깁니다.

MaxHp_ver2는 바로 이 **'설명서'**에 해당합니다. 설명서에 적힌 내용을 바꾸려고 하니, 원본이 훼손될 위험이 있었던 것입니다.

![alt text](https://storage.googleapis.com/gweb-cloud-media-pub/images/Project_Window.max-1000x1000.png)

2. "씬(Scene) 창" = 레고 조립판 (실제 성을 만드는 공간)

  • 씬(Scene): 이것은 우리가 성을 실제로 조립하는 넓은 **'레고 조립판'**입니다.
  • 씬에 있는 게임 오브젝트 (Player, Enemy):
    • 조립판 위에 Player를 하나 놓는 것은, 부품 상자에서 '기본 병사' 프리팹을 하나 꺼내서 그대로 복제한 뒤 조립판 위에 올려놓는 것과 같습니다.
    • PlayerController 스크립트는 이 **'복제된 병사 레고'**에게 붙어있는 행동 지침서입니다.

핵심: 조립판 위에 있는 것은 모두 **'복제품(Instance)'**입니다. 이 복제된 병사 레고의 투구를 다른 색으로 칠하거나(moveSpeed를 바꾸는 것), 칼을 부러뜨려도 부품 상자에 있는 원본 병사에게는 아무런 영향이 없습니다.

PlayerController moveSpeed를 바꾸는 것은, 조립판 위에 있는 이 **'복제된 병사'**의 속도를 잠시 빠르게 해주는 것과 같습니다. 원본에는 전혀 영향이 없습니다.

![alt text](https://storage.googleapis.com/gweb-cloud-media-pub/images/Scene_Window.max-1000x1000.png)

3. "플레이 버튼" = 조립 시작과 해체

  • 플레이 버튼 누르기: 이제 조립판 위에서 성을 가지고 신나게 놉니다. 복제된 병사 레고들은 움직이고, 싸우고, 부서지기도 합니다.
  • 플레이 버튼 끄기: 놀이가 끝나면, 조립판 위에 있던 모든 복제품들을 싹 다 치워버리고 해체합니다. 조립판은 다시 깨끗해집니다.
  • 다시 플레이 버튼 누르기: 부품 상자에서 깨끗한 상태의 '원본 병사'를 다시 복제해서 조립판 위에 올려놓고 처음부터 다시 놉니다.

결론: 그래서 무엇을 조심해야 하는가?

"레고 설명서(ScriptableObject)나 부품 상자 속 원본(Prefab)을 직접 고치려고 하는가?"
=> 위험! 원본 훼손 가능성이 있으니 static 같은 특별한 도구로 안전하게 작업해야 한다.

"조립판 위의 복제품(Scene Object)을 잠시 가지고 노는 것인가?"
=> 안전! 어차피 놀이가 끝나면 다 치워버릴 것이므로 마음대로 변경해도 된다.

이 비유를 통해 "프로젝트 창의 원본"과 "씬 창의 복제품"의 차이가 명확해지셨기를 바랍니다. 이 둘을 구분하는 것이 유니티에서 데이터를 안전하게 다루는 첫걸음입니다.

 


using UnityEngine;

[CreateAssetMenu(fileName = "MoveSpeedUp", menuName = "Rewards/MoveSpeedUp")]
public class MoveSpeedUp : RewardData
{
    // 인스펙터에서 얼마만큼 속도를 증가시킬지 설정합니다.
    public float speedIncreaseAmount = 1f;

    public override void Apply(PlayerStats playerStats)
    {
        // PlayerStats 매개변수를 통해 Player 게임 오브젝트를 찾습니다.
        // playerStats.gameObject는 PlayerStats 컴포넌트가 붙어있는 게임 오브젝트, 즉 'Player'를 가리킵니다.
        PlayerController playerController = playerStats.gameObject.GetComponent<PlayerController>();

        // PlayerController 컴포넌트를 성공적으로 찾았다면
        if (playerController != null)
        {
            // PlayerController의 moveSpeed 변수를 직접 증가시킵니다.
            // 이것은 '씬에 있는 플레이어'의 속도를 바꾸는 것이므로 안전합니다.
            playerController.moveSpeed += speedIncreaseAmount;

            Debug.Log(rewardName + " 획득! 이동 속도가 " + speedIncreaseAmount + " 증가했습니다! 현재 속도: " + playerController.moveSpeed);
        }
        else
        {
            Debug.LogError("Player 오브젝트에서 PlayerController 컴포넌트를 찾을 수 없습니다.");
        }
    }
}

 

근데 걍 나처럼 하면 안되나

 

내 코드 아래

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "SpeedLv", menuName = "Rewards/SpeedLv")]
public class SpeedLv : RewardData
{
    PlayerController playerController;
    public float speedIncreaseAmount;
    public override void Apply(PlayerStats playerStats)
    {
        playerController.moveSpeed += speedIncreaseAmount;
    }
}

오류의 원인은 아주 간단하고 명확합니다. PlayerController playerController; 라는 변수를 선언만 해놓고, 이 변수에 아무것도 할당해주지 않았기 때문입니다.

컴퓨터의 입장에서 보면 이런 상황입니다.

  1. PlayerController playerController; -> "PlayerController를 담을 수 있는 playerController라는 빈 상자를 하나 만들겠습니다."
  2. playerController.moveSpeed += ... -> "아까 만든 그 빈 상자 안에 들어있는 moveSpeed를 바꿔주세요."
  3. 오류 발생! -> "잠깐만요, 그 상자는 텅 비어있는데 어떻게 그 안의 moveSpeed를 바꿀 수 있나요?"

이것이 바로 NullReferenceException 이라는, 유니티 개발자가 가장 많이 만나게 되는 오류의 원인입니다.


어떻게 해결해야 할까요?

Apply 함수 안에서 playerController라는 빈 상자에 실제로 씬(Scene)에 있는 PlayerController 컴포넌트를 찾아서 넣어주는 과정이 필요합니다.

 

// 이 변수는 이제 Apply 함수 안에서만 사용되므로 apply밖에서 선언할 필요가 없습니다. // PlayerController playerController;

 


근데 현재 방어도를 추가하려고 하는데
얘는 체력바나, 경험치바같은 슬라이더 종류는 아니고,
그냥 방패 표시위에 숫자를 표시할건데

그냥 그림놔두고
empty생성하면되나
하이아키에서

그리고 text mash pro생성하면 되나

그리고
playerstats랑 연결시켜주기?

 


오류의 진짜 원인: "오래된 설계도" 문제

오류 메시지('UIManager' does not contain a definition for 'UpdateDefenseText')는 "UIManager 스크립트에는 UpdateDefenseText라는 함수가 없어!"라고 말하고 있습니다.

하지만 우리는 UIManager.cs 파일을 보면 UpdateDefenseText 함수가 분명히 존재합니다.

이런 모순적인 상황은 왜 발생할까요?

  • 1단계: 사용자님께서 UIManager.cs 파일을 수정하고 저장했습니다. (새로운 설계도를 그렸습니다)
  • 2단계: PlayerStats.cs 파일이 UIManager의 새로운 함수(UpdateDefenseText)를 호출하려고 합니다. (새로운 설계도를 보고 "이 기능 써야지!"라고 말합니다)
  • 3단계: 하지만 유니티 에디터나 Visual Studio가 아직 컴파일(Compile)을 끝내지 않아서, PlayerStats는 예전에 컴파일된 **'옛날 버전의 UIManager'**를 보고 있습니다. (아직 옛날 설계도를 보고 있습니다)
  • 결과: 옛날 설계도에는 당연히 UpdateDefenseText가 없으므로, "그런 함수는 없어!" 라는 오류가 발생하는 것입니다.

해결 방법 체크리스트 (간단한 순서대로)

이 문제는 보통 아래의 방법들로 간단하게 해결됩니다.

1. 모든 스크립트 저장 및 유니티 에디터 재포커싱 (가장 흔한 해결법)

  1. Visual Studio(또는 사용 중인 코드 에디터)에서 Ctrl + S 를 눌러 모든 스크립트 파일이 확실히 저장되었는지 확인합니다.
  2. 유니티 에디터 창을 한 번 클릭합니다. 유니티는 창이 다시 활성화될 때 스크립트 변경 사항을 감지하고 컴파일을 시작하는 경우가 많습니다. 이때 유니티 에디터 오른쪽 아래에 작은 로딩 아이콘이 잠깐 돌았다가 사라지는 것을 볼 수 있습니다.
  3. 컴파일이 끝나면 오류가 사라지는지 확인합니다.

2. 콘솔 창의 다른 오류 확인

가끔 이 오류 메시지 위에 다른 빨간색 오류가 있을 수 있습니다. 전혀 상관없어 보이는 다른 스크립트의 오류 하나가 전체 컴파일 과정을 막아서, UIManager의 변경 사항이 적용되지 않는 경우가 있습니다. 콘솔(Console) 창을 열어 가장 위에 있는 오류부터 해결해보세요.

3. 유니티 재시작

위의 방법으로도 해결되지 않으면, 가장 확실한 방법은 유니티 에디터를 완전히 껐다가 다시 켜는 것입니다. 이렇게 하면 모든 스크립트를 처음부터 다시 깨끗하게 컴파일하므로, 대부분의 경우 문제가 해결됩니다. (씬 저장을 잊지 마세요!)

 


근데 방어도 숫자가 중간에 오게 하고싶은데

 

해결 방법: Rect Transform과 TextMeshPro 정렬 기능 활용

가장 깔끔하고 이상적인 방법은 텍스트를 방패 이미지의 자식(Child)으로 만드는 것입니다. 이렇게 하면 방패를 옮길 때 숫자도 항상 따라다니게 됩니다.

1단계: 계층 구조 정리 (Hierarchy)

  1. 유니티 에디터의 하이아키(Hierarchy) 창에서, 방어력 숫자를 표시하는 TextMeshPro 오브젝트 (예: Defense_Text)를 방패 Image 오브젝트 (예: Shield_Icon) 위로 드래그 앤 드롭하세요.
  2. 이렇게 하면 Defense_Text Shield_Icon의 자식이 됩니다.

올바른 구조:

Code
Shield_Icon (부모)
└── Defense_Text (자식)

2단계: 앵커(Anchor)와 피봇(Pivot)을 중앙으로 맞추기 (가장 중요!)

  1. 하이아키 창에서 자식이 된 Defense_Text 오브젝트를 선택합니다.
  2. 인스펙터(Inspector) 창에서 Rect Transform 컴포넌트를 찾습니다.
  3. Rect Transform의 왼쪽 위에 있는 앵커 프리셋(Anchor Presets) 상자 (네모 안에 화살표들이 있는 아이콘)를 클릭합니다.
![alt text](https://storage.googleapis.com/gweb-cloud-media-pub/images/unity_anchor_presets.max-1000x1000.png)
  1. 키보드의 Alt 키와 Shift 키를 동시에 누른 상태로, 창의 **정중앙에 있는 사각형(middle-center)**을 클릭합니다.
    • Shift를 누르고 클릭: 앵커(닻)의 위치를 중앙으로 설정합니다.
    • Alt를 누르고 클릭: 피봇(객체의 중심점)의 위치를 중앙으로 설정하고, 실제 위치 값(Pos X, Y)을 0으로 만듭니다.
  2. 이 작업을 하고 나면, Rect Transform Pos X, Pos Y, Pos Z 값이 모두 0이 되면서 텍스트 오브젝트의 중심이 부모(방패)의 중심에 정확히 위치하게 됩니다.

3단계: 텍스트 자체를 가운데 정렬하기

Rect Transform은 텍스트가 담긴 '상자'를 가운데로 옮긴 것입니다. 이제 상자 안의 '글자' 자체를 가운데로 정렬해야 합니다.

  1. 계속해서 Defense_Text 오브젝트의 인스펙터를 봅니다.
  2. Rect Transform 아래에 있는 TextMeshPro - Text 컴포넌트를 찾습니다.
  3. Alignment 항목을 찾으세요. 워드 프로세서처럼 생긴 여러 개의 정렬 버튼이 보일 겁니다.
  4. 여기서 가로 가운데 정렬 세로 중간 정렬 버튼을 모두 클릭하여 눌러진 상태로 만드세요. (아래 이미지에서 빨간색으로 표시된 버튼들)
![alt text](https://storage.googleapis.com/gweb-cloud-media-pub/images/tmpro_alignment.max-1000x1000.png)

추천: 텍스트 크기 자동 조절 (Auto Size)

방어력 숫자가 한 자리(9)에서 세 자리(100)로 바뀌면 글자가 상자 밖으로 삐져나갈 수 있습니다. 이를 방지하려면 Auto Size 기능을 사용하는 것이 좋습니다.

  • TextMeshPro - Text 컴포넌트에서 Auto Size 옵션을 체크하세요.
  • 이렇게 하면 텍스트가 할당된 사각형 영역(Rect Transform의 Width, Height) 안에서 넘치지 않도록 글자 크기를 자동으로 조절해줍니다. Min Max 값으로 최소/최대 폰트 크기를 지정할 수 있습니다.

이 모든 과정을 거치면, 방어력 숫자는 항상 방패 이미지의 정중앙에 완벽하게 표시될 것입니다.

 

현재는 Anchor Presets middle-center가 선택되어 있지만, 이는 앵커(Anchor)만 중앙으로 옮긴 상태입니다.

이미지를 보시면 Pos X Pos Y 0.88..., -1.74... 같은 값이 남아있죠? 이것은 앵커(기준점)는 중앙으로 갔지만, 오브젝트 자체의 위치는 아직 그 기준점에서 약간 떨어져 있다는 의미입니다.


해결책: Alt 키를 함께 사용하기

정답: Alt 키를 누른 상태로 클릭해야 합니다.

사용자님께서 보신 Anchor Presets 창의 툴팁에 그 답이 있습니다.

  • Shift: Also set pivot (Shift를 누르면 피봇도 함께 설정)
  • Alt: Also set position (Alt를 누르면 위치도 함께 설정)

따라서, 완벽하게 정중앙에 맞추기 위한 정확한 순서는 다음과 같습니다.

  1. armorNumber 오브젝트를 선택합니다.
  2. Rect Transform Anchor Presets 상자를 클릭합니다.
  3. 키보드의 Alt 키와 Shift 키를 동시에 누릅니다.
  4. 그 상태로 마우스 커서를 가운데 있는 middle-center 프리셋으로 가져가서 클릭합니다.

이 작업을 하고 나면, Rect Transform Pos X Pos Y 값이 둘 다 **정확히 0**으로 바뀌면서 텍스트 오브젝트가 부모의 정중앙에 완벽하게 위치하게 될 것입니다.

 

 

왜 숫자가 아직 왼쪽 정렬이지
alt누르고햇는데도

 

 

네, Alt 키를 사용해서 Pos X Pos Y를 0으로 만드신 것은 아주 잘 하셨습니다! 캡처 화면을 보니 Rect Transform의 위치는 완벽하게 중앙으로 설정되었습니다.

그런데도 숫자가 왼쪽에 쏠려있는 이유는, 텍스트를 담는 '상자'는 중앙으로 왔지만, 상자 안의 '글자' 자체가 아직 왼쪽 정렬로 되어있기 때문입니다.

비유하자면, 액자는 벽의 정중앙에 걸었지만, 액자 속 사진이 한쪽으로 쏠려있는 것과 같은 상황입니다.

해결 방법: TextMeshPro 자체의 정렬(Alignment) 기능 사용

이제 마지막 한 단계만 남았습니다. TextMeshPro 컴포넌트에서 글자 자체를 가운데로 정렬해주면 됩니다.

  1. 계속해서 armorNumber 오브젝트가 선택된 상태로 인스펙터 창을 봅니다.
  2. Rect Transform 컴포넌트 아래로 스크롤하여 TextMeshPro - Text 컴포넌트를 찾습니다.
  3. 아래 이미지에 빨간색으로 표시된 Alignment 항목을 찾으세요.
  4. 여기서 가로 정렬 줄의 가운데 버튼과, 세로 정렬 줄의 가운데 버튼을 모두 클릭하여 눌러주세요.
![alt text](https://storage.googleapis.com/gweb-cloud-media-pub/images/tmpro_alignment_fix.max-1000x1000.png)

이 두 버튼을 누르는 즉시, Scene 뷰에서 숫자가 방패의 정중앙으로 예쁘게 이동하는 것을 보실 수 있을 겁니다.

 

 

 

 


레벨업햇을때 방어도를 주는 항목인데 이렇게 해도  된다
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "ShieldGain", menuName = "Rewards/ShieldGain")]
public class ShieldGain : RewardData
{
    UIManager uIManager;
    public int amount;
    public override void Apply(PlayerStats playerStats)
    {
        playerStats.shield += amount;
        uIManager.UpdateDefenseText(amount);
    }
}





근데 예전에는 ui매니저의 인스턴스 해야햇던거 같은데
지금은 그럴필요없나
UIManager.Instance.UpdateHealthBar(playerStats.currentHealth, playerStats.maxHealth);

 

결론부터 말씀드리면, 지금 작성하신 ShieldGain.cs 코드는 작동하지 않고 오류를 발생시킵니다.
그리고 예전에 사용하셨던 UIManager.Instance.Update... 방식이 올바른 방법이 맞습니다.


왜 지금 코드는 작동하지 않을까요? (가장 중요한 이유)

가장 큰 이유는 ShieldGain.cs 스크립트 안에 있는 이 두 줄 때문입니다.

C#
// 1. UIManager를 담을 수 있는 '빈 상자'를 선언했습니다.
UIManager uIManager;

// ...

// 2. 그 '빈 상자'에게 일을 시키려고 합니다.
uIManager.UpdateDefenseText(amount);

여기서 uIManager 변수는 선언만 되었을 뿐, 어떤 UIManager 객체를 가리키는지 전혀 할당되지 않은 '빈 값(null)' 상태입니다. 컴퓨터는 텅 비어있는 대상에게 일을 시킬 수 없기 때문에, "참조하는 대상이 없습니다!" 라는 NullReferenceException 오류를 발생시키게 됩니다.

이것은 PlayerController 때 겪었던 문제와 정확히 동일합니다.

"그럼 UIManager.Instance는 왜 괜찮았던 걸까요?"

이것이 바로 싱글톤(Singleton) 패턴의 마법입니다. UIManager 스크립트에 있는 이 코드를 다시 보겠습니다.

C#
// 파일 이름: UIManager.cs
public class UIManager : MonoBehaviour
{
    // 1. 'Instance'라는 이름의 '공용 주소록'을 만듭니다. (static)
    public static UIManager Instance { get; private set; }

    void Awake()
    {
        // 2. UIManager가 깨어날 때, 이 '공용 주소록'에 자기 자신의 주소를 등록합니다.
        if (Instance == null)
        {
            Instance = this; // "나 UIManager의 주소는 바로 'this'야!"
        }
        // ...
    }
    // ...
}
  • static:  Instance라는 주소록은 특정 UIManager 복제품에 속한 것이 아니라, UIManager라는 클래스(설계도) 자체에 속해 있습니다. 전역적으로 딱 하나만 존재합니다.
  • Awake(): UIManager 오브젝트가 씬에서 활성화될 때, 자기 자신(this)을 이 공용 주소록(Instance)에 등록합니다.

결과적으로 UIManager.Instance는 "현재 씬에 존재하는 단 하나의 UIManager를 가리키는 전역적인 바로가기" 가 됩니다.

다른 어떤 스크립트에서든 UIManager.Instance를 사용하면, 이 바로가기를 통해 씬에 있는 실제 UIManager 객체와 직접 통신할 수 있습니다. 그래서 GetComponent 같은 걸로 찾아줄 필요도 없고, null일 걱정도 없는 것입니다.


올바르게 수정한 ShieldGain.cs 코드

따라서 ShieldGain.cs는 다음과 같이 수정해야 올바르게 작동합니다.

C#
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(fileName = "ShieldGain", menuName = "Rewards/ShieldGain")]
public class ShieldGain : RewardData
{
    // 이 변수는 이제 필요 없습니다. Instance를 사용할 것이기 때문입니다.
    // UIManager uIManager; 
    
    public int amount;
    
    public override void Apply(PlayerStats playerStats)
    {
        // 1. 플레이어의 방어막을 증가시킵니다.
        playerStats.shield += amount;

        // 2. 'UIManager'의 공용 주소록(Instance)을 통해 
        //    씬에 있는 UIManager에게 UI 업데이트를 요청합니다.
        //    이때, 증가량(amount)이 아닌, 플레이어의 '최종 방어력(playerStats.shield)'을 전달해야 합니다.
        UIManager.Instance.UpdateDefenseText(playerStats.shield);
    }
}

요약

구분 UIManager uIManager; (사용자님의 코드) UIManager.Instance (올바른 코드)
정체 아무것도 가리키지 않는 '빈 변수' (null) 씬에 있는 UIManager를 가리키는 '전역 바로가기'
결과 NullReferenceException 오류 발생 정상 작동
결론 틀린 방법 싱글톤 패턴을 활용한 올바른 방법

 

 


그런데 피격됫을때도 호출해야하는데

이떄 PlayerStats.Instance.으로 해야하나

ㄴㄴ

 PlayerStats.Instance를 쓰지 않아도 될까요? (가장 중요한 개념)

이것을 "자기 자신" vs "다른 사람" 의 개념으로 생각하면 아주 쉽습니다.

  • 자기 자신 (PlayerStats 스크립트 내부):
    • 지금 우리는 PlayerStats.cs 라는 스크립트 파일 안에서 TakeDamage 함수를 코딩하고 있습니다.
    • 이 함수는 PlayerStats 클래스에 속한 '가족'입니다.
    • 따라서 같은 가족인 shield, currentHealth, maxHealth 같은 변수들을 사용할 때, 굳이 '우리 집 주소'를 부를 필요가 없습니다. 그냥 "방어막 줘", "체력 줘" 라고 이름만 부르면 바로 알아듣습니다.
    • this.shield 라고 쓸 수도 있지만, 보통 this는 생략합니다. 그래서 그냥 shield 라고 씁니다.
  • 다른 사람 (UIManager):
    • PlayerStats UIManager의 함수를 호출하는 것은 '다른 집'에 있는 사람에게 말을 거는 것과 같습니다.
    • 그래서 "저기 UIManager네 집에 사는 Instance씨, UpdateDefenseText 좀 실행해주세요!" 라고 주소를 정확히 불러줘야 합니다.
    • 이것이 바로 UIManager.Instance.UpdateDefenseText(...) 입니다. Instance UIManager네 집의 대표 이름(싱글톤)인 셈입니다.

결론: TakeDamage 함수는 PlayerStats의 집 안방에 있으므로, 같은 집에 있는 shield 변수를 부를 때는 그냥 이름만 부르면 됩니다.