나름 여러 번 팀과제를 했지만 3D는 처음이라 무엇이 문제인지 모르겠지만 어쨌든 해결(?)한 문제들과 재밌는 버그가 많았던 것 같다.
트러블 슈팅
Scriptable Object 연결끊김
프로젝트를 병합하는 과정에서 자원과 구조물 등의 Scriptable Object 연결이 끊겼다. 다시 연결해도 Missing이 뜨길래 Scriptable Object를 상속받은 스크립트 파일의 위치를 옮겼다가 다시 원래대로 옮기니까 해결되었다. meta 파일 때문인 것 같은데 정확한 원인은 모르겠다.
외부 객체의 컴포넌트 참조
프로젝트 작업에서 프리팹으로 오브젝트들을 관리했는데 마찬가지로 병합하는 과정에서 연결이 끊겨서 코드로 연결시켰다.
낚시대로 자원 채집
Raycast를 충돌체로 설정해서 모든 충돌체와 상호작용하는 문제였는데 웃겨서 넣었다.
하고 싶은 것들이 많았지만 마감은 맞춰야하니 타협하며 적정선에서 마무리한 것 같다. 마지막 날은 버그 찾는데 시간을 보냈는데 배울 점도 많았고 재밌었다.
제작버튼을 누르면 제작창이 닫히고 미리보기 프리팹이 보이면서 플레이어를 따라가고, 좌클릭하면 프리팹이 사라지고 실제 구조물이 설치된다.
여러 개를 제작할 수 있도록 배열로 구조물들을 저장한다.
[System.Serializable]
public class Craft
{
public string craftName;
public GameObject buildCraft; // 실제 설치되는 구조물
public GameObject previewCraft; // 설치 미리보기
}
public class CraftBuild : MonoBehaviour
{
private bool isPreviewActivated = false;
[SerializeField] private Craft[] craftCampfire;
private GameObject varPreviewCraft; // 미리보기 변수
private GameObject varBuildCraft; // 실제 설치되는 구조물 변수
[SerializeField] private Transform playerPosition;
private RaycastHit hitInfor;
[SerializeField] private LayerMask layerMask;
[SerializeField] private float range;
[SerializeField] private GameObject craftingWindow;
private void Update()
{
if (Input.GetButtonDown("Fire1"))
{
Build();
}
}
private void Build()
{
if (isPreviewActivated)
{
Debug.Log("Create");
Instantiate(varBuildCraft, varPreviewCraft.transform.position, Quaternion.identity);
Destroy(varPreviewCraft);
isPreviewActivated = false;
craftingWindow.SetActive(false);
varPreviewCraft = null;
varBuildCraft = null;
}
}
public void CreateBtnClick(int craftNum)
{
varPreviewCraft = Instantiate(craftCampfire[craftNum].previewCraft, playerPosition.position + playerPosition.forward, Quaternion.identity);
varPreviewCraft.transform.parent = playerPosition;
varBuildCraft = craftCampfire[craftNum].buildCraft;
isPreviewActivated = true;
craftingWindow.SetActive(false);
PlayerController.instance.ToggleCursor(false);
}
}
다른 구조물도 추가할 예정인데 코드가 효율적이진 않은 것 같다. 실전과 이론을 함께 병행하며좀 더 고민해보면 좋을 것 같은데 시간에 대한 아쉬움이 많고 이해력도 좋지 못해서 생각이 오래 걸려서 아쉽다. 구현하고 싶은 건 정말 많은데 어떤 식으로 방향을 잡아야할지 막막하다. 금방 뚝딱 만들 수 있는 경지까진 바라지 않아도 어떻게 구현할지 감을 잡을 수 있으면 좋겠다.
빈 오브젝트를 생성하고 해와 달 역할을 해줄 Directional Light 두 개를 하위에 넣는다.
낮과 밤, 시간이 바뀌게 할 스크립트를 만든다.
0부터 1까지 시간을 조절하도록 하고 게임 내의 하루 시간을 몇으로 할 것인지, 자정의 각도를 몇으로 할 것인지 정할 수 있게 해준다. AnimationCurve는 원하는 각도로 그래프를 그려서 타임 값을 가져올 수 있고, 반대로 타임 값을 주면 그 시간에 맞는 그래프를 가져온다. 시간에 따라 빛의 각도를 조절하여 실제 해가 움직이는 것처럼 만든다.
[Range(0.0f, 1.0f)]
public float time;
public float fullDayLength;
public float startTime = 0.4f;
private float timeRate;
public Vector3 noon;
[Header("Sun")]
public Light sun;
public Gradient sunColor;
public AnimationCurve sunIntensity;
[Header("Moon")]
public Light moon;
public Gradient moonColor;
public AnimationCurve moonIntensity;
[Header("Other Lighting")]
public AnimationCurve lightingIntensityMultiplier;
public AnimationCurve reflectionIntensityMultiplier;
private void Start()
{
timeRate = 1.0f / fullDayLength;
time = startTime;
}
private void Update()
{
time = (time + timeRate * Time.deltaTime) % 1.0f;
UpdateLighting(sun, sunColor,sunIntensity);
UpdateLighting(moon, moonColor,moonIntensity);
RenderSettings.ambientIntensity = lightingIntensityMultiplier.Evaluate(time);
RenderSettings.reflectionIntensity = reflectionIntensityMultiplier.Evaluate(time);
}
void UpdateLighting(Light lightSource, Gradient colorGradiant, AnimationCurve intensityCurve)
{
float intensity = intensityCurve.Evaluate(time);
lightSource.transform.eulerAngles = (time - (lightSource == sun ? 0.25f : 0.75f)) * noon * 4.0f;
lightSource.color = colorGradiant.Evaluate(time);
lightSource.intensity = intensity;
GameObject go = lightSource.gameObject;
if(lightSource.intensity ==0&&go.activeInHierarchy)
go.SetActive(false);
else if( lightSource.intensity>0 &&!go.activeInHierarchy)
go.SetActive(true);
}
유니티로 돌아와서 그래프로 시간에 따른 조명 색을 정해준다.
아이템 상호작용
Interactable 레이어를 추가하여 상호작용 할 수 있는 것들에 대한 처리를 준비한다. 아이템 데이터로 아이템을 생성하기 위해 스크립트를 만든다.
public enum ItemType
{
Resource,
Equipable,
Consumable
}
public enum ConsumableType
{
Hunger,
Health
}
[System.Serializable]
public class ItemDataConsumable
{
public ConsumableType type;
public float value;
}
[CreateAssetMenu(fileName ="Item", menuName = "New Item")]
public class ItemData : ScriptableObject
{
[Header("Info")]
public string displayName;
public string description;
public ItemType type;
public Sprite icon;
public GameObject dropPrefab;
[Header("Stacking")]
public bool canStack;
public int maxStackAmount;
[Header("Consumable")]
public ItemDataConsumable[] consumables;
}
public class ItemObject : MonoBehaviour
{
public ItemData item;
}
InteractionManager 스크립트를 만들고 인터페이스를 ItemData 스크립트에 상속 시킨다.
public interface IInteractable
{
string GetInteractPrompt();
void OnInteract();
}
public class InteractionManager : MonoBehaviour
{
public float checkRate = 0.05f;
private float lastCheckTime;
public float maxCheckDistance;
public LayerMask layerMask;
private GameObject curInteractGameobject;
private IInteractable curInteractable;
public TextMeshProUGUI promptText;
private Camera camera;
// Start is called before the first frame update
void Start()
{
camera = Camera.main;
}
// Update is called once per frame
void Update()
{
if (Time.time - lastCheckTime > checkRate)
{
lastCheckTime = Time.time;
Ray ray = camera.ScreenPointToRay(new Vector3(Screen.width / 2, Screen.height / 2));
RaycastHit hit;
if (Physics.Raycast(ray, out hit, maxCheckDistance, layerMask))
{
if (hit.collider.gameObject != curInteractGameobject)
{
curInteractGameobject = hit.collider.gameObject;
curInteractable = hit.collider.GetComponent<IInteractable>();
SetPromptText();
}
}
else
{
curInteractGameobject = null;
curInteractable = null;
promptText.gameObject.SetActive(false);
}
}
}
private void SetPromptText()
{
promptText.gameObject.SetActive(true);
promptText.text = string.Format("<b>[E]</b> {0}", curInteractable.GetInteractPrompt());
}
public void OnInteractInput(InputAction.CallbackContext callbackContext)
{
if (callbackContext.phase == InputActionPhase.Started && curInteractable != null)
{
curInteractable.OnInteract();
curInteractGameobject = null;
curInteractable = null;
promptText.gameObject.SetActive(false);
}
}
}
public class ItemObject : MonoBehaviour, IInteractable
{
public ItemData item;
public string GetInteractPrompt()
{
return string.Format("Pickup {0}", item.displayName);
}
public void OnInteract()
{
Destroy(gameObject);
}
}
PromptText를 상태창 UI에 추가한다.
Player에 InteractionManager 스크립트를 연결하고 상호작용 문구를 연결한다.
Player에 Interact를 추가하여 상호작용 하도록 한다.
인벤토리 & 아이템 사용
인벤토리를 만들기 위해 Canvas를 만들고 클릭해서 사용할 것이기 때문에 Graphic Raycaster을 추가한다.
스크린 해상도를 설정하고 상태창과 겹쳐졌을 때, 가장 위에 와야하기 때문에 Sort Order을 1로 해준다.
하단에 인벤토리의 배경이 될 Image를 추가한다. 인벤토리 아이템을 선택할 수 있는 창을 만들고 아이템 슬롯을 만들어서 Prefab화 하여 Grid Layout Group으로 묶어준다.
인터페이스 창을 만들어준다.
Inventory 스크립트를 만들고 유니티에서 UI를 연결한다.Slot Prefab에 Outline을 추가하고 선택했을 때 Outline이 켜지고 아이템 정보가 뜨도록 한다. 기존에 같은 아이템을 가지고 있다면 쌓을 수 있는지 여부를 확인하고 쌓는다. 쌓지 못하거나 최대 수량으로 아이템이 쌓여있으면 다른 슬롯에 아이템을 추가한다. 인벤토리가 다 찼을 때, 아이템을 획득하면 랜덤으로 회전하며 떨어뜨린다.
public class ItemSlot
{
public ItemData item;
public int quantity;
}
public class Inventory : MonoBehaviour
{
public ItemSlotUI[] uiSlots;
public ItemSlot[] slots;
public GameObject inventoryWindow;
public Transform dropPosition;
[Header("Selected Item")]
private ItemSlot selectedItem;
private int selectedItemIndex;
public TextMeshProUGUI selectedItemName;
public TextMeshProUGUI selectedItemDescription;
public TextMeshProUGUI selectedItemStatNames;
public TextMeshProUGUI selectedItemStatValues;
public GameObject useButton;
public GameObject equipButton;
public GameObject unEquipButton;
public GameObject dropButton;
private int curEquipIndex;
private PlayerController controller;
private PlayerConditions condition;
[Header("Events")]
public UnityEvent onOpenInventory;
public UnityEvent onCloseInventory;
public static Inventory instance;
void Awake()
{
instance = this;
controller = GetComponent<PlayerController>();
condition = GetComponent<PlayerConditions>();
}
private void Start()
{
inventoryWindow.SetActive(false);
slots = new ItemSlot[uiSlots.Length];
for (int i = 0; i < slots.Length; i++)
{
slots[i] = new ItemSlot();
uiSlots[i].index = i;
uiSlots[i].Clear();
}
ClearSeletecItemWindow();
}
public void OnInventoryButton(InputAction.CallbackContext callbackContext)
{
if(callbackContext.phase == InputActionPhase.Started)
{
Toggle();
}
}
public void Toggle()
{
if (inventoryWindow.activeInHierarchy)
{
inventoryWindow.SetActive(false);
onCloseInventory?.Invoke();
controller.ToggleCursor(false);
}
else
{
inventoryWindow.SetActive(true);
onOpenInventory?.Invoke();
controller.ToggleCursor(true);
}
}
public bool IsOpen()
{
return inventoryWindow.activeInHierarchy;
}
public void AddItem(ItemData item)
{
if(item.canStack)
{
ItemSlot slotToStackTo = GetItemStack(item);
if(slotToStackTo != null)
{
slotToStackTo.quantity++;
UpdateUI();
return;
}
}
ItemSlot emptySlot = GetEmptySlot();
if(emptySlot != null)
{
emptySlot.item = item;
emptySlot.quantity = 1;
UpdateUI();
return;
}
ThrowItem(item);
}
void ThrowItem(ItemData item)
{
Instantiate(item.dropPrefab, dropPosition.position, Quaternion.Euler(Vector3.one * Random.value * 360f));
}
void UpdateUI()
{
for(int i = 0; i< slots.Length; i++)
{
if (slots[i].item != null)
uiSlots[i].Set(slots[i]);
else
uiSlots[i].Clear();
}
}
ItemSlot GetItemStack(ItemData item)
{
for (int i = 0; i < slots.Length; i++)
{
if (slots[i].item == item && slots[i].quantity < item.maxStackAmount)
return slots[i];
}
return null;
}
ItemSlot GetEmptySlot()
{
for (int i = 0; i < slots.Length; i++)
{
if (slots[i].item == null)
return slots[i];
}
return null;
}
public void SelectItem(int index)
{
if (slots[index].item == null)
return;
selectedItem = slots[index];
selectedItemIndex = index;
selectedItemName.text = selectedItem.item.displayName;
selectedItemDescription.text = selectedItem.item.description;
selectedItemStatNames.text = string.Empty;
selectedItemStatValues.text = string.Empty;
for(int i = 0; i< selectedItem.item.consumables.Length; i++)
{
selectedItemStatNames.text += selectedItem.item.consumables[i].type.ToString() + "\n";
selectedItemStatValues.text += selectedItem.item.consumables[i].value.ToString() + "\n";
}
useButton.SetActive(selectedItem.item.type == ItemType.Consumable);
equipButton.SetActive(selectedItem.item.type == ItemType.Equipable && !uiSlots[index].equipped);
unEquipButton.SetActive(selectedItem.item.type == ItemType.Equipable && uiSlots[index].equipped);
dropButton.SetActive(true);
}
private void ClearSeletecItemWindow()
{
selectedItem = null;
selectedItemName.text = string.Empty;
selectedItemDescription.text = string.Empty;
selectedItemStatNames.text = string.Empty;
selectedItemStatValues.text = string.Empty;
useButton.SetActive(false);
equipButton.SetActive(false);
unEquipButton.SetActive(false);
dropButton.SetActive(false);
}
public void OnUseButton()
{
if(selectedItem.item.type == ItemType.Consumable)
{
for (int i = 0; i < selectedItem.item.consumables.Length; i++)
{
switch (selectedItem.item.consumables[i].type)
{
case ConsumableType.Health:
condition.Heal(selectedItem.item.consumables[i].value); break;
case ConsumableType.Hunger:
condition.Eat(selectedItem.item.consumables[i].value); break;
}
}
}
RemoveSelectedItem();
}
public void OnEquipButton()
{
}
void UnEquip(int index)
{
}
public void OnUnEquipButton()
{
}
public void OnDropButton()
{
ThrowItem(selectedItem.item);
RemoveSelectedItem();
}
private void RemoveSelectedItem()
{
selectedItem.quantity--;
if(selectedItem.quantity <= 0 )
{
if (uiSlots[selectedItemIndex].equipped )
{
UnEquip(selectedItemIndex);
}
selectedItem.item = null;
ClearSeletecItemWindow();
}
UpdateUI();
}
public void RemoveItem(ItemData item)
{
}
public bool HasItems(ItemData item, int quantity)
{
return false;
}
}
아이템의 떨어지는 위치를 지정하기 위해 Player 오브젝트에 오브젝트를 추가하고 연결한다.
마찬가지로 ItemUISlot 스크립트를 만들어서 Slot Prefab에 적용한다.
public Button button;
public Image icon;
public TextMeshProUGUI quatityText;
private ItemSlot curSlot;
private Outline outline;
public int index;
public bool equipped;
private void Awake()
{
outline = GetComponent<Outline>();
}
private void OnEnable()
{
outline.enabled = equipped;
}
public void Set(ItemSlot slot)
{
curSlot = slot;
icon.gameObject.SetActive(true);
icon.sprite = slot.item.icon;
quatityText.text = slot.quantity > 1 ? slot.quantity.ToString() : string.Empty;
if (outline != null)
{
outline.enabled = equipped;
}
}
public void Clear()
{
curSlot = null;
icon.gameObject.SetActive(false);
quatityText.text = string.Empty;
}
public void OnButtonClick()
{
Inventory.instance.SelectItem(index);
}
Button을 달고 Button에 자기 자신을 연결시켜준다.
아이템들을 Prefab화 시키고 아이템 데이터에 연결한다.
Player에 인벤토리를 연결하여 탭을 눌렀을 때, 인벤토리 창이 켜지도록 한다.
ItemObject 스크립트에 아이템을 주웠을 때, 인벤토리 창에 아이템이 추가되도록 코드를 추가한다.
public void OnInteract()
{
Inventory.instance.AddItem(item);
Destroy(gameObject);
}