Carrot
본문 바로가기
Unity/멋쟁이사자처럼 부트캠프

[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(68일차) - 땅 파기 게임 (2)

by 독기품은토끼 2025. 8. 25.
✅ 오늘의 학습 목표
1. 땅 파기 게임 (2) 구현 - 싱글 플레이와 멀티 플레이 2가지 구현

1. 싱글 플레이

1. 땅 파기

Tilemap에서 한 칸씩 땅을 파내려면 단순히 SetActive(false)로는 수행되지 않는다.

이렇게 구현하면 타일맵 전체가 비활성화되기 때문에 셀 단위 삭제가 불가능하다.

 

using UnityEngine;
using UnityEngine.Tilemaps;

public class DigEvent : MonoBehaviour
{
    [SerializeField] private Tilemap tilemap;
    [SerializeField] private LayerMask tileLayer;

    private void OnTriggerEnter2D(Collider2D other)
    {
        Debug.Log(other.name);

        Collider2D coll = Physics2D.OverlapCircle(transform.position, 0.1f, tileLayer);

        if (coll != null)
        {
            coll.gameObject.SetActive(false);
        }
    }
}

 

코드를 이렇게 구현하면

타일맵의 한 칸만 삭제하고싶은데 타일맵 전체가 삭제된다.

 

private void OnTriggerEnter2D(Collider2D other)
{
    Collider2D coll = Physics2D.OverlapCircle(transform.position, 0.1f, tileLayer);

    if (coll != null)
    {
        Vector3Int cellPos = tilemap.WorldToCell(transform.position);

        tilemap.SetTile(cellPos, null);
    }
}

 

  • OverlapCircle : 특정 위치에 겹쳐있는 타일 콜라이더를 찾는다.
  • WorldToCell : 월드 좌표 → 타일맵 셀 좌표로 변환
  • SetTile(cellPos, null) : 해당 좌표의 타일을 삭제

 

이렇게 현재 위치를 기준으로 가장 가까운 셀을 찾아서 null 처리로 원하는 칸을 삭제하도록 수정해주었다.

 

 

그런데 지금처럼 트리거 방식으로 타일맵을 삭제하게되면 원하지 않는 타일을 지워버릴 수도 있다.

그래서 망치를 휘두루는 애니메이션에 이벤트를 넣고 실제로 부딪힌 위치만 삭제하는 방법이 더 안정적이다.

 

using UnityEngine;
using UnityEngine.Tilemaps;

public class DigEvent : MonoBehaviour
{
    [SerializeField] private Tilemap tilemap;
    [SerializeField] private LayerMask tileLayer;
    [SerializeField] private Transform[] hitPoints;

    public void OnDig()
    {
        for (int i = 0; i < hitPoints.Length; i++)
        {
            Collider2D coll = Physics2D.OverlapCircle(hitPoints[i].position, 0.1f, tileLayer);

            if (coll != null)
            {
                Vector3Int cellPos = tilemap.WorldToCell(hitPoints[i].position);
                tilemap.SetTile(cellPos, null);

                break; // 처음으로 닿는 곳만 지우고 break
            }
        }
    }
}

 

  • Hit Point 오브젝트 만들기
    • 망치 궤적에 맞춰 빈 오브젝트들을 여러 개 배치
    • Inspector에서 배열로 넣어주고 위에서부터 아래로 순서대로 정렬
  • OverlapCircle로 충돌 체크
    • 각 포인트마다 검사하다가 하나라도 타일이 걸리면 해당 셀을 삭제
    • break로 루프를 끝내서 한 칸만 지워지게 함

 

2. 아이템 획득

🥕 Asset 다운로드
 

Free Minerals Pixel Art Icons | 2D 아이콘 | Unity Asset Store

Elevate your workflow with the Free Minerals Pixel Art Icons asset from CraftPix. Browse more 2D GUI on the Unity Asset Store.

assetstore.unity.com

 

2.1. 상호작용을 위한 스크립트

using UnityEngine;

public class MineralEvent : MonoBehaviour
{
    private MinerScoreManager scoreManager;

    void Start()
    {
        scoreManager = FindFirstObjectByType<MinerScoreManager>();
    }

    private void OnCollisionEnter2D(Collision2D other)
    {
        if (other.collider.CompareTag("Player"))
        {
            Debug.Log("광물 획득");
            scoreManager.AddScore();
            gameObject.SetActive(false);
        }
    }
}

 

플레이어가 생성된 미네랄과 충돌했을 때 점수를 올려주고, 미네랄은 비활성화 되도록 구현하였다.

 

2.2. 점수 관리 스크립트

using TMPro;
using UnityEngine;

public class MinerScoreManager : MonoBehaviour
{
    private int score;
    [SerializeField] private TextMeshProUGUI scoreUI;

    void Start()
    {
        scoreUI.text = $"Mineral Count : {score}";
    }

    public void AddScore()
    {
        score++;
        scoreUI.text = $"Mineral Count : {score}";
    }
}

 

게임 전체 점수를 관리하는 전역 매니저 스크립트를 생성해 주었다.

 

2.3. 타일 관리 스크립트 + 광물 스폰

using UnityEngine;
using UnityEngine.Tilemaps;

public class NetworkTilemap : MonoBehaviour
{
    [SerializeField] private GameObject[] minerals;

    private Tilemap tilemap;

    void Awake()
    {
        tilemap = GetComponent<Tilemap>();
    }

    public void RemoveTile(Vector3 hitPos)
    {
        Vector3Int cellPos = tilemap.WorldToCell(hitPos);

        tilemap.SetTile(cellPos, null);

        int ranItemDrop = Random.Range(0, 101);
        if (ranItemDrop >= 70)
        {
            int ranIndex = Random.Range(0, minerals.Length);
            Instantiate(minerals[ranIndex], cellPos, Quaternion.identity);
        }
    }
}

 

RemoveTile 메서드는 셀 좌표 변환 후 타일을 제거하고 일정 확률로 미네랄 프리팹을 생성한다.

 

2.4. 카메라 팔로우 기능

using UnityEngine;

public class CameraFollow : MonoBehaviour
{
    public Transform target;

    [SerializeField] private Vector3 offset;
    [SerializeField] private float smoothSpeed = 10f;

    void LateUpdate()
    {
        if (target == null)
            return;

        Vector3 targetPos = target.position + offset;
        transform.position = Vector3.Lerp(transform.position, targetPos, smoothSpeed * Time.deltaTime);
    }
}

 

2. 멀티 플레이

1. 플레이어 움직임

using System.Collections;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;

public class NetworkPlayerController : NetworkBehaviour
{
    public enum ActionType { Idle, Move, Attack }
    public ActionType actionType = ActionType.Idle;

    [SerializeField] private GameObject[] animObjs;
    private Rigidbody2D rb;

    private Vector3 moveInput;

    [SerializeField] private float moveSpeed = 2f;
    [SerializeField] private float jumpPower = 7f;

    void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
    }

    void Update()
    {
        if (IsOwner)
            Movement();
    }

    private void Movement()
    {
        if (actionType == ActionType.Attack)
            return;

        if (moveInput.x == 0)
        {
            actionType = ActionType.Idle;
            SetAnimObject(0);
        }
        else if (moveInput.x != 0)
        {
            actionType = ActionType.Move;

            SetAnimObject(1);

            int dirX = moveInput.x < 0 ? 1 : -1;
            transform.localScale = new Vector3(dirX, 1, 1);

            transform.position += moveInput * moveSpeed * Time.deltaTime;
        }
    }

    void OnMove(InputValue value)
    {
        var moveValue = value.Get<Vector2>();

        moveInput = new Vector3(moveValue.x, 0, 0);
    }

    void OnJump()
    {
        if (IsOwner)
            rb.AddForceY(jumpPower, ForceMode2D.Impulse);
    }

    void OnAttack()
    {
        if (IsOwner)
        {
            if (actionType != ActionType.Attack)
                StartCoroutine(AttackRoutine());
        }
    }

    IEnumerator AttackRoutine()
    {
        actionType = ActionType.Attack;
        SetAnimObject(2);

        yield return new WaitForSeconds(1f);
        SetAnimObject(0);
        actionType = ActionType.Idle;
    }

    private void SetAnimObject(int index)
    {
        for (int i = 0; i < animObjs.Length; i++)
            animObjs[i].SetActive(i == index);
    }
}

 

 

  • NetworkBehaviour 상속
    MonoBehaviour 대신 NetworkBehaviour를 상속받아 멀티플레이 환경에서 네트워크 기능을 쓸 수 있도록 하였다.
  • 소유자(Owner) 체크 추가
    Update, OnJump, OnAttack 같은 입력 처리 로직 앞에 IsOwner 조건을 붙여서
    자기 캐릭터만 조작하고 다른 플레이어는 건드리지 않도록 했다.
  • 상태 관리 방식 변경
    기존의 bool isAttack 플래그 대신 enum ActionType { Idle, Move, Attack }를 도입해
    캐릭터 상태를 더 명확하게 구분할 수 있게 했다.
  • 애니메이션 처리 단순화
    SetAnimObject에서 반복문을 돌며 전부 끄는 대신 인덱스 비교로 한 줄로 정리해 코드가 깔끔해졌다.

 

그러나 지금 코드의 문제점

클라이언트 입력 → 값 변경이 서버 권한으로만 가능하다 보니 실제 플레이어의 동작이 제대로 반영되지 않는다.

즉, RPC 없이 작성했기 때문에 동기화 작업이 정상적으로 작동되지 않는다.

 

using System.Collections;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;

public class NetworkPlayerController : NetworkBehaviour
{
    // 0: Idle, 1: Move, 2: Attack
    private NetworkVariable<int> currentAnimState = new NetworkVariable<int>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
    
    [SerializeField] private GameObject[] animObjs;
    private Rigidbody2D rb;
    
    private Vector3 moveInput;
    
    [SerializeField] private float moveSpeed = 2f;
    [SerializeField] private float jumpPower = 7f;

    public override void OnNetworkSpawn()
    {
        base.OnNetworkSpawn();
        
        rb = GetComponent<Rigidbody2D>();
        currentAnimState.OnValueChanged += UpdateAnimation;

        if (!IsOwner)
        {
            GetComponent<PlayerInput>().enabled = false;
        }
    }
    
    void Update()
    {
        if (IsOwner)
            Movement();
    }

    private void Movement()
    {
        if (currentAnimState.Value == 2)
            return;
        
        if (moveInput.x == 0)
        {
            currentAnimState.Value = 0;
        }
        else if (moveInput.x != 0)
        {
            currentAnimState.Value = 1;
            
            int dirX = moveInput.x < 0 ? 1 : -1;
            transform.localScale = new Vector3(dirX, 1, 1);
            
            transform.position += moveInput * moveSpeed * Time.deltaTime;
        }
    }

    void OnMove(InputValue value)
    {
        var moveValue = value.Get<Vector2>();

        moveInput = new Vector3(moveValue.x, 0, 0);
    }

    void OnJump()
    {
        if (IsOwner)
            rb.AddForceY(jumpPower, ForceMode2D.Impulse);
    }

    void OnAttack()
    {
        if (IsOwner)
        {
            if (currentAnimState.Value != 2)
                AttackServerRpc();
        }
    }

    [ServerRpc]
    private void AttackServerRpc()
    {
        StartCoroutine(AttackRoutine());
    }

    IEnumerator AttackRoutine()
    {
        currentAnimState.Value = 2;

        yield return new WaitForSeconds(1f);
        currentAnimState.Value = 0;
    }

    private void UpdateAnimation(int prevValue, int newValue)
    {
        for (int i = 0; i < animObjs.Length; i++)
            animObjs[i].SetActive(i == newValue);
    }
}

 

 

 

  • 클라이언트: 입력(공격 키)을 누르면 서버에게 공격 시작 요청을 보냄
  • 서버: AttackRoutine() 코루틴 실행 → 애니메이션 상태를 2(Attack)로 변경
  • NetworkVariable: 서버에서 바뀐 값을 자동으로 모든 클라이언트에 전파
  • OnValueChanged 이벤트: 각 클라이언트에서 UI/애니메이션을 갱신

 

2. 아이템 획득 동기화

using TMPro;
using Unity.Netcode;
using UnityEngine;

public class NetworkScoreManager : NetworkBehaviour
{
    public static NetworkScoreManager Instace;

    [SerializeField] private TextMeshProUGUI scoreUI;

    private NetworkVariable<int> score = new NetworkVariable<int>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);

    void Awake()
    {
        Instace = this;    
    }

    public override void OnNetworkSpawn()
    {
        base.OnNetworkSpawn();

        score.OnValueChanged += OnScoreChanged;
        scoreUI.text = score.Value.ToString();
    }

    private void OnScoreChanged(int prevValue, int newValue)
    {
        scoreUI.text = newValue.ToString();
    }

    public void AddScore()
    {
        if (!IsServer)
            return;

        score.Value++;
    }
}

 

 

  • Instance = this
    • 초기화 순서 에러 방지 → 다른 스크립트에서 Awake나 Start 메서드 실행 시 NetworkScoreManager.Instance.AddScore() 같은 걸 부를 수 있음
    • 이 때 Instance가 아직 설정되어있지 않는다면 NullReference가 발생한다.
    • 그래서 가장 빠른 생명주기인 Awake()의 맨 앞에서 Instance = this를 선언해 두면 이후에 어느 곳에서든 안전하게 접근할 수 있다.
  • score.Value++
    • NetworkVariable<T>는 네트워크 동기화를 위한 래퍼 타입으로 값은 반드시 .Value로 읽고 써야 NGO가 이를 반영한다.
  • OnValueChanged
    • 형식 : T previousValue, T newValue
    • 해당 콜백은 서버/클라이언트 모든 곳에서 호출되므로 UI 갱신은 이 안에서만 하면 동기화가 깔금하다.
  • OnNetworkSpawn
    • 네트워크 스폰과 수명주기를 맞추려면 OnNetworkSpawn과 OnNetworkDespawn을 활용하는 것이 좋다.
    • 지금처럼 지금 Awake에서 구독해도 작동은 하지만 스폰 전/비연결 상태까지 커버하려다 NRE 나올 수 있다.

 

using Unity.Netcode;
using UnityEngine;

public class NetworkMineralEvent : NetworkBehaviour
{
    private void OnCollisionEnter2D(Collision2D other)
    {
        if (other.collider.CompareTag("Player") && IsOwner)
        {
            AddScoreServerRpc();
        }
    }

    [ServerRpc]
    private void AddScoreServerRpc()
    {
        NetworkScoreManager.Instace.AddScore();
        GetComponent<NetworkObject>().Despawn();
    }
}

 

3. 타일맵 동기화

using Unity.Netcode;
using UnityEngine;
using UnityEngine.Tilemaps;

public class NetworkTilemap : NetworkBehaviour
{
    [SerializeField] private GameObject[] minerals;

    private Tilemap tilemap;

    private NetworkList<Vector3Int> destroyedTiles = new NetworkList<Vector3Int>();

    void Awake()
    {
        tilemap = GetComponent<Tilemap>();
    }

    public override void OnNetworkSpawn()
    {
        base.OnNetworkSpawn();

        destroyedTiles.OnListChanged += OnTileDestroyed;

        foreach (var tilePos in destroyedTiles)
        {
            tilemap.SetTile(tilePos, null);
        }
    }

    public void RemoveTile(Vector3 hitPos)
    {
        if (!IsServer)
            return;

        Vector3Int cellPos = tilemap.WorldToCell(hitPos);

        int ranItemDrop = Random.Range(0, 101);
        if (ranItemDrop >= 70)
        {
            int ranIndex = Random.Range(0, minerals.Length);

            // NetworkObject.Instantiate()
            GameObject mineral = Instantiate(minerals[ranIndex], cellPos, Quaternion.identity);
            mineral.GetComponent<NetworkObject>().Spawn();
        }

        if (tilemap.GetTile(cellPos) != null)
        {
            destroyedTiles.Add(cellPos);
        }
    }

    private void OnTileDestroyed(NetworkListEvent<Vector3Int> changeEvent)
    {
        if (changeEvent.Type == NetworkListEvent<Vector3Int>.EventType.Add)
        {
            tilemap.SetTile(changeEvent.Value, null);
        }
    }
}

 

1. 늦게 접속한 클라이언트도 상태 맞추기

  • NetworkList<Vector3Int> destroyedTiles에 지운 셀 좌표를 누적 기록.
  • OnNetworkSpawn()에서
    • destroyedTiles.OnListChanged += OnTileDestroyed;
    • foreach (var pos in destroyedTiles) tilemap.SetTile(pos, null);
  • 타일 상태를 구독하였기 때문에 늦게 접속한 클라이언트도 접속 즉시 과거 파괴 이력을 한 번에 적용받아 화면이 똑같이 보인다.

2. 드랍은 네트워크로 스폰

  • 단순 Instantiate()만 하면 서버에만 보이는 오브젝트가 될 수 있다.
  • 드랍 시  GameObject mineral = Instantiate(minerals[ranIndex], cellPos, Quaternion.identity);
    mineral.GetComponent<NetworkObject>().Spawn();
  • 모든 플레이어 화면에 동일한 드랍이 생성된다.
  • 드랍 프리팹엔 NetworkObject가 붙어 있어야 하고 NetworkManager > NetworkPrefabs에 등록되어 있어야 한다.

3) 추가 정리

  • 서버 권한 유지: if (!IsServer) return;로 타일 파괴·드랍을 서버만 결정 → 치트/불일치 방지
  • 실시간 반영은 OnListChanged, 과거 재현은 foreach. 두 갈래로 항상 동일 상태를 보장