diff --git a/Assembly-CSharp.csproj b/Assembly-CSharp.csproj
index 1c67444..c99ac11 100644
--- a/Assembly-CSharp.csproj
+++ b/Assembly-CSharp.csproj
@@ -59,10 +59,10 @@
+
-
@@ -70,6 +70,7 @@
+
diff --git a/Assets/Scripts/Building.cs b/Assets/Scripts/Building.cs
index 4849e64..a39db3f 100644
--- a/Assets/Scripts/Building.cs
+++ b/Assets/Scripts/Building.cs
@@ -1,9 +1,10 @@
+using System;
using Unity.Netcode;
using UnityEngine;
namespace Northbound
{
- public class Building : NetworkBehaviour
+ public class Building : NetworkBehaviour, IDamageable, IVisionProvider
{
[Header("References")]
public BuildingData buildingData;
@@ -12,17 +13,387 @@ namespace Northbound
public Vector3Int gridPosition;
public int rotation; // 0-3 (0=0°, 1=90°, 2=180°, 3=270°)
+ [Header("Ownership (for pre-placed buildings)")]
+ [Tooltip("씬에 미리 배치된 건물의 경우 여기서 소유자 설정 (0 = 중립, 1+ = 플레이어 ID)")]
+ public ulong initialOwnerId = 0;
+ [Tooltip("사전 배치 건물인가요? 체크하면 initialOwnerId를 사용합니다")]
+ public bool useInitialOwner = false;
+
+ [Header("Health")]
+ public bool showHealthBar = true;
+ public GameObject healthBarPrefab;
+
+ [Header("Visual Effects")]
+ public GameObject destroyEffectPrefab;
+ public GameObject damageEffectPrefab;
+ public Transform effectSpawnPoint;
+
[Header("Debug")]
public bool showGridBounds = true;
public Color gridBoundsColor = Color.cyan;
- public void Initialize(BuildingData data, Vector3Int gridPos, int rot)
+ // 현재 체력
+ private NetworkVariable _currentHealth = new NetworkVariable(
+ 0,
+ NetworkVariableReadPermission.Everyone,
+ NetworkVariableWritePermission.Server
+ );
+
+ // 건물 소유자 (사전 배치 건물 또는 동적 건물 모두 지원)
+ private NetworkVariable _ownerId = new NetworkVariable(
+ 0,
+ NetworkVariableReadPermission.Everyone,
+ NetworkVariableWritePermission.Server
+ );
+
+ // 이벤트
+ public event Action OnHealthChanged; // (current, max)
+ public event Action OnDestroyed;
+
+ private BuildingHealthBar _healthBar;
+ private float _lastRegenTime;
+ private bool _isInitialized = false;
+
+ public override void OnNetworkSpawn()
+ {
+ base.OnNetworkSpawn();
+
+ if (IsServer)
+ {
+ // 체력 초기화
+ if (_currentHealth.Value == 0)
+ {
+ _currentHealth.Value = buildingData != null ? buildingData.maxHealth : 100;
+ }
+
+ // 소유자 초기화 (사전 배치 건물 체크)
+ if (useInitialOwner && _ownerId.Value == 0)
+ {
+ _ownerId.Value = initialOwnerId;
+ Debug.Log($"[Building] 사전 배치 건물 '{buildingData?.buildingName ?? gameObject.name}' 소유자: {initialOwnerId}");
+ }
+ else if (!useInitialOwner && _ownerId.Value == 0)
+ {
+ // 동적 생성 건물은 NetworkObject의 Owner 사용
+ _ownerId.Value = OwnerClientId;
+ }
+
+ _lastRegenTime = Time.time;
+
+ // FogOfWar 시스템에 시야 제공자로 등록
+ if (buildingData != null && buildingData.providesVision)
+ {
+ FogOfWarSystem.Instance?.RegisterVisionProvider(this);
+ }
+ }
+
+ // 체력 변경 이벤트 구독
+ _currentHealth.OnValueChanged += OnHealthValueChanged;
+
+ // 체력바 생성
+ if (showHealthBar && healthBarPrefab != null)
+ {
+ CreateHealthBar();
+ }
+
+ // 초기 체력 UI 업데이트
+ UpdateHealthUI();
+ }
+
+ public override void OnNetworkDespawn()
+ {
+ _currentHealth.OnValueChanged -= OnHealthValueChanged;
+
+ // FogOfWar 시스템에서 제거
+ if (IsServer && buildingData != null && buildingData.providesVision)
+ {
+ FogOfWarSystem.Instance?.UnregisterVisionProvider(this);
+ }
+ }
+
+ private void Update()
+ {
+ if (!IsServer || buildingData == null)
+ return;
+
+ // 자동 체력 회복
+ if (buildingData.autoRegenerate && _currentHealth.Value < buildingData.maxHealth)
+ {
+ if (Time.time - _lastRegenTime >= 1f)
+ {
+ int regenAmount = Mathf.Min(buildingData.regenPerSecond, buildingData.maxHealth - _currentHealth.Value);
+ _currentHealth.Value += regenAmount;
+ _lastRegenTime = Time.time;
+ }
+ }
+ }
+
+ ///
+ /// 건물 초기화 (BuildingManager가 동적 생성 시 호출)
+ ///
+ public void Initialize(BuildingData data, Vector3Int gridPos, int rot, ulong ownerId)
{
buildingData = data;
gridPosition = gridPos;
rotation = rot;
+
+ // 이미 스폰된 경우
+ if (IsServer && IsSpawned)
+ {
+ _currentHealth.Value = data.maxHealth;
+ _ownerId.Value = ownerId;
+
+ // 시야 제공자 등록
+ if (data.providesVision)
+ {
+ FogOfWarSystem.Instance?.RegisterVisionProvider(this);
+ }
+ }
+
+ _isInitialized = true;
}
+ ///
+ /// 건물 소유권 변경 (점령 등)
+ ///
+ public void SetOwner(ulong newOwnerId)
+ {
+ if (!IsServer) return;
+
+ ulong previousOwner = _ownerId.Value;
+ _ownerId.Value = newOwnerId;
+
+ Debug.Log($"[Building] {buildingData?.buildingName ?? "건물"} 소유권 변경: {previousOwner} → {newOwnerId}");
+
+ // 시야 제공자 재등록 (소유자가 바뀌었으므로)
+ if (buildingData != null && buildingData.providesVision)
+ {
+ FogOfWarSystem.Instance?.UnregisterVisionProvider(this);
+ FogOfWarSystem.Instance?.RegisterVisionProvider(this);
+ }
+ }
+
+ #region IVisionProvider Implementation
+
+ public ulong GetOwnerId() => _ownerId.Value;
+
+ public float GetVisionRange()
+ {
+ return buildingData != null ? buildingData.visionRange : 0f;
+ }
+
+ public Transform GetTransform() => transform;
+
+ public bool IsActive()
+ {
+ // 건물이 스폰되어 있고, 파괴되지 않았으며, 시야 제공 설정이 켜져있어야 함
+ return IsSpawned && !IsDestroyed() && buildingData != null && buildingData.providesVision;
+ }
+
+ #endregion
+
+ #region IDamageable Implementation
+
+ public void TakeDamage(int damage, ulong attackerId)
+ {
+ if (!IsServer)
+ {
+ // 클라이언트는 서버에 요청
+ TakeDamageServerRpc(damage, attackerId);
+ return;
+ }
+
+ // 무적 건물
+ if (buildingData != null && buildingData.isIndestructible)
+ {
+ Debug.Log($"[Building] {buildingData.buildingName}은(는) 무적입니다.");
+ return;
+ }
+
+ // 이미 파괴됨
+ if (_currentHealth.Value <= 0)
+ return;
+
+ // 데미지 적용
+ int actualDamage = Mathf.Min(damage, _currentHealth.Value);
+ _currentHealth.Value -= actualDamage;
+
+ Debug.Log($"[Building] {buildingData?.buildingName ?? "건물"}이(가) {actualDamage} 데미지를 받았습니다. 남은 체력: {_currentHealth.Value}/{buildingData?.maxHealth ?? 100}");
+
+ // 데미지 이펙트
+ ShowDamageEffectClientRpc();
+
+ // 체력이 0이 되면 파괴
+ if (_currentHealth.Value <= 0)
+ {
+ DestroyBuilding(attackerId);
+ }
+ }
+
+ [Rpc(SendTo.Server)]
+ private void TakeDamageServerRpc(int damage, ulong attackerId)
+ {
+ TakeDamage(damage, attackerId);
+ }
+
+ #endregion
+
+ #region Health Management
+
+ ///
+ /// 건물 파괴
+ ///
+ private void DestroyBuilding(ulong attackerId)
+ {
+ if (!IsServer)
+ return;
+
+ Debug.Log($"[Building] {buildingData?.buildingName ?? "건물"}이(가) 파괴되었습니다! (공격자: {attackerId})");
+
+ // 파괴 이벤트 발생
+ OnDestroyed?.Invoke();
+ NotifyDestroyedClientRpc();
+
+ // 파괴 이펙트
+ ShowDestroyEffectClientRpc();
+
+ // FogOfWar 시스템에서 제거
+ if (buildingData != null && buildingData.providesVision)
+ {
+ FogOfWarSystem.Instance?.UnregisterVisionProvider(this);
+ }
+
+ // BuildingManager에서 제거
+ if (BuildingManager.Instance != null)
+ {
+ BuildingManager.Instance.RemoveBuilding(this);
+ }
+
+ // 네트워크 오브젝트 파괴 (약간의 딜레이)
+ Invoke(nameof(DespawnBuilding), 0.5f);
+ }
+
+ private void DespawnBuilding()
+ {
+ if (IsServer && NetworkObject != null)
+ {
+ NetworkObject.Despawn(true);
+ }
+ }
+
+ ///
+ /// 체력 회복
+ ///
+ public void Heal(int amount)
+ {
+ if (!IsServer)
+ return;
+
+ if (buildingData == null)
+ return;
+
+ int healAmount = Mathf.Min(amount, buildingData.maxHealth - _currentHealth.Value);
+ _currentHealth.Value += healAmount;
+
+ Debug.Log($"[Building] {buildingData.buildingName}이(가) {healAmount} 회복되었습니다. 현재 체력: {_currentHealth.Value}/{buildingData.maxHealth}");
+ }
+
+ ///
+ /// 현재 체력
+ ///
+ public int GetCurrentHealth() => _currentHealth.Value;
+
+ ///
+ /// 최대 체력
+ ///
+ public int GetMaxHealth() => buildingData != null ? buildingData.maxHealth : 100;
+
+ ///
+ /// 체력 비율 (0.0 ~ 1.0)
+ ///
+ public float GetHealthPercentage()
+ {
+ int maxHealth = GetMaxHealth();
+ return maxHealth > 0 ? (float)_currentHealth.Value / maxHealth : 0f;
+ }
+
+ ///
+ /// 파괴되었는지 여부
+ ///
+ public bool IsDestroyed() => _currentHealth.Value <= 0;
+
+ #endregion
+
+ #region Health UI
+
+ private void CreateHealthBar()
+ {
+ if (_healthBar != null)
+ return;
+
+ GameObject healthBarObj = Instantiate(healthBarPrefab, transform);
+ _healthBar = healthBarObj.GetComponent();
+
+ if (_healthBar != null)
+ {
+ _healthBar.Initialize(this);
+ }
+ }
+
+ private void UpdateHealthUI()
+ {
+ if (_healthBar != null)
+ {
+ _healthBar.UpdateHealth(_currentHealth.Value, GetMaxHealth());
+ }
+
+ OnHealthChanged?.Invoke(_currentHealth.Value, GetMaxHealth());
+ }
+
+ private void OnHealthValueChanged(int previousValue, int newValue)
+ {
+ UpdateHealthUI();
+ }
+
+ #endregion
+
+ #region Visual Effects
+
+ [ClientRpc]
+ private void ShowDamageEffectClientRpc()
+ {
+ if (damageEffectPrefab != null)
+ {
+ Transform spawnPoint = effectSpawnPoint != null ? effectSpawnPoint : transform;
+ GameObject effect = Instantiate(damageEffectPrefab, spawnPoint.position, spawnPoint.rotation);
+ Destroy(effect, 2f);
+ }
+ }
+
+ [ClientRpc]
+ private void ShowDestroyEffectClientRpc()
+ {
+ if (destroyEffectPrefab != null)
+ {
+ Transform spawnPoint = effectSpawnPoint != null ? effectSpawnPoint : transform;
+ GameObject effect = Instantiate(destroyEffectPrefab, spawnPoint.position, spawnPoint.rotation);
+ Destroy(effect, 3f);
+ }
+ }
+
+ [ClientRpc]
+ private void NotifyDestroyedClientRpc()
+ {
+ if (!IsServer)
+ {
+ OnDestroyed?.Invoke();
+ }
+ }
+
+ #endregion
+
+ #region Grid Bounds
+
///
/// Gets the grid-based bounds (from BuildingData width/length/height)
/// This is used for placement validation, NOT the actual collider bounds
@@ -47,6 +418,10 @@ namespace Northbound
return GetGridBounds();
}
+ #endregion
+
+ #region Gizmos
+
private void OnDrawGizmos()
{
if (!showGridBounds || buildingData == null) return;
@@ -71,6 +446,29 @@ namespace Northbound
Gizmos.color = Color.magenta;
Gizmos.DrawSphere(worldPos, 0.2f);
}
+
+ // Draw vision range (if provides vision)
+ if (buildingData.providesVision)
+ {
+ Gizmos.color = Color.cyan;
+ Gizmos.DrawWireSphere(transform.position, buildingData.visionRange);
+ }
+
+ // Draw owner ID label
+ #if UNITY_EDITOR
+ if (Application.isPlaying)
+ {
+ UnityEditor.Handles.Label(transform.position + Vector3.up * 3f,
+ $"Owner: {_ownerId.Value}");
+ }
+ else if (useInitialOwner)
+ {
+ UnityEditor.Handles.Label(transform.position + Vector3.up * 3f,
+ $"Initial Owner: {initialOwnerId}");
+ }
+ #endif
}
+
+ #endregion
}
}
diff --git a/Assets/Scripts/BuildingDamageTest.cs b/Assets/Scripts/BuildingDamageTest.cs
new file mode 100644
index 0000000..138145f
--- /dev/null
+++ b/Assets/Scripts/BuildingDamageTest.cs
@@ -0,0 +1,69 @@
+using Unity.Netcode;
+using UnityEngine;
+
+namespace Northbound
+{
+ ///
+ /// 건물에 데미지를 주는 테스트/공격 시스템
+ ///
+ public class BuildingDamageTest : MonoBehaviour
+ {
+ [Header("Damage Settings")]
+ public int damageAmount = 10;
+ public float damageInterval = 1f;
+ public KeyCode damageKey = KeyCode.F;
+
+ [Header("Target")]
+ public float maxDistance = 10f;
+ public LayerMask buildingLayer;
+
+ private float _lastDamageTime;
+
+ private void Update()
+ {
+ if (Input.GetKeyDown(damageKey))
+ {
+ TryDamageBuilding();
+ }
+
+ // 자동 데미지 (테스트용)
+ if (Input.GetKey(KeyCode.LeftShift) && Input.GetKey(damageKey))
+ {
+ if (Time.time - _lastDamageTime >= damageInterval)
+ {
+ TryDamageBuilding();
+ _lastDamageTime = Time.time;
+ }
+ }
+ }
+
+ private void TryDamageBuilding()
+ {
+ // 카메라에서 레이캐스트
+ Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
+
+ if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, buildingLayer))
+ {
+ Building building = hit.collider.GetComponentInParent();
+
+ if (building != null)
+ {
+ ulong attackerId = NetworkManager.Singleton != null && NetworkManager.Singleton.IsClient
+ ? NetworkManager.Singleton.LocalClientId
+ : 0;
+
+ building.TakeDamage(damageAmount, attackerId);
+ Debug.Log($"[Test] {building.buildingData?.buildingName ?? "건물"}에게 {damageAmount} 데미지!");
+ }
+ }
+ }
+
+ private void OnGUI()
+ {
+ GUILayout.BeginArea(new Rect(10, 10, 300, 100));
+ GUILayout.Label($"[{damageKey}] 건물에 {damageAmount} 데미지");
+ GUILayout.Label($"[Shift + {damageKey}] 연속 데미지");
+ GUILayout.EndArea();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Assets/Scripts/BuildingDamageTest.cs.meta b/Assets/Scripts/BuildingDamageTest.cs.meta
new file mode 100644
index 0000000..25d9b7c
--- /dev/null
+++ b/Assets/Scripts/BuildingDamageTest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 62b3bdffc8c849d48886167d316e16b6
\ No newline at end of file
diff --git a/Assets/Scripts/BuildingData.cs b/Assets/Scripts/BuildingData.cs
index 014307c..723f9bd 100644
--- a/Assets/Scripts/BuildingData.cs
+++ b/Assets/Scripts/BuildingData.cs
@@ -23,6 +23,22 @@ namespace Northbound
[Tooltip("Can rotate this building?")]
public bool allowRotation = true;
+ [Header("Health Settings")]
+ [Tooltip("Maximum health of the building")]
+ public int maxHealth = 100;
+ [Tooltip("Can this building be damaged?")]
+ public bool isIndestructible = false;
+ [Tooltip("Auto-regenerate health over time")]
+ public bool autoRegenerate = false;
+ [Tooltip("Health regeneration per second")]
+ public int regenPerSecond = 1;
+
+ [Header("Vision Settings")]
+ [Tooltip("Does this building provide vision?")]
+ public bool providesVision = true;
+ [Tooltip("Vision range in world units")]
+ public float visionRange = 15f;
+
public Vector3 GetSize(int rotation)
{
// Rotation 0,180 = normal, 90,270 = swap width/length
diff --git a/Assets/Scripts/BuildingHealthBar.cs b/Assets/Scripts/BuildingHealthBar.cs
new file mode 100644
index 0000000..4dda2c0
--- /dev/null
+++ b/Assets/Scripts/BuildingHealthBar.cs
@@ -0,0 +1,118 @@
+using UnityEngine;
+using UnityEngine.UI;
+using TMPro;
+
+namespace Northbound
+{
+ ///
+ /// 건물 위에 표시되는 체력바
+ ///
+ public class BuildingHealthBar : MonoBehaviour
+ {
+ [Header("UI References")]
+ public Image fillImage;
+ public TextMeshProUGUI healthText;
+ public GameObject barContainer;
+
+ [Header("Settings")]
+ public float heightOffset = 3f;
+ public bool hideWhenFull = true;
+ public float hideDelay = 3f;
+
+ [Header("Colors")]
+ public Color fullHealthColor = Color.green;
+ public Color mediumHealthColor = Color.yellow;
+ public Color lowHealthColor = Color.red;
+ public float mediumHealthThreshold = 0.6f;
+ public float lowHealthThreshold = 0.3f;
+
+ private Building _building;
+ private Camera _mainCamera;
+ private float _lastDamageTime;
+ private Canvas _canvas;
+
+ private void Awake()
+ {
+ // Canvas 설정
+ _canvas = GetComponent