플레이어/적/몬스터 팀 시스템 생성

몬스터 및 적 AI 구현
This commit is contained in:
2026-01-27 15:30:02 +09:00
parent 9a47af4317
commit 194845a9e1
33 changed files with 2519 additions and 445 deletions

View File

@@ -4,7 +4,7 @@ using UnityEngine;
namespace Northbound
{
/// <summary>
/// 액션 - 공격 (대상 없이도 실행 가능)
/// 액션 - 공격 (팀 시스템 적용)
/// </summary>
public class AttackAction : NetworkBehaviour, IAction
{
@@ -15,7 +15,7 @@ namespace Northbound
public LayerMask attackableLayer = ~0;
[Header("Animation")]
public string attackAnimationTrigger = "Attack"; // 공격 애니메이션 트리거
public string attackAnimationTrigger = "Attack";
[Header("Visual")]
public GameObject attackEffectPrefab;
@@ -23,10 +23,12 @@ namespace Northbound
private float _lastAttackTime;
private Animator _animator;
private ITeamMember _teamMember;
private void Awake()
{
_animator = GetComponent<Animator>();
_teamMember = GetComponent<ITeamMember>();
}
public bool CanExecute(ulong playerId)
@@ -44,7 +46,7 @@ namespace Northbound
// 애니메이션 재생
PlayAttackAnimation();
// 범위 내 적이 있으면 데미지
// 범위 내 적 검색
Vector3 attackOrigin = attackPoint != null ? attackPoint.position : transform.position;
Collider[] hits = Physics.OverlapSphere(attackOrigin, attackRange, attackableLayer);
@@ -54,10 +56,22 @@ namespace Northbound
if (hit.transform.root == transform.root)
continue;
// 적에게 데미지
var enemy = hit.GetComponent<IDamageable>();
if (enemy != null)
// 대상 확인
var targetDamageable = hit.GetComponent<IDamageable>();
var targetTeamMember = hit.GetComponent<ITeamMember>();
if (targetDamageable != null)
{
// 팀 확인 - 적대 관계인 경우에만 공격
if (_teamMember != null && targetTeamMember != null)
{
if (!TeamManager.CanAttack(_teamMember, targetTeamMember))
{
Debug.Log($"<color=yellow>[AttackAction] {TeamManager.GetTeamName(_teamMember.GetTeam())} 팀은 {TeamManager.GetTeamName(targetTeamMember.GetTeam())} 팀을 공격할 수 없습니다.</color>");
continue;
}
}
var netObj = hit.GetComponent<NetworkObject>();
if (netObj != null)
{
@@ -66,7 +80,7 @@ namespace Northbound
}
}
Debug.Log($"플레이어 {playerId} 공격! (적중: {hits.Length}개)");
Debug.Log($"<color=cyan>[AttackAction] 플레이어 {playerId} ({TeamManager.GetTeamName(_teamMember?.GetTeam() ?? TeamType.Neutral)}) 공격!</color>");
}
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]

View File

@@ -0,0 +1,177 @@
using Unity.Netcode;
using UnityEngine;
namespace Northbound
{
/// <summary>
/// 자동으로 적을 탐지하고 공격하는 시스템
/// </summary>
public class AutoTargetSystem : NetworkBehaviour
{
[Header("Targeting")]
[Tooltip("적을 감지하는 범위")]
public float detectionRange = 15f;
[Tooltip("공격 가능한 범위")]
public float attackRange = 10f;
[Tooltip("공격 간격 (초)")]
public float attackInterval = 1f;
[Tooltip("탐지할 레이어")]
public LayerMask targetLayer = ~0;
[Header("Combat")]
[Tooltip("공격 데미지")]
public int attackDamage = 10;
[Header("Debug")]
[Tooltip("디버그 정보 표시")]
public bool showDebugInfo = true;
private ITeamMember _teamMember;
private float _lastAttackTime;
private void Awake()
{
_teamMember = GetComponent<ITeamMember>();
if (_teamMember == null)
{
Debug.LogError($"<color=red>[AutoTargetSystem] {gameObject.name}에 ITeamMember 컴포넌트가 없습니다!</color>");
}
}
private void Update()
{
if (!IsServer) return;
if (_teamMember == null) return;
if (Time.time - _lastAttackTime >= attackInterval)
{
FindAndAttackEnemy();
}
}
private void FindAndAttackEnemy()
{
// 범위 내 모든 콜라이더 탐지
Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRange, targetLayer);
if (showDebugInfo && colliders.Length > 0)
{
Debug.Log($"<color=cyan>[AutoTarget] {gameObject.name}이(가) {colliders.Length}개의 오브젝트를 감지했습니다.</color>");
}
GameObject closestEnemy = null;
float closestDistance = float.MaxValue;
foreach (Collider col in colliders)
{
// 자기 자신 제외
if (col.transform.root == transform.root)
continue;
// 팀 확인
ITeamMember targetTeam = col.GetComponent<ITeamMember>();
if (targetTeam == null)
{
// 부모나 자식에서 찾기
targetTeam = col.GetComponentInParent<ITeamMember>();
if (targetTeam == null)
{
targetTeam = col.GetComponentInChildren<ITeamMember>();
}
}
if (targetTeam == null)
{
if (showDebugInfo)
{
Debug.Log($"<color=yellow>[AutoTarget] {col.gameObject.name}에 ITeamMember가 없습니다.</color>");
}
continue;
}
// 적대 관계 확인
bool canAttack = TeamManager.CanAttack(_teamMember, targetTeam);
if (showDebugInfo)
{
Debug.Log($"<color=yellow>[AutoTarget] {gameObject.name} ({TeamManager.GetTeamName(_teamMember.GetTeam())}) → {col.gameObject.name} ({TeamManager.GetTeamName(targetTeam.GetTeam())}): 공격가능={canAttack}</color>");
}
if (!canAttack)
continue;
// 가장 가까운 적 찾기
float distance = Vector3.Distance(transform.position, col.transform.position);
if (distance < closestDistance && distance <= attackRange)
{
closestDistance = distance;
closestEnemy = col.gameObject;
}
}
// 공격
if (closestEnemy != null)
{
IDamageable damageable = closestEnemy.GetComponent<IDamageable>();
if (damageable == null)
{
damageable = closestEnemy.GetComponentInParent<IDamageable>();
if (damageable == null)
{
damageable = closestEnemy.GetComponentInChildren<IDamageable>();
}
}
if (damageable != null)
{
damageable.TakeDamage(attackDamage, NetworkObjectId);
_lastAttackTime = Time.time;
var targetTeam = closestEnemy.GetComponent<ITeamMember>() ??
closestEnemy.GetComponentInParent<ITeamMember>() ??
closestEnemy.GetComponentInChildren<ITeamMember>();
Debug.Log($"<color=red>[AutoTarget] {gameObject.name} ({TeamManager.GetTeamName(_teamMember.GetTeam())})이(가) {closestEnemy.name} ({TeamManager.GetTeamName(targetTeam?.GetTeam() ?? TeamType.Neutral)})을(를) 공격! (거리: {closestDistance:F2}m, 데미지: {attackDamage})</color>");
}
else
{
Debug.LogWarning($"<color=orange>[AutoTarget] {closestEnemy.name}에 IDamageable이 없습니다.</color>");
}
}
else if (showDebugInfo && colliders.Length > 0)
{
Debug.Log($"<color=yellow>[AutoTarget] {gameObject.name}이(가) 공격 가능한 적을 찾지 못했습니다.</color>");
}
}
private void OnDrawGizmos()
{
// 탐지 범위 (노란색)
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectionRange);
// 공격 범위 (빨간색)
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, attackRange);
}
private void OnDrawGizmosSelected()
{
OnDrawGizmos();
#if UNITY_EDITOR
if (_teamMember != null && Application.isPlaying)
{
UnityEditor.Handles.Label(transform.position + Vector3.up * 3f,
$"Auto Target\nTeam: {TeamManager.GetTeamName(_teamMember.GetTeam())}\nDetection: {detectionRange}m\nAttack: {attackRange}m");
}
#endif
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8dd1c341faa09554aa2bc35164888453

View File

@@ -4,7 +4,7 @@ using UnityEngine;
namespace Northbound
{
public class Building : NetworkBehaviour, IDamageable, IVisionProvider
public class Building : NetworkBehaviour, IDamageable, IVisionProvider, ITeamMember
{
[Header("References")]
public BuildingData buildingData;
@@ -13,6 +13,10 @@ namespace Northbound
public Vector3Int gridPosition;
public int rotation; // 0-3 (0=0°, 1=90°, 2=180°, 3=270°)
[Header("Team")]
[Tooltip("건물의 팀 (플레이어/적대세력/몬스터/중립)")]
public TeamType initialTeam = TeamType.Player;
[Header("Ownership (for pre-placed buildings)")]
[Tooltip("씬에 미리 배치된 건물의 경우 여기서 소유자 설정 (0 = 중립, 1+ = 플레이어 ID)")]
public ulong initialOwnerId = 0;
@@ -46,9 +50,17 @@ namespace Northbound
NetworkVariableWritePermission.Server
);
// 건물 팀
private NetworkVariable<TeamType> _team = new NetworkVariable<TeamType>(
TeamType.Neutral,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
// 이벤트
public event Action<int, int> OnHealthChanged; // (current, max)
public event Action OnDestroyed;
public event Action<TeamType> OnTeamChanged;
private BuildingHealthBar _healthBar;
private float _lastRegenTime;
@@ -66,11 +78,17 @@ namespace Northbound
_currentHealth.Value = buildingData != null ? buildingData.maxHealth : 100;
}
// 팀 초기화
if (_team.Value == TeamType.Neutral)
{
_team.Value = initialTeam;
}
// 소유자 초기화 (사전 배치 건물 체크)
if (useInitialOwner && _ownerId.Value == 0)
{
_ownerId.Value = initialOwnerId;
Debug.Log($"<color=cyan>[Building] 사전 배치 건물 '{buildingData?.buildingName ?? gameObject.name}' 소유자: {initialOwnerId}</color>");
Debug.Log($"<color=cyan>[Building] 사전 배치 건물 '{buildingData?.buildingName ?? gameObject.name}' 소유자: {initialOwnerId}, 팀: {_team.Value}</color>");
}
else if (!useInitialOwner && _ownerId.Value == 0)
{
@@ -87,8 +105,9 @@ namespace Northbound
}
}
// 체력 변경 이벤트 구독
// 이벤트 구독
_currentHealth.OnValueChanged += OnHealthValueChanged;
_team.OnValueChanged += OnTeamValueChanged;
// 체력바 생성
if (showHealthBar && healthBarPrefab != null)
@@ -98,11 +117,13 @@ namespace Northbound
// 초기 체력 UI 업데이트
UpdateHealthUI();
UpdateTeamVisuals();
}
public override void OnNetworkDespawn()
{
_currentHealth.OnValueChanged -= OnHealthValueChanged;
_team.OnValueChanged -= OnTeamValueChanged;
// FogOfWar 시스템에서 제거
if (IsServer && buildingData != null && buildingData.providesVision)
@@ -131,7 +152,7 @@ namespace Northbound
/// <summary>
/// 건물 초기화 (BuildingManager가 동적 생성 시 호출)
/// </summary>
public void Initialize(BuildingData data, Vector3Int gridPos, int rot, ulong ownerId)
public void Initialize(BuildingData data, Vector3Int gridPos, int rot, ulong ownerId, TeamType team = TeamType.Player)
{
buildingData = data;
gridPosition = gridPos;
@@ -142,6 +163,7 @@ namespace Northbound
{
_currentHealth.Value = data.maxHealth;
_ownerId.Value = ownerId;
_team.Value = team;
// 시야 제공자 등록
if (data.providesVision)
@@ -156,14 +178,17 @@ namespace Northbound
/// <summary>
/// 건물 소유권 변경 (점령 등)
/// </summary>
public void SetOwner(ulong newOwnerId)
public void SetOwner(ulong newOwnerId, TeamType newTeam)
{
if (!IsServer) return;
ulong previousOwner = _ownerId.Value;
TeamType previousTeam = _team.Value;
_ownerId.Value = newOwnerId;
_team.Value = newTeam;
Debug.Log($"<color=yellow>[Building] {buildingData?.buildingName ?? ""} 소유권 변경: {previousOwner} → {newOwnerId}</color>");
Debug.Log($"<color=yellow>[Building] {buildingData?.buildingName ?? ""} 소유권 변경: {previousOwner} → {newOwnerId}, 팀: {TeamManager.GetTeamName(previousTeam)} → {TeamManager.GetTeamName(newTeam)}</color>");
// 시야 제공자 재등록 (소유자가 바뀌었으므로)
if (buildingData != null && buildingData.providesVision)
@@ -173,6 +198,35 @@ namespace Northbound
}
}
#region ITeamMember Implementation
public TeamType GetTeam() => _team.Value;
public void SetTeam(TeamType team)
{
if (!IsServer) return;
_team.Value = team;
}
private void OnTeamValueChanged(TeamType previousValue, TeamType newValue)
{
OnTeamChanged?.Invoke(newValue);
UpdateTeamVisuals();
Debug.Log($"<color=cyan>[Building] {buildingData?.buildingName ?? ""} 팀 변경: {TeamManager.GetTeamName(previousValue)} → {TeamManager.GetTeamName(newValue)}</color>");
}
private void UpdateTeamVisuals()
{
// 팀 색상으로 건물 외곽선이나 이펙트 변경 가능
// 예: Renderer의 emission 색상 변경
Color teamColor = TeamManager.GetTeamColor(_team.Value);
// 여기에 실제 비주얼 업데이트 로직 추가
// 예: outline shader, emission, particle system 색상 등
}
#endregion
#region IVisionProvider Implementation
public ulong GetOwnerId() => _ownerId.Value;
@@ -214,11 +268,24 @@ namespace Northbound
if (_currentHealth.Value <= 0)
return;
// 공격자의 팀 확인 (팀 공격 방지)
var attackerObj = NetworkManager.Singleton.SpawnManager.SpawnedObjects[attackerId];
var attackerTeamMember = attackerObj?.GetComponent<ITeamMember>();
if (attackerTeamMember != null)
{
if (!TeamManager.CanAttack(attackerTeamMember, this))
{
Debug.Log($"<color=yellow>[Building] {TeamManager.GetTeamName(attackerTeamMember.GetTeam())} 팀은 {TeamManager.GetTeamName(_team.Value)} 팀을 공격할 수 없습니다.</color>");
return;
}
}
// 데미지 적용
int actualDamage = Mathf.Min(damage, _currentHealth.Value);
_currentHealth.Value -= actualDamage;
Debug.Log($"<color=red>[Building] {buildingData?.buildingName ?? ""}이(가) {actualDamage} 데미지를 받았습니다. 남은 체력: {_currentHealth.Value}/{buildingData?.maxHealth ?? 100}</color>");
Debug.Log($"<color=red>[Building] {buildingData?.buildingName ?? ""} ({TeamManager.GetTeamName(_team.Value)})이(가) {actualDamage} 데미지를 받았습니다. 남은 체력: {_currentHealth.Value}/{buildingData?.maxHealth ?? 100}</color>");
// 데미지 이펙트
ShowDamageEffectClientRpc();
@@ -248,7 +315,7 @@ namespace Northbound
if (!IsServer)
return;
Debug.Log($"<color=red>[Building] {buildingData?.buildingName ?? ""}이(가) 파괴되었습니다! (공격자: {attackerId})</color>");
Debug.Log($"<color=red>[Building] {buildingData?.buildingName ?? ""} ({TeamManager.GetTeamName(_team.Value)})이(가) 파괴되었습니다! (공격자: {attackerId})</color>");
// 파괴 이벤트 발생
OnDestroyed?.Invoke();
@@ -427,7 +494,10 @@ namespace Northbound
if (!showGridBounds || buildingData == null) return;
Bounds bounds = GetGridBounds();
Gizmos.color = gridBoundsColor;
// 팀 색상으로 표시
Color teamColor = Application.isPlaying ? TeamManager.GetTeamColor(_team.Value) : TeamManager.GetTeamColor(initialTeam);
Gizmos.color = new Color(teamColor.r, teamColor.g, teamColor.b, 0.3f);
Gizmos.DrawWireCube(bounds.center, bounds.size);
}
@@ -454,17 +524,19 @@ namespace Northbound
Gizmos.DrawWireSphere(transform.position, buildingData.visionRange);
}
// Draw owner ID label
// Draw team info label
#if UNITY_EDITOR
if (Application.isPlaying)
{
string teamName = TeamManager.GetTeamName(_team.Value);
UnityEditor.Handles.Label(transform.position + Vector3.up * 3f,
$"Owner: {_ownerId.Value}");
$"Owner: {_ownerId.Value}\nTeam: {teamName}");
}
else if (useInitialOwner)
{
string teamName = TeamManager.GetTeamName(initialTeam);
UnityEditor.Handles.Label(transform.position + Vector3.up * 3f,
$"Initial Owner: {initialOwnerId}");
$"Initial Owner: {initialOwnerId}\nTeam: {teamName}");
}
#endif
}

View File

@@ -6,12 +6,17 @@ namespace Northbound
/// <summary>
/// 플레이어가 자원을 건내받아 게임의 전역 자원으로 관리하는 중앙 허브
/// </summary>
public class Core : NetworkBehaviour, IInteractable
public class Core : NetworkBehaviour, IInteractable, IDamageable, ITeamMember
{
[Header("Core Settings")]
public int maxStorageCapacity = 1000; // 코어의 최대 저장 용량
public bool unlimitedStorage = false; // 무제한 저장소
[Header("Health")]
public int maxHealth = 1000;
public GameObject damageEffectPrefab;
public GameObject destroyEffectPrefab;
[Header("Deposit Settings")]
public bool depositAll = true; // true: 전부 건네기, false: 일부만 건네기
public int depositAmountPerInteraction = 10; // depositAll이 false일 때 한 번에 건네는 양
@@ -32,17 +37,163 @@ namespace Northbound
NetworkVariableWritePermission.Server
);
private NetworkVariable<int> _currentHealth = new NetworkVariable<int>(
0,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
public int TotalResources => _totalResources.Value;
public int MaxStorageCapacity => maxStorageCapacity;
public int CurrentHealth => _currentHealth.Value;
public int MaxHealth => maxHealth;
public override void OnNetworkSpawn()
{
if (IsServer)
{
_totalResources.Value = 0;
_currentHealth.Value = maxHealth;
}
_currentHealth.OnValueChanged += OnHealthChanged;
}
public override void OnNetworkDespawn()
{
_currentHealth.OnValueChanged -= OnHealthChanged;
}
private void OnHealthChanged(int previousValue, int newValue)
{
Debug.Log($"<color=red>[Core] 코어 체력 변경: {previousValue} → {newValue} ({newValue}/{maxHealth})</color>");
}
#region ITeamMember Implementation
public TeamType GetTeam()
{
return TeamType.Player; // 코어는 플레이어 팀
}
public void SetTeam(TeamType team)
{
// 코어의 팀은 변경할 수 없음 (항상 플레이어 팀)
Debug.LogWarning("[Core] 코어의 팀은 변경할 수 없습니다.");
}
#endregion
#region IDamageable Implementation
public void TakeDamage(int damage, ulong attackerId)
{
if (!IsServer) return;
if (_currentHealth.Value <= 0) return;
int actualDamage = Mathf.Min(damage, _currentHealth.Value);
_currentHealth.Value -= actualDamage;
Debug.Log($"<color=red>[Core] 코어가 {actualDamage} 데미지를 받았습니다! 남은 체력: {_currentHealth.Value}/{maxHealth}</color>");
// 데미지 이펙트
ShowDamageEffectClientRpc();
// 체력이 0이 되면 게임 오버
if (_currentHealth.Value <= 0)
{
OnCoreDestroyed();
}
}
private void OnCoreDestroyed()
{
if (!IsServer) return;
Debug.Log($"<color=red>[Core] 코어가 파괴되었습니다! 게임 오버!</color>");
// 파괴 이펙트
ShowDestroyEffectClientRpc();
// 게임 오버 로직 (추후 구현)
// GameManager.Instance?.OnGameOver();
}
[Rpc(SendTo.ClientsAndHost)]
private void ShowDamageEffectClientRpc()
{
if (damageEffectPrefab != null)
{
GameObject effect = Instantiate(damageEffectPrefab, transform.position + Vector3.up * 2f, Quaternion.identity);
Destroy(effect, 2f);
}
}
[Rpc(SendTo.ClientsAndHost)]
private void ShowDestroyEffectClientRpc()
{
if (destroyEffectPrefab != null)
{
GameObject effect = Instantiate(destroyEffectPrefab, transform.position, Quaternion.identity);
Destroy(effect, 5f);
}
}
#endregion
#region Resource Management
/// <summary>
/// 자원을 소비할 수 있는지 확인
/// </summary>
public bool CanConsumeResource(int amount)
{
return _totalResources.Value >= amount;
}
/// <summary>
/// 자원 소비 (서버에서만)
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
public void ConsumeResourceServerRpc(int amount)
{
if (!CanConsumeResource(amount))
{
Debug.LogWarning($"[Core] 자원이 부족합니다. 필요: {amount}, 보유: {_totalResources.Value}");
return;
}
int previousAmount = _totalResources.Value;
_totalResources.Value -= amount;
Debug.Log($"<color=yellow>[Core] {amount} 자원 소비. 남은 자원: {_totalResources.Value}/{maxStorageCapacity}</color>");
}
/// <summary>
/// 자원 추가 (서버에서만)
/// </summary>
public void AddResource(int amount)
{
if (!IsServer) return;
if (!unlimitedStorage)
{
int availableSpace = maxStorageCapacity - _totalResources.Value;
amount = Mathf.Min(amount, availableSpace);
}
if (amount > 0)
{
_totalResources.Value += amount;
Debug.Log($"<color=green>[Core] {amount} 자원 추가. 총 자원: {_totalResources.Value}" +
(unlimitedStorage ? "" : $"/{maxStorageCapacity}") + "</color>");
}
}
#endregion
#region IInteractable Implementation
public bool CanInteract(ulong playerId)
{
// 저장소가 가득 찼는지 확인 (무제한이 아닐 때)
@@ -75,6 +226,33 @@ namespace Northbound
DepositResourceServerRpc(playerId);
}
public string GetInteractionPrompt()
{
if (unlimitedStorage)
{
return "자원 보관 (무제한)";
}
else
{
return $"자원 보관 ({_totalResources.Value}/{maxStorageCapacity})";
}
}
public string GetInteractionAnimation()
{
return interactionAnimationTrigger;
}
public InteractionEquipmentData GetEquipmentData()
{
return equipmentData;
}
public Transform GetTransform()
{
return transform;
}
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void DepositResourceServerRpc(ulong playerId)
{
@@ -132,8 +310,8 @@ namespace Northbound
// 코어에 자원 추가
_totalResources.Value += depositAmount;
Debug.Log($"플레이어 {playerId}가 {depositAmount} 자원을 코어에 건넸습니다. 코어 총 자원: {_totalResources.Value}" +
(unlimitedStorage ? "" : $"/{maxStorageCapacity}"));
Debug.Log($"<color=green>[Core] 플레이어 {playerId}가 {depositAmount} 자원을 건넸습니다. 코어 총 자원: {_totalResources.Value}" +
(unlimitedStorage ? "" : $"/{maxStorageCapacity}") + "</color>");
ShowDepositEffectClientRpc();
}
@@ -148,76 +326,6 @@ namespace Northbound
}
}
/// <summary>
/// 게임 시스템이 코어의 자원을 사용 (건물 건설 등)
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
public void ConsumeResourceServerRpc(int amount)
{
if (amount <= 0) return;
int actualAmount = Mathf.Min(amount, _totalResources.Value);
_totalResources.Value -= actualAmount;
Debug.Log($"코어에서 {actualAmount} 자원을 사용했습니다. 남은 자원: {_totalResources.Value}");
}
/// <summary>
/// 자원을 사용할 수 있는지 확인
/// </summary>
public bool CanConsumeResource(int amount)
{
return _totalResources.Value >= amount;
}
/// <summary>
/// 코어에 자원 추가 (디버그/관리자 기능)
/// </summary>
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
public void AddResourceServerRpc(int amount)
{
if (amount <= 0) return;
if (!unlimitedStorage)
{
int availableSpace = maxStorageCapacity - _totalResources.Value;
amount = Mathf.Min(amount, availableSpace);
}
_totalResources.Value += amount;
Debug.Log($"코어에 {amount} 자원이 추가되었습니다. 현재: {_totalResources.Value}");
}
public string GetInteractionPrompt()
{
if (unlimitedStorage)
{
return depositAll ?
$"[E] 자원 모두 건네기" :
$"[E] 자원 건네기 ({depositAmountPerInteraction}개씩)";
}
if (_totalResources.Value >= maxStorageCapacity)
return "코어 저장소 가득 찼음";
return depositAll ?
$"[E] 자원 모두 건네기 ({_totalResources.Value}/{maxStorageCapacity})" :
$"[E] 자원 건네기 ({_totalResources.Value}/{maxStorageCapacity})";
}
public string GetInteractionAnimation()
{
return interactionAnimationTrigger;
}
public InteractionEquipmentData GetEquipmentData()
{
return equipmentData;
}
public Transform GetTransform()
{
return transform;
}
#endregion
}
}

View File

@@ -0,0 +1,714 @@
using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;
namespace Northbound
{
/// <summary>
/// 몬스터와 적대 세력의 AI 컨트롤러
/// </summary>
[RequireComponent(typeof(NavMeshAgent))]
[RequireComponent(typeof(EnemyUnit))]
public class EnemyAIController : NetworkBehaviour
{
[Header("AI Type")]
[Tooltip("Monster: 코어로 이동, Hostile: 제자리에서 대기")]
public TeamType aiType = TeamType.Monster;
[Header("Detection")]
[Tooltip("플레이어 감지 범위")]
public float detectionRange = 15f;
[Tooltip("시야 각도 (0-360, 360=전방향)")]
[Range(0, 360)]
public float detectionAngle = 120f;
[Tooltip("탐지할 레이어")]
public LayerMask playerLayer = ~0;
[Tooltip("시야 체크 장애물 레이어")]
public LayerMask obstacleLayer = ~0;
[Header("Chase Settings")]
[Tooltip("추적 최대 거리 (이 거리 이상 추적하면 중단)")]
public float maxChaseDistance = 30f;
[Tooltip("추적 포기 거리 (플레이어와 이 거리 이상 멀어지면 추적 중단)")]
public float chaseGiveUpDistance = 25f;
[Header("Combat")]
[Tooltip("공격 범위")]
public float attackRange = 2f;
[Tooltip("공격 간격 (초)")]
public float attackInterval = 1.5f;
[Tooltip("공격 데미지")]
public int attackDamage = 10;
[Header("Movement")]
[Tooltip("이동 속도")]
public float moveSpeed = 3.5f;
[Tooltip("추적 중 속도 배율")]
public float chaseSpeedMultiplier = 1.5f;
[Header("Debug")]
[Tooltip("디버그 정보 표시")]
public bool showDebugInfo = true;
private NavMeshAgent _agent;
private EnemyUnit _enemyUnit;
private Transform _coreTransform;
private Collider _coreCollider;
private Vector3 _originPosition;
private Vector3 _chaseStartPosition;
private float _lastAttackTime;
private bool _hasSetCoreDestination;
private float _lastDetectionLogTime;
private NetworkVariable<EnemyAIState> _currentState = new NetworkVariable<EnemyAIState>(
EnemyAIState.Idle,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private NetworkVariable<ulong> _targetPlayerId = new NetworkVariable<ulong>(
0,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private GameObject _cachedTargetPlayer;
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
_agent = GetComponent<NavMeshAgent>();
_enemyUnit = GetComponent<EnemyUnit>();
_originPosition = transform.position;
if (IsServer)
{
// NavMeshAgent 초기 설정
_agent.speed = moveSpeed;
_agent.acceleration = 8f;
_agent.angularSpeed = 120f;
_agent.stoppingDistance = attackRange * 0.7f;
_agent.autoBraking = true;
_agent.updateRotation = true;
_agent.updateUpAxis = false;
// NavMesh 위에 있는지 확인
if (!_agent.isOnNavMesh)
{
Debug.LogWarning($"<color=orange>[EnemyAI] {gameObject.name}이(가) NavMesh 위에 있지 않습니다!</color>");
}
else
{
Debug.Log($"<color=green>[EnemyAI] {gameObject.name} NavMeshAgent 초기화 완료</color>");
}
// AI 타입에 따라 초기 상태 설정
if (aiType == TeamType.Monster)
{
FindCore();
TransitionToState(EnemyAIState.MoveToCore);
}
else if (aiType == TeamType.Hostile)
{
TransitionToState(EnemyAIState.Idle);
}
}
}
private void Update()
{
if (!IsServer) return;
if (!_agent.isOnNavMesh) return;
switch (_currentState.Value)
{
case EnemyAIState.Idle:
UpdateIdle();
break;
case EnemyAIState.MoveToCore:
UpdateMoveToCore();
break;
case EnemyAIState.ChasePlayer:
UpdateChasePlayer();
break;
case EnemyAIState.Attack:
UpdateAttack();
break;
case EnemyAIState.ReturnToOrigin:
UpdateReturnToOrigin();
break;
}
}
#region State Updates
private void UpdateIdle()
{
GameObject player = DetectPlayer();
if (player != null)
{
SetTargetPlayer(player);
TransitionToState(EnemyAIState.ChasePlayer);
}
}
private void UpdateMoveToCore()
{
// 플레이어 감지
GameObject player = DetectPlayer();
if (player != null)
{
SetTargetPlayer(player);
TransitionToState(EnemyAIState.ChasePlayer);
return;
}
// 코어가 없으면 찾기
if (_coreTransform == null)
{
FindCore();
_hasSetCoreDestination = false;
return;
}
// 코어 표면까지의 실제 거리 계산
float distanceToCore = GetDistanceToCoreSurface();
// 공격 범위 안에 있으면 공격 상태로 전환
if (distanceToCore <= attackRange)
{
TransitionToState(EnemyAIState.Attack);
return;
}
// 경로가 설정되지 않았거나 무효화된 경우에만 설정
if (!_hasSetCoreDestination || !_agent.hasPath || _agent.pathStatus == NavMeshPathStatus.PathInvalid)
{
if (_agent.SetDestination(_coreTransform.position))
{
_hasSetCoreDestination = true;
if (showDebugInfo)
{
Debug.Log($"<color=cyan>[EnemyAI] {gameObject.name} 코어로 경로 설정 (표면 거리: {distanceToCore:F2}m, 공격범위: {attackRange:F2}m)</color>");
}
}
else
{
Debug.LogWarning($"<color=orange>[EnemyAI] {gameObject.name}이(가) 코어로 가는 경로를 찾을 수 없습니다!</color>");
_hasSetCoreDestination = false;
}
}
}
private void UpdateChasePlayer()
{
GameObject targetPlayer = GetTargetPlayer();
if (targetPlayer == null)
{
OnLostTarget();
return;
}
float distanceToPlayer = Vector3.Distance(transform.position, targetPlayer.transform.position);
// 추적 기준점 설정
Vector3 chaseReferencePoint = (aiType == TeamType.Monster) ? _chaseStartPosition : _originPosition;
float distanceFromReference = Vector3.Distance(transform.position, chaseReferencePoint);
// 추적 중단 조건 확인
if (distanceToPlayer > chaseGiveUpDistance || distanceFromReference > maxChaseDistance)
{
if (showDebugInfo)
{
string referenceType = (aiType == TeamType.Monster) ? "추적 시작" : "원점";
Debug.Log($"<color=yellow>[EnemyAI] {gameObject.name}이(가) 추적을 중단합니다. (플레이어 거리: {distanceToPlayer:F2}m, {referenceType} 거리: {distanceFromReference:F2}m)</color>");
}
OnLostTarget();
return;
}
// 공격 범위 확인
if (distanceToPlayer <= attackRange)
{
TransitionToState(EnemyAIState.Attack);
return;
}
// 플레이어 추적 - 매 프레임 업데이트
if (_agent.isOnNavMesh && !_agent.isStopped)
{
_agent.SetDestination(targetPlayer.transform.position);
}
}
private void UpdateAttack()
{
// 코어 공격 중인지 확인
bool attackingCore = _coreTransform != null &&
GetDistanceToCoreSurface() <= attackRange * 1.2f;
if (attackingCore)
{
float distanceToCore = GetDistanceToCoreSurface();
if (distanceToCore > attackRange * 1.2f)
{
TransitionToState(EnemyAIState.MoveToCore);
return;
}
// 코어를 바라보기
Vector3 directionToCore = (_coreTransform.position - transform.position).normalized;
directionToCore.y = 0;
if (directionToCore != Vector3.zero)
{
Quaternion targetRotation = Quaternion.LookRotation(directionToCore);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f);
}
// 코어 공격
IDamageable coreHealth = _coreTransform.GetComponent<IDamageable>();
if (coreHealth != null && Time.time - _lastAttackTime >= attackInterval)
{
coreHealth.TakeDamage(attackDamage, NetworkObjectId);
_lastAttackTime = Time.time;
Debug.Log($"<color=red>[EnemyAI] {gameObject.name}이(가) 코어를 공격! (데미지: {attackDamage}, 표면 거리: {distanceToCore:F2}m)</color>");
}
}
else
{
// 플레이어 공격
GameObject targetPlayer = GetTargetPlayer();
if (targetPlayer == null)
{
OnLostTarget();
return;
}
float distanceToPlayer = Vector3.Distance(transform.position, targetPlayer.transform.position);
if (distanceToPlayer > attackRange * 1.2f)
{
TransitionToState(EnemyAIState.ChasePlayer);
return;
}
// 플레이어를 바라보기
Vector3 directionToPlayer = (targetPlayer.transform.position - transform.position).normalized;
directionToPlayer.y = 0;
if (directionToPlayer != Vector3.zero)
{
Quaternion targetRotation = Quaternion.LookRotation(directionToPlayer);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f);
}
// 공격
if (Time.time - _lastAttackTime >= attackInterval)
{
AttackPlayer(targetPlayer);
}
}
}
private void UpdateReturnToOrigin()
{
GameObject player = DetectPlayer();
if (player != null)
{
SetTargetPlayer(player);
TransitionToState(EnemyAIState.ChasePlayer);
return;
}
if (!_agent.pathPending && _agent.remainingDistance <= _agent.stoppingDistance)
{
if (!_agent.hasPath || _agent.velocity.sqrMagnitude == 0f)
{
if (showDebugInfo)
{
Debug.Log($"<color=green>[EnemyAI] {gameObject.name}이(가) 원래 위치로 복귀했습니다.</color>");
}
TransitionToState(EnemyAIState.Idle);
}
}
}
#endregion
#region Detection
private GameObject DetectPlayer()
{
Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRange, playerLayer);
GameObject closestPlayer = null;
float closestDistance = float.MaxValue;
foreach (Collider col in colliders)
{
// 자기 자신 제외
if (col.transform.root == transform.root)
continue;
// 플레이어 팀 확인 (부모에서 찾기)
ITeamMember teamMember = col.GetComponentInParent<ITeamMember>();
if (teamMember == null || teamMember.GetTeam() != TeamType.Player)
continue;
// 플레이어 위치 (루트 오브젝트 사용)
Transform playerRoot = col.transform.root;
Vector3 playerPosition = playerRoot.position;
// 거리 체크
float distance = Vector3.Distance(transform.position, playerPosition);
if (distance > detectionRange)
continue;
// 시야각 확인 (360도면 모든 방향 감지)
if (detectionAngle < 360f)
{
Vector3 directionToTarget = (playerPosition - transform.position).normalized;
float angleToTarget = Vector3.Angle(transform.forward, directionToTarget);
if (angleToTarget > detectionAngle / 2f)
continue;
}
// 시야 체크 (레이캐스트) - 플레이어 중심으로
Vector3 rayStart = transform.position + Vector3.up * 1f; // 적의 눈 높이
Vector3 rayTarget = playerPosition + Vector3.up * 1f; // 플레이어 중심
Vector3 rayDirection = (rayTarget - rayStart).normalized;
float rayDistance = Vector3.Distance(rayStart, rayTarget);
bool lineOfSight = true;
// 장애물 체크 (옵션)
if (Physics.Raycast(rayStart, rayDirection, out RaycastHit hit, rayDistance, obstacleLayer))
{
// 맞은 오브젝트가 플레이어의 루트나 자식인지 확인
if (hit.transform.root != playerRoot)
{
lineOfSight = false;
}
}
if (lineOfSight)
{
// 가장 가까운 플레이어 찾기
if (distance < closestDistance)
{
closestDistance = distance;
closestPlayer = playerRoot.gameObject;
}
}
}
// 감지 성공 시 로그 (1초에 한 번만)
if (closestPlayer != null && showDebugInfo && Time.time - _lastDetectionLogTime >= 1f)
{
string angleInfo = detectionAngle >= 360f ? "전방향" : $"{Vector3.Angle(transform.forward, (closestPlayer.transform.position - transform.position).normalized):F1}°";
Debug.Log($"<color=cyan>[EnemyAI] {gameObject.name}이(가) {closestPlayer.name}을(를) 감지! (거리: {closestDistance:F2}m, 각도: {angleInfo})</color>");
_lastDetectionLogTime = Time.time;
}
return closestPlayer;
}
#endregion
#region Combat
private void AttackPlayer(GameObject player)
{
IDamageable damageable = player.GetComponentInParent<IDamageable>();
if (damageable != null)
{
damageable.TakeDamage(attackDamage, NetworkObjectId);
_lastAttackTime = Time.time;
if (showDebugInfo)
{
Debug.Log($"<color=red>[EnemyAI] {gameObject.name}이(가) {player.name}을(를) 공격! (데미지: {attackDamage})</color>");
}
}
}
#endregion
#region Distance Calculation
private float GetDistanceToCoreSurface()
{
if (_coreTransform == null)
return float.MaxValue;
if (_coreCollider != null)
{
Vector3 closestPoint = _coreCollider.ClosestPoint(transform.position);
float distanceToSurface = Vector3.Distance(transform.position, closestPoint);
return distanceToSurface;
}
else
{
return Vector3.Distance(transform.position, _coreTransform.position);
}
}
#endregion
#region State Management
private void TransitionToState(EnemyAIState newState)
{
if (_currentState.Value == newState) return;
if (showDebugInfo)
{
Debug.Log($"<color=magenta>[EnemyAI] {gameObject.name} 상태 변경: {_currentState.Value} → {newState}</color>");
}
OnExitState(_currentState.Value);
_currentState.Value = newState;
OnEnterState(newState);
}
private void OnEnterState(EnemyAIState state)
{
switch (state)
{
case EnemyAIState.Idle:
_agent.isStopped = true;
_agent.speed = moveSpeed;
_agent.ResetPath();
break;
case EnemyAIState.MoveToCore:
_agent.isStopped = false;
_agent.speed = moveSpeed;
_hasSetCoreDestination = false;
if (showDebugInfo)
{
Debug.Log($"<color=cyan>[EnemyAI] {gameObject.name}이(가) 코어로 이동 시작</color>");
}
break;
case EnemyAIState.ChasePlayer:
_agent.isStopped = false;
_agent.speed = moveSpeed * chaseSpeedMultiplier;
_chaseStartPosition = transform.position;
if (showDebugInfo)
{
Debug.Log($"<color=cyan>[EnemyAI] {gameObject.name}이(가) 추적 시작! (시작 위치: {_chaseStartPosition})</color>");
}
break;
case EnemyAIState.Attack:
_agent.isStopped = true;
_agent.ResetPath();
break;
case EnemyAIState.ReturnToOrigin:
_agent.isStopped = false;
_agent.speed = moveSpeed;
_agent.stoppingDistance = 1f;
_agent.SetDestination(_originPosition);
ClearTargetPlayer();
break;
}
}
private void OnExitState(EnemyAIState state)
{
if (state == EnemyAIState.ReturnToOrigin)
{
_agent.stoppingDistance = attackRange * 0.7f;
}
}
private void OnLostTarget()
{
if (aiType == TeamType.Hostile)
{
TransitionToState(EnemyAIState.ReturnToOrigin);
}
else if (aiType == TeamType.Monster)
{
ClearTargetPlayer();
_hasSetCoreDestination = false;
TransitionToState(EnemyAIState.MoveToCore);
}
}
#endregion
#region Target Management
private void SetTargetPlayer(GameObject player)
{
var networkObject = player.GetComponentInParent<NetworkObject>();
if (networkObject != null)
{
_targetPlayerId.Value = networkObject.NetworkObjectId;
_cachedTargetPlayer = player;
}
}
private void ClearTargetPlayer()
{
_targetPlayerId.Value = 0;
_cachedTargetPlayer = null;
}
private GameObject GetTargetPlayer()
{
if (_targetPlayerId.Value == 0) return null;
if (_cachedTargetPlayer != null && _cachedTargetPlayer.activeSelf)
{
return _cachedTargetPlayer;
}
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(_targetPlayerId.Value, out NetworkObject networkObject))
{
_cachedTargetPlayer = networkObject.gameObject;
return _cachedTargetPlayer;
}
return null;
}
#endregion
#region Utilities
private void FindCore()
{
Core core = FindFirstObjectByType<Core>();
if (core != null)
{
_coreTransform = core.transform;
_coreCollider = core.GetComponent<Collider>();
if (_coreCollider == null)
{
_coreCollider = core.GetComponentInChildren<Collider>();
}
if (_coreCollider != null)
{
Debug.Log($"<color=green>[EnemyAI] {gameObject.name}이(가) 코어를 찾았습니다! (위치: {_coreTransform.position}, Collider: {_coreCollider.GetType().Name})</color>");
}
else
{
Debug.LogWarning($"<color=orange>[EnemyAI] {gameObject.name}이(가) 코어를 찾았지만 Collider가 없습니다.</color>");
}
}
else
{
Debug.LogWarning($"<color=orange>[EnemyAI] {gameObject.name}이(가) 코어를 찾을 수 없습니다!</color>");
}
}
#endregion
#region Gizmos
private void OnDrawGizmos()
{
if (!showDebugInfo) return;
// 감지 범위
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, detectionRange);
// 공격 범위
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, attackRange);
// 시야각 (360도가 아닐 때만 표시)
if (detectionAngle < 360f)
{
Vector3 forward = transform.forward * detectionRange;
Vector3 leftBoundary = Quaternion.Euler(0, -detectionAngle / 2f, 0) * forward;
Vector3 rightBoundary = Quaternion.Euler(0, detectionAngle / 2f, 0) * forward;
Gizmos.color = Color.blue;
Gizmos.DrawLine(transform.position, transform.position + leftBoundary);
Gizmos.DrawLine(transform.position, transform.position + rightBoundary);
}
// 원점 표시 (적대 세력만)
if (aiType == TeamType.Hostile && Application.isPlaying)
{
Gizmos.color = Color.green;
Gizmos.DrawWireSphere(_originPosition, 1f);
Gizmos.DrawLine(transform.position, _originPosition);
}
// 추적 시작 위치 표시
if (Application.isPlaying && (_currentState.Value == EnemyAIState.ChasePlayer || _currentState.Value == EnemyAIState.Attack))
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireSphere(_chaseStartPosition, 1.5f);
Gizmos.DrawLine(transform.position, _chaseStartPosition);
}
// 코어 방향 및 표면까지의 거리 표시
if (Application.isPlaying && _currentState.Value == EnemyAIState.MoveToCore && _coreTransform != null)
{
Gizmos.color = Color.magenta;
Gizmos.DrawLine(transform.position, _coreTransform.position);
Gizmos.DrawWireSphere(_coreTransform.position, 2f);
if (_coreCollider != null)
{
Vector3 closestPoint = _coreCollider.ClosestPoint(transform.position);
Gizmos.color = Color.green;
Gizmos.DrawLine(transform.position, closestPoint);
Gizmos.DrawWireSphere(closestPoint, 0.5f);
}
}
}
private void OnDrawGizmosSelected()
{
OnDrawGizmos();
#if UNITY_EDITOR
if (Application.isPlaying && _agent != null)
{
string pathInfo = _agent.hasPath ? $"Path: {_agent.path.status}" : "No Path";
string navMeshInfo = _agent.isOnNavMesh ? "On NavMesh" : "OFF NAVMESH!";
string velocityInfo = $"Velocity: {_agent.velocity.magnitude:F2}";
string distanceInfo = "";
if (_coreTransform != null && _currentState.Value == EnemyAIState.MoveToCore)
{
float surfaceDistance = GetDistanceToCoreSurface();
distanceInfo = $"\nCore Surface Dist: {surfaceDistance:F2}m";
}
string angleInfo = detectionAngle >= 360f ? "\nDetection: 360° (전방향)" : $"\nDetection: {detectionAngle}°";
UnityEditor.Handles.Label(transform.position + Vector3.up * 3f,
$"Enemy AI\nState: {_currentState.Value}\nType: {aiType}\n{navMeshInfo}\n{pathInfo}\n{velocityInfo}{angleInfo}\nRange: {detectionRange}m\nAttack: {attackRange}m{distanceInfo}");
}
#endif
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 453e726e48d16214f84c6d5737edd7df

View File

@@ -0,0 +1,14 @@
namespace Northbound
{
/// <summary>
/// 적 AI의 상태
/// </summary>
public enum EnemyAIState
{
Idle, // 대기 (적대 세력 기본 상태)
MoveToCore, // 코어로 이동 (몬스터 기본 상태)
ChasePlayer, // 플레이어 추적
Attack, // 공격
ReturnToOrigin // 원래 위치로 복귀 (적대 세력)
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f34add9794647d043ae26b47fac8e429

191
Assets/Scripts/EnemyUnit.cs Normal file
View File

@@ -0,0 +1,191 @@
using Unity.Netcode;
using UnityEngine;
namespace Northbound
{
/// <summary>
/// 적대 유닛 (적대세력 또는 몬스터)
/// </summary>
public class EnemyUnit : NetworkBehaviour, IDamageable, ITeamMember, IVisionProvider
{
[Header("Team Settings")]
[Tooltip("이 유닛의 팀 (Hostile = 적대세력, Monster = 몬스터)")]
public TeamType enemyTeam = TeamType.Hostile;
[Header("Combat")]
public int maxHealth = 100;
public float visionRange = 10f;
[Header("Visual")]
public GameObject damageEffectPrefab;
public GameObject destroyEffectPrefab;
private NetworkVariable<int> _currentHealth = new NetworkVariable<int>(
0,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private NetworkVariable<TeamType> _team = new NetworkVariable<TeamType>(
TeamType.Neutral,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
if (IsServer)
{
_currentHealth.Value = maxHealth;
_team.Value = enemyTeam;
// FogOfWar 시스템에 등록
FogOfWarSystem.Instance?.RegisterVisionProvider(this);
Debug.Log($"<color=magenta>[EnemyUnit] {gameObject.name} 스폰됨 (팀: {TeamManager.GetTeamName(_team.Value)})</color>");
}
}
public override void OnNetworkDespawn()
{
base.OnNetworkDespawn();
if (IsServer)
{
FogOfWarSystem.Instance?.UnregisterVisionProvider(this);
}
}
#region IDamageable Implementation
public void TakeDamage(int damage, ulong attackerId)
{
if (!IsServer) return;
if (_currentHealth.Value <= 0) return;
// 공격자의 팀 확인
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(attackerId, out NetworkObject attackerObj))
{
var attackerTeamMember = attackerObj.GetComponent<ITeamMember>();
if (attackerTeamMember != null)
{
if (!TeamManager.CanAttack(attackerTeamMember, this))
{
Debug.Log($"<color=yellow>[EnemyUnit] {TeamManager.GetTeamName(attackerTeamMember.GetTeam())} 팀은 {TeamManager.GetTeamName(_team.Value)} 팀을 공격할 수 없습니다.</color>");
return;
}
}
}
int actualDamage = Mathf.Min(damage, _currentHealth.Value);
_currentHealth.Value -= actualDamage;
Debug.Log($"<color=red>[EnemyUnit] {gameObject.name} ({TeamManager.GetTeamName(_team.Value)})이(가) {actualDamage} 데미지를 받았습니다. 남은 체력: {_currentHealth.Value}/{maxHealth}</color>");
// 데미지 이펙트
ShowDamageEffectClientRpc();
// 체력이 0이 되면 파괴
if (_currentHealth.Value <= 0)
{
DestroyUnit(attackerId);
}
}
private void DestroyUnit(ulong attackerId)
{
if (!IsServer) return;
Debug.Log($"<color=red>[EnemyUnit] {gameObject.name} ({TeamManager.GetTeamName(_team.Value)})이(가) 파괴되었습니다! (공격자: {attackerId})</color>");
// 파괴 이펙트
ShowDestroyEffectClientRpc();
// FogOfWar 시스템에서 제거
FogOfWarSystem.Instance?.UnregisterVisionProvider(this);
// 네트워크 오브젝트 파괴
Invoke(nameof(DespawnUnit), 0.5f);
}
private void DespawnUnit()
{
if (IsServer && NetworkObject != null)
{
NetworkObject.Despawn(true);
}
}
[ClientRpc]
private void ShowDamageEffectClientRpc()
{
if (damageEffectPrefab != null)
{
GameObject effect = Instantiate(damageEffectPrefab, transform.position, Quaternion.identity);
Destroy(effect, 2f);
}
}
[ClientRpc]
private void ShowDestroyEffectClientRpc()
{
if (destroyEffectPrefab != null)
{
GameObject effect = Instantiate(destroyEffectPrefab, transform.position, Quaternion.identity);
Destroy(effect, 3f);
}
}
#endregion
#region ITeamMember Implementation
public TeamType GetTeam() => _team.Value;
public void SetTeam(TeamType team)
{
if (!IsServer) return;
_team.Value = team;
}
#endregion
#region IVisionProvider Implementation
public ulong GetOwnerId() => OwnerClientId;
public float GetVisionRange() => visionRange;
public Transform GetTransform() => transform;
public bool IsActive() => IsSpawned && _currentHealth.Value > 0;
#endregion
private void OnDrawGizmosSelected()
{
// 팀 색상으로 시야 범위 표시
Color teamColor = Application.isPlaying
? TeamManager.GetTeamColor(_team.Value)
: TeamManager.GetTeamColor(enemyTeam);
Gizmos.color = new Color(teamColor.r, teamColor.g, teamColor.b, 0.3f);
Gizmos.DrawWireSphere(transform.position, visionRange);
#if UNITY_EDITOR
if (Application.isPlaying)
{
UnityEditor.Handles.Label(transform.position + Vector3.up * 2f,
$"Team: {TeamManager.GetTeamName(_team.Value)}\nHP: {_currentHealth.Value}/{maxHealth}");
}
else
{
UnityEditor.Handles.Label(transform.position + Vector3.up * 2f,
$"Team: {TeamManager.GetTeamName(enemyTeam)}");
}
#endif
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 345fc6e7d4f06314f8b548129700eccb

View File

@@ -0,0 +1,11 @@
namespace Northbound
{
/// <summary>
/// 팀에 속한 엔티티
/// </summary>
public interface ITeamMember
{
TeamType GetTeam();
void SetTeam(TeamType team);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5e343729a9b720e438ce09faa7886ab0

View File

@@ -2,13 +2,37 @@ using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
using Unity.Cinemachine;
using Northbound;
public class NetworkPlayerController : NetworkBehaviour
public class NetworkPlayerController : NetworkBehaviour, ITeamMember, IDamageable
{
[Header("Movement Settings")]
public float moveSpeed = 5f;
public float rotationSpeed = 10f;
[Header("Team Settings")]
[SerializeField] private TeamType initialTeam = TeamType.Player;
[Header("Health Settings")]
[SerializeField] private int maxHealth = 100;
[SerializeField] private bool showHealthBar = true;
[Header("Visual Effects")]
[SerializeField] private GameObject damageEffectPrefab;
[SerializeField] private GameObject deathEffectPrefab;
private NetworkVariable<TeamType> _team = new NetworkVariable<TeamType>(
TeamType.Player,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private NetworkVariable<int> _currentHealth = new NetworkVariable<int>(
100,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private Vector2 _moveInput;
private CharacterController _controller;
private PlayerInputActions _inputActions;
@@ -22,6 +46,27 @@ public class NetworkPlayerController : NetworkBehaviour
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
// 서버에서 초기화
if (IsServer)
{
if (_team.Value == TeamType.Neutral)
{
_team.Value = initialTeam;
}
if (_currentHealth.Value == 0)
{
_currentHealth.Value = maxHealth;
}
Debug.Log($"<color=cyan>[Player] {gameObject.name} 스폰됨 (팀: {TeamManager.GetTeamName(_team.Value)}, 체력: {_currentHealth.Value}/{maxHealth})</color>");
}
// 체력 변경 이벤트 구독
_currentHealth.OnValueChanged += OnHealthChanged;
if (!IsOwner) return;
var vcam = GameObject.FindFirstObjectByType<CinemachineCamera>();
@@ -39,16 +84,23 @@ public class NetworkPlayerController : NetworkBehaviour
public override void OnNetworkDespawn()
{
_currentHealth.OnValueChanged -= OnHealthChanged;
if (IsOwner && _inputActions != null)
{
_inputActions.Disable();
}
base.OnNetworkDespawn();
}
void Update()
{
if (!IsOwner) return;
// 죽었으면 이동 불가
if (_currentHealth.Value <= 0) return;
_moveInput = _inputActions.Player.Move.ReadValue<Vector2>();
Vector3 move = new Vector3(_moveInput.x, 0, _moveInput.y).normalized;
@@ -68,4 +120,201 @@ public class NetworkPlayerController : NetworkBehaviour
_animator.SetFloat("MoveSpeed", move.magnitude);
}
}
#region ITeamMember Implementation
public TeamType GetTeam() => _team.Value;
public void SetTeam(TeamType team)
{
if (!IsServer) return;
TeamType previousTeam = _team.Value;
_team.Value = team;
Debug.Log($"<color=cyan>[Player] 팀 변경: {TeamManager.GetTeamName(previousTeam)} → {TeamManager.GetTeamName(team)}</color>");
}
#endregion
#region IDamageable Implementation
public void TakeDamage(int damage, ulong attackerId)
{
if (!IsServer) return;
// 이미 죽었으면 무시
if (_currentHealth.Value <= 0) return;
// 공격자의 팀 확인
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(attackerId, out NetworkObject attackerObj))
{
var attackerTeamMember = attackerObj.GetComponent<ITeamMember>();
if (attackerTeamMember != null)
{
if (!TeamManager.CanAttack(attackerTeamMember, this))
{
Debug.Log($"<color=yellow>[Player] {TeamManager.GetTeamName(attackerTeamMember.GetTeam())} 팀은 {TeamManager.GetTeamName(_team.Value)} 팀을 공격할 수 없습니다.</color>");
return;
}
}
}
// 데미지 적용
int actualDamage = Mathf.Min(damage, _currentHealth.Value);
_currentHealth.Value -= actualDamage;
Debug.Log($"<color=red>[Player] {gameObject.name} ({TeamManager.GetTeamName(_team.Value)})이(가) {actualDamage} 데미지를 받았습니다. 남은 체력: {_currentHealth.Value}/{maxHealth}</color>");
// 데미지 이펙트
ShowDamageEffectClientRpc();
// 체력이 0이 되면 사망
if (_currentHealth.Value <= 0)
{
Die(attackerId);
}
}
private void Die(ulong killerId)
{
if (!IsServer) return;
Debug.Log($"<color=red>[Player] {gameObject.name} ({TeamManager.GetTeamName(_team.Value)})이(가) 사망했습니다! (킬러: {killerId})</color>");
// 사망 이펙트
ShowDeathEffectClientRpc();
// 애니메이션 (있는 경우)
if (_animator != null)
{
_animator.SetTrigger("Die");
}
// 일정 시간 후 리스폰 또는 디스폰
Invoke(nameof(HandleDeath), 3f);
}
private void HandleDeath()
{
if (!IsServer) return;
// 여기서 리스폰 로직을 추가하거나 게임 오버 처리
// 예: 리스폰 위치로 이동 및 체력 회복
Respawn();
}
private void Respawn()
{
if (!IsServer) return;
// 체력 회복
_currentHealth.Value = maxHealth;
// 스폰 포인트로 이동 (PlayerSpawnPoint 활용)
var spawnPoints = FindObjectsByType<PlayerSpawnPoint>(FindObjectsSortMode.None);
if (spawnPoints.Length > 0)
{
var spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)];
transform.position = spawnPoint.transform.position;
transform.rotation = spawnPoint.transform.rotation;
}
Debug.Log($"<color=green>[Player] {gameObject.name} 리스폰!</color>");
}
[ClientRpc]
private void ShowDamageEffectClientRpc()
{
if (damageEffectPrefab != null)
{
GameObject effect = Instantiate(damageEffectPrefab, transform.position + Vector3.up, Quaternion.identity);
Destroy(effect, 2f);
}
}
[ClientRpc]
private void ShowDeathEffectClientRpc()
{
if (deathEffectPrefab != null)
{
GameObject effect = Instantiate(deathEffectPrefab, transform.position, Quaternion.identity);
Destroy(effect, 3f);
}
}
#endregion
#region Health Management
/// <summary>
/// 현재 체력
/// </summary>
public int GetCurrentHealth() => _currentHealth.Value;
/// <summary>
/// 최대 체력
/// </summary>
public int GetMaxHealth() => maxHealth;
/// <summary>
/// 체력 비율 (0.0 ~ 1.0)
/// </summary>
public float GetHealthPercentage()
{
return maxHealth > 0 ? (float)_currentHealth.Value / maxHealth : 0f;
}
/// <summary>
/// 죽었는지 여부
/// </summary>
public bool IsDead() => _currentHealth.Value <= 0;
/// <summary>
/// 체력 회복
/// </summary>
public void Heal(int amount)
{
if (!IsServer) return;
int healAmount = Mathf.Min(amount, maxHealth - _currentHealth.Value);
_currentHealth.Value += healAmount;
Debug.Log($"<color=green>[Player] {gameObject.name}이(가) {healAmount} 회복되었습니다. 현재 체력: {_currentHealth.Value}/{maxHealth}</color>");
}
private void OnHealthChanged(int previousValue, int newValue)
{
// 체력바 UI 업데이트 또는 체력 변경 시각 효과
Debug.Log($"<color=yellow>[Player] 체력 변경: {previousValue} → {newValue}</color>");
// 클라이언트에서도 체력 변경 인지 가능
if (IsOwner)
{
// UI 업데이트 등
}
}
#endregion
#region Gizmos
private void OnDrawGizmosSelected()
{
#if UNITY_EDITOR
if (Application.isPlaying)
{
string teamName = TeamManager.GetTeamName(_team.Value);
UnityEditor.Handles.Label(transform.position + Vector3.up * 3f,
$"Player: {gameObject.name}\nTeam: {teamName}\nHP: {_currentHealth.Value}/{maxHealth}");
}
else
{
string teamName = TeamManager.GetTeamName(initialTeam);
UnityEditor.Handles.Label(transform.position + Vector3.up * 3f,
$"Player: {gameObject.name}\nTeam: {teamName}\nHP: {maxHealth}/{maxHealth}");
}
#endif
}
#endregion
}

View File

@@ -0,0 +1,96 @@
using System.Collections.Generic;
using UnityEngine;
namespace Northbound
{
/// <summary>
/// 팀 간의 관계 및 적대 관계를 관리
/// </summary>
public static class TeamManager
{
// 팀 간 적대 관계 테이블
private static readonly Dictionary<(TeamType, TeamType), bool> _hostilityTable = new Dictionary<(TeamType, TeamType), bool>
{
// 플레이어 vs 적대 세력
{ (TeamType.Player, TeamType.Hostile), true },
{ (TeamType.Hostile, TeamType.Player), true },
// 플레이어 vs 몬스터
{ (TeamType.Player, TeamType.Monster), true },
{ (TeamType.Monster, TeamType.Player), true },
// 적대 세력 vs 몬스터 (서로 공격하지 않음)
{ (TeamType.Hostile, TeamType.Monster), false },
{ (TeamType.Monster, TeamType.Hostile), false },
// 같은 팀끼리는 공격하지 않음
{ (TeamType.Player, TeamType.Player), false },
{ (TeamType.Hostile, TeamType.Hostile), false },
{ (TeamType.Monster, TeamType.Monster), false },
// 중립은 공격받지 않음
{ (TeamType.Neutral, TeamType.Player), false },
{ (TeamType.Neutral, TeamType.Hostile), false },
{ (TeamType.Neutral, TeamType.Monster), false },
{ (TeamType.Player, TeamType.Neutral), false },
{ (TeamType.Hostile, TeamType.Neutral), false },
{ (TeamType.Monster, TeamType.Neutral), false },
{ (TeamType.Neutral, TeamType.Neutral), false }
};
/// <summary>
/// 두 팀이 적대 관계인지 확인
/// </summary>
public static bool AreHostile(TeamType team1, TeamType team2)
{
if (_hostilityTable.TryGetValue((team1, team2), out bool isHostile))
{
return isHostile;
}
// 기본적으로 다른 팀이면 적대
return team1 != team2 && team1 != TeamType.Neutral && team2 != TeamType.Neutral;
}
/// <summary>
/// 공격 가능한 대상인지 확인
/// </summary>
public static bool CanAttack(ITeamMember attacker, ITeamMember target)
{
if (attacker == null || target == null)
return false;
return AreHostile(attacker.GetTeam(), target.GetTeam());
}
/// <summary>
/// 팀의 색상 가져오기 (UI 표시용)
/// </summary>
public static Color GetTeamColor(TeamType team)
{
return team switch
{
TeamType.Player => Color.blue,
TeamType.Hostile => Color.red,
TeamType.Monster => new Color(0.8f, 0f, 0.8f), // 보라색
TeamType.Neutral => Color.gray,
_ => Color.white
};
}
/// <summary>
/// 팀 이름 가져오기 (한글)
/// </summary>
public static string GetTeamName(TeamType team)
{
return team switch
{
TeamType.Player => "플레이어",
TeamType.Hostile => "적대 세력",
TeamType.Monster => "몬스터",
TeamType.Neutral => "중립",
_ => "알 수 없음"
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95252f6c80f5e2a40b0d4f95c23b2039

View File

@@ -0,0 +1,13 @@
namespace Northbound
{
/// <summary>
/// 게임 내 팀 타입
/// </summary>
public enum TeamType
{
Neutral = 0, // 중립 (공격받지 않음)
Player = 1, // 플레이어 팀
Hostile = 2, // 적대 세력 (플레이어 공격)
Monster = 3 // 몬스터 (플레이어 공격)
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fa67976f76e9a5f4fac1e55e7bfedf52