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

굥지·2026년 1월 27일

3D 농사게임

목록 보기
2/2

1월 19일에 개발한걸 이제야 씀니다..

1. 초기 설정

오늘은 밭을 내마음대로 배치했다면 그 위에 씨앗을 심을 예정입니다

씨앗 - 새싹 - 열매 - 성체 순으로 점점 커지는 느낌으로 아래 에셋을 다운 받아요

https://assetstore.unity.com/packages/3d/environments/industrial/lite-farm-pack-low-poly-3d-art-by-gridness-243315

일단 버튼으로 밭-토마토 중 하나를 골라서 심어야하니까 TmpText 먼저 만들어주기

32-126,44032-55203,12593-12643,8200-9900

저번에 한 밭을 투명하게 한 것 처럼 씨앗도 이렇게 해줌

2. 결과

씬뷰 확대하니까 도트같아서 귀여운데 ㅋ

일단 요런식으로 밭 - 토마토 버튼이 있어서 무조건 밭 위에 심을 수 있게 하려고 함

3. 작물 설정

Crop이라는 빈 오브젝트를 만들어주고

using UnityEngine;

public class Crop : MonoBehaviour
{
    private CropData data;
    private int currentState = 0;
    private GameObject currentModel;

    public void Initialize(CropData cropData)
    {
        data = cropData;
        currentState = 0; // 0단계부터 시작 보장

        // 데이터가 정상인지 검사
        if (data == null || data.growthStagePrefabs == null || data.growthStagePrefabs.Length == 0)
        {
            Debug.LogError("CropData 또는 성장 프리팹 배열이 비어있습니다.");
            return;
        }

        UpdateModel();

        // Invoke 지연 시간 재확인 (0이면 실행 안됨)
        float interval = data.timeBetweenStages;
        if (interval <= 0) interval = 2.0f; // 기본값 방어 코드

        // 기존 예약된 Grow가 있다면 취소 후 재등록
        CancelInvoke(nameof(Grow));
        InvokeRepeating(nameof(Grow), interval, interval);

        Debug.Log($"{data.cropName} 성장 시작. 주기: {interval}초");
    }

    void Grow()
    {
        if (currentState < data.growthStagePrefabs.Length - 1)
        {
            currentState++;
            UpdateModel();
            Debug.Log($"{gameObject.name}가 {currentState}단계로 성장했습니다.");
        }
        else
        {
            CancelInvoke("Grow");
        }
    }
    
    void UpdateModel()
    {
        if(currentModel != null) Destroy(currentModel);

        // 자식으로 생성하되, 생성된 모델의 Local Position을 0으로 맞춤
        currentModel = Instantiate(data.growthStagePrefabs[currentState], transform);
        currentModel.transform.localPosition = Vector3.zero;
        currentModel.transform.localRotation = Quaternion.identity;
    }
}
using UnityEngine;

[CreateAssetMenu(fileName = "CropData", menuName = "Scriptable Objects/CropData")]
public class CropData : ScriptableObject
{
    public string cropName;
    public GameObject[] growthStagePrefabs; // 4단계 프리팹(씨앗-새싹-성장-수확)
    public float timeBetweenStages; //단계별 성장 시간
}

Scriptable Objects로 CropData도 만들어줌

GridManager 스크립트 수정

using UnityEngine;

public enum TileType { Empty, Mud, Crop };

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

    private TileType[,] tileTypes; // bool 대신 TileType 사용

    /// <summary>
    /// 게임 시작 시 설정된 크기에 맞춰 배열 메모리를 할당
    /// </summary>
    private void Awake()
    {
        tileTypes = new TileType[width, height];
    }

    /// <summary>
    /// 특정 좌표(x, z)에 오브젝트를 설치할 수 있는지 검사
    /// </summary>
    /// <param name="x">검사할 그리드 X 인덱스</param>
    /// <param name="z">검사할 그리드 Z 인덱스</param>
    /// <returns>해당 칸이 Empty일때만 true, 불가능하면 false</returns>
    public bool CanPlace(int x, int z)
    {
        // 맵 범위 체크
        if (x < 0 || x >= width || z < 0 || z >= height)
        {
            return false;
        }

        // 해당 칸이 Empty 상태일 때만 true 반환
        // (isOccupied 배열을 삭제했으므로 tileTypes만 확인하면 됩니다)
        return tileTypes[x, z] == TileType.Empty;
    }

    /// <summary>
    /// 오브젝트 설치가 확정되었을 때 데이터 갱신
    /// </summary>
    public void PlaceObject(int x, int z, TileType type)
    {
        // 맵 범위 안일 때만 실행
        if (x >= 0 && x < width && z >= 0 && z < height)
        {
            tileTypes[x, z] = type;
            Debug.Log($"<color=yellow>데이터 갱신:</color> [{x},{z}] 번지 타일이 {type} 상태가 되었습니다.");
        }
    }

    /// <summary>
    /// 현재 타일의 타입을 반환하는 함수
    /// </summary>
    public TileType GetTileType(int x, int z)
    {
        if(x < 0 || x >= width || z < 0 || z>=height) return TileType.Empty;
        return tileTypes[x, z];
    }
}

GridSystem 수정

using System;
using UnityEditor.ShaderGraph;
using UnityEngine;

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

    [Header("밭 설정")]
    [SerializeField] private GameObject mudPreviewPrefab;   // 설치 전 보여줄 투명한 미리보기용 프리팹
    [SerializeField] private GameObject mudPrefab;      // 실제로 설치될 완성된 오브젝트 프리팹

    [Header("작물 설정")]
    [SerializeField] private GameObject previewCropPrefab;
    [SerializeField] private GameObject cropPrefab;    // Crop 스크립트가 붙어있는 빈 오브젝트 프리팹

    [Header("선택된 상태")]
    [SerializeField] private bool isMudSelect = false; // 기본적으로 아무것도 선택X
    [SerializeField] private CropData currentSelectedCrop; // 선택된 씨앗 데이터

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

    void Start()
    {
       /* // 미리보기 인스턴스를 생성하고 초기 설정 진행
        if (mudPreviewPrefab != null)
        {
            previewInstance = Instantiate(mudPreviewPrefab);
            mudPreviewPrefab.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));
            
            // 모드에 따른 높이 결정
            // 밭 설치 모드면 0, 작물 설치 모드면 0.16f
            float targetY = isMudSelect ? 0f : 0.16f;

            // 프리뷰 처리 (targetY 반영)
            HandlePreview(snapPos, targetY, xIdx, zIdx);

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

    /// <summary>
    /// 선택된 모드에 맞춰 미리보기(Preview) 모델을 교체합니다.
    /// </summary>
    void UpdatePreviewInstance(GameObject newPrefab)
    {
        // 기존 미리보기가 있다면 삭제
        if(previewInstance != null)
        {
            Destroy(previewInstance); 
        }
        //OnClickSelectCrop 새로운 프리팹으로 생성
        if (newPrefab != null)
        {
            // 원본 프리팹을 복제하여 previewInstance 변수에 할당
            previewInstance = Instantiate(newPrefab);
            previewInstance.SetActive(false);

            // 레이캐스트 방해 방지 (콜라이더 끄기)
            if (previewInstance.TryGetComponent<Collider>(out Collider col))
                col.enabled = false;
        }
    }

    /// <summary>
    /// 밭 선택 버튼
    /// </summary>
    public void OnClickSelectMud()
    {
        isMudSelect = true;
        currentSelectedCrop = null;
        // 미리보기를 밭용으로 교체
        UpdatePreviewInstance(mudPreviewPrefab);
        Debug.Log("밭 선택 모드");
    }

    public void OnClickSelectCrop(CropData cropdata)
    {
        isMudSelect = false;
        currentSelectedCrop = cropdata;
        // 미리보기를 작물용으로 교체
        UpdatePreviewInstance(previewCropPrefab);
        Debug.Log($"{cropdata.cropName} 씨앗 선택");
    }

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

        // 아무 모드도 선택되지 않았다면 프리뷰를 끄고 리턴
        if (!isMudSelect && currentSelectedCrop == null)
        {
            previewInstance.SetActive(false);
            return;
        }
        pos.y = y;
        previewInstance.SetActive(true);
        previewInstance.transform.position = pos;

        if (gridManager == null) return;

        bool canPlaceVisually = false;
        if (isMudSelect)
        {
            //밭 설치 모드 : 해당 칸이 Empty여야 초록색
            canPlaceVisually = gridManager.CanPlace(x, z);
        }
        else if (currentSelectedCrop != null)
        {
            //씨앗 심기 모드 : 해당 칸이 Mud 여야 초록색(이미 Crop인 곳은 false가 됨)
            canPlaceVisually = (gridManager.GetTileType(x, z) == TileType.Mud);
        }

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

    /// <summary>
    /// 최종적으로 GridManager의 승인을 받아 실제 오브젝트를 생성하고 점유 데이터를 기록하는 메서드
    /// </summary>
    void PlaceAt(Vector3 pos, int x, float y, int z)
    {
        // 밭 설치 모드일 때
        if (isMudSelect && gridManager.CanPlace(x, z))
        {
            pos.y = 0f; // 밭은 무조건 바닥에 설치
            Instantiate(mudPrefab, pos, Quaternion.identity);
            gridManager.PlaceObject(x, z, TileType.Mud);
            Debug.Log($"[{x}, {z}] 위치에 밭 설치 성공");
        }
        // 작물 설치 모드 (현재 칸이 정확히 Mud 상태여야만 함)
        // 만약 이미 작물을 심어서 TileType.Crop으로 변했다면, GetTileType은 Mud가 아니게 됨
        else if (!isMudSelect && currentSelectedCrop != null)
        {
            TileType currentTile = gridManager.GetTileType(x, z);
            if (gridManager.GetTileType(x, z) == TileType.Mud)
            {
                pos.y = 0.16f;
                PlantCrop(pos, x, z);
            }
            else if (currentTile == TileType.Crop)
            {
                Debug.Log("여기는 이미 작물이 자라고 있습니다!"); // 이 로그가 찍히는지 확인하세요.
            }
            else
            {
                Debug.Log("밭이 아닌 곳에는 심을 수 없습니다.");
            }
        }
    }

    void PlantCrop(Vector3 pos, int x, int z)
    {
        // 작물 프리팹 생성
        GameObject newCrop = Instantiate(cropPrefab, pos, Quaternion.identity);

        gridManager.PlaceObject(x, z, TileType.Crop);

        // 생성된 작물에 Crop 스크립트를 가져와 데이터 전달
        if (newCrop.TryGetComponent<Crop>(out Crop crop))
        {
            crop.Initialize(currentSelectedCrop);
            Debug.Log($"{currentSelectedCrop.cropName}을 심었습니다/");
        }

        else
        {
            Debug.LogError($"cropPrefab 루트에 Crop 컴포넌트가 없습니다! 프리팹을 확인하세요: {newCrop.name}");
            gridManager.PlaceObject(x, z, TileType.Mud);
            Destroy(newCrop);
        }
    }
}

4. 스크립트 역할 정리

GridSystem.cs (입력 및 로직 제어기)

  • 역할: 플레이어의 마우스 입력을 게임 내 좌표로 변환하고, 현재 무엇을 들고 있는지(밭 or 씨앗)에 따라 적절한 행동을 지시
  • 핵심 기능:
    • Raycasting: 3D 공간의 바닥을 감지하여 그리드 좌표를 산출
    • State Management: isMudSelectcurrentSelectedCrop 변수를 통해 현재 모드를 관리함
    • Preview Switching: 모드 변경 시 기존 미리보기를 삭제하고 새로운 모델을 생성하여 시각적 피드백을 제공

GridManager.cs (데이터 관리자)

  • 역할: 농장의 모든 타일 상태를 2차원 배열(TileType[,])로 저장하는 데이터 장부
  • 핵심 기능:
    • Data Integrity: PlaceObject를 통해 설치된 객체의 타입을 기록하여 중복 설치를 방지하고 작물 설치 조건을 검사
    • Encapsulation: 데이터를 직접 수정하지 않고 CanPlace, GetTileType 등의 함수를 통해서만 접근하게 하여 버그를 최소화

Crop.cs & CropData.cs (객체 및 데이터 구조)

  • 역할: CropData는 작물의 정보를 담는 설계도이며, Crop은 실제 월드에서 시간에 따라 자라나는 식물임
  • 핵심 기능:
    • Data-Driven Design: ScriptableObject를 사용하여 기획 데이터(성장 시간, 메쉬 등)를 코드와 분리
    • Recursive Update: InvokeRepeating을 사용하여 정해진 주기마다 성장 단계를 갱신하고 모델을 교체

0개의 댓글