Files
Northbound/Assets/Scripts/ObstacleSpawner.cs
dal4segno b5f8943bcc ObstacleSpawner 네트워크 연동
모든 클라이언트가 동일한 장애물을 보도록 함
2026-02-02 10:08:57 +09:00

456 lines
16 KiB
C#

using UnityEngine;
using System.Collections.Generic;
using Unity.Netcode;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace Northbound
{
public class ObstacleSpawner : NetworkBehaviour
{
[System.Serializable]
public class ObstacleEntry
{
[Tooltip("배치할 장애물 프리팹")]
public GameObject prefab;
[Tooltip("이 장애물의 스폰 가중치 (높을수록 자주 등장)")]
[Range(1, 100)]
public int spawnWeight = 50;
[Tooltip("최소 스폰 개수")]
public int minCount = 0;
[Tooltip("최대 스폰 개수")]
public int maxCount = 10;
}
[Header("Spawn Area Settings")]
[Tooltip("장애물을 배치할 영역의 중심점 (비어있으면 현재 위치 사용)")]
[SerializeField] private Transform spawnCenter;
[Tooltip("배치 영역의 크기")]
[SerializeField] private Vector2 spawnAreaSize = new Vector2(50f, 50f);
[Tooltip("배치 영역의 형태")]
[SerializeField] private SpawnAreaShape areaShape = SpawnAreaShape.Rectangle;
[Header("Obstacle Settings")]
[Tooltip("배치할 장애물 목록")]
[SerializeField] private List<ObstacleEntry> obstacles = new List<ObstacleEntry>();
[Tooltip("장애물 밀도 (0.0 ~ 1.0, 높을수록 많이 배치)")]
[Range(0f, 1f)]
[SerializeField] private float density = 0.5f;
[Tooltip("최대 장애물 개수")]
[SerializeField] private int maxTotalObstacles = 100;
[Header("Placement Rules")]
[Tooltip("장애물 간 최소 거리")]
[SerializeField] private float minDistanceBetweenObstacles = 2f;
[Tooltip("지형에 맞춰 배치")]
[SerializeField] private bool alignToTerrain = true;
[Tooltip("Y축 랜덤 회전 적용")]
[SerializeField] private bool randomRotation = true;
[Tooltip("크기 랜덤 변화 범위 (0이면 변화 없음)")]
[Range(0f, 0.5f)]
[SerializeField] private float scaleVariation = 0.1f;
[Tooltip("배치 시도 최대 횟수")]
[SerializeField] private int maxAttempts = 50;
[Header("Collision Check")]
[Tooltip("배치 전 충돌 체크")]
[SerializeField] private bool checkCollision = true;
[Tooltip("충돌 체크 레이어")]
[SerializeField] private LayerMask collisionLayers = -1;
[Tooltip("충돌 체크 반경")]
[SerializeField] private float collisionCheckRadius = 1f;
[Header("Runtime Settings")]
[Tooltip("게임 시작 시 자동 생성")]
[SerializeField] private bool spawnOnStart = true;
[Tooltip("생성된 장애물을 부모로 그룹화")]
[SerializeField] private bool groupUnderParent = true;
private List<Vector3> _spawnedPositions = new List<Vector3>();
private Transform _obstacleParent;
public enum SpawnAreaShape
{
Rectangle,
Circle
}
public override void OnNetworkSpawn()
{
if (IsServer && spawnOnStart)
{
SpawnObstacles();
}
}
/// <summary>
/// 장애물을 생성합니다
/// </summary>
public void SpawnObstacles()
{
if (!IsServer)
{
Debug.LogWarning("[ObstacleSpawner] Only server can spawn obstacles!");
return;
}
ClearObstacles();
_spawnedPositions.Clear();
if (obstacles.Count == 0)
{
Debug.LogWarning("[ObstacleSpawner] 배치할 장애물이 없습니다!");
return;
}
if (groupUnderParent)
{
_obstacleParent = new GameObject("Generated Obstacles").transform;
_obstacleParent.SetParent(transform);
}
Vector3 center = transform.position;
int totalSpawned = 0;
int targetCount = Mathf.RoundToInt(maxTotalObstacles * density);
// Debug.Log($"<color=cyan>[ObstacleSpawner] 장애물 생성 시작 (목표: {targetCount}개)</color>");
// 각 장애물 타입별로 최소 개수 보장
foreach (var obstacle in obstacles)
{
if (obstacle.prefab == null) continue;
int minRequired = obstacle.minCount;
for (int i = 0; i < minRequired && totalSpawned < maxTotalObstacles; i++)
{
if (TrySpawnObstacle(obstacle, center))
{
totalSpawned++;
}
}
}
// 나머지를 가중치에 따라 랜덤 배치
int consecutiveFailures = 0;
int maxConsecutiveFailures = 50; // 연속 실패 허용 횟수
while (totalSpawned < targetCount && consecutiveFailures < maxConsecutiveFailures)
{
ObstacleEntry selectedObstacle = SelectRandomObstacle();
if (selectedObstacle == null || selectedObstacle.prefab == null)
{
consecutiveFailures++;
continue;
}
// 최대 개수 체크
int currentCount = CountObstacleType(selectedObstacle.prefab);
if (currentCount >= selectedObstacle.maxCount)
{
consecutiveFailures++;
// 모든 장애물이 최대치에 도달했는지 확인
if (AllObstaclesAtMax())
{
// Debug.Log("<color=yellow>[ObstacleSpawner] 모든 장애물이 최대 개수에 도달했습니다.</color>");
break;
}
continue;
}
if (TrySpawnObstacle(selectedObstacle, center))
{
totalSpawned++;
consecutiveFailures = 0; // 성공 시 카운터 리셋
}
else
{
consecutiveFailures++;
}
}
if (consecutiveFailures >= maxConsecutiveFailures)
{
Debug.LogWarning($"<color=yellow>[ObstacleSpawner] 배치 공간이 부족합니다. {totalSpawned}개만 생성되었습니다.</color>");
}
// Debug.Log($"<color=green>[ObstacleSpawner] 장애물 생성 완료: {totalSpawned}개</color>");
}
private bool AllObstaclesAtMax()
{
foreach (var obstacle in obstacles)
{
if (obstacle.prefab == null) continue;
int currentCount = CountObstacleType(obstacle.prefab);
if (currentCount < obstacle.maxCount)
{
return false;
}
}
return true;
}
private bool TrySpawnObstacle(ObstacleEntry obstacleEntry, Vector3 center)
{
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
Vector3 randomPos = GetRandomPositionInArea(center);
// 최소 거리 체크
if (!IsValidPosition(randomPos))
{
// Debug.Log($"<color=orange>[Spawn] 최소 거리 체크 실패: {randomPos}</color>");
continue;
}
// 충돌 체크
if (checkCollision && Physics.CheckSphere(randomPos, collisionCheckRadius, collisionLayers))
{
// Debug.Log($"<color=orange>[Spawn] 충돌 감지: {randomPos}, 반경: {collisionCheckRadius}</color>");
continue;
}
// 지형에 맞춤
if (alignToTerrain)
{
if (Physics.Raycast(randomPos + Vector3.up * 100f, Vector3.down, out RaycastHit hit, 200f))
{
randomPos = hit.point;
// Debug.Log($"<color=cyan>[Spawn] 지형 감지 성공: {hit.collider.name}</color>");
}
else
{
// Debug.Log($"<color=orange>[Spawn] 지형 감지 실패: {randomPos}</color>");
// 지형이 없어도 배치 시도
}
}
// 장애물 생성
Quaternion rotation = randomRotation
? Quaternion.Euler(0, Random.Range(0f, 360f), 0)
: Quaternion.identity;
GameObject obstacle = Instantiate(obstacleEntry.prefab, randomPos, rotation);
// 크기 변화
if (scaleVariation > 0)
{
float scale = 1f + Random.Range(-scaleVariation, scaleVariation);
obstacle.transform.localScale *= scale;
}
// Add NetworkObject component if not exists
NetworkObject networkObj = obstacle.GetComponent<NetworkObject>();
if (networkObj == null)
{
networkObj = obstacle.AddComponent<NetworkObject>();
}
if (groupUnderParent && _obstacleParent != null)
{
obstacle.transform.SetParent(_obstacleParent);
}
// Spawn on network
networkObj.Spawn();
// Add FogOfWarVisibility component to hide obstacles in unexplored areas
if (obstacle.GetComponent<FogOfWarVisibility>() == null)
{
var visibility = obstacle.AddComponent<FogOfWarVisibility>();
visibility.showInExploredAreas = false;
visibility.updateInterval = 0.2f;
}
_spawnedPositions.Add(randomPos);
// Debug.Log($"<color=green>[Spawn] 장애물 배치 성공: {obstacleEntry.prefab.name} at {randomPos}</color>");
return true;
}
Debug.LogWarning($"<color=red>[Spawn] {obstacleEntry.prefab.name} 배치 실패 (시도: {maxAttempts}회)</color>");
return false;
}
private Vector3 GetRandomPositionInArea(Vector3 center)
{
Vector3 randomPos = center;
switch (areaShape)
{
case SpawnAreaShape.Rectangle:
randomPos.x += Random.Range(-spawnAreaSize.x / 2, spawnAreaSize.x / 2);
randomPos.z += Random.Range(-spawnAreaSize.y / 2, spawnAreaSize.y / 2);
break;
case SpawnAreaShape.Circle:
Vector2 randomCircle = Random.insideUnitCircle * spawnAreaSize.x / 2;
randomPos.x += randomCircle.x;
randomPos.z += randomCircle.y;
break;
}
return randomPos;
}
private bool IsValidPosition(Vector3 position)
{
foreach (var spawnedPos in _spawnedPositions)
{
if (Vector3.Distance(position, spawnedPos) < minDistanceBetweenObstacles)
{
return false;
}
}
return true;
}
private ObstacleEntry SelectRandomObstacle()
{
if (obstacles.Count == 0) return null;
int totalWeight = 0;
foreach (var obstacle in obstacles)
{
if (obstacle.prefab != null)
{
totalWeight += obstacle.spawnWeight;
}
}
if (totalWeight == 0) return null;
int randomValue = Random.Range(0, totalWeight);
int currentWeight = 0;
foreach (var obstacle in obstacles)
{
if (obstacle.prefab == null) continue;
currentWeight += obstacle.spawnWeight;
if (randomValue < currentWeight)
{
return obstacle;
}
}
return obstacles[0];
}
private int CountObstacleType(GameObject prefab)
{
int count = 0;
if (_obstacleParent != null)
{
foreach (Transform child in _obstacleParent)
{
if (child.name.StartsWith(prefab.name))
{
count++;
}
}
}
return count;
}
/// <summary>
/// 생성된 장애물을 모두 제거합니다
/// </summary>
public void ClearObstacles()
{
if (_obstacleParent != null)
{
foreach (Transform child in _obstacleParent)
{
NetworkObject networkObj = child.GetComponent<NetworkObject>();
if (networkObj != null && networkObj.IsSpawned)
{
networkObj.Despawn(true);
}
}
if (Application.isPlaying)
{
Destroy(_obstacleParent.gameObject);
}
else
{
DestroyImmediate(_obstacleParent.gameObject);
}
_obstacleParent = null;
}
_spawnedPositions.Clear();
}
private void OnDrawGizmos()
{
Vector3 center = transform.position;
Gizmos.color = new Color(0, 1, 0, 0.3f);
switch (areaShape)
{
case SpawnAreaShape.Rectangle:
Gizmos.DrawCube(center, new Vector3(spawnAreaSize.x, 0.1f, spawnAreaSize.y));
break;
case SpawnAreaShape.Circle:
DrawCircle(center, spawnAreaSize.x / 2, 32);
break;
}
Gizmos.color = Color.green;
Gizmos.DrawWireCube(center, new Vector3(spawnAreaSize.x, 0.1f, spawnAreaSize.y));
}
private void DrawCircle(Vector3 center, float radius, int segments)
{
float angleStep = 360f / segments;
Vector3 prevPoint = center + new Vector3(radius, 0, 0);
for (int i = 1; i <= segments; i++)
{
float angle = i * angleStep * Mathf.Deg2Rad;
Vector3 newPoint = center + new Vector3(Mathf.Cos(angle) * radius, 0, Mathf.Sin(angle) * radius);
Gizmos.DrawLine(prevPoint, newPoint);
prevPoint = newPoint;
}
}
private void OnDrawGizmosSelected()
{
#if UNITY_EDITOR
Vector3 center = spawnCenter != null ? spawnCenter.position : transform.position;
// 밀도 정보 표시
int targetCount = Mathf.RoundToInt(maxTotalObstacles * density);
Handles.Label(center + Vector3.up * 2f,
$"Obstacle Spawner\n목표: {targetCount}개 / 최대: {maxTotalObstacles}개\n밀도: {density:P0}");
// 생성된 장애물 위치 표시
Gizmos.color = Color.yellow;
foreach (var pos in _spawnedPositions)
{
Gizmos.DrawWireSphere(pos, minDistanceBetweenObstacles / 2f);
}
#endif
}
}
}