광물을 통해 장비 아이템을 제작하여 캐릭터한테 입히는 부분을 하였는데
이와 같은 광물을 만들기 위해 사용할 용광로 블록을 적용하는 작업을 진행하였다
월드 맵에서 광물 블럭을 캐고 해당 광물 블럭을 용광로에 넣고 아래에는 나무 또는 석탄을 통하여 광물을 녹여 주괴로 만드는 과정이다
용광로 또한 패널을 새 창으로 만들어 관리하는 것이 아닌 인벤토리 패널을 이용하여 재사용하여 만들었으며
용광로의 레시피 광물 블록 , 광물 , 결과개수, 걸리는시간
용광로의 에너지 연료, 사용할 수 있는 시간
이런식의 csv를 이용해 정리하였다
using System;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
[Serializable]
public class FurnaceRecipeDefinition
{
public int recipeId; // 이름
public ItemId inputItemId; // 넣을 아이템
public ItemId outputItemId; // 결과 아이템
public int outputCount; // 결과 개수
public float smeltSeconds; // 녹는 시간
}
[Serializable]
public class FurnaceFuelDefinition
{
public ItemId itemId; // 연료 이름
public float burnSeconds; // 소모 초
}
public static class FurnaceDataDatabase
{
private static readonly Dictionary<ItemId, FurnaceRecipeDefinition> recipeByInput =
new Dictionary<ItemId, FurnaceRecipeDefinition>();
private static readonly Dictionary<ItemId, FurnaceFuelDefinition> fuelByItem =
new Dictionary<ItemId, FurnaceFuelDefinition>();
// csv id 를 통해 아이템 찾기
public static bool IsLoaded { get; private set; } // 데이터 로드 확인
public static int RecipeCount => recipeByInput.Count;
public static int FuelCount => fuelByItem.Count;
public static void Load(TextAsset recipeCsv, TextAsset fuelCsv)
{
// 이전 로드된 데이터 초기화
Clear();
if (!ItemDatabase.IsLoaded)
{
Debug.LogError(
"[FurnaceDataDatabase] ItemDatabase must be loaded before furnace data."
);
return;
}
LoadRecipes(recipeCsv);
LoadFuels(fuelCsv);
IsLoaded = true;
Debug.Log(
$"[FurnaceDataDatabase] Loaded. " +
$"Recipes={RecipeCount}, Fuels={FuelCount}"
);
}
public static void Clear()
{
recipeByInput.Clear();
fuelByItem.Clear();
IsLoaded = false;
}
public static bool TryGetRecipe(ItemId inputItemId, out FurnaceRecipeDefinition recipe)
{
return recipeByInput.TryGetValue(inputItemId, out recipe);
// 아이템 레시피 가져오기
}
public static bool IsSmeltable(ItemId inputItemId)
{
return recipeByInput.ContainsKey(inputItemId);
// 제련 가능한 아이템인지 확인
}
public static bool TryGetFuel(ItemId fuelItemId, out FurnaceFuelDefinition fuel)
{
return fuelByItem.TryGetValue(fuelItemId, out fuel);
// 연료로 사용이 가능한지 확인
}
private static void LoadRecipes(TextAsset recipeCsv)
{
if (recipeCsv == null)
{
Debug.LogError("[FurnaceDataDatabase] FurnaceRecipeTable.csv is not assigned.");
return;
}
List<Dictionary<string, string>> rows = CsvTableReader.Read(recipeCsv);
foreach (Dictionary<string, string> row in rows)
{
int recipeId = GetInt(row, "recipeId", 0);
string inputKey = GetString(row, "inputKey");
string outputKey = GetString(row, "outputKey");
int outputCount = Mathf.Max(1, GetInt(row, "outputCount", 1));
float smeltSeconds = Mathf.Max(0.1f, GetFloat(row, "smeltSeconds", 4f));
if (recipeId <= 0)
{
Debug.LogWarning("[FurnaceDataDatabase] Ignored furnace recipe with invalid recipeId.");
continue;
}
if (!TryResolveItemKey(inputKey, out ItemId inputItemId) ||
!TryResolveItemKey(outputKey, out ItemId outputItemId))
{
Debug.LogWarning(
$"[FurnaceDataDatabase] Ignored furnace recipeId={recipeId}. " +
$"inputKey='{inputKey}', outputKey='{outputKey}'"
);
continue;
}
if (recipeByInput.ContainsKey(inputItemId))
{
Debug.LogWarning(
$"[FurnaceDataDatabase] Duplicate furnace input recipe ignored. " +
$"inputKey='{inputKey}', recipeId={recipeId}"
);
continue;
}
recipeByInput.Add(
inputItemId,
new FurnaceRecipeDefinition
{
recipeId = recipeId,
inputItemId = inputItemId,
outputItemId = outputItemId,
outputCount = outputCount,
smeltSeconds = smeltSeconds
}
);
}
}
private static void LoadFuels(TextAsset fuelCsv)
{
if (fuelCsv == null)
{
Debug.LogError("[FurnaceDataDatabase] FurnaceFuelTable.csv is not assigned.");
return;
}
List<Dictionary<string, string>> rows = CsvTableReader.Read(fuelCsv);
foreach (Dictionary<string, string> row in rows)
{
string itemKey = GetString(row, "itemKey");
float burnSeconds = Mathf.Max(0.1f, GetFloat(row, "burnSeconds", 0f));
if (!TryResolveItemKey(itemKey, out ItemId itemId))
{
Debug.LogWarning(
$"[FurnaceDataDatabase] Ignored fuel row. itemKey='{itemKey}'"
);
continue;
}
if (burnSeconds <= 0f)
{
Debug.LogWarning(
$"[FurnaceDataDatabase] Ignored fuel with invalid burnSeconds. itemKey='{itemKey}'"
);
continue;
}
if (fuelByItem.ContainsKey(itemId))
{
Debug.LogWarning(
$"[FurnaceDataDatabase] Duplicate fuel item ignored. itemKey='{itemKey}'"
);
continue;
}
fuelByItem.Add(
itemId,
new FurnaceFuelDefinition
{
itemId = itemId,
burnSeconds = burnSeconds
}
);
}
}
private static bool TryResolveItemKey(string itemKey, out ItemId itemId)
{
// key 값을 id로
itemId = ItemId.None;
if (string.IsNullOrWhiteSpace(itemKey))
return false;
if (!ItemDatabase.TryGet(itemKey.Trim(), out ItemDefinition definition))
return false;
itemId = (ItemId)definition.id;
return itemId != ItemId.None;
}
private static string GetString(Dictionary<string, string> row, string key)
{
// 문자열로
if (row != null && row.TryGetValue(key, out string value))
return value?.Trim() ?? string.Empty;
return string.Empty;
}
private static int GetInt(Dictionary<string, string> row, string key, int defaultValue)
{
// 정수로
string raw = GetString(row, key);
return int.TryParse(
raw,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int value
)
? value
: defaultValue;
}
private static float GetFloat(Dictionary<string, string> row, string key, float defaultValue)
{
// id를 float로
string raw = GetString(row, key);
return float.TryParse(
raw,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out float value
)
? value
: defaultValue;
}
}
FurnaceDataDatabase.cs
csv파일의 Furnace 레시피를 읽어서 Dictionary에 저장
using System;
using UnityEngine;
public enum FurnaceSlot : byte
{
Input, // 제련할 광석
Fuel, // 석탄 나무 연료
Output // 제련 결과
}
[Serializable]
public class FurnaceState
{
private const float Epsilon = 0.0001f;
// 용광로의 세 기본 슬롯
private ItemStack input = new ItemStack(ItemId.None, 0);
private ItemStack fuel = new ItemStack(ItemId.None, 0);
private ItemStack output = new ItemStack(ItemId.None, 0);
// 현재 한 번의 제련에 누적된 시간과 해당 제련의 총 시간
private float smeltElapsedSeconds;
private float currentSmeltDurationSeconds;
// 이미 소모한 연료 1개에서 남은 연소 시간과 그 연료의 총 연소 시간
private float fuelRemainingSeconds;
private float currentFuelDurationSeconds;
public ItemStack Input => input;
public ItemStack Fuel => fuel;
public ItemStack Output => output;
public float SmeltProgress01
{
get
{
if (currentSmeltDurationSeconds <= Epsilon)
return 0f;
return Mathf.Clamp01(smeltElapsedSeconds / currentSmeltDurationSeconds);
}
}
public float FuelProgress01
{
get
{
if (currentFuelDurationSeconds <= Epsilon || fuelRemainingSeconds <= Epsilon)
return 0f;
return Mathf.Clamp01(fuelRemainingSeconds / currentFuelDurationSeconds);
}
}
public bool IsSmelting { get; private set; }
public ItemStack Get(FurnaceSlot slot)
{
return slot switch
{
FurnaceSlot.Input => input,
FurnaceSlot.Fuel => fuel,
FurnaceSlot.Output => output,
_ => new ItemStack(ItemId.None, 0)
};
// 아이템 반환
}
public void Set(FurnaceSlot slot, ItemStack stack)
{
ItemStack normalized = Normalize(stack);
// stack을 정규화
switch (slot)
{
case FurnaceSlot.Input:
{
bool inputTypeChanged = input.id != normalized.id;
// 아이템 확인
input = normalized;
// 값 교체
if (inputTypeChanged)
ResetSmeltProgress();
// 아이템이 바뀌었다면 진행도 초기화
IsSmelting = false;
break;
}
case FurnaceSlot.Fuel:
fuel = normalized;
// 이미 점화된 연료는 슬롯의 연료 아이템과 별도로 남은 시간을 보관
// 따라서 Fuel 슬롯을 바꿔도 현재 태우는 연료 시간이 즉시 사라지지는 않음
IsSmelting = false;
break;
case FurnaceSlot.Output:
output = normalized; // 결과값 정규화, 플레이어가 직접 결과슬롯에 넣는것을 막음
IsSmelting = false;
break;
}
}
public bool Tick(float deltaTime)
{
// 매 프레임 재련 상태를 false로 초기화
IsSmelting = false;
if (deltaTime <= 0f || !FurnaceDataDatabase.IsLoaded)
return false;
// 입력 아이템을 찾아서
// 입력 아이템이 제련 레시피가 아니면 기존 진행도를 초기화
if (!FurnaceDataDatabase.TryGetRecipe(input.id, out FurnaceRecipeDefinition recipe))
return ResetSmeltProgress();
// 결과 슬롯이 가득 찼거나 다른 아이템을 들고 있으면 진행을 멈춤
// 플레이어가 결과를 꺼낸 뒤 이어서 제련할 수 있도록 진행도를 보존
if (!CanAcceptOutput(recipe))
return false;
bool changed = false; // 상태 확인
float remainingDelta = deltaTime;
// 프레임 지연이 커졌을 때에도 연료,제련 완료 처리가 꼬이지 않도록
// 남은 시간 안에서 필요한 만큼만 반복 처리합니다.
while (remainingDelta > Epsilon)
{
// 앞의 반복에서 제련이 완료되어 Input이 비었거나 Output이 가득 찼을 수 있으므로
// 매 반복마다 다시 확인
if (!FurnaceDataDatabase.TryGetRecipe(input.id, out recipe))
{
changed |= ResetSmeltProgress();
break;
}
if (!CanAcceptOutput(recipe))
break;
// 현재 태우는 연료가 끝났다면 Fuel 슬롯에서 다음 연료 1개를 꺼냄
if (fuelRemainingSeconds <= Epsilon)
{
if (!TryConsumeNextFuel())
break;
changed = true;
}
// 레시피 제련 시간 설정
currentSmeltDurationSeconds = Mathf.Max(Epsilon, recipe.smeltSeconds);
float secondsToComplete = Mathf.Max(
0f,
currentSmeltDurationSeconds - smeltElapsedSeconds
// 제련 완료까지의 시간 계산
);
// 실제 진행한 시간,남은 프레임 ,남은 연료, 제련 완료 중 가장 작은 시간 값만 진행
float step = Mathf.Min(
remainingDelta,
fuelRemainingSeconds,
secondsToComplete
);
// 부동소수점 오차 등으로 0초 진행 상태에 빠지지 않도록 방어합니다
if (step <= Epsilon)
{
if (secondsToComplete <= Epsilon)
{
CompleteOneSmelt(recipe);
changed = true;
continue;
}
break;
}
smeltElapsedSeconds += step; // 제련 시간 증가
fuelRemainingSeconds = Mathf.Max(0f, fuelRemainingSeconds - step);
// 연료 남은 시간 감소
remainingDelta -= step; // 프레임 남은 처리 시간 감소
IsSmelting = true; // 제련 성공ㄴ
changed = true;
// 제련 완료 시 Input 1개를 소비하고 결과를 Output에 추가
if (smeltElapsedSeconds + Epsilon >= currentSmeltDurationSeconds)
{
CompleteOneSmelt(recipe);
changed = true;
}
}
return changed;
}
public bool HasAnyItem()
{
return !input.IsEmpty || !fuel.IsEmpty || !output.IsEmpty;
// 용광로 파괴 -> 내부 아이템 반환
}
private bool ResetSmeltProgress()
{
// 현재 제련 진행도 초기화
bool hadProgress =
smeltElapsedSeconds > Epsilon ||
currentSmeltDurationSeconds > Epsilon;
smeltElapsedSeconds = 0f;
currentSmeltDurationSeconds = 0f;
return hadProgress;
}
private bool TryConsumeNextFuel()
{
if (fuel.IsEmpty)
return false;
if (!FurnaceDataDatabase.TryGetFuel(fuel.id, out FurnaceFuelDefinition fuelDefinition))
return false;
fuelRemainingSeconds = Mathf.Max(Epsilon, fuelDefinition.burnSeconds);
// 연료 시간 설정
currentFuelDurationSeconds = fuelRemainingSeconds;
// 현재 연료의 전체 시간 저장
// ui 연료 게이지 비율 계산 시 사용
fuel.count--; // 연료 소비
if (fuel.count <= 0)
fuel = new ItemStack(ItemId.None, 0);
return true;
}
private bool CanAcceptOutput(FurnaceRecipeDefinition recipe)
{
// 결과값 슬롯 표시
if (recipe == null || recipe.outputItemId == ItemId.None)
return false;
if (output.IsEmpty)
return recipe.outputCount <= ItemDb.MaxStack(recipe.outputItemId);
if (output.id != recipe.outputItemId)
return false;
int maxStack = ItemDb.MaxStack(output.id);
return output.count <= maxStack - recipe.outputCount;
}
private void CompleteOneSmelt(FurnaceRecipeDefinition recipe)
{
input.count--; // input아이템 1개 소비
if (input.count <= 0)
input = new ItemStack(ItemId.None, 0);
if (output.IsEmpty)
{
output = new ItemStack(recipe.outputItemId, recipe.outputCount);
// 결과 슬롯이 비어있다면 새로운 결과 스택 생성
}
else
{
output.count += recipe.outputCount;
// 같은 결과물이 이미 존재한다면 수량 추가
}
// 한 번의 제련이 끝났으므로 진행도 초기화
smeltElapsedSeconds = 0f;
currentSmeltDurationSeconds = 0f;
}
private static ItemStack Normalize(ItemStack stack)
{
if (stack.IsEmpty)
return new ItemStack(ItemId.None, 0);
// 비어있다면 0으로
int maxStack = ItemDb.MaxStack(stack.id);
// 아이템 최대 스택 수 확인
int count = Mathf.Clamp(stack.count, 1, maxStack);
// 개수를 1~ maxstack으로 제한
return new ItemStack(stack.id, count); // 반환
}
}
FurnaceState.cs
용광로의 데이터 클래스로
유저가 패널 인벤토리를 닫아도 용광로의 내부 아이템과 제련 진행도가 유지되도록 만들었다
using System;
using System.Collections.Generic;
using UnityEngine;
[DisallowMultipleComponent]
public class FurnaceWorldController : MonoBehaviour
{
[Header("References")]
[Tooltip("비워 두면 같은 GameObject에 있는 VoxelWorld를 자동으로 찾습니다.")]
[SerializeField] private VoxelWorld world;
[Header("Runtime")]
[Tooltip("체크되어 있으면 UI가 닫혀 있어도 모든 용광로의 제련을 계속 진행합니다.")]
[SerializeField] private bool runSmelting = true;
private readonly Dictionary<Vector3Int, FurnaceState> states =
new Dictionary<Vector3Int, FurnaceState>();
private readonly List<Vector3Int> invalidFurnacePositions =
new List<Vector3Int>();
public event Action<Vector3Int> StateChanged;
// InventoryUIController가 해당 이벤트 구독하고 갱신
private void Awake()
{
if (world == null)
world = GetComponent<VoxelWorld>();
if (world == null)
{
Debug.LogError(
"[FurnaceWorldController] VoxelWorld reference is missing. " +
"Attach this component to the same GameObject as VoxelWorld, " +
"or assign VoxelWorld in the Inspector."
);
}
}
private void Update()
{
if (!runSmelting || world == null || !FurnaceDataDatabase.IsLoaded)
return;
if (states.Count == 0)
return;
// 이전 프레임의 제거 대상 목록 지우기
invalidFurnacePositions.Clear();
foreach (KeyValuePair<Vector3Int, FurnaceState> pair in states)
{
Vector3Int position = pair.Key; // 용광로의 월드 좌표
FurnaceState state = pair.Value; // 해당 좌표의 FurnaceState
// 해당 좌표의 블록이 용광로가 아니라면 용광로가 파괴되었거나 다른 블록으로 바뀐 것
if (!IsFurnacePosition(position))
{
invalidFurnacePositions.Add(position);
continue;
}
bool stateChanged = state.Tick(Time.deltaTime);
// 제련 로직을 한 프레임 만큼 진행
bool worldAppearanceChanged = UpdateWorldLitState(position, state.IsSmelting);
// 실제로 제련이 진행되는 동안에는 불타는 용광로 상태로 보여주고
// 아니라면 일반용광로 상태로 만들어줌
if (stateChanged || worldAppearanceChanged)
StateChanged?.Invoke(position);
}
// foreach가 끝난 뒤 제거해야 Dictionary 열거 예외가 발생하지 않음
for (int i = 0; i < invalidFurnacePositions.Count; i++)
{
Vector3Int position = invalidFurnacePositions[i];
if (states.Remove(position))
StateChanged?.Invoke(position);
}
}
public bool IsFurnacePosition(Vector3Int position)
{
// 해당 좌표에 있는 블록이 용광로인지 확인
if (world == null)
return false;
BlockType blockType = world.GetBlockWorld(position.x, position.y, position.z);
return blockType == BlockType.Furnace ||
blockType == BlockType.FurnaceLit;
}
public bool TryOpenFurnace(Vector3Int position, out FurnaceState state)
{
// 용광로의 상태
state = null;
if (!IsFurnacePosition(position))
return false;
state = GetOrCreateState(position);
return true;
}
public ItemStack GetStack(Vector3Int position, FurnaceSlot slot)
{
// 용광로의 슬롯 데이터 읽기
if (TryOpenFurnace(position, out FurnaceState state))
return state.Get(slot);
return new ItemStack(ItemId.None, 0);
}
public bool SetStack(Vector3Int position, FurnaceSlot slot, ItemStack stack)
{
// 해당 위치의 용광로 상태를 가져와 생성
if (!TryOpenFurnace(position, out FurnaceState state))
return false;
state.Set(slot, stack); // 용광로 내부 슬롯 값 변경
// ui를 최신 상태로 갱신하기 위해 이벤트 발생
StateChanged?.Invoke(position);
return true;
}
public bool TryGetProgress(
// 용광로에 표시할 진행도 정보 반환
Vector3Int position,
out float smeltProgress01,
out float fuelProgress01,
out bool isSmelting)
{
// 기본값 초기화
smeltProgress01 = 0f;
fuelProgress01 = 0f;
isSmelting = false;
// 해당 위치의 용광로 상태를 가져옴
if (!TryOpenFurnace(position, out FurnaceState state))
return false;
smeltProgress01 = state.SmeltProgress01; // 제련 진행도
fuelProgress01 = state.FuelProgress01; // 연료 진행도
isSmelting = state.IsSmelting; // 제련 중 여부
return true;
}
public bool TryGetState(Vector3Int position, out FurnaceState state)
{
// 이미 생성된 용광로가 있는지 확인
return states.TryGetValue(position, out state);
}
public void RemoveState(Vector3Int position)
{
// 용광로가 제거 된 뒤 상태를 삭제
if (states.Remove(position))
StateChanged?.Invoke(position);
}
private bool UpdateWorldLitState(Vector3Int position, bool shouldBeLit)
{
// 현재 월드에 있는 블록 타입 가져옴
BlockType currentType = world.GetBlockWorld(position.x, position.y, position.z);
if (currentType != BlockType.Furnace && currentType != BlockType.FurnaceLit)
return false;
// 제련 상태에 따라 불타는 용광로와 일반 용광로 구분
BlockType desiredType = shouldBeLit
? BlockType.FurnaceLit
: BlockType.Furnace;
// 이미 바뀌어진 상태라면 변경 x
if (currentType == desiredType)
return false;
// 해당 블록이 플레이어 설치 블록임을 보존
bool placedByPlayer = world.GetBlockPlacedByPlayerWorld(
position.x,
position.y,
position.z
);
// 월드 블록 타입 변경
return world.TrySetBlockWorld(
position.x,
position.y,
position.z,
desiredType,
placedByPlayer
);
}
private FurnaceState GetOrCreateState(Vector3Int position)
{
// 해당 위치의 용광로 상태를 가져오거나 생성
if (states.TryGetValue(position, out FurnaceState state))
return state;
// 이미 상태가 있다면 반환
state = new FurnaceState(); // 없으면 생성
states.Add(position, state); // Dictionary에 등록
return state;
}
}
FurnaceWorldController.cs
해당 파일에서의 Update를 통해 제련을 처리하여 UI가 닫혀 있어도 용광로가 계속 작동하도록 하였음
또한 제련 중인 것을 확인하여 월드 블록에서 불타고있는 용광로와 일반 용광로의 블록 상태를 전환시켜줌
작동 영상 링크
다음은 캐릭터가 방어구가 장착하였을 경우 실제 몬스터에게 데미지에 대한 감소가 적용
using UnityEngine;
[DisallowMultipleComponent]
public class EquipmentStatController : MonoBehaviour
{
[Header("References")]
[Tooltip("방어력 최종값을 적용할 플레이어의 CharacterStats")]
[SerializeField] private CharacterStats characterStats;
[Tooltip("Head / Chest / Legs / Feet 장비 데이터를 가진 EquipmentInventory")]
[SerializeField] private EquipmentInventory equipmentInventory;
private bool subscribed; // 이벤트 구독 확인, 중복 구독 방지
private bool loggedMissingReferenceWarning;
// 매 프레임 참조 누락 로그를 반복 출력하는걸 방지
private void Awake()
{
ResolveReferences();
// inspectot 참조 누락 방지, 자동으로 찾음
}
private void OnEnable()
{
ResolveReferences(); // 오브젝트 활성화 시 참조 다시 확인
Subscribe(); // 장비 교체에 대한 이벤트 구독 확인
RefreshEquipmentDefense(); // 현재 장착 기준으로 스탯 적용
}
private void OnDisable()
{
Unsubscribe(); // 오브젝트 비활성화 상태면 구독 해제
}
private void ResolveReferences()
{
if (characterStats == null)
characterStats = GetComponent<CharacterStats>();
if (characterStats == null)
characterStats = GetComponentInParent<CharacterStats>();
if (characterStats == null)
characterStats = GetComponentInChildren<CharacterStats>();
if (equipmentInventory == null)
equipmentInventory = GetComponent<EquipmentInventory>();
if (equipmentInventory == null)
equipmentInventory = GetComponentInParent<EquipmentInventory>();
if (equipmentInventory == null)
equipmentInventory = GetComponentInChildren<EquipmentInventory>();
if (equipmentInventory == null)
equipmentInventory = FindFirstObjectByType<EquipmentInventory>();
// 참조 찾기
}
private void Subscribe()
{
// 이미 구독했거나 EquipmentInventory가 없다면 중단
if (subscribed || equipmentInventory == null)
return;
// 장비 상태가 바뀔 때마다 교체 이벤트 구독
equipmentInventory.Changed += HandleEquipmentChanged;
subscribed = true; // 중복 구독 방지 true 표시
}
private void Unsubscribe()
{
if (!subscribed || equipmentInventory == null)
return;
equipmentInventory.Changed -= HandleEquipmentChanged; // 이벤트 구독 해제
subscribed = false;// false로 구독 안함 표시
}
private void HandleEquipmentChanged()
{
RefreshEquipmentDefense();
// 장비가 변경되는 경우 방어력 합계를 다시 계산
}
public void RefreshEquipmentDefense()
{
// 필수 참조가 없으면 계산 x
if (characterStats == null || equipmentInventory == null)
{
// 같은 경고가 계속 반복되지 않도록 한번만 출력
if (!loggedMissingReferenceWarning)
{
Debug.LogWarning(
"[EquipmentStatController] CharacterStats or EquipmentInventory reference is missing. " +
"Assign both references in the Inspector."
);
loggedMissingReferenceWarning = true;
}
return;
}
// 참조가 정상이라면 경고 로그 초기화
loggedMissingReferenceWarning = false;
// 현재 장착된 모든 장비의 defense 합계 구하기
int totalDefense = equipmentInventory.TotalDefense();
// 최종 defense 계산은 CharacterStats 내부에서 처리
characterStats.SetEquipmentDefense(totalDefense);
}
}
EquipmentStatController.cs
EquipmentInventory를 통해 장착된 아이템으로 플레이어의 스탯을 바꾸게 되면 장비 데이터 관리와 캐릭터 전투 스탯 관리가 한 파일에 섞이게 된다 그렇기에 따로 StatController를 통해 장비 시스템과 스탯 시스템을 연결하여 조정
public void SetEquipmentDefense(float value)
{
float normalizedDefense = Mathf.Max(0f, value);
if (Mathf.Approximately(equipmentDefense, normalizedDefense))
return;
equipmentDefense = normalizedDefense;
OnStatsChanged?.Invoke(this);
}
public void TakeDamage(float amount)
{
if (IsDead)
return;
// 이미 죽었으면 피해 x
if (amount <= 0f)
return;
// 0 이하의 피해는 무시
float finalDamage = Mathf.Max(1f, amount - Defense);
// 방어력이 공격력보다 높아도 최소 1의 피해는 보장
LastDamageTaken = finalDamage; // 최근 받은 피해
currentHealth -= finalDamage; // 피해량 만큼 현재 체력 감소
currentHealth = Mathf.Max(0f, currentHealth); // 0이하로 내려가지 않게 방지
if (logDamageCalculation)
{
Debug.Log(
$"[{name}] Damage Received | " +
$"Raw={amount:0.##}, " +
$"Defense={Defense:0.##}, " +
$"Final={finalDamage:0.##}, " +
$"HP={currentHealth:0.##}/{MaxHealth:0.##}"
);
}
OnHealthChanged?.Invoke(this); // 체력 변동 되었으니 UI로 이벤트 알림
if (currentHealth <= 0f) // 0 이하면 사망 처리
Die();
}
CharacterStat.cs
장비 방어력 반영 및 데미지 적용
public bool SetSlot(int index, ItemStack stack)
{
if (!CanPlace(index, stack))
return false;
// 해당 슬롯에 현재 아이템을 넣을 수 있는지 검사
//
switch (index)
{
case 0:
head = stack;
break;
case 1:
chest = stack;
break;
case 2:
legs = stack;
break;
case 3:
feet = stack;
break;
default:
return false;
}
// 번호에 맞는 실제 장비 필드에 저장
Changed?.Invoke();
// 장비 상태 변경 이벤트 발생, 나중 캐릭터한테 직접 입혀주거나 스탯 재계산할때 사용
return true;
// 장착 성공
}
public int TotalDefense()
{
int total = 0;
total += ItemDb.Defense(head.id);
total += ItemDb.Defense(chest.id);
total += ItemDb.Defense(legs.id);
total += ItemDb.Defense(feet.id);
return total;
// 현재 장착된 모든 장비의 방어력 합계 반환하기
// 몬스터 한테 피격될 때 데미지 감소를 위해
}
public CsvEquipmentSlot IndexToEquipmentSlot(int index)
{
return index switch
{
0 => CsvEquipmentSlot.Head,
1 => CsvEquipmentSlot.Chest,
2 => CsvEquipmentSlot.Legs,
3 => CsvEquipmentSlot.Feet,
_ => CsvEquipmentSlot.None
};
// 장비 슬롯 indxe를 CsvEquipmentSlot으로 변환
// Inventory UI에서는 슬롯을 index로 다루고
// csv 데이터에서는 장비 부위를 head, chest 로 다루기에 변환이 필요
}
EquipmentInventory.cs
해당 부분에서 장비 변경 이벤트 발생 확인
장비 방어력 합산
csv를 slot index로 변환
private static ItemDefinition ParseItem(Dictionary<string, string> row)
{
int id = GetInt(row, "id", 0);
// CSV의 id 값 읽기
if (id <= 0)
{
Debug.LogWarning("[ItemDatabase] Invalid item id.");
return null;
}
string key = GetString(row, "key"); // CSV의 key 값
string displayName = GetString(row, "displayName"); // CSV의 UI 표출 텍스트 값
ItemDefinition def = new ItemDefinition
{
id = id,
key = key,
displayName = string.IsNullOrWhiteSpace(displayName) ? key : displayName,
// displayName이 비어 있으면 key를 표시 이름으로 사용
kind = ParseKind(GetString(row, "kind")),
// 종류 문자열을 enum으로 변환
maxStack = Mathf.Max(1, GetInt(row, "maxStack", 64)),
// 최대 스택 수 64 기본 , 최소 1
iconKey = NormalizeOptional(GetString(row, "iconKey")), // 아이콘 매칭 key
blockTypeName = NormalizeOptional(GetString(row, "blockType")),
// 블록 아이템인 경우 BlockType 으로
toolKindName = NormalizeOptional(GetString(row, "toolKind")),
// 도구 아이템인 경우 도구 종류
equipmentSlot = ParseEquipmentSlot(GetString(row, "equipmentSlot")),
// 장비 아이템인 경우 장착 슬롯으로 분류
attackPower = GetInt(row, "attackPower", 0),
// 공격력
defense = GetInt(row, "defense", 0)
// 방어력
};
return def;
// 완성된 Item 정보 반환
}
ItemDataBase.cs
csv를 통해 아이템의 정보 조회
장비 부위, 방어력 조회해줌
public static int Defense(ItemId id)
{
if (ItemDatabase.IsLoaded)
return ItemDatabase.GetDefense((int)id);
//ItemDatabase가 로드되어있으면 csv에서 읽어온 defense값을 반환
return 0;
}
public static CsvEquipmentSlot EquipmentSlot(ItemId id)
{
if (ItemDatabase.IsLoaded)
return ItemDatabase.GetEquipmentSlot((int)id);
return CsvEquipmentSlot.None;
}
ItemType.cs
csv통해 조회된 값을 반환해줌
using UnityEngine;
using UnityEngine.UI;
public class PlayerHealthUI : MonoBehaviour
{
[Header("Refs")]
[SerializeField] private CharacterStats playerStats;
[Header("UI")]
[SerializeField] private Image hpFillImage; // Fill Bar Image
[SerializeField] private Text hpText; // hp 숫자 표시
private void Awake()
{
if (playerStats == null)
{
GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
if (playerObj != null)
playerStats = playerObj.GetComponent<CharacterStats>();
}
}
private void OnEnable()
{
// 이벤트 구독 활성화
if (playerStats != null)
{
playerStats.OnHealthChanged += HandleHealthChanged;
playerStats.OnDied += HandleDied;
}
Refresh(); // 체력 상태 반영
}
private void OnDisable()
{
// 이벤트 구독 해제
if (playerStats != null)
{
playerStats.OnHealthChanged -= HandleHealthChanged;
playerStats.OnDied -= HandleDied;
}
}
private void HandleHealthChanged(CharacterStats stats)
{
// 이벤트 발생시 호출
Refresh();
}
private void HandleDied(CharacterStats stats)
{
// 플레이어 사망 시 호출
Refresh();
Debug.Log("Player died.");
}
private void Refresh()
{
if (playerStats == null)
return;
float max = Mathf.Max(1f, playerStats.MaxHealth); // 최대 체력
float current = Mathf.Clamp(playerStats.CurrentHealth, 0f, max); // 현재 체력
float ratio = current / max; // 현재 체력을 기준 비율, 필바 채우기 위함
if (hpFillImage != null)
hpFillImage.fillAmount = ratio;
if (hpText != null)
hpText.text = $"{Mathf.CeilToInt(current)} / {Mathf.CeilToInt(max)}";
// Fill Bar 이미지 갱신
}
}
PlayerHealthUI.cs
플레이어 hp ui 표시
영상
장비 없이 맞았을 경우 5씩 데미지 받고 부츠를 장착했을 경우 방어력이 2이기 때문에 3씩 감소
갑옷까지 장착했을 경우 데미지 5를 초과 하기 때문에 1씩 데미지를 받는다
다음 글에서는 몬스터를 잡았을 경우 경험치가 오르고 레벨업을 통해 스탯을 받아 캐릭터의 기본 능력치를 올리는 것을 진행할것이다
'Unity' 카테고리의 다른 글
| Unity(13) - Character Equipment (1) | 2026.06.23 |
|---|---|
| Unity(12) - Random Trees & Crafting Item (0) | 2026.06.02 |
| Unity(11) - Stage Select & Lobby (0) | 2026.05.31 |
| Unity(10) - Take Damage Event & UI (0) | 2026.04.28 |
| Unity(9) - Random Spawn Monster & A* (0) | 2026.04.27 |