본문 바로가기

Unity 개발 공부

[내배캠] 본캠 26일차. 스킬 선택창, 스킬 UI 씬에 띄우기, 컨테이너

스킬 선택창 만들기

우선 스킬 창에서 값들을 가져온다. 그 방법으로 UI_StageResult 인스펙터창에서 skill 컴포넌트(스크립트)를 가진 오브젝트를
할당해줬다.

using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class UI_StageResult : UI_Popup
{
    [SerializeField] private SkillManager skill;

    [Header("Buttons&Labels")]

    public Button option1Button;
    public Button option2Button;
    public Button option3Button;


    public void Init()
    {
    
        skill = GameObject.Find("Weapon_Bow").GetComponent<SkillManager>();
        
        var candidates = new Dictionary<string, System.Action>();
        if (skill.shootSpeed < 50f)
            candidates[$"shootSpeed +1 ({skill.shootSpeed} -> {skill.shootSpeed + 1})"] =
                () => skill.shootSpeed += 1f;

        if (skill.arrowCount < 5)
            candidates[$"arrowCount +1 ({skill.arrowCount} -> {skill.arrowCount + 1})"] =
                () => skill.arrowCount += 1;

        if (!skill.addGhost)
            candidates["addGhost"] = () => skill.addGhost = true;


        if (skill.addBomb < 3) // ?덉떆 ?곹븳 3
            candidates[$"addBomb +1 ({skill.addBomb} -> {skill.addBomb + 1})"] =
                () => skill.addBomb += 1;

        if (!skill.addPenetrates)
            candidates["addPenetrates"] = () => skill.addPenetrates = true;


        if (skill.addSpread < 5)
            candidates[$"addSpread +1 ({skill.addSpread} -> {skill.addSpread + 1})"] =
                () => skill.addSpread += 1;

        if (!skill.addChase)
            candidates["addChase"] = () => skill.addChase = true;


        var keys = new List<string>(candidates.Keys);
        var picked = new List<string>();
        var rnd = new System.Random();
        int pickCount = Math.Min(3, keys.Count);
        for (int i = 0; i < pickCount; i++)
        {
            int idx = rnd.Next(keys.Count);
            picked.Add(keys[idx]);
            keys.RemoveAt(idx);
           
        }

        var buttons = new[] { option1Button, option2Button, option3Button };
        for (int i = 0; i < buttons.Length; i++)
        {
            var btn = buttons[i];
            btn.onClick.RemoveAllListeners();

            if (i < picked.Count)
            {
                string label = picked[i];
                Action apply = candidates[label];

                btn.gameObject.SetActive(true);
                btn.GetComponentInChildren<TextMeshProUGUI>().text = label;
                GameObject popupRoot = this.gameObject;
                btn.onClick.AddListener(() =>
                {
                    apply();
                    GameManager.instance.IsStageClear = true;   
                    Destroy(popupRoot);
                });
            }
            else
            {
                btn.gameObject.SetActive(false);
            }
        }
    }
}
    
몇가지만 모르고있어 공부했다.

우선 keys.RemoveAt(idx)
key가 List<string> 일때, RemoveAt<int index) 는  리스트에서 인덱스 index에 해당하는 요소를 제거하는것이다.
예를들어
var fruits = new List<string> { "Apple", "Banana", "Cherry"} 
fruits.RemoveAt(1);
이라면
Banana가 인덱스1이니까 제거되고
fruits = {"Apple", Cherry"}
가 된다.

Remove(item)은 값(item)을 찾아서 제거한 코드였고
RemoveRange(startIndex, count) : 연속된 여러요소 제거하는...
Clear() : 리스트 모두 비움
Insert(index, item) : 특정위치에 새 요소 삽입
같은 실행코드들도 존재한다.

그렇게 본다면.. 
int pickCount = Math.Min(3, keys.Count); 해서
루프 횟수(i < pickCount)로 고정한다음에, (최대라해바야 3)
루프 3번돌면서 예를들어
keys.Count가 7이라면 rnd.Next(7) → 0~6 중 하나
예를 들어 idx == 3 → picked에 keys[3]을 추가
keys.RemoveAt(3)로 리스트에서 인덱스 3 요소(원래 4번째)를 삭제
이제 keys.Count는 6이 된다.

두 번째 반복
이제 rnd.Next(6) → 0~5 중 하나
또 하나 뽑고, 그 인덱스의 요소를 제거 → keys.Count는 5로 줄어듦
세 번째 반복
rnd.Next(5) → 0~4 중 하나
다시 뽑고 제거 → keys.Count는 4가 됨

var keys = new List<string>(candidates.Keys);

이 한 줄은 루프 밖에서 한 번만 실행되어
원본 candidates.Keys(딕셔너리의 키들)를 복사한 새로운 List<string>를 만든다.
이후 RemoveAt은 이 복사본(list)만 수정할 뿐,
원본 candidates 딕셔너리에는 영향을 주지는 않는다.

여기서 한번더 복습하자면 new List는 얕은 복사의 방법이다.
List<T> 자체는 참조타입이라서
var a = somLists; 하면 a와 someLists는 같은 객체를 가리킨다
하지만
var keys = new List<string>(candidates.Keys);
이 구문은 새로운 List<string> 객체를 생성하는 코드이고
new List<T>(IEnumerable<T> collection) 생성자는
내부에서 새로운 배열(또는 버퍼)를 만들고
collection을 한 원소씩 꺼내서(foreach)
그 원소들을 새로운 배열에 복사해 넣는다.

따라서 keys 변수는 원본 cadidates.keys가 아니라
원본에 있는 키들을 한번 읽어서 담아놓은 완전히 다른리스트 객체가된다.

new List<string>(...)는 얕은 복사에 가깝다.
리스트의 구조(어떤 원소가 몇 개 들어있는지)만 복사
원소(string)들은 같은 참조(문자열 리터럴이나 기존 키 문자열)를 가리킨다.
하지만 리스트 그 자체(메모리 버퍼)는 별도이므로
keys.RemoveAt(idx);
해도 원본딕셔너리의 키 목록은 전혀 건드리지않는다.

깊은복사라고 말그대로 원본객체 전체를 통째로 복제해서 객체의 원본이 어떤 참조도 공유하지않도록
하는 방식도 있다.

물론 얕은복사라지만, List안에 있는 값들을 더하거나 빼거나, 값자체를 변경한다고 ( 예: keys[0] = "새문자열";) 
 candidates.keys 의 값이 바뀌지는 않는다.
그이유는 c#의 string은 불변(immutable) 이어서 문자열 내부가 절대 바뀌지않는다.
딕셔너리 키 자체도 외부에서 그 키를 직접 수정 또는 삭제할 수 없다.
읽기전용으로 제공하고만있다. 딕셔너리는 RemoveAt(int index) 같은 인덱서기반 삭제 메서드가없다.
Dictionary(TKey, TValue> 는 내부적으로 해시 테이블을 이용해 키를 해시값으로 바로 찾아가는 구조다.
따라서 0번째 요소 같은 개념이없고 키를 알아야 Remove(key)로 삭제할수있다.

var dict = new Dictionary<string, int> {
    ["A"] = 1,
    ["B"] = 2,
    ["C"] = 3
};

// ❌ dict.RemoveAt(1);   // 컴파일 오류
dict.Remove("B");         // ✅ 키 "B"에 해당하는 항목 삭제

해당  Remove를 실행하면
딕셔너리에서 키- 값 쌍으로 제거된다.
그래서 이번 예제 코드에서도 키목록을 리스트로 복사후 위치지정삭제하는 방식으로 응용한것이다.

참고로 가변객체라면 얕은복사의 경우 값이 참조타입이면서 동시에 수정한 부분이 오리지널에도 적용되게 되는데
그예시로

public class Person
{
    public string Name;  
    public int Age;      // Age 필드를 언제든 바꿀 수 있다
}

var p1 = new Person { Name = "Alice", Age = 20 };
var p2 = p1;     // p2도 동일한 인스턴스를 가리킴
p2.Age = 30;     // p1.Age도 30으로 바뀜!
Console.WriteLine(p1.Age); // 30

새로 new 해서 객체를 만들었음에도, 변경가능한 상태다.
비슷한 상황을 예시로들어보자면

// 가변 객체 예시
var person = new Person { Name = "Alice" };
originalList.Add(person);
var copyList = new List<Person>(originalList);

copyList[0].Name = "Bob";
// originalList[0].Name 도 "Bob" 으로 바뀜

//불변객체 예시
var original = new List<string> { "Hello" };
var copy     = new List<string>(original);  // 얕은 복사

// 문자열 내부 문자를 직접 바꾸는 건 애초에 불가
// copy[0][0] = 'h'; // 컴파일 오류!

// 대신 리스트의 요소를 교체하는 건 가능하지만
copy[0] = "World";  
Console.WriteLine(original[0]);  // 여전히 "Hello"


Dictionary<TKey, TValue> 기본문법을 한번더 짚고넘어가보자
var candidates = new Dictionary<string, Action>(); 이라면
값저장은
candidates["speedUp"] = () => speed += 1; 
// key: "speedUp", value: 람다(() => speed += 1)

candidates[key] = value;
대괄호 인덱서 문법을 통해 key에 해당하는 값을 value로 할당(추가 또는 교체)

값 읽어오기는
Action upgrade = candidates["speedUp"]

var upgrade = candidates[key];
key가 딕셔너리에 있어야만함, 있다면, 그에 대응하는 value를 반환한다.

안전하게 읽으려면,
ContainsKey 사용해서   키 존재 여부 검사
if(candidates.ContainsKey("speedUp"))
candidates["speedUp"]();

TryGetValue 사용해서 존재 시 값 반환, 없으면 false
if(candidates.TryGetValue("speedUp", out Action action))
action(); // 바로 실행

말고도
Add(key, value) 존재하지 않는 키만 추가 (중복 시 예외)
Clear() 모든 항목 제거
도 존재한다.

new List<string>(candidates.Keys) 는 무슨 문법일까? 소괄호에 매개변수로 들어가는것같다.
List<T> 생성자 중 하나는 IEnumerable<T>(열거 가능한 컬렉션)을 받는다는 뜻으로
candidates.Keys는 Dictionary<string, Action>에 있는 모든 키(ICollection<string>)를 열거할 수 있는 컬렉션.

일반 List<string>과 다른 점?
var empty = new List<string>();
아무것도 넣지않은 빈 리스트

생성자에 컬렉션을 넘기면, 넘긴 컬렉션의 모든 요소를 복사해서 리스트 초기값으로 사용
var fruits = new List<string>( new[] { "A", "B", "C" } );
// fruits에는 "A","B","C"가 들어있는 상태로 시작

여기서
candidates.keys 문법은,,
속성(프로퍼티)를 사용한것이다.

Dictionary<TKey,TValue>.Keys
현재 딕셔너리에 저장된 모든 키만 모아서 돌려줌 (ICollection<TKey>)
비슷한 속성으로
dictionary.Values : **값(들)**만 모아서 돌려주는 컬렉션
dictionary.Count : 항목 개수
dictionary.Item[key] : 대괄호 인덱서, 앞서 본 dictionary[key]

RemoveAllListeners() 는 UnityEvent 중 Button.onClick 의 API로
지금까지 onClick.AddListener(...)로 등록했던 모든 콜백(리스너)을 한 번에 제거한다.

AddListener(UnityAction call) 새 클릭 콜백 등록
RemoveListener(UnityAction call) 특정 콜백만 제거
RemoveAllListeners() 모든 콜백 제거
Invoke() 직접 이벤트 호출 (보통 내부에서 실행)

이런식으로있다.


여기서 
            if (i < picked.Count)
            {
                string label = picked[i];
                Action apply = candidates[label];

                btn.gameObject.SetActive(true);
                btn.GetComponentInChildren<TextMeshProUGUI>().text = label;
                GameObject popupRoot = this.gameObject;
                btn.onClick.AddListener(() =>
                {
                    apply();
                    GameManager.instance.IsStageClear = true;   
                    Destroy(popupRoot);
                });
여기서쓰인 apply(); 함수가 뭘까?
apply는 Action 델리게이트 변수였다.
Action apply = candidates[label]; 이라고 변수에 할당했다.

apply(); 는 해당 액션 델리게이트를 호출하는것으로
내부적으로는
apply.Invoke(); 와 동일하다.

// 예시
void Foo() { Console.WriteLine("Foo!"); }
Action a = Foo;  
a();  // "Foo!" 출력

 

--------------------------

스킬 아이콘 오른쪽에 정렬하기 공부.

using System.Collections;
using System.Collections.Generic;
using Systehttp://m.Reflection.Emit;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class UI_GameScene : MonoBehaviour
{
    public TextMeshProUGUI stageText;
    public Button optionButton;
    //public Button statusButton;
    public TextMeshProUGUI goldText;

    [Header("Skill Icons")]
    public RectTransform skillsContainer;
    public GameObject skillIconPrefab;

    private Dictionary<string, TextMeshProUGUI> skillIconMap = new Dictionary<string, TextMeshProUGUI>();

    private GridLayoutGroup grid;
    private SkillManager skillManager;


    [Header("UI Prefabs (Resources/UI)")]
    
    public string questListName = "UI_QuestList";
    public string healthBarName = "UI_HealthBar";

    private UI_QuestList questList;

    public Slider healthSlider;
    public TextMeshProUGUI healthText;
    private PlayerManager player;

    private void Awake()
    {
        grid = skillsContainer.GetComponent<GridLayoutGroup>();
        skillManager = FindObjectOfType<SkillManager>();

        GameManager.instance.OnSkillUpgraded += OnSkillUpgraded;
        PlayerController.Instance.OnGoldChanged += UpdateGoldUI;
        UpdateGoldUI(PlayerController.Instance.Gold);
    }

    private void Start()
    {
        player = FindObjectOfType<PlayerManager>();
        if (player == null)
        {
            Debug.LogError("PlayerManager not found in the scene.");
            return;
        }
        player.OnHealthChanged += SetHealth;
        player.OnPlayerDie += ShowFailPopup;
        SetHealth(player.CurrentHealth, player.MaxHealth);

        SetInfo();
    }

    public void Init()
    {
        GameManager.instance.OnStageUpdated -= OnStageUpdated;
        GameManager.instance.OnStageUpdated += OnStageUpdated;

        optionButton.onClick.AddListener(OnClickOptionButton);
        //statusButton.onClick.AddListener(OnClickStatusButton);

        //questList = UIManager.Instance.ShowPopup<UI_QuestList>(questListName);
        //questList.Init();      

        //var stat = PlayerStat.Instance;
        //healthBar.SetHealth(stat.CurrentHealth, statMaxHealth: stat.MaxHealth);
        //stat.OnHealthChanged += (cur, max) =>
        //{
        //    healthBar.SetHealth(cur, max);
        //};

    }

    public void SetInfo()
    {
        Refresh();
    }

    void Refresh()
    {
        OnStageUpdated();
        // TODO: 다른 UI 요소 리프레시 호출
    }

    void OnStageUpdated()
    {
        stageText.text = "스테이지 " + GameManager.instance.Stage.ToString();
    }

    void OnClickOptionButton()
    {
        UI_OptionPopup optionPopup =  UIManager.Instance.ShowPopup<UI_OptionPopup>("UI_OptionPopup");
        optionPopup.Init();
    }

    //void OnClickStatusButton()
    //{
    //    UI_StatsPopup statusPopup = UIManager.Instance.ShowPopup<UI_StatsPopup>("UI_StatsPopup");
    //    statusPopup.Init();
    //}
    public void SetHealth(int current, int max)
    {
        healthSlider.maxValue = max;
        healthSlider.value = current;
        healthText.text = $"{current}/{max}";
    }

    private void ShowFailPopup()
    {
        var popup = UIManager.Instance.ShowPopup<UI_StageFailPopup>("UI_StageFailPopup");
        popup.Init();
    }

    private void OnDestroy()
    {
        GameManager.instance.OnSkillUpgraded -= OnSkillUpgraded;
    }

    private void OnSkillUpgraded(string label)
    {
        AddSkillIcon(label);
        UpdateGridConstraint();
    }

    private void AddSkillIcon(string label)
    {
        string key = label.Split(' ')[0];

        string value = GetSkillValue(key);

        if(skillIconMap.TryGetValue(key, out var existingText))
        {
            existingText.text = $"{key}: \n{value}";
        }
        else
        {
            var icon = Instantiate(skillIconPrefab, skillsContainer, false);
            var rt = icon.GetComponent<RectTransform>();
            rt.localScale = Vector3.one;
            var iconText = icon.GetComponentInChildren<TextMeshProUGUI>();
            iconText.text = $"{key}: \n{value}";
            skillIconMap[key] = iconText;
        }


    }

    private string GetSkillValue(string key)
    {
        switch (key)
        {
            case "shootSpeed":
                return skillManager.shootSpeed.ToString();
            case "arrowCount":
                return skillManager.arrowCount.ToString();
            case "addGhost":
                return skillManager.addGhost ? "True" : "False";
            case "addBomb":
                return skillManager.addBomb.ToString();
            case "addPenetrates":
                return skillManager.addPenetrates ? "True" : "False";
            case "addSpread":
                return skillManager.addSpread.ToString();
            case "addChase":
                return skillManager.addChase ? "True" : "False";
            default:
                return "";
        }
    }

    private void UpdateGridConstraint()
    {
        float width = skillsContainer.rect.width;
        float cellAndSpacing = grid.cellSize.x + grid.spacing.x;

        int maxColumns = Mathf.Max(1, Mathf.FloorToInt((width + grid.spacing.x) / cellAndSpacing));

        grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
        grid.constraintCount = maxColumns;
    }

    private void UpdateGoldUI(int gold)
    {
        goldText.text = $"Gold: {gold}";
    }

}