저번 글에서 블록 맵을 만들었으니 이제 블록들을 활용이 필요하다
마인크래프트처럼 만든다고 하였으니 우선 블록을 깨고 블록아이템을 먹고 설치를 해야하는데
이를 위해 우선적으로 9개의 아이템을 선택할 수 있는 바를 먼저 만들어줄것이다.

이런 느낌이라고 보면된다.
해당 바를 HotBar라고 부른다.
이를 구현하기 전에 일단 임시적으로 세개정도의 칸으로 만들고
블럭을 부수고 설치하는 부분을 먼저 해보겠다.
일단 블럭을 부술려면 카메라로부터 시작된 RayCasting을 통해 일정 거리내에 블록이 있는지 확인을 하고
있다면 외곽선을 표현하여 현재 캐스팅된 블록을 표현해주고 버튼을 꾹누르면 가운데에 원형의 프로그레스바로 블럭을 부수고 있다는것을 표현해줄 것이다.
using UnityEngine;
public class BlockInteractor : MonoBehaviour
{
[Header("Refs")]
[SerializeField] private Camera cam; // 레이 캐스트 기준점 카메라
[SerializeField] private VoxelWorld world; // 블록 조회 , 블록 부수기 설치하기
[SerializeField] private HotbarInventory inv; // 핫바에서 설치할 블록 정보 가져오기
[SerializeField] private BlockHighlighter highlighter; // 현재 타겟 외곽선 표시
[Header("Raycast")]
[SerializeField] private float reach = 6f; // 상호작용 거리(손 길이)
[SerializeField] private LayerMask hitMask = ~0; // 어떤 레이어를 맞출 건지 선택
[SerializeField] private bool drawDebugRay = true; // 레이가 어디로 쏘아지는지 확인, 디버깅 Scene에서 확인
[Header("Mining (Hold)")]
[SerializeField] private bool mineHold = true; // 누르고 있는 동안 채굴 진행
[SerializeField] private bool allowMineAir = false; // 허공은 채굴 불가
public float MineProgress01 { get; private set; } // UI에서 채굴 진행도 표시
public bool IsMining => mining; // 디버깅 채굴 확인
private bool hasHit; // 현재 프레임에 레이가 무엇에 맞았는지 확인
private Vector3Int currentBlock; // 레이로 맞춘 블록의 정수 좌표
private Vector3Int currentNormal; // 맞은 면의 노말 방향( 설치하는 경우 한칸 옆으로 계산)
private bool mining; // 채굴 상태 플래그
private Vector3Int miningBlock; // 현재 채굴 대상으로 고정된 블록 좌표, 타겟이 바뀌면 리셋
private float miningTime; // 채굴 진행 누적 시간(일정 시간 눌러야 블럭이 부서짐)
private float miningDuration; // 해당 블록을 부수는데 필요한 총 시간(도구, 블록에 따라 다름)
void Awake()
{
if (cam == null) cam = Camera.main;
// cam을 Inspector에서 안정했을 때를 대비하여 메인 카메라 기본값 넣어줌
MineProgress01 = 0f; // 시작 진행도 0
}
void Update()
{
UpdateRayTarget();
// 매 프레임 카메라 중심에서 레이를 쏴서 타겟 블록 업데이트해줌
if (highlighter != null)
{
// 타겟이 있으면 하이라이트 표시, 없으면 꺼줌
if (hasHit) highlighter.SetTarget(currentBlock);
// 현재 블록을 외곽선 대상으로 지정
else highlighter.Clear();
// 아무것도 맞지 않은 경우 표시 제거
}
if (mineHold) TickMining(Time.deltaTime);
else StopMining(resetProgress: true);
// 채굴 진행 시간초
}
private void UpdateRayTarget()
{
hasHit = false;
// 기본값 히트 x 시작
if (cam == null || world == null) return;
// 카메라나 월드가 없으면 레이캐스트 자체를 할 수 없기에 종료해줌
Ray ray = cam.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));
// 화면 정중앙 플레이어가 바라보는 중앙으로 조준
Vector3 origin = ray.origin + ray.direction * 0.05f;
// 원점에서 살짝 앞으로 해서 Ray안정화 ,
// 메시 표면에 붙이면 Ray가 제대로 동작 안할 수도 있음
const float sphereRadius = 0.1f;
// Voxel 메시는 얇은 면이나 가장자리 부분 삼각형 경계에서 ray의 판별이 잘안되는 경우가 있기에
// SphereCast로 두께를 주어 안정성을 올려줌, ray의 선 두께를 늘려줬다고 생각하면됌
Debug.DrawRay(origin, ray.direction * reach, Color.red);
// 디버깅 시 Scene에서 보이는 레이가 맞춘 블록 확인
if (Physics.SphereCast(origin, sphereRadius, ray.direction, out RaycastHit hit,
reach, hitMask, QueryTriggerInteraction.Ignore))
{
// 의도하지 않은 UI, 트리거 충돌 방지
hasHit = true; // 무언가 맞긴 했으니 True
Vector3 n = hit.normal; // 맞은 면의 법선 넣어서 어느 방향의 면을 맞추었는지 확인
Vector3 p = hit.point - n * 0.02f;
// float 오차로 인해 다른 블록으로 계산될 수 있기에 법선 방향으로 면 안쪽으로 넣어서 블록좌표를 계산
currentBlock = new Vector3Int(
Mathf.FloorToInt(p.x),
Mathf.FloorToInt(p.y),
Mathf.FloorToInt(p.z)
);
// 월드 좌표를 블록 그리그 정수 좌표로 변환해줌
currentNormal = new Vector3Int(
Mathf.RoundToInt(n.x),
Mathf.RoundToInt(n.y),
Mathf.RoundToInt(n.z)
);
// 블록 설치할 때 쓸 노말 계산 , 오차 생각하여 Round로 정수화 시켜줌
// 혹시 계산된 좌표가 Air면 한 번 더 보정 시도
if (world.GetBlockWorld(currentBlock.x, currentBlock.y, currentBlock.z) == BlockType.Air)
{
p = hit.point - n * 0.2f;
currentBlock = new Vector3Int(
Mathf.FloorToInt(p.x),
Mathf.FloorToInt(p.y),
Mathf.FloorToInt(p.z)
);
}
// 계산된 블록이 비어있다고 나오는 경우 법선 방향으로 더 넣어서 보정시도
}
}
public void OnMineDown()
{
mineHold = true; // 누르고 있는 상태
TryStartMining(); // 누르는 순간 현재 타겟 블록 기준 채굴 시작
}
public void OnMineUp()
{
mineHold = false; // 손 떼면 채굴 상태 해제
StopMining(resetProgress: true); // 진행도 0으로 바꿔줌
}
public void OnPlacePressed()
{
TryPlace(); // 설치 버튼
}
private void TryStartMining()
{
if (!hasHit) { StopMining(true); return; }
// 레이가 아무것도 맞추지 못했다면 채굴을 시작할 수 없음
BlockType bt = world.GetBlockWorld(currentBlock.x, currentBlock.y, currentBlock.z);
// 월드에서 실제 블록 타입을 조회해줌 ( 렌더링이 아닌 데이터 기준)
if (!allowMineAir && bt == BlockType.Air)
{
// 비어있는 부분은 캐지 않음, 채굴 중단
StopMining(true);
return;
}
if (!mining || miningBlock != currentBlock)
{
// 타겟 블럭이 바뀐 경우 리셋
mining = true; // 채굴 상태 켜줌
miningBlock = currentBlock; // 해당 블록을 캐고 있는 대상
miningTime = 0f; // 누적 시간 초기화
ItemId held = inv != null ? inv.Selected.id : ItemId.None;
// 손에 들고있는 Item의 Type에 따라 채굴 시간을 다르게 줌 (아직 미구현)
miningDuration = GetMineDuration(bt, held); // 블록 도구에 따라 채굴 시간 결정
MineProgress01 = 0f; // UI도 초기화
}
}
private void TickMining(float dt)
{
if (!mining)
{
// 아직 채굴 상태가 아닌경우 시작
TryStartMining();
return;
}
if (!hasHit || currentBlock != miningBlock)
{
// 도중에 레이가 안맞거나 다른 블록을 보면 리셋
TryStartMining();
return;
}
BlockType bt = world.GetBlockWorld(miningBlock.x, miningBlock.y, miningBlock.z);
// 채굴 중에도 해당 블록이 존재하는지 확인
if (!allowMineAir && bt == BlockType.Air)
{
StopMining(true);
return;
}
miningTime += dt; // 누르는 동안 시간 누적
MineProgress01 = Mathf.Clamp01(miningTime / Mathf.Max(0.0001f, miningDuration)); // UI 진행도 값 정규화
if (miningTime >= miningDuration)
{
// 채굴 완료 조건, 누적시간이 채굴되는 시간 이상이 되는 경우
world.TrySetBlockWorld(miningBlock.x, miningBlock.y, miningBlock.z, BlockType.Air);
// 해당 블록 데이터를 빈공간으로 만들어주고 청크, 콜라이더 갱신
StopMining(true);
// 완료 후 리셋
}
}
private void StopMining(bool resetProgress)
{
mining = false; // 채굴 상태 꺼줌
miningTime = 0f; // 채굴 시간 리셋
miningDuration = 0f; // 목표 시간 리셋(블럭이 따라 다르니까)
if (resetProgress) MineProgress01 = 0f; // UI 진행도 리셋
}
private void TryPlace()
{
// 설치 코두
if (!hasHit || world == null || inv == null) return;
// 타겟이 존재하며, world와 hotbar의 참조가 있는 경우 가능
var sel = inv.Selected; // 현재 선택된 슬롯
if (sel.IsEmpty) return; // 슬롯이 비어 있는지 확인
if (!ItemDb.TryGetBlockType(sel.id, out BlockType placeType))
return;
// 선택된 아이템이 블록인지 확인
Vector3Int placeBlock = currentBlock + currentNormal;
// 설치 위치 = 맞춘 블록에서 맞은 면 방향으로 한칸 옆에
if (world.GetBlockWorld(placeBlock.x, placeBlock.y, placeBlock.z) != BlockType.Air)
return;
// 이미 해당 부분에 블록이 있다면 설치 금지
if (!inv.ConsumeSelected(1))
return;
// 인벤토리에서 1개 소모가 되면 설치
world.TrySetBlockWorld(placeBlock.x, placeBlock.y, placeBlock.z, placeType);
// 위의 모든 조건 통과 시 월드에 블록 설치
}
private float GetMineDuration(BlockType bt, ItemId held)
{
bool isPickaxe = false;
// 들고 있는 아이템이 곡괭이인지를 판단해서 돌,광물 채굴 시간을 줄여줌
if (ItemDb.Kind(held) == ItemKind.Tool)
{
// 도구인지 확인 후 ToolInfo로 세부 종류 확인
var info = ItemDb.ToolInfo(held); // 도구 정보 넣어줌
if (info.kind == ToolKind.Pickaxe) isPickaxe = true; // 곡괭이가 맞다면 true
}
return bt switch
{
BlockType.Dirt => 3f,
BlockType.Grass => 3f,
BlockType.Stone => isPickaxe ? 4f : 10f,
BlockType.Coal => isPickaxe ? 6f : 10f,
BlockType.Iron => isPickaxe ? 8f : 10f,
BlockType.Water => 1000.0f,
_ => 9000f
};
// 각 블럭에 따라 처리 시간
}
}
블럭과 상호작용 즉 레이를 쏴줄 BlockInteractor 스크립트를 만들어 준다.
카메라 중앙에서 Ray를 발사해 MeshComllider에맞은 지점과 면의 법선을 얻어 해당 월드의 블록 좌표를 계산한다.
월드 데이터에서 해당 좌표의 블록의 타입을 조회하여 준다.
using UnityEngine;
using UnityEngine.UI;
public class MiningProgressUI : MonoBehaviour
{
[SerializeField] private BlockInteractor interactor; // 시간초 정규화 했던 0 ~ 1의 값
[SerializeField] private Image fillImage; // UI에서 쓰일 이미지 (원형 프로그레스 바)
void Awake()
{
if (fillImage != null)
fillImage.type = Image.Type.Filled;
//런타임에 filled로 만들어서 진행바가 작동하게 해줌
}
void Update()
{
if (interactor == null || fillImage == null) return;
// 참조,연결 안됐을 경우의 오류 방지
float p = interactor.MineProgress01;
fillImage.fillAmount = p;
// 0 ~ 1의 블럭 채굴 진행도 UI에 반영
fillImage.enabled = p > 0.001f;
// 진행 중 아닐 땐 숨기기(선택)
Debug.Log($"p={p}");
Debug.Log($"[UI] interactor={interactor.name} id={interactor.GetInstanceID()} p={interactor.MineProgress01}");
Debug.Log($"[UI] id={interactor.GetInstanceID()} mining={interactor.IsMining} p={interactor.MineProgress01:0.000}");
// 디버그 로그로 P의 값과 상호작용하고 있는 인스턴스 확인
}
}
해당 블록에 상호작용이 완료가 되면 버튼을 통해 길게 누르는 방식으로 꾹 누르면 프로그레스 바가 채워진다.
손에서 떼면 초기화 프로그레스 바가 가득차게되면 블럭이 부서진다.
using UnityEngine;
public class BlockHighlighter : MonoBehaviour
{
// Scene에서 확인하는 디버그용 외곽선 그리기
public bool HasTarget { get; private set; }
// private set으로 외부에서 바꾸지 못하고 SetTrtget과 Clear로만 바꾸도록 강제
public Vector3Int TargetBlock { get; private set; }
// BlockInteractor가 레이캐스트로 계산한 블록 좌표를 넣어줌
[Header("Debug Draw")]
public bool draw = true; // Scene에서 타겟 블록에 외곽선 그려줌
public void SetTarget(Vector3Int blockPos)
{
HasTarget = true; // 타겟 온
TargetBlock = blockPos; // 블럭 위치 받아줌
}
public void Clear()
{
HasTarget = false; // 레이캐스트가 적중되지 않으면 외곽선 꺼줌
}
void OnDrawGizmos()
{
if (!draw || !HasTarget) return;
Gizmos.color = Color.yellow;
// 블록 중심은 (x+0.5, y+0.5, z+0.5)
Gizmos.DrawWireCube((Vector3)TargetBlock + Vector3.one * 0.5f, Vector3.one);
}
}
잘되어지는지 확인을 위해 BlockHighter스크립트를 만들어 Scene창에서 확인
using UnityEngine;
[RequireComponent(typeof(LineRenderer))]
public class BlockOutlineWireRenderer : MonoBehaviour
{
// 실제 외곽선을 그려 상호작용하고 있는 타겟 블럭에 표시
[Header("Refs")]
[SerializeField] private Camera cam; // 레이를 쏠 카메라
[SerializeField] private VoxelWorld world; // 맞은 블록이 비어있는지 확인
[Header("Raycast")]
[SerializeField] private float reach = 6f; // 상호작용 거리
[SerializeField] private LayerMask hitMask; // 레이가 맞을 레이어 필터 World에만 적용
[Header("Visual")]
[SerializeField] private float inset = 0.0025f;
// 3D 렌더링에서 두 개 이상의 면이 거의 같은 깊에 위치하면 렌더러가 우선순위를 정하지 못하여
// 깜빡이는 오류가 발생함 그렇기에 조정을 줌
private LineRenderer lr; // 실제 선을 그려주는 컴포넌트
private bool hasTarget; // 현재 레이가 유효한 블록을 쏘고있는지 확인
private Vector3Int targetBlock; // 타겟 블록의 정수 좌표
void Awake()
{
lr = GetComponent<LineRenderer>();
// LineRenderer를 매 프레임 GetComponenet 하면 느려지기 떄문에 캐실해줌
if (cam == null) cam = Camera.main; // 아까 했던 거 처럼 미리 기본 카메라로 세팅해줌
// 라인 기본 세팅(Inspector에서도 가능)
lr.useWorldSpace = true; // 월드 좌표 기준으로
lr.loop = false; // 12개의 edge를 연결
lr.positionCount = 16; // 12 edge를 한 스트립으로 그리기 위한 점 개수
lr.enabled = false; // 아무 블록도 안잡혔을 수 있으니 꺼줌
}
void Update()
{
// Update로 매프레임 반복
FindTarget(); // 타겟 찾기
Apply(); // 외곽선 적용
}
void FindTarget()
{
hasTarget = false; // 타겟 없음
if (cam == null || world == null) return; // 카메라 참조가 제대로 안되어졌을 경우 반환
Ray ray = cam.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0)); // 정중앙 레이
if (!Physics.Raycast(ray, out var hit, reach, hitMask)) return;
// 맞은 지점을 블록 안쪽으로 살짝 넣어서 셀 계산 안정화
Vector3 p = hit.point - hit.normal * 0.01f;
var b = new Vector3Int(
Mathf.FloorToInt(p.x),
Mathf.FloorToInt(p.y),
Mathf.FloorToInt(p.z)
);
// 블록 정수 좌표 변환
if (world.GetBlockWorld(b.x, b.y, b.z) == BlockType.Air) return;
// 레이가 맞았는데 해당 좌표가 비어있는 곳이면 return
hasTarget = true; // 모든 if를 통과 했다면 타겟 확정
targetBlock = b; // 블록 좌표 넣어줌
}
void Apply()
{
if (!hasTarget)
{
lr.enabled = false;
return;
}
// 타겟이 없으면 꺼줌
lr.enabled = true;
// 타겟이 있으면 켜서 좌표를 업데이트 해줌
Vector3 baseMin = (Vector3)targetBlock;
Vector3 baseMax = baseMin + Vector3.one;
// 타겟 블록의 최소, 최대
baseMin -= Vector3.one * inset;
baseMax += Vector3.one * inset;
// z-fighting 방지하기 위해 조금 띄워줌
Vector3 p000 = new(baseMin.x, baseMin.y, baseMin.z);
Vector3 p001 = new(baseMin.x, baseMin.y, baseMax.z);
Vector3 p010 = new(baseMin.x, baseMax.y, baseMin.z);
Vector3 p011 = new(baseMin.x, baseMax.y, baseMax.z);
Vector3 p100 = new(baseMax.x, baseMin.y, baseMin.z);
Vector3 p101 = new(baseMax.x, baseMin.y, baseMax.z);
Vector3 p110 = new(baseMax.x, baseMax.y, baseMin.z);
Vector3 p111 = new(baseMax.x, baseMax.y, baseMax.z);
// 큐브 선으로 한번에 그리기 위해 점 나열
lr.SetPositions(new Vector3[]
{
p000, p100, p101, p001, p000,
// 아래 사각형
p010, p110, p100,
p110, p111, p101,
p111, p011, p001,
p011, p010
// 세로선 + 위의 사각형 일부를 이어서 그려줌
});
}
}
확인이 완료되었으면 실제로 GameScene에다가 사각형 외곽선을 그려 블록의 크기보다 조금 크게해서 타겟 블록에 설치해준다
using UnityEngine;
public enum ItemKind : byte { Block, Tool }
// 아이템 종류 블럭인지 도구인지
public enum ToolKind : byte { None, Pickaxe }
// 도구 종료, 나중 추가
public enum ToolTier : byte { None, Wood, Stone, Iron }
// 블럭 종류
public enum ItemId : int
{
None = 0,
// Blocks
Dirt = 100,
Grass = 101,
Stone = 102,
Coal = 103,
Iron = 104,
// Tools
WoodenPickaxe = 200,
StonePickaxe = 201,
IronPickaxe = 202,
// 각 블럭과 도구의 id값
}
public static class ItemDb
{
// ItemId -> 블록 타입 매핑 (블록 설치에 필요)
public static bool TryGetBlockType(ItemId id, out BlockType bt)
{
// 설치 하는 경우 현재 아이템이 블럭인지 확인
// 블럭이면 type에 따라 결정
bt = BlockType.Air;
switch (id)
{
case ItemId.Dirt: bt = BlockType.Dirt; return true;
case ItemId.Grass: bt = BlockType.Grass; return true;
case ItemId.Stone: bt = BlockType.Stone; return true;
case ItemId.Coal: bt = BlockType.Coal; return true;
case ItemId.Iron: bt = BlockType.Iron; return true;
default: return false;
}
}
public static ItemKind Kind(ItemId id)
{
// ID 대역을 이용해 Block / Tool 을 빠르게 판별
if ((int)id >= 200) return ItemKind.Tool; // 200 이상 tool
if ((int)id >= 100) return ItemKind.Block; // 100 이상이면 block
return ItemKind.Block;
}
public static (ToolKind kind, ToolTier tier) ToolInfo(ItemId id)
{
// 도구 상세 종류 조회
switch (id)
{
case ItemId.WoodenPickaxe: return (ToolKind.Pickaxe, ToolTier.Wood);
case ItemId.StonePickaxe: return (ToolKind.Pickaxe, ToolTier.Stone);
case ItemId.IronPickaxe: return (ToolKind.Pickaxe, ToolTier.Iron);
default: return (ToolKind.None, ToolTier.None);
}
}
public static int MaxStack(ItemId id)
{
// 도구는 1개, 블록은 64개로 맥스 스택
return Kind(id) == ItemKind.Tool ? 1 : 64;
}
public static string DisplayName(ItemId id) => id.ToString();
// 나중 이름 변경
}
Grass/Dirt는 3초 Stone은 10초 이런식으로 블록마다 손으로 부술때의 시간 차이를 준다.
도구를 사용하면 빠르게 캐짐 이를 위해 ItemType를 정해준다.
using UnityEngine;
using UnityEngine.UI;
public class MiningProgressUI : MonoBehaviour
{
[SerializeField] private BlockInteractor interactor; // 시간초 정규화 했던 0 ~ 1의 값
[SerializeField] private Image fillImage; // UI에서 쓰일 이미지 (원형 프로그레스 바)
void Awake()
{
if (fillImage != null)
fillImage.type = Image.Type.Filled;
//런타임에 filled로 만들어서 진행바가 작동하게 해줌
}
void Update()
{
if (interactor == null || fillImage == null) return;
// 참조,연결 안됐을 경우의 오류 방지
float p = interactor.MineProgress01;
fillImage.fillAmount = p;
// 0 ~ 1의 블럭 채굴 진행도 UI에 반영
fillImage.enabled = p > 0.001f;
// 진행 중 아닐 땐 숨기기(선택)
Debug.Log($"p={p}");
Debug.Log($"[UI] interactor={interactor.name} id={interactor.GetInstanceID()} p={interactor.MineProgress01}");
Debug.Log($"[UI] id={interactor.GetInstanceID()} mining={interactor.IsMining} p={interactor.MineProgress01:0.000}");
// 디버그 로그로 P의 값과 상호작용하고 있는 인스턴스 확인
}
}
다음 블럭을 캘 떄 필요한 UI 채굴 시간을 나타내준다.
using UnityEngine;
[System.Serializable]
public struct ItemStack
{
public ItemId id;
// 어떤 아이템인지 id 확인
public int count;
// 해당 아이템의 개수
public bool IsEmpty => id == ItemId.None || count <= 0;
// 비어있는 슬롯 판정, id가 none이거나 수량이 0인 경우
public ItemStack(ItemId id, int count)
{
// itemstack 생성
this.id = id;
this.count = count;
}
}
public class HotbarInventory : MonoBehaviour
{
[Header("Hotbar")]
[SerializeField] private int slotCount = 3; // 슬롯 기본 3개, 나중 확실해지면 9개로 늘릴예정
[SerializeField] private ItemStack[] slots; // 슬롯 데이터, 각 칸에 id와 count 넣어줌
[SerializeField] private int selectedIndex = 0; // 현재 선택된 슬롯 번호
public int SlotCount => slots != null ? slots.Length : 0;
// 현재 슬롯 개수가 null이면 0
public int SelectedIndex => selectedIndex;
// 현재 선택 인덱스를 외부에서 읽기만 가능
public ItemStack Selected => GetSlot(selectedIndex);
// 현재 선택된 슬롯의 ItemStack을 반환해줌
void Awake()
{
if (slots == null || slots.Length != slotCount)
slots = new ItemStack[slotCount];
// 슬롯에 배열이 없거나, 길이가 count와 다르다면 새로 생성해줌 -> 안정성
}
public void Select(int index)
{
if (slots == null || slots.Length == 0) return;
// 슬롯없으면 선택 불가
selectedIndex = Mathf.Clamp(index, 0, slots.Length - 1);
// 인덱스 범위를 벗어나도 clamp로 제어
}
public ItemStack GetSlot(int index)
{
if (slots == null || index < 0 || index >= slots.Length) return default;
// 슬롯의 유효성 검사, 배열이 없거나 인덱스 범위 밖이면 default
return slots[index];
// 해당 슬롯 데이터 받기
}
public void SetSlot(int index, ItemId id, int count)
{
// 특정 슬롯에 아이템 강제 세팅, 아이템이 제대로 들어가나 테스트 확인
if (slots == null || index < 0 || index >= slots.Length) return;
slots[index] = new ItemStack(id, count);
// 새로운 스택으로 덮어쓰기
}
public bool ConsumeSelected(int count)
{
// 설치 하는 경우 재료 1개 줄이기
if (count <= 0) return true;
// 0 이하 소비는 넘기기 의미 없음
if (slots == null || selectedIndex < 0 || selectedIndex >= slots.Length)
return false;
// 선택 인덱스 유효한지 검사
var s = slots[selectedIndex]; // 현재 선택 슬롯 가져오기
if (s.IsEmpty) return false; // 비어있는 상태면 소비 불가
if (s.count < count) return false; // 소비할 개수가 가진 개수가 더 적으면 실패
s.count -= count; // 수량 차감
if (s.count <= 0) s = new ItemStack(ItemId.None, 0); // 차감 후 0 이하면 빈슬롯으로 변경
slots[selectedIndex] = s; // 다시 배열에 반영 -> 복사본 수정했기 떄문에 갱신
return true; // 성공
}
public bool Add(ItemId id, int count)
{
// 아이템 추가
if (count <= 0) return true;
if (id == ItemId.None) return false;
// none 아이템은 인벤에 넣을 수 없음
int max = ItemDb.MaxStack(id);
// 아이템의 최대 수량
// 1) 같은 아이템이 있고 스택 여유가 있으면 먼저 채우기
for (int i = 0; i < slots.Length; i++)
{
if (slots[i].id == id && slots[i].count < max)
{
// 같은 아이템이고 아직 max 미만이면 합칠 수 있음
int can = Mathf.Min(max - slots[i].count, count);
// 이번 슬롯에 넣을 수 있는 개수, 남은 공간, 남은 count 중 작은 값
slots[i].count += can;
// 슬롯에 추가
count -= can;
// 남은 count 감소
if (count <= 0) return true;
// 다 넣었으면 성공 종료
}
}
for (int i = 0; i < slots.Length; i++)
{
if (slots[i].IsEmpty)
{
// 빈 슬롯이면 새로 스택 생성 가능
int put = Mathf.Min(max, count); // 새 슬롯에 넣을 개수
slots[i] = new ItemStack(id, put); // 새 스택 생성해서 넣기
count -= put; // 남은 count 감소
if (count <= 0) return true; // 다 넣었으면 성공 종료
}
}
// 못 넣은 경우(핫바 꽉 찼음)
return false;
}
}
설치를 위한 HotBar
using System.Collections.Generic;
using UnityEngine;
public class VoxelWorld : MonoBehaviour
{
[Header("Rendering")]
[SerializeField] private Material voxelMaterial;
// 청크들이 공유해서 사용한 머테리얼
[Header("World Size (in chunks)")]
[SerializeField] private int chunksX = 7;
[SerializeField] private int chunksZ = 7;
// 월드 크기 설정
[Header("Terrain")]
[SerializeField] private int baseHeight = 28; // 평균 높이
[SerializeField] private int amplitude = 18; // 진폭(얼마나 위아래로 흔들릴지)
[SerializeField] private float noiseScale = 0.06f;
// PerlinNoise 샘플링 스케일(작을수록 큰 언덕이 만들어지고, 클수록 잔잔히 생김)
[SerializeField] private int topDirtDepth = 5; // 지표면 아래 흙 블록 깊이
[SerializeField] private int waterLevel = 22; // 물 높이, 낮은 지형은 물로 채워줌
[Header("Ore Chances (only in Stone)")]
[Range(0f, 1f)][SerializeField] private float coalChance = 0.02f; // 석탄 블록 생성 확률
[Range(0f, 1f)][SerializeField] private float ironChance = 0.01f; // 철 블록 생성 확률
private readonly Dictionary<Vector2Int, VoxelChunk> chunks = new();
// 월드에서 청크 좌표를 찾기 위한 맵
// 월드 좌표로 블록을 조회하는 경우 빨리 찾기 위해 Dictionary 사용
void Start()
{
Physics.queriesHitBackfaces = true;
// backface를 켜주어서 레이를 쏘았을때 누락의 가능성을 줄여줌
GenerateInitialWorld(); // 게임 시작 시 월드 생성
}
private void GenerateInitialWorld()
{
for (int cx = 0; cx < chunksX; cx++)
for (int cz = 0; cz < chunksZ; cz++)
CreateChunk(new Vector2Int(cx, cz));
// 월드 범위만큼의 청크 생성
// 청크 생성 후 mesh 빌드
// 경계면이 이웃 청크를 조회 해야 정확히 컬링이 되기 때문에 청크 생성 먼저 함
foreach (var kv in chunks)
kv.Value.BuildMesh();
}
private void CreateChunk(Vector2Int coord)
{
var go = new GameObject($"Chunk_{coord.x}_{coord.y}");
// 런타임에 청크 GameObject 생성, 게임 시작하면 Hierarchy에 chunk_0_0이런거 생길것임
go.layer = gameObject.layer;
// 월드 오브젝트 레이어를 청크도 따라가도록 해줌 ( RayCast hitMask로 world 레이어만 맞추는 설계)
go.transform.SetParent(transform, false);
// VoxelWorld 아래 자식으로 넣어 Hierarchy 정리
// false 월드 변환 유지
go.transform.position = new Vector3(coord.x * VoxelChunk.SizeX, 0f, coord.y * VoxelChunk.SizeZ);
// 청크의 실제 월드 위치
// 청크 좌표 * 청크 사이즈 = 청크 시작점의 월드 좌표
var chunk = go.AddComponent<VoxelChunk>();
// 청크 컴포넌트 추가
chunk.Initialize(this, coord, voxelMaterial);
// 청크 초기화 world 참조, coord, 머테리얼 전달
FillChunkBlocks(chunk);
// 생성 직후 블록 데이터 채우기
chunks.Add(coord, chunk);
// 경계면 조회 시 반드시 이웃 청크 확인하고 딕셔너리에 등록
}
private void FillChunkBlocks(VoxelChunk chunk)
{
int startX = chunk.ChunkCoord.x * VoxelChunk.SizeX;
int startZ = chunk.ChunkCoord.y * VoxelChunk.SizeZ;
// 해당 청크의 로컬 (0,0)이 월드에서 어디인지를 계산해줌
for (int lx = 0; lx < VoxelChunk.SizeX; lx++)
for (int lz = 0; lz < VoxelChunk.SizeZ; lz++)
{
// 청크 내부의 모든 x,z칸을 돌면서 해당 위치의 높이를 뽑고
// 그 높이를 기준으로 y축을 쌓아 블록 타입을 결정해준다.
int wx = startX + lx;
int wz = startZ + lz;
// 월드 좌표로 변환(PerlinNoisze를 연속적으로 만들기 위해서)
int h = SampleHeight(wx, wz);
// wx,wz에서의 지표면 높이
for (int y = 0; y < VoxelChunk.SizeY; y++)
{
// y 0 부터 청크 높이까지 블록 채우기
BlockType t;
if (y > h)
{
t = (y <= waterLevel) ? BlockType.Water : BlockType.Air;
// 지표면 보다 위라면 비어있는 공간 또는 물
// 근데 WaterLevel이하면 물로 채워서 호수만들어줌
}
else
{
if (y == h) t = BlockType.Grass;
// 지표면은 잔디
else if (y >= h - (topDirtDepth - 1)) t = BlockType.Dirt;
// 지표면 아래 일정 깊이 까지는 흙층
else t = PickStoneOrOre(wx, y, wz);
// 아래는 돌, 랜덤 광물로
}
chunk.SetBlock(lx, y, lz, t);
// 블록 데이터 기록 (메쉬를 만들지 않음)
}
}
}
private int SampleHeight(int wx, int wz)
{
// PerlinNoise 0 ~ 1로 반환
float n = Mathf.PerlinNoise(wx * noiseScale, wz * noiseScale);
return baseHeight + Mathf.RoundToInt((n - 0.5f) * 2f * amplitude);
}
private BlockType PickStoneOrOre(int wx, int y, int wz)
{
float depth01 = 1f - (y / (float)VoxelChunk.SizeY);
// y가 낮을수록 depth가 1에 가까워짐, 깊을 수록 값 증가
// 깊을 수록 석탄과 철이 나올 확률이 올라감
float r = Hash01(wx, y, wz);
// 좌표 기반 고정 난수 (같은 좌표는 항상 같은 값임)
// UnityRandom을 사용하는 경우 실행마다 달라지고 , 안정성이 떨어짐
float coal = coalChance + depth01 * 0.01f;
float iron = ironChance + depth01 * 0.006f;
// 깊이에 비례하여 확률 보정 조금씩 증가하도록
if (r < iron) return BlockType.Iron;
// 더 희귀한 철을 먼저 체크하여 더 낮은 r 구간을 선점하도록함
if (r < iron + coal) return BlockType.Coal;
return BlockType.Stone;
// 나머진 돌로
}
public BlockType GetBlockWorld(int wx, int y, int wz)
{
if (y < 0 || y >= VoxelChunk.SizeY) return BlockType.Air;
// 월드 높이 범위를 벗어나면 비어 있는 곤간으로 처리해줌
int cx = Mathf.FloorToInt(wx / (float)VoxelChunk.SizeX);
int cz = Mathf.FloorToInt(wz / (float)VoxelChunk.SizeZ);
// 월드 좌표가 속한 청크 좌표 계산
// FloorToInt -> 음수 좌표 포함 시에도 일관되게 처리해줌
int lx = wx - cx * VoxelChunk.SizeX;
int lz = wz - cz * VoxelChunk.SizeZ;
// 청크 내부 로컬 좌표로 변환해줌
var key = new Vector2Int(cx, cz);
if (!chunks.TryGetValue(key, out var chunk))
return BlockType.Air;
// 청크가 존재하지 않는 부분은 비어있음 으로 처리
return chunk.GetBlockLocal(lx, y, lz);
// 청크 내부 배열 블록 타입 반환
}
private static float Hash01(int x, int y, int z)
{
unchecked
{
int h = x * 73856093 ^ y * 19349663 ^ z * 83492791;
h = (h << 13) ^ h;
int v = (h * (h * h * 15731 + 789221) + 1376312589);
return (v & 0x7fffffff) / 2147483647f;
}
// 좌표 기반 해시 -> 0 ~ 1 flaot로 정규화해서 반환
// 같은 좌표 같은 값나오게 됌
}
public bool TrySetBlockWorld(int wx, int y, int wz, BlockType type)
{
if (y < 0 || y >= VoxelChunk.SizeY) return false;
// 월드 밖이면 실패
int cx = Mathf.FloorToInt(wx / (float)VoxelChunk.SizeX);
int cz = Mathf.FloorToInt(wz / (float)VoxelChunk.SizeZ);
// 월드 좌표를 청크 좌표로 바꿔줌
int lx = wx - cx * VoxelChunk.SizeX;
int lz = wz - cz * VoxelChunk.SizeZ;
// 월드 좌표를 로컬 좌표로
var key = new Vector2Int(cx, cz);
if (!chunks.TryGetValue(key, out var chunk))
return false;
// 해당 청크가 없다면 실패
chunk.SetBlock(lx, y, lz, type);
// 블록 데이터 수정
chunk.BuildMesh();
// 청크 리빌드
if (lx == 0) RebuildChunkIfExists(new Vector2Int(cx - 1, cz));
if (lx == VoxelChunk.SizeX - 1) RebuildChunkIfExists(new Vector2Int(cx + 1, cz));
if (lz == 0) RebuildChunkIfExists(new Vector2Int(cx, cz - 1));
if (lz == VoxelChunk.SizeZ - 1) RebuildChunkIfExists(new Vector2Int(cx, cz + 1));
// 경계면이면 이웃 청크도 리빌드
// 왜냐, 안해주면 블록있는 것으로 판단해서 면을 안만들어 뚫려보일 수 도있음
return true;
}
private void RebuildChunkIfExists(Vector2Int coord)
{
if (chunks.TryGetValue(coord, out var c))
c.BuildMesh();
// 이웃 청크가 존재한다면 메시 재생성
}
}
using System.Collections.Generic; // 메쉬 빌드 시 정점 / 삼각형 / 컬러 / UV를 동적 리스트로 모으는 헤더
using UnityEngine;
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer), typeof(MeshCollider))]
public class VoxelChunk : MonoBehaviour
{
public const int SizeX = 16; // 청크 하나의 x ,y ,z 크기
public const int SizeZ = 16;
public const int SizeY = 64;
public Vector2Int ChunkCoord { get; private set; }
// 해당 청크가 월드에서 어디에 있는지 좌표
// 외부에서 읽는것만 가능
private BlockType[,,] blocks;
// 로컬 좌표 기반의 청크 내부의 블록 데이터
// [x,y,z] 형태로 빠르게 접근 가능함
private MeshFilter mf;
private MeshRenderer mr;
private MeshCollider mc;
// 매번 호출하기에는 비효율적이기에 캐싱해줌
// 캐싱 : 자주 사용하는 데이터, 파일의 복사본은 미리 저장
private VoxelWorld world;
// 청크 바깥 블록을 조회할 때 필요
// 청크끼리 직접 참조를 하지 않고 월드에서 "월드 좌표 블록 조회"를 위임하여 의존성 단순화
public void Initialize(VoxelWorld world, Vector2Int coord, Material mat)
{
// 청크 생성자
// 월드 참조. 좌표, 머테리얼, 데이터배열 세팅
this.world = world; // 이웃 청크 조회
ChunkCoord = coord; // 청크 위치 기억 -> 월드좌표 , 로컬좌표 변환
mf = GetComponent<MeshFilter>();
mr = GetComponent<MeshRenderer>();
mc = GetComponent<MeshCollider>();
// 모든 청크가 같은 머테리얼을 가지도록 세팅
// 드로우콜, 메모리 감소
mr.sharedMaterial = mat;
mc.convex = false;
mc.isTrigger = false;
// 지형용 MeshCollider는 Convex 불가
// 지형은 실제 충돌로 써야 하기 떄문에 Coliider로 유지
// Coliider 안정화
mc.cookingOptions =
MeshColliderCookingOptions.CookForFasterSimulation |
// PhysX가 충돌 최적화 전처리를 수행해 런타임 충돌이 빠르고 안정됌
MeshColliderCookingOptions.EnableMeshCleaning |
// 삼각형/정점에 이상한 데이터가 있으면 정리해줌 ( 충돌 누락. 뚫림방지)
MeshColliderCookingOptions.WeldColocatedVertices;
// 겹치는 정점 연결, 블록을 부수었을 경우 특정 구역이 뚫리거나
// 레이가 안 맞는 증상 줄이기
blocks = new BlockType[SizeX, SizeY, SizeZ];
// 블록 데이터 배열 생성
// 청크는 런타임에 데이터를 계속 바꾸기 때문에 가변 리스트가 아닌 고정 3D 배열로 빠른 접근
}
public void SetBlock(int x, int y, int z, BlockType type) => blocks[x, y, z] = type;
// 블록 세팅 (단순 대입) -> 메시 리빌드는 다른 로직에서 따로 관리
// 변경이 많이 일어나는 경우 매번 리빌드를 하면 메모리 소모가 심함(터질수도)
public BlockType GetBlockLocal(int x, int y, int z)
{
// 청크 로컬 ㅈ하표로 블록 타입 조회
// 범위 밖이면 비어 있는 공간으로 처리
if (x < 0 || x >= SizeX || y < 0 || y >= SizeY || z < 0 || z >= SizeZ)
return BlockType.Air; // 범위 밖은 비어 있다고 가정해줌
return blocks[x, y, z];
}
private BlockType GetBlockWithNeighbors(int x, int y, int z)
{
// 이웃 청크 포함 블로 조회
// 청크 내부면 배열에서 바로 가져오고
// 청크 밖인 경우 월드 좌표로 변환하여 world에서 조회해줌
if (x >= 0 && x < SizeX && y >= 0 && y < SizeY && z >= 0 && z < SizeZ)
return blocks[x, y, z];
// 내부면 빠른 경로
int worldX = ChunkCoord.x * SizeX + x;
int worldZ = ChunkCoord.y * SizeZ + z;
return world.GetBlockWorld(worldX, y, worldZ);
// 월드에서 해당 월드좌표의 블록을 가져온다
// 이때 world는 없는 청크 또는 범위 밖인 경우 비어있는 상태를 반환
}
public void BuildMesh()
{
// 청크 메쉬 리빌드
// 비어있는 부분과 인접한 면만 생성 -> 정점, 삼각형 수를 줄여줌
// 매번 리빌드 떄 GC (쓰레기 값. 안쓰는 객체)를 최소화 하기 위헤 Capacity(용량)을 크게 잡아줌
// 청크가 꽉 차 있는 경우 대략적인 최악 케이스를 감안한 초기 용량
var verts = new List<Vector3>(8192); // 정점
var tris = new List<int>(16384); // 삼각형 인덱스
var cols = new List<Color32>(8192); // 버텍스 컬러
var uvs = new List<Vector2>(8192); // UV 외곽선
int vCount = 0; // 지금까지 추가된 정점의 수
for (int x = 0; x < SizeX; x++)
for (int y = 0; y < SizeY; y++)
for (int z = 0; z < SizeZ; z++)
{
// 청크 전체 블록 순회
BlockType t = blocks[x, y, z]; // 현재 블록 타입
if (t == BlockType.Air) continue; // 비어있는 공간은 제외
Vector3 basePos = new Vector3(x, y, z); // 블록 로컬 위치 받아줌
if (IsFaceVisible(x + 1, y, z)) AddFace(FaceDir.PosX, basePos, t, ref vCount, verts, tris, cols, uvs);
if (IsFaceVisible(x - 1, y, z)) AddFace(FaceDir.NegX, basePos, t, ref vCount, verts, tris, cols, uvs);
if (IsFaceVisible(x, y, z + 1)) AddFace(FaceDir.PosZ, basePos, t, ref vCount, verts, tris, cols, uvs);
if (IsFaceVisible(x, y, z - 1)) AddFace(FaceDir.NegZ, basePos, t, ref vCount, verts, tris, cols, uvs);
if (IsFaceVisible(x, y + 1, z)) AddFace(FaceDir.PosY, basePos, t, ref vCount, verts, tris, cols, uvs);
if (IsFaceVisible(x, y - 1, z)) AddFace(FaceDir.NegY, basePos, t, ref vCount, verts, tris, cols, uvs);
// 6방향 이웃이 비어있는 공간이라면 해당 면은 외부로 노출되는 상태이므로 생성
// 내부 면은 전부 생성하지 않는 방식으로 메모리 감소
}
Mesh mesh = new Mesh();
// 메시 객체 생성
mesh.indexFormat = (verts.Count > 65000)
? UnityEngine.Rendering.IndexFormat.UInt32
: UnityEngine.Rendering.IndexFormat.UInt16;
// 정점이 최대치를 넘어가면 16bit 인덱스로 모자르기에 32로 변환하여 늘려줌
mesh.SetVertices(verts);
mesh.SetTriangles(tris, 0);
mesh.SetColors(cols);
mesh.SetUVs(0, uvs);
// 메시 데이터 설정
mesh.RecalculateNormals();
mesh.RecalculateBounds();
// 조명 계산, 셰이더
// 카메라 컬링
mf.sharedMesh = mesh;
// 렌더러에 메시 적용
mc.enabled = false;
mc.sharedMesh = null;
mc.sharedMesh = mesh;
mc.enabled = true;
// Collider 데이터 다시 생성 및 동기화
// 만약 shareMesh만 다시 생성했을 때 Physx가 내부적으로 갱신이 늦는 경우
// 레이가 제대로 동작하지 않거나 바닥이 뚫리는 상황이 생김
// enable 끄기 -> sharedemsh null로 비우기 -> 새로운 mesh 세팅 -> enable 켜기
// 해당 과정으로 Collider를 확실하게 만들어줌
Physics.SyncTransforms();
// Coliider, Transform 변화를 물리엔진에 즉시 반영해줌
}
private bool IsFaceVisible(int nx, int ny, int nz)
{
// 특정 방향 이웃 블록이 비어있는 공간이면 외부로 노출된것으로 판단해줌
BlockType n = GetBlockWithNeighbors(nx, ny, nz); // 경계면은 월드로 넘어가서 조회
return n == BlockType.Air; // 비어 있는 공간이면 면이 보이도록 리턴
}
private enum FaceDir { PosX, NegX, PosY, NegY, PosZ, NegZ }
// 6방향 식별값
// 정점 배열 선택 삼각형 순서 처리에 사용해줌
private static readonly Vector3[] facePosX = { new(1, 0, 0), new(1, 0, 1), new(1, 1, 1), new(1, 1, 0) };
private static readonly Vector3[] faceNegX = { new(0, 0, 1), new(0, 0, 0), new(0, 1, 0), new(0, 1, 1) };
private static readonly Vector3[] facePosY = { new(0, 1, 0), new(1, 1, 0), new(1, 1, 1), new(0, 1, 1) };
private static readonly Vector3[] faceNegY = { new(0, 0, 1), new(1, 0, 1), new(1, 0, 0), new(0, 0, 0) };
private static readonly Vector3[] facePosZ = { new(1, 0, 1), new(0, 0, 1), new(0, 1, 1), new(1, 1, 1) };
private static readonly Vector3[] faceNegZ = { new(0, 0, 0), new(1, 0, 0), new(1, 1, 0), new(0, 1, 0) };
// 각 면은 4개의 정점으로 구성
// 방향마다 정점 순러를 고정하여 노말 방향이 올바르게 나오도록하고, 삼각형이 뒤집히지 않도록
private static void AddFace(
FaceDir dir, Vector3 basePos, BlockType type,
ref int vCount,
List<Vector3> verts, List<int> tris, List<Color32> cols, List<Vector2> uvs)
{
// 블록의 한 면에 Quad 추가, Vertices, triangles, colors, uvs
Vector3[] f = dir switch
{
FaceDir.PosX => facePosX,
FaceDir.NegX => faceNegX,
FaceDir.PosY => facePosY,
FaceDir.NegY => faceNegY,
FaceDir.PosZ => facePosZ,
_ => faceNegZ,
};
// 방향에 맞는 정점 선택
verts.Add(basePos + f[0]);
verts.Add(basePos + f[1]);
verts.Add(basePos + f[2]);
verts.Add(basePos + f[3]);
// 실제 정점 좌표를 블록 기준 위치와 면으로 잡아줌
Color32 c = BlockColor32(type);
cols.Add(c); cols.Add(c); cols.Add(c); cols.Add(c);
// VertexColor 텍스처 대신 색으로 블록 구분
// color32를 사용해 color(float)보다 메모리 효율 올려줌
uvs.Add(new Vector2(0f, 0f));
uvs.Add(new Vector2(1f, 0f));
uvs.Add(new Vector2(1f, 1f));
uvs.Add(new Vector2(0f, 1f));
// UV : 외곽선 셰이더에서 면 가장자리를 판별하기 위해 0~1 사각형 uv를 넣어줌
// 각 면을 하나의 사각형으로 보고 가장자리 감지
if (dir == FaceDir.NegX || dir == FaceDir.NegY || dir == FaceDir.NegZ)
{
tris.Add(vCount + 0); tris.Add(vCount + 2); tris.Add(vCount + 1);
tris.Add(vCount + 0); tris.Add(vCount + 3); tris.Add(vCount + 2);
}
else
{
tris.Add(vCount + 0); tris.Add(vCount + 1); tris.Add(vCount + 2);
tris.Add(vCount + 0); tris.Add(vCount + 2); tris.Add(vCount + 3);
}
// 삼각형 인덱스 , 사각형 면 Quad를 삼각형 두개로
// 방향에 따라 삼각형 정점 뒤집어 유지
// Neg 방향들은 순서를 뒤집어서 앞면이 바깥으로 향하도록
vCount += 4;
// 추가한 4개의 면에 대해서 +4
}
private static Color32 BlockColor32(BlockType t) => t switch
{
BlockType.Grass => new Color32(64, 192, 64, 255),
BlockType.Dirt => new Color32(115, 77, 38, 255),
BlockType.Stone => new Color32(140, 140, 148, 255),
BlockType.Water => new Color32(50, 100, 210, 255),
BlockType.Coal => new Color32(40, 40, 40, 255),
BlockType.Iron => new Color32(190, 165, 140, 255),
_ => new Color32(255, 0, 255, 255),
// 블록 타입에 따른 컬러 넣어줌
};
}
작업을 하면서 저번 Block맵의 코드가 수정되었다
설치는 히트한 블록의 면 방향으로 옆칸에 배치하는 방식으로 구현된다.
설치할 위치가 빈 공간인지 확인하고 인벤토리에서 선택된 아이템이 블록인지 확인하고
인벤토리에서 소비 성공 시에만 월드에 설치하여 준다.
설치에 성공하면 해당 chunk를 리빌드하고 경계면이라면 이웃 chunk도 리빌드 해준다.


상호작용된 블럭에 하이라이트를 추가,
저번 Voxel을 만들었던 거처럼 Project에서 Shader Graph에서 URP -> Unlit으로 생성 다음 알파 컬러값과 알파값 적용
Material을 하나 만들어서 만들어진 Shader 적용
월드의 블록을 바꾸는 것이 아니라 LineRender를 움직여서 표시하는 방식으로 구성했다.
매 프레임 현재 타겟 블록 좌표를 얻어 Outline 오브젝트를 활성화시켜서 위치를 이동시켜 외곽선을 띄워준다.
외곽선은 게임에서 자연스럽게 보이는 외곽선으로 만들기 위해 ShaderGraph 기반(Unlit outline) + Render face 설정을 조정해 구현하였다.

아까 코드를 짜면서 레이어 판별을 하는 부분이 있었는데 해당 부분이다.
Layer에 Player와 world 추가해서 Player는 Player로 레이어로 설정해주고

Gameplay에 있는 BlockInteractor에 HitMask를 world로 설정

다음 버튼 UI Place 설치와 부수기 버튼 Mine을 화면에 배치해주고
HotBar가 되어줄 이미지 텍스트 세개 만들어준다.
다음 프로그레스바도 화면 정가운데에 설치해서 위의 사진과 같이 해줌


설치에 onClick으로 Gameplay의 BlockInteractor를 가져와줌
Mine은 Trigger Event로 up down으로 이벤트 가져오기

Slot HotBar는 Gameplay의 Hotbarinventory가져와서 인덱스는 0,1,2 순서대로

프로그레스바는 스크립트를 가져와 컴포넌트에 넣어주고 이미지는 원으로 설정


다음 Hierarchy에 오브젝트 추가해서 OutLineRender 외곽선 그려주기
width값 조정을 통해 선 굵기 정해주고 HitMask는 world로 해주기 이렇게 하면 GameScene에서 외곽선 볼 수 있음

그리고 오브젝트를 하나 더 만들어서 디버깅용 Scene에서만 보기위한 Highlighter 설정
레이어(Layer) : 오브젝트 분류에서 충돌/ 레이캐스트/ 렌더링같은 태그를 말한다.
레이캐스트 마스크(Raycast Mask / LayerMask) : 레이캐스트가 "어떤 레이어를 맞출지 필터링하는 옵션"
참조 연결(Reference Wiring) : Inspector에서 스크립트 변수 슬롯에 오브젝트를 드래그해서 연결하는 것
런타임 하이라이터(Highlight Renderer) : Gizmo가 아니라 실제로 화면에 그려지는 하이라이트
현재 RayCast가 일부 되지 않는 문제와 Grass블록 아래의 Dirt블록의 Physics가 제대로 동작되지 않아 해당 블록으로 가면 캐릭터가 떨어지는 오류가 있다. 현재는 오류 해결중으로 다음 글에서 제대로 할 예정이다. ㅜㅜ
일단 오늘 성공한 것들



블럭 상호작용 해서 블럭 부수기 -> 동영상이 없어서 사진으로 대체

현재 오류 블럭 부수고 해당 흙으로 가면 캐릭터가 떨어져버림.. 확실한건 석탄과 철도 잘 생성되는거 같음
해결해서 다음글에서 수정해서 올릴 예정
'Unity' 카테고리의 다른 글
| Unity(6) - Atlas Block Map & Crafting Table (2) (0) | 2026.03.10 |
|---|---|
| Unity(5) - Inventory & Crafting (1) | 2026.03.05 |
| Unity(3) - Block Map (1) | 2026.02.19 |
| Unity(2) - Basic Move, Rotation (0) | 2026.02.13 |
| Unity (1) (0) | 2026.02.11 |