Unity 3D/디펜스 게임

타워 디펜스 게임 (3) - UI, 타워 설치

ybbro 2025. 3. 27. 09:25

8. UI

게임 플레이 화면보다 위에 있는 오버레이 캔버스 UI를 생성하고

플레이에 필요한 표시, 버튼을 배치

상단: 플레이어 라이프, 일시정지 버튼
하단: 상점(타워 구매),
왼쪽: 코스트(골드),
오른쪽: 다음 웨이브까지의 시간

일시정지 버튼
게임오버 UI


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

public class UIManager : MonoBehaviour
{
    TextMeshProUGUI playerLifeUI;
    TextMeshProUGUI GameOverRoundUI;
    TextMeshProUGUI levelUI;

    GameObject GameOver;
    GameObject Menu;
    GameObject LevelClear;
    FadeManager fm;
    bool isGameOverActive;
    bool isLevelClearedActive;

    // 버튼 동작 중 추가 입력 방지
    // -> 주로 씬 전환이 페이드 인 효과 시작 후 2초 뒤에 이루어지므로 그 사이 추가 입력이 들어가면 먹통이 됨
    // -> 씬 전환하려고 기다리는 중 추가 입력을 받지 못하게 하기 위함
    bool isSceneChangeActived;

    int nextLevel = PlayerStatus.curretStage + 1;

    // Start is called before the first frame update
    void Start()
    {
        // 자식 오브젝트 중 비활성화 상태를 포함한 필요한 오브젝트를 찾음
        GameOver = transform.Find("GameOver").gameObject;
        Menu = transform.Find("Menu").gameObject;
        LevelClear = transform.Find("LevelClear").gameObject;
        // 비활성화 된 오브젝트까지 포함해 필요한 TMP를 가져옴
        TextMeshProUGUI[] allUI = FindObjectsOfType<TextMeshProUGUI>(true);
        for (int i = 0; i < allUI.Length; i++)
        {
            if (allUI[i].name.Contains("Life"))
                playerLifeUI = allUI[i];
            else if (allUI[i].name.Contains("Round"))
                GameOverRoundUI = allUI[i];
            else if (allUI[i].name.Contains("Level"))
                levelUI = allUI[i];
        }

        fm = GameObject.Find("FadeCanvas").GetComponent<FadeManager>();

        // 시작과 동시에 현재 라이프를 UI에 갱신
        playerLifeUI.text = $"<sprite=0> {PlayerStatus.GetLife()}";

        isGameOverActive = false;
        isLevelClearedActive = false;
        isSceneChangeActived = false;
    }

    // Update is called once per frame
    void Update()
    {
        // 게임 오버가 될 경우 한번만
        if (!GameManager.isPlayerAlive && !isGameOverActive)
        {
            // 게임 오버 패널 활성화
            GameOver.SetActive(true);
            // 해당 if문 내의 구문 여러 번 호출 방지
            isGameOverActive = true;

            // 현재 라운드를 받아와서 표시
            GameOverRoundUI.text = $"Round : {PlayerStatus.GetRound()}";
        }

        // 레벨 클리어할 경우 1번만
        if(GameManager.isLevelCleared && !isLevelClearedActive)
        {
            // 레벨 클리어 패널 활성화
            LevelClear.SetActive(true);
            // 클리어한 스테이지 표시
            levelUI.text = $"LEVEL : {PlayerStatus.curretStage}";

            // 클라이언트에 클리어한 스테이지 저장
            PlayerPrefs.SetInt("maxClearedStage", PlayerStatus.curretStage);

            // 마지막 스테이지 클리어 시 Continue 버튼 비활성화
            if (PlayerStatus.curretStage.Equals(PlayerStatus.maxStage))
                LevelClear.GetComponentsInChildren<Button>()[0].interactable = false;
            // 마지막 스테이지가 아니고 Continue 버튼이 비활성화 상태라면 (마지막 스테이지 클리어 후 이전 난이도 클리어할 경우 컨티뉴 버튼을 활성화 해줘야 한다)
            else if (!LevelClear.GetComponentsInChildren<Button>()[0].interactable)
                LevelClear.GetComponentsInChildren<Button>()[0].interactable = true;

            // 해당 if문 내의 구문 여러 번 호출 방지
            isLevelClearedActive = true;
        }

        // 라이프 변화가 있을 경우 한번만 UI에 표시
        if (PlayerStatus.GetLifeChanged())
        {
            playerLifeUI.text = $"<sprite=0> {PlayerStatus.GetLife()}";
            PlayerStatus.SetLifeChanged();
        }

        // ESC 키 입력 시 일시 정지 / 재개
        if(Input.GetKeyDown(KeyCode.Escape))
            PauseORcontinuePressed();      
    }

    // 게임 플레이 도중 일시 정지 버튼 or PAUSE 상태에서 Continue 버튼을 눌렀을 때
    public void PauseORcontinuePressed()
    {
        if (isSceneChangeActived)
            return;
        // 게임 정지, 메뉴창 팝업 or 정상 속도, 메뉴창 닫기 토글
        if (Menu.activeSelf)
        {
            Menu.SetActive(false);
            Time.timeScale = 1.0f;
        }
        else
        {
            Menu.SetActive(true);
            Time.timeScale = 0.0f;
        }
    }

    // 재도전 버튼(일시 정지 화면 or 게임 오버 화면)
    public void RetryButtenPressed()
    {
        // 버튼 중복 입력 방지 처리
        if (isSceneChangeActived)
            return;
        isSceneChangeActived = true;

        // 시간이 정지해 있다면 원래 속도로 돌려주고
        if (Time.timeScale == 0f)
            Time.timeScale = 1.0f;
        // 필요한 파라미터 초기화
        PlayerStatus.Initialize();
        string sceneNameToLoad = SceneManager.GetActiveScene().name;
        // 페이드 아웃 효과 후 해당 레벨 씬 다시 로드
        StartCoroutine(LoadScene(sceneNameToLoad));

    }

    // Menu 버튼 (메인 메뉴 화면으로)
    public void MenuButtonPressed()
    {
        // 버튼 중복 입력 방지 처리
        if (isSceneChangeActived)
            return;
        isSceneChangeActived = true;

        // 메뉴창이 열려있을 경우 정상 속도, 메뉴창 닫기
        if (Menu.activeSelf)
        {
            Menu.SetActive(false);
            Time.timeScale = 1.0f;
        }
        // 필요한 파라미터 초기화
        PlayerStatus.Initialize();
        string sceneNameToLoad = "MainMenuScene";
        // 페이드 아웃 효과 후 메인 메뉴로
        StartCoroutine(LoadScene(sceneNameToLoad));
    }

    // Continue : 다음 스테이지 버튼 클릭 시
    public void ContinueButtonPressed()
    {
        // 버튼 중복 입력 방지 처리
        if (isSceneChangeActived)
            return;
        isSceneChangeActived = true;

        PlayerStatus.curretStage = nextLevel;
        PlayerStatus.Initialize();

        string sceneNameToLoad;
        // 100 이상의 스테이지에 대하여 Level 뒤에 스테이지를 그대로 붙임
        if (nextLevel / 100 !=0)
            sceneNameToLoad = $"Level{nextLevel}";
        // 10~99 (두 자리 정수) 스테이지에 대하여 Level0 뒤에 스테이지를 붙임
        else if(nextLevel / 10 != 0)
            sceneNameToLoad = $"Level0{nextLevel}";
        // 1~9 (1 자리 정수) 스테이지에 대하여 Level00 뒤에 스테이지를 붙임
        else
            sceneNameToLoad = $"Level00{nextLevel}";
        StartCoroutine(LoadScene(sceneNameToLoad));
    }

    IEnumerator LoadScene(string sceneName)
    {
        // 페이드 아웃 효과
        StartCoroutine(fm.FadeInOut());

        // 페이드 아웃 효과를 보여주기 위해 다음 씬 로드 전에 딜레이를 줌
        const float delayForSceneChange = 2f;
        yield return new WaitForSeconds(delayForSceneChange);

        SceneManager.LoadScene(sceneName);
    }
}

 

 

해당 스크립트는 OverlayCanvas 오브젝트에 붙어 있으며

UI 각 요소에 접근할 수 있게끔 UI 오브젝트 요소에 대한 지칭, 초기화
각 버튼을 눌렀을 때 동작하는 내용들에 대한 메서드를 가지고 있습니다.

 

 

버튼 종류 : 일시정지, 재개, 처음부터 재시작, 타이틀로 나가기

 

이를 구현하기 위해 타임스케일, 씬 불러오기, 씬 전환 전에 페이드아웃(점점 화면이 검게 됨) 을 사용하였습니다.

 

페이드 아웃은 아래와 같이 구현했습니다.

using System.Collections;
using UnityEngine;

public class FadeManager : MonoBehaviour
{
    Animator Fade;
    bool isFadeOut;

    // Start is called before the first frame update
    void Start()
    {
        isFadeOut = false;
        Fade = transform.Find("Fade").GetComponent<Animator>();

        // 씬 로드 직후 자동으로 페이드 인 효과
        StartCoroutine(FadeInOut());
    }

    // 화면 효과 : 페이드 인, 아웃
    public IEnumerator FadeInOut()
    {
        // 페이드 이미지 활성화
        if(!Fade.gameObject.activeSelf)
            Fade.gameObject.SetActive(true);

        // 홀수 번째 호출 시 : Fade In (해당 씬 불러올 때)
        if (!isFadeOut)
        {
            // 2초간 페이드 인 이펙트를 보여주고
            yield return new WaitForSeconds(2f);
            // 두 번째 실행부터 메인 메뉴로 가기 때문에 첫 실행 마지막에 true로 바꿔줌
            isFadeOut = true;
            // 해당 이미지는 비활성화
            Fade.gameObject.SetActive(false);
        }
        // 짝수 번째 호출 시 : Fade Out
        else
        {
            // 다음 호출 시 Fade In
            isFadeOut = false;
            // SetFloat을 1f로 바꿔두어도 다음 함수 호출 때는 0이 되어있음 그러니 2번째 호출 직후 1로 변경해야 제대로 동작
            Fade.SetFloat("isFadeOut", 1f);
            yield return null;
        }
    }
}

 

 

 

9. 타워 설치

각 타워 이미지, 가격 표시를 곁들인 버튼

 

설치에 필요한 절차는 아래와 같습니다.


타워 선택 버튼 터치 >> 설치에 필요한 코스트가 충분한가? >> 충분하면 선택 >> 지정한 장소가 설치할 장소가 맞는가? >> 설치 가능한 타일 위라면 설치

 

1) 상점 UI

using UnityEngine;
using TMPro;

public class Shop : MonoBehaviour
{
    // 모든 타워 최대 업그레이드 단계 지정
    public const int MaxUpgrade = 3;

    // [SerializeField] : [Serializable]을 써서 선언한 클래스 혹은 구조체를 인스펙터 창에서 볼 수 있게 됨
    //[SerializeField]
    public TurretBlueprint BasicTurret;
    public TurretBlueprint MissileLauncher;
    public TurretBlueprint LaserBeamer;

    public TextMeshProUGUI Gold;

    private void Start()
    {
        // 모든 터렛, 업그레이드 단계별 가격 지정
        BasicTurret.price = new int[Shop.MaxUpgrade] { 100, 150, 200 };
        MissileLauncher.price = new int[Shop.MaxUpgrade] { 250, 300, 350 };
        LaserBeamer.price = new int[Shop.MaxUpgrade] { 350, 400, 450 };
    }

    void Update()
    {
        Gold.text = $"G : {PlayerStatus.GetGold()}";
    }
    // 각 터렛 모양의 버튼을 누를 경우 해당하는 메서드 호출
    // 설치할 터렛 변수에 선택한 것과 같은 터렛의 프리팹, 가격 정보가 들어있는 블루프린트를 할당
    public void BuyBasicTurret()
    {
        BuildManager.BMInstance.SetTurretToInstall(BasicTurret);
    }

    public void BuyMissileLauncher()
    {
        BuildManager.BMInstance.SetTurretToInstall(MissileLauncher);
    }

    public void BuyLaserBeamer()
    {
        BuildManager.BMInstance.SetTurretToInstall(LaserBeamer);
    }
}

제일 아래 3가지 메서드는 각각의 타워 구매 버튼에 할당하였습니다.

유저가 만든 클래스는 일반적으로 인스펙터에서 매개변수로 출현하지 않기에 메서드를 각각 나눠 썼습니다.

 

 

2) 타일

using UnityEngine;
using UnityEngine.EventSystems;

public class Tiles : MonoBehaviour
{
    // 랜더러 컴포넌트를 통해 메터리얼에 접근
    Renderer render;
    // 마우스가 올라갔을 때 변할 색상 -> 인스펙터 창에서 컬러 픽커 창으로 설정 가능함
    //public Color hoverColor;
    // 기존 색상을 저장할 변수
    //Color originColor;
    // 기존 재료를 저장할 변수
    Material originMaterial;
    // 패턴이 있는 재료를 받아올 변수
    public Material hoverMaterial;
    public Material notEnoughMoney;

    GameObject turret;

    BuildManager bm;

    public TurretBlueprint tb;

    // 배치하면 타워가 타일에 파뭍혀서 위로 올려줄 오프셋 지정
    float offset = 0.5f;

    // 해당 타일에 설치된 터렛 레벨
    int turretLV;
 
    private void Start()
    {
        // 레벨은 0부터
        turretLV = 0;
        // 트랜스폼을 통해 랜더러 인스턴스 생성
        render = transform.GetComponent<Renderer>();
        // 기존 메터리얼을 할당
        originMaterial = render.material;

        bm = BuildManager.BMInstance;
        turret = null;
    }

    public GameObject GetInstalledTurret() => turret;

    // 마우스가 해당 오브젝트 위에 있을 때
    private void OnMouseOver()
    {
        // 선택한 터렛이 있고 UI위에서 마우스 입력이 일어나지 않은 경우, 마우스를 타일 위로 가져가면 해당 타일 패턴(메터리얼) 변경
        if (bm.GetTurretToInstall() != null && EventSystem.current.IsPointerOverGameObject() == false)
        {
            // 소지금이 충분하다면, 설치 가능 표시. 아닐 경우 설치 불가 표시
            if(bm.IsGoldEnough(0))
                render.material = hoverMaterial;
            else
                render.material = notEnoughMoney;
        }
        if(bm.GetTurretToInstall() == null)
            render.material = originMaterial;
    }

    // 마우스가 해당 오브젝트 위치에서 벗어났을 때
    private void OnMouseExit()
    {
        // 원래 재질로 돌아옴
        render.material = originMaterial;
    }
    // 마우스 좌클릭하면
    private void OnMouseDown()
    {
        // UI위에서 마우스 입력이 일어나지 않은 경우
        if (EventSystem.current.IsPointerOverGameObject() == false)
        {
            // 선택한 타일을 저장
            bm.TileforUI = this;

            // 선택한 터렛이 없다면
            if (bm.GetTurretToInstall() == null)
            {
                Debug.Log("설치할 터렛이 없습니다");
            }
            // 선택한 터렛이 있고, 해당 타일 위에 터렛이 없다면
            else if (turret == null)
            {
                // 설치한 터렛의 블루프린트를 할당
                tb = bm.GetTurretToInstall();
                // 골드가 충분하다면 설치
                InstallTurret(turretLV, transform.position);
            }
        }
    }

    public void InstallTurret(int LV, Vector3 InstallLocation)
    {
        // 설치된 터렛이 있다면 터렛블루프린트를 먼저 설정해야 함
        if (turret != null)
            bm.SetTurretToInstall(tb);
        // 설치할 골드가 충분하다면
        if (bm.IsGoldEnough(LV))
        {
            // 이번 유저 입력에 타워를 설치했음을 알리고
            bm.turretInstalled = true;

            // 설치에 필요한 골드 차감
            PlayerStatus.SetGold(PlayerStatus.GetGold() - bm.GetTurretToInstall().price[LV]);

            // 타워 설치 이펙트 호출
            EffectManager.NeedEffect((int)EffectManager.effectIndex.InstallEffect, InstallLocation + Vector3.up * offset);

            // 터렛이 없다면, 설치하고 turret에 할당
            if (turret == null)
            {
                turret = Instantiate(bm.GetTurretToInstall().prefab, InstallLocation + Vector3.up * offset, Quaternion.identity);
                turret.transform.SetParent(transform);
            }
            // 터렛을 업그레이드했다면
            else
                // 타워 레벨 변경을 알림 -> 타워, 불릿의 외형, 스테이터스 변경
                turret.GetComponent<Turret>().isUpgraded = true;
            // 최대 레벨 미만인 경우 터렛의 레벨을 올려줌
            if (turretLV < Shop.MaxUpgrade)
                ++turretLV;

            // 설치 후 선택중인 타워 해제
            bm.SetTurretToInstall(null);
        }
        else
        {
            bm.SetTurretToInstall(null);
            Debug.Log("Gold 부족");
        }
    }

    // 현재 터렛에 지금까지 들어간 골드 계산 -> 절반이 판매 가격
    public int SellPrice()
    {
        int sum = 0;
        for (int i = 0; i < turretLV; i++)
        {
            sum += tb.price[i];
        }
        return sum/2;
    }

    public void SellTurret()
    {
        // 반값 돌려줌
        PlayerStatus.SetGold(PlayerStatus.GetGold() + SellPrice());
        // 터렛 파괴
        Destroy(turret);
        // 다음 터렛 설치를 위해 Tiles.cs 의 변수 초기화
        turret = null;
        tb = null;
        turretLV = 0;
    }

    public int GetTurretLV() => turretLV;
}

 

설치 가능/불가 판정 알림을 위한 타일 색상 교체

해당 타일 위에 터렛 설치
타일을 터치하여 설치되어 있던 터렛 판매 / 업그레이드

 

3) 설치 매니저

설치 과정에 관여

위치는 상관없습니다. 빈 오브젝트에 기능만 불러올 수 있게 넣어뒀습니다.

using UnityEngine;

public class BuildManager : MonoBehaviour
{
	// 자기 자신을 메모리에 올려주기 위해 타입 선언
    public static BuildManager BMInstance;

    // 설치할 터렛 : 버튼 클릭으로 바꿈
    private TurretBlueprint TurretToInstall = null;

    // 타일을 선택했을 때 그 타일 오브젝트를 저장
    Tiles SelectedTile;

    bool isturretInstalled;



    // 각각 투명, 반투명에 쓰이는 쉐이더의 경로 및 명칭
    string shaderTransparent = "UI/Lit/Transparent";
    // 모든 터렛 구성 메터리얼 색상의 알파값
    float transparentAlpha = 0.5f;

    GameObject preInstall;

    private void Awake()
    {
    	// BuildManager 자신을 메모리에 올려주기
        if(BMInstance != null)
        {
            Destroy(gameObject);
            return;
        }
        BMInstance = this;
        DontDestroyOnLoad(gameObject);
		
        // 변수들 초기화
        SelectedTile = null;
        TileforUI = null;
        turretInstalled = false;

        preInstall = null;
    }

    private void Update()
    {
        // 반투명하게 생성한 타워가 있다면
        if(preInstall!=null)
        {
            // 반투명 타워 오브젝트가 마우스 커서 위치를 따라다니게
            Vector3 mousePos = new Vector3(Input.mousePosition.x, Input.mousePosition.y, -Camera.main.transform.position.z-10f);
            preInstall.transform.position = Camera.main.ScreenToWorldPoint(mousePos);
        }

        // 마우스 우클릭 시, 선택한 타워 해제
        if (Input.GetKeyDown(KeyCode.Mouse1))
            SetTurretToInstall(null);
    }

    public TurretBlueprint GetTurretToInstall()
    {
        return TurretToInstall;
    }

    // 기존에는 상점에서 터렛 버튼 클릭 시, 설치할 터렛의 정보만 넘겨주었지만
    // 터렛의 프리팹을 미리 반투명하게 생성하고 정보도 넘겨주고 이후 해당 반투명 오브젝트가 마우스를 따라다니게끔 위치 변경
    // 플레이어가 좀 더 직관적으로 게임 상황을 파악할 수 있음
    public void SetTurretToInstall(TurretBlueprint turret)
    {
        TurretToInstall = turret;

        // 상점에서 터렛을 선택했을 때
        if (TurretToInstall != null)
        {
            // 해당 터렛 생성
            preInstall = Instantiate(TurretToInstall.prefab);
            // 생성한 터렛의 Turret 스크립트를 막고
            preInstall.GetComponent<Turret>().enabled = false;
            // 생성한 터렛의 자식 오브젝트을 받아옴
            Transform[] turretComponents = preInstall.GetComponentsInChildren<Transform>();

            float colorOffset = 0.1f;
            // 모든 자식 오브젝트들에 대해
            for (int i = 0; i < turretComponents.Length; i++)
            {
                // MeshRenderer가 있는 오브젝트만
                if (turretComponents[i].TryGetComponent(out MeshRenderer MR))
                {
                    // 모든 메테리얼을 받아와서 색상, 투명도 조절
                    Material[] MRmaterials = MR.materials;
                    for (int j = 0; j < MRmaterials.Length; j++)
                    {
                        // colorOffset을 이용하여 조금 더 어둡게, transparentAlpha를 이용하여 반투명하게
                        MRmaterials[j].color = new Color(MRmaterials[j].color.r - colorOffset, MRmaterials[j].color.g - colorOffset, MRmaterials[j].color.b - colorOffset, transparentAlpha);
                        MRmaterials[j].shader = Shader.Find(shaderTransparent);
                    }
                }
            }
        }
        // TurretToInstall이 null이 들어오는 때는 선택 해제 및 반투명 터렛 파괴
        else
            Destroy(preInstall);

    }

    public bool IsGoldEnough(int Lv)
    {
       return PlayerStatus.GetGold() >= TurretToInstall.price[Lv];
    }

    // 타일이 선택될 때 해당 타일을 가져와서 UI표시에 씀
    public Tiles TileforUI
    {
        get => SelectedTile;
        set => SelectedTile = value;
    }

    // 터렛을 설치했을 때 true -> TileUI에서 true일 경우 UI를 표시하지 않고 false로 만듦
    public bool turretInstalled
    {
        get => isturretInstalled;
        set => isturretInstalled = value;
    }
}