using System.Collections.Generic; using Unity.Netcode; using UnityEngine; namespace Northbound { /// /// Helper class for efficient line-of-sight calculations using sector-based raycasting /// public class LineOfSightCalculator { private struct SectorData { public float angle; // Angle in degrees (0-360) public float blockedDistance; // Distance where vision is blocked (float.MaxValue if clear) public bool hasObstacle; // True if this sector has an obstacle public float obstacleHeight; // Height of blocking obstacle public Vector3 obstaclePosition; // World position of obstacle hit point } private FogOfWarSystem _fogSystem; private SectorData[] _sectors; private int _sectorCount; public LineOfSightCalculator(FogOfWarSystem fogSystem) { _fogSystem = fogSystem; RecalculateSectors(); } /// /// Recalculate sector count based on angular step /// public void RecalculateSectors() { _sectorCount = Mathf.CeilToInt(360f / _fogSystem.raycastAngularStep); _sectors = new SectorData[_sectorCount]; for (int i = 0; i < _sectorCount; i++) { _sectors[i].angle = i * _fogSystem.raycastAngularStep; _sectors[i].blockedDistance = float.MaxValue; _sectors[i].hasObstacle = false; _sectors[i].obstacleHeight = 0f; _sectors[i].obstaclePosition = Vector3.zero; } } /// /// Perform raycasting to determine blocked sectors with height awareness /// public void CalculateVisibleSectors(Vector3 viewerPosition, float visionRange) { // Reset sectors for (int i = 0; i < _sectorCount; i++) { _sectors[i].blockedDistance = float.MaxValue; _sectors[i].hasObstacle = false; _sectors[i].obstacleHeight = 0f; _sectors[i].obstaclePosition = Vector3.zero; } // Raycast origin at viewer eye height Vector3 rayOrigin = viewerPosition + Vector3.up * _fogSystem.viewerEyeHeight; for (int i = 0; i < _sectorCount; i++) { float angleRad = _sectors[i].angle * Mathf.Deg2Rad; Vector3 direction = new Vector3(Mathf.Cos(angleRad), 0, Mathf.Sin(angleRad)); if (Physics.Raycast(rayOrigin, direction, out RaycastHit hit, visionRange, _fogSystem.visionBlockingLayers)) { _sectors[i].blockedDistance = hit.distance; _sectors[i].hasObstacle = true; _sectors[i].obstaclePosition = hit.point; // Determine obstacle height if (_fogSystem.enableHeightBlocking) { _sectors[i].obstacleHeight = GetObstacleHeight(hit.collider); } } } } /// /// Get the height of an obstacle for vision blocking calculations /// private float GetObstacleHeight(Collider obstacle) { // Check if it's a building Building building = obstacle.GetComponent(); if (building != null && building.buildingData != null) { return building.buildingData.height; } // For non-buildings (rocks, trees, terrain), use collider bounds return obstacle.bounds.size.y; } /// /// Check if a grid cell is visible from viewer position (with height awareness) /// public bool IsCellVisible(Vector3 viewerPosition, Vector2Int cellGridPos, float visionRange) { Vector3 cellWorldPos = _fogSystem.GridToWorld(cellGridPos.x, cellGridPos.y); Vector3 viewerEye = viewerPosition + Vector3.up * _fogSystem.viewerEyeHeight; Vector3 toCell = cellWorldPos - viewerPosition; float horizontalDistance = new Vector2(toCell.x, toCell.z).magnitude; // Outside vision range if (horizontalDistance > visionRange) return false; // Calculate angle to cell float angle = Mathf.Atan2(toCell.z, toCell.x) * Mathf.Rad2Deg; if (angle < 0) angle += 360f; // Find corresponding sector int sectorIndex = Mathf.FloorToInt(angle / _fogSystem.raycastAngularStep) % _sectorCount; // Check if blocked by obstacle in this sector if (_sectors[sectorIndex].hasObstacle) { // Dynamic tolerance to handle large mesh colliders // Large rocks can be hit several units before their center // Use cell size + generous buffer for very large obstacles float distanceTolerance = _fogSystem.cellSize * 5.0f + 3.0f; // Height-based blocking check if (_fogSystem.enableHeightBlocking && _sectors[sectorIndex].obstacleHeight >= _fogSystem.minBlockingHeight) { float obstacleDistance = _sectors[sectorIndex].blockedDistance; // If cell is beyond obstacle (with tolerance), check if obstacle blocks the sight line if (horizontalDistance > obstacleDistance + distanceTolerance) { // Calculate sight line angle to cell float verticalAngleToCell = Mathf.Atan2(cellWorldPos.y - viewerEye.y, horizontalDistance); // Calculate obstacle top height float obstacleTopHeight = _sectors[sectorIndex].obstaclePosition.y + _sectors[sectorIndex].obstacleHeight; // Calculate angle to obstacle top float verticalAngleToObstacleTop = Mathf.Atan2(obstacleTopHeight - viewerEye.y, obstacleDistance); // If sight line passes below obstacle top, it's blocked if (verticalAngleToCell <= verticalAngleToObstacleTop) return false; } } else { // Simple distance-based blocking (no height consideration) // Use tolerance to allow objects at the obstacle position to be visible if (horizontalDistance > _sectors[sectorIndex].blockedDistance + distanceTolerance) return false; } } // Detailed raycasting for edge cases if (_fogSystem.useDetailedRaycasting) { float distanceTolerance = _fogSystem.cellSize * 3.0f + 2.0f; int prevSector = (sectorIndex - 1 + _sectorCount) % _sectorCount; int nextSector = (sectorIndex + 1) % _sectorCount; if (_sectors[prevSector].hasObstacle && horizontalDistance > _sectors[prevSector].blockedDistance + distanceTolerance) return false; if (_sectors[nextSector].hasObstacle && horizontalDistance > _sectors[nextSector].blockedDistance + distanceTolerance) return false; } return true; } } /// /// 전장의 안개 시스템 - 플레이어별 시야 관리 /// public class FogOfWarSystem : NetworkBehaviour { public static FogOfWarSystem Instance { get; private set; } [Header("Grid Settings")] public int gridWidth = 100; public int gridHeight = 100; public float cellSize = 1f; public Vector3 worldOrigin = Vector3.zero; [Header("Visibility Settings")] public float updateInterval = 0.2f; [Header("Line of Sight Settings")] [Tooltip("Enable line-of-sight blocking by obstacles")] public bool enableLineOfSight = true; [Tooltip("Layers that block vision (buildings, obstacles, terrain)")] public LayerMask visionBlockingLayers = ~0; [Tooltip("Angular resolution for raycasting (degrees). Lower = more accurate, higher = better performance")] [Range(1f, 15f)] public float raycastAngularStep = 6f; [Tooltip("Use detailed raycasting (more accurate but slower)")] public bool useDetailedRaycasting = false; [Header("Height-Based Visibility")] [Tooltip("Enable height-based vision blocking (tall obstacles block vision)")] public bool enableHeightBlocking = true; [Tooltip("Viewer eye height for line-of-sight calculations")] public float viewerEyeHeight = 1.5f; [Tooltip("Minimum obstacle height to block vision")] public float minBlockingHeight = 2.0f; [Header("Editor Settings")] [Tooltip("Disable fog of war in Unity Editor for easier development")] public bool disableInEditor = true; // 서버: 각 플레이어별 가시성 맵 private Dictionary _serverFogData = new Dictionary(); // 클라이언트: 로컬 플레이어의 가시성 맵 private FogOfWarData _localFogData; private List _visionProviders = new List(); private float _updateTimer; private LineOfSightCalculator _losCalculator; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; _losCalculator = new LineOfSightCalculator(this); } public override void OnNetworkSpawn() { base.OnNetworkSpawn(); if (IsServer) { // Server: Register client connected callback to initialize fog data NetworkManager.Singleton.OnClientConnectedCallback += OnClientConnected; } if (IsClient && !IsServer) { // 클라이언트는 로컬 데이터 초기화 _localFogData = new FogOfWarData(gridWidth, gridHeight); Debug.Log($"[FogOfWar] 클라이언트 {NetworkManager.LocalClientId} 초기화"); } } private void OnClientConnected(ulong clientId) { if (!IsServer) return; // Ensure fog data exists for this client if (!_serverFogData.ContainsKey(clientId)) { _serverFogData[clientId] = new FogOfWarData(gridWidth, gridHeight); Debug.Log($"[FogOfWar] 클라이언트 {clientId} 안개 데이터 초기화"); } } public override void OnNetworkDespawn() { base.OnNetworkDespawn(); if (IsServer && NetworkManager.Singleton != null) { NetworkManager.Singleton.OnClientConnectedCallback -= OnClientConnected; } } private void Update() { if (!IsServer) return; _updateTimer += Time.deltaTime; if (_updateTimer >= updateInterval) { _updateTimer = 0f; UpdateAllVision(); } } /// /// 시야 제공자 등록 (플레이어, 건물 등) /// public void RegisterVisionProvider(IVisionProvider provider) { if (!_visionProviders.Contains(provider)) { _visionProviders.Add(provider); // Debug.Log($"[FogOfWar] Vision Provider 등록: {provider.GetTransform().name} (Owner: {provider.GetOwnerId()})"); } } /// /// 시야 제공자 등록 해제 /// public void UnregisterVisionProvider(IVisionProvider provider) { _visionProviders.Remove(provider); } /// /// 플레이어별 FogOfWar 데이터 가져오기 /// public FogOfWarData GetPlayerFogData(ulong clientId) { // 클라이언트는 자신의 로컬 데이터 반환 if (IsClient && !IsServer) { return _localFogData; } // 서버는 해당 클라이언트의 데이터 반환 if (!_serverFogData.ContainsKey(clientId)) { _serverFogData[clientId] = new FogOfWarData(gridWidth, gridHeight); } return _serverFogData[clientId]; } /// /// 모든 시야 업데이트 (서버만) /// private void UpdateAllVision() { // 각 플레이어의 현재 시야 초기화 foreach (var fogData in _serverFogData.Values) { fogData.ClearCurrentVision(); } // 모든 시야 제공자의 시야 범위 계산 foreach (var provider in _visionProviders) { if (provider == null || !provider.IsActive()) continue; ulong ownerId = provider.GetOwnerId(); if (!_serverFogData.ContainsKey(ownerId)) { _serverFogData[ownerId] = new FogOfWarData(gridWidth, gridHeight); } Vector3 position = provider.GetTransform().position; float visionRange = provider.GetVisionRange(); RevealArea(ownerId, position, visionRange); } // 각 클라이언트에게 시야 데이터 전송 foreach (var kvp in _serverFogData) { ulong clientId = kvp.Key; FogOfWarData fogData = kvp.Value; // 압축된 데이터로 전송 byte[] visibleData = fogData.GetVisibleData(); byte[] exploredData = fogData.GetExploredData(); SendFogDataToClientRpc(visibleData, exploredData, new ClientRpcParams { Send = new ClientRpcSendParams { TargetClientIds = new ulong[] { clientId } } }); } } /// /// 클라이언트에게 안개 데이터 전송 /// [ClientRpc] private void SendFogDataToClientRpc(byte[] visibleData, byte[] exploredData, ClientRpcParams clientRpcParams = default) { if (_localFogData == null) { _localFogData = new FogOfWarData(gridWidth, gridHeight); } _localFogData.SetVisibleData(visibleData); _localFogData.SetExploredData(exploredData); } /// /// 특정 영역을 밝힘 (서버만) - Line-of-Sight 지원 /// private void RevealArea(ulong clientId, Vector3 worldPosition, float radius) { FogOfWarData fogData = _serverFogData[clientId]; Vector2Int gridPos = WorldToGrid(worldPosition); int cellRadius = Mathf.CeilToInt(radius / cellSize); // Line-of-sight raycasting if enabled if (enableLineOfSight) { _losCalculator.CalculateVisibleSectors(worldPosition, radius); } for (int x = -cellRadius; x <= cellRadius; x++) { for (int y = -cellRadius; y <= cellRadius; y++) { // Basic circular range check if (x * x + y * y > cellRadius * cellRadius) continue; int gridX = gridPos.x + x; int gridY = gridPos.y + y; if (gridX < 0 || gridX >= gridWidth || gridY < 0 || gridY >= gridHeight) continue; // Line-of-sight check if (enableLineOfSight) { Vector2Int cellPos = new Vector2Int(gridX, gridY); if (!_losCalculator.IsCellVisible(worldPosition, cellPos, radius)) continue; } // 현재 시야에 표시 + 방문한 적 있음으로 기록 fogData.SetVisible(gridX, gridY, true); fogData.SetExplored(gridX, gridY, true); } } } /// /// 월드 좌표를 그리드 좌표로 변환 /// public Vector2Int WorldToGrid(Vector3 worldPos) { Vector3 localPos = worldPos - worldOrigin; int x = Mathf.FloorToInt(localPos.x / cellSize); int z = Mathf.FloorToInt(localPos.z / cellSize); return new Vector2Int(x, z); } /// /// 그리드 좌표를 월드 좌표로 변환 /// public Vector3 GridToWorld(int x, int y) { return worldOrigin + new Vector3(x * cellSize, 0, y * cellSize); } /// /// 특정 위치가 플레이어에게 보이는지 확인 /// public FogOfWarState GetVisibilityState(ulong clientId, Vector3 worldPosition) { #if UNITY_EDITOR if (disableInEditor) return FogOfWarState.Visible; #endif FogOfWarData fogData = GetPlayerFogData(clientId); if (fogData == null) return FogOfWarState.Unexplored; Vector2Int gridPos = WorldToGrid(worldPosition); return fogData.GetState(gridPos.x, gridPos.y); } private void OnDrawGizmos() { // 그리드 시각화 Gizmos.color = Color.yellow; Vector3 size = new Vector3(gridWidth * cellSize, 0.1f, gridHeight * cellSize); Gizmos.DrawWireCube(worldOrigin + size / 2f, size); } } /// /// 플레이어별 안개 데이터 /// public class FogOfWarData { private bool[,] _explored; // 한번이라도 방문한 적이 있는가 private bool[,] _currentlyVisible; // 현재 보이는가 private int _width; private int _height; public FogOfWarData(int width, int height) { _width = width; _height = height; _explored = new bool[width, height]; _currentlyVisible = new bool[width, height]; } public void SetExplored(int x, int y, bool value) { if (IsValidCoord(x, y)) _explored[x, y] = value; } public void SetVisible(int x, int y, bool value) { if (IsValidCoord(x, y)) _currentlyVisible[x, y] = value; } public void ClearCurrentVision() { System.Array.Clear(_currentlyVisible, 0, _currentlyVisible.Length); } public FogOfWarState GetState(int x, int y) { if (!IsValidCoord(x, y)) return FogOfWarState.Unexplored; if (_currentlyVisible[x, y]) return FogOfWarState.Visible; if (_explored[x, y]) return FogOfWarState.Explored; return FogOfWarState.Unexplored; } private bool IsValidCoord(int x, int y) { return x >= 0 && x < _explored.GetLength(0) && y >= 0 && y < _explored.GetLength(1); } /// /// Visible 데이터를 바이트 배열로 압축 /// public byte[] GetVisibleData() { int totalCells = _width * _height; int byteCount = (totalCells + 7) / 8; // 8비트 = 1바이트 byte[] data = new byte[byteCount]; for (int i = 0; i < totalCells; i++) { int x = i % _width; int y = i / _width; if (_currentlyVisible[x, y]) { int byteIndex = i / 8; int bitIndex = i % 8; data[byteIndex] |= (byte)(1 << bitIndex); } } return data; } /// /// Explored 데이터를 바이트 배열로 압축 /// public byte[] GetExploredData() { int totalCells = _width * _height; int byteCount = (totalCells + 7) / 8; byte[] data = new byte[byteCount]; for (int i = 0; i < totalCells; i++) { int x = i % _width; int y = i / _width; if (_explored[x, y]) { int byteIndex = i / 8; int bitIndex = i % 8; data[byteIndex] |= (byte)(1 << bitIndex); } } return data; } /// /// 바이트 배열에서 Visible 데이터 복원 /// public void SetVisibleData(byte[] data) { System.Array.Clear(_currentlyVisible, 0, _currentlyVisible.Length); int totalCells = _width * _height; for (int i = 0; i < totalCells && i / 8 < data.Length; i++) { int byteIndex = i / 8; int bitIndex = i % 8; bool isVisible = (data[byteIndex] & (1 << bitIndex)) != 0; int x = i % _width; int y = i / _width; _currentlyVisible[x, y] = isVisible; } } /// /// 바이트 배열에서 Explored 데이터 복원 /// public void SetExploredData(byte[] data) { int totalCells = _width * _height; for (int i = 0; i < totalCells && i / 8 < data.Length; i++) { int byteIndex = i / 8; int bitIndex = i % 8; bool isExplored = (data[byteIndex] & (1 << bitIndex)) != 0; int x = i % _width; int y = i / _width; // Explored는 누적 (한번 탐색하면 계속 유지) if (isExplored) _explored[x, y] = true; } } } /// /// 안개 상태 /// public enum FogOfWarState { Unexplored, // 방문한 적 없음 (완전히 어두움) Explored, // 방문했지만 현재 시야 밖 (지형/건물만 보임) Visible // 현재 시야 안 (모두 보임) } }