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(); + if (_canvas == null) + { + _canvas = gameObject.AddComponent(); + } + _canvas.renderMode = RenderMode.WorldSpace; + + // Canvas Scaler 설정 + var scaler = GetComponent(); + if (scaler == null) + { + scaler = gameObject.AddComponent(); + } + scaler.dynamicPixelsPerUnit = 10f; + } + + private void Start() + { + _mainCamera = Camera.main; + } + + public void Initialize(Building building) + { + _building = building; + transform.localPosition = Vector3.up * heightOffset; + } + + public void UpdateHealth(int currentHealth, int maxHealth) + { + if (fillImage != null) + { + float healthPercentage = maxHealth > 0 ? (float)currentHealth / maxHealth : 0f; + fillImage.fillAmount = healthPercentage; + + // 체력에 따른 색상 변경 + fillImage.color = GetHealthColor(healthPercentage); + } + + if (healthText != null) + { + healthText.text = $"{currentHealth}/{maxHealth}"; + } + + // 체력바 표시/숨김 + if (barContainer != null) + { + _lastDamageTime = Time.time; + barContainer.SetActive(true); + } + } + + private void Update() + { + // 카메라를 향하도록 회전 + if (_mainCamera != null) + { + transform.rotation = Quaternion.LookRotation(transform.position - _mainCamera.transform.position); + } + + // 체력이 가득 차면 숨김 + if (hideWhenFull && barContainer != null && _building != null) + { + float healthPercentage = _building.GetHealthPercentage(); + + if (healthPercentage >= 1f && Time.time - _lastDamageTime > hideDelay) + { + barContainer.SetActive(false); + } + } + } + + private Color GetHealthColor(float healthPercentage) + { + if (healthPercentage <= lowHealthThreshold) + return lowHealthColor; + else if (healthPercentage <= mediumHealthThreshold) + return mediumHealthColor; + else + return fullHealthColor; + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/BuildingHealthBar.cs.meta b/Assets/Scripts/BuildingHealthBar.cs.meta new file mode 100644 index 0000000..2ebd31a --- /dev/null +++ b/Assets/Scripts/BuildingHealthBar.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 634b9713ccae67242a54fca1ed979b58 \ No newline at end of file diff --git a/Assets/Scripts/BuildingManager.cs b/Assets/Scripts/BuildingManager.cs index a63710e..a083cb3 100644 --- a/Assets/Scripts/BuildingManager.cs +++ b/Assets/Scripts/BuildingManager.cs @@ -113,41 +113,136 @@ namespace Northbound return false; } - [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] - public void PlaceBuildingServerRpc(int buildingIndex, Vector3 position, int rotation) + /// + /// 건물 배치 요청 (클라이언트에서 호출) + /// + public void RequestPlaceBuilding(int buildingIndex, Vector3 position, int rotation) { - if (buildingIndex < 0 || buildingIndex >= availableBuildings.Count) + if (!NetworkManager.Singleton.IsClient) + { + Debug.LogWarning("[BuildingManager] 클라이언트가 아닙니다."); return; + } + + ulong clientId = NetworkManager.Singleton.LocalClientId; + PlaceBuildingServerRpc(buildingIndex, position, rotation, clientId); + } + + [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] + private void PlaceBuildingServerRpc(int buildingIndex, Vector3 position, int rotation, ulong requestingClientId) + { + // 보안 검증 1: 유효한 클라이언트인지 확인 + if (!NetworkManager.Singleton.ConnectedClients.ContainsKey(requestingClientId)) + { + Debug.LogWarning($"[BuildingManager] 유효하지 않은 클라이언트 ID: {requestingClientId}"); + return; + } + + // 보안 검증 2: 건물 인덱스 유효성 확인 + if (buildingIndex < 0 || buildingIndex >= availableBuildings.Count) + { + Debug.LogWarning($"[BuildingManager] 유효하지 않은 건물 인덱스: {buildingIndex} (클라이언트: {requestingClientId})"); + return; + } BuildingData data = availableBuildings[buildingIndex]; - // IsValidPlacement now returns snapped position in groundPosition - if (!IsValidPlacement(data, position, rotation, out Vector3 snappedPosition)) + // 보안 검증 3: 건물 데이터 유효성 확인 + if (data == null || data.prefab == null) + { + Debug.LogWarning($"[BuildingManager] 건물 데이터가 유효하지 않습니다. (클라이언트: {requestingClientId})"); return; + } + + // 배치 가능 여부 확인 + if (!IsValidPlacement(data, position, rotation, out Vector3 snappedPosition)) + { + Debug.LogWarning($"[BuildingManager] 건물 배치 불가능 위치 (클라이언트: {requestingClientId})"); + return; + } Vector3Int gridPosition = WorldToGrid(snappedPosition); - // Spawn building at snapped position + // 건물 생성 GameObject buildingObj = Instantiate(data.prefab, snappedPosition + data.placementOffset, Quaternion.Euler(0, rotation * 90f, 0)); NetworkObject netObj = buildingObj.GetComponent(); if (netObj != null) { - netObj.Spawn(); + // 건물의 소유자를 설정 + netObj.SpawnWithOwnership(requestingClientId); Building building = buildingObj.GetComponent(); if (building == null) + { building = buildingObj.AddComponent(); + } - building.Initialize(data, gridPosition, rotation); + // 건물 초기화 + building.Initialize(data, gridPosition, rotation, requestingClientId); placedBuildings.Add(building); + + Debug.Log($"[BuildingManager] {data.buildingName} 건설 완료 (소유자: {requestingClientId}, 위치: {gridPosition})"); + } + else + { + Debug.LogError($"[BuildingManager] NetworkObject 컴포넌트가 없습니다! (Prefab: {data.prefab.name})"); + Destroy(buildingObj); } } public void RemoveBuilding(Building building) { if (placedBuildings.Contains(building)) + { placedBuildings.Remove(building); + Debug.Log($"[BuildingManager] 건물 제거됨: {building.buildingData?.buildingName ?? "Unknown"}"); + } + } + + /// + /// 사전 배치 건물 등록 (씬에 미리 있는 건물용) + /// + public void RegisterPrePlacedBuilding(Building building) + { + if (!placedBuildings.Contains(building)) + { + placedBuildings.Add(building); + Debug.Log($"[BuildingManager] 사전 배치 건물 등록: {building.buildingData?.buildingName ?? building.gameObject.name}"); + } + } + + /// + /// 특정 위치에 건물이 있는지 확인 + /// + public Building GetBuildingAtPosition(Vector3Int gridPosition) + { + foreach (var building in placedBuildings) + { + if (building != null && building.gridPosition == gridPosition) + { + return building; + } + } + return null; + } + + /// + /// 특정 소유자의 모든 건물 가져오기 + /// + public List GetBuildingsByOwner(ulong ownerId) + { + List ownedBuildings = new List(); + + foreach (var building in placedBuildings) + { + if (building != null && building.GetOwnerId() == ownerId) + { + ownedBuildings.Add(building); + } + } + + return ownedBuildings; } } } diff --git a/Assets/Scripts/BuildingPlacement.cs b/Assets/Scripts/BuildingPlacement.cs index 7335cdb..046387b 100644 --- a/Assets/Scripts/BuildingPlacement.cs +++ b/Assets/Scripts/BuildingPlacement.cs @@ -118,6 +118,139 @@ namespace Northbound } } + private void OnToggleBuildMode(InputAction.CallbackContext context) + { + isBuildModeActive = !isBuildModeActive; + + if (isBuildModeActive) + { + CreatePreview(); + } + else + { + DestroyPreview(); + } + } + + private void CreatePreview() + { + if (BuildingManager.Instance == null) + { + Debug.LogWarning("[BuildingPlacement] BuildingManager가 없습니다."); + return; + } + + if (selectedBuildingIndex < 0 || selectedBuildingIndex >= BuildingManager.Instance.availableBuildings.Count) + { + Debug.LogWarning($"[BuildingPlacement] 유효하지 않은 건물 인덱스: {selectedBuildingIndex}"); + return; + } + + BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex]; + if (data == null || data.prefab == null) + { + Debug.LogWarning("[BuildingPlacement] BuildingData 또는 Prefab이 없습니다."); + return; + } + + previewObject = Instantiate(data.prefab); + + // Remove NetworkObject component from preview + NetworkObject netObj = previewObject.GetComponent(); + if (netObj != null) + { + Destroy(netObj); + } + + // Apply ghost materials + previewRenderers = previewObject.GetComponentsInChildren(); + foreach (var renderer in previewRenderers) + { + Material[] mats = new Material[renderer.materials.Length]; + for (int i = 0; i < mats.Length; i++) + { + mats[i] = validMaterial; + } + renderer.materials = mats; + } + + // Disable colliders in preview + foreach (var collider in previewObject.GetComponentsInChildren()) + { + collider.enabled = false; + } + } + + private void DestroyPreview() + { + if (previewObject != null) + { + Destroy(previewObject); + previewObject = null; + } + } + + private void UpdatePreviewPosition() + { + if (previewObject == null || BuildingManager.Instance == null) + return; + + Ray ray = Camera.main.ScreenPointToRay(Mouse.current.position.ReadValue()); + + if (Physics.Raycast(ray, out RaycastHit hit, maxPlacementDistance, groundLayer)) + { + BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex]; + + // Check if placement is valid + bool isValid = BuildingManager.Instance.IsValidPlacement(data, hit.point, currentRotation, out Vector3 snappedPosition); + + // Update preview position + previewObject.transform.position = snappedPosition + data.placementOffset; + previewObject.transform.rotation = Quaternion.Euler(0, currentRotation * 90f, 0); + + // Update material based on validity + Material targetMat = isValid ? validMaterial : invalidMaterial; + foreach (var renderer in previewRenderers) + { + Material[] mats = new Material[renderer.materials.Length]; + for (int i = 0; i < mats.Length; i++) + { + mats[i] = targetMat; + } + renderer.materials = mats; + } + } + } + + private void OnRotate(InputAction.CallbackContext context) + { + if (!isBuildModeActive) return; + + currentRotation = (currentRotation + 1) % 4; + } + + private void OnBuild(InputAction.CallbackContext context) + { + if (!isBuildModeActive || previewObject == null || BuildingManager.Instance == null) + return; + + BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex]; + Vector3 placementPosition = previewObject.transform.position - data.placementOffset; + + // Validate placement one more time + if (BuildingManager.Instance.IsValidPlacement(data, placementPosition, currentRotation, out Vector3 snappedPosition)) + { + // 🔥 변경: PlaceBuildingServerRpc 대신 RequestPlaceBuilding 호출 + BuildingManager.Instance.RequestPlaceBuilding(selectedBuildingIndex, snappedPosition, currentRotation); + + Debug.Log($"[BuildingPlacement] 건물 배치 요청: {data.buildingName}"); + } + else + { + Debug.LogWarning("[BuildingPlacement] 건물을 배치할 수 없는 위치입니다."); + } + } + private void OnDrawGizmos() { if (!IsOwner || !isBuildModeActive || !showGridBounds) return; @@ -134,321 +267,6 @@ namespace Northbound Gizmos.color = isValid ? new Color(0, 1, 0, 0.5f) : new Color(1, 0, 0, 0.5f); Gizmos.DrawWireCube(bounds.center, bounds.size); - - // Draw grid cells - Gizmos.color = Color.yellow; - float gridSize = BuildingManager.Instance.gridSize; - Vector3 snappedPos = BuildingManager.Instance.SnapToGrid(previewObject.transform.position); - - // Draw grid origin point - Gizmos.DrawSphere(snappedPos, 0.1f); - - // Draw grid outline for each cell - int width = Mathf.RoundToInt(bounds.size.x / gridSize); - int length = Mathf.RoundToInt(bounds.size.z / gridSize); - - for (int x = 0; x < width; x++) - { - for (int z = 0; z < length; z++) - { - Vector3 cellPos = snappedPos + new Vector3( - (x - width / 2f + 0.5f) * gridSize, - 0.01f, - (z - length / 2f + 0.5f) * gridSize - ); - Gizmos.DrawWireCube(cellPos, new Vector3(gridSize, 0.01f, gridSize)); - } - } - } - - private void OnToggleBuildMode(InputAction.CallbackContext context) - { - ToggleBuildMode(); - } - - private void OnRotate(InputAction.CallbackContext context) - { - if (!isBuildModeActive) return; - - float rotateValue = context.ReadValue(); - if (rotateValue > 0) - { - currentRotation = (currentRotation + 1) % 4; - } - else if (rotateValue < 0) - { - currentRotation = (currentRotation - 1 + 4) % 4; - } - } - - private void OnBuild(InputAction.CallbackContext context) - { - if (!isBuildModeActive) return; - TryPlaceBuilding(); - } - - private void ToggleBuildMode() - { - isBuildModeActive = !isBuildModeActive; - - if (isBuildModeActive) - { - EnterBuildMode(); - } - else - { - ExitBuildMode(); - } - } - - private void EnterBuildMode() - { - currentRotation = 0; - CreatePreview(); - } - - private void ExitBuildMode() - { - DestroyPreview(); - } - - private void CreatePreview() - { - if (BuildingManager.Instance == null) - return; - - if (BuildingManager.Instance.availableBuildings.Count == 0) - return; - - selectedBuildingIndex = Mathf.Clamp(selectedBuildingIndex, 0, - BuildingManager.Instance.availableBuildings.Count - 1); - - BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex]; - if (data == null || data.prefab == null) - return; - - previewObject = Instantiate(data.prefab); - previewObject.name = "BuildingPreview"; - - // Disable colliders on preview - foreach (var collider in previewObject.GetComponentsInChildren()) - { - collider.enabled = false; - } - - // Remove NetworkObject from preview if exists - NetworkObject netObj = previewObject.GetComponent(); - if (netObj != null) - { - Destroy(netObj); - } - - // Store original materials and setup preview renderers - previewRenderers = previewObject.GetComponentsInChildren(); - - if (previewRenderers.Length == 0) - { - // Add a debug cube so you can at least see something - GameObject debugCube = GameObject.CreatePrimitive(PrimitiveType.Cube); - debugCube.transform.SetParent(previewObject.transform); - debugCube.transform.localPosition = Vector3.zero; - Destroy(debugCube.GetComponent()); - previewRenderers = previewObject.GetComponentsInChildren(); - } - - originalMaterials = new Material[previewRenderers.Length]; - - for (int i = 0; i < previewRenderers.Length; i++) - { - originalMaterials[i] = previewRenderers[i].material; - // Replace all materials with ghost material - Material[] mats = new Material[previewRenderers[i].materials.Length]; - for (int j = 0; j < mats.Length; j++) - { - mats[j] = validMaterial; - } - previewRenderers[i].materials = mats; - - // Disable shadow casting - previewRenderers[i].shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; - previewRenderers[i].receiveShadows = false; - } - } - - private void DestroyPreview() - { - if (previewObject != null) - { - Destroy(previewObject); - previewObject = null; - } - } - - private void UpdatePreviewPosition() - { - if (BuildingManager.Instance == null) return; - - if (previewObject == null) - { - CreatePreview(); - if (previewObject == null) return; - } - - if (Mouse.current == null) - return; - - Vector2 mousePos = Mouse.current.position.ReadValue(); - Ray ray = Camera.main.ScreenPointToRay(mousePos); - - Vector3 targetPosition = Vector3.zero; - bool foundPosition = false; - - // Robust approach: Find where ray intersects ground plane, then raycast down from there - // This works even when pointing at empty space between buildings - - // Step 1: Calculate where ray intersects a horizontal plane at Y=0 - // Using plane intersection math - Plane groundPlane = new Plane(Vector3.up, Vector3.zero); - float enter; - - if (groundPlane.Raycast(ray, out enter)) - { - // Get the XZ position where ray hits the ground plane - Vector3 planeHitPoint = ray.GetPoint(enter); - - // Step 2: Cast down from above this XZ position to find actual ground - Vector3 highPoint = new Vector3(planeHitPoint.x, 100f, planeHitPoint.z); - - if (Physics.Raycast(highPoint, Vector3.down, out RaycastHit downHit, 200f, groundLayer)) - { - // Always use the hit point - targetPosition = downHit.point; - foundPosition = true; - } - else - { - // No ground found via raycast, use the plane intersection point directly - targetPosition = planeHitPoint; - foundPosition = true; - } - } - else - { - // Fallback: Try direct raycast to anything - if (Physics.Raycast(ray, out RaycastHit anyHit, maxPlacementDistance)) - { - targetPosition = anyHit.point; - foundPosition = true; - } - } - - if (foundPosition) - { - BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex]; - - // IsValidPlacement now returns the snapped position in groundPosition - bool isValid = BuildingManager.Instance.IsValidPlacement(data, targetPosition, currentRotation, out Vector3 snappedPosition); - - previewObject.transform.position = snappedPosition + data.placementOffset; - previewObject.transform.rotation = Quaternion.Euler(0, currentRotation * 90f, 0); - - if (!previewObject.activeSelf) - { - previewObject.SetActive(true); - } - - // Update material - Material previewMat = isValid ? validMaterial : invalidMaterial; - foreach (var renderer in previewRenderers) - { - Material[] mats = new Material[renderer.materials.Length]; - for (int i = 0; i < mats.Length; i++) - { - mats[i] = previewMat; - } - renderer.materials = mats; - } - } - else - { - if (previewObject.activeSelf) - { - previewObject.SetActive(false); - } - } - } - - private void TryPlaceBuilding() - { - if (previewObject == null || !previewObject.activeSelf) return; - - Vector2 mousePos = Mouse.current.position.ReadValue(); - Ray ray = Camera.main.ScreenPointToRay(mousePos); - - Vector3 targetPosition = Vector3.zero; - bool foundPosition = false; - - // Use plane intersection to get XZ position, then raycast down to find ground - Plane groundPlane = new Plane(Vector3.up, Vector3.zero); - float enter; - - if (groundPlane.Raycast(ray, out enter)) - { - Vector3 planeHitPoint = ray.GetPoint(enter); - Vector3 highPoint = new Vector3(planeHitPoint.x, 100f, planeHitPoint.z); - - if (Physics.Raycast(highPoint, Vector3.down, out RaycastHit downHit, 200f, groundLayer)) - { - if (downHit.collider.GetComponentInParent() == null) - { - targetPosition = downHit.point; - foundPosition = true; - } - else - { - targetPosition = downHit.point; - foundPosition = true; - } - } - else - { - targetPosition = planeHitPoint; - foundPosition = true; - } - } - else - { - // Fallback: direct raycast - if (Physics.Raycast(ray, out RaycastHit anyHit, maxPlacementDistance)) - { - targetPosition = anyHit.point; - foundPosition = true; - } - } - - if (foundPosition) - { - BuildingData data = BuildingManager.Instance.availableBuildings[selectedBuildingIndex]; - - // IsValidPlacement now returns the snapped position - if (BuildingManager.Instance.IsValidPlacement(data, targetPosition, currentRotation, out Vector3 snappedPosition)) - { - BuildingManager.Instance.PlaceBuildingServerRpc(selectedBuildingIndex, snappedPosition, currentRotation); - } - } - } - - - override public void OnDestroy() - { - DestroyPreview(); - - if (_inputActions != null) - { - _inputActions.Dispose(); - } - - base.OnDestroy(); } } } diff --git a/Assets/Scripts/BuildingVisionProvider.cs b/Assets/Scripts/BuildingVisionProvider.cs deleted file mode 100644 index 32d0de2..0000000 --- a/Assets/Scripts/BuildingVisionProvider.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Unity.Netcode; -using UnityEngine; - -namespace Northbound -{ - /// - /// 건물의 시야 제공 컴포넌트 - /// - public class BuildingVisionProvider : NetworkBehaviour, IVisionProvider - { - [Header("Vision Settings")] - public float visionRange = 15f; - - private Building _building; - - private void Awake() - { - _building = GetComponent(); - } - - public override void OnNetworkSpawn() - { - if (IsServer) - { - FogOfWarSystem.Instance?.RegisterVisionProvider(this); - } - } - - public override void OnNetworkDespawn() - { - if (IsServer) - { - FogOfWarSystem.Instance?.UnregisterVisionProvider(this); - } - } - - public ulong GetOwnerId() => OwnerClientId; - public float GetVisionRange() => visionRange; - public Transform GetTransform() => transform; - public bool IsActive() => IsSpawned; - - private void OnDrawGizmosSelected() - { - Gizmos.color = Color.cyan; - Gizmos.DrawWireSphere(transform.position, visionRange); - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/BuildingVisionProvider.cs.meta b/Assets/Scripts/BuildingVisionProvider.cs.meta deleted file mode 100644 index 651619d..0000000 --- a/Assets/Scripts/BuildingVisionProvider.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b59ae4328ce49c846b20d7a6d7ce7e47 \ No newline at end of file