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:
2026-03-31 23:06:13 +09:00
parent 2c6a65d406
commit 106e53c9aa
22 changed files with 6779 additions and 112 deletions

View File

@@ -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 환경에서는 메인 에디터에 탱커, 가상 플레이어 복제본에 지원 프리셋을 자동 적용합니다.