유니티로 만드는 3D 농사게임 - 1일차

굥지·2026년 1월 20일

3D 농사게임

목록 보기
1/2

안녕하신가요
오늘(사실 1월 16일)부터 시작한 3D 농사게임을 만들어보려고 해요

제미나이와 함께한 + 제 포트폴리오에 3D가 없기에 + 기술적 향상을 위한 의미있는 프로젝트인데 기록겸 남깁니당

1. 초기 설정

일단 아이소메트릭뷰로 만들거기 때문에 Camera를 자식으로 둔 Camera Pivot 오브젝트 생성 후 Rotation 30, 45, 0으로 설정해줘요

Main Camera는 Orthographic으로 설정 후 Size 및 Clipping Planes 조절

Plane 생성 후 Position(10, 0, 10) Scale(2, 2, 2)로 변경

농사게임에 일단 밭을 만들어야하니까
이 에셋 다운/임포트 후

https://assetstore.unity.com/packages/3d/environments/industrial/low-poly-farm-pack-lite-188100

그리드 크기에 맞춰서 작게 만들어줍니다(원본/투명 둘 다)

투명 오브젝트는 어케 만드냐?
메테리얼을 생성하고 - 인스펙터 창에서 Surface Type을 Transparent로 변경합니다

Blending Mode를 Alpha로 설정하고 만든 머티리얼을 Preview Prefab의 Mesh Renderer에 할당하면 끝!

2. 그리드 시스템의 데이터와 로직 분리

아이소메트릭뷰에 배치 시스템이 있으면 그리드 시스템이 기본으로 깔리겟져?

GridSystem 스크립트 생성 후 Plane에 부착해줘요

using System;
using UnityEngine;

/// <summary>
/// 마우스 입력을 받아 그리드 상에 오브젝트를 배치하고 
/// 배치 가능 여부를 시각적으로 보여주는 클래스
/// </summary>
public class GridSystem : MonoBehaviour
{
    [Header("설정")]
    public float cellSize = 10f;       // 그리드 한 칸의 크기 (기본 Plane 10x10에 맞춤)
    public LayerMask groundLayer;      // 레이캐스트가 감지할 바닥 레이어

    [Header("프리팹")]
    public GameObject previewPrefab;   // 설치 전 보여줄 투명한 미리보기용 프리팹
    public GameObject realPrefab;      // 실제로 설치될 완성된 오브젝트 프리팹

    private GridManager gridManager;   // 설치 데이터를 관리하는 GridManager 참조
    private GameObject previewInstance; // 씬에 생성되어 따라다닐 미리보기 인스턴스

    void Start()
    {
        // 미리보기 인스턴스를 생성하고 초기 설정 진행
        if (previewPrefab != null)
        {
            previewInstance = Instantiate(previewPrefab);
            previewPrefab.SetActive(false);

            // TryGetComponent: GameObject에 존재하는 경우 지정된 유형의 컴포넌트를 검색하려고 시도하고,
            // 발견되면 true, 발견되지 않으면 false를 반환한다.
            //public bool TryGetComponent<T>(out T component) where T : Component;
            /* T: 가져오려는 컴포넌트의 타입
            component: 컴포넌트를 가져올 때 사용되는 out 매개변수 */

            // 미리보기 오브젝트가 마우스 레이캐스트를 방해하지 않도록 콜라이더 비활성화
            if (previewInstance.TryGetComponent<Collider>(out Collider col)) col.enabled = false;
        }
        // 씬에 존재하는 GridManager를 찾아 참조 연결
        if (gridManager == null) gridManager = FindAnyObjectByType<GridManager>();
    }
    void Update()
    {
        // 마우스 위치로부터 화면 안쪽으로 레이(광선)를 쏜다.
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        RaycastHit hit;

        //Ray가 어떤 Collider에 맞으면 true를 반환하고, 그 충돌 정보는 hit에 담긴다.

        // 지정된 groundLayer(바닥)에 레이가 맞았을 때만 로직 실행
        if (Physics.Raycast(ray, out hit, Mathf.Infinity, groundLayer))
        {
            // [좌표 계산] 맞은 지점의 월드 좌표를 그리드 인덱스(0, 1, 2...)로 변환
            int xIdx = Mathf.FloorToInt(hit.point.x / cellSize);
            int zIdx = Mathf.FloorToInt(hit.point.z / cellSize);

            // [스냅 좌표] 오브젝트가 그리드 정중앙에 오도록 좌표 보정 (+cellSize/2)
            Vector3 snapPos = new Vector3(xIdx * cellSize + (cellSize / 2), 0, zIdx * cellSize + (cellSize / 2));

            // 미리보기 위치 업데이트 및 색상 변경 함수 호출
            HandlePreview(snapPos, xIdx, zIdx);

            // 마우스 왼쪽 클릭 시 해당 위치에 실제 설치 시도
            if (Input.GetMouseButtonDown(0))
            {
                PlaceAt(snapPos, xIdx, zIdx);
            }
        }
        else
        {
            // 바닥을 벗어나면 미리보기를 숨김
            if (previewInstance != null)
            {
                previewInstance.SetActive(false);
            }
        }

        /// <summary>
        /// 미리보기 오브젝트를 마우스 위치로 이동시키고, 설치 가능 여부에 따라 색상을 변경하는 메서드
        /// </summary>
        void HandlePreview(Vector3 pos, int x, int z)
        {
            if (previewInstance == null)
            {
                return;
            }
            previewInstance.SetActive(true);
            previewInstance.transform.position = pos;

            // GridManager에게 현재 칸이 비어있는지 확인 요청
            bool canPlace = gridManager.CanPlace(x, z);

            // 시각적 피드백: 가능하면 초록색(Green), 불가능하면 빨간색(Red) 반투명 처리
            MeshRenderer mr = previewInstance.GetComponentInChildren<MeshRenderer>();
            if(mr != null)
            {
                mr.material.color = canPlace ? new Color(0, 1, 0, 0.5f) : new Color(1, 0, 0, 0.5f);
            }
        }

        /// <summary>
        /// 최종적으로 GridManager의 승인을 받아 실제 오브젝트를 생성하고 점유 데이터를 기록하는 메서드
        /// </summary>
        void PlaceAt(Vector3 pos, int x, int z)
        {
            if (gridManager.CanPlace(x, z))
            {
                // 실제 오브젝트 생성
                Instantiate(realPrefab, pos, Quaternion.identity);

                // GridManager에 해당 좌표가 점유되었음을 기록 (중복 설치 방지)
                gridManager.PlaceObject(x, z);

                Debug.Log($"[{x}, {z}] 위치에 설치 성공");
            }
            else
            {
                Debug.Log("설치 불가 지역");
            }
        }
    }
}

GridManager 스크립트 생성 후 GridManager 오브젝트에 부착

using UnityEngine;

/// <summary>
/// 농장의 물리적인 공간 데이터를 관리하는 클래스
/// 어떤 타일이 비어있고, 어떤 타일이 점유되었는지를 2차원 배열로 저장함
/// </summary>
public class GridManager : MonoBehaviour
{
    [Header("농장 크기 설정")]
    public int width = 10;  // 가로 타일 개수
    public int height = 10; // 세로 타일 개수

    // 타일의 점유 상태를 저장하는 2차원 배열 데이터 구조
    // false : 비어있음 (설치 가능)
    // true  : 이미 무언가 설치됨 (설치 불가)
    private bool[,] isOccupied;

    /// <summary>
    /// 게임 시작 시 설정된 크기에 맞춰 배열 메모리를 할당
    /// </summary>
    private void Awake()
    {
        // width x height 크기의 바둑판 모양 데이터를 생성
        isOccupied = new bool[width, height];
    }

    /// <summary>
    /// 특정 좌표(x, z)에 오브젝트를 설치할 수 있는지 검사
    /// </summary>
    /// <param name="x">검사할 그리드 X 인덱스</param>
    /// <param name="z">검사할 그리드 Z 인덱스</param>
    /// <returns>설치 가능하면 true, 불가능하면 false</returns>
    public bool CanPlace(int x, int z)
    {
        // 인덱스가 맵의 범위를 벗어났는지 먼저 체크 (배열 오류 방지)
        if (x < 0 || x >= width || z < 0 || z >= height)
        {
            return false; // 맵 밖은 설치 불가
        }

        // 해당 칸의 점유 상태를 반전시켜 반환
        // !isOccupied[x, z] 의 의미:
        // 점유(true)면 설치불가(false) 반환, 비었으면(false) 설치가능(true) 반환
        return !isOccupied[x, z];
    }

    /// <summary>
    /// 오브젝트 설치가 확정되었을 때 해당 칸의 상태를 '점유됨'으로 변경
    /// </summary>
    /// <param name="x">점유할 그리드 X 인덱스</param>
    /// <param name="z">점유할 그리드 Z 인덱스</param>
    public void PlaceObject(int x, int z)
    {
        // 맵 범위 안일 때만 실행
        if (x >= 0 && x < width && z >= 0 && z < height)
        {
            isOccupied[x, z] = true;
            Debug.Log($"데이터 업데이트: [{x}, {z}] 지점이 점유되었습니다.");
        }
    }
}

농장 크기는 Plane에 그리드 개수만큼 해줌(저는 2,2,2 크기로해서 20, 20이 나옴)

0,0,0 위치가 맨 아래가도록 배치해주고 Plane의 Layer를 Ground로 설정해주면 끝
왜 이럿케 하느냐
x,z 좌표가 양수인 부분만 배치가 가능하게 해둠

왜"???

가장 큰 이유는 배열(GridManager의 tileTypes)의 인덱스는 음수가 될 수 없기 때문입니다.

배열의 한계: GridManager에서 사용하는 new TileType[width, height]는 0부터 시작하는 '방 번호'를 가집니다. 컴퓨터 과학에서 배열 인덱스는 항상 0 또는 양수입니다.
좌표 변환 방식: 현재 GridSystem에서 인덱스를 계산할 때 아래 공식을 사용합니다.
int xIdx = Mathf.FloorToInt(hit.point.x / cellSize);

만약 hit.point.x가 -5.0이고 cellSize가 10이라면, 결과는 -1이 됩니다.
코드의 방어막: GridManager의 CanPlace 함수에는 아래와 같은 조건문이 있습니다.
if (x < 0 || x >= width ...)
여기서 음수 인덱스(-1)가 들어오면, 배열 오류(IndexOutOfRangeException)를 막기 위해 무조건 false(배치 불가)를 반환하도록 설계되어 있습니다.

라는 제미나이의 답변,, 요거는 좀 더 공부를 해바야겟음

3. 결과


클릭하면 밭이 배치되고, 배치할 수 없는(이미 점유된)곳에 배치하려고 하면 빨간색으로 표시,

가능한 곳은 초록색으로 표시되게 함

4. 지식 +

🔷 TryGetComponent?

💡 GameObject에 존재하는 경우 지정된 유형의 컴포넌트를 검색하려고 시도하고, 발견되면 true, 발견되지 않으면 false를 반환한다.

public bool TryGetComponent<T>(out T component) where T : Component;

T: 가져오려는 컴포넌트의 타입
component: 컴포넌트를 가져올 때 사용되는 out 매개변수

🔷Physics.Raycast

💡 Ray가 어떤 Collider에 맞으면 true를 반환하고, 그 충돌 정보는 hit에 담긴다.

Physics.Raycast(ray, out hit, Mathf.Infinity, groundLayer)

: groundLayer에 맞으면 hit에 정보가 담기고 길이는 무한

💡 기술적 포인트

  1. 데이터 무결성 (Data Integrity):
  • 시각적인 오브젝트(GameObject)는 파괴되거나 오류가 날 수 있지만, GridManagerbool 배열은 현재 농장 상태의 절대적인 기준이 됨
  1. 연산 효율성:
  • 이미 설치된 게 있나?를 알기 위해 Physics.OverlapBox 같은 무거운 물리 연산을 쓰지 않고, 단순한 배열 인덱스 참조(O(1))만으로 판별하기 때문에 매우 빠름
  1. 예외 처리 능력:
  • if (x < 0 || x >= width ...)와 같은 조건문을 통해 배열 인덱스 초과 에러(IndexOutOfRangeException)를 사전에 방지

1개의 댓글

comment-user-thumbnail
2026년 1월 20일

유익해요

답글 달기