Files
Colosseum/Assets/_Game/Scripts/Skills/SkillController.cs
dal4segno e5ef94da85 feat: 멀티플레이어 네트워크 동기화 구현
- 로비 씬 추가 및 LobbyManager/LobbyUI/LobbySceneBuilder 구현
- NetworkPrefabsList로 플레이어 프리팹 등록 (PlayerPrefab 자동스폰 비활성화)
- PlayerMovement 서버 권한 이동 아키텍처로 전환
  - NetworkVariable<Vector2>로 클라이언트 입력 → 서버 전달
  - 점프 JumpRequestRpc로 서버 검증 후 실행
- 보스 프리팹에 NetworkTransform/NetworkAnimator 추가 (서버 권한)
- SkillController를 NetworkBehaviour로 전환
  - PlaySkillClipClientRpc로 클립 override + 재생 원자적 동기화
  - OnEffect/OnSkillEnd 클라이언트 실행 차단
- WeaponEquipment 클라이언트 무기 시각화 동기화 수정
  - registeredWeapons 인덱스 기반 NetworkVariable 동기화
  - SpawnWeaponVisualsLocal로 클라이언트 무기 생성
  - 중복 Instantiate 버그 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:46:45 +09:00

359 lines
12 KiB
C#

using UnityEngine;
using System.Collections.Generic;
using Unity.Netcode;
namespace Colosseum.Skills
{
/// <summary>
/// 스킬 실행을 관리하는 컴포넌트.
/// 애니메이션 이벤트 기반으로 효과가 발동됩니다.
/// </summary>
public class SkillController : NetworkBehaviour
{
private const string SKILL_STATE_NAME = "Skill";
private const string END_STATE_NAME = "SkillEnd";
[Header("애니메이션")]
[SerializeField] private Animator animator;
[Tooltip("기본 Animator Controller (스킬 종료 후 복원용)")]
[SerializeField] private RuntimeAnimatorController baseController;
[Tooltip("Skill 상태에 연결된 기본 클립 (Override용)")]
[SerializeField] private AnimationClip baseSkillClip;
[Header("네트워크 동기화")]
[Tooltip("이 SkillController가 사용하는 모든 스킬/엔드 클립 (순서대로 인덱스 부여). 서버→클라이언트 클립 동기화에 사용됩니다.")]
[SerializeField] private List<AnimationClip> registeredClips = new();
[Header("설정")]
[SerializeField] private bool debugMode = false;
[Tooltip("공격 범위 시각화 (Scene 뷰에서 확인)")]
[SerializeField] private bool showAreaDebug = true;
[Tooltip("범위 표시 지속 시간")]
[Min(0.1f)] [SerializeField] private float debugDrawDuration = 1f;
// 현재 실행 중인 스킬
private SkillData currentSkill;
private bool skillEndRequested; // OnSkillEnd 이벤트 호출 여부
private bool waitingForEndAnimation; // EndAnimation 종료 대기 중
// 쿨타임 추적
private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
public bool IsExecutingSkill => currentSkill != null && !skillEndRequested;
public bool IsPlayingAnimation => currentSkill != null;
public bool UsesRootMotion => currentSkill != null && currentSkill.UseRootMotion;
public bool IgnoreRootMotionY => currentSkill != null && currentSkill.IgnoreRootMotionY;
public SkillData CurrentSkill => currentSkill;
public Animator Animator => animator;
private void Awake()
{
if (animator == null)
{
animator = GetComponentInChildren<Animator>();
}
// 기본 컨트롤러 저장
if (baseController == null && animator != null)
{
baseController = animator.runtimeAnimatorController;
}
}
private void Update()
{
if (currentSkill == null || animator == null) return;
var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
// EndAnimation 종료 감지
if (waitingForEndAnimation)
{
if (stateInfo.normalizedTime >= 1f)
{
if (debugMode) Debug.Log($"[Skill] EndAnimation complete: {currentSkill.SkillName}");
RestoreBaseController();
currentSkill = null;
}
return;
}
// 애니메이션 종료 시 처리 (OnSkillEnd 여부와 관계없이 애니메이션 끝까지 재생)
if (stateInfo.normalizedTime >= 1f)
{
if (currentSkill.EndClip != null)
{
// EndAnimation 재생 후 종료 대기
if (debugMode) Debug.Log($"[Skill] SkillAnimation done, playing EndAnimation: {currentSkill.SkillName}");
PlayEndClip(currentSkill.EndClip);
waitingForEndAnimation = true;
}
else
{
// EndAnimation 없으면 바로 종료
if (debugMode) Debug.Log($"[Skill] Animation complete: {currentSkill.SkillName}");
RestoreBaseController();
currentSkill = null;
}
}
}
/// <summary>
/// 스킬 시전
/// </summary>
public bool ExecuteSkill(SkillData skill)
{
if (skill == null)
{
Debug.LogWarning("Skill is null!");
return false;
}
// 사망 상태면 스킬 사용 불가
var damageable = GetComponent<Colosseum.Combat.IDamageable>();
if (damageable != null && damageable.IsDead)
{
if (debugMode) Debug.Log($"[Skill] Cannot execute skill - owner is dead");
return false;
}
if (IsExecutingSkill)
{
if (debugMode) Debug.Log($"Already executing skill: {currentSkill.SkillName}");
return false;
}
if (IsOnCooldown(skill))
{
if (debugMode) Debug.Log($"Skill {skill.SkillName} is on cooldown");
return false;
}
currentSkill = skill;
skillEndRequested = false;
waitingForEndAnimation = false;
if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}");
// 쿨타임 시작
StartCooldown(skill);
// 스킬 애니메이션 재생
if (skill.SkillClip != null && animator != null)
{
PlaySkillClip(skill.SkillClip);
}
return true;
}
/// <summary>
/// 스킬 클립으로 Override Controller 생성 후 재생
/// </summary>
private void PlaySkillClip(AnimationClip clip)
{
if (baseSkillClip == null)
{
Debug.LogError("[SkillController] Base Skill Clip is not assigned!");
return;
}
if (debugMode)
{
Debug.Log($"[Skill] PlaySkillClip: {clip.name}, BaseClip: {baseSkillClip.name}");
}
var overrideController = new AnimatorOverrideController(baseController);
overrideController[baseSkillClip] = clip;
animator.runtimeAnimatorController = overrideController;
// 애니메이터 완전 리셋 후 재생
animator.Rebind();
animator.Update(0f);
animator.Play(SKILL_STATE_NAME, 0, 0f);
// 클라이언트에 클립 동기화
if (IsServer && IsSpawned)
PlaySkillClipClientRpc(registeredClips.IndexOf(clip));
}
/// <summary>
/// 종료 클립 재생
/// </summary>
private void PlayEndClip(AnimationClip clip)
{
if (baseSkillClip == null)
{
Debug.LogError("[SkillController] Base Skill Clip is not assigned!");
return;
}
var overrideController = new AnimatorOverrideController(baseController);
overrideController[baseSkillClip] = clip;
animator.runtimeAnimatorController = overrideController;
// 애니메이터 완전 리셋 후 재생
animator.Rebind();
animator.Update(0f);
animator.Play(SKILL_STATE_NAME, 0, 0f);
// 클라이언트에 클립 동기화
if (IsServer && IsSpawned)
PlaySkillClipClientRpc(registeredClips.IndexOf(clip));
}
/// <summary>
/// 기본 컨트롤러로 복원
/// </summary>
private void RestoreBaseController()
{
if (animator != null && baseController != null)
{
animator.runtimeAnimatorController = baseController;
}
// 클라이언트에 복원 동기화
if (IsServer && IsSpawned)
RestoreBaseControllerClientRpc();
}
/// <summary>
/// 클라이언트: override 컨트롤러 적용 + 스킬 상태 재생 (원자적 실행으로 타이밍 문제 해결)
/// </summary>
[Rpc(SendTo.NotServer)]
private void PlaySkillClipClientRpc(int clipIndex)
{
if (baseSkillClip == null || animator == null || baseController == null) return;
if (clipIndex < 0 || clipIndex >= registeredClips.Count || registeredClips[clipIndex] == null)
{
if (debugMode) Debug.LogWarning($"[SkillController] Clip index {clipIndex} not found in registeredClips. Add it to sync to clients.");
return;
}
var overrideController = new AnimatorOverrideController(baseController);
overrideController[baseSkillClip] = registeredClips[clipIndex];
animator.runtimeAnimatorController = overrideController;
animator.Rebind();
animator.Update(0f);
animator.Play(SKILL_STATE_NAME, 0, 0f);
}
/// <summary>
/// 클라이언트: 기본 컨트롤러 복원
/// </summary>
[Rpc(SendTo.NotServer)]
private void RestoreBaseControllerClientRpc()
{
if (animator != null && baseController != null)
animator.runtimeAnimatorController = baseController;
}
/// <summary>
/// 애니메이션 이벤트에서 호출. Effect 리스트의 index번째 효과를 발동합니다.
/// Animation Event: Function = OnEffect, Int Parameter = effect index (0-based)
/// </summary>
public void OnEffect(int index)
{
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer) return;
if (currentSkill == null)
{
if (debugMode) Debug.LogWarning("[Effect] No skill executing");
return;
}
// 사망 상태면 효과 발동 중단
var damageable = GetComponent<Colosseum.Combat.IDamageable>();
if (damageable != null && damageable.IsDead)
{
if (debugMode) Debug.Log($"[Effect] Cancelled - owner is dead");
return;
}
var effects = currentSkill.Effects;
if (index < 0 || index >= effects.Count)
{
if (debugMode) Debug.LogWarning($"[Effect] Invalid index: {index}");
return;
}
var effect = effects[index];
if (debugMode) Debug.Log($"[Effect] {effect.name} (index {index})");
// 공격 범위 시각화
if (showAreaDebug)
{
effect.DrawDebugRange(gameObject, debugDrawDuration);
}
effect.ExecuteOnCast(gameObject);
}
/// <summary>
/// 애니메이션 이벤트에서 호출. 스킬 종료를 요청합니다.
/// 애니메이션은 끝까지 재생된 후 종료됩니다.
/// Animation Event: Function = OnSkillEnd
/// </summary>
public void OnSkillEnd()
{
if (NetworkManager.Singleton != null && !NetworkManager.Singleton.IsServer) return;
if (currentSkill == null)
{
if (debugMode) Debug.LogWarning("[SkillEnd] No skill executing");
return;
}
skillEndRequested = true;
if (debugMode) Debug.Log($"[Skill] End requested: {currentSkill.SkillName} (will complete after animation)");
}
public void CancelSkill()
{
if (currentSkill != null)
{
if (debugMode) Debug.Log($"Skill cancelled: {currentSkill.SkillName}");
RestoreBaseController();
currentSkill = null;
skillEndRequested = false;
waitingForEndAnimation = false;
}
}
public bool IsOnCooldown(SkillData skill)
{
if (!cooldownTracker.ContainsKey(skill))
return false;
return Time.time < cooldownTracker[skill];
}
public float GetRemainingCooldown(SkillData skill)
{
if (!cooldownTracker.ContainsKey(skill))
return 0f;
float remaining = cooldownTracker[skill] - Time.time;
return Mathf.Max(0f, remaining);
}
private void StartCooldown(SkillData skill)
{
cooldownTracker[skill] = Time.time + skill.Cooldown;
}
public void ResetCooldown(SkillData skill)
{
cooldownTracker.Remove(skill);
}
public void ResetAllCooldowns()
{
cooldownTracker.Clear();
}
}
}