feat: 아군 타게팅 시스템 구현 — SingleAlly 투사체형 치유/보호막
- 치유/보호막 스킬을 즉발 자가시전에서 투사체형 아군 1인 타겟팅으로 전환 - TargetType.SingleAlly 추가, targetOverride 매개변수로 외부 타겟 주입 지원 - PlayerSkillInput: 카메라 레이캐스트 기반 아군 탐지, 서버 검증, RPC 타겟 ID 전달 - AllyTargetIndicator: 호버 아군 위에 디스크 인디케이터 표시, 사거리/초과 색상 변경 - SpawnEffect: 타겟 방향 회전 보정 - 투사체 스폰 이펙트 에셋 생성 (치유/보호막 각각) - 인디케이터 프리팹 + URP/Unlit 머티리얼 생성 - Player 프리팹에 AllyTargetIndicator 컴포넌트 추가 및 설정 - Input.mousePosition → Mouse.current.position.ReadValue() 수정 (Input System 호환)
This commit is contained in:
@@ -4,6 +4,7 @@ using Unity.Netcode;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Colosseum;
|
||||
using Colosseum.Skills;
|
||||
using Colosseum.Passives;
|
||||
using Colosseum.Weapons;
|
||||
@@ -77,6 +78,16 @@ namespace Colosseum.Player
|
||||
[Tooltip("행동 상태 관리자 (없으면 자동 검색)")]
|
||||
[SerializeField] private PlayerActionState actionState;
|
||||
|
||||
[Header("아군 타게팅 설정")]
|
||||
[Tooltip("아군 탐지용 레이캐스트 레이어")]
|
||||
[SerializeField] private LayerMask allyDetectionLayers;
|
||||
[Tooltip("아군 타게팅 최대 사거리 (0이면 무제한)")]
|
||||
[Min(0f)] [SerializeField] private float allyTargetMaxRange = 30f;
|
||||
[Tooltip("시야 차단 확인 여부")]
|
||||
[SerializeField] private bool requireLineOfSight = true;
|
||||
[Tooltip("시야 차단 확인용 레이어 (벽, 바닥 등)")]
|
||||
[SerializeField] private LayerMask lineOfSightBlockLayers;
|
||||
|
||||
private InputSystem_Actions inputActions;
|
||||
private bool gameplayInputEnabled = true;
|
||||
|
||||
@@ -305,14 +316,27 @@ namespace Colosseum.Player
|
||||
}
|
||||
|
||||
// 서버에 스킬 실행 요청
|
||||
RequestSkillExecutionRpc(slotIndex);
|
||||
// SingleAlly 스킬인 경우 커서 방향으로 아군 탐지
|
||||
ulong targetNetworkObjectId = 0;
|
||||
if (loadoutEntry != null && loadoutEntry.HasEffectWithTargetType(TargetType.SingleAlly))
|
||||
{
|
||||
GameObject allyTarget = RaycastForAllyTarget();
|
||||
if (allyTarget != null)
|
||||
{
|
||||
var networkObject = allyTarget.GetComponent<NetworkObject>();
|
||||
if (networkObject != null)
|
||||
targetNetworkObjectId = networkObject.NetworkObjectId;
|
||||
}
|
||||
}
|
||||
|
||||
RequestSkillExecutionRpc(slotIndex, targetNetworkObjectId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서버에 스킬 실행 요청
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RequestSkillExecutionRpc(int slotIndex)
|
||||
private void RequestSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
|
||||
return;
|
||||
@@ -333,6 +357,13 @@ namespace Colosseum.Player
|
||||
if (networkController != null && networkController.Mana < actualManaCost)
|
||||
return;
|
||||
|
||||
// 서버에서 타겟 유효성 검증
|
||||
if (targetNetworkObjectId != 0 && !ValidateAllyTargetOnServer(targetNetworkObjectId))
|
||||
{
|
||||
Debug.Log($"[Target] 아군 타겟 검증 실패. Self로 대체합니다.");
|
||||
targetNetworkObjectId = 0;
|
||||
}
|
||||
|
||||
// 마나 소모 (무기 배율 적용)
|
||||
if (networkController != null && actualManaCost > 0)
|
||||
{
|
||||
@@ -340,14 +371,14 @@ namespace Colosseum.Player
|
||||
}
|
||||
|
||||
// 모든 클라이언트에 스킬 실행 전파
|
||||
BroadcastSkillExecutionRpc(slotIndex);
|
||||
BroadcastSkillExecutionRpc(slotIndex, targetNetworkObjectId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 클라이언트에 스킬 실행 전파
|
||||
/// </summary>
|
||||
[Rpc(SendTo.ClientsAndHost)]
|
||||
private void BroadcastSkillExecutionRpc(int slotIndex)
|
||||
private void BroadcastSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
|
||||
return;
|
||||
@@ -356,8 +387,11 @@ namespace Colosseum.Player
|
||||
SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null;
|
||||
if (skill == null) return;
|
||||
|
||||
// 타겟 오버라이드 해석
|
||||
GameObject targetOverride = ResolveTargetFromNetworkId(targetNetworkObjectId);
|
||||
|
||||
// 모든 클라이언트에서 스킬 실행 (애니메이션 포함)
|
||||
skillController.ExecuteSkill(loadoutEntry);
|
||||
skillController.ExecuteSkill(loadoutEntry, targetOverride);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -669,6 +703,126 @@ namespace Colosseum.Player
|
||||
}
|
||||
}
|
||||
|
||||
#region 아군 타게팅
|
||||
|
||||
/// <summary>
|
||||
/// 카메라에서 커서 방향으로 레이캐스트하여 아군 GameObject를 탐색합니다.
|
||||
/// SingleAlly 스킬 입력 시 호출됩니다.
|
||||
/// </summary>
|
||||
private GameObject RaycastForAllyTarget()
|
||||
{
|
||||
Camera mainCamera = Camera.main;
|
||||
if (mainCamera == null)
|
||||
{
|
||||
Debug.LogWarning("[Target] Camera.main을 찾을 수 없습니다.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (allyDetectionLayers.value == 0)
|
||||
{
|
||||
Debug.LogWarning("[Target] allyDetectionLayers가 설정되지 않았습니다.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
|
||||
float maxDistance = allyTargetMaxRange > 0f ? allyTargetMaxRange : Mathf.Infinity;
|
||||
|
||||
if (!Physics.Raycast(ray, out RaycastHit hit, maxDistance, allyDetectionLayers))
|
||||
return null;
|
||||
|
||||
GameObject hitObject = hit.collider.gameObject;
|
||||
|
||||
// 팀 체크: 아군만 타겟
|
||||
if (!Team.IsSameTeam(gameObject, hitObject))
|
||||
return null;
|
||||
|
||||
// 생존 체크: 사망한 아군은 타겟 불가
|
||||
var damageable = hitObject.GetComponent<Combat.IDamageable>();
|
||||
if (damageable != null && damageable.IsDead)
|
||||
return null;
|
||||
|
||||
// 시야 차단 체크
|
||||
if (requireLineOfSight && lineOfSightBlockLayers.value != 0)
|
||||
{
|
||||
Vector3 casterPos = transform.position + Vector3.up * 1.5f;
|
||||
Vector3 targetPos = hitObject.transform.position + Vector3.up * 1.5f;
|
||||
Vector3 direction = targetPos - casterPos;
|
||||
float distance = direction.magnitude;
|
||||
|
||||
if (Physics.Raycast(casterPos, direction.normalized, out RaycastHit losHit, distance, lineOfSightBlockLayers))
|
||||
{
|
||||
if (losHit.collider.gameObject != hitObject)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return hitObject;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서버에서 아군 타겟의 유효성을 검증합니다.
|
||||
/// 팀, 거리, 생존 상태를 확인합니다.
|
||||
/// </summary>
|
||||
private bool ValidateAllyTargetOnServer(ulong targetNetworkObjectId)
|
||||
{
|
||||
if (targetNetworkObjectId == 0)
|
||||
return false;
|
||||
|
||||
GameObject target = ResolveTargetFromNetworkId(targetNetworkObjectId);
|
||||
if (target == null)
|
||||
{
|
||||
Debug.LogWarning("[Target] NetworkObjectId를 GameObject로 변환할 수 없습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 팀 검증
|
||||
if (!Team.IsSameTeam(gameObject, target))
|
||||
{
|
||||
Debug.LogWarning($"[Target] 타겟 팀 불일치: {target.name}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 생존 검증
|
||||
var damageable = target.GetComponent<Combat.IDamageable>();
|
||||
if (damageable != null && damageable.IsDead)
|
||||
{
|
||||
Debug.LogWarning($"[Target] 타겟 사망 상태: {target.name}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 거리 검증
|
||||
if (allyTargetMaxRange > 0f)
|
||||
{
|
||||
float distance = Vector3.Distance(transform.position, target.transform.position);
|
||||
if (distance > allyTargetMaxRange)
|
||||
{
|
||||
Debug.LogWarning($"[Target] 타겟 거리 초과: {distance:F1}m (max={allyTargetMaxRange}m)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NetworkObjectId로부터 GameObject를 해석합니다.
|
||||
/// </summary>
|
||||
private static GameObject ResolveTargetFromNetworkId(ulong targetNetworkObjectId)
|
||||
{
|
||||
if (targetNetworkObjectId == 0)
|
||||
return null;
|
||||
|
||||
if (NetworkManager.Singleton == null)
|
||||
return null;
|
||||
|
||||
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetNetworkObjectId, out NetworkObject networkObject))
|
||||
return networkObject != null ? networkObject.gameObject : null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>
|
||||
/// MPP 환경에서는 메인 에디터에 탱커, 가상 플레이어 복제본에 지원 프리셋을 자동 적용합니다.
|
||||
|
||||
Reference in New Issue
Block a user