https://www.youtube.com/watch?v=XhfB3ZS3JoM&list=LL&index=5&t=11139s

이번에 유투브 강의영상을 보고 따라 만들어 보기로 하였다. 여기서 똑같이 만드는 것이 아닌 내 방식대로 만들어보려고 한다.

그리고 여기에 내가 응원하는 배우님이 카페에 올려주신 사진을 활용해서 카드 짝 맞추기 게임을 만들어 볼 것이다.

구현 순서는 영상에 나오는 순서대로 구현을 해보도록 할 것이다.

1.카드 및 보드 구현

우선 카드 객체를 구현해보자 필요한 기능은 뒤집는 기능이 필요하다. 이 기능은 Dotween과 IPointerClickHandler를 사용하여 구현하였다.

Card.cs

using UnityEngine;
using UnityEngine.EventSystems;
using DG.Tweening;

public class Card : MonoBehaviour, IPointerClickHandler
{
    [SerializeField] private SpriteRenderer spriteRenderer;
    [SerializeField] private Sprite frontSprite;
    [SerializeField] private Sprite backSprite;
    private Board board;
    private bool isFlipped = false;
    private bool isAnimating = false;

    private void Awake()
    {
        spriteRenderer = GetComponent<SpriteRenderer>();

    }

    public void SetCard(Sprite sprite, Board board)
    {
        frontSprite = sprite;
        this.board = board;
        backSprite = Resources.Load<Sprite>("Sprites/Front/Front"); // 뒷면 기본 이미지
        spriteRenderer.sprite = backSprite;
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        if (board.GetSelectedCardCount() >= 2 || isFlipped || isAnimating)
            return; // 이미 2장이 선택되었으면 클릭 방지

        FlipCard();
        board.SelectCard(this);
    }


    public void FlipCard()
    {
        if (isAnimating) return;
        isAnimating = true;

        Vector3 targetScale = new Vector3(0f, transform.localScale.y, transform.localScale.z);


        transform.DOScale(targetScale, 0.2f).OnComplete(() =>
        {
            spriteRenderer.sprite = isFlipped ? backSprite : frontSprite;
            isFlipped = !isFlipped;

            transform.DOScale(Vector3.one, 0.2f).OnComplete(() =>
            {
                isAnimating = false;
            });
        });


    }

    public void FlipBack()
    {
        if (!isFlipped) return;
        FlipCard();
    }

    public Sprite GetSprite()
    {
        return frontSprite;
    }

    public bool IsFlipped()
    {
        return isFlipped;
    }

}

 

보드는 모든 카드의 위치를 관리해주고 룰을 결정해준다. 카드는 4*5배열로 배치되게 하였다. 

Board.cs

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

public class Board : MonoBehaviour
{
    [SerializeField] private GameObject cardPrefab;
    [SerializeField] private Sprite[] cardSprites; // 카드 앞면 (1~6번 이미지)
    private List<Card> cards = new List<Card>();
    private List<Card> selectedCards = new List<Card>(); // 선택된 카드들

    private int rowCount = 5;           //세로
    private int colCount = 4;           //가로
    private float xStart = -2.1f;
    private float yStart = 3.3f;
    private float xSpacing = 1.4f;
    private float ySpacing = -1.8f;

    private void Awake()
    {
        cardPrefab = Resources.Load<GameObject>("Prefabs/Card/Card");
        LoadSprites();
        ShuffleCards();
        InitBoard();
    }

    void LoadSprites()
    {
        // Resources 폴더에서 "Sprites/0~9" 로드
        cardSprites = new Sprite[10];
        for (int i = 0; i < 10; i++)
        {
            cardSprites[i] = Resources.Load<Sprite>($"Sprites/Back/{i}");
        }
    }

    public void ShuffleCards()
    {
        List<Sprite> tempSprites = new List<Sprite>();

        // 0~9번 카드 각각 2장씩 추가
        foreach (var sprite in cardSprites)
        {
            tempSprites.Add(sprite);
            tempSprites.Add(sprite);
        }

        // 랜덤 섞기
        tempSprites = tempSprites.OrderBy(x => Random.value).ToList();

        // cards 리스트에 카드 추가
        cards.Clear();
        for (int i = 0; i < tempSprites.Count; i++)
        {
            GameObject newCard = Instantiate(cardPrefab, Vector3.zero, Quaternion.identity, this.transform);
            Card card = newCard.GetComponent<Card>();
            card.SetCard(tempSprites[i], this);
            cards.Add(card);
        }
    }


    public void InitBoard()
    {
        int index = 0;
        for (int i = 0; i < rowCount; i++)
        {
            for (int j = 0; j < colCount; j++)
            {
                if (index >= cards.Count) return;  // 카드 개수 초과 방지

                // 위치 설정
                Vector3 pos = new Vector3(xStart + (xSpacing * j), yStart + (ySpacing * i), 0);

                // 기존에 생성된 카드 객체를 위치만 변경
                cards[index++].transform.position = pos;

            }
        }
    }


    public void SelectCard(Card card)
    {
        if (selectedCards.Contains(card) || selectedCards.Count >= 2)
            return;

        selectedCards.Add(card);

        if (selectedCards.Count == 2)
        {
            CheckMatch();
        }
    }

    void CheckMatch()
    {
        if (selectedCards.Count < 2) return; // 두 장 선택되지 않으면 비교 불가

        if (selectedCards[0].GetSprite() == selectedCards[1].GetSprite())
        {
            // 같은 카드라면 유지
            selectedCards.Clear();
            Managers.Audio.PlaySound("Match");  // 카드 맞추면 효과음 재생!
        }
        else
        {
            // 다른 카드라면 1초 후 다시 뒤집기
            Invoke(nameof(ResetCards), 1f);
        }
    }

    void ResetCards()
    {
        foreach (var card in selectedCards)
        {
            card.FlipBack();
        }
        selectedCards.Clear();
    }


    public int GetSelectedCardCount() => selectedCards.Count;
    public List<Card> GetCards() => cards;
}

 

실제로 배치된 모습을 보면 다음과 같다

 

이제 GameManager를 통해 이 보드의 초기화 함수인 Init을 호출해주고 코루틴 함수를 통해 모든 카드를 오픈하고 다시 뒤집어서 유저가 게임을 진행할 수 있게 한다. 또한 시간을 관리해주고 게임이 이겼는지 확인해준다. 

GamaManager.cs

using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.PlayerLoop;
using UnityEngine.UI;
using static UnityEngine.RuleTile.TilingRuleOutput;

public class GameManager
{
    private Board board;
    private List<Card> cards;
    private bool isGameActive = false;
    private float gameTime = 60f; // 총 게임 시간
    private float remainingTime; // 현재 남은 시간

    private Slider timeSlider;
    private Image sliderFill; // 슬라이더의 Fill 색상 변경용
    private TextMeshProUGUI timeText; // 남은 시간을 표시하는 UI
    public event Action<float> OnTimeUpdated;

    public void Init()
    {
        board = GameObject.Find("Board")?.GetComponent<Board>();
        timeSlider = GameObject.Find("TimeOutSlider")?.GetComponent<Slider>();
        timeText = GameObject.Find("TimeOutText")?.GetComponent<TextMeshProUGUI>();

        if (board == null || timeSlider == null || timeText == null)
        {
            Debug.LogError("GameManager 초기화 실패 - 필수 UI 요소가 없음.");
            return;
        }

        // 게임 시작
        isGameActive = true;

        sliderFill = timeSlider.fillRect.GetComponent<Image>();
        remainingTime = gameTime;

        CoroutineHelper.StartCoroutine(StartGameSequence());
    }


    IEnumerator StartGameSequence()
    {
        // 보드가 초기화될 시간을 기다림
        yield return new WaitForSeconds(0.3f);
        cards = board.GetCards();

        // 모든 카드 공개 (처음 1초 동안)
        foreach (var card in cards)
        {
            card.FlipCard();
        }
        yield return new WaitForSeconds(1.5f);

        // 다시 뒤집기
        foreach (var card in cards)
        {
            card.FlipBack();
        }

        yield return new WaitForSeconds(0.3f);

        // 타이머 UI 활성화
        timeSlider.gameObject.SetActive(true);
        timeText.gameObject.SetActive(true);
        Managers.Audio.PlayBGM("BGM");

  
        CoroutineHelper.StartCoroutine(UpdateTimer());
    }

    IEnumerator UpdateTimer()
    {
        while (remainingTime > 0 && isGameActive)
        {
            remainingTime -= Time.deltaTime;
            timeSlider.value = remainingTime;
            OnTimeUpdated?.Invoke(remainingTime); // UI 업데이트 호출

            if (CheckWinCondition())
            {
                GameOver(true);
                yield return new WaitForSeconds(1f);
                yield break;
            }

            yield return null;
        }

        if (remainingTime <= 0)
        {
            GameOver(false);
        }
    }

    private bool CheckWinCondition()
    {
        foreach (var card in board.GetCards())
        {
            if (!card.IsFlipped()) return false;
        }
        return true;
    }

    private void GameOver(bool isWin)
    {
        isGameActive = false;
        Time.timeScale = 0.0f;
        CoroutineHelper.StartCoroutine(GameOverSequence(isWin));
    }

    private IEnumerator GameOverSequence(bool isWin)
    {
        yield return new WaitForSecondsRealtime(0.5f); // 0.5초 딜레이 후 실행

        // DOTween의 모든 트위닝을 제거
        DG.Tweening.DOTween.KillAll();

        if (isWin)
        {
            Managers.UI.ShowPopupUI<UI_Success>();
        }
        else
        {
            Managers.UI.ShowPopupUI<UI_GameOver>();
        }
    }

}

 

그리고 게임에 필요한 UI는 UI_Game으로 묶어서 관리해주도록 했다. 옵저버 패턴을 사용하여 시간이 지남에 따라 색깔이 바뀌고 슬라이더의 바가 줄어들도록 구현했다. 이때 각 오브젝트의 이름과 Bind하는 enum 변수들의 이름이 같아야 정상적으로  Bind가 이루어진다.

UI_Game.cs

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

public class UI_Game : UI_Scene
{

    enum Buttons
    {
        EscapeButton,
    }

    enum Texts
    {
        TimeOutText,
    }

    enum GameObjects
    {
        TimeOutSlider,
    }

    private float gameTime = 60f; // 총 게임 시간


    public override void Init()
    {
        base.Init(); // 상위 클래스의 초기화 메서드 호출

        Bind<Button>(typeof(Buttons));
        Bind<TextMeshProUGUI>(typeof(Texts));
        Bind<GameObject>(typeof(GameObjects));

        GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().maxValue = gameTime;
        GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().value = gameTime;
        GetButton((int)Buttons.EscapeButton).gameObject.AddUIEvent(PauseOrResume);

        Managers.Game.OnTimeUpdated += UpdateTimeUI;
    }

    void PauseOrResume(PointerEventData eventData)
    {
        // 1. 뭐든지 열려있으면 다 닫기
        // 2. 아무것도 없으면 열기

        if (Managers.UI.GetStackSize() > 0)
            Managers.UI.CloseAllPopupUI();
        else
            Managers.UI.ShowPopupUI<UI_PausePopup>();
    }

    private void UpdateTimeUI(float time)
    {
        GetText((int)Texts.TimeOutText).text = Mathf.CeilToInt(time).ToString();
        GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().value = time; 
        UpdateTimeColor(time);
    }


    private void UpdateTimeColor(float time)
    {
        float normalizedTime = time / gameTime;
        Color startColor = new Color(0.96f, 0.55f, 0.0f);
        Color endColor = new Color(1.0f, 0.0f, 0.0f);
        Color timeColor = Color.Lerp(endColor, startColor, normalizedTime);

        GetText((int)Texts.TimeOutText).color = timeColor;
        GetObject((int)GameObjects.TimeOutSlider).GetComponent<Slider>().fillRect.GetComponent<Image>().color = timeColor;
    }

    private void OnDisable()
    {
        Managers.Game.OnTimeUpdated -= UpdateTimeUI;
    }
}

 

UI구성은 다음과 같다. 


이제 적에 대한 코드를 구현해보자. 우선 적이 어떤 상태를 가지게 될지 생각해보자.

간단한 쯔구르게임이기 때문에 가장 기본인 순찰, 추적, 공격상태가 있을 것이다. 우선은 순찰 및 추적까지 구현해보자.

이를 위해 상태 인터페이스를 구현해주자. 2개의 PatrolPoint에서 하나씩 목적지로 선정하여 가까워지면 다른 포인트로 이동하게 구현하였다.

IMobState.cs

using UnityEngine;

public interface IMobState
{
    void Enter(BaseMobController mob);      // 상태 진입
    void Execute();                 // 상태 실행
    void Exit();                    // 상태 종료
}

public class PatrolState : IMobState
{
    private BaseMobController _mob;
    private Vector2 currentTargetPoint;
    private Vector2 patrolPointA;
    private Vector2 patrolPointB;

    public PatrolState(Vector2 pointA, Vector2 pointB)
    {
        patrolPointA = pointA;
        patrolPointB = pointB;
        currentTargetPoint = patrolPointA;
    }

    public void Enter(BaseMobController mob)
    {
        this._mob = mob;
        _mob.SetDestination(currentTargetPoint);
    }

    public void Execute()
    {
        if (_mob.IsPlayerDetected())
        {
            _mob.ChangeState(new ChaseState());
            return;
        }

        if (Vector2.Distance(_mob.transform.position, currentTargetPoint) < 1f)
        {
            currentTargetPoint = (currentTargetPoint == patrolPointA) ? patrolPointB : patrolPointA;
            _mob.SetDestination(currentTargetPoint);
        }

        _mob.Move(currentTargetPoint);
    }

    public void Exit()
    {

    }
}

public class ChaseState : IMobState
{
    private BaseMobController _mob;
    private Transform playerTransform;

    public void Enter(BaseMobController mob)
    {
        this._mob = mob;
        playerTransform = Managers.Game.GetPlayer().transform;
    }

    public void Execute()
    {
        float distanceToPlayer = Vector2.Distance(_mob.transform.position,playerTransform.position);
        if(distanceToPlayer <= _mob.GetAttackRange())
        {
            _mob.ChangeState(new AttackState());
        }else if(distanceToPlayer > _mob.GetAttackRange())
        {
            Vector2 pointA = _mob.GetPatrolPointA();
            Vector2 pointB = _mob.GetPatrolPointB();
            _mob.ChangeState(new PatrolState(pointA, pointB));
        }else
        {
            _mob.Move(playerTransform.position);
        }
    }

    public void Exit()
    {
        
    }
}

 

그리고 다양한 Mob이 있을 수 있기때문에 Mob이 가지고 있어야  할 함수와 변수를 남은 abstract 객체를 선언해주자. 그리고 기본적인 정보는 FlyWeight 패턴을 사용하여 Scriptable Object를 참조하도록  했다.

BaseMobController.cs

using System.Collections.Generic;
using UnityEngine;

public abstract class BaseMobController : MonoBehaviour
{
    protected IMobState currentState;
    public MobData mobData;

    public IMobState GetCurrentState()
    {
        return currentState;
    }

    public abstract void ChangeState(IMobState state);
    public abstract float GetDetectionRange();
    public abstract float GetAttackRange();
    public abstract float GetChasableRange();
    public abstract Vector2 GetPatrolPointA();
    public abstract Vector2 GetPatrolPointB();
    public abstract void SetDestination(Vector2 destination);
    public abstract void SetPatrolPoints(Vector2 pointA, Vector2 pointB);
    public abstract bool IsPlayerDetected();
    public abstract void Move(Vector2 target);
}

 

MobData.cs

using UnityEngine;

[CreateAssetMenu(fileName = "MobBaseStat", menuName = "Game Data/Stats")]
public class MobData : ScriptableObject
{
    public float currentHealth;
    public float deffensePower;
}

 

그리고 일단 하나의 Mob을 움직여보기 위해 위의 abstract 클래스를 상속받는 MobController를 만들어주자.

using UnityEditor;
using UnityEngine;

public class MobController : BaseMobController
{
    [SerializeField] private float speed = 10f;
    [SerializeField] private float detectionRange = 5f;
    [SerializeField] private float chasableRange = 5f;
    [SerializeField] private float attackRange = 5f;

    [SerializeField] private Vector2 patrolPointA;
    [SerializeField] private Vector2 patrolPointB;

    private Rigidbody2D Rigidbody2D;
    private Animator animator;

    public override float GetDetectionRange() => detectionRange;
    public override float GetAttackRange() => attackRange;
    public override float GetChasableRange() => chasableRange;

    public override Vector2 GetPatrolPointA() => patrolPointA;
    public override Vector2 GetPatrolPointB() => patrolPointB;

    private void Awake()
    {
        Rigidbody2D = GetComponent<Rigidbody2D>();
        animator = GetComponent<Animator>();

        // 초기 상태를 순찰 상태로 설정
        ChangeState(new PatrolState(patrolPointA, patrolPointB));
    }

    private void FixedUpdate()
    {
        currentState?.Execute();
    }

    public override void ChangeState(IMobState newState)
    {
        currentState?.Exit();
        currentState = newState;
        currentState.Enter(this);
    }


    public override void SetDestination(Vector2 destination)
    {
        Move(destination);
    }

    public override void SetPatrolPoints(Vector2 pointA, Vector2 pointB)
    {
        patrolPointA = pointA;
        patrolPointB = pointB;
#if UNITY_EDITOR
        EditorUtility.SetDirty(this); // 에디터에서 변경사항을 감지하도록 설정
#endif
    }

    public override bool IsPlayerDetected()
    {
        return Vector2.Distance(transform.position, Managers.Game.GetPlayer().transform.position) <= detectionRange;
    }

    public override void Move(Vector2 target)
    {
        //이동
        Vector2 currentPosition = transform.position;
        //방향벡터
        Vector2 direction = (target - currentPosition).normalized;
        //방향*속도 => 해당방향으로의 속도
        Rigidbody2D.velocity = direction * speed;

        animator.SetFloat("MoveX", direction.x);
        animator.SetFloat("MoveY", direction.y);
        animator.SetFloat("Speed",Rigidbody2D.velocity.magnitude);
    }
}

 

이때 움직이는 방향에 따라 다른 애니메이션이 재생되도록 몹의 애니메이션에 Blend Tree를 적용시켜 주었다.

그리고 이 PatrolPoint를 에디터에서 보고 직접 조정할 수 있으며 초기화할 수도 있게하기위해 Editor 수정 코드를 추가해주었다.

MobController.cs

using UnityEditor;
using UnityEngine;

public class MobController : BaseMobController
{
    [SerializeField] private float speed = 10f;
    [SerializeField] private float detectionRange = 5f;
    [SerializeField] private float chasableRange = 5f;
    [SerializeField] private float attackRange = 5f;

    [SerializeField] private Vector2 patrolPointA;
    [SerializeField] private Vector2 patrolPointB;

    private Rigidbody2D Rigidbody2D;
    private Animator animator;

    public override float GetDetectionRange() => detectionRange;
    public override float GetAttackRange() => attackRange;
    public override float GetChasableRange() => chasableRange;

    public override Vector2 GetPatrolPointA() => patrolPointA;
    public override Vector2 GetPatrolPointB() => patrolPointB;

    private void Awake()
    {
        Rigidbody2D = GetComponent<Rigidbody2D>();
        animator = GetComponent<Animator>();

        // 초기 상태를 순찰 상태로 설정
        ChangeState(new PatrolState(patrolPointA, patrolPointB));
    }

    private void FixedUpdate()
    {
        currentState?.Execute();
    }

    public override void ChangeState(IMobState newState)
    {
        currentState?.Exit();
        currentState = newState;
        currentState.Enter(this);
    }


    public override void SetDestination(Vector2 destination)
    {
        Move(destination);
    }

    public override void SetPatrolPoints(Vector2 pointA, Vector2 pointB)
    {
        patrolPointA = pointA;
        patrolPointB = pointB;
#if UNITY_EDITOR
        EditorUtility.SetDirty(this); // 에디터에서 변경사항을 감지하도록 설정
#endif
    }

    public override bool IsPlayerDetected()
    {
        return Vector2.Distance(transform.position, Managers.Game.GetPlayer().transform.position) <= detectionRange;
    }

    public override void Move(Vector2 target)
    {
        //이동
        Vector2 currentPosition = transform.position;
        //방향벡터
        Vector2 direction = (target - currentPosition).normalized;
        //방향*속도 => 해당방향으로의 속도
        Rigidbody2D.velocity = direction * speed;

        animator.SetFloat("MoveX", direction.x);
        animator.SetFloat("MoveY", direction.y);
        animator.SetFloat("Speed",Rigidbody2D.velocity.magnitude);
    }
}

 

 

에디터를 수정해주려면 [CustomEditor(typeof(BaseMobController), true)] 이러한 코드를 클래스전에 선언해주어야한다. 또한 클래스에서 Editor 클래스를  상속받아 코드를 구현해야한다. setDirty를 통해 진행상황이 바로 저장되도록 구현하였다.

MobControllerEditor.cs

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(BaseMobController), true)] // 이제 모든 MobController 파생 클래스에 대해 이 에디터를 사용할 수 있습니다.
public class MobControllerEditor : Editor
{
    private float _handleSize = 5f; // Scene view에서의 핸들 크기를 더 작게 조정

    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI(); // 기존 인스펙터 GUI 요소를 그린다.

        BaseMobController mob = (BaseMobController)target;
        if (GUILayout.Button("Initialize Patrol Points"))
        {
            Vector2 center = mob.transform.position;
            Vector2 pointA = center + new Vector2(2f, 0f);
            Vector2 pointB = center + new Vector2(-2f, 0f);

            mob.SetPatrolPoints(pointA, pointB);

            //변경사항 적용
            EditorUtility.SetDirty(mob);
        }
    }

    protected void OnSceneGUI()
    {
        BaseMobController mob = (BaseMobController)target;
        EditorGUI.BeginChangeCheck();

        Vector3 pointAWorld = mob.GetPatrolPointA();
        Vector3 pointBWorld = mob.GetPatrolPointB();

        // 🔹 Scene 뷰 줌 레벨에 따라 핸들 크기 자동 조정
        float handleSize = HandleUtility.GetHandleSize(mob.transform.position) * 0.2f;

        // 현재 상태에 따라 범위를 다르게 그리기
        IMobState currentState = mob.GetCurrentState();
        if (currentState is PatrolState)
        {
            DrawDetectionRange(mob);
            DrawAttackRange(mob);
        }
        else if (currentState is ChaseState)
        {
            DrawChasableRange(mob);
            DrawAttackRange(mob);
        }
        else if (currentState is AttackState)
        {
            DrawAttackRange(mob);
        }

        Handles.color = Color.red;
        pointAWorld = Handles.FreeMoveHandle(pointAWorld, handleSize, Vector3.zero, Handles.SphereHandleCap);
        Handles.Label(pointAWorld, "Patrol Point A");

        Handles.color = Color.blue;
        pointBWorld = Handles.FreeMoveHandle(pointBWorld, handleSize, Vector3.zero, Handles.SphereHandleCap);
        Handles.Label(pointBWorld, "Patrol Point B");

        if (EditorGUI.EndChangeCheck())
        {
            Undo.RecordObject(mob, "Change Patrol Points");
            mob.SetPatrolPoints(pointAWorld, pointBWorld);
            EditorUtility.SetDirty(mob);
        }

        Handles.DrawLine(pointAWorld, pointBWorld);
    }


    private void DrawDetectionRange(BaseMobController mob)
    {
        Handles.color = Color.yellow;
        Handles.DrawWireArc(mob.transform.position, Vector3.forward, Vector3.up, 360, mob.GetDetectionRange());
    }

    private void DrawChasableRange(BaseMobController mob)
    {
        Handles.color = Color.yellow;
        Handles.DrawWireArc(mob.transform.position, Vector3.forward, Vector3.up, 360, mob.GetChasableRange());
    }

    private void DrawAttackRange(BaseMobController mob)
    {
        Handles.color = Color.red;
        Handles.DrawWireArc(mob.transform.position, Vector3.forward, Vector3.up, 360, mob.GetAttackRange());
    }
}

 

 

이렇게 해주면 Scene창에서 캐릭터의 Patorl Point를 눈으로 보고 직접 수정해줄 수 있으며 초기화해줄 수 있다.

 

이렇게 해주고 실행해주면 Patrol이 잘 실행되는것을 볼 수 있다.



지금 아이템코드는 아이템의 타입에 따라 Switch문을 통해 다른 사용효과를 부여하고 있는데 이렇게 하면 나중에 코드가 복잡해질 수 도 있고 확장성에 좋지 않다고 생각해서 인터페이스를 만들고 이를 상속받는 다양한 아이템 클래스를 만들어주겠다.

 

IItemBase.cs

using Controllers.Entity;
using UnityEngine;
using static Define;

public interface IItemBase
{
    ItemType ItemType { get; } // 아이템 유형
    ItemData ItemData { get; set; }
    int Quantity { get; }
    void Use();               // 아이템 사용
    void Initialize();
}

 

이를 상속받는 장비 클래스와 소모품 클래스를 만들어주자.

이때 각각의 클래스는 주울 수 있어야하고 실제 프리펩에 부착되어야 하기 때문에 Monobehaviour와 IPickable 또한 상속받도록 해주었다.

 

Equiment.cs

using Controllers.Entity;
using UnityEngine;
using static Define;

public class Equipment : MonoBehaviour, IItemBase, IPickable
{

    [SerializeField] private ItemData itemData; // Inspector에서 할당할 수 있는 필드
    [SerializeField] private int quantity;

    public  ItemType ItemType => ItemType.Equipment;

    public  ItemData ItemData
    {
        get => itemData;
        set => itemData = value;    
    }

    public int Quantity
    {
        get => quantity;
        set => quantity = 1;
    }


    public void Initialize()
    {
        if (ItemData == null)
        {
            Debug.LogError("ItemData가 설정되지 않았습니다.");
            return;
        }

        // 공통 초기화 로직 (예: 아이콘 설정)
        var spriteRenderer = GetComponent<SpriteRenderer>();
        if (spriteRenderer != null && ItemData.icon != null)
        {
            spriteRenderer.sprite = ItemData.icon;
        }
    }

    public void Pickup()
    {
        bool addedSuccessfully = Managers.Inventory.TryAddItem(this.ItemData.itemID,quantity);

        if (addedSuccessfully)
        {
            Destroy(gameObject);
        }
        else
        {
            Debug.LogWarning("아이템을 인벤토리에 추가할 수 없습니다.");
        }
    }

    public void Use()
    {
        
        if( Managers.Inventory.EquippedWeapon != null )
        {
            Managers.Inventory.UnEquipCurrentWeapon();       
        }
        else
        {
            Managers.Inventory.EquipWeapon(this.ItemData.itemID);
        }
        
    }
}

 

Consumable.cs

using Controllers.Entity;
using UnityEngine;
using static Define;

public class Consumable : MonoBehaviour, IItemBase, IPickable
{

    [SerializeField] private ItemData itemData; // Inspector에서 할당할 수 있는 필드
    [SerializeField] private int quantity;

    public ItemType ItemType => ItemType.Consumable;

    public ItemData ItemData
    {
        get => itemData;
        set => itemData = value;
    }

    public int Quantity
    {
        get=> quantity;
        set => quantity = value;
    }


    public void Initialize()
    {
        if (ItemData == null)
        {
            Debug.LogError("ItemData가 설정되지 않았습니다.");
            return;
        }

        // 공통 초기화 로직 (예: 아이콘 설정)
        var spriteRenderer = GetComponent<SpriteRenderer>();
        if (spriteRenderer != null && ItemData.icon != null)
        {
            spriteRenderer.sprite = ItemData.icon;
        }
    }

    public void Pickup()
    {
        bool addedSuccessfully = Managers.Inventory.TryAddItem(this.ItemData.itemID,quantity);

        if (addedSuccessfully)
        {
            Destroy(gameObject);
        }
        else
        {
            Debug.LogWarning("아이템을 인벤토리에 추가할 수 없습니다.");
        }
    }

    public void Use()
    {
        Debug.Log($"장비 아이템 '{ItemData.itemName}'를 장착합니다.");
        Managers.Inventory.UseItem(this.ItemData.itemID);
    }
}

 

이를 실제 프리펩에 부착하고 테스트해보자.

 

이렇게 나타나는 것을 볼 수 있다. 이에 맞춰 인벤토리 코드도 수정을 해주자. 주요한 수정사항은 아이템 추가시에 맵에 배치된 아이템과 관련된 정보를 가져와서 추가해준다는 점이다. 이때 나는 계속 아이템을 찾는 부하를 줄이기 위해 시작시에 모든 아이템의정보를 캐싱해서 사용했다.

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

public class InventoryManager : MonoBehaviour
{
    private Dictionary<int, ItemSlot> ownedItems = new Dictionary<int, ItemSlot>(); // 소유한 아이템과 수량
    private Dictionary<int, ItemData> itemDataCache = new Dictionary<int, ItemData>(); // 캐싱된 아이템 데이터
    private Dictionary<int, IItemBase> ItemCache = new Dictionary<int, IItemBase>(); // 캐싱된 아이템 데이터

    public Equipment EquippedWeapon { get; private set; }

    public void Init()
    {
        CacheItemData();
        EquippedWeapon = null;
    }

    // 1. 아이템 데이터를 캐싱하여 메모리에 저장
    private void CacheItemData()
    {
        ItemData[] items = Resources.LoadAll<ItemData>("Items");
        GameObject[] itemDatas = Resources.LoadAll<GameObject>("Prefabs/Items");
        
        foreach (var item in items)
        {
            if (!itemDataCache.ContainsKey(item.itemID))
            {
                //Debug.Log(item.itemName + "아이템 데이터 캐싱 완료");
                itemDataCache[item.itemID] = item;
            }
        }

        foreach (var item in itemDatas)
        {
            IItemBase itemBase = item.GetComponent<IItemBase>();
            if (!ItemCache.ContainsKey(itemBase.ItemData.itemID))
            {
                Debug.Log(itemBase.ItemData.itemName + "아이템 데이터 캐싱 완료");
                ItemCache[itemBase.ItemData.itemID] = itemBase;
            }
        }
    }

    // 2. 아이템 데이터 검색
    public ItemData FindItemDataByID(int itemID)
    {
        if (itemDataCache.TryGetValue(itemID, out var itemData))
        {
            return itemData;
        }

        Debug.LogWarning($"아이템 데이터를 찾을 수 없습니다: ID = {itemID}");
        return null;
    }

    public IItemBase FindItemBaseByID(int itemID)
    {
        if (ItemCache.TryGetValue(itemID, out var itemBase))
        {
            return itemBase;
        }

        Debug.LogWarning($"아이템 데이터를 찾을 수 없습니다: ID = {itemID}");
        return null;
    }

    // 3. 아이템 추가
    public bool TryAddItem(int itemID, int quantity = 1)
    {
        // 아이템 데이터를 가져옴
        if (!itemDataCache.TryGetValue(itemID, out var itemData))
        {
            Debug.LogWarning($"추가하려는 아이템 데이터(ID: {itemID})가 유효하지 않습니다.");
            return false;
        }

        // 기존 아이템이 있는 경우 수량 증가
        if (ownedItems.TryGetValue(itemID, out var existingSlot))
        {
            existingSlot.Quantity += quantity;
            Debug.Log($"아이템 '{itemData.itemName}'의 수량이 {existingSlot.Quantity}로 증가했습니다.");
        }
        else
        {
            // 새로운 아이템 슬롯 추가
            ownedItems[itemID] = new ItemSlot(FindItemBaseByID(itemID), quantity);
            Debug.Log($"새로운 아이템 '{itemData.itemName}'이(가) {quantity}개 추가되었습니다.");
        }

        RefreshUI();
        return true;
    }

    // 4. 아이템 제거
    public void RemoveItem(int itemID, int quantity = 1)
    {
        if (!ownedItems.TryGetValue(itemID, out var itemSlot))
        {
            Debug.LogWarning($"제거하려는 아이템(ID: {itemID})이(가) 없습니다.");
            return;
        }

        itemSlot.Quantity -= quantity;
        if (itemSlot.Quantity <= 0)
        {
            ownedItems.Remove(itemID);
            Debug.Log($"아이템 '{FindItemDataByID(itemID)?.itemName}'이(가) 인벤토리에서 제거되었습니다.");
        }
        else
        {
            Debug.Log($"아이템 '{FindItemDataByID(itemID)?.itemName}'의 수량이 {itemSlot.Quantity}로 줄었습니다.");
        }

        RefreshUI();
    }

    // 5. 아이템 사용
    public void UseItem(int itemID)
    {
        if (!ownedItems.TryGetValue(itemID, out var itemSlot))
        {
            Debug.LogWarning($"사용하려는 아이템(ID: {itemID})이(가) 없습니다.");
            return;
        }

        var itemData = FindItemDataByID(itemID);
        if (itemData == null)
        {
            Debug.LogWarning($"아이템 데이터를 찾을 수 없습니다: ID = {itemID}");
            return;
        }

        Debug.Log($"아이템 '{itemData.itemName}' 사용됨.");
        itemSlot.Item?.Use();

        // 소비 아이템은 사용 후 제거
        if (itemData.itemType != Define.ItemType.Equipment)
        {
            RemoveItem(itemID, 1);
        }

        RefreshUI();
    }

    // 6. 무기 장착
    public void EquipWeapon(int itemID)
    {
        if (!ownedItems.TryGetValue(itemID, out var itemSlot))
        {
            Debug.LogWarning($"장착하려는 무기(ID: {itemID})이(가) 없습니다.");
            return;
        }

        if (EquippedWeapon != null)
        {
            UnEquipCurrentWeapon();
        }

        EquippedWeapon = (Equipment)itemSlot.Item;
        RemoveItem(itemID, 1);
        Debug.Log($"무기 '{EquippedWeapon.ItemData.itemName}' 장착 완료.");

        RefreshUI();
    }

    // 7. 장착 해제
    public void UnEquipCurrentWeapon()
    {
        if (EquippedWeapon == null)
        {
            Debug.LogWarning("장착된 무기가 없습니다.");
            return;
        }

        TryAddItem(EquippedWeapon.ItemData.itemID, 1);
        Debug.Log($"무기 '{EquippedWeapon.ItemData.itemName}' 해제 완료.");
        EquippedWeapon = null;

        RefreshUI();
    }

    // 8. 인벤토리 UI 갱신
    public void RefreshUI()
    {

        UI_ItemSel uI_ItemSel = FindAnyObjectByType<UI_ItemSel>();

        if (uI_ItemSel != null)
        {
            Managers.UI.ClosePopupUI(uI_ItemSel);
        }

        UI_Inventory inventoryUI = Managers.UI.GetTopPopupUI() as UI_Inventory;

        if (inventoryUI != null)
        {
            inventoryUI.RefreshUI();
        }
       
    }

    // 9. 저장
    public void SaveInventory(ref SaveData saveData)
    {
        saveData.inventoryData = new List<SerializedItemSlot>();
        foreach (var slot in ownedItems)
        {
            saveData.inventoryData.Add(new SerializedItemSlot
            {
                itemID = slot.Key,
                quantity = slot.Value.Quantity
            });
        }

        saveData.equippedWeaponID = EquippedWeapon?.ItemData.itemID ?? -1;
    }

    // 10. 로드
    public void LoadInventory(SaveData saveData)
    {
        if (saveData.inventoryData == null) return;

        ownedItems.Clear();

        foreach (var serializedSlot in saveData.inventoryData)
        {
            var itemBase = FindItemBaseByID(serializedSlot.itemID);
            if (itemBase != null)
            {
                ownedItems[serializedSlot.itemID] = new ItemSlot(itemBase, serializedSlot.quantity);
            }
        }

        if (saveData.equippedWeaponID != -1)
        {
            var weaponData = FindItemBaseByID(saveData.equippedWeaponID);
            Debug.Log("현재 장착된 무기 : " + weaponData.ItemData.itemName + " 로드완료");
            if (weaponData != null)
            {
                EquippedWeapon = (Equipment)weaponData;
            }
        }

        RefreshUI();
    }

    public List<ItemSlot> GetOwnedItems()
    {
        
        return new List<ItemSlot>(ownedItems.Values);
    }
}

 

 

이제 이에 맞춰서 인벤토리의 장비칸, 아이템칸의 코드도 수정해주고

아이템을 선택했을 때 장착, 사용하기가 나오는 선택UI도 하나로 통합해주자.

SlotButton.cs

using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class SlotButton : UI_Base, IPointerClickHandler
{
    [SerializeField] private Image itemIcon;
    [SerializeField] private TextMeshProUGUI itemName;
    [SerializeField] private TextMeshProUGUI itemInfo;
    [SerializeField] private TextMeshProUGUI itemCount;
    [SerializeField] public ItemSlot currentItem;

    public override void Init() { }

    public void UpdateSlotUI(ItemSlot item)
    {
        currentItem = item;
        if(item!= null)
        {
            Debug.Log(item.Item.ItemData.itemName + " " + item.Quantity);
        }

        if (currentItem == null)
        {
            ClearSlotUI();
            return;
        }

        itemIcon.sprite = currentItem.Item.ItemData.icon;
        itemIcon.gameObject.SetActive(true);
        itemName.text = currentItem.Item.ItemData.itemName;
        itemCount.text = $"{currentItem.Quantity} 개";
    }

    private void ClearSlotUI()
    {
        currentItem = null;
        itemIcon.gameObject.SetActive(false);
        itemName.text = "";
        itemCount.text = "";
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.Log("클릭 이벤트 호출!");

        UI_ItemSel uI_ItemSel = FindAnyObjectByType<UI_ItemSel>();

        if (uI_ItemSel == null)
        {
            uI_ItemSel = Managers.UI.ShowPopupUI<UI_ItemSel>();
            uI_ItemSel.transform.SetParent(transform, false);
            Vector3 location = eventData.position;
            location.y -= 50;
            uI_ItemSel.transform.SetPositionAndRotation(location, Quaternion.identity);
        }
        else
        {
            Managers.UI.ClosePopupUI(uI_ItemSel);
        }
    }
}

 

EquipSlot.cs

using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class EquipSlot : UI_Base, IPointerClickHandler
{
    [SerializeField] private Image itemIcon;          // 아이템 아이콘 표시
    [SerializeField] private TextMeshProUGUI itemName; // 아이템 이름 
    [SerializeField] private TextMeshProUGUI itemInfo;  // 아이템 설명
    [SerializeField] private TextMeshProUGUI AttackInfo; // 공격력 설명

    public Equipment currentItem; // 현재 슬롯에 연결된 아이템 데이터

    public override void Init()
    {
        Equipment equippedItem = Managers.Inventory.EquippedWeapon;

        if (equippedItem != null)
        {
            UpdateSlotUI(equippedItem);
        }
        else
        {
            ClearSlotUI();
        }
    }

    // 슬롯 UI 업데이트
    public void UpdateSlotUI(Equipment item)
    {
        currentItem = item;

        if (currentItem != null)
        {
            itemIcon.sprite = currentItem.ItemData.icon;
            itemIcon.gameObject.SetActive(true);
            itemName.text = currentItem.ItemData.itemName;
            itemInfo.text = currentItem.ItemData.description;

            if (currentItem is Equipment equipment)
            {
                AttackInfo.text = $"공격력: {equipment.ItemData.AttackDamage}";
            }
            else
            {
                AttackInfo.text = "";
            }
        }
        else
        {
            ClearSlotUI();
        }
    }

    // 슬롯 UI 초기화
    private void ClearSlotUI()
    {
        currentItem = null;
        itemIcon.gameObject.SetActive(false);
        itemName.text = "장착 없음";
        itemInfo.text = "";
        AttackInfo.text = "";
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.Log("클릭 이벤트 호출!");

        if (currentItem == null) return;

        UI_ItemSel equipPopup = FindAnyObjectByType<UI_ItemSel>();
        if (equipPopup == null)
        {
            equipPopup = Managers.UI.ShowPopupUI<UI_ItemSel>();
            equipPopup.transform.SetParent(transform, false);
            Vector3 location = eventData.position;
            location.y -= 50;
            equipPopup.transform.SetPositionAndRotation(location, Quaternion.identity);
        }
        else
        {
            Managers.UI.ClosePopupUI(equipPopup);
        }
    }

   
}

 

UI_ItemSel.cs

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

public class UI_ItemSel : UI_Popup
{
    public enum Buttons
    {
        UseButton,
        RemoveButton,
    }

    public override void Init()
    {
        Bind<Button>(typeof(Buttons));

        //현재 장비를 클릭했을 때
        if (transform.parent.GetComponent<EquipSlot>() != null)         
        {
            GetButton((int)Buttons.UseButton).GetComponentInChildren<TextMeshProUGUI>().text = "장착해제";
        }
        else    //인벤토리 창을 클릭했을 때
        {
            
            if (GetComponentInParent<SlotButton>().currentItem.ItemType == Define.ItemType.Equipment)
            {
                GetButton((int)Buttons.UseButton).GetComponentInChildren<TextMeshProUGUI>().text = "장착하기";
            }
            else if (GetComponentInParent<SlotButton>().currentItem.ItemType == Define.ItemType.Consumable)
            {
                GetButton((int)Buttons.UseButton).GetComponentInChildren<TextMeshProUGUI>().text = "사용하기";
            }
            else
            {
                GetButton((int)Buttons.UseButton).GetComponentInChildren<TextMeshProUGUI>().text = "";
            }
        }



       
        GetButton((int)Buttons.UseButton).gameObject.AddUIEvent(UseItem);
    }

    void UseItem(PointerEventData eventData)
    {
        //현재 장비를 클릭했을 때
        if (transform.parent.GetComponent<EquipSlot>() != null)
        {
            GetComponentInParent<EquipSlot>().currentItem.Use();
        }
        else
        {
            GetComponentInParent<SlotButton>().currentItem.Use();
        }
        
    }
}

 

이렇게 해주고 테스트해보면 정상적으로 작동하는 것을 볼 수 있다.

 

 

실행화면

아이템
아이템을 주웠을 때

 

인벤토리

 



오늘은 State패턴에 대해 알아보고 실제 코드를 통해 작동하는 것을 보자. 

State패턴은 객체 내부 상태에 따라 스스로 행동을 변경하는 것으로 State를 인터페이스로 만들고 이를 상속받은 여러 상태를 클래스로 만든 다음 상태 전환을 클래스 간의 참조 변경으로 처리한다.

용도

애니메이션이나 플레이어 움직임, 몬스터AI에 사용하곤 한다,

 

코드

우선 State들이 상속받을 인터페이스를 알아보자.

// State 패턴
public interface IPlayerState
{
    void Enter(Player player);      //진입
    void Execute();                 //변경조건
    void Exit();                    //종료
}

 

 

이런식으로 인터페이스는 진입했을 때의 행동, 변경 조건, 종료시의 행동을 정의할 수 있게 구성되어있다.

간단한 쿠키런 같은 점프 게임을 통해 실제로 어떤식으로 작동하는지 알아보자.

점프게임에 필요한 플레이어의 행동은 평상시, 점프, 떨어지기, 피격등이 있을 것이다. 이를 IPlayerState를 상속받는 클래스로 만들어주자.

 

IdleState

private class IdleState : IPlayerState
{
    private Player _player;

    public void Enter(Player player)
    {
        _player = player;
        _player.animator.SetInteger("state", 0);
    }

    public void Execute()
    {
        // 플레이어가 땅에 붙어있지 않다면 떨어지고 있어야한다.
        if (!_player.isGrounded) _player.ChangeState(_player._fallState);
    }

    public void Exit() { }
}

 

JumpState

private class JumpState : IPlayerState
{
    private Player _player;

    public void Enter(Player player)
    {
        _player = player;
        _player.animator.SetInteger("state", 1);
        _player.rb.AddForce(Vector2.up * _player.jumpForce, ForceMode2D.Impulse);
        Managers.Audio.PlaySound("Jump");
        _player.isGrounded = false;
    }

    public void Execute()
    {
        if (_player.rb.linearVelocityY <= 0) _player.ChangeState(_player._fallState);
    }

    public void Exit() { }
}

 

Fall State

private class FallState : IPlayerState
{
    private Player _player;

    public void Enter(Player player)
    {
        _player = player;
        _player.animator.SetInteger("state", 2);
    }

    public void Execute()
    {
        // 땅에 도달하면 Idle로
        if (_player.isGrounded) _player.ChangeState(_player._idleState);
    }

    public void Exit() { }
}

 

Hit State

 private class HitState : IPlayerState
 {
     private Player _player;

     public void Enter(Player player)
     {
         _player = player;
         Managers.Audio.PlaySound("HealthDown");
         _player.CurrentLives--;

         if (_player.CurrentLives <= 0) _player.ChangeState(_player._deathState);
     }

     public void Execute() { }

     public void Exit() { }
 }

 

이렇게 해준 다음 플레이어의 Awake단에서 이 클래스를 선언하고 각 키의 호출 시에 State를 변경하도록 하고 주기적으로 변경 조건을 검사하도록 FixedUpdate에서 이 부분을 호출해주자.

Player.cs

   private void Awake()
   {
       // State 클래스 선언
       _idleState = new IdleState();
       _jumpState = new JumpState();
       _fallState = new FallState();
       _hitState = new HitState();
       _deathState = new DeathState();

       ChangeState(_idleState);
   }
   
    private void FixedUpdate()
 {
     _currentState?.Execute();
 }
 
 private void ChangeState(IPlayerState newState)
{
    _currentState?.Exit();
    _currentState = newState;
    _currentState.Enter(this);
}

 

Jump동작만 예시를 들어보자면 다음과 같이 작동한다.

private void OnEnable()
{
	playerInputActions.PlayerAction.Jump.performed += JumpAction;
    playerInputActions.PlayerAction.Enable();
}

private void OnDisable()
{
	playerInputActions.PlayerAction.Jump.performed -= JumpAction;
    playerInputActions.PlayerAction.Disable();
}

private void JumpAction(InputAction.CallbackContext context)
{
    if (isGrounded)
    {
        ChangeState(_jumpState);
    }
}

 

이렇게 해주면 잘 작동하는 것을 볼 수 있다.

 

이 게임은 다음 링크에서 해볼 수 있다.

https://play.unity.com/en/games/bb897f9a-ede5-4217-9d35-47793607782d/kimchi-run-by-munchi

 

 

1. call by value vs call by reference

 

 

C#으로 사용하는 Unity에서는 제네릭 함수가 많은데 이때 벨류타입의 제네릭을 많이 사용하게 되면 공유 코드를 사용하지 못할 때 JIT(Just In Time)방식, C#에서 IL(Intermediate Language)라고 중간 언어로 변환을 하는데 유저 코드 자체를 실시간으로 파싱하기 때문에 굉장히 느린데 JIT방식은 중간 형태의 .Net Assembly를 가지고 Target device에 맞게 컴파일 하기 때문에 인터프리터보다 빠르다. 이렇게 빌드되기 때문에 빌드 사이즈가 커질 수록 시간이 오래 걸린다. 하지만 Faster builds 방식은 이 사이즈와 시간을 감소 시킬 수 있지만 런타임 퍼포먼스가 떨어진다.

 

2. Sprite Atlas

sprite가 여러개 있는 경우 Draw Call이 각각의 Sprite마다 실행되는데 Sprite Atlas를 사용하는 경우에는 이 Draw Call이 하나로 묶여서 실행된다.

 

※Layer로 오브젝트들을 묶어주게 되면 이 또한 하나로 묶어서 DrawCall이 발생한다.

 

1. 장비 착용UI 만들기

 

플레이어가 착용한 장비가 보이게 UI를 수정해주자

착용한 장비에 따라 인벤토리칸이 달라지도록해야한다.

'

 

이렇게 만들고 UI_Inventory코드를 수정하고 EquipSlot코드도 새로 만들어주자.

UI_Inventory.cs

using System.Collections.Generic;
using UnityEngine;

public class UI_Inventory : UI_Popup
{
    [SerializeField] private Transform slotContainer; // 슬롯 UI 부모 객체
    [SerializeField] private GameObject slotPrefab;   // 슬롯 UI 프리팹

    enum GameObjects
    {
        EquipSlot,
    }

    private List<ItemSlot> itemSlots = new List<ItemSlot>();

    public override void Init()
    {
        Bind<GameObject>(typeof(GameObjects));
        base.Init();
        RefreshUI(); // UI 활성화 시 즉시 인벤토리 데이터 업데이트
    }

    public void RefreshUI()
    {
        List<ItemSlot> inventory = Managers.Inventory.GetOwnedItems();
        int requiredSlotCount = inventory.Count;

        GetObject((int)GameObjects.EquipSlot).GetComponent<EquipSlot>().Init();

        // **슬롯 초기화**
        for (int i = 0; i < slotContainer.childCount; i++)
        {
            GameObject slotObj = slotContainer.GetChild(i).gameObject;
            if (i < requiredSlotCount)
            {
                slotObj.SetActive(true);
                SlotButton slotButton = slotObj.GetComponent<SlotButton>();
                slotButton.UpdateSlotUI(inventory[i]);
            }
            else
            {
                slotObj.SetActive(false); // 남는 슬롯 비활성화
            }
        }

        // **부족한 슬롯 생성**
        for (int i = slotContainer.childCount; i < requiredSlotCount; i++)
        {
            GameObject newSlot = Instantiate(slotPrefab, slotContainer);
            newSlot.SetActive(true);
            SlotButton slotButton = newSlot.GetComponent<SlotButton>();
            slotButton.UpdateSlotUI(inventory[i]);
        }
    }



    private void CreateSlotUI(ItemSlot itemSlot)
    {
        GameObject slotObj = Instantiate(slotPrefab, slotContainer);
        slotObj.SetActive(true);
        SlotButton slotButton = slotObj.GetComponent<SlotButton>();
        slotButton.UpdateSlotUI(itemSlot);

        // 슬롯 데이터 디버그 확인
        Debug.Log($"Created Slot for Item: {itemSlot.itemData.itemName}, Quantity: {itemSlot.quantity}");
    }
}

 

EquipSlot.cs

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

public class EquipSlot : UI_Base, IPointerClickHandler, IPointerUpHandler, IPointerEnterHandler, IPointerExitHandler
{
    [SerializeField] private Image itemIcon;          // 아이템 아이콘 표시
    [SerializeField] private TextMeshProUGUI itemName; // 아이템 이름 
    [SerializeField] private TextMeshProUGUI itemInfo;  // 아이템 설명
    [SerializeField] private TextMeshProUGUI AttackInfo; // 공격력 설명

    public ItemSlot currentItemSlot;                 // 현재 슬롯에 연결된 아이템 데이터


    public override void Init()
    {
        ItemSlot CurrentEquippedItem = Managers.Inventory.EquippedWeapon;

        if(CurrentEquippedItem != null ) 
        { 
            UpdateSlotUI(CurrentEquippedItem);
        }else
        {
            itemIcon.gameObject.SetActive(false);
            itemName.text = "장착 없음";
            itemInfo.text = "";
            AttackInfo.text = "";
        }
    }

    // 슬롯 UI 업데이트
    public void UpdateSlotUI(ItemSlot itemSlot)
    {
        currentItemSlot = itemSlot;

        if (currentItemSlot != null && currentItemSlot.itemData != null)
        {
            itemIcon.sprite = currentItemSlot.itemData.icon;
            itemIcon.gameObject.SetActive(true);
            itemName.text = currentItemSlot.itemData.itemName;
            itemInfo.text = currentItemSlot.itemData.description;
            AttackInfo.text = $"공격력: {currentItemSlot.itemData.AttackDamage}";
        }
        else
        {
            itemIcon.gameObject.SetActive(false);
            itemName.text = "장착 없음";
            itemInfo.text = "";
            AttackInfo.text = "";
        }
    }


    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.Log("클릭 이벤트 호출!");

        UI_EquipSel uI_EquipSel = FindAnyObjectByType<UI_EquipSel>();
        if (uI_EquipSel == null)
        {
            uI_EquipSel = Managers.UI.ShowPopupUI<UI_EquipSel>();
            uI_EquipSel.gameObject.transform.SetParent(transform, false);
            Vector3 loaction = eventData.position;
            loaction.y -= 50;
            uI_EquipSel.gameObject.transform.SetPositionAndRotation(loaction, Quaternion.identity);
        }
        else
        {
            Managers.UI.ClosePopupUI(uI_EquipSel);
        }

    }
    }
}

그리고 단일 책임 원칙을 지키고 중앙화된 아이템 관리를 하기 위해  플레이어가 장착한 무기도 인벤토리 매니저에서 관리하도록 수정해주었다.

InventoryManager.cs

using System.Collections.Generic;
using UnityEngine;

public class InventoryManager : MonoBehaviour
{
    [SerializeField] private Transform slotContainer; // 슬롯 UI 부모 객체
    [SerializeField] private GameObject slotPrefab;   // 슬롯 UI 프리팹
    private List<ItemSlot> inventory = new List<ItemSlot>();
    private Dictionary<int, ItemData> itemDataCache = new Dictionary<int, ItemData>();

    public ItemSlot EquippedWeapon { get; private set; }

    public void Init()
    {
        inventory = new List<ItemSlot>();
        slotPrefab = Resources.Load<GameObject>("Prefabs/Item");
        CacheItemData();
        EquippedWeapon = null;
    }

    private void CacheItemData()
    {
        ItemData[] items = Resources.LoadAll<ItemData>("Items");
        foreach (var item in items)
        {
            if (!itemDataCache.ContainsKey(item.itemID))
            {
                itemDataCache.Add(item.itemID, item);
            }
        }
    }

    public ItemData FindItemDataByID(int itemID)
    {
        if (itemDataCache.TryGetValue(itemID, out ItemData itemData))
        {
            return itemData;
        }

        Debug.LogWarning($"ItemData를 찾을 수 없습니다: ID = {itemID}");
        return null;
    }

    public void EquipWeapon(ItemData itemData)
    {
        if (EquippedWeapon != null)
        {
            Debug.LogWarning("이미 무기가 장착되어 있습니다.");
            return;
        }

        ItemSlot slot = inventory.Find(s => s.itemData.itemID == itemData.itemID);
        if (slot != null)
        {
            slot.quantity--;
            if (slot.quantity <= 0)
            {
                inventory.Remove(slot);
            }

            EquippedWeapon = new ItemSlot { itemData = itemData, quantity = 1 };
            Debug.Log($"무기 '{itemData.itemName}' 장착 완료.");
        }
        else
        {
            Debug.LogWarning("장착하려는 무기가 인벤토리에 없습니다.");
        }

        RefreshUI();
    }

    public void UnEquipWeapon()
    {
        if (EquippedWeapon == null)
        {
            Debug.LogWarning("장착된 무기가 없습니다.");
            return;
        }

        TryAddItem(EquippedWeapon.itemData, 1);
        Debug.Log($"무기 '{EquippedWeapon.itemData.itemName}' 해제 완료.");
        EquippedWeapon = null;

        RefreshUI();
    }

    public bool TryAddItem(ItemData itemData, int count = 1)
    {
        ItemSlot slot = inventory.Find(s => s.itemData.itemID == itemData.itemID);

        if (itemData.itemType == Define.ItemType.Equipment)
        {
            if (slot != null && slot.quantity >= 1)
            {
                Debug.LogWarning("이미 인벤토리에 무기가 있습니다.");
                return false;
            }
        }

        if (inventory.Count >= 30)
        {
            Debug.LogWarning("인벤토리 용량이 초과되었습니다.");
            return false;
        }

        if (slot != null)
        {
            slot.quantity += count;
        }
        else
        {
            inventory.Add(new ItemSlot { itemData = itemData, quantity = count });
        }

        RefreshUI();
        return true;
    }

    public void RemoveItem(ItemData itemData, int count = 1)
    {
        ItemSlot slot = inventory.Find(s => s.itemData.itemID == itemData.itemID);
        if (slot != null)
        {
            slot.quantity -= count;
            if (slot.quantity <= 0)
            {
                inventory.Remove(slot);
                Debug.Log($"아이템 '{itemData.itemName}'이(가) 인벤토리에서 제거되었습니다.");
            }
            else
            {
                Debug.Log($"아이템 '{itemData.itemName}'의 수량이 {slot.quantity}로 줄었습니다.");
            }
        }
        else
        {
            Debug.LogWarning($"제거하려는 아이템 '{itemData.itemName}'이(가) 인벤토리에 없습니다.");
        }

        RefreshUI();
    }

    public void RefreshUI()
    {
        UI_WeaponSel uI_WeaponSel = FindAnyObjectByType<UI_WeaponSel>();
        UI_EquipSel uI_EquipSel = FindAnyObjectByType<UI_EquipSel>();
        if(uI_WeaponSel != null)
        {
            Managers.UI.ClosePopupUI(uI_WeaponSel);
        }else if(uI_EquipSel != null)
        {
            Managers.UI.ClosePopupUI(uI_EquipSel);
        }

        UI_Inventory inventoryUI = Managers.UI.GetTopPopupUI() as UI_Inventory;
        
        if (inventoryUI != null)
        {
            inventoryUI.RefreshUI();
        }
    }

    public List<ItemSlot> GetOwnedItems()
    {
        return inventory;
    }

    public void SaveInventory(ref SaveData saveData)
    {
        List<SerializedItemSlot> serializedInventory = new List<SerializedItemSlot>();
        foreach (var slot in inventory)
        {
            serializedInventory.Add(new SerializedItemSlot
            {
                itemID = slot.itemData.itemID,
                quantity = slot.quantity
            });
        }

        saveData.inventoryData = serializedInventory;

        saveData.equippedWeaponID = EquippedWeapon?.itemData.itemID ?? -1;
    }

    public void LoadInventory(SaveData saveData)
    {
        if (saveData.inventoryData == null) return;

        inventory.Clear();
        foreach (var serializedSlot in saveData.inventoryData)
        {
            ItemData itemData = FindItemDataByID(serializedSlot.itemID);
            if (itemData != null)
            {
                inventory.Add(new ItemSlot { itemData = itemData, quantity = serializedSlot.quantity });
            }
        }

        if (saveData.equippedWeaponID != -1)
        {
            ItemData weaponData = FindItemDataByID(saveData.equippedWeaponID);
            if (weaponData != null)
            {
                EquippedWeapon = new ItemSlot { itemData = weaponData, quantity = 1 };
                Debug.Log($"무기 '{weaponData.itemName}' 장착 복원.");
            }
        }

        RefreshUI();
    }
}

 

RefreshUI에서 만약 장착이나 장착해제 UI가 떠있는 상태라면 인벤토리 UI가 최상단이 아니기 때문에 꺼주고 최상단 UI를 불러와서 Refresh를 진행하도록 해주었다. 

이에 맞춰서 장착해제와 장착의 UI의 코드도 수정해줬다.

UI_WeaponSel.cs

using UnityEngine.EventSystems;
using UnityEngine.UI;

public class UI_WeaponSel : UI_Popup
{
    public enum Buttons
    {
        EquipButton,
        RemoveButton,
    }

    public override void Init()
    {
        Bind<Button>(typeof(Buttons));
        GetButton((int)Buttons.EquipButton).gameObject.AddUIEvent(EquipWeapon);
    }

    void EquipWeapon(PointerEventData eventData)
    {
        SlotButton slotButton = transform.parent.GetComponent<SlotButton>();
        if (slotButton != null)
        {
            Managers.Inventory.EquipWeapon(slotButton.currentItemSlot.itemData);
            Managers.Inventory.RefreshUI();
        }
    }
}

 

SlotButton.cs

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

public class UI_EquipSel : UI_Popup
{
    public enum Buttons
    {
        UnequipButton,
        RemoveButton,
    }

    public override void Init()
    {
        Bind<Button>(typeof(Buttons));

        GetButton((int)Buttons.UnequipButton).gameObject.AddUIEvent(UnequipWeapon);
    }

    void UnequipWeapon(PointerEventData eventData)
    {
        EquipSlot currentItem = transform.parent.gameObject.GetComponent<EquipSlot>();
        if (currentItem != null)
        {
            Managers.Inventory.UnEquipWeapon();
            Managers.Inventory.RefreshUI();
        }
    }
}

 

이렇게 해주면 인벤토리가 잘 작동하는 것을 볼 수 있다.

 

1. 아이템 슬롯UI 및 코드 제작

만들어둔 인벤토리를 통해 실제 인벤토리 UI가 호출되고 이 UI에 아이템이 표시되도록 구현해보자.

아이템리스트가  ScrollView를 통해 나오도록 만들어 보려고 한다.

이를 위해 아이템슬롯 UI를 만들어보자 UI에 필요한 항목은 아이콘,이름,개수이다. 

 

프리팹을 제작해주고 이 SlotButton에 아이콘,이름, 개수를 아이템 정보에 따라 달라질 수 있도록 클래스를 만들어주자.

SlotButton.cs

using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class SlotButton : MonoBehaviour
{
    [SerializeField] private Image itemIcon;          // 아이템 아이콘 표시
    [SerializeField] private TextMeshProUGUI itemName; // 아이템 이름 및 개수 표시
    [SerializeField] private TextMeshProUGUI itemCount; // 아이템 이름 및 개수 표시
    private ItemSlot currentItemSlot;                 // 현재 슬롯에 연결된 아이템 데이터

    // 슬롯 UI 업데이트
    public void UpdateSlotUI(ItemSlot itemSlot)
    {
        currentItemSlot = itemSlot;

        if (currentItemSlot != null && currentItemSlot.itemData != null)
        {
            if (currentItemSlot.itemData.icon != null)
            {
                itemIcon.sprite = currentItemSlot.itemData.icon;
                itemIcon.gameObject.SetActive(true);
            }
            else
            {
                Debug.LogWarning($"아이템 {currentItemSlot.itemData.itemName}에 아이콘이 없습니다!");
            }

            itemName.text = $"{currentItemSlot.itemData.itemName}";
            itemCount.text = $"{currentItemSlot.quantity} 개";
        }
        else
        {
            Debug.LogWarning("슬롯 UI를 업데이트할 데이터가 없습니다!");
            itemIcon.gameObject.SetActive(false);
            itemName.text = "";
            itemCount.text = "";
        }
    }

}

 

2.인벤토리UI제작 및 코그 구현

 

그리고 이에 맞춰 ScrollView가 포함된 UI를 제작해주자. 이때 아이템들이 담길 부모객체에 Vertical Layout Group과 Content Size Filter가 적용되어 있어야한다.

 

그리고 팝업UI로 이 UI를 호출할 수 있도록 UI_Popup을 상속받아서 UI_Inventory 클래스를 구현해주자. 이때 InventoryManager에서 가지고 있는 리스트를 반환해주는 함수를 추가해주자.

InventoryManager.cs

public List<ItemSlot> GetOwnedItems()
{
    return inventory;
}

 

UI_Inventory.cs

using System.Collections.Generic;
using UnityEngine;

public class UI_Inventory : UI_Popup
{
    [SerializeField] private Transform slotContainer; // 슬롯 UI 부모 객체
    [SerializeField] private GameObject slotPrefab;   // 슬롯 UI 프리팹

    private List<ItemSlot> itemSlots = new List<ItemSlot>();

    public override void Init()
    {
        base.Init();
        RefreshUI(); // UI 활성화 시 즉시 인벤토리 데이터 업데이트
    }

    public void RefreshUI()
    {
        List<ItemSlot> inventory = Managers.Inventory.GetOwnedItems(); // 현재 인벤토리 아이템 목록

        // 필요한 슬롯 개수와 현재 슬롯 개수 비교
        int requiredSlotCount = inventory.Count;
        int currentSlotCount = slotContainer.childCount;

        // 슬롯 부족 시 추가 생성
        for (int i = currentSlotCount; i < requiredSlotCount; i++)
        {
            GameObject newSlot = Instantiate(slotPrefab, slotContainer);
            newSlot.SetActive(false); // 비활성화 상태로 생성
        }

        // 슬롯 업데이트
        for (int i = 0; i < requiredSlotCount; i++)
        {
            Transform slotTransform = slotContainer.GetChild(i);
            slotTransform.gameObject.SetActive(true); // 활성화
            SlotButton slotButton = slotTransform.GetComponent<SlotButton>();
            slotButton.UpdateSlotUI(inventory[i]); // 슬롯 UI 업데이트
        }

        // 남는 슬롯 비활성화
        for (int i = requiredSlotCount; i < currentSlotCount; i++)
        {
            slotContainer.GetChild(i).gameObject.SetActive(false);
        }

        // 디버그 로그
        Debug.Log($"Inventory Count: {inventory.Count}");
        foreach (var slot in inventory)
        {
            Debug.Log($"Slot: {slot.itemData.itemName}, Quantity: {slot.quantity}");
        }
    }


    private void CreateSlotUI(ItemSlot itemSlot)
    {
        GameObject slotObj = Instantiate(slotPrefab, slotContainer);
        slotObj.SetActive(true);
        SlotButton slotButton = slotObj.GetComponent<SlotButton>();
        slotButton.UpdateSlotUI(itemSlot);

        // 슬롯 데이터 디버그 확인
        Debug.Log($"Created Slot for Item: {itemSlot.itemData.itemName}, Quantity: {itemSlot.quantity}");
    }
}

 

이렇게 해주고 Player에서 호출해주면 된다.

Player.cs

 private void OpenInventory(InputAction.CallbackContext context)
 {
     DebugEx.Log("인벤토리 키 입력");
     UI_Inventory inventoryPopup = FindAnyObjectByType<UI_Inventory>();
     if (inventoryPopup == null)
     {
         Managers.UI.ShowPopupUI<UI_Inventory>();
         
     } 
     else
     {
         Managers.UI.CloseAllPopupUI();
     }
 }

 

이렇게 해주면 정상적으로 인벤토리가 호출되는 것을 볼 수 있다. 

 

3.클릭이벤트 구현

인벤토리에서 아이템을 클릭했을 때 이벤트를 구현해보자

우선 아이템을 클릭했을 때 나올 UI를 제작해보자. 

일단 장착하기와 버리기 버튼만이 있는 상태로 버튼UI를 만들어주면 된다.

 

클릭이벤트를 구현하기 위해 전체 WeaponSel에 관한 코드를 작성해주자.

코드를 통해 2가지 버튼에 바인딩하고 이 버튼을 가져와서 이벤트를 추가해주자. 이때 게임오브젝트와 enum에서 변수의 명이 동일해야 바인딩이 가능하다.

UI_WeaponSel.cs

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

public class UI_WeaponSel : UI_Popup
{
    public enum Buttons
    {
        EquipButton,
        RemoveButton,
    }

    public override void Init()
    {
        Bind<Button>(typeof(Buttons));

        GetButton((int)Buttons.EquipButton).gameObject.AddUIEvent(EquipWeapon);
    }

    void EquipWeapon(PointerEventData eventData)
    {
        SlotButton currentItem = transform.parent.gameObject.GetComponent<SlotButton>();
        if (currentItem != null) 
        {
            currentItem.UseItem();
        }
    }
}

 

클릭이벤트를 위해 유니티에서 제공해주는 인터페이스를 사용해보자 

나는 IPointerClickHandler, IPointerUpHandler, IPointerEnterHandler, IPointerExitHandler 이렇게 4가지를 사용해보았다. 

SlotButton.cs

using UnityEngine;
using UnityEngine.UI;
using TMPro;
using UnityEngine.EventSystems;

public class SlotButton : UI_Base, IPointerClickHandler, IPointerUpHandler, IPointerEnterHandler, IPointerExitHandler
{
    [SerializeField] private Image itemIcon;          // 아이템 아이콘 표시
    [SerializeField] private TextMeshProUGUI itemName; // 아이템 이름 
    [SerializeField] private TextMeshProUGUI itemInfo;  // 아이템 설명
    [SerializeField] private TextMeshProUGUI itemCount; // 아이템 개수 
    private ItemSlot currentItemSlot;                 // 현재 슬롯에 연결된 아이템 데이터

    

    // 아이템 사용 로직
    public void UseItem()
    {
        if (currentItemSlot.itemData.itemType == Define.ItemType.Consumable)
        {
            Debug.Log($"사용: {currentItemSlot.itemData.itemName}");
            Managers.Inventory.RemoveItem(currentItemSlot.itemData, 1);
            //Managers.Inventory.RefreshUI();
        }
        else if(currentItemSlot.itemData.itemType == Define.ItemType.Equipment)
        {
            DebugEx.Log("장착하기!");
        }
        else
        {
            Debug.Log($"{currentItemSlot.itemData.itemName}는 사용할 수 없는 아이템입니다.");
        }
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        Debug.Log("클릭 이벤트 호출!");

        UI_WeaponSel uI_WeaponSel = FindAnyObjectByType<UI_WeaponSel>();
        if( uI_WeaponSel == null ) 
        {
            uI_WeaponSel = Managers.UI.ShowPopupUI<UI_WeaponSel>();
            uI_WeaponSel.gameObject.transform.SetParent(transform, false);
            Vector3 loaction = eventData.position;
            loaction.y -= 50;
            uI_WeaponSel.gameObject.transform.SetPositionAndRotation(loaction, Quaternion.identity);
        }
        else
        {
            Managers.UI.ClosePopupUI(uI_WeaponSel);
        }
        // 팝업 생성
        
        
    }


    public void OnPointerUp(PointerEventData eventData)
    {
        
    }

    public void OnPointerEnter(PointerEventData eventData)
    {
        DebugEx.Log("마우스 올려두기 호출!");
    }

    public void OnPointerExit(PointerEventData eventData)
    {
        DebugEx.Log("마우스 내려놓기 호출!");
        UI_WeaponSel uI_WeaponSel = FindAnyObjectByType<UI_WeaponSel>();
        if (uI_WeaponSel != null)
        {
            Managers.UI.ClosePopupUI(uI_WeaponSel);
        }
    }
}

 

이때 선택된 아이템의 자식으로 팝업을 두는 것으로 나중에 아이템의 정보를 쉽게 얻어오려고 했다. 그리고 마우스 클릭하곳에서 가깝게 팝업이 뜨도록 transform의 위치를 수정해주었다.

실제로 작동시켜보면 잘 작동하는 것을 볼 수 있다.

 

 

오늘은 만들어둔 시스템의 데이터를 저장하고 불러오는 기능을 구현해보자. 

우선 Save를 관리해줄 SaveManager를 만들어주자. 경로는 기본경로로 해주고 저장방식은 Json으로 저장해주도록 하자.

SaveManager.cs

public class SaveManager
{
    public void Init()
    {
        // 경로 설정
        _defaultPath = Path.Combine(Application.persistentDataPath, "saveData.json");

        // 디렉터리 확인 및 생성
        string directoryPath = Path.GetDirectoryName(_defaultPath);
        if (!Directory.Exists(directoryPath))
        {
            Directory.CreateDirectory(directoryPath);
        }

        Debug.Log($"Save File Path Initialized: {_defaultPath}");

    }
}

 

그리고 지금까지 만들어둔 시스템에서 저장할 부분을 직렬화방식으로 저장하기 위해 SaveData를 따로 만들어주자. 저장해야할 부분은 Inventory,Quest,Player 데이터를 저장하면 된다.

 

SaveData.cs

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

[System.Serializable]
public class SaveData
{
    public List<SerializedItemSlot> inventoryData; // 인벤토리 데이터
    public PlayerData playerData;                 // 플레이어 데이터
    public List<SerializedQuestData> questData;             // 퀘스트 데이터
}

[System.Serializable]
public class PlayerData
{
    public Vector3 position; // 플레이어 위치
    public Vector3 rotation; // 플레이어 방향
    public float health;     // 플레이어 체력
}

[System.Serializable]
public class SerializedItemSlot
{
    public int itemID;   // 아이템 ID
    public int quantity; // 아이템 개수
}

[System.Serializable]
public class SerializedQuestData
{
    public int questID;       // 퀘스트 ID
    public string questState; // 퀘스트 상태
}

 

이렇게 하고 저장하는 부분을 구현해볼텐데 저장은 직렬화로 구현된 부분을 각 객체의 함수로 추가하고 마지막에 SaveManager에서 Json으로 저장하도록하자

Player.cs

// 저장
public void SavePlayer(ref SaveData saveData)
{
    saveData.playerData = new PlayerData
    {
        position = transform.position,
        rotation = transform.eulerAngles,
        health = currentHealth
    };
}

QuestManager.cs

 public void SaveQuests(ref SaveData saveData)
 {
     List<SerializedQuestData> serializedQuests = new List<SerializedQuestData>();
     foreach (var quest in Quests.Values)
     {
         serializedQuests.Add(new SerializedQuestData
         {
             questID = quest.questId,
             questState = quest.questState.ToString()
         }); 
     }
     saveData.questData = serializedQuests;
 }

InventoryManager.cs

 // 저장
 public void SaveInventory(ref SaveData saveData)
 {
     List<SerializedItemSlot> serializedInventory = new List<SerializedItemSlot>();
     foreach (var slot in inventory)
     {
         serializedInventory.Add(new SerializedItemSlot
         {
             itemID = slot.itemData.itemID,
             quantity = slot.quantity
         });
     }
     saveData.inventoryData = serializedInventory;
 }

SaveManager.cs

  public void SaveData()
  {
      SaveData saveData = new SaveData();

      Managers.Inventory.SaveInventory(ref saveData);
      Managers.Game.GetPlayer().GetComponent<Player>().SavePlayer(ref saveData);
      Managers.Quest.SaveQuests(ref saveData);

      string json = JsonUtility.ToJson(saveData, true);
      File.WriteAllText(_defaultPath, json);
      Debug.Log($"게임 데이터가 저장되었습니다: {_defaultPath}");
  }

 

이렇게 만들고 게임 데이터를 가져오는 기능을 만들어주자. 이때 만약 저장데이터가 없다면 기본 데이터를 저장해주고 

다른 시스템을 초기화해주는 기능도 넣어주도록 하자.

SaveManager.cs

public void LoadData()
{
    if (!File.Exists(_defaultPath))
    {
        Debug.LogWarning("저장된 데이터가 없습니다. 초기 데이터를 생성합니다.");

        // 초기 데이터를 생성
        CreateInitialData();
        SaveData(); // 초기 데이터를 저장
        return;
    }
    string json = File.ReadAllText(_defaultPath);
    SaveData saveData = JsonUtility.FromJson<SaveData>(json);

    SpawnPlayer();

    Managers.Quest.Init();
    Managers.Inventory.LoadInventory(saveData);
    Managers.Game.GetPlayer().GetComponent<Player>().LoadPlayer(saveData);
    Managers.Quest.LoadQuestsFromSaveFile(saveData);

    Debug.Log("게임 데이터가 불러와졌습니다.");
}

private void CreateInitialData()
{
   SpawnPlayer();

    // 인벤토리 초기화
    Managers.Inventory.Init();
    //Managers.Inventory.AddItem(Resources.Load<ItemData>("Items/Potion"), 5); // 기본 아이템 추가
    //Managers.Inventory.AddItem(Resources.Load<ItemData>("Items/Sword"), 1); // 기본 무기 추가

    // 플레이어 초기화
    var player = Managers.Game.GetPlayer().GetComponent<Player>();
    player.transform.position = Vector3.zero; // 기본 위치
    player.CurrentHealth = 100f; // 기본 체력
    player.index = 0; // 초기 퀘스트 인덱스

    // 플레이어 정보 출력
    Debug.Log("=== 플레이어 초기화 ===");
    Debug.Log($"위치: {player.GetComponent<Player>().transform.position}");
    Debug.Log($"체력: {player.GetComponent<Player>().CurrentHealth}");
    Debug.Log($"퀘스트 진행 상태: {player.GetComponent<Player>().index}");

    // 퀘스트 초기화
    Managers.Quest.Init();
}

public void SpawnPlayer()
{
    GameObject player = Managers.Game.Spawn(Define.WorldObject.Player, "Player");
    CinemachineVirtualCamera virtualCamera = GameObject.FindObjectOfType<CinemachineVirtualCamera>();
    virtualCamera.Follow = player.transform; // virtual camera의 Follow에 플레이어 등록
}

 

Player.cs

// 불러오기
public void LoadPlayer(SaveData saveData)
{
    if (saveData.playerData == null) return;

    transform.position = saveData.playerData.position;
    transform.eulerAngles = saveData.playerData.rotation;
    currentHealth = saveData.playerData.health;
}

QuestManager.cs

public class QuestManager 
{
    [SerializeField] private Dictionary<int, QuestData> Quests;

    public void Init()
    {
        LoadQuestsFromResources();
        InitializeQuest();
    }

    // Resources 폴더에서 모든 ScriptableObject를 로드하고 Dictionary로 초기화
    private void LoadQuestsFromResources()
    {
        QuestData[] loadedQuests = Resources.LoadAll<QuestData>("Quest");
        Quests = new Dictionary<int, QuestData>();
        if (loadedQuests.Length == 0)
        {
            Debug.LogError("Resources/Quest 폴더에 퀘스트 데이터가 없습니다.");
            return;
        }

        foreach (QuestData questData in loadedQuests)
        {
            if (!Quests.ContainsKey(questData.questId))
            {
                Quests.Add(questData.questId, questData);
                Debug.Log($"퀘스트 Id '{questData.questId}' 인 '{questData.questName}'가 추가되었습니다.");
            }
            else
            {
                Debug.LogWarning($"중복된 questId({questData.questId})가 발견되었습니다. QuestName: {questData.questName}");
            }
        }

        Debug.Log($"{Quests.Count}개의 퀘스트가 로드되었습니다.");
    }

        // Dictionary를 사용하여 퀘스트를 초기화
        private void InitializeQuest()
    {
        if (Quests == null || Quests.Count == 0)
        {
            Debug.LogError("Quests가 null이거나 비어 있습니다. LoadQuestsFromResources를 확인하세요.");
            return;
        }

        foreach (var questData in Quests.Values)
        {
            if (questData.QuestIndex >= Managers.Game.GetPlayer().GetComponent<Player>().index &&
                questData.questState == QuestState.REQUIREMENTS_NOT_MET)
            {
                // 진행 가능한 상태로 변경
                questData.questState = QuestState.CAN_START;
                Debug.Log($"퀘스트 Id '{questData.questId}' 인 '{questData.questName}'가 CAN_START로 초기화되었습니다.");
            }
        }
    }
    
     public void LoadQuestsFromSaveFile(SaveData saveData)
     {
         if (saveData.questData == null) return;

         Quests.Clear();

         foreach (var serializedQuest in saveData.questData)
         {
             QuestData quest = FindQuestByID(serializedQuest.questID);
             if (quest != null)
             {
                 quest.questState = Enum.Parse<QuestState>(serializedQuest.questState);
                 Quests.Add(quest.questId, quest);
             }
         }
     }
     
}

InventoryManager.cs

// 불러오기
public void LoadInventory(SaveData saveData)
{
    if (saveData.inventoryData == null) return;

    inventory.Clear();
    foreach (var serializedSlot in saveData.inventoryData)
    {
        // 저장된 itemID를 통해 ItemData 불러오기
        ItemData itemData = FindItemDataByID(serializedSlot.itemID);
        if (itemData != null)
        {
            // 인벤토리에 새로운 ItemSlot 추가
            inventory.Add(new ItemSlot { itemData = itemData, quantity = serializedSlot.quantity });
        }
        else
        {
            Debug.LogWarning($"ItemData를 찾을 수 없습니다: ID {serializedSlot.itemID}");
        }
    }
}

 

이렇게 해주고 테스트해보면 정상적으로 작동하는것을 볼 수 있다.

 


오늘은 아이템과 관련된 시스템을 만들어보자.

우선 아이템에 필요한 것을 생각해보자. 우선 아이템 이름, 설명, 식별자, 사진, 종류등이 필요할 것 같다. 

이를 클래스로 구현해보자 

이때 나중에 저장기능을 구현할 때

직렬화에 적합하고 데이터 관리와 로직을 분리하며 확장성을 높이기 위해 ItemData 객체를 만들어 이 곳에 정보를 넣고 

Item객체에는 필요한 정보만 가지고 있도록 구현했다.

Item.cs

using Controllers.Entity;
using UnityEngine;

public class Item : MonoBehaviour, IPickable
{
    public ItemData itemData; // ScriptableObject 참조
    public int quantity = 1;

    public void Pickup()
    {
        Managers.Inventory.AddItem(itemData, quantity); // ScriptableObject 기반 인벤토리 추가
        Destroy(gameObject);
    }
}

 

ItemData.cs

using UnityEngine;

[CreateAssetMenu(fileName = "NewItemData", menuName = "Inventory/Item Data")]
public class ItemData : ScriptableObject
{
    public string itemName;
    public int itemID;
    public Sprite icon;
    public string description;
    public ItemType itemType; // 예: 무기, 소비 아이템 등
}

 

ItemType.cs

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

public enum ItemType
{
    Weapon,
    Healing,
    ingredients,
    etc
}

 

이렇게 만든다음에 아이템을 습득할 수 있게 해야하는데 이것은 전에 npc와 상호작용에서 사용한 인터페이스과 레이케스틍을 통해 구현해보자.

IPickable.cs

namespace Controllers.Entity
{
    public interface IPickable
    {
        /// <summary>
        /// Pickable의 필수 구현 사항
        /// </summary>
        void Pickup();
    }
}

Player.cs

private void PickupSomeThing()
{
    RaycastHit2D hit = Physics2D.Raycast(transform.position, facingDirection, interactDistance, LayerMask.GetMask("Pickable"));

    if (hit.collider != null)
    {
        IPickable pickable = hit.collider.GetComponent<IPickable>();
        if (pickable != null)
        {
            pickable.Pickup();
        }
        else
        {
            Debug.LogWarning("주울수 있는 객체가 아닙니다.");
        }
    }
    else
    {
        Debug.LogWarning("주울수 있는 대상이 없습니다.");
    }
}

 

이렇게 해준다음 주웠을 때 플레이어의 인벤토리에 추가될 수 있도록 Inventory를 관리해줄 Manager를 만들어보자.

나는 인벤토리를 리스트로 구현해보았다. 그리고 아이템 추가, 제거, 정보가져오기와 같은 기능들이 들어가도록 구현해보았다.

저장할 때 직렬화에 적합하도록 ItemSlot을 통해 데이터를 조작하도록 구현하였다.

ItemSlot.cs

[System.Serializable]
public class ItemSlot
{
    public ItemData itemData; // ScriptableObject 참조
    public int quantity;      // 해당 아이템 개수
}

 

InventoryManager.cs

using System.Collections.Generic;
using UnityEngine;

public class InventoryManager : MonoBehaviour
{
    private List<ItemSlot> inventory; // 슬롯 기반 관리

    public void Init()
    {
        inventory = new List<ItemSlot>();
    }

    // 아이템 추가
    public void AddItem(Item item, int count = 1)
    {
        ItemSlot slot = inventory.Find(s => s.itemData.itemID == item.itemID);
        if (slot != null)
        {
            slot.quantity += count; // 이미 존재하면 개수 증가
            Debug.Log(slot.itemData.itemName + "의 아이템의 개수가 " + slot.quantity + "개로 늘어났습니다.");
        }
        else
        {
            inventory.Add(new ItemSlot { itemData = item, quantity = count }); // 새로운 슬롯 추가
            Debug.Log(item.itemName + "의 아이템의 개수가 " + count + "개로 늘어났습니다.");
        }
    }

    // 아이템 제거
    public void RemoveItem(Item item, int count = 1)
    {
        ItemSlot slot = inventory.Find(s => s.itemData.itemID == item.itemID);
        if (slot != null)
        {
            slot.quantity -= count;
            if (slot.quantity <= 0)
            {
                inventory.Remove(slot); // 개수가 0 이하일 경우 슬롯 제거
            }
        }
    }

    // 특정 아이템 개수 가져오기
    public int GetItemCount(Item item)
    {
        ItemSlot slot = inventory.Find(s => s.itemData.itemID == item.itemID);
        return slot != null ? slot.quantity : 0;
    }

    private Item FindItemDataByID(int itemID)
    {
        return Resources.Load<Item>($"Items/{itemID}"); // Resources에서 아이템 데이터 검색
    }
}

 

이렇게 해서 테스트해보면 잘 작동하는 것을 볼 수 있다.

 

 

오늘은 대화창의 선택에 따라 퀘스트가 진행되도록 만들어보자 

우선 퀘스트를 하나 만들어줘야한다. 퀘스트는 Scriptable Object로 만들어서 관리해주도록 하자. 

클래스위에 Create를 쉽게 할 수 있도록 메뉴에서 선택가능하도록 메뉴에 추가해주는 코드를 추가해줬다.

QuestData.cs

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

[CreateAssetMenu(fileName = "NewQuest", menuName = "Quest/QuestData")]
public class QuestData : ScriptableObject
{
    [Header("Quest Info")]
    public string questName;
    public int questId;             //퀘스트번호
    public QuestState questState;   //퀘스트 상태
    public int QuestIndex;          //퀘스트 필요인덱스
    public string location;

    [Header("Reward Info")]
    public int goldReward;
}

 

이 QuestData를 관리해주는 Manager를 만들어주자. 퀘스트를 Resource에서 가져오고 Quest의 상태를 Update하고 QuestData를 반환해주는 함수도 추가해주자.

QuestManager.cs

using System.Collections.Generic;
using UnityEngine;

public class QuestManager : MonoBehaviour
{
    [SerializeField] private Dictionary<int, QuestData> Quests;

    public void Init()
    {
        LoadQuestsFromResources();
        InitializeQuest();
    }

    // Resources 폴더에서 모든 ScriptableObject를 로드하고 Dictionary로 초기화
    private void LoadQuestsFromResources()
    {
        QuestData[] loadedQuests = Resources.LoadAll<QuestData>("Quest");
        Quests = new Dictionary<int, QuestData>();
        if (loadedQuests.Length == 0)
        {
            Debug.LogError("Resources/Quest 폴더에 퀘스트 데이터가 없습니다.");
            return;
        }

        foreach (QuestData questData in loadedQuests)
        {
            if (!Quests.ContainsKey(questData.questId))
            {
                Quests.Add(questData.questId, questData);
                Debug.Log($"퀘스트 Id '{questData.questId}' 인 '{questData.questName}'가 추가되었습니다.");
            }
            else
            {
                Debug.LogWarning($"중복된 questId({questData.questId})가 발견되었습니다. QuestName: {questData.questName}");
            }
        }

        Debug.Log($"{Quests.Count}개의 퀘스트가 로드되었습니다.");
    }

    // Dictionary를 사용하여 퀘스트를 초기화
    private void InitializeQuest()
    {
        if (Quests == null || Quests.Count == 0)
        {
            Debug.LogError("Quests가 null이거나 비어 있습니다. LoadQuestsFromResources를 확인하세요.");
            return;
        }

        foreach (var questData in Quests.Values)
        {
            if (questData.QuestIndex >= Managers.Game.GetPlayer().GetComponent<Player>().index &&
                questData.questState == QuestState.REQUIREMENTS_NOT_MET)
            {
                // 진행 가능한 상태로 변경
                questData.questState = QuestState.CAN_START;
                Debug.Log($"퀘스트 Id '{questData.questId}' 인 '{questData.questName}'가 CAN_START로 초기화되었습니다.");
            }
        }
    }

    // 퀘스트 상태를 업데이트
    public void UpdateQuest(int questId)
    {
        if (Quests.TryGetValue(questId, out QuestData questData))
        {
            questData.questState++;

            if (questData.questState == QuestState.CAN_START)
            {
                Debug.Log($"퀘스트 시작! QuestName: {questData.questName}");
            }
            else if (questData.questState == QuestState.CAN_FINISH)
            {
                Debug.Log($"퀘스트 완료 가능! QuestName: {questData.questName}");
            }
            else if (questData.questState == QuestState.FINISHED)
            {
                Debug.Log($"퀘스트 완료! QuestName: {questData.questName}");
                Managers.Game.GetPlayer().GetComponent<Player>().index++;
                InitializeQuest();
            }
        }
        else
        {
            Debug.LogError($"questId({questId})에 해당하는 퀘스트가 없습니다.");
        }
    }

    // questId로 퀘스트 데이터를 가져옴
    public QuestData GetQuestData(int questId)
    {
        foreach (var questData in Quests.Values)
        {
            Debug.Log(questData.name);
            if (questData.questId == questId) return questData;
        }

        return null;
    }
}

 

이렇게 해주고 간단한 퀘스트를 추가해서 이 매니저가 작동하는지 확인해주자

우선 퀘스트를 가져오는 것은 성공했다.

이제 대화에서 선택했을 때 퀘스트의 진행상황이 바뀌는지 확인하자. 이를 위해 DialogueManager의 Tag관리 코드를 수정해주자.

DialogueManager.cs

private const string QuestTag = "Quest"; //테그값들 테그값 : 변수
private const string ChooseTag = "Start";
private const string EndTag = "End";

public void ContinueStory()
{
    if (currentStory == null) // Null 체크 추가
    {
        Debug.LogError("currentStory가 null입니다!");
        return;
    }

    if (currentStory.canContinue) // 더 보여줄 이야기가 있다면
    {
        popup.displayNameText.text = npcdata.getName();
        popup.portraitImage.sprite = npcdata.getPortrait();
        popup.dialogueText.text = currentStory.Continue(); // 한줄 출력
        DisplayChoices(); // 선택이 있으면 선택 출력
        HandleTags(currentStory.currentTags);
    }
    else
    {
        ExitDialogueMode();
    }
}
 
 private void HandleTags(List<string> currentTags)
 {
     foreach (string tag in currentTags)
     {
         string[] splitTag = tag.Split(':');
         if (splitTag.Length != 2)
         {
             Debug.LogError("Tag parsed error : " + tag + splitTag);
         }
         string tagkey = splitTag[0].Trim();
         string tagvalue = splitTag[1].Trim();


         switch (tagkey)
         {
             case QuestTag:
                 currentQuestIndex = int.Parse(tagvalue);
                 break;
             case ChooseTag:
                 if (tagvalue =="1")
                 {
                     Managers.Quest.UpdateQuest(currentQuestIndex);
                     Debug.Log("Update!"+Managers.Quest.GetQuestData(currentQuestIndex).questState);
                 }
                 break;
             case EndTag:
                 if(tagvalue =="1")
                 {
                     Managers.Quest.UpdateQuest(currentQuestIndex);
                     Debug.Log("Update!" + Managers.Quest.GetQuestData(currentQuestIndex).questState);
                 }
                 break;
             default:
                 Debug.LogWarning("Tag exists but not handled");
                 break;
         }

     }
 }

 

이렇게 해주고 대화를 하나 만들어주자.

Sori1.ink

이렇게 해주고 테스트해보자.

정상적으로 작동하는 것을 볼 수 있다.

+ Recent posts