벌목 시스템
벌목 시스템은 플레이어가 나무를 베어 목재 자원을 획득할 수 있는 채집 시스템이다.
나무가 쓰러지면 일정량의 목재 아이템이 드랍되며, 일정 시간이 지나면 나무가 원래 위치에서 다시 생성되어 반복적으로 채집할 수 있다.
목차
1. 유니티 구현
1.1. 나무 물리 처리
1.2. 나뭇잎 Material 제작
1.3. Particle System을 이용한 나뭇잎 연출
2. 전체 코드
유니티 구현
1.1. 나무 물리 처리
벌목 시스템에서는 나무를 베었을 때 뿌리 부분만 남고 줄기 부분이 쓰러지는 연출을 구현하기 위해, 나무 오브젝트를 하나의 Mesh가 아닌 뿌리(root)와 줄기(trunk)로 분리된 구조의 모델을 사용하였다.

뿌리 부분은 지면에 고정되어 있어야 하기 때문에 MeshCollider만 적용하였다.

반면 줄기 부분은 벌목 시 실제로 넘어지는 물리 연출이 필요하므로 MeshCollider와 Rigidbody 컴포넌트를 적용하였다.
MeshCollider는 Mesh 형태를 그대로 충돌 영역으로 사용하는 Collider로, 복잡한 형태의 오브젝트에서도 정확한 충돌 판정을 제공한다.
벌목 시스템에서는 나무의 형태가 단순한 박스나 캡슐이 아니기 때문에, 나무 모델의 실제 형태를 기준으로 충돌을 처리하기 위해 MeshCollider를 사용하였다.
Rigidbody는 Unity 물리 엔진에서 물리 연산의 대상이 되는 컴포넌트로, 중력, 충돌 반응, 속도 변화 등을 자동으로 계산한다.
나무가 베어졌을 때 Rigidbody의 중력을 활성화하여 줄기가 자연스럽게 지면으로 떨어지도록 구성하였다.
이를 통해 단순 애니메이션이 아니라 실제 물리 기반으로 나무가 쓰러지는 연출을 구현할 수 있다.
1.2. 나뭇잎 Meterial 제작
파티클에 사용할 나뭇잎 Material을 제작하였다.
일반적인 텍스처를 그대로 사용할 경우 이미지의 배경 영역까지 함께 렌더링되기 때문에, 잎 모양만 보이도록 하기 위해 투명 처리를 지원하는 Shader를 적용하였다.
이를 위해 Alpha Blend 방식의 커스텀 Shader를 사용하였다.
Shader "Custom/AlphaBlend"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_Color ("Color", Color) = (1,1,1,1)
}
SubShader
{
Tags { "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" }
LOD 200
Blend SrcAlpha OneMinusSrcAlpha // 알파 블렌딩 설정
CGPROGRAM
#pragma surface surf Lambert alpha // 투명도 속성 사용
struct Input
{
float2 uv_MainTex;
};
sampler2D _MainTex;
fixed4 _Color;
void surf (Input IN, inout SurfaceOutput o)
{
fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; // 텍스처와 색상을 blend
o.Albedo = c.rgb;
o.Alpha = c.a; // 투명도 설정
}
ENDCG
}
FallBack "Diffuse"
}
이 Shader는 텍스처의 알파값을 이용해 투명도를 처리하는 방식으로 동작한다.
텍스처의 색상 정보는 Albedo로 적용하고, 알파 채널 값은 Alpha 값으로 전달하여 투명한 영역은 렌더링되지 않도록 구성하였다.
이 방식은 잎 이미지의 배경을 자연스럽게 제거할 수 있으며, 파티클로 사용했을 때 실제 잎사귀처럼 보이도록 만들어준다.

Material을 생성한 후 Shader를 AlphaBlend로 설정하면 Inspector에서 텍스처와 색상을 설정할 수 있는 속성이 표시된다.


여기서 Texture 슬롯에 잎 이미지를 적용하고 Color 값을 조절하면 파티클에 적용될 잎의 색상을 쉽게 조정할 수 있다.
이렇게 만든 Material을 이후 파티클 시스템의 렌더러에 적용하여 낙엽 효과에 사용하였다.
1.3. Particle System을 이용한 나뭇잎 연출

벌목 시스템에서는 나무가 쓰러지는 물리 연출뿐만 아니라, 주변 환경과 어울리는 시각 효과가 필요하다고 생각했다.
이를 위해 Unity의 Particle System을 사용하여 나뭇잎이 자연스럽게 떨어지는 환경 연출을 구현하였다.
Particle System은 동일한 오브젝트를 다수 생성하여 특정 패턴의 움직임을 만드는 시각 효과 시스템으로, 눈, 연기, 불꽃, 낙엽과 같은 자연 현상을 표현할 때 주로 사용된다.
파티클 시스템은 나무 오브젝트의 잎 부분 자식 오브젝트로 배치하였다.
이를 통해 나무 위치를 기준으로 낙엽이 생성되도록 하였으며, 벌목 시 주변 환경과 자연스럽게 어우러지는 시각 효과를 만들 수 있었다.

파티클 시스템은 다수의 작은 오브젝트를 생성하여 특정 방향과 속도로 움직이게 할 수 있기 때문에, 낙엽이 떨어지는 자연 현상을 표현하기에 적합하다.
생성한 파티클 시스템에는 앞서 만든 잎 Material을 적용하여 각각의 파티클이 나뭇잎 형태로 렌더링되도록 설정하였다.
낙엽이 자연스럽게 떨어지는 효과를 만들기 위해 파티클 시스템의 여러 속성을 조정하였다.
먼저 Transform의 Rotation 범위를 설정하여 파티클이 생성될 때 다양한 각도를 가지도록 하였다.
이를 통해 모든 나뭇잎이 동일한 방향으로 떨어지는 단조로운 움직임을 방지하고, 각각의 잎이 서로 다른 방향으로 회전하며 떨어지는 효과를 만들 수 있다.
Particle System의 Prewarm 옵션도 활성화하였다.
Prewarm은 파티클 시스템이 시작될 때 이미 일정 시간 동안 시뮬레이션이 진행된 상태로 시작하도록 만드는 기능이다.
이를 사용하지 않으면 파티클 시스템이 처음 실행될 때 잎이 갑자기 생성되는 느낌이 들 수 있는데, Prewarm을 활성화하면 시작 시점부터 이미 낙엽이 떨어지고 있는 것처럼 자연스럽게 보이게 된다.
또한 Looping 옵션을 활성화하여 파티클이 한 번만 생성되는 것이 아니라 지속적으로 생성되도록 설정하였다.
이를 통해 나무 주변에서 계속해서 낙엽이 떨어지는 환경 효과를 유지할 수 있다.
파티클의 크기와 회전값은 모두 Random Between Two Constants 방식으로 설정하였다.
이는 파티클이 생성될 때 지정된 범위 안에서 랜덤한 값을 가지도록 하는 옵션이다.
Start Size를 랜덤 범위로 설정하면 나뭇잎 크기가 조금씩 다르게 생성되어 자연스러운 다양성을 만들 수 있다.
Start Rotation 역시 랜덤 값으로 설정하여 잎이 떨어질 때 서로 다른 방향으로 회전하도록 하였다.
낙엽이 천천히 떨어지는 느낌을 표현하기 위해 Gravity Modifier를 낮은 값으로 설정하였다.
기본 중력보다 약한 중력을 적용함으로써 잎이 빠르게 떨어지지 않고 천천히 낙하하는 효과를 만들 수 있다.
또한 Simulation Speed를 낮추어 전체 파티클 시뮬레이션 속도를 줄였는데, 이는 낙엽의 움직임을 더욱 부드럽고 느리게 보이도록 만드는 역할을 한다.
파티클의 움직임이 단순히 직선 낙하로 보이지 않도록 Noise 모듈도 사용하였다.
Noise는 파티클의 이동 경로에 무작위 변화를 추가하는 기능으로, 바람에 흔들리는 듯한 움직임을 표현할 수 있다.
각 축에 다른 강도를 설정하여 특정 방향으로 더 많이 흔들리도록 조정하였으며, 낮은 Frequency 값을 사용하여 천천히 변화하는 부드러운 흔들림을 만들었다.
이러한 설정을 통해 나뭇잎이 단순히 떨어지는 것이 아니라 공중에서 약간씩 흔들리며 낙하하는 자연스러운 움직임을 연출할 수 있다.
또한 Collision 모듈을 활성화하여 파티클이 특정 평면에 닿으면 멈추도록 설정하였다.
이를 통해 낙엽이 바닥에 도달했을 때 튕기지 않고 자연스럽게 멈추는 효과를 만들 수 있다.
Bounce 값을 0으로 설정하여 반동을 제거하고, Dampen 값을 적용해 충돌 이후 속도가 자연스럽게 줄어들도록 구성하였다.
마지막으로 Force Over Lifetime 설정을 통해 파티클의 회전 속도가 시간이 지날수록 감소하도록 조정하였다.
낙엽은 떨어질 때 처음에는 빠르게 회전하지만 점점 회전 속도가 줄어드는 특성이 있기 때문에, Angular Velocity를 시간에 따라 감소하도록 설정하여 이러한 자연스러운 움직임을 표현하였다.
완성된 파티클 시스템은 나무 오브젝트의 잎 부분에 자식 오브젝트로 배치하였다.
이렇게 하면 나무의 위치를 기준으로 낙엽 효과가 생성되며, 나무 주변에서 잎이 떨어지는 환경 연출을 자연스럽게 구현할 수 있다.
이러한 방식은 단순한 오브젝트 애니메이션보다 훨씬 자연스러운 환경 효과를 만들 수 있으며, 벌목 시스템의 시각적 완성도를 높이는 데 중요한 역할을 한다.
전체 코드
* InteractData클래스에서 벌목 관련 부분만 코드를 짬
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class InteractData : MonoBehaviour
{
Animator ani;
Item item;
public Slider GSP; //채집 게이지
public GameObject GSPBar; //채집 게이지
public TextMeshProUGUI GSPTxt; //채집 중을 나타내는 텍스트
public TextMeshProUGUI GSTxt; //채집 속도 텍스트
public GameObject interactKey; //상호 작용 키
public TextMeshProUGUI interactText; //상호작용 키와 상호작용을 나타내는 텍스트
public TextMeshProUGUI EquipTxt; //채집을 위한 도구를 들고있지 않으면 출력하는 텍스트
public static InteractData instance;
public MineCopperObj copperObj;
public MineSteelObj steelObj;
[HideInInspector]
public bool isUIOpen = false;
bool isKeyPress = false;
public float disPlayTime = 2.0f;
PlayerStatus playerStatus;
private void Awake()
{
instance = this;
}
void Start()
{
playerStatus = FindObjectOfType<PlayerStatus>();
ani = GetComponent<Animator>();
EquipTxt.enabled = false;
}
void Update()
{
GSP.maxValue = playerStatus.GSP;
RaycastHit hit;
if (Physics.Raycast(transform.position + new Vector3(0f, 0.5f, 0f), transform.forward, out hit, 3f, LayerMask.GetMask("Interact")) && !isUIOpen)
{
//Debug.Log(hit.transform.name);
if (hit.collider != null)
{
if (hit.collider.tag == "Mining")
{
interactText.text = "채광하기(F)";
ShowInteractKey(hit.collider.transform.position + Vector3.up);
if (Input.GetKeyDown(KeyCode.F))
{
if (WeaponChange.instance.pick.activeSelf)
{
isKeyPress = true;
GSPTxt.text = "곡괭이를 내려찍는 중...";
InteractGSP(hit.collider.transform.position + Vector3.up);
}
else
{
EquipTxt.text = "[2]번을 눌러 곡괭이로 교체해주세요";
StartCoroutine(EquipChangeText());
}
}
}
if (hit.collider.tag == "Pc" && !isUIOpen)
{
interactText.text = "컴퓨터 사용(F)";
ShowInteractKey(hit.collider.transform.position + Vector3.up);
if (Input.GetKeyDown(KeyCode.F))
{
HideInteractKey();
UiManager_.instance.ShopUI.SetActive(true);
isUIOpen = true;
}
}
if (hit.collider.tag == "Tree")
{
interactText.text = "벌목하기(F)";
ShowInteractKey(hit.collider.transform.position + Vector3.up);
if (Input.GetKeyDown(KeyCode.F))
{
if (WeaponChange.instance.axe.activeSelf)
{
isKeyPress = true;
GSPTxt.text = "도끼를 휘두르는 중...";
InteractGSP(hit.collider.transform.position + Vector3.up);
}
else
{
EquipTxt.text = "[1]번을 눌러 도끼로 교체해주세요";
StartCoroutine(EquipChangeText());
}
}
}
if (hit.collider.tag == "Enhance")
{
interactText.text = "작업대 사용(F)";
ShowInteractKey(hit.collider.transform.position + Vector3.up);
if (Input.GetKeyDown(KeyCode.F))
{
isUIOpen = true;
HideInteractKey();
UiManager_.instance.EnhanceUI.SetActive(true);
}
}
if (hit.collider.tag == "Door")
{
interactText.text = "문열기(F)";
ShowInteractKey(hit.collider.transform.position + Vector3.up);
if (Input.GetKeyDown(KeyCode.F))
{
hit.transform.GetComponent<DoorScript>().StartCoroutine(hit.transform.GetComponent<DoorScript>().DoorOpen());
}
}
if (hit.collider.tag == "Map")
{
interactText.text = "지도 보기(F)";
ShowInteractKey(hit.collider.transform.position + Vector3.up);
if (Input.GetKeyDown(KeyCode.F))
{
HideInteractKey();
hit.transform.GetComponent<TeleportManager>().OnMap();
}
}
}
if (isKeyPress && hit.collider.tag == "Mining")
{
GSPBarTime(hit.collider.gameObject);
}
if (isKeyPress && hit.collider.tag == "Tree")
{
GSPBarTimeTree(hit.collider.gameObject);
}
}
else
{
HideInteractKey();
if (GSPBar.activeSelf && isKeyPress)
{
GSP.value = 0;
GSPBar.SetActive(false);
isKeyPress = false;
ani.SetBool("Mining", false);
ani.SetBool("Logging", false);
}
WarningHeadLight();
}
}
public void ShowInteractKey(Vector3 position)
{
Vector3 viewPoint = Camera.main.WorldToScreenPoint(position);
interactKey.transform.position = viewPoint;
interactKey.gameObject.SetActive(true);
}
void HideInteractKey()
{
interactKey.gameObject.SetActive(false);
}
void InteractGSP(Vector3 position)
{
Vector3 viewPoint = Camera.main.WorldToScreenPoint(position);
GSPBar.transform.position = viewPoint;
GSPBar.SetActive(true);
}
void GSPBarTime(GameObject obj)
{
interactKey.gameObject.SetActive(false); //상호 작용 키 가리기
ani.SetBool("Mining", true);
StartCoroutine(PlayActionSound(SoundManager.instance.miningClips, 0.55f));
GSP.value += Time.deltaTime; //슬라이더 값을 실제 시간으로
GSTxt.text = (int)GSP.value + " / " + (int)GSP.maxValue + "초";
if (GSP.value >= GSP.maxValue)
{
GSP.value = 0f; //슬라이더 값을 0으로 초기화
ani.SetBool("Mining", false);
MineItmeData mineItmeData = obj.GetComponent<MineItmeData>();
Destroy(obj); //채집이 끝난 오브젝트 파괴
GSPBar.SetActive(false); //채집 시간을 보여주는 오브젝트 비활성화
isKeyPress = false; //false로 다시 초기화
AddInvenMineItem(obj);
StatusManager.instance.CurrentBt -= 20;
if (mineItmeData.item.itemName == "철")
{
steelObj.curCount--;
}
if (mineItmeData.item.itemName == "구리")
{
copperObj.curCount--;
}
}
}
void GSPBarTimeTree(GameObject obj)
{
interactKey.gameObject.SetActive(false); //상호 작용 키 가리기
ani.SetBool("Logging", true);
StartCoroutine(PlayActionSound(SoundManager.instance.loggingClips, 0.65f, obj));
GSP.value += Time.deltaTime; //슬라이더 값을 실제 시간으로
GSTxt.text = (int)GSP.value + " / " + (int)GSP.maxValue + "초";
if (GSP.value >= GSP.maxValue)
{
GSP.value = 0f; //슬라이더 값을 0으로 초기화
ani.SetBool("Logging", false);
obj.GetComponent<Tree>().Felled();
GSPBar.SetActive(false); //채집 시간을 보여주는 오브젝트 비활성화
isKeyPress = false; //false로 다시 초기화
}
}
IEnumerator EquipChangeText()
{
EquipTxt.enabled = true;
yield return new WaitForSeconds(disPlayTime);
EquipTxt.enabled = false;
}
void AddInvenMineItem(GameObject obj)
{
MineItmeData mineItmeData = obj.GetComponent<MineItmeData>();
if (mineItmeData != null)
{
item = mineItmeData.item;
UiManager_.instance.MineItem(item, mineItmeData.count);
}
}
void WarningHeadLight() //헤드라이트 강화 이전에 사용 시도시 경고 문구 출력 함수
{
if (!GameManager.instance.isGetLight && Input.GetKeyDown(KeyCode.E))
{
EquipTxt.text = "헤드라이트를 강화하지 않아\n사용할 수 없습니다";
StartCoroutine(EquipChangeText());
}
}
bool isPlayingActionSound = false;
IEnumerator PlayActionSound(AudioClip[] clips, float delay, GameObject hitObj = null)
{
if (isPlayingActionSound) yield break;
isPlayingActionSound = true;
yield return new WaitForSeconds(delay);
if (clips != null && clips.Length > 0)
{
// SoundManager의 배열과 볼륨 사용
SoundManager.instance.PlaySfx(
transform.position,
clips[Random.Range(0, clips.Length)],
0,
SoundManager.instance.sfxVolum
);
}
// 벌목(Logging)일 때만 나무 흔들림 연출 적용
if (hitObj != null && hitObj.CompareTag("Tree"))
{
hitObj.transform.rotation *= Quaternion.Euler(0.3f, 0, 0.3f);
}
isPlayingActionSound = false;
}
}
using UnityEngine;
public class Tree : MonoBehaviour
{
Rigidbody rb;
MeshCollider cd;
float RegenTime = 10f;
float RegenElapsedTime = 0;
bool regenerate = false;
public Vector3 startPos; // 처음 생성될 때 위치 저장
public Item woodItem; // 드랍 아이템 설정
private void Awake()
{
rb = GetComponent<Rigidbody>();
cd = GetComponent<MeshCollider>();
// 처음 위치 저장
startPos = transform.position;
}
public void Felled()
{
RegenElapsedTime = 0;
cd.isTrigger = false;
rb.useGravity = true;
rb.isKinematic = false;
}
// 재생성 시작 함수
void StartRegeneration()
{
// 재생 중이 아니라면
if (!regenerate)
{
regenerate = true; // 재생 중 표시
InvokeRepeating("Regenerate", 0f, 3.0f); // 재생성 함수를 0초 후 3초마다 반복
// Invoke가 아니라 Coroutine을 썼을 때 제대로 동작하지 않는 오류가 생김
// yield return new WaitForSecondsRealtime을 써도 해결되지 않아서 인보크를 사용함
}
}
// 재생성 종료 함수
void StopRegeneration()
{
// 재생 중이라면
if (regenerate)
{
regenerate = false; // 재생 취소 표시
CancelInvoke("Regenerate"); // 재생성 함수를 취소함
}
}
// 재생성 함수
void Regenerate()
{
RegenElapsedTime += 1;
if (RegenElapsedTime >= RegenTime) // 만약 현재 진행도가 재생성 진행도 이상이라면
{
RegenElapsedTime = RegenTime; // 현재 진행도를 재생성 진행도로 설정
cd.isTrigger = true;
rb.useGravity = false;
rb.isKinematic = true;
StopRegeneration(); // 재생성 종료 함수 호출
transform.position = startPos; // 처음 생성된 위치로 이동 => 날라간 위치에서 처음 위치로 재생성된 것 처럼 보이게 하기 위해서
transform.rotation = Quaternion.Euler(0f, 0f, 0f); // 회전값 초기화 => 날라갔을 때 회전값이 변경되었기 때문에, 초기화를 해줌
gameObject.SetActive(true); // 위치와 회전값을 초기화하고, 오브젝트를 활성화하여 재생성된 것 처럼 보이게 함
}
}
void OnCollisionEnter(Collision collision)
{
if (collision.gameObject.CompareTag("Ground"))
{
gameObject.SetActive(false);
// 드랍아이템
UiManager_.instance.DropItem(woodItem, 5);
// 재생성 시작 함수 호출
StartRegeneration();
}
}
}