chore: Assets 디렉토리 구조 정리 및 네이밍 컨벤션 적용
- Assets/_Game/ 하위로 게임 에셋 통합 - External/ 패키지 벤더별 분류 (Synty, Animations, UI) - 에셋 네이밍 컨벤션 확립 및 적용 (Data_Skill_, Data_SkillEffect_, Prefab_, Anim_, Model_, BT_ 등) - pre-commit hook으로 네이밍 컨벤션 자동 검사 추가 - RESTRUCTURE_CHECKLIST.md 작성 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
8
Assets/_Game/Scripts/AI.meta
Normal file
8
Assets/_Game/Scripts/AI.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dd7f6bc7e9d2e9140802e1bc3a3ebffd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/AI/BehaviorActions.meta
Normal file
8
Assets/_Game/Scripts/AI/BehaviorActions.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7648a6805082131489f501769b3c0f18
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/AI/BehaviorActions/Actions.meta
Normal file
8
Assets/_Game/Scripts/AI/BehaviorActions/Actions.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 09f6a44dd1b90c348a0dfb17312c5804
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
using Action = Unity.Behavior.Action;
|
||||
using Unity.Properties;
|
||||
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "ChaseTarget", story: "타겟 추적", category: "Action", id: "0889fbb015b8bf414ef569af08bb6868")]
|
||||
public partial class ChaseTargetAction : Action
|
||||
{
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<GameObject> Target;
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> Speed = new BlackboardVariable<float>(0f);
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> StopDistance = new BlackboardVariable<float>(2f);
|
||||
|
||||
private UnityEngine.AI.NavMeshAgent agent;
|
||||
|
||||
protected override Status OnStart()
|
||||
{
|
||||
if (Target.Value == null)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
agent = GameObject.GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
if (agent == null)
|
||||
{
|
||||
Debug.LogWarning("[ChaseTarget] NavMeshAgent not found");
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
// Speed가 0 이하면 NavMeshAgent의 기존 speed 유지 (EnemyData에서 설정한 값)
|
||||
if (Speed.Value > 0f)
|
||||
{
|
||||
agent.speed = Speed.Value;
|
||||
}
|
||||
agent.stoppingDistance = StopDistance.Value;
|
||||
agent.isStopped = false;
|
||||
|
||||
return Status.Running;
|
||||
}
|
||||
|
||||
protected override Status OnUpdate()
|
||||
{
|
||||
if (Target.Value == null)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
// 이미 사거리 내에 있으면 성공
|
||||
float distance = Vector3.Distance(GameObject.transform.position, Target.Value.transform.position);
|
||||
if (distance <= StopDistance.Value)
|
||||
{
|
||||
agent.isStopped = true;
|
||||
return Status.Success;
|
||||
}
|
||||
|
||||
// 타겟 위치로 이동
|
||||
agent.SetDestination(Target.Value.transform.position);
|
||||
return Status.Running;
|
||||
}
|
||||
|
||||
protected override void OnEnd()
|
||||
{
|
||||
if (agent != null)
|
||||
{
|
||||
agent.isStopped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 14296786101ccd742ac9f752f1fd3393
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
using Action = Unity.Behavior.Action;
|
||||
using Unity.Properties;
|
||||
using Colosseum.Combat;
|
||||
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "FindTarget", story: "[타겟] 탐색", category: "Action", id: "bb947540549026f3c5625c6d19213311")]
|
||||
public partial class FindTargetAction : Action
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<GameObject> Target;
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<string> Tag = new BlackboardVariable<string>("Player");
|
||||
|
||||
protected override Status OnStart()
|
||||
{
|
||||
if (Tag.Value == null || string.IsNullOrEmpty(Tag.Value))
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
// 모든 타겟 후보 검색
|
||||
GameObject[] candidates = GameObject.FindGameObjectsWithTag(Tag.Value);
|
||||
|
||||
if (candidates == null || candidates.Length == 0)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
// 사망하지 않은 타겟 찾기
|
||||
foreach (GameObject candidate in candidates)
|
||||
{
|
||||
IDamageable damageable = candidate.GetComponent<IDamageable>();
|
||||
|
||||
// IDamageable이 없거나 살아있는 경우 타겟으로 선택
|
||||
if (damageable == null || !damageable.IsDead)
|
||||
{
|
||||
Target.Value = candidate;
|
||||
return Status.Success;
|
||||
}
|
||||
}
|
||||
|
||||
// 살아있는 타겟이 없음
|
||||
return Status.Failure;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f569cb803bceb7c4291d0f3074346741
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
|
||||
[Serializable, Unity.Properties.GeneratePropertyBag]
|
||||
[Condition(name: "isDie", story: "죽었는지 확인", category: "Conditions", id: "8067176f9f490e7d974824f8087de448")]
|
||||
public partial class IsDieCondition : Condition
|
||||
{
|
||||
|
||||
public override bool IsTrue()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public override void OnStart()
|
||||
{
|
||||
}
|
||||
|
||||
public override void OnEnd()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3d2c81704b2be4c4289bcf5555059b87
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
using Action = Unity.Behavior.Action;
|
||||
using Unity.Properties;
|
||||
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "PlayAnimation", story: "[애니메이션] 재생", category: "Action", id: "b558ee0df6e00cd2e6bb2273b4f59cd2")]
|
||||
public partial class PlayAnimationAction : Action
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<string> AnimationName = new BlackboardVariable<string>("");
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<bool> WaitForCompletion = new BlackboardVariable<bool>(true);
|
||||
|
||||
private Animator animator;
|
||||
private bool animationStarted;
|
||||
|
||||
protected override Status OnStart()
|
||||
{
|
||||
animator = GameObject.GetComponentInChildren<Animator>();
|
||||
|
||||
if (animator == null || string.IsNullOrEmpty(AnimationName.Value))
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
animator.Play(AnimationName.Value);
|
||||
animationStarted = true;
|
||||
|
||||
if (!WaitForCompletion.Value)
|
||||
{
|
||||
return Status.Success;
|
||||
}
|
||||
|
||||
return Status.Running;
|
||||
}
|
||||
|
||||
protected override Status OnUpdate()
|
||||
{
|
||||
if (!WaitForCompletion.Value)
|
||||
{
|
||||
return Status.Success;
|
||||
}
|
||||
|
||||
if (animator == null)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
|
||||
|
||||
// 애니메이션이 완료되었는지 확인
|
||||
if (stateInfo.IsName(AnimationName.Value) && stateInfo.normalizedTime >= 1f)
|
||||
{
|
||||
return Status.Success;
|
||||
}
|
||||
|
||||
return Status.Running;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3bc57afe07d75aa4e80ef5394b57f774
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
using Action = Unity.Behavior.Action;
|
||||
using Unity.Properties;
|
||||
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "RotateToTarget", story: "[대상을] 바라보도록 회전", category: "Action", id: "30341f7e1af0565c0aca0253341b3e28")]
|
||||
public partial class RotateToTargetAction : Action
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<GameObject> Target;
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> RotationSpeed = new BlackboardVariable<float>(10f);
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> AngleThreshold = new BlackboardVariable<float>(5f);
|
||||
|
||||
protected override Status OnUpdate()
|
||||
{
|
||||
if (Target.Value == null)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
Vector3 direction = Target.Value.transform.position - GameObject.transform.position;
|
||||
direction.y = 0f;
|
||||
|
||||
if (direction == Vector3.zero)
|
||||
{
|
||||
return Status.Success;
|
||||
}
|
||||
|
||||
Quaternion targetRotation = Quaternion.LookRotation(direction);
|
||||
float angleDifference = Quaternion.Angle(GameObject.transform.rotation, targetRotation);
|
||||
|
||||
// 임계값 이내면 성공
|
||||
if (angleDifference <= AngleThreshold.Value)
|
||||
{
|
||||
return Status.Success;
|
||||
}
|
||||
|
||||
// 부드럽게 회전
|
||||
GameObject.transform.rotation = Quaternion.Slerp(
|
||||
GameObject.transform.rotation,
|
||||
targetRotation,
|
||||
RotationSpeed.Value * Time.deltaTime
|
||||
);
|
||||
|
||||
return Status.Running;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33182146e0ade8443a539cf7780735e2
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using Action = Unity.Behavior.Action;
|
||||
using Unity.Properties;
|
||||
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "SetAnimatorTrigger", story: "애니메이션 [트리거] 설정", category: "Action", id: "bfef104cbd43df3d01f24570a4caa8ed")]
|
||||
public partial class SetAnimatorTriggerAction : Action
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<string> TriggerName = new BlackboardVariable<string>("");
|
||||
|
||||
protected override Status OnStart()
|
||||
{
|
||||
if (string.IsNullOrEmpty(TriggerName.Value))
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
Animator animator = GameObject.GetComponentInChildren<Animator>();
|
||||
|
||||
if (animator == null)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
animator.SetTrigger(TriggerName.Value);
|
||||
return Status.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 77cf4f2ff35fe3040af16374f428a648
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
using Action = Unity.Behavior.Action;
|
||||
using Unity.Properties;
|
||||
using Colosseum.Combat;
|
||||
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "SetTargetInRange", story: "[거리] 내에 [대상이] 있는지 확인", category: "Action", id: "93b7a5d823a58618d5371c01ef894948")]
|
||||
public partial class SetTargetInRangeAction : Action
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<GameObject> Target;
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<string> Tag = new BlackboardVariable<string>("Player");
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> Range = new BlackboardVariable<float>(10f);
|
||||
|
||||
protected override Status OnStart()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Tag.Value))
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
// 모든 타겟 태그 오브젝트 찾기
|
||||
GameObject[] targets = GameObject.FindGameObjectsWithTag(Tag.Value);
|
||||
|
||||
if (targets == null || targets.Length == 0)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
// 가장 가까운 살아있는 타겟 찾기
|
||||
GameObject nearestTarget = null;
|
||||
float nearestDistance = Range.Value; // Range 내에서만 검색
|
||||
|
||||
foreach (GameObject potentialTarget in targets)
|
||||
{
|
||||
// 사망한 타겟은 제외
|
||||
IDamageable damageable = potentialTarget.GetComponent<IDamageable>();
|
||||
if (damageable != null && damageable.IsDead)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float distance = Vector3.Distance(
|
||||
GameObject.transform.position,
|
||||
potentialTarget.transform.position
|
||||
);
|
||||
|
||||
if (distance < nearestDistance)
|
||||
{
|
||||
nearestDistance = distance;
|
||||
nearestTarget = potentialTarget;
|
||||
}
|
||||
}
|
||||
|
||||
if (nearestTarget == null)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
Target.Value = nearestTarget;
|
||||
return Status.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 02cd3ab41f67bf344b667b6a0c12a4d0
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
using Action = Unity.Behavior.Action;
|
||||
using Unity.Properties;
|
||||
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "StopMovement", story: "이동 정지", category: "Action", id: "1a0e2eb87421ed94502031790df56f37")]
|
||||
public partial class StopMovementAction : Action
|
||||
{
|
||||
|
||||
protected override Status OnStart()
|
||||
{
|
||||
UnityEngine.AI.NavMeshAgent agent = GameObject.GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
|
||||
if (agent != null)
|
||||
{
|
||||
agent.isStopped = true;
|
||||
}
|
||||
|
||||
return Status.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: afacb51ec60675d498bfc8b7ce942368
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using Colosseum.AI;
|
||||
using Colosseum.Skills;
|
||||
using Unity.Behavior;
|
||||
using Unity.Properties;
|
||||
using UnityEngine;
|
||||
using Action = Unity.Behavior.Action;
|
||||
|
||||
/// <summary>
|
||||
/// 보스 패턴을 실행하는 Behavior Tree Action.
|
||||
/// 패턴 내 스텝(스킬 또는 대기)을 순서대로 실행하며, 패턴 쿨타임을 관리합니다.
|
||||
/// </summary>
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "Use Pattern", story: "[Pattern] 실행", category: "Action", id: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")]
|
||||
public partial class UsePatternAction : Action
|
||||
{
|
||||
[SerializeReference] public BlackboardVariable<BossPatternData> Pattern;
|
||||
|
||||
private SkillController skillController;
|
||||
private int currentStepIndex;
|
||||
private float waitEndTime;
|
||||
private bool isWaiting;
|
||||
private float lastUsedTime = float.MinValue;
|
||||
|
||||
protected override Status OnStart()
|
||||
{
|
||||
if (Pattern?.Value == null)
|
||||
{
|
||||
Debug.LogWarning("[UsePatternAction] 패턴이 null입니다.");
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
if (Time.time - lastUsedTime < Pattern.Value.Cooldown)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
if (Pattern.Value.Steps.Count == 0)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
skillController = GameObject.GetComponent<SkillController>();
|
||||
if (skillController == null)
|
||||
{
|
||||
Debug.LogWarning($"[UsePatternAction] SkillController를 찾을 수 없습니다: {GameObject.name}");
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
currentStepIndex = 0;
|
||||
isWaiting = false;
|
||||
return ExecuteCurrentStep();
|
||||
}
|
||||
|
||||
protected override Status OnUpdate()
|
||||
{
|
||||
if (skillController == null)
|
||||
return Status.Failure;
|
||||
|
||||
if (isWaiting)
|
||||
{
|
||||
if (Time.time < waitEndTime)
|
||||
return Status.Running;
|
||||
|
||||
isWaiting = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (skillController.IsPlayingAnimation)
|
||||
return Status.Running;
|
||||
|
||||
}
|
||||
|
||||
currentStepIndex++;
|
||||
|
||||
if (currentStepIndex >= Pattern.Value.Steps.Count)
|
||||
{
|
||||
lastUsedTime = Time.time;
|
||||
return Status.Success;
|
||||
}
|
||||
|
||||
return ExecuteCurrentStep();
|
||||
}
|
||||
|
||||
protected override void OnEnd()
|
||||
{
|
||||
skillController = null;
|
||||
}
|
||||
|
||||
private Status ExecuteCurrentStep()
|
||||
{
|
||||
PatternStep step = Pattern.Value.Steps[currentStepIndex];
|
||||
|
||||
if (step.Type == PatternStepType.Wait)
|
||||
{
|
||||
isWaiting = true;
|
||||
waitEndTime = Time.time + step.Duration;
|
||||
return Status.Running;
|
||||
}
|
||||
|
||||
// PatternStepType.Skill
|
||||
if (step.Skill == null)
|
||||
{
|
||||
Debug.LogWarning($"[UsePatternAction] 스킬이 null입니다. (index {currentStepIndex})");
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
bool success = skillController.ExecuteSkill(step.Skill);
|
||||
if (!success)
|
||||
{
|
||||
Debug.LogWarning($"[UsePatternAction] 스킬 실행 실패: {step.Skill.SkillName} (index {currentStepIndex})");
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
return Status.Running;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a696fff0581f7264d9491514e9aee277
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using Colosseum.Skills;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
using Action = Unity.Behavior.Action;
|
||||
using Unity.Properties;
|
||||
|
||||
/// <summary>
|
||||
/// 지정된 스킬을 사용하는 Behavior Tree Action.
|
||||
/// 스킬 실행이 완료될 때까지 Running 상태를 유지합니다.
|
||||
/// </summary>
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "Use Skill", story: "[스킬] 사용", category: "Action", id: "799f1e8cfafa78b2d52ef61a6bbb29b9")]
|
||||
public partial class UseSkillAction : Action
|
||||
{
|
||||
[SerializeReference] public BlackboardVariable<SkillData> 스킬;
|
||||
|
||||
private SkillController skillController;
|
||||
|
||||
protected override Status OnStart()
|
||||
{
|
||||
// 스킬 데이터 확인
|
||||
if (스킬?.Value == null)
|
||||
{
|
||||
Debug.LogWarning("[UseSkillAction] 스킬이 null입니다.");
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
// SkillController 컴포넌트 가져오기
|
||||
skillController = GameObject.GetComponent<SkillController>();
|
||||
if (skillController == null)
|
||||
{
|
||||
Debug.LogWarning($"[UseSkillAction] SkillController를 찾을 수 없습니다: {GameObject.name}");
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
// 스킬 실행 시도
|
||||
bool success = skillController.ExecuteSkill(스킬.Value);
|
||||
if (!success)
|
||||
{
|
||||
// 이미 다른 스킬 사용 중이거나 쿨타임
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
return Status.Running;
|
||||
}
|
||||
|
||||
protected override Status OnUpdate()
|
||||
{
|
||||
// SkillController가 해제된 경우
|
||||
if (skillController == null)
|
||||
{
|
||||
return Status.Failure;
|
||||
}
|
||||
|
||||
// 스킬 애니메이션이 종료되면 성공
|
||||
if (!skillController.IsPlayingAnimation)
|
||||
{
|
||||
return Status.Success;
|
||||
}
|
||||
|
||||
return Status.Running;
|
||||
}
|
||||
|
||||
protected override void OnEnd()
|
||||
{
|
||||
skillController = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cf140a11f5104674fb8367f4eb5702cb
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using Unity.Behavior;
|
||||
using UnityEngine;
|
||||
using Action = Unity.Behavior.Action;
|
||||
using Unity.Properties;
|
||||
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "Wait", story: "대기", category: "Action", id: "73b84bd4bc0eb61c998ac84c9853a69d")]
|
||||
public partial class WaitAction : Action
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> Duration = new BlackboardVariable<float>(1f);
|
||||
|
||||
private float startTime;
|
||||
|
||||
protected override Status OnStart()
|
||||
{
|
||||
startTime = Time.time;
|
||||
return Status.Running;
|
||||
}
|
||||
|
||||
protected override Status OnUpdate()
|
||||
{
|
||||
if (Time.time - startTime >= Duration.Value)
|
||||
{
|
||||
return Status.Success;
|
||||
}
|
||||
return Status.Running;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 324b69471491c9448ae5d71a426dd596
|
||||
8
Assets/_Game/Scripts/AI/BehaviorActions/Conditions.meta
Normal file
8
Assets/_Game/Scripts/AI/BehaviorActions/Conditions.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 422be729ebdf10b43b44b80b8593258a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Unity.Behavior;
|
||||
using Unity.Properties;
|
||||
using Condition = Unity.Behavior.Condition;
|
||||
using Colosseum.Combat;
|
||||
|
||||
namespace Colosseum.AI.BehaviorActions.Conditions
|
||||
{
|
||||
/// <summary>
|
||||
/// 타겟이 존재하고 살아있는지 확인합니다.
|
||||
/// </summary>
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "Has Target", story: "Has [Target]", category: "Combat")]
|
||||
public partial class HasTargetCondition : Condition
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<GameObject> Target;
|
||||
|
||||
public override bool IsTrue()
|
||||
{
|
||||
if (Target.Value == null || !Target.Value.activeInHierarchy)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 타겟이 사망했는지 확인
|
||||
IDamageable damageable = Target.Value.GetComponent<IDamageable>();
|
||||
if (damageable != null && damageable.IsDead)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5aba729f8b5ddea468304d6b1bf43dce
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Unity.Behavior;
|
||||
using Unity.Properties;
|
||||
using Condition = Unity.Behavior.Condition;
|
||||
using Colosseum.Enemy;
|
||||
|
||||
namespace Colosseum.AI.BehaviorActions.Conditions
|
||||
{
|
||||
/// <summary>
|
||||
/// 체력이 지정된 비율 이하인지 확인합니다.
|
||||
/// </summary>
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "Is Health Below", story: "Check if health is below [HealthPercent] percent", category: "Combat")]
|
||||
public partial class IsHealthBelowCondition : Condition
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> HealthPercent = new BlackboardVariable<float>(50f);
|
||||
|
||||
public override bool IsTrue()
|
||||
{
|
||||
EnemyBase enemy = GameObject.GetComponent<EnemyBase>();
|
||||
|
||||
if (enemy == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float currentHealthPercent = (enemy.CurrentHealth / enemy.MaxHealth) * 100f;
|
||||
return currentHealthPercent <= HealthPercent.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e03ad43d6a068dd44bef3d524d20a4c1
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Unity.Behavior;
|
||||
using Unity.Properties;
|
||||
using Condition = Unity.Behavior.Condition;
|
||||
|
||||
namespace Colosseum.AI.BehaviorActions.Conditions
|
||||
{
|
||||
/// <summary>
|
||||
/// 타겟이 공격 사거리 내에 있는지 확인합니다.
|
||||
/// </summary>
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "Is In Attack Range", story: "Is [Target] within [Range]", category: "Combat")]
|
||||
public partial class IsInAttackRangeCondition : Condition
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<GameObject> Target;
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> Range = new BlackboardVariable<float>(2f);
|
||||
|
||||
public override bool IsTrue()
|
||||
{
|
||||
if (Target.Value == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float distance = Vector3.Distance(
|
||||
GameObject.transform.position,
|
||||
Target.Value.transform.position
|
||||
);
|
||||
|
||||
return distance <= Range.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 10007872b79b2b641980c0d8dfd4f6a4
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Unity.Behavior;
|
||||
using Unity.Properties;
|
||||
using Condition = Unity.Behavior.Condition;
|
||||
|
||||
namespace Colosseum.AI.BehaviorActions.Conditions
|
||||
{
|
||||
/// <summary>
|
||||
/// 타겟이 지정된 거리 내에 있는지 확인합니다.
|
||||
/// </summary>
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "Is In Range", story: "Is [Target] within [Range] distance", category: "Combat")]
|
||||
public partial class IsInRangeCondition : Condition
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<GameObject> Target;
|
||||
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> Range = new BlackboardVariable<float>(2f);
|
||||
|
||||
public override bool IsTrue()
|
||||
{
|
||||
if (Target.Value == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float distance = Vector3.Distance(GameObject.transform.position, Target.Value.transform.position);
|
||||
return distance <= Range.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0035b82da5a602d44b552684970273a8
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Unity.Behavior;
|
||||
using Unity.Properties;
|
||||
using Condition = Unity.Behavior.Condition;
|
||||
|
||||
namespace Colosseum.AI.BehaviorActions.Conditions
|
||||
{
|
||||
/// <summary>
|
||||
/// 무작위 확률로 성공/실패를 반환합니다.
|
||||
/// </summary>
|
||||
[Serializable, GeneratePropertyBag]
|
||||
[NodeDescription(name: "Random Chance", story: "Random chance of [Chance] percent", category: "Utility")]
|
||||
public partial class RandomChanceCondition : Condition
|
||||
{
|
||||
[SerializeReference]
|
||||
public BlackboardVariable<float> Chance = new BlackboardVariable<float>(50f);
|
||||
|
||||
public override bool IsTrue()
|
||||
{
|
||||
float roll = UnityEngine.Random.Range(0f, 100f);
|
||||
return roll <= Chance.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3abe3d08ab4653b43a5a1708770fd3a1
|
||||
38
Assets/_Game/Scripts/AI/BossPatternData.cs
Normal file
38
Assets/_Game/Scripts/AI/BossPatternData.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using Colosseum.Skills;
|
||||
|
||||
namespace Colosseum.AI
|
||||
{
|
||||
public enum PatternStepType { Skill, Wait }
|
||||
|
||||
[System.Serializable]
|
||||
public class PatternStep
|
||||
{
|
||||
public PatternStepType Type = PatternStepType.Skill;
|
||||
public SkillData Skill;
|
||||
[Min(0f)] public float Duration = 0.5f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 패턴 데이터. 순서대로 실행할 스텝(스킬 또는 대기) 목록과 쿨타임을 정의합니다.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "NewBossPattern", menuName = "Colosseum/Boss Pattern")]
|
||||
public class BossPatternData : ScriptableObject
|
||||
{
|
||||
[Header("패턴 정보")]
|
||||
[SerializeField] private string patternName;
|
||||
|
||||
[Header("스텝 순서")]
|
||||
[SerializeField] private List<PatternStep> steps = new List<PatternStep>();
|
||||
|
||||
[Header("쿨타임")]
|
||||
[Min(0f)]
|
||||
[Tooltip("패턴 완료 후 다시 사용 가능해지기까지의 시간")]
|
||||
[SerializeField] private float cooldown = 5f;
|
||||
|
||||
public string PatternName => patternName;
|
||||
public IReadOnlyList<PatternStep> Steps => steps;
|
||||
public float Cooldown => cooldown;
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/AI/BossPatternData.cs.meta
Normal file
2
Assets/_Game/Scripts/AI/BossPatternData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ce956e0878565343974c31b8111c0c6
|
||||
8
Assets/_Game/Scripts/Abnormalities.meta
Normal file
8
Assets/_Game/Scripts/Abnormalities.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bc6434195fb88a443939a5a0b2747f0a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
101
Assets/_Game/Scripts/Abnormalities/AbnormalityData.cs
Normal file
101
Assets/_Game/Scripts/Abnormalities/AbnormalityData.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Abnormalities
|
||||
{
|
||||
/// <summary>
|
||||
/// 제어 효과 타입
|
||||
/// </summary>
|
||||
public enum ControlType
|
||||
{
|
||||
None, // 제어 효과 없음
|
||||
Stun, // 기절 (이동, 스킬 사용 불가)
|
||||
Silence, // 침묵 (스킬 사용 불가)
|
||||
Slow // 둔화 (이동 속도 감소)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스탯 수정자 엔트리
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class AbnormalityStatModifier
|
||||
{
|
||||
[Tooltip("수정할 스탯 타입")]
|
||||
public StatType statType;
|
||||
|
||||
[Tooltip("수정값")]
|
||||
public float value;
|
||||
|
||||
[Tooltip("수정 타입 (Flat: 고정값, PercentAdd: 퍼센트 합산, PercentMult: 퍼센트 곱셈)")]
|
||||
public StatModType modType;
|
||||
|
||||
public AbnormalityStatModifier() { }
|
||||
|
||||
public AbnormalityStatModifier(StatType statType, float value, StatModType modType)
|
||||
{
|
||||
this.statType = statType;
|
||||
this.value = value;
|
||||
this.modType = modType;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이상 상태 정의 ScriptableObject
|
||||
/// 버프/디버프의 데이터를 정의합니다.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "AbnormalityData", menuName = "Colosseum/Abnormalities/Abnormality")]
|
||||
public class AbnormalityData : ScriptableObject
|
||||
{
|
||||
[Header("기본 정보")]
|
||||
[Tooltip("이상 상태 이름")]
|
||||
public string abnormalityName = "Abnormality";
|
||||
|
||||
[Tooltip("아이콘")]
|
||||
public Sprite icon;
|
||||
|
||||
[Tooltip("지속 시간 (초, 0 이하면 영구)")]
|
||||
public float duration = 5f;
|
||||
|
||||
[Tooltip("효과 레벨 (중복 처리용, 높으면 우선)")]
|
||||
public int level = 1;
|
||||
|
||||
[Tooltip("디버프 여부")]
|
||||
public bool isDebuff = false;
|
||||
|
||||
[Header("스탯 수정자")]
|
||||
[Tooltip("스탯에 적용할 수정자 목록")]
|
||||
public List<AbnormalityStatModifier> statModifiers = new List<AbnormalityStatModifier>();
|
||||
|
||||
[Header("주기적 효과 (DoT/HoT)")]
|
||||
[Tooltip("주기적 효과 간격 (초, 0이면 비활성)")]
|
||||
public float periodicInterval = 0f;
|
||||
|
||||
[Tooltip("주기적 효과값 (양수=힐, 음수=데미지)")]
|
||||
public float periodicValue = 0f;
|
||||
|
||||
[Header("제어 효과 (CC)")]
|
||||
[Tooltip("제어 효과 타입")]
|
||||
public ControlType controlType = ControlType.None;
|
||||
|
||||
[Tooltip("둔화 배율 (Slow일 때, 0.5 = 50% 감소)")]
|
||||
[Range(0f, 1f)]
|
||||
public float slowMultiplier = 0.5f;
|
||||
|
||||
/// <summary>
|
||||
/// 영구 효과인지 확인
|
||||
/// </summary>
|
||||
public bool IsPermanent => duration <= 0f;
|
||||
|
||||
/// <summary>
|
||||
/// 주기적 효과가 있는지 확인
|
||||
/// </summary>
|
||||
public bool HasPeriodicEffect => periodicInterval > 0f && periodicValue != 0f;
|
||||
|
||||
/// <summary>
|
||||
/// 제어 효과가 있는지 확인
|
||||
/// </summary>
|
||||
public bool HasControlEffect => controlType != ControlType.None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b08cc671f858a3b409170a5356e960a0
|
||||
494
Assets/_Game/Scripts/Abnormalities/AbnormalityManager.cs
Normal file
494
Assets/_Game/Scripts/Abnormalities/AbnormalityManager.cs
Normal file
@@ -0,0 +1,494 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
using Colosseum.Stats;
|
||||
using Colosseum.Player;
|
||||
|
||||
namespace Colosseum.Abnormalities
|
||||
{
|
||||
/// <summary>
|
||||
/// 캐릭터에 부착되어 이상 상태를 관리하는 컴포넌트
|
||||
/// 버프/디버프의 적용, 제거, 주기적 효과를 처리합니다.
|
||||
/// </summary>
|
||||
public class AbnormalityManager : NetworkBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")]
|
||||
[SerializeField] private CharacterStats characterStats;
|
||||
|
||||
[Tooltip("PlayerNetworkController 컴포넌트 (HP/MP 관리용)")]
|
||||
[SerializeField] private PlayerNetworkController networkController;
|
||||
|
||||
// 활성화된 이상 상태 목록
|
||||
private readonly List<ActiveAbnormality> activeAbnormalities = new List<ActiveAbnormality>();
|
||||
|
||||
// 제어 효과 상태
|
||||
private int stunCount;
|
||||
private int silenceCount;
|
||||
private float slowMultiplier = 1f;
|
||||
|
||||
// 네트워크 동기화용 데이터
|
||||
private NetworkList<AbnormalitySyncData> syncedAbnormalities;
|
||||
|
||||
/// <summary>
|
||||
/// 기절 상태 여부
|
||||
/// </summary>
|
||||
public bool IsStunned => stunCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// 침묵 상태 여부
|
||||
/// </summary>
|
||||
public bool IsSilenced => silenceCount > 0;
|
||||
|
||||
/// <summary>
|
||||
/// 이동 속도 배율 (1.0 = 기본, 0.5 = 50% 감소)
|
||||
/// </summary>
|
||||
public float MoveSpeedMultiplier => slowMultiplier;
|
||||
|
||||
/// <summary>
|
||||
/// 행동 가능 여부 (기절이 아닐 때)
|
||||
/// </summary>
|
||||
public bool CanAct => !IsStunned;
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 사용 가능 여부
|
||||
/// </summary>
|
||||
public bool CanUseSkills => !IsStunned && !IsSilenced;
|
||||
|
||||
/// <summary>
|
||||
/// 활성화된 이상 상태 목록 (읽기 전용)
|
||||
/// </summary>
|
||||
public IReadOnlyList<ActiveAbnormality> ActiveAbnormalities => activeAbnormalities;
|
||||
|
||||
// 이벤트
|
||||
public event Action<ActiveAbnormality> OnAbnormalityAdded;
|
||||
public event Action<ActiveAbnormality> OnAbnormalityRemoved;
|
||||
public event Action OnAbnormalitiesChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 네트워크 동기화용 이상 상태 데이터 구조체
|
||||
/// </summary>
|
||||
private struct AbnormalitySyncData : INetworkSerializable, IEquatable<AbnormalitySyncData>
|
||||
{
|
||||
public int AbnormalityId;
|
||||
public float RemainingDuration;
|
||||
public ulong SourceClientId;
|
||||
|
||||
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
|
||||
{
|
||||
serializer.SerializeValue(ref AbnormalityId);
|
||||
serializer.SerializeValue(ref RemainingDuration);
|
||||
serializer.SerializeValue(ref SourceClientId);
|
||||
}
|
||||
|
||||
public bool Equals(AbnormalitySyncData other)
|
||||
{
|
||||
return AbnormalityId == other.AbnormalityId;
|
||||
}
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (characterStats == null)
|
||||
characterStats = GetComponent<CharacterStats>();
|
||||
|
||||
if (networkController == null)
|
||||
networkController = GetComponent<PlayerNetworkController>();
|
||||
|
||||
syncedAbnormalities = new NetworkList<AbnormalitySyncData>();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
syncedAbnormalities.OnListChanged += OnSyncedAbnormalitiesChanged;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
syncedAbnormalities.OnListChanged -= OnSyncedAbnormalitiesChanged;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
|
||||
UpdateAbnormalities(Time.deltaTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이상 상태 적용
|
||||
/// </summary>
|
||||
/// <param name="data">적용할 이상 상태 데이터</param>
|
||||
/// <param name="source">효과 시전자</param>
|
||||
public void ApplyAbnormality(AbnormalityData data, GameObject source)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
Debug.LogWarning("[Abnormality] ApplyAbnormality called with null data");
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsServer)
|
||||
{
|
||||
ApplyAbnormalityInternal(data, source);
|
||||
}
|
||||
else
|
||||
{
|
||||
var sourceNetId = source != null && source.TryGetComponent<NetworkObject>(out var netObj) ? netObj.NetworkObjectId : 0UL;
|
||||
ApplyAbnormalityServerRpc(data.GetInstanceID(), sourceNetId);
|
||||
}
|
||||
}
|
||||
|
||||
[Rpc(SendTo.Server)]
|
||||
private void ApplyAbnormalityServerRpc(int dataId, ulong sourceNetworkId)
|
||||
{
|
||||
var data = FindAbnormalityDataById(dataId);
|
||||
if (data == null)
|
||||
{
|
||||
Debug.LogWarning($"[Abnormality] Could not find data with ID: {dataId}");
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject source = null;
|
||||
if (sourceNetworkId != 0UL && NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(sourceNetworkId, out var netObj))
|
||||
{
|
||||
source = netObj.gameObject;
|
||||
}
|
||||
|
||||
ApplyAbnormalityInternal(data, source);
|
||||
}
|
||||
|
||||
private void ApplyAbnormalityInternal(AbnormalityData data, GameObject source)
|
||||
{
|
||||
var existing = FindExistingAbnormality(data);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
if (existing.Data == data)
|
||||
{
|
||||
existing.RefreshDuration();
|
||||
UpdateSyncedAbnormalityDuration(existing);
|
||||
Debug.Log($"[Abnormality] Refreshed {data.abnormalityName} on {gameObject.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.level > existing.Data.level)
|
||||
{
|
||||
RemoveAbnormalityInternal(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"[Abnormality] Ignored {data.abnormalityName} (level {data.level}) - existing level {existing.Data.level} is higher or equal");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var newAbnormality = new ActiveAbnormality(data, source);
|
||||
activeAbnormalities.Add(newAbnormality);
|
||||
|
||||
ApplyStatModifiers(newAbnormality);
|
||||
ApplyControlEffect(data);
|
||||
SyncAbnormalityAdd(newAbnormality, source);
|
||||
|
||||
OnAbnormalityAdded?.Invoke(newAbnormality);
|
||||
OnAbnormalitiesChanged?.Invoke();
|
||||
|
||||
Debug.Log($"[Abnormality] Applied {data.abnormalityName} (level {data.level}) to {gameObject.name} for {data.duration}s");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이상 상태 제거
|
||||
/// </summary>
|
||||
/// <param name="data">제거할 이상 상태 데이터</param>
|
||||
public void RemoveAbnormality(AbnormalityData data)
|
||||
{
|
||||
if (data == null) return;
|
||||
|
||||
if (IsServer)
|
||||
{
|
||||
var abnormality = FindExistingAbnormality(data);
|
||||
if (abnormality != null)
|
||||
{
|
||||
RemoveAbnormalityInternal(abnormality);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
RemoveAbnormalityServerRpc(data.GetInstanceID());
|
||||
}
|
||||
}
|
||||
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RemoveAbnormalityServerRpc(int dataId)
|
||||
{
|
||||
var abnormality = activeAbnormalities.Find(a => a.Data.GetInstanceID() == dataId);
|
||||
if (abnormality != null)
|
||||
{
|
||||
RemoveAbnormalityInternal(abnormality);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveAbnormalityInternal(ActiveAbnormality abnormality)
|
||||
{
|
||||
RemoveStatModifiers(abnormality);
|
||||
RemoveControlEffect(abnormality.Data);
|
||||
SyncAbnormalityRemove(abnormality);
|
||||
activeAbnormalities.Remove(abnormality);
|
||||
|
||||
OnAbnormalityRemoved?.Invoke(abnormality);
|
||||
OnAbnormalitiesChanged?.Invoke();
|
||||
|
||||
Debug.Log($"[Abnormality] Removed {abnormality.Data.abnormalityName} from {gameObject.name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 이상 상태 제거
|
||||
/// </summary>
|
||||
public void RemoveAllAbnormalities()
|
||||
{
|
||||
if (!IsServer)
|
||||
{
|
||||
RemoveAllAbnormalitiesServerRpc();
|
||||
return;
|
||||
}
|
||||
|
||||
while (activeAbnormalities.Count > 0)
|
||||
{
|
||||
RemoveAbnormalityInternal(activeAbnormalities[0]);
|
||||
}
|
||||
}
|
||||
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RemoveAllAbnormalitiesServerRpc()
|
||||
{
|
||||
RemoveAllAbnormalities();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 출처의 모든 이상 상태 제거
|
||||
/// </summary>
|
||||
public void RemoveAbnormalitiesFromSource(GameObject source)
|
||||
{
|
||||
if (!IsServer)
|
||||
{
|
||||
var sourceNetId = source != null && source.TryGetComponent<NetworkObject>(out var netObj) ? netObj.NetworkObjectId : 0UL;
|
||||
RemoveAbnormalitiesFromSourceServerRpc(sourceNetId);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = activeAbnormalities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (activeAbnormalities[i].Source == source)
|
||||
{
|
||||
RemoveAbnormalityInternal(activeAbnormalities[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RemoveAbnormalitiesFromSourceServerRpc(ulong sourceNetworkId)
|
||||
{
|
||||
for (int i = activeAbnormalities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var abnormality = activeAbnormalities[i];
|
||||
var sourceNetId = abnormality.Source != null && abnormality.Source.TryGetComponent<NetworkObject>(out var netObj) ? netObj.NetworkObjectId : 0UL;
|
||||
if (sourceNetId == sourceNetworkId)
|
||||
{
|
||||
RemoveAbnormalityInternal(abnormality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAbnormalities(float deltaTime)
|
||||
{
|
||||
for (int i = activeAbnormalities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var abnormality = activeAbnormalities[i];
|
||||
|
||||
if (abnormality.CanTriggerPeriodic())
|
||||
{
|
||||
TriggerPeriodicEffect(abnormality);
|
||||
}
|
||||
|
||||
if (abnormality.Tick(deltaTime))
|
||||
{
|
||||
RemoveAbnormalityInternal(abnormality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerPeriodicEffect(ActiveAbnormality abnormality)
|
||||
{
|
||||
if (networkController == null) return;
|
||||
|
||||
float value = abnormality.Data.periodicValue;
|
||||
|
||||
if (value > 0)
|
||||
{
|
||||
networkController.RestoreHealthRpc(value);
|
||||
Debug.Log($"[Abnormality] Periodic heal: +{value} HP from {abnormality.Data.abnormalityName}");
|
||||
}
|
||||
else if (value < 0)
|
||||
{
|
||||
networkController.TakeDamageRpc(-value);
|
||||
Debug.Log($"[Abnormality] Periodic damage: {-value} HP from {abnormality.Data.abnormalityName}");
|
||||
}
|
||||
}
|
||||
|
||||
private ActiveAbnormality FindExistingAbnormality(AbnormalityData data)
|
||||
{
|
||||
return activeAbnormalities.Find(a => a.Data.abnormalityName == data.abnormalityName);
|
||||
}
|
||||
|
||||
private void ApplyStatModifiers(ActiveAbnormality abnormality)
|
||||
{
|
||||
if (characterStats == null) return;
|
||||
|
||||
foreach (var entry in abnormality.Data.statModifiers)
|
||||
{
|
||||
var stat = characterStats.GetStat(entry.statType);
|
||||
if (stat != null)
|
||||
{
|
||||
var modifier = new StatModifier(entry.value, entry.modType, abnormality);
|
||||
abnormality.AppliedModifiers.Add(modifier);
|
||||
stat.AddModifier(modifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveStatModifiers(ActiveAbnormality abnormality)
|
||||
{
|
||||
if (characterStats == null) return;
|
||||
|
||||
foreach (StatType statType in Enum.GetValues(typeof(StatType)))
|
||||
{
|
||||
var stat = characterStats.GetStat(statType);
|
||||
stat?.RemoveAllModifiersFromSource(abnormality);
|
||||
}
|
||||
|
||||
abnormality.AppliedModifiers.Clear();
|
||||
}
|
||||
|
||||
private void ApplyControlEffect(AbnormalityData data)
|
||||
{
|
||||
switch (data.controlType)
|
||||
{
|
||||
case ControlType.Stun:
|
||||
stunCount++;
|
||||
break;
|
||||
|
||||
case ControlType.Silence:
|
||||
silenceCount++;
|
||||
break;
|
||||
|
||||
case ControlType.Slow:
|
||||
slowMultiplier = Mathf.Min(slowMultiplier, data.slowMultiplier);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveControlEffect(AbnormalityData data)
|
||||
{
|
||||
switch (data.controlType)
|
||||
{
|
||||
case ControlType.Stun:
|
||||
stunCount = Mathf.Max(0, stunCount - 1);
|
||||
break;
|
||||
|
||||
case ControlType.Silence:
|
||||
silenceCount = Mathf.Max(0, silenceCount - 1);
|
||||
break;
|
||||
|
||||
case ControlType.Slow:
|
||||
RecalculateSlowMultiplier();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecalculateSlowMultiplier()
|
||||
{
|
||||
slowMultiplier = 1f;
|
||||
|
||||
foreach (var abnormality in activeAbnormalities)
|
||||
{
|
||||
if (abnormality.Data.controlType == ControlType.Slow)
|
||||
{
|
||||
slowMultiplier = Mathf.Min(slowMultiplier, abnormality.Data.slowMultiplier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncAbnormalityAdd(ActiveAbnormality abnormality, GameObject source)
|
||||
{
|
||||
var sourceClientId = source != null && source.TryGetComponent<NetworkObject>(out var netObj) ? netObj.OwnerClientId : 0UL;
|
||||
|
||||
var syncData = new AbnormalitySyncData
|
||||
{
|
||||
AbnormalityId = abnormality.Data.GetInstanceID(),
|
||||
RemainingDuration = abnormality.RemainingDuration,
|
||||
SourceClientId = sourceClientId
|
||||
};
|
||||
|
||||
syncedAbnormalities.Add(syncData);
|
||||
}
|
||||
|
||||
private void UpdateSyncedAbnormalityDuration(ActiveAbnormality abnormality)
|
||||
{
|
||||
for (int i = 0; i < syncedAbnormalities.Count; i++)
|
||||
{
|
||||
if (syncedAbnormalities[i].AbnormalityId == abnormality.Data.GetInstanceID())
|
||||
{
|
||||
var syncData = syncedAbnormalities[i];
|
||||
syncData.RemainingDuration = abnormality.RemainingDuration;
|
||||
syncedAbnormalities[i] = syncData;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncAbnormalityRemove(ActiveAbnormality abnormality)
|
||||
{
|
||||
for (int i = 0; i < syncedAbnormalities.Count; i++)
|
||||
{
|
||||
if (syncedAbnormalities[i].AbnormalityId == abnormality.Data.GetInstanceID())
|
||||
{
|
||||
syncedAbnormalities.RemoveAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSyncedAbnormalitiesChanged(NetworkListEvent<AbnormalitySyncData> changeEvent)
|
||||
{
|
||||
OnAbnormalitiesChanged?.Invoke();
|
||||
}
|
||||
|
||||
private AbnormalityData FindAbnormalityDataById(int instanceId)
|
||||
{
|
||||
var allData = Resources.FindObjectsOfTypeAll<AbnormalityData>();
|
||||
foreach (var data in allData)
|
||||
{
|
||||
if (data.GetInstanceID() == instanceId)
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 이름의 이상 상태가 활성화되어 있는지 확인
|
||||
/// </summary>
|
||||
public bool HasAbnormality(string name)
|
||||
{
|
||||
return activeAbnormalities.Exists(a => a.Data.abnormalityName == name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 데이터의 이상 상태가 활성화되어 있는지 확인
|
||||
/// </summary>
|
||||
public bool HasAbnormality(AbnormalityData data)
|
||||
{
|
||||
return activeAbnormalities.Exists(a => a.Data.abnormalityName == data.abnormalityName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a766b6ab825c1445a3385079bb32cc5
|
||||
134
Assets/_Game/Scripts/Abnormalities/ActiveAbnormality.cs
Normal file
134
Assets/_Game/Scripts/Abnormalities/ActiveAbnormality.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Abnormalities
|
||||
{
|
||||
/// <summary>
|
||||
/// 런타임 활성 이상 상태 인스턴스
|
||||
/// AbnormalityData의 인스턴스로, 실제 적용 중인 이상 상태를 관리합니다.
|
||||
/// </summary>
|
||||
public class ActiveAbnormality
|
||||
{
|
||||
/// <summary>
|
||||
/// 이상 상태 데이터
|
||||
/// </summary>
|
||||
public AbnormalityData Data { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 효과를 건 대상 (버프/디버프 시전자)
|
||||
/// </summary>
|
||||
public GameObject Source { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 남은 지속 시간
|
||||
/// </summary>
|
||||
public float RemainingDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 적용된 스탯 수정자 목록
|
||||
/// </summary>
|
||||
public List<StatModifier> AppliedModifiers { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 주기적 효과 타이머
|
||||
/// </summary>
|
||||
public float PeriodicTimer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 고유 식별자 (네트워크 동기화용)
|
||||
/// </summary>
|
||||
public Guid Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 활성 이상 상태 생성
|
||||
/// </summary>
|
||||
/// <param name="data">이상 상태 데이터</param>
|
||||
/// <param name="source">효과 시전자</param>
|
||||
public ActiveAbnormality(AbnormalityData data, GameObject source)
|
||||
{
|
||||
Data = data;
|
||||
Source = source;
|
||||
RemainingDuration = data.duration;
|
||||
PeriodicTimer = 0f;
|
||||
Id = Guid.NewGuid();
|
||||
AppliedModifiers = new List<StatModifier>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지속 시간 갱신
|
||||
/// </summary>
|
||||
public void RefreshDuration()
|
||||
{
|
||||
RemainingDuration = Data.duration;
|
||||
PeriodicTimer = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 시간 경과 처리
|
||||
/// </summary>
|
||||
/// <param name="deltaTime">경과 시간</param>
|
||||
/// <returns>효과가 만료되었으면 true</returns>
|
||||
public bool Tick(float deltaTime)
|
||||
{
|
||||
// 영구 효과는 시간 감소 없음
|
||||
if (Data.IsPermanent)
|
||||
return false;
|
||||
|
||||
RemainingDuration -= deltaTime;
|
||||
|
||||
// 주기적 효과 타이머 업데이트
|
||||
if (Data.HasPeriodicEffect)
|
||||
{
|
||||
PeriodicTimer += deltaTime;
|
||||
}
|
||||
|
||||
return RemainingDuration <= 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 주기적 효과 발동 가능 여부 확인
|
||||
/// </summary>
|
||||
/// <returns>발동 가능하면 true</returns>
|
||||
public bool CanTriggerPeriodic()
|
||||
{
|
||||
if (!Data.HasPeriodicEffect)
|
||||
return false;
|
||||
|
||||
if (PeriodicTimer >= Data.periodicInterval)
|
||||
{
|
||||
PeriodicTimer -= Data.periodicInterval;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 진행률 (0~1)
|
||||
/// </summary>
|
||||
public float Progress
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Data.IsPermanent)
|
||||
return 1f;
|
||||
return Mathf.Clamp01(1f - (RemainingDuration / Data.duration));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 남은 시간 비율 (1~0, UI 표시용)
|
||||
/// </summary>
|
||||
public float RemainingRatio
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Data.IsPermanent)
|
||||
return 1f;
|
||||
return Mathf.Clamp01(RemainingDuration / Data.duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b90fb3ef8cb13be4383eb397857cfa2b
|
||||
23
Assets/_Game/Scripts/Colosseum.Game.asmdef
Normal file
23
Assets/_Game/Scripts/Colosseum.Game.asmdef
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Colosseum.Game",
|
||||
"rootNamespace": "Colosseum",
|
||||
"references": [
|
||||
"Unity.Netcode.Runtime",
|
||||
"Unity.Networking.Transport",
|
||||
"Unity.Transport",
|
||||
"Unity.InputSystem",
|
||||
"Unity.TextMeshPro",
|
||||
"Unity.Behavior",
|
||||
"Unity.Behavior.SerializableGUID",
|
||||
"Unity.Collections"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
7
Assets/_Game/Scripts/Colosseum.Game.asmdef.meta
Normal file
7
Assets/_Game/Scripts/Colosseum.Game.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 597e1695ce4bb584f96f8673b0cf7a6a
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/Core.meta
Normal file
8
Assets/_Game/Scripts/Core.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cfe0b5a63c8965949b1c8885d4232e7a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
515
Assets/_Game/Scripts/Core/GameManager.cs
Normal file
515
Assets/_Game/Scripts/Core/GameManager.cs
Normal file
@@ -0,0 +1,515 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using Unity.Netcode;
|
||||
using Colosseum.Player;
|
||||
using Colosseum.Enemy;
|
||||
|
||||
namespace Colosseum
|
||||
{
|
||||
/// <summary>
|
||||
/// 게임 상태 열거형
|
||||
/// </summary>
|
||||
public enum GameState
|
||||
{
|
||||
Waiting, // 대기 중
|
||||
Playing, // 게임 진행 중
|
||||
GameOver, // 게임 오버
|
||||
Victory // 승리
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 게임 전체를 관리하는 매니저.
|
||||
/// 게임 상태, 플레이어 사망 체크, 승리/패배 조건을 처리합니다.
|
||||
/// </summary>
|
||||
public class GameManager : NetworkBehaviour
|
||||
{
|
||||
[Header("UI Prefabs")]
|
||||
[Tooltip("게임 오버 UI 프리팹")]
|
||||
[SerializeField] private GameObject gameOverUIPrefab;
|
||||
|
||||
[Tooltip("승리 UI 프리팹")]
|
||||
[SerializeField] private GameObject victoryUIPrefab;
|
||||
|
||||
[Tooltip("승리 연출 이펙트 프리팹")]
|
||||
[SerializeField] private GameObject victoryEffectPrefab;
|
||||
|
||||
[Header("Settings")]
|
||||
[Tooltip("게임 오버 후 재시작까지 대기 시간")]
|
||||
[SerializeField] private float gameOverRestartDelay = 5f;
|
||||
|
||||
[Tooltip("승리 후 로비로 이동까지 대기 시간")]
|
||||
[SerializeField] private float victoryToLobbyDelay = 5f;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugMode = true;
|
||||
|
||||
// 싱글톤
|
||||
public static GameManager Instance { get; private set; }
|
||||
|
||||
// 게임 상태
|
||||
private NetworkVariable<GameState> currentState = new NetworkVariable<GameState>(GameState.Waiting);
|
||||
|
||||
// 인스턴스화된 UI
|
||||
private GameObject gameOverUIInstance;
|
||||
private GameObject victoryUIInstance;
|
||||
private GameObject victoryEffectInstance;
|
||||
|
||||
// 이벤트
|
||||
public event Action<GameState> OnGameStateChanged;
|
||||
public event Action OnGameOver;
|
||||
public event Action OnVictory;
|
||||
|
||||
// Properties
|
||||
public GameState CurrentState => currentState.Value;
|
||||
public bool IsGameOver => currentState.Value == GameState.GameOver;
|
||||
public bool IsVictory => currentState.Value == GameState.Victory;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 싱글톤 설정
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
currentState.OnValueChanged += HandleGameStateChanged;
|
||||
|
||||
// 네트워크 씬 로드 이벤트 구독
|
||||
if (NetworkManager.Singleton.SceneManager != null)
|
||||
{
|
||||
NetworkManager.Singleton.SceneManager.OnLoadEventCompleted += OnSceneLoadCompleted;
|
||||
}
|
||||
|
||||
// UI 인스턴스화 (모든 클라이언트에서)
|
||||
SpawnUI();
|
||||
|
||||
if (IsServer)
|
||||
{
|
||||
// 플레이어 사망 이벤트 구독
|
||||
StartCoroutine(WaitForPlayersAndSubscribe());
|
||||
|
||||
// 보스 사망 이벤트 구독
|
||||
SubscribeToBossEvents();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSceneLoadCompleted(string sceneName, LoadSceneMode loadSceneMode, List<ulong> clientsCompleted, List<ulong> clientsTimedOut)
|
||||
{
|
||||
if (loadSceneMode == LoadSceneMode.Single)
|
||||
{
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[GameManager] Scene loaded: {sceneName}");
|
||||
}
|
||||
|
||||
// 씬 로드 완료 시 플레이어 리스폰
|
||||
if (IsServer)
|
||||
{
|
||||
RespawnAllPlayersClientRpc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Rpc(SendTo.ClientsAndHost)]
|
||||
private void RespawnAllPlayersClientRpc()
|
||||
{
|
||||
// 모든 플레이어 리스폰
|
||||
var players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
|
||||
foreach (var player in players)
|
||||
{
|
||||
player.Respawn();
|
||||
}
|
||||
|
||||
// 카메라 재설정
|
||||
var playerMovement = FindObjectsByType<PlayerMovement>(FindObjectsSortMode.None);
|
||||
foreach (var movement in playerMovement)
|
||||
{
|
||||
movement.RefreshCamera();
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
currentState.OnValueChanged -= HandleGameStateChanged;
|
||||
|
||||
// 네트워크 씬 로드 이벤트 구독 해제
|
||||
if (NetworkManager.Singleton.SceneManager != null)
|
||||
{
|
||||
NetworkManager.Singleton.SceneManager.OnLoadEventCompleted -= OnSceneLoadCompleted;
|
||||
}
|
||||
|
||||
// UI 정리
|
||||
CleanupUI();
|
||||
|
||||
if (IsServer)
|
||||
{
|
||||
UnsubscribeFromPlayerEvents();
|
||||
UnsubscribeFromBossEvents();
|
||||
}
|
||||
}
|
||||
|
||||
#region UI Management
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// UI 프리팹 인스턴스화
|
||||
/// </summary>
|
||||
private void SpawnUI()
|
||||
{
|
||||
// Canvas 찾기 또는 생성
|
||||
Canvas canvas = FindOrCreateCanvas();
|
||||
|
||||
// 게임 오버 UI
|
||||
if (gameOverUIPrefab != null)
|
||||
{
|
||||
gameOverUIInstance = Instantiate(gameOverUIPrefab, canvas.transform);
|
||||
gameOverUIInstance.name = "GameOverUI";
|
||||
gameOverUIInstance.SetActive(false);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[GameManager] GameOverUI instantiated");
|
||||
}
|
||||
}
|
||||
|
||||
// 승리 UI
|
||||
if (victoryUIPrefab != null)
|
||||
{
|
||||
victoryUIInstance = Instantiate(victoryUIPrefab, canvas.transform);
|
||||
victoryUIInstance.name = "VictoryUI";
|
||||
victoryUIInstance.SetActive(false);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[GameManager] VictoryUI instantiated");
|
||||
}
|
||||
}
|
||||
|
||||
// 승리 연출 이펙트
|
||||
if (victoryEffectPrefab != null)
|
||||
{
|
||||
victoryEffectInstance = Instantiate(victoryEffectPrefab);
|
||||
victoryEffectInstance.name = "VictoryEffect";
|
||||
victoryEffectInstance.SetActive(false);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[GameManager] VictoryEffect instantiated");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canvas 찾기 또는 생성
|
||||
/// </summary>
|
||||
private Canvas FindOrCreateCanvas()
|
||||
{
|
||||
// 기존 Canvas 찾기
|
||||
Canvas canvas = FindFirstObjectByType<Canvas>();
|
||||
|
||||
if (canvas == null)
|
||||
{
|
||||
// 새 Canvas 생성
|
||||
var canvasObject = new GameObject("GameUI Canvas");
|
||||
canvas = canvasObject.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvasObject.AddComponent<UnityEngine.UI.CanvasScaler>();
|
||||
canvasObject.AddComponent<UnityEngine.UI.GraphicRaycaster>();
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[GameManager] Created new Canvas");
|
||||
}
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UI 정리
|
||||
/// </summary>
|
||||
private void CleanupUI()
|
||||
{
|
||||
if (gameOverUIInstance != null)
|
||||
{
|
||||
Destroy(gameOverUIInstance);
|
||||
}
|
||||
|
||||
if (victoryUIInstance != null)
|
||||
{
|
||||
Destroy(victoryUIInstance);
|
||||
}
|
||||
|
||||
if (victoryEffectInstance != null)
|
||||
{
|
||||
Destroy(victoryEffectInstance);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private void HandleGameStateChanged(GameState oldValue, GameState newValue)
|
||||
{
|
||||
OnGameStateChanged?.Invoke(newValue);
|
||||
|
||||
// UI 활성화/비활성화
|
||||
UpdateUIVisibility(newValue);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[GameManager] State changed: {oldValue} -> {newValue}");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateUIVisibility(GameState state)
|
||||
{
|
||||
// 게임 오버 UI
|
||||
if (gameOverUIInstance != null)
|
||||
{
|
||||
gameOverUIInstance.SetActive(state == GameState.GameOver);
|
||||
}
|
||||
|
||||
// 승리 UI
|
||||
if (victoryUIInstance != null)
|
||||
{
|
||||
victoryUIInstance.SetActive(state == GameState.Victory);
|
||||
}
|
||||
|
||||
// 승리 연출
|
||||
if (victoryEffectInstance != null && state == GameState.Victory)
|
||||
{
|
||||
victoryEffectInstance.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
#region Player Death Tracking
|
||||
|
||||
private List<PlayerNetworkController> alivePlayers = new List<PlayerNetworkController>();
|
||||
|
||||
private IEnumerator WaitForPlayersAndSubscribe()
|
||||
{
|
||||
// 플레이어들이 스폰될 때까지 대기
|
||||
yield return new WaitForSeconds(1f);
|
||||
|
||||
SubscribeToPlayerEvents();
|
||||
|
||||
// 게임 시작
|
||||
SetGameState(GameState.Playing);
|
||||
}
|
||||
|
||||
private void SubscribeToPlayerEvents()
|
||||
{
|
||||
var players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
|
||||
foreach (var player in players)
|
||||
{
|
||||
player.OnDeath += HandlePlayerDeath;
|
||||
if (!player.IsDead)
|
||||
{
|
||||
alivePlayers.Add(player);
|
||||
}
|
||||
}
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[GameManager] Subscribed to {players.Length} players, {alivePlayers.Count} alive");
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeFromPlayerEvents()
|
||||
{
|
||||
var players = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
|
||||
foreach (var player in players)
|
||||
{
|
||||
player.OnDeath -= HandlePlayerDeath;
|
||||
}
|
||||
alivePlayers.Clear();
|
||||
}
|
||||
|
||||
private void HandlePlayerDeath(PlayerNetworkController player)
|
||||
{
|
||||
alivePlayers.Remove(player);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[GameManager] Player died. Alive: {alivePlayers.Count}");
|
||||
}
|
||||
|
||||
// 모든 플레이어 사망 체크
|
||||
if (alivePlayers.Count == 0)
|
||||
{
|
||||
TriggerGameOver();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Boss Death Tracking
|
||||
|
||||
private void SubscribeToBossEvents()
|
||||
{
|
||||
BossEnemy.OnBossSpawned += HandleBossSpawned;
|
||||
|
||||
// 이미 스폰된 보스가 있는지 확인
|
||||
if (BossEnemy.ActiveBoss != null)
|
||||
{
|
||||
SubscribeToBossDeath(BossEnemy.ActiveBoss);
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeFromBossEvents()
|
||||
{
|
||||
BossEnemy.OnBossSpawned -= HandleBossSpawned;
|
||||
}
|
||||
|
||||
private void HandleBossSpawned(BossEnemy boss)
|
||||
{
|
||||
SubscribeToBossDeath(boss);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[GameManager] Boss spawned: {boss.name}");
|
||||
}
|
||||
}
|
||||
|
||||
private void SubscribeToBossDeath(BossEnemy boss)
|
||||
{
|
||||
boss.OnDeath += HandleBossDeath;
|
||||
}
|
||||
|
||||
private void HandleBossDeath()
|
||||
{
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[GameManager] Boss died!");
|
||||
}
|
||||
|
||||
TriggerVictory();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Game State Management
|
||||
|
||||
private void SetGameState(GameState newState)
|
||||
{
|
||||
if (!IsServer) return;
|
||||
|
||||
currentState.Value = newState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 게임 오버 처리 (서버에서만 실행)
|
||||
/// </summary>
|
||||
public void TriggerGameOver()
|
||||
{
|
||||
if (!IsServer || currentState.Value != GameState.Playing) return;
|
||||
|
||||
SetGameState(GameState.GameOver);
|
||||
OnGameOver?.Invoke();
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[GameManager] Game Over!");
|
||||
}
|
||||
|
||||
// N초 후 씬 재시작
|
||||
StartCoroutine(RestartSceneAfterDelay(gameOverRestartDelay));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 승리 처리 (서버에서만 실행)
|
||||
/// </summary>
|
||||
public void TriggerVictory()
|
||||
{
|
||||
if (!IsServer || currentState.Value != GameState.Playing) return;
|
||||
|
||||
SetGameState(GameState.Victory);
|
||||
OnVictory?.Invoke();
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[GameManager] Victory!");
|
||||
}
|
||||
|
||||
// N초 후 씬 재시작 (또는 로비로 이동)
|
||||
StartCoroutine(RestartSceneAfterDelay(victoryToLobbyDelay));
|
||||
}
|
||||
|
||||
private IEnumerator RestartSceneAfterDelay(float delay)
|
||||
{
|
||||
yield return new WaitForSeconds(delay);
|
||||
|
||||
// 현재 씬 다시 로드
|
||||
string currentScene = SceneManager.GetActiveScene().name;
|
||||
|
||||
if (IsServer)
|
||||
{
|
||||
// 네트워크 씬 관리 사용
|
||||
NetworkManager.Singleton.SceneManager.LoadScene(currentScene, LoadSceneMode.Single);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Utility
|
||||
|
||||
/// <summary>
|
||||
/// 살아있는 플레이어 목록 반환
|
||||
/// </summary>
|
||||
public List<PlayerNetworkController> GetAlivePlayers()
|
||||
{
|
||||
return alivePlayers.Where(p => p != null && !p.IsDead).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 랜덤한 살아있는 플레이어 반환 (관전용)
|
||||
/// </summary>
|
||||
public PlayerNetworkController GetRandomAlivePlayer()
|
||||
{
|
||||
var alive = GetAlivePlayers();
|
||||
if (alive.Count == 0) return null;
|
||||
|
||||
return alive[UnityEngine.Random.Range(0, alive.Count)];
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Core/GameManager.cs.meta
Normal file
2
Assets/_Game/Scripts/Core/GameManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7bde02fc6ca2ab0468bb3ce777206089
|
||||
39
Assets/_Game/Scripts/Core/IDamageable.cs
Normal file
39
Assets/_Game/Scripts/Core/IDamageable.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace Colosseum.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 대미지를 받을 수 있는 엔티티를 위한 인터페이스.
|
||||
/// 플레이어, 적, 보스 등이 구현합니다.
|
||||
/// </summary>
|
||||
public interface IDamageable
|
||||
{
|
||||
/// <summary>
|
||||
/// 현재 체력
|
||||
/// </summary>
|
||||
float CurrentHealth { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 최대 체력
|
||||
/// </summary>
|
||||
float MaxHealth { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 사망 여부
|
||||
/// </summary>
|
||||
bool IsDead { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 적용
|
||||
/// </summary>
|
||||
/// <param name="damage">적용할 대미지량</param>
|
||||
/// <param name="source">대미지 출처 (선택)</param>
|
||||
/// <returns>실제로 적용된 대미지량</returns>
|
||||
float TakeDamage(float damage, object source = null);
|
||||
|
||||
/// <summary>
|
||||
/// 체력 회복
|
||||
/// </summary>
|
||||
/// <param name="amount">회복량</param>
|
||||
/// <returns>실제로 회복된 양</returns>
|
||||
float Heal(float amount);
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Core/IDamageable.cs.meta
Normal file
2
Assets/_Game/Scripts/Core/IDamageable.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33a0f0f245adbf64791b38c182c48062
|
||||
44
Assets/_Game/Scripts/Core/Team.cs
Normal file
44
Assets/_Game/Scripts/Core/Team.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Colosseum
|
||||
{
|
||||
/// <summary>
|
||||
/// 팀 타입 열거형
|
||||
/// </summary>
|
||||
public enum TeamType
|
||||
{
|
||||
None = 0,
|
||||
Player = 1,
|
||||
Enemy = 2,
|
||||
Neutral = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 팀 정보를 관리하는 컴포넌트.
|
||||
/// 캐릭터나 엔티티에 추가하여 팀 구분에 사용합니다.
|
||||
/// </summary>
|
||||
public class Team : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private TeamType teamType = TeamType.None;
|
||||
|
||||
public TeamType TeamType => teamType;
|
||||
|
||||
/// <summary>
|
||||
/// 같은 팀인지 확인
|
||||
/// </summary>
|
||||
public static bool IsSameTeam(GameObject a, GameObject b)
|
||||
{
|
||||
if (a == null || b == null) return false;
|
||||
|
||||
var teamA = a.GetComponent<Team>();
|
||||
var teamB = b.GetComponent<Team>();
|
||||
|
||||
// 둘 다 팀이 없으면 같은 팀으로 처리
|
||||
if (teamA == null && teamB == null) return true;
|
||||
// 한쪽만 팀이 없으면 다른 팀
|
||||
if (teamA == null || teamB == null) return false;
|
||||
|
||||
return teamA.TeamType == teamB.TeamType;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Core/Team.cs.meta
Normal file
2
Assets/_Game/Scripts/Core/Team.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d1f7d13276f272b428bddd4d9aa5b3d8
|
||||
8
Assets/_Game/Scripts/Editor.meta
Normal file
8
Assets/_Game/Scripts/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d3cd76473ef5dcf44afccfab5fbdbfc6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
314
Assets/_Game/Scripts/Editor/BossEnemyEditor.cs
Normal file
314
Assets/_Game/Scripts/Editor/BossEnemyEditor.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using Colosseum.Enemy;
|
||||
|
||||
namespace Colosseum.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// BossEnemy 커스텀 인스펙터.
|
||||
/// 페이즈 정보, HP, 상태를 시각적으로 표시합니다.
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(BossEnemy))]
|
||||
public class BossEnemyEditor : UnityEditor.Editor
|
||||
{
|
||||
private BossEnemy boss;
|
||||
private bool showPhaseDetails = true;
|
||||
private bool showDebugTools = true;
|
||||
private int selectedPhaseIndex = 0;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
boss = (BossEnemy)target;
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
// 기본 인스펙터 그리기
|
||||
DrawDefaultInspector();
|
||||
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox("런타임 디버그 정보는 플레이 모드에서만 표시됩니다.", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 상태 요약
|
||||
DrawStatusSummary();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 페이즈 정보
|
||||
DrawPhaseInfo();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 디버그 도구
|
||||
DrawDebugTools();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 상태 요약 표시
|
||||
/// </summary>
|
||||
private void DrawStatusSummary()
|
||||
{
|
||||
EditorGUILayout.LabelField("상태 요약", EditorStyles.boldLabel);
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
// HP 바
|
||||
float hpPercent = boss.MaxHealth > 0 ? boss.CurrentHealth / boss.MaxHealth : 0f;
|
||||
DrawProgressBar("HP", hpPercent, GetHealthColor(hpPercent), $"{boss.CurrentHealth:F0} / {boss.MaxHealth:F0}");
|
||||
|
||||
// 상태 정보
|
||||
EditorGUILayout.LabelField("현재 페이즈", $"{boss.CurrentPhaseIndex + 1} / {boss.TotalPhases}");
|
||||
EditorGUILayout.LabelField("상태", GetStatusText());
|
||||
|
||||
if (boss.CurrentPhase != null)
|
||||
{
|
||||
EditorGUILayout.LabelField("페이즈명", boss.CurrentPhase.PhaseName);
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 상세 정보 표시
|
||||
/// </summary>
|
||||
private void DrawPhaseInfo()
|
||||
{
|
||||
showPhaseDetails = EditorGUILayout.Foldout(showPhaseDetails, "페이즈 상세 정보", true);
|
||||
|
||||
if (!showPhaseDetails)
|
||||
return;
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
var phasesProp = serializedObject.FindProperty("phases");
|
||||
if (phasesProp == null || phasesProp.arraySize == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("등록된 페이즈가 없습니다.", MessageType.Warning);
|
||||
EditorGUI.indentLevel--;
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < phasesProp.arraySize; i++)
|
||||
{
|
||||
var phaseProp = phasesProp.GetArrayElementAtIndex(i);
|
||||
var phase = phaseProp.objectReferenceValue as BossPhaseData;
|
||||
|
||||
if (phase == null)
|
||||
continue;
|
||||
|
||||
bool isCurrentPhase = i == boss.CurrentPhaseIndex;
|
||||
bool isCompleted = i < boss.CurrentPhaseIndex;
|
||||
|
||||
// 페이즈 헤더
|
||||
GUIStyle phaseStyle = new GUIStyle(EditorStyles.foldout);
|
||||
if (isCurrentPhase)
|
||||
phaseStyle.fontStyle = FontStyle.Bold;
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
|
||||
// 상태 아이콘
|
||||
string statusIcon = isCurrentPhase ? "▶" : (isCompleted ? "✓" : "○");
|
||||
GUIContent phaseLabel = new GUIContent($"{statusIcon} Phase {i + 1}: {phase.PhaseName}");
|
||||
|
||||
EditorGUILayout.LabelField(phaseLabel, GUILayout.Width(200));
|
||||
|
||||
// 전환 조건
|
||||
EditorGUILayout.LabelField($"[{phase.TransitionType}]", EditorStyles.miniLabel, GUILayout.Width(100));
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
if (isCurrentPhase)
|
||||
{
|
||||
EditorGUI.indentLevel++;
|
||||
EditorGUILayout.LabelField($"전환 조건: {GetTransitionConditionText(phase)}");
|
||||
EditorGUILayout.LabelField($"경과 시간: {boss.PhaseElapsedTime:F1}초");
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(2);
|
||||
}
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 디버그 도구 표시
|
||||
/// </summary>
|
||||
private void DrawDebugTools()
|
||||
{
|
||||
showDebugTools = EditorGUILayout.Foldout(showDebugTools, "디버그 도구", true);
|
||||
|
||||
if (!showDebugTools)
|
||||
return;
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
|
||||
EditorGUILayout.HelpBox("이 도구는 서버에서만 작동합니다.", MessageType.Info);
|
||||
|
||||
// 페이즈 강제 전환
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("페이즈 강제 전환", GUILayout.Width(120));
|
||||
selectedPhaseIndex = EditorGUILayout.IntSlider(selectedPhaseIndex, 0, Mathf.Max(0, boss.TotalPhases - 1));
|
||||
if (GUILayout.Button("전환", GUILayout.Width(60)))
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
boss.ForcePhaseTransition(selectedPhaseIndex);
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// 현재 페이즈 재시작
|
||||
if (GUILayout.Button("현재 페이즈 재시작"))
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
boss.RestartCurrentPhase();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// HP 조작
|
||||
EditorGUILayout.LabelField("HP 조작", EditorStyles.boldLabel);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("HP 10%"))
|
||||
{
|
||||
SetBossHP(0.1f);
|
||||
}
|
||||
if (GUILayout.Button("HP 30%"))
|
||||
{
|
||||
SetBossHP(0.3f);
|
||||
}
|
||||
if (GUILayout.Button("HP 50%"))
|
||||
{
|
||||
SetBossHP(0.5f);
|
||||
}
|
||||
if (GUILayout.Button("HP 100%"))
|
||||
{
|
||||
SetBossHP(1f);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(5);
|
||||
|
||||
// 커스텀 조건
|
||||
EditorGUILayout.LabelField("커스텀 조건 설정", EditorStyles.boldLabel);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("조건 ID:", GUILayout.Width(60));
|
||||
string conditionId = EditorGUILayout.TextField("Enraged");
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("활성화"))
|
||||
{
|
||||
boss.SetCustomCondition(conditionId, true);
|
||||
}
|
||||
if (GUILayout.Button("비활성화"))
|
||||
{
|
||||
boss.SetCustomCondition(conditionId, false);
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HP 설정 (서버에서만)
|
||||
/// </summary>
|
||||
private void SetBossHP(float percent)
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
return;
|
||||
|
||||
float targetHP = boss.MaxHealth * percent;
|
||||
float damage = boss.CurrentHealth - targetHP;
|
||||
|
||||
if (damage > 0)
|
||||
{
|
||||
boss.TakeDamage(damage);
|
||||
}
|
||||
else if (damage < 0)
|
||||
{
|
||||
boss.Heal(-damage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 진행 바 그리기
|
||||
/// </summary>
|
||||
private void DrawProgressBar(string label, float value, Color color, string text = "")
|
||||
{
|
||||
Rect rect = EditorGUILayout.GetControlRect();
|
||||
rect.height = 20f;
|
||||
|
||||
// 레이블
|
||||
Rect labelRect = new Rect(rect.x, rect.y, 60, rect.height);
|
||||
EditorGUI.LabelField(labelRect, label);
|
||||
|
||||
// 바
|
||||
Rect barRect = new Rect(rect.x + 65, rect.y, rect.width - 65, rect.height);
|
||||
EditorGUI.DrawRect(barRect, new Color(0.2f, 0.2f, 0.2f));
|
||||
|
||||
Rect fillRect = new Rect(barRect.x, barRect.y, barRect.width * Mathf.Clamp01(value), barRect.height);
|
||||
EditorGUI.DrawRect(fillRect, color);
|
||||
|
||||
// 텍스트
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
GUIStyle centeredStyle = new GUIStyle(EditorStyles.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter
|
||||
};
|
||||
EditorGUI.LabelField(barRect, text, centeredStyle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HP 비율에 따른 색상 반환
|
||||
/// </summary>
|
||||
private Color GetHealthColor(float percent)
|
||||
{
|
||||
if (percent > 0.6f)
|
||||
return new Color(0.2f, 0.8f, 0.2f); // 녹색
|
||||
if (percent > 0.3f)
|
||||
return new Color(0.9f, 0.7f, 0.1f); // 노란색
|
||||
return new Color(0.9f, 0.2f, 0.2f); // 빨간색
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 상태 텍스트 반환
|
||||
/// </summary>
|
||||
private string GetStatusText()
|
||||
{
|
||||
if (boss.IsDead)
|
||||
return "<color=red>사망</color>";
|
||||
if (boss.IsTransitioning)
|
||||
return "<color=yellow>페이즈 전환 중</color>";
|
||||
return "<color=green>활성</color>";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 전환 조건 텍스트 반환
|
||||
/// </summary>
|
||||
private string GetTransitionConditionText(BossPhaseData phase)
|
||||
{
|
||||
return phase.TransitionType switch
|
||||
{
|
||||
PhaseTransitionType.HealthPercent => $"HP ≤ {phase.HealthPercentThreshold * 100:F0}%",
|
||||
PhaseTransitionType.TimeElapsed => $"시간 ≥ {phase.TimeThreshold:F0}초",
|
||||
PhaseTransitionType.CustomCondition => $"조건: {phase.CustomConditionId}",
|
||||
PhaseTransitionType.Manual => "수동 전환",
|
||||
_ => "알 수 없음"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
2
Assets/_Game/Scripts/Editor/BossEnemyEditor.cs.meta
Normal file
2
Assets/_Game/Scripts/Editor/BossEnemyEditor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 867ffff975b9a7a4694783f4a5ee1c6e
|
||||
53
Assets/_Game/Scripts/Editor/ConnectionUIEditor.cs
Normal file
53
Assets/_Game/Scripts/Editor/ConnectionUIEditor.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Colosseum.UI.Editor
|
||||
{
|
||||
[CustomEditor(typeof(ConnectionUI))]
|
||||
public class ConnectionUIEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
DrawDefaultInspector();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
EditorGUILayout.LabelField("Connection Controls", EditorStyles.boldLabel);
|
||||
|
||||
ConnectionUI connectionUI = (ConnectionUI)target;
|
||||
|
||||
EditorGUI.BeginDisabledGroup(!Application.isPlaying);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Start Host", GUILayout.Height(30)))
|
||||
{
|
||||
connectionUI.StartHost();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Start Client", GUILayout.Height(30)))
|
||||
{
|
||||
connectionUI.StartClient();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Start Server", GUILayout.Height(30)))
|
||||
{
|
||||
connectionUI.StartServer();
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Disconnect", GUILayout.Height(25)))
|
||||
{
|
||||
connectionUI.Disconnect();
|
||||
}
|
||||
|
||||
EditorGUI.EndDisabledGroup();
|
||||
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox("Play Mode에서만 연결할 수 있습니다.", MessageType.Info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
2
Assets/_Game/Scripts/Editor/ConnectionUIEditor.cs.meta
Normal file
2
Assets/_Game/Scripts/Editor/ConnectionUIEditor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 088a15576b464764b85a18f4dacb1a43
|
||||
8
Assets/_Game/Scripts/Editor/Skills.meta
Normal file
8
Assets/_Game/Scripts/Editor/Skills.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ba44fce3757f70c42acd5b75c3ced354
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/Effects.meta
Normal file
8
Assets/_Game/Scripts/Effects.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9598f5b3fb42a1945ab57c2dc55b2815
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
187
Assets/_Game/Scripts/Effects/VictoryEffect.cs
Normal file
187
Assets/_Game/Scripts/Effects/VictoryEffect.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using Colosseum.Enemy;
|
||||
|
||||
namespace Colosseum.Effects
|
||||
{
|
||||
/// <summary>
|
||||
/// 보스 승리 연출 이펙트.
|
||||
/// 보스 사망 시 카메라 연출, 이펙트, 슬로우 모션 등을 처리합니다.
|
||||
/// GameManager에 의해 활성화됩니다.
|
||||
/// </summary>
|
||||
public class VictoryEffect : MonoBehaviour
|
||||
{
|
||||
[Header("Victory Settings")]
|
||||
[Tooltip("승리 시 슬로우 모션 배율")]
|
||||
[SerializeField] private float slowMotionScale = 0.3f;
|
||||
|
||||
[Tooltip("슬로우 모션 지속 시간")]
|
||||
[SerializeField] private float slowMotionDuration = 2f;
|
||||
|
||||
[Header("Effects")]
|
||||
[Tooltip("승리 시 생성할 이펙트 프리팹")]
|
||||
[SerializeField] private GameObject victoryEffectPrefab;
|
||||
|
||||
[Tooltip("이펙트 생성 위치 오프셋")]
|
||||
[SerializeField] private Vector3 effectOffset = Vector3.up * 2f;
|
||||
|
||||
[Header("Audio")]
|
||||
[Tooltip("승리 사운드")]
|
||||
[SerializeField] private AudioClip victorySound;
|
||||
|
||||
[Tooltip("사운드 볼륨")]
|
||||
[SerializeField] private float soundVolume = 1f;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugMode = true;
|
||||
|
||||
// 상태
|
||||
private bool isPlaying = false;
|
||||
private float originalTimeScale;
|
||||
private Camera mainCamera;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
mainCamera = Camera.main;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
PlayVictoryEffect();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// 시간 스케일 복구
|
||||
if (isPlaying)
|
||||
{
|
||||
Time.timeScale = 1f;
|
||||
isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 승리 연출 재생
|
||||
/// </summary>
|
||||
public void PlayVictoryEffect()
|
||||
{
|
||||
if (isPlaying) return;
|
||||
|
||||
StartCoroutine(VictorySequence());
|
||||
}
|
||||
|
||||
private IEnumerator VictorySequence()
|
||||
{
|
||||
isPlaying = true;
|
||||
originalTimeScale = Time.timeScale;
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[VictoryEffect] Starting victory sequence");
|
||||
}
|
||||
|
||||
// 1. 슬로우 모션
|
||||
yield return StartCoroutine(PlaySlowMotion());
|
||||
|
||||
// 2. 카메라 연출
|
||||
yield return StartCoroutine(PlayCameraEffect());
|
||||
|
||||
// 3. 이펙트 생성
|
||||
SpawnVictoryEffect();
|
||||
|
||||
// 4. 사운드 재생
|
||||
PlayVictorySound();
|
||||
|
||||
// 5. 시간 복구
|
||||
Time.timeScale = originalTimeScale;
|
||||
isPlaying = false;
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[VictoryEffect] Victory sequence complete");
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator PlaySlowMotion()
|
||||
{
|
||||
float elapsed = 0f;
|
||||
|
||||
while (elapsed < slowMotionDuration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
float t = elapsed / slowMotionDuration;
|
||||
|
||||
// 처음에는 슬로우, 나중에는 복구
|
||||
if (t < 0.5f)
|
||||
{
|
||||
Time.timeScale = Mathf.Lerp(originalTimeScale, slowMotionScale, t * 2f);
|
||||
}
|
||||
else
|
||||
{
|
||||
Time.timeScale = Mathf.Lerp(slowMotionScale, originalTimeScale, (t - 0.5f) * 2f);
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Time.timeScale = slowMotionScale;
|
||||
}
|
||||
|
||||
private IEnumerator PlayCameraEffect()
|
||||
{
|
||||
if (mainCamera == null || BossEnemy.ActiveBoss == null)
|
||||
yield break;
|
||||
|
||||
// 보스 위치로 카메라 이동
|
||||
Transform bossTransform = BossEnemy.ActiveBoss.transform;
|
||||
Vector3 targetPosition = bossTransform.position + effectOffset;
|
||||
|
||||
float elapsed = 0f;
|
||||
float duration = slowMotionDuration * 0.5f;
|
||||
|
||||
while (elapsed < duration)
|
||||
{
|
||||
elapsed += Time.unscaledDeltaTime;
|
||||
|
||||
// 카메라가 보스를 바라보도록
|
||||
mainCamera.transform.LookAt(targetPosition);
|
||||
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnVictoryEffect()
|
||||
{
|
||||
if (victoryEffectPrefab == null) return;
|
||||
|
||||
Vector3 spawnPosition = transform.position + effectOffset;
|
||||
|
||||
if (BossEnemy.ActiveBoss != null)
|
||||
{
|
||||
spawnPosition = BossEnemy.ActiveBoss.transform.position + effectOffset;
|
||||
}
|
||||
|
||||
var effect = Instantiate(victoryEffectPrefab, spawnPosition, Quaternion.identity);
|
||||
|
||||
// 일정 시간 후 제거
|
||||
Destroy(effect, 5f);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[VictoryEffect] Spawned victory effect");
|
||||
}
|
||||
}
|
||||
|
||||
private void PlayVictorySound()
|
||||
{
|
||||
if (victorySound == null) return;
|
||||
|
||||
AudioSource.PlayClipAtPoint(victorySound, transform.position, soundVolume);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log("[VictoryEffect] Played victory sound");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Effects/VictoryEffect.cs.meta
Normal file
2
Assets/_Game/Scripts/Effects/VictoryEffect.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7cbb78d0cea7014ba69a25271583954
|
||||
8
Assets/_Game/Scripts/Enemy.meta
Normal file
8
Assets/_Game/Scripts/Enemy.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f6dc132c1fce114da1ae74c46fd57dd
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
250
Assets/_Game/Scripts/Enemy/BossArea.cs
Normal file
250
Assets/_Game/Scripts/Enemy/BossArea.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
using Colosseum.UI;
|
||||
using Colosseum.Player;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 보스 영역 트리거.
|
||||
/// 플레이어가 이 영역에 진입하면 연결된 보스의 체력바 UI를 표시합니다.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider))]
|
||||
public class BossArea : MonoBehaviour
|
||||
{
|
||||
[Header("Boss Reference")]
|
||||
[Tooltip("이 영역에 연결된 보스")]
|
||||
[SerializeField] private BossEnemy boss;
|
||||
|
||||
[Header("UI Settings")]
|
||||
[Tooltip("보스 체력바 UI (없으면 씬에서 자동 검색)")]
|
||||
[SerializeField] private BossHealthBarUI bossHealthBarUI;
|
||||
|
||||
[Header("Trigger Settings")]
|
||||
[Tooltip("플레이어 퇴장 시 UI 숨김 여부")]
|
||||
[SerializeField] private bool hideOnExit = false;
|
||||
|
||||
[Tooltip("영역 진입 시 한 번만 표시")]
|
||||
[SerializeField] private bool showOnceOnly = false;
|
||||
|
||||
// 이벤트
|
||||
/// <summary>
|
||||
/// 플레이어 진입 시 호출
|
||||
/// </summary>
|
||||
public event Action OnPlayerEnter;
|
||||
|
||||
/// <summary>
|
||||
/// 플레이어 퇴장 시 호출
|
||||
/// </summary>
|
||||
public event Action OnPlayerExit;
|
||||
|
||||
// 상태
|
||||
private bool hasShownUI = false;
|
||||
private bool isPlayerInArea = false;
|
||||
private Collider triggerCollider;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugMode = false;
|
||||
|
||||
/// <summary>
|
||||
/// 연결된 보스
|
||||
/// </summary>
|
||||
public BossEnemy Boss => boss;
|
||||
|
||||
/// <summary>
|
||||
/// 플레이어가 영역 내에 있는지 여부
|
||||
/// </summary>
|
||||
public bool IsPlayerInArea => isPlayerInArea;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// Collider 설정 확인
|
||||
triggerCollider = GetComponent<Collider>();
|
||||
if (triggerCollider != null && !triggerCollider.isTrigger)
|
||||
{
|
||||
Debug.LogWarning($"[BossArea] {name}: Collider가 Trigger가 아닙니다. 자동으로 Trigger로 설정합니다.");
|
||||
triggerCollider.isTrigger = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// BossHealthBarUI 자동 검색
|
||||
if (bossHealthBarUI == null)
|
||||
{
|
||||
bossHealthBarUI = FindFirstObjectByType<BossHealthBarUI>();
|
||||
if (bossHealthBarUI == null)
|
||||
{
|
||||
Debug.LogWarning($"[BossArea] {name}: BossHealthBarUI를 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 보스 참조 확인
|
||||
if (boss == null)
|
||||
{
|
||||
Debug.LogWarning($"[BossArea] {name}: 연결된 보스가 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTriggerEnter(Collider other)
|
||||
{
|
||||
// 이미 표시했고 한 번만 표시 설정이면 무시
|
||||
if (showOnceOnly && hasShownUI)
|
||||
return;
|
||||
|
||||
// 플레이어 확인 (태그 또는 컴포넌트)
|
||||
if (!IsPlayer(other, out var playerController))
|
||||
return;
|
||||
|
||||
isPlayerInArea = true;
|
||||
ShowBossHealthBar();
|
||||
|
||||
OnPlayerEnter?.Invoke();
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[BossArea] {name}: 플레이어 진입 - 보스: {boss?.name ?? "없음"}");
|
||||
}
|
||||
|
||||
private void OnTriggerExit(Collider other)
|
||||
{
|
||||
// 플레이어 확인
|
||||
if (!IsPlayer(other, out var playerController))
|
||||
return;
|
||||
|
||||
isPlayerInArea = false;
|
||||
|
||||
if (hideOnExit)
|
||||
{
|
||||
HideBossHealthBar();
|
||||
}
|
||||
|
||||
OnPlayerExit?.Invoke();
|
||||
|
||||
if (debugMode)
|
||||
Debug.Log($"[BossArea] {name}: 플레이어 퇴장");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 체력바 표시
|
||||
/// </summary>
|
||||
public void ShowBossHealthBar()
|
||||
{
|
||||
if (boss == null || bossHealthBarUI == null)
|
||||
return;
|
||||
|
||||
// BossHealthBarUI에 보스 설정
|
||||
bossHealthBarUI.SetBoss(boss);
|
||||
hasShownUI = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 체력바 숨김
|
||||
/// </summary>
|
||||
public void HideBossHealthBar()
|
||||
{
|
||||
if (bossHealthBarUI == null)
|
||||
return;
|
||||
|
||||
bossHealthBarUI.gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 플레이어 여부 확인
|
||||
/// </summary>
|
||||
private bool IsPlayer(Collider other, out PlayerNetworkController playerController)
|
||||
{
|
||||
playerController = null;
|
||||
|
||||
// 1. 태그로 확인
|
||||
if (other.CompareTag("Player"))
|
||||
{
|
||||
playerController = other.GetComponent<PlayerNetworkController>();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 컴포넌트로 확인
|
||||
playerController = other.GetComponent<PlayerNetworkController>();
|
||||
if (playerController != null)
|
||||
return true;
|
||||
|
||||
// 3. 부모에서 검색
|
||||
playerController = other.GetComponentInParent<PlayerNetworkController>();
|
||||
return playerController != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 수동 설정
|
||||
/// </summary>
|
||||
public void SetBoss(BossEnemy newBoss)
|
||||
{
|
||||
boss = newBoss;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UI 수동 설정
|
||||
/// </summary>
|
||||
public void SetHealthBarUI(BossHealthBarUI ui)
|
||||
{
|
||||
bossHealthBarUI = ui;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 상태 초기화 (재진입 허용)
|
||||
/// </summary>
|
||||
public void ResetState()
|
||||
{
|
||||
hasShownUI = false;
|
||||
isPlayerInArea = false;
|
||||
}
|
||||
|
||||
#region Debug Gizmos
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (!debugMode)
|
||||
return;
|
||||
|
||||
// 영역 시각화
|
||||
Gizmos.color = new Color(1f, 0.5f, 0f, 0.3f); // 주황색 반투명
|
||||
|
||||
var col = GetComponent<Collider>();
|
||||
if (col is BoxCollider boxCol)
|
||||
{
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Gizmos.DrawCube(boxCol.center, boxCol.size);
|
||||
}
|
||||
else if (col is SphereCollider sphereCol)
|
||||
{
|
||||
Gizmos.DrawSphere(transform.position + sphereCol.center, sphereCol.radius);
|
||||
}
|
||||
else if (col is CapsuleCollider capsuleCol)
|
||||
{
|
||||
// 캡슐은 구+실린더로 근접 표현
|
||||
Gizmos.DrawWireSphere(transform.position + capsuleCol.center, capsuleCol.radius);
|
||||
}
|
||||
|
||||
// 보스 연결 표시
|
||||
if (boss != null)
|
||||
{
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawLine(transform.position, boss.transform.position);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
// 선택 시 더 명확하게 표시
|
||||
Gizmos.color = new Color(1f, 0.3f, 0f, 0.5f);
|
||||
|
||||
var col = GetComponent<Collider>();
|
||||
if (col is BoxCollider boxCol)
|
||||
{
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Gizmos.DrawCube(boxCol.center, boxCol.size);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/BossArea.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/BossArea.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1735054b0ca6d674b99668aeb74ba273
|
||||
315
Assets/_Game/Scripts/Enemy/BossEnemy.cs
Normal file
315
Assets/_Game/Scripts/Enemy/BossEnemy.cs
Normal file
@@ -0,0 +1,315 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
using Unity.Behavior;
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 보스 캐릭터. 페이즈 시스템과 동적 AI 전환을 지원합니다.
|
||||
/// Unity Behavior 패키지를 사용하여 Behavior Tree 기반 AI를 구현합니다.
|
||||
/// </summary>
|
||||
|
||||
public class BossEnemy : EnemyBase
|
||||
{
|
||||
[Header("Boss Settings")]
|
||||
[Tooltip("보스 페이즈 데이터 목록 (순서대로 전환)")]
|
||||
[SerializeField] private List<BossPhaseData> phases = new();
|
||||
|
||||
[Tooltip("초기 Behavior Graph")]
|
||||
[SerializeField] private BehaviorGraph initialBehaviorGraph;
|
||||
|
||||
[Header("Phase Settings")]
|
||||
[Tooltip("페이즈 전환 시 무적 시간")]
|
||||
[Min(0f)] [SerializeField] private float phaseTransitionInvincibilityTime = 2f;
|
||||
|
||||
[Tooltip("페이즈 전환 연출 시간")]
|
||||
[Min(0f)] [SerializeField] private float phaseTransitionDuration = 3f;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugMode = true;
|
||||
|
||||
// 컴포넌트
|
||||
private BehaviorGraphAgent behaviorAgent;
|
||||
|
||||
// 페이즈 상태
|
||||
private int currentPhaseIndex = 0;
|
||||
private bool isTransitioning = false;
|
||||
private float phaseStartTime;
|
||||
private float phaseElapsedTime;
|
||||
private bool isInvincible = false;
|
||||
|
||||
// 커스텀 조건 딕셔너리
|
||||
private Dictionary<string, bool> customConditions = new Dictionary<string, bool>();
|
||||
|
||||
// 이벤트
|
||||
public event System.Action<int> OnPhaseChanged; // phaseIndex
|
||||
public event System.Action<float> OnPhaseTransitionStart; // transitionDuration
|
||||
public event System.Action OnPhaseTransitionEnd;
|
||||
// 정적 이벤트 (UI 자동 연결용)
|
||||
/// <summary>
|
||||
/// 보스 스폰 시 발생하는 정적 이벤트
|
||||
/// </summary>
|
||||
public static event System.Action<BossEnemy> OnBossSpawned;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 활성화된 보스 (Scene에 하나만 존재한다고 가정)
|
||||
/// </summary>
|
||||
public static BossEnemy ActiveBoss { get; private set; }
|
||||
|
||||
// Properties
|
||||
public int CurrentPhaseIndex => currentPhaseIndex;
|
||||
public BossPhaseData CurrentPhase => phases.Count > currentPhaseIndex ? phases[currentPhaseIndex] : null;
|
||||
public int TotalPhases => phases.Count;
|
||||
public bool IsTransitioning => isTransitioning;
|
||||
public float PhaseElapsedTime => phaseElapsedTime;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
base.OnNetworkSpawn();
|
||||
|
||||
// BehaviorGraphAgent 컴포넌트 확인/추가
|
||||
behaviorAgent = GetComponent<BehaviorGraphAgent>();
|
||||
if (behaviorAgent == null)
|
||||
{
|
||||
behaviorAgent = gameObject.AddComponent<BehaviorGraphAgent>();
|
||||
}
|
||||
|
||||
// 초기 AI 설정
|
||||
if (IsServer && initialBehaviorGraph != null)
|
||||
{
|
||||
behaviorAgent.Graph = initialBehaviorGraph;
|
||||
}
|
||||
|
||||
// 정적 이벤트 발생 (UI 자동 연결용)
|
||||
ActiveBoss = this;
|
||||
OnBossSpawned?.Invoke(this);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[Boss] Boss spawned: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected override void InitializeStats()
|
||||
{
|
||||
base.InitializeStats();
|
||||
phaseStartTime = Time.time;
|
||||
phaseElapsedTime = 0f;
|
||||
currentPhaseIndex = 0;
|
||||
isTransitioning = false;
|
||||
isInvincible = false;
|
||||
customConditions.Clear();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsServer || IsDead || isTransitioning)
|
||||
return;
|
||||
|
||||
phaseElapsedTime = Time.time - phaseStartTime;
|
||||
|
||||
// 다음 페이즈 전환 조건 확인
|
||||
CheckPhaseTransition();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 전환 조건 확인
|
||||
/// </summary>
|
||||
private void CheckPhaseTransition()
|
||||
{
|
||||
int nextPhaseIndex = currentPhaseIndex + 1;
|
||||
if (nextPhaseIndex >= phases.Count)
|
||||
return;
|
||||
|
||||
BossPhaseData nextPhase = phases[nextPhaseIndex];
|
||||
if (nextPhase == null)
|
||||
return;
|
||||
|
||||
if (nextPhase.CheckTransitionCondition(this, phaseElapsedTime))
|
||||
{
|
||||
StartPhaseTransition(nextPhaseIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 전환 시작
|
||||
/// </summary>
|
||||
private void StartPhaseTransition(int newPhaseIndex)
|
||||
{
|
||||
if (newPhaseIndex >= phases.Count || isTransitioning)
|
||||
return;
|
||||
|
||||
isTransitioning = true;
|
||||
isInvincible = true;
|
||||
|
||||
StartCoroutine(PhaseTransitionCoroutine(newPhaseIndex));
|
||||
}
|
||||
|
||||
private System.Collections.IEnumerator PhaseTransitionCoroutine(int newPhaseIndex)
|
||||
{
|
||||
BossPhaseData newPhase = phases[newPhaseIndex];
|
||||
|
||||
// 전환 이벤트
|
||||
OnPhaseTransitionStart?.Invoke(phaseTransitionDuration);
|
||||
|
||||
// 전환 연출
|
||||
yield return PlayPhaseTransitionEffect(newPhase);
|
||||
|
||||
// AI 그래프 교체
|
||||
if (newPhase.BehaviorGraph != null && behaviorAgent != null)
|
||||
{
|
||||
behaviorAgent.End();
|
||||
behaviorAgent.Graph = newPhase.BehaviorGraph;
|
||||
}
|
||||
|
||||
// 페이즈 전환 완료
|
||||
currentPhaseIndex = newPhaseIndex;
|
||||
phaseStartTime = Time.time;
|
||||
phaseElapsedTime = 0f;
|
||||
|
||||
// 무적 해제
|
||||
yield return new WaitForSeconds(phaseTransitionInvincibilityTime);
|
||||
isInvincible = false;
|
||||
isTransitioning = false;
|
||||
|
||||
OnPhaseTransitionEnd?.Invoke();
|
||||
OnPhaseChanged?.Invoke(currentPhaseIndex);
|
||||
|
||||
if (debugMode)
|
||||
{
|
||||
Debug.Log($"[Boss] Phase transition: {currentPhaseIndex} ({newPhase.PhaseName})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 페이즈 전환 연출 재생
|
||||
/// </summary>
|
||||
private System.Collections.IEnumerator PlayPhaseTransitionEffect(BossPhaseData newPhase)
|
||||
{
|
||||
// 애니메이션 재생
|
||||
if (animator != null && newPhase.PhaseStartAnimation != null)
|
||||
{
|
||||
animator.Play(newPhase.PhaseStartAnimation.name);
|
||||
}
|
||||
|
||||
// 이펙트 생성
|
||||
if (newPhase.PhaseTransitionEffect != null)
|
||||
{
|
||||
var effect = Instantiate(newPhase.PhaseTransitionEffect, transform.position, transform.rotation);
|
||||
Destroy(effect, phaseTransitionDuration);
|
||||
}
|
||||
|
||||
// 전환 시간 대기
|
||||
yield return new WaitForSeconds(phaseTransitionDuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 적용 (무적 상태 고려)
|
||||
/// </summary>
|
||||
public override float TakeDamage(float damage, object source = null)
|
||||
{
|
||||
if (isInvincible)
|
||||
return 0f;
|
||||
|
||||
return base.TakeDamage(damage, source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 커스텀 조건 설정
|
||||
/// </summary>
|
||||
public void SetCustomCondition(string conditionId, bool value)
|
||||
{
|
||||
customConditions[conditionId] = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 커스텀 조건 확인
|
||||
/// </summary>
|
||||
public bool CheckCustomCondition(string conditionId)
|
||||
{
|
||||
return customConditions.TryGetValue(conditionId, out bool value) && value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 수동으로 페이즈 전환
|
||||
/// </summary>
|
||||
public void ForcePhaseTransition(int phaseIndex)
|
||||
{
|
||||
if (!IsServer)
|
||||
return;
|
||||
|
||||
if (phaseIndex >= 0 && phaseIndex < phases.Count && phaseIndex != currentPhaseIndex)
|
||||
{
|
||||
StartPhaseTransition(phaseIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 페이즈 재시작
|
||||
/// </summary>
|
||||
public void RestartCurrentPhase()
|
||||
{
|
||||
if (!IsServer)
|
||||
return;
|
||||
|
||||
phaseStartTime = Time.time;
|
||||
phaseElapsedTime = 0f;
|
||||
|
||||
if (behaviorAgent != null)
|
||||
{
|
||||
behaviorAgent.Restart();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void HandleDeath()
|
||||
{
|
||||
// 마지막 페이즈에서만 사망 처리
|
||||
if (currentPhaseIndex < phases.Count - 1 && !isTransitioning)
|
||||
{
|
||||
// 아직 페이즈가 남아있으면 강제로 다음 페이즈로
|
||||
StartPhaseTransition(currentPhaseIndex + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// AI 완전 중단 (순서 중요: enabled=false를 먼저 호출하여 Update() 차단)
|
||||
if (behaviorAgent != null)
|
||||
{
|
||||
behaviorAgent.enabled = false; // 가장 먼저: Update() 호출 방지
|
||||
behaviorAgent.End();
|
||||
behaviorAgent.Graph = null;
|
||||
}
|
||||
behaviorAgent = null;
|
||||
|
||||
base.HandleDeath();
|
||||
}
|
||||
|
||||
#region Debug
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!debugMode)
|
||||
return;
|
||||
|
||||
// 현재 페이즈 정보 표시
|
||||
#if UNITY_EDITOR
|
||||
if (phases != null && currentPhaseIndex < phases.Count)
|
||||
{
|
||||
var phase = phases[currentPhaseIndex];
|
||||
if (phase != null)
|
||||
{
|
||||
UnityEditor.Handles.Label(
|
||||
transform.position + Vector3.up * 3f,
|
||||
$"Phase {currentPhaseIndex + 1}/{phases.Count}\n{phase.PhaseName}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/BossEnemy.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/BossEnemy.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4a49d1cf004a0c944be905fe6fabf936
|
||||
123
Assets/_Game/Scripts/Enemy/BossPhaseData.cs
Normal file
123
Assets/_Game/Scripts/Enemy/BossPhaseData.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unity.Behavior;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 보스 페이즈 전환 조건 타입
|
||||
/// </summary>
|
||||
public enum PhaseTransitionType
|
||||
{
|
||||
HealthPercent, // 체력 비율 기반
|
||||
TimeElapsed, // 시간 경과
|
||||
CustomCondition, // 커스텀 조건 (코드에서 설정)
|
||||
Manual, // 수동 전환
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스 페이즈 데이터. 각 페이즈의 AI, 조건, 보상을 정의합니다.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "NewBossPhase", menuName = "Colosseum/Boss Phase")]
|
||||
public class BossPhaseData : ScriptableObject
|
||||
{
|
||||
[Header("페이즈 정보")]
|
||||
[SerializeField] private string phaseName = "Phase 1";
|
||||
[TextArea(1, 3)]
|
||||
[SerializeField] private string description;
|
||||
|
||||
[Header("전환 조건")]
|
||||
[SerializeField] private PhaseTransitionType transitionType = PhaseTransitionType.HealthPercent;
|
||||
|
||||
[Tooltip("체력 비율 기반 전환 시, 이 비율 이하에서 페이즈 전환")]
|
||||
[Range(0f, 1f)] [SerializeField] private float healthPercentThreshold = 0.7f;
|
||||
|
||||
[Tooltip("시간 기반 전환 시, 경과 시간 (초)")]
|
||||
[Min(0f)] [SerializeField] private float timeThreshold = 60f;
|
||||
|
||||
[Tooltip("커스텀 조건 ID (코드에서 사용)")]
|
||||
[SerializeField] private string customConditionId;
|
||||
|
||||
[Header("AI 설정")]
|
||||
[Tooltip("이 페이즈에서 사용할 Behavior Graph")]
|
||||
[SerializeField] private BehaviorGraph behaviorGraph;
|
||||
|
||||
[Tooltip("페이즈 전환 시 Blackboard 변수 오버라이드")]
|
||||
[SerializeField] private List<BlackboardVariableOverride> blackboardOverrides = new();
|
||||
|
||||
[Header("페이즈 효과")]
|
||||
[Tooltip("페이즈 시작 시 재생할 애니메이션")]
|
||||
[SerializeField] private AnimationClip phaseStartAnimation;
|
||||
|
||||
[Tooltip("페이즈 전환 효과 (이펙트, 사운드 등)")]
|
||||
[SerializeField] private GameObject phaseTransitionEffect;
|
||||
|
||||
// Properties
|
||||
public string PhaseName => phaseName;
|
||||
public string Description => description;
|
||||
public PhaseTransitionType TransitionType => transitionType;
|
||||
public float HealthPercentThreshold => healthPercentThreshold;
|
||||
public float TimeThreshold => timeThreshold;
|
||||
public string CustomConditionId => customConditionId;
|
||||
public BehaviorGraph BehaviorGraph => behaviorGraph;
|
||||
public IReadOnlyList<BlackboardVariableOverride> BlackboardOverrides => blackboardOverrides;
|
||||
public AnimationClip PhaseStartAnimation => phaseStartAnimation;
|
||||
public GameObject PhaseTransitionEffect => phaseTransitionEffect;
|
||||
|
||||
/// <summary>
|
||||
/// 전환 조건 충족 여부 확인
|
||||
/// </summary>
|
||||
public bool CheckTransitionCondition(BossEnemy boss, float elapsedTime)
|
||||
{
|
||||
return transitionType switch
|
||||
{
|
||||
PhaseTransitionType.HealthPercent => boss.CurrentHealth / boss.MaxHealth <= healthPercentThreshold,
|
||||
PhaseTransitionType.TimeElapsed => elapsedTime >= timeThreshold,
|
||||
PhaseTransitionType.CustomCondition => boss.CheckCustomCondition(customConditionId),
|
||||
PhaseTransitionType.Manual => false,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blackboard 변수 오버라이드 정보
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class BlackboardVariableOverride
|
||||
{
|
||||
[Tooltip("변수 이름")]
|
||||
[SerializeField] private string variableName;
|
||||
|
||||
[Tooltip("변수 타입")]
|
||||
[SerializeField] private BlackboardVariableType variableType = BlackboardVariableType.Float;
|
||||
|
||||
[Tooltip("설정할 값")]
|
||||
[SerializeField] private float floatValue;
|
||||
[SerializeField] private int intValue;
|
||||
[SerializeField] private bool boolValue;
|
||||
[SerializeField] private string stringValue;
|
||||
[SerializeField] private GameObject gameObjectValue;
|
||||
|
||||
public string VariableName => variableName;
|
||||
public BlackboardVariableType VariableType => variableType;
|
||||
public float FloatValue => floatValue;
|
||||
public int IntValue => intValue;
|
||||
public bool BoolValue => boolValue;
|
||||
public string StringValue => stringValue;
|
||||
public GameObject GameObjectValue => gameObjectValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blackboard 변수 타입
|
||||
/// </summary>
|
||||
public enum BlackboardVariableType
|
||||
{
|
||||
Float,
|
||||
Int,
|
||||
Bool,
|
||||
String,
|
||||
GameObject,
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/BossPhaseData.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/BossPhaseData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e9dd028b74b2124895ac9673115a9b9
|
||||
126
Assets/_Game/Scripts/Enemy/EnemyAnimationController.cs
Normal file
126
Assets/_Game/Scripts/Enemy/EnemyAnimationController.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 적 캐릭터 애니메이션 컨트롤러
|
||||
/// NavMeshAgent 속도에 따라 Idle/Move 애니메이션 제어
|
||||
/// </summary>
|
||||
public class EnemyAnimationController : NetworkBehaviour
|
||||
{
|
||||
[Header("Animation Parameters")]
|
||||
[SerializeField] private string speedParam = "Speed";
|
||||
[SerializeField] private string attackTriggerParam = "Attack";
|
||||
[SerializeField] private string skillTriggerParam = "Skill";
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float speedSmoothTime = 0.1f;
|
||||
[SerializeField] private float stopThreshold = 0.05f;
|
||||
|
||||
private Animator animator;
|
||||
private UnityEngine.AI.NavMeshAgent navMeshAgent;
|
||||
private EnemyBase enemyBase;
|
||||
private float currentSpeed;
|
||||
private float speedVelocity;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
animator = GetComponent<Animator>();
|
||||
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
enemyBase = GetComponent<EnemyBase>();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (!IsServer)
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
|
||||
UpdateAnimationParameters();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 애니메이션 파라미터 업데이트
|
||||
/// </summary>
|
||||
private void UpdateAnimationParameters()
|
||||
{
|
||||
// 사망 상태에서는 애니메이션 파라미터 업데이트 중단
|
||||
if (enemyBase != null && enemyBase.IsDead)
|
||||
return;
|
||||
|
||||
if (animator == null || navMeshAgent == null)
|
||||
return;
|
||||
|
||||
// NavMeshAgent의 속도를 기반으로 타겟 속도 계산
|
||||
float targetSpeed = navMeshAgent.velocity.magnitude;
|
||||
|
||||
// 정지 임계값 처리
|
||||
if (targetSpeed < stopThreshold)
|
||||
targetSpeed = 0f;
|
||||
|
||||
// 부드러운 속도 변화
|
||||
currentSpeed = Mathf.SmoothDamp(currentSpeed, targetSpeed, ref speedVelocity, speedSmoothTime);
|
||||
|
||||
// 애니메이터 파라미터 설정
|
||||
animator.SetFloat(speedParam, currentSpeed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 공격 애니메이션 트리거 (외부에서 호출)
|
||||
/// </summary>
|
||||
public void PlayAttack()
|
||||
{
|
||||
if (!IsServer || animator == null)
|
||||
return;
|
||||
|
||||
// 사망 상태에서는 공격 애니메이션 재생하지 않음
|
||||
if (enemyBase != null && enemyBase.IsDead)
|
||||
return;
|
||||
|
||||
animator.SetTrigger(attackTriggerParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 애니메이션 트리거 (외부에서 호출)
|
||||
/// </summary>
|
||||
public void PlaySkill()
|
||||
{
|
||||
if (!IsServer || animator == null)
|
||||
return;
|
||||
|
||||
// 사망 상태에서는 스킬 애니메이션 재생하지 않음
|
||||
if (enemyBase != null && enemyBase.IsDead)
|
||||
return;
|
||||
|
||||
animator.SetTrigger(skillTriggerParam);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 애니메이션 트리거 재생
|
||||
/// </summary>
|
||||
/// <param name="triggerName">트리거 파라미터 이름</param>
|
||||
public void PlayTrigger(string triggerName)
|
||||
{
|
||||
if (!IsServer || animator == null)
|
||||
return;
|
||||
|
||||
// 사망 상태에서는 일반 애니메이션 재생하지 않음
|
||||
if (enemyBase != null && enemyBase.IsDead)
|
||||
return;
|
||||
|
||||
animator.SetTrigger(triggerName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 애니메이션 속도
|
||||
/// </summary>
|
||||
public float CurrentSpeed => currentSpeed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5443105bfa8e570438bb5dc62c58aca9
|
||||
227
Assets/_Game/Scripts/Enemy/EnemyBase.cs
Normal file
227
Assets/_Game/Scripts/Enemy/EnemyBase.cs
Normal file
@@ -0,0 +1,227 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
using Colosseum.Stats;
|
||||
using Colosseum.Combat;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 적 캐릭터 기본 클래스.
|
||||
/// 네트워크 동기화, 스탯 관리, 대미지 처리를 담당합니다.
|
||||
/// </summary>
|
||||
public class EnemyBase : NetworkBehaviour, IDamageable
|
||||
{
|
||||
[Header("References")]
|
||||
[Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")]
|
||||
[SerializeField] protected CharacterStats characterStats;
|
||||
[Tooltip("Animator 컴포넌트")]
|
||||
[SerializeField] protected Animator animator;
|
||||
[Tooltip("NavMeshAgent 또는 이동 컴포넌트")]
|
||||
[SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent;
|
||||
|
||||
[Header("Data")]
|
||||
[SerializeField] protected EnemyData enemyData;
|
||||
|
||||
|
||||
|
||||
// 네트워크 동기화 변수
|
||||
protected NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
|
||||
protected NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
|
||||
protected NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
|
||||
|
||||
// 이벤트
|
||||
public event Action<float, float> OnHealthChanged; // currentHealth, maxHealth
|
||||
public event Action<float> OnDamageTaken; // damage
|
||||
public event Action OnDeath;
|
||||
|
||||
// Properties
|
||||
public float CurrentHealth => currentHealth.Value;
|
||||
public float MaxHealth => characterStats != null ? characterStats.MaxHealth : 100f;
|
||||
public float CurrentMana => currentMana.Value;
|
||||
public float MaxMana => characterStats != null ? characterStats.MaxMana : 50f;
|
||||
public bool IsDead => isDead.Value;
|
||||
public CharacterStats Stats => characterStats;
|
||||
public EnemyData Data => enemyData;
|
||||
public Animator Animator => animator;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
// 컴포넌트 참조 확인
|
||||
if (characterStats == null)
|
||||
characterStats = GetComponent<CharacterStats>();
|
||||
if (animator == null)
|
||||
animator = GetComponentInChildren<Animator>();
|
||||
if (navMeshAgent == null)
|
||||
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
|
||||
|
||||
// 서버에서 초기화
|
||||
if (IsServer)
|
||||
{
|
||||
InitializeStats();
|
||||
}
|
||||
|
||||
// 클라이언트에서 체력 변화 감지
|
||||
currentHealth.OnValueChanged += OnHealthChangedInternal;
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
currentHealth.OnValueChanged -= OnHealthChangedInternal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스탯 초기화 (서버에서만 실행)
|
||||
/// </summary>
|
||||
protected virtual void InitializeStats()
|
||||
{
|
||||
if (enemyData != null && characterStats != null)
|
||||
{
|
||||
enemyData.ApplyBaseStats(characterStats);
|
||||
}
|
||||
|
||||
// NavMeshAgent 속도 설정
|
||||
if (navMeshAgent != null && enemyData != null)
|
||||
{
|
||||
navMeshAgent.speed = enemyData.MoveSpeed;
|
||||
navMeshAgent.angularSpeed = enemyData.RotationSpeed;
|
||||
}
|
||||
|
||||
currentHealth.Value = MaxHealth;
|
||||
currentMana.Value = MaxMana;
|
||||
isDead.Value = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 적용 (서버에서 실행)
|
||||
/// </summary>
|
||||
public virtual float TakeDamage(float damage, object source = null)
|
||||
{
|
||||
if (!IsServer || isDead.Value)
|
||||
return 0f;
|
||||
|
||||
float actualDamage = Mathf.Min(damage, currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
|
||||
|
||||
OnDamageTaken?.Invoke(actualDamage);
|
||||
|
||||
// 대미지 피드백 (애니메이션, 이펙트 등)
|
||||
OnTakeDamageFeedback(actualDamage, source);
|
||||
|
||||
if (currentHealth.Value <= 0f)
|
||||
{
|
||||
HandleDeath();
|
||||
}
|
||||
|
||||
return actualDamage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 피드백 (애니메이션, 이펙트)
|
||||
/// </summary>
|
||||
protected virtual void OnTakeDamageFeedback(float damage, object source)
|
||||
{
|
||||
if (animator != null)
|
||||
{
|
||||
animator.SetTrigger("Hit");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 체력 회복 (서버에서 실행)
|
||||
/// </summary>
|
||||
public virtual float Heal(float amount)
|
||||
{
|
||||
if (!IsServer || isDead.Value)
|
||||
return 0f;
|
||||
|
||||
float oldHealth = currentHealth.Value;
|
||||
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
|
||||
float actualHeal = currentHealth.Value - oldHealth;
|
||||
|
||||
return actualHeal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사망 애니메이션 재생 (모든 클라이언트에서 실행)
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Everyone)]
|
||||
private void PlayDeathAnimationRpc()
|
||||
{
|
||||
if (animator != null)
|
||||
{
|
||||
// EnemyAnimationController 비활성화 (더 이상 애니메이션 제어하지 않음)
|
||||
var animController = GetComponent<EnemyAnimationController>();
|
||||
if (animController != null)
|
||||
{
|
||||
animController.enabled = false;
|
||||
}
|
||||
|
||||
// 모든 트리거 리셋
|
||||
animator.ResetTrigger("Attack");
|
||||
animator.ResetTrigger("Skill");
|
||||
animator.ResetTrigger("Hit");
|
||||
animator.ResetTrigger("Jump");
|
||||
animator.ResetTrigger("Land");
|
||||
animator.ResetTrigger("Die");
|
||||
|
||||
// 즉시 Die 상태로 전환 (다른 애니메이션 중단)
|
||||
animator.Play("Die", 0, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사망 처리 (서버에서 실행)
|
||||
/// </summary>
|
||||
protected virtual void HandleDeath()
|
||||
{
|
||||
isDead.Value = true;
|
||||
|
||||
// 실행 중인 스킬 즉시 취소
|
||||
var skillController = GetComponent<Colosseum.Skills.SkillController>();
|
||||
if (skillController != null)
|
||||
{
|
||||
skillController.CancelSkill();
|
||||
}
|
||||
|
||||
// 모든 클라이언트에서 사망 애니메이션 재생
|
||||
PlayDeathAnimationRpc();
|
||||
|
||||
if (navMeshAgent != null)
|
||||
{
|
||||
navMeshAgent.isStopped = true;
|
||||
}
|
||||
|
||||
OnDeath?.Invoke();
|
||||
|
||||
Debug.Log($"[Enemy] {name} died!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 리스폰
|
||||
/// </summary>
|
||||
public virtual void Respawn()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
|
||||
isDead.Value = false;
|
||||
InitializeStats();
|
||||
|
||||
if (navMeshAgent != null)
|
||||
{
|
||||
navMeshAgent.isStopped = false;
|
||||
}
|
||||
|
||||
if (animator != null)
|
||||
{
|
||||
animator.Rebind();
|
||||
}
|
||||
}
|
||||
|
||||
// 체력 변화 이벤트 전파
|
||||
private void OnHealthChangedInternal(float oldValue, float newValue)
|
||||
{
|
||||
OnHealthChanged?.Invoke(newValue, MaxHealth);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/EnemyBase.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/EnemyBase.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d928c3a8adf0b424886395e6864ce010
|
||||
68
Assets/_Game/Scripts/Enemy/EnemyData.cs
Normal file
68
Assets/_Game/Scripts/Enemy/EnemyData.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using UnityEngine;
|
||||
using Colosseum.Stats;
|
||||
|
||||
namespace Colosseum.Enemy
|
||||
{
|
||||
/// <summary>
|
||||
/// 적 캐릭터 데이터. 기본 스탯과 보상을 정의합니다.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "NewEnemyData", menuName = "Colosseum/Enemy Data")]
|
||||
public class EnemyData : ScriptableObject
|
||||
{
|
||||
[Header("기본 정보")]
|
||||
[SerializeField] private string enemyName;
|
||||
[TextArea(2, 4)]
|
||||
[SerializeField] private string description;
|
||||
[SerializeField] private Sprite icon;
|
||||
|
||||
[Header("기본 스탯")]
|
||||
[Min(1f)] [SerializeField] private float baseStrength = 10f;
|
||||
[Min(1f)] [SerializeField] private float baseDexterity = 10f;
|
||||
[Min(1f)] [SerializeField] private float baseIntelligence = 10f;
|
||||
[Min(1f)] [SerializeField] private float baseVitality = 10f;
|
||||
[Min(1f)] [SerializeField] private float baseWisdom = 10f;
|
||||
[Min(1f)] [SerializeField] private float baseSpirit = 10f;
|
||||
|
||||
[Header("이동")]
|
||||
[Min(0f)] [SerializeField] private float moveSpeed = 3f;
|
||||
[Min(0f)] [SerializeField] private float rotationSpeed = 10f;
|
||||
|
||||
[Header("전투")]
|
||||
[Min(0f)] [SerializeField] private float aggroRange = 10f;
|
||||
[Min(0f)] [SerializeField] private float attackRange = 2f;
|
||||
[Min(0f)] [SerializeField] private float attackCooldown = 1f;
|
||||
|
||||
// Properties
|
||||
public string EnemyName => enemyName;
|
||||
public string Description => description;
|
||||
public Sprite Icon => icon;
|
||||
|
||||
public float BaseStrength => baseStrength;
|
||||
public float BaseDexterity => baseDexterity;
|
||||
public float BaseIntelligence => baseIntelligence;
|
||||
public float BaseVitality => baseVitality;
|
||||
public float BaseWisdom => baseWisdom;
|
||||
public float BaseSpirit => baseSpirit;
|
||||
|
||||
public float MoveSpeed => moveSpeed;
|
||||
public float RotationSpeed => rotationSpeed;
|
||||
public float AggroRange => aggroRange;
|
||||
public float AttackRange => attackRange;
|
||||
public float AttackCooldown => attackCooldown;
|
||||
|
||||
/// <summary>
|
||||
/// CharacterStats에 기본 스탯 적용
|
||||
/// </summary>
|
||||
public void ApplyBaseStats(CharacterStats stats)
|
||||
{
|
||||
if (stats == null) return;
|
||||
|
||||
stats.Strength.BaseValue = baseStrength;
|
||||
stats.Dexterity.BaseValue = baseDexterity;
|
||||
stats.Intelligence.BaseValue = baseIntelligence;
|
||||
stats.Vitality.BaseValue = baseVitality;
|
||||
stats.Wisdom.BaseValue = baseWisdom;
|
||||
stats.Spirit.BaseValue = baseSpirit;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Enemy/EnemyData.cs.meta
Normal file
2
Assets/_Game/Scripts/Enemy/EnemyData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ecdc2379b078b246a0bd5c0fb58e346
|
||||
1928
Assets/_Game/Scripts/InputSystem_Actions.cs
Normal file
1928
Assets/_Game/Scripts/InputSystem_Actions.cs
Normal file
File diff suppressed because it is too large
Load Diff
2
Assets/_Game/Scripts/InputSystem_Actions.cs.meta
Normal file
2
Assets/_Game/Scripts/InputSystem_Actions.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b884d077dc9f2543a6b5cca0957e6d6
|
||||
1072
Assets/_Game/Scripts/InputSystem_Actions.inputactions
Normal file
1072
Assets/_Game/Scripts/InputSystem_Actions.inputactions
Normal file
File diff suppressed because it is too large
Load Diff
14
Assets/_Game/Scripts/InputSystem_Actions.inputactions.meta
Normal file
14
Assets/_Game/Scripts/InputSystem_Actions.inputactions.meta
Normal file
@@ -0,0 +1,14 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 052faaac586de48259a63d0c4782560b
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 11500000, guid: 8404be70184654265930450def6a9037, type: 3}
|
||||
generateWrapperCode: 1
|
||||
wrapperCodePath:
|
||||
wrapperClassName:
|
||||
wrapperCodeNamespace:
|
||||
8
Assets/_Game/Scripts/Network.meta
Normal file
8
Assets/_Game/Scripts/Network.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee7b4209244032546a087316c8f2f2ed
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
45
Assets/_Game/Scripts/Network/NetworkLauncher.cs
Normal file
45
Assets/_Game/Scripts/Network/NetworkLauncher.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
|
||||
namespace Colosseum.Network
|
||||
{
|
||||
/// <summary>
|
||||
/// 네트워크 연결 관리 (Host/Client 시작)
|
||||
/// </summary>
|
||||
public class NetworkLauncher : MonoBehaviour
|
||||
{
|
||||
[Header("UI References")]
|
||||
[SerializeField] private GameObject connectionUI;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// 게임 시작 시 UI 표시
|
||||
if (connectionUI != null)
|
||||
connectionUI.SetActive(true);
|
||||
}
|
||||
|
||||
public void StartHost()
|
||||
{
|
||||
NetworkManager.Singleton.StartHost();
|
||||
HideConnectionUI();
|
||||
}
|
||||
|
||||
public void StartClient()
|
||||
{
|
||||
NetworkManager.Singleton.StartClient();
|
||||
HideConnectionUI();
|
||||
}
|
||||
|
||||
public void StartServer()
|
||||
{
|
||||
NetworkManager.Singleton.StartServer();
|
||||
HideConnectionUI();
|
||||
}
|
||||
|
||||
private void HideConnectionUI()
|
||||
{
|
||||
if (connectionUI != null)
|
||||
connectionUI.SetActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Network/NetworkLauncher.cs.meta
Normal file
2
Assets/_Game/Scripts/Network/NetworkLauncher.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a94a6fa23cedaf545aea2b1057800149
|
||||
56
Assets/_Game/Scripts/Network/PlayerSpawnPoint.cs
Normal file
56
Assets/_Game/Scripts/Network/PlayerSpawnPoint.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Colosseum.Network
|
||||
{
|
||||
/// <summary>
|
||||
/// 플레이어 스폰 위치 마커
|
||||
/// 씬에 배치하여 플레이어가 스폰될 위치를 지정
|
||||
/// </summary>
|
||||
public class PlayerSpawnPoint : MonoBehaviour
|
||||
{
|
||||
[Header("Spawn Settings")]
|
||||
[SerializeField] private bool useRotation = true;
|
||||
|
||||
private static System.Collections.Generic.List<PlayerSpawnPoint> spawnPoints = new System.Collections.Generic.List<PlayerSpawnPoint>();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
spawnPoints.Add(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
spawnPoints.Remove(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용 가능한 스폰 포인트 중 하나를 반환
|
||||
/// </summary>
|
||||
public static Transform GetRandomSpawnPoint()
|
||||
{
|
||||
if (spawnPoints.Count == 0)
|
||||
return null;
|
||||
|
||||
int index = Random.Range(0, spawnPoints.Count);
|
||||
return spawnPoints[index].transform;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스폰 포인트 개수 반환
|
||||
/// </summary>
|
||||
public static int SpawnPointCount => spawnPoints.Count;
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
Gizmos.color = Color.green;
|
||||
Gizmos.DrawWireSphere(transform.position, 0.5f);
|
||||
Gizmos.color = Color.blue;
|
||||
Gizmos.DrawLine(transform.position, transform.position + transform.forward * 1.5f);
|
||||
|
||||
// 화살표 머리
|
||||
Vector3 arrowPos = transform.position + transform.forward * 1.5f;
|
||||
Gizmos.DrawLine(arrowPos, arrowPos - transform.forward * 0.3f + transform.right * 0.2f);
|
||||
Gizmos.DrawLine(arrowPos, arrowPos - transform.forward * 0.3f - transform.right * 0.2f);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Network/PlayerSpawnPoint.cs.meta
Normal file
2
Assets/_Game/Scripts/Network/PlayerSpawnPoint.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aba9a2eb2571da14d901ed51c8866f47
|
||||
8
Assets/_Game/Scripts/Player.meta
Normal file
8
Assets/_Game/Scripts/Player.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b58faa7a971b2c4aa9cadf4132ac7a6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
98
Assets/_Game/Scripts/Player/PlayerAnimationController.cs
Normal file
98
Assets/_Game/Scripts/Player/PlayerAnimationController.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
|
||||
namespace Colosseum.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 플레이어 애니메이션 컨트롤러
|
||||
/// 이동 속도에 따라 Idle/Walk/Run 애니메이션 제어
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Animator))]
|
||||
[RequireComponent(typeof(PlayerMovement))]
|
||||
public class PlayerAnimationController : NetworkBehaviour
|
||||
{
|
||||
[Header("Animation Parameters")]
|
||||
[SerializeField] private string speedParam = "Speed";
|
||||
[SerializeField] private string isGroundedParam = "IsGrounded";
|
||||
[SerializeField] private string jumpTriggerParam = "Jump";
|
||||
[SerializeField] private string landTriggerParam = "Land";
|
||||
|
||||
[Header("Settings")]
|
||||
[SerializeField] private float speedSmoothTime = 0.1f;
|
||||
|
||||
private Animator animator;
|
||||
private PlayerMovement playerMovement;
|
||||
private CharacterController characterController;
|
||||
private float currentSpeed;
|
||||
private float speedVelocity;
|
||||
private bool wasGrounded = true;
|
||||
private bool isJumpingAnimation;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
animator = GetComponent<Animator>();
|
||||
playerMovement = GetComponent<PlayerMovement>();
|
||||
characterController = GetComponent<CharacterController>();
|
||||
}
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (!IsOwner)
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsOwner) return;
|
||||
|
||||
UpdateAnimationParameters();
|
||||
}
|
||||
|
||||
private void UpdateAnimationParameters()
|
||||
{
|
||||
// PlayerMovement에서 직접 속도 가져오기
|
||||
float targetSpeed = playerMovement.CurrentMoveSpeed;
|
||||
|
||||
// 부드러운 속도 변화
|
||||
currentSpeed = Mathf.SmoothDamp(currentSpeed, targetSpeed, ref speedVelocity, speedSmoothTime);
|
||||
|
||||
// 지면 접촉 상태
|
||||
bool isGrounded = characterController.isGrounded;
|
||||
|
||||
// 착지 감지 (공중에서 지면으로)
|
||||
if (!wasGrounded && isGrounded && isJumpingAnimation)
|
||||
{
|
||||
PlayLand();
|
||||
}
|
||||
|
||||
// 애니메이터 파라미터 설정
|
||||
animator.SetFloat(speedParam, currentSpeed);
|
||||
animator.SetBool(isGroundedParam, isGrounded);
|
||||
|
||||
wasGrounded = isGrounded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 점프 애니메이션 트리거 (외부에서 호출)
|
||||
/// </summary>
|
||||
public void PlayJump()
|
||||
{
|
||||
if (IsOwner)
|
||||
{
|
||||
isJumpingAnimation = true;
|
||||
animator.SetTrigger(jumpTriggerParam);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 착지 애니메이션 트리거
|
||||
/// </summary>
|
||||
private void PlayLand()
|
||||
{
|
||||
isJumpingAnimation = false;
|
||||
animator.SetTrigger(landTriggerParam);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dafca28b13e62ee43893a43187dc3535
|
||||
162
Assets/_Game/Scripts/Player/PlayerCamera.cs
Normal file
162
Assets/_Game/Scripts/Player/PlayerCamera.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace Colosseum.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 3인칭 카메라 컨트롤러
|
||||
/// </summary>
|
||||
public class PlayerCamera : MonoBehaviour
|
||||
{
|
||||
[Header("Camera Settings")]
|
||||
[SerializeField] private float distance = 5f;
|
||||
[SerializeField] private float height = 2f;
|
||||
[SerializeField] private float rotationSpeed = 2f;
|
||||
[SerializeField] private float minPitch = -30f;
|
||||
[SerializeField] private float maxPitch = 60f;
|
||||
|
||||
private Transform target;
|
||||
private float yaw;
|
||||
private float pitch;
|
||||
private Camera cameraInstance;
|
||||
private InputSystem_Actions inputActions;
|
||||
private bool isSpectating = false;
|
||||
|
||||
public Transform Target => target;
|
||||
public bool IsSpectating => isSpectating;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
// 씬 로드 시 카메라 참조 갱신
|
||||
RefreshCamera();
|
||||
SnapToTarget();
|
||||
|
||||
Debug.Log($"[PlayerCamera] Scene loaded, camera refreshed");
|
||||
}
|
||||
|
||||
public void Initialize(Transform playerTransform, InputSystem_Actions actions)
|
||||
{
|
||||
target = playerTransform;
|
||||
inputActions = actions;
|
||||
isSpectating = false;
|
||||
|
||||
// 기존 메인 카메라 사용 또는 새로 생성
|
||||
cameraInstance = Camera.main;
|
||||
if (cameraInstance == null)
|
||||
{
|
||||
var cameraObject = new GameObject("PlayerCamera");
|
||||
cameraInstance = cameraObject.AddComponent<Camera>();
|
||||
cameraObject.tag = "MainCamera";
|
||||
}
|
||||
|
||||
// 초기 각도
|
||||
if (target != null)
|
||||
{
|
||||
yaw = target.eulerAngles.y;
|
||||
}
|
||||
pitch = 20f;
|
||||
|
||||
// 카메라 위치를 즉시 타겟 위치로 초기화
|
||||
SnapToTarget();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 관전 대상 변경
|
||||
/// </summary>
|
||||
public void SetTarget(Transform newTarget)
|
||||
{
|
||||
if (newTarget == null) return;
|
||||
|
||||
target = newTarget;
|
||||
isSpectating = true;
|
||||
|
||||
// 부드러운 전환을 위해 현재 카메라 위치에서 새 타겟으로
|
||||
yaw = target.eulerAngles.y;
|
||||
|
||||
Debug.Log($"[PlayerCamera] Now spectating: {target.name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 원래 플레이어로 복귀
|
||||
/// </summary>
|
||||
public void ResetToPlayer(Transform playerTransform)
|
||||
{
|
||||
target = playerTransform;
|
||||
isSpectating = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 카메라 위치를 타겟 위치로 즉시 이동 (부드러운 전환 없이)
|
||||
/// </summary>
|
||||
public void SnapToTarget()
|
||||
{
|
||||
if (target == null || cameraInstance == null) return;
|
||||
|
||||
Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f);
|
||||
Vector3 offset = rotation * new Vector3(0f, 0f, -distance);
|
||||
offset.y += height;
|
||||
|
||||
cameraInstance.transform.position = target.position + offset;
|
||||
cameraInstance.transform.LookAt(target.position + Vector3.up * height * 0.5f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 카메라 참조 갱신 (씬 전환 후 호출)
|
||||
/// </summary>
|
||||
public void RefreshCamera()
|
||||
{
|
||||
// 씬 전환 시 항상 새 카메라 참조 획득
|
||||
cameraInstance = Camera.main;
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
// 카메라 참조가 없으면 갱신 시도
|
||||
if (cameraInstance == null)
|
||||
{
|
||||
RefreshCamera();
|
||||
}
|
||||
|
||||
if (target == null || cameraInstance == null) return;
|
||||
|
||||
HandleRotation();
|
||||
UpdateCameraPosition();
|
||||
}
|
||||
|
||||
private void HandleRotation()
|
||||
{
|
||||
if (inputActions == null) return;
|
||||
|
||||
// Input Actions에서 Look 입력 받기
|
||||
Vector2 lookInput = inputActions.Player.Look.ReadValue<Vector2>();
|
||||
float mouseX = lookInput.x * rotationSpeed * 0.1f;
|
||||
float mouseY = lookInput.y * rotationSpeed * 0.1f;
|
||||
|
||||
yaw += mouseX;
|
||||
pitch -= mouseY;
|
||||
pitch = Mathf.Clamp(pitch, minPitch, maxPitch);
|
||||
}
|
||||
|
||||
private void UpdateCameraPosition()
|
||||
{
|
||||
// 구면 좌표로 카메라 위치 계산
|
||||
Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f);
|
||||
Vector3 offset = rotation * new Vector3(0f, 0f, -distance);
|
||||
offset.y += height;
|
||||
|
||||
cameraInstance.transform.position = target.position + offset;
|
||||
cameraInstance.transform.LookAt(target.position + Vector3.up * height * 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Player/PlayerCamera.cs.meta
Normal file
2
Assets/_Game/Scripts/Player/PlayerCamera.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ffd5f6c47d39e94f92515c69e69a9a1
|
||||
393
Assets/_Game/Scripts/Player/PlayerMovement.cs
Normal file
393
Assets/_Game/Scripts/Player/PlayerMovement.cs
Normal file
@@ -0,0 +1,393 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unity.Netcode;
|
||||
using Colosseum.Network;
|
||||
using Colosseum.Skills;
|
||||
|
||||
namespace Colosseum.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 3인칭 플레이어 이동 (네트워크 동기화)
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CharacterController))]
|
||||
public class PlayerMovement : NetworkBehaviour
|
||||
{
|
||||
[Header("Movement Settings")]
|
||||
[SerializeField] private float moveSpeed = 5f;
|
||||
[SerializeField] private float rotationSpeed = 10f;
|
||||
[SerializeField] private float gravity = -9.81f;
|
||||
|
||||
[Header("Jump Settings")]
|
||||
[SerializeField] private float jumpForce = 5f;
|
||||
|
||||
[Header("References")]
|
||||
[SerializeField] private SkillController skillController;
|
||||
[SerializeField] private Animator animator;
|
||||
|
||||
private CharacterController characterController;
|
||||
private Vector3 velocity;
|
||||
private Vector2 moveInput;
|
||||
private InputSystem_Actions inputActions;
|
||||
private bool isJumping;
|
||||
private bool wasGrounded;
|
||||
|
||||
// 적 충돌 방향 (이동 차단용)
|
||||
private Vector3 blockedDirection;
|
||||
private int enemyLayerMask;
|
||||
|
||||
/// <summary>
|
||||
/// 현재 이동 속도 (애니메이션용)
|
||||
/// </summary>
|
||||
public float CurrentMoveSpeed => moveInput.magnitude * moveSpeed;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 현재 지면 접촉 상태
|
||||
/// </summary>
|
||||
public bool IsGrounded => characterController != null ? characterController.isGrounded : false;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 점프 중 상태
|
||||
/// </summary>
|
||||
public bool IsJumping => isJumping;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
// 로컬 플레이어가 아니면 입력 비활성화
|
||||
if (!IsOwner)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
characterController = GetComponent<CharacterController>();
|
||||
|
||||
// SkillController 참조
|
||||
if (skillController == null)
|
||||
{
|
||||
skillController = GetComponent<SkillController>();
|
||||
}
|
||||
|
||||
// Animator 참조
|
||||
if (animator == null)
|
||||
{
|
||||
animator = GetComponentInChildren<Animator>();
|
||||
}
|
||||
|
||||
// 스폰 포인트에서 위치 설정
|
||||
SetSpawnPosition();
|
||||
|
||||
// Input Actions 초기화
|
||||
InitializeInputActions();
|
||||
|
||||
// 카메라 설정
|
||||
SetupCamera();
|
||||
|
||||
// 적 레이어 마스크 초기화
|
||||
enemyLayerMask = LayerMask.GetMask("Enemy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 입력 액션 초기화
|
||||
/// </summary>
|
||||
private void InitializeInputActions()
|
||||
{
|
||||
inputActions = new InputSystem_Actions();
|
||||
inputActions.Player.Enable();
|
||||
|
||||
// Move 액션 콜백 등록
|
||||
inputActions.Player.Move.performed += OnMovePerformed;
|
||||
inputActions.Player.Move.canceled += OnMoveCanceled;
|
||||
|
||||
// Jump 액션 콜백 등록
|
||||
inputActions.Player.Jump.performed += OnJumpPerformed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 입력 액션 해제
|
||||
/// </summary>
|
||||
private void CleanupInputActions()
|
||||
{
|
||||
if (inputActions != null)
|
||||
{
|
||||
inputActions.Player.Move.performed -= OnMovePerformed;
|
||||
inputActions.Player.Move.canceled -= OnMoveCanceled;
|
||||
inputActions.Player.Jump.performed -= OnJumpPerformed;
|
||||
inputActions.Player.Disable();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// 컴포넌트 비활성화 시 입력 해제
|
||||
CleanupInputActions();
|
||||
|
||||
// 입력 초기화
|
||||
moveInput = Vector2.zero;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// 컴포넌트 재활성화 시 입력 다시 등록
|
||||
if (IsOwner && inputActions != null)
|
||||
{
|
||||
inputActions.Player.Enable();
|
||||
inputActions.Player.Move.performed += OnMovePerformed;
|
||||
inputActions.Player.Move.canceled += OnMoveCanceled;
|
||||
inputActions.Player.Jump.performed += OnJumpPerformed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스폰 위치 설정
|
||||
/// </summary>
|
||||
private void SetSpawnPosition()
|
||||
{
|
||||
Transform spawnPoint = PlayerSpawnPoint.GetRandomSpawnPoint();
|
||||
|
||||
if (spawnPoint != null)
|
||||
{
|
||||
// CharacterController 비활성화 후 위치 설정 (충돌 문제 방지)
|
||||
characterController.enabled = false;
|
||||
transform.position = spawnPoint.position;
|
||||
transform.rotation = spawnPoint.rotation;
|
||||
characterController.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 네트워크 정리
|
||||
/// </summary>
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
CleanupInputActions();
|
||||
}
|
||||
|
||||
private void OnMovePerformed(InputAction.CallbackContext context)
|
||||
{
|
||||
moveInput = context.ReadValue<Vector2>();
|
||||
}
|
||||
|
||||
private void OnMoveCanceled(InputAction.CallbackContext context)
|
||||
{
|
||||
moveInput = Vector2.zero;
|
||||
}
|
||||
|
||||
private void OnJumpPerformed(InputAction.CallbackContext context)
|
||||
{
|
||||
if (!isJumping && characterController.isGrounded)
|
||||
{
|
||||
Jump();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupCamera()
|
||||
{
|
||||
var cameraController = GetComponent<PlayerCamera>();
|
||||
if (cameraController == null)
|
||||
{
|
||||
cameraController = gameObject.AddComponent<PlayerCamera>();
|
||||
}
|
||||
cameraController.Initialize(transform, inputActions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 카메라 재설정 (씬 로드 후 호출)
|
||||
/// </summary>
|
||||
public void RefreshCamera()
|
||||
{
|
||||
SetupCamera();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!IsOwner) return;
|
||||
|
||||
ApplyGravity();
|
||||
Move();
|
||||
}
|
||||
|
||||
private void ApplyGravity()
|
||||
{
|
||||
if (wasGrounded && velocity.y < 0)
|
||||
{
|
||||
velocity.y = -2f;
|
||||
}
|
||||
else
|
||||
{
|
||||
velocity.y += gravity * Time.deltaTime;
|
||||
}
|
||||
}
|
||||
|
||||
private void Move()
|
||||
{
|
||||
if (characterController == null) return;
|
||||
|
||||
// 스킬 애니메이션 재생 중에는 이동 불가 (루트 모션은 OnAnimatorMove에서 처리)
|
||||
if (skillController != null && skillController.IsPlayingAnimation)
|
||||
{
|
||||
// 루트 모션을 사용하지 않는 경우 중력만 적용
|
||||
if (!skillController.UsesRootMotion)
|
||||
{
|
||||
characterController.Move(velocity * Time.deltaTime);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 이동 방향 계산 (카메라 기준)
|
||||
Vector3 moveDirection = new Vector3(moveInput.x, 0f, moveInput.y);
|
||||
moveDirection = TransformDirectionByCamera(moveDirection);
|
||||
moveDirection.Normalize();
|
||||
|
||||
// 충돌 방향으로의 이동 차단 (미끄러짐 방지)
|
||||
if (blockedDirection != Vector3.zero)
|
||||
{
|
||||
float blockedAmount = Vector3.Dot(moveDirection, blockedDirection);
|
||||
if (blockedAmount > 0f)
|
||||
{
|
||||
moveDirection -= blockedDirection * blockedAmount;
|
||||
moveDirection.Normalize();
|
||||
}
|
||||
}
|
||||
|
||||
// 이동 적용
|
||||
Vector3 moveVector = moveDirection * moveSpeed * Time.deltaTime;
|
||||
characterController.Move(moveVector + velocity * Time.deltaTime);
|
||||
|
||||
// 충돌 방향 리셋
|
||||
blockedDirection = Vector3.zero;
|
||||
|
||||
// 회전 (이동 중일 때만)
|
||||
if (moveDirection != Vector3.zero)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(moveDirection);
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
|
||||
}
|
||||
|
||||
// 착지 체크 (Move 후에 isGrounded가 업데이트됨)
|
||||
if (!wasGrounded && characterController.isGrounded && isJumping)
|
||||
{
|
||||
OnJumpEnd();
|
||||
}
|
||||
|
||||
// 다음 프레임을 위해 현재 상태 저장
|
||||
wasGrounded = characterController.isGrounded;
|
||||
}
|
||||
|
||||
private void Jump()
|
||||
{
|
||||
isJumping = true;
|
||||
velocity.y = jumpForce;
|
||||
|
||||
// 애니메이션 컨트롤러에 점프 알림
|
||||
var animController = GetComponent<PlayerAnimationController>();
|
||||
if (animController != null)
|
||||
{
|
||||
animController.PlayJump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 점프 중 상태가 끝나면 IsJumping = false;
|
||||
/// </summary>
|
||||
public void OnJumpEnd()
|
||||
{
|
||||
isJumping = false;
|
||||
}
|
||||
|
||||
private Vector3 TransformDirectionByCamera(Vector3 direction)
|
||||
{
|
||||
if (Camera.main == null) return direction;
|
||||
|
||||
Transform cameraTransform = Camera.main.transform;
|
||||
Vector3 cameraForward = cameraTransform.forward;
|
||||
Vector3 cameraRight = cameraTransform.right;
|
||||
|
||||
// Y축 제거
|
||||
cameraForward.y = 0f;
|
||||
cameraRight.y = 0f;
|
||||
cameraForward.Normalize();
|
||||
cameraRight.Normalize();
|
||||
|
||||
return cameraRight * direction.x + cameraForward * direction.z;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 루트 모션 처리. 스킬 애니메이션 중에 애니메이션의 이동/회전 데이터를 적용합니다.
|
||||
/// </summary>
|
||||
private void OnAnimatorMove()
|
||||
{
|
||||
if (!IsOwner) return;
|
||||
if (animator == null || characterController == null) return;
|
||||
if (skillController == null || !skillController.IsPlayingAnimation) return;
|
||||
if (!skillController.UsesRootMotion) return;
|
||||
|
||||
// 루트 모션 이동 적용
|
||||
Vector3 deltaPosition = animator.deltaPosition;
|
||||
|
||||
// Y축 무시 설정 시 중력 유지
|
||||
if (skillController.IgnoreRootMotionY)
|
||||
{
|
||||
deltaPosition.y = 0f;
|
||||
characterController.Move(deltaPosition + velocity * Time.deltaTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
characterController.Move(deltaPosition);
|
||||
}
|
||||
|
||||
// 루트 모션 회전 적용
|
||||
if (animator.deltaRotation != Quaternion.identity)
|
||||
{
|
||||
transform.rotation *= animator.deltaRotation;
|
||||
}
|
||||
|
||||
// 착지 체크
|
||||
if (!wasGrounded && characterController.isGrounded && isJumping)
|
||||
{
|
||||
OnJumpEnd();
|
||||
}
|
||||
|
||||
wasGrounded = characterController.isGrounded;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CharacterController 충돌 처리. 적과 충돌 시 해당 방향 이동을 차단합니다.
|
||||
/// 충돌 normal을 8방향으로 양자화하여 각진 충돌 느낌을 줍니다.
|
||||
/// </summary>
|
||||
private void OnControllerColliderHit(ControllerColliderHit hit)
|
||||
{
|
||||
// 적과의 충돌인지 확인
|
||||
if ((enemyLayerMask & (1 << hit.gameObject.layer)) != 0)
|
||||
{
|
||||
// 충돌 방향 저장 (이동 차단용)
|
||||
blockedDirection = hit.normal;
|
||||
blockedDirection.y = 0f;
|
||||
blockedDirection.Normalize();
|
||||
|
||||
// 8방향으로 양자화 (45도 간격)
|
||||
blockedDirection = QuantizeToOctagon(blockedDirection);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 방향을 8각형(45도 간격) 방향으로 양자화합니다.
|
||||
/// </summary>
|
||||
private Vector3 QuantizeToOctagon(Vector3 direction)
|
||||
{
|
||||
if (direction == Vector3.zero)
|
||||
return direction;
|
||||
|
||||
// 각도 계산
|
||||
float angle = Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg;
|
||||
|
||||
// 45도 단위로 반올림
|
||||
float snappedAngle = Mathf.Round(angle / 45f) * 45f;
|
||||
|
||||
// 다시 벡터로 변환
|
||||
float radians = snappedAngle * Mathf.Deg2Rad;
|
||||
return new Vector3(Mathf.Sin(radians), 0f, Mathf.Cos(radians));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Player/PlayerMovement.cs.meta
Normal file
2
Assets/_Game/Scripts/Player/PlayerMovement.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db1d9c2d6f86e254f9889e2fa9d41e31
|
||||
242
Assets/_Game/Scripts/Player/PlayerNetworkController.cs
Normal file
242
Assets/_Game/Scripts/Player/PlayerNetworkController.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using Unity.Netcode;
|
||||
using Colosseum.Stats;
|
||||
using Colosseum.Combat;
|
||||
|
||||
namespace Colosseum.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 플레이어 네트워크 상태 관리 (HP, MP 등)
|
||||
/// </summary>
|
||||
public class PlayerNetworkController : NetworkBehaviour, IDamageable
|
||||
{
|
||||
[Header("References")]
|
||||
[Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")]
|
||||
[SerializeField] private CharacterStats characterStats;
|
||||
|
||||
// 네트워크 동기화 변수
|
||||
private NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
|
||||
private NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
|
||||
private NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
|
||||
|
||||
public float Health => currentHealth.Value;
|
||||
public float Mana => currentMana.Value;
|
||||
public float MaxHealth => characterStats != null ? characterStats.MaxHealth : 100f;
|
||||
public float MaxMana => characterStats != null ? characterStats.MaxMana : 50f;
|
||||
public CharacterStats Stats => characterStats;
|
||||
|
||||
// 체력/마나 변경 이벤트
|
||||
public event Action<float, float> OnHealthChanged; // (oldValue, newValue)
|
||||
public event Action<float, float> OnManaChanged; // (oldValue, newValue)
|
||||
|
||||
// 사망 이벤트
|
||||
public event Action<PlayerNetworkController> OnDeath;
|
||||
public event Action<bool> OnDeathStateChanged; // (isDead)
|
||||
|
||||
// IDamageable 구현
|
||||
public float CurrentHealth => currentHealth.Value;
|
||||
public bool IsDead => isDead.Value;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
// CharacterStats 참조 확인
|
||||
if (characterStats == null)
|
||||
{
|
||||
characterStats = GetComponent<CharacterStats>();
|
||||
}
|
||||
|
||||
// 네트워크 변수 변경 콜백 등록
|
||||
currentHealth.OnValueChanged += HandleHealthChanged;
|
||||
currentMana.OnValueChanged += HandleManaChanged;
|
||||
isDead.OnValueChanged += HandleDeathStateChanged;
|
||||
|
||||
// 초기화
|
||||
if (IsServer)
|
||||
{
|
||||
currentHealth.Value = MaxHealth;
|
||||
currentMana.Value = MaxMana;
|
||||
isDead.Value = false;
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
// 콜백 해제
|
||||
currentHealth.OnValueChanged -= HandleHealthChanged;
|
||||
currentMana.OnValueChanged -= HandleManaChanged;
|
||||
isDead.OnValueChanged -= HandleDeathStateChanged;
|
||||
}
|
||||
|
||||
private void HandleHealthChanged(float oldValue, float newValue)
|
||||
{
|
||||
OnHealthChanged?.Invoke(oldValue, newValue);
|
||||
}
|
||||
|
||||
private void HandleManaChanged(float oldValue, float newValue)
|
||||
{
|
||||
OnManaChanged?.Invoke(oldValue, newValue);
|
||||
}
|
||||
|
||||
private void HandleDeathStateChanged(bool oldValue, bool newValue)
|
||||
{
|
||||
OnDeathStateChanged?.Invoke(newValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대미지 적용 (서버에서만 실행)
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
public void TakeDamageRpc(float damage)
|
||||
{
|
||||
if (isDead.Value) return;
|
||||
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - damage);
|
||||
|
||||
if (currentHealth.Value <= 0f)
|
||||
{
|
||||
HandleDeath();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마나 소모 (서버에서만 실행)
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
public void UseManaRpc(float amount)
|
||||
{
|
||||
currentMana.Value = Mathf.Max(0f, currentMana.Value - amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 체력 회복 (서버에서만 실행)
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
public void RestoreHealthRpc(float amount)
|
||||
{
|
||||
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마나 회복 (서버에서만 실행)
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
public void RestoreManaRpc(float amount)
|
||||
{
|
||||
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사망 애니메이션 재생 (모든 클라이언트에서 실행)
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Everyone)]
|
||||
private void PlayDeathAnimationRpc()
|
||||
{
|
||||
var animator = GetComponentInChildren<Animator>();
|
||||
if (animator != null)
|
||||
{
|
||||
animator.SetTrigger("Die");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사망 처리 (서버에서만 실행)
|
||||
/// </summary>
|
||||
private void HandleDeath()
|
||||
{
|
||||
if (isDead.Value) return;
|
||||
|
||||
isDead.Value = true;
|
||||
|
||||
// 이동 비활성화
|
||||
var movement = GetComponent<PlayerMovement>();
|
||||
if (movement != null)
|
||||
{
|
||||
movement.enabled = false;
|
||||
}
|
||||
|
||||
// 스킬 입력 비활성화
|
||||
var skillInput = GetComponent<PlayerSkillInput>();
|
||||
if (skillInput != null)
|
||||
{
|
||||
skillInput.enabled = false;
|
||||
}
|
||||
|
||||
// 모든 클라이언트에서 사망 애니메이션 재생
|
||||
PlayDeathAnimationRpc();
|
||||
|
||||
// 사망 이벤트 발생
|
||||
OnDeath?.Invoke(this);
|
||||
|
||||
Debug.Log($"[Player] Player {OwnerClientId} died!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 리스폰 (서버에서만 실행)
|
||||
/// </summary>
|
||||
public void Respawn()
|
||||
{
|
||||
if (!IsServer) return;
|
||||
|
||||
isDead.Value = false;
|
||||
currentHealth.Value = MaxHealth;
|
||||
currentMana.Value = MaxMana;
|
||||
|
||||
// 이동 재활성화
|
||||
var movement = GetComponent<PlayerMovement>();
|
||||
if (movement != null)
|
||||
{
|
||||
movement.enabled = true;
|
||||
}
|
||||
|
||||
// 스킬 입력 재활성화
|
||||
var skillInput = GetComponent<PlayerSkillInput>();
|
||||
if (skillInput != null)
|
||||
{
|
||||
skillInput.enabled = true;
|
||||
}
|
||||
|
||||
// 애니메이션 리셋
|
||||
var animator = GetComponentInChildren<Animator>();
|
||||
if (animator != null)
|
||||
{
|
||||
animator.Rebind();
|
||||
}
|
||||
|
||||
Debug.Log($"[Player] Player {OwnerClientId} respawned!");
|
||||
}
|
||||
|
||||
#region IDamageable
|
||||
/// <summary>
|
||||
/// 대미지 적용 (서버에서만 호출)
|
||||
/// </summary>
|
||||
public float TakeDamage(float damage, object source = null)
|
||||
{
|
||||
if (!IsServer || isDead.Value) return 0f;
|
||||
|
||||
float actualDamage = Mathf.Min(damage, currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - damage);
|
||||
|
||||
if (currentHealth.Value <= 0f)
|
||||
{
|
||||
HandleDeath();
|
||||
}
|
||||
|
||||
return actualDamage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 체력 회복 (서버에서만 호출)
|
||||
/// </summary>
|
||||
public float Heal(float amount)
|
||||
{
|
||||
if (!IsServer || isDead.Value) return 0f;
|
||||
|
||||
float actualHeal = Mathf.Min(amount, MaxHealth - currentHealth.Value);
|
||||
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
|
||||
|
||||
return actualHeal;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f5b322d198fc60a41ae175ea9c58a337
|
||||
246
Assets/_Game/Scripts/Player/PlayerSkillInput.cs
Normal file
246
Assets/_Game/Scripts/Player/PlayerSkillInput.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
using Unity.Netcode;
|
||||
|
||||
using Colosseum.Skills;
|
||||
using Colosseum.Weapons;
|
||||
|
||||
namespace Colosseum.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 플레이어 스킬 입력 처리.
|
||||
/// 논타겟 방식: 입력 시 즉시 스킬 시전
|
||||
/// </summary>
|
||||
public class PlayerSkillInput : NetworkBehaviour
|
||||
{
|
||||
[Header("Skill Slots")]
|
||||
[Tooltip("각 슬롯에 등록할 스킬 데이터 (6개)")]
|
||||
[SerializeField] private SkillData[] skillSlots = new SkillData[6];
|
||||
|
||||
[Header("References")]
|
||||
[Tooltip("SkillController (없으면 자동 검색)")]
|
||||
[SerializeField] private SkillController skillController;
|
||||
[Tooltip("PlayerNetworkController (없으면 자동 검색)")]
|
||||
[SerializeField] private PlayerNetworkController networkController;
|
||||
[Tooltip("WeaponEquipment (없으면 자동 검색)")]
|
||||
[SerializeField] private WeaponEquipment weaponEquipment;
|
||||
|
||||
private InputSystem_Actions inputActions;
|
||||
|
||||
public SkillData[] SkillSlots => skillSlots;
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (!IsOwner)
|
||||
{
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// SkillController 참조 확인
|
||||
if (skillController == null)
|
||||
{
|
||||
skillController = GetComponent<SkillController>();
|
||||
if (skillController == null)
|
||||
{
|
||||
Debug.LogError("PlayerSkillInput: SkillController not found!");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// PlayerNetworkController 참조 확인
|
||||
if (networkController == null)
|
||||
{
|
||||
networkController = GetComponent<PlayerNetworkController>();
|
||||
}
|
||||
|
||||
// WeaponEquipment 참조 확인
|
||||
if (weaponEquipment == null)
|
||||
{
|
||||
weaponEquipment = GetComponent<WeaponEquipment>();
|
||||
}
|
||||
|
||||
InitializeInputActions();
|
||||
}
|
||||
|
||||
private void InitializeInputActions()
|
||||
{
|
||||
inputActions = new InputSystem_Actions();
|
||||
inputActions.Player.Enable();
|
||||
|
||||
// 스킬 액션 콜백 등록
|
||||
inputActions.Player.Skill1.performed += _ => OnSkillInput(0);
|
||||
inputActions.Player.Skill2.performed += _ => OnSkillInput(1);
|
||||
inputActions.Player.Skill3.performed += _ => OnSkillInput(2);
|
||||
inputActions.Player.Skill4.performed += _ => OnSkillInput(3);
|
||||
inputActions.Player.Skill5.performed += _ => OnSkillInput(4);
|
||||
inputActions.Player.Skill6.performed += _ => OnSkillInput(5);
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (inputActions != null)
|
||||
{
|
||||
inputActions.Player.Skill1.performed -= _ => OnSkillInput(0);
|
||||
inputActions.Player.Skill2.performed -= _ => OnSkillInput(1);
|
||||
inputActions.Player.Skill3.performed -= _ => OnSkillInput(2);
|
||||
inputActions.Player.Skill4.performed -= _ => OnSkillInput(3);
|
||||
inputActions.Player.Skill5.performed -= _ => OnSkillInput(4);
|
||||
inputActions.Player.Skill6.performed -= _ => OnSkillInput(5);
|
||||
inputActions.Disable();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 입력 처리
|
||||
/// </summary>
|
||||
private void OnSkillInput(int slotIndex)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
|
||||
return;
|
||||
|
||||
SkillData skill = skillSlots[slotIndex];
|
||||
if (skill == null)
|
||||
{
|
||||
Debug.Log($"Skill slot {slotIndex + 1} is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
// 사망 상태 체크
|
||||
if (networkController != null && networkController.IsDead)
|
||||
return;
|
||||
|
||||
// 로컬 체크 (빠른 피드백용)
|
||||
if (skillController.IsExecutingSkill)
|
||||
{
|
||||
Debug.Log($"Already executing skill");
|
||||
return;
|
||||
}
|
||||
|
||||
if (skillController.IsOnCooldown(skill))
|
||||
{
|
||||
Debug.Log($"Skill {skill.SkillName} is on cooldown");
|
||||
return;
|
||||
}
|
||||
|
||||
// 마나 비용 체크 (무기 배율 적용)
|
||||
float actualManaCost = GetActualManaCost(skill);
|
||||
if (networkController != null && networkController.Mana < actualManaCost)
|
||||
{
|
||||
Debug.Log($"Not enough mana for skill: {skill.SkillName}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버에 스킬 실행 요청
|
||||
RequestSkillExecutionRpc(slotIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서버에 스킬 실행 요청
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server)]
|
||||
private void RequestSkillExecutionRpc(int slotIndex)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
|
||||
return;
|
||||
|
||||
SkillData skill = skillSlots[slotIndex];
|
||||
if (skill == null) return;
|
||||
|
||||
// 서버에서 다시 검증
|
||||
// 사망 상태 체크
|
||||
if (networkController != null && networkController.IsDead)
|
||||
return;
|
||||
|
||||
if (skillController.IsExecutingSkill || skillController.IsOnCooldown(skill))
|
||||
return;
|
||||
|
||||
// 마나 비용 체크 (무기 배율 적용)
|
||||
float actualManaCost = GetActualManaCost(skill);
|
||||
if (networkController != null && networkController.Mana < actualManaCost)
|
||||
return;
|
||||
|
||||
// 마나 소모 (무기 배율 적용)
|
||||
if (networkController != null && actualManaCost > 0)
|
||||
{
|
||||
networkController.UseManaRpc(actualManaCost);
|
||||
}
|
||||
|
||||
// 모든 클라이언트에 스킬 실행 전파
|
||||
BroadcastSkillExecutionRpc(slotIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 클라이언트에 스킬 실행 전파
|
||||
/// </summary>
|
||||
[Rpc(SendTo.ClientsAndHost)]
|
||||
private void BroadcastSkillExecutionRpc(int slotIndex)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
|
||||
return;
|
||||
|
||||
SkillData skill = skillSlots[slotIndex];
|
||||
if (skill == null) return;
|
||||
|
||||
// 모든 클라이언트에서 스킬 실행 (애니메이션 포함)
|
||||
skillController.ExecuteSkill(skill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 무기 마나 배율이 적용된 실제 마나 비용 계산
|
||||
/// </summary>
|
||||
private float GetActualManaCost(SkillData skill)
|
||||
{
|
||||
if (skill == null) return 0f;
|
||||
|
||||
float baseCost = skill.ManaCost;
|
||||
float multiplier = weaponEquipment != null ? weaponEquipment.ManaCostMultiplier : 1f;
|
||||
|
||||
return baseCost * multiplier;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 슬롯 접근자
|
||||
/// </summary>
|
||||
public SkillData GetSkill(int slotIndex)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
|
||||
return null;
|
||||
return skillSlots[slotIndex];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 슬롯 변경
|
||||
/// </summary>
|
||||
public void SetSkill(int slotIndex, SkillData skill)
|
||||
{
|
||||
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
|
||||
return;
|
||||
|
||||
skillSlots[slotIndex] = skill;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 남은 쿨타임 조회
|
||||
/// </summary>
|
||||
public float GetRemainingCooldown(int slotIndex)
|
||||
{
|
||||
SkillData skill = GetSkill(slotIndex);
|
||||
if (skill == null) return 0f;
|
||||
|
||||
return skillController.GetRemainingCooldown(skill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스킬 사용 가능 여부
|
||||
/// </summary>
|
||||
public bool CanUseSkill(int slotIndex)
|
||||
{
|
||||
SkillData skill = GetSkill(slotIndex);
|
||||
if (skill == null) return false;
|
||||
|
||||
return !skillController.IsOnCooldown(skill) && !skillController.IsExecutingSkill;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Player/PlayerSkillInput.cs.meta
Normal file
2
Assets/_Game/Scripts/Player/PlayerSkillInput.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c5b5422385dda854cbe61b01950f06da
|
||||
195
Assets/_Game/Scripts/Player/PlayerSpectator.cs
Normal file
195
Assets/_Game/Scripts/Player/PlayerSpectator.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Colosseum.Player;
|
||||
|
||||
namespace Colosseum.Player
|
||||
{
|
||||
/// <summary>
|
||||
/// 플레이어 관전 시스템.
|
||||
/// 사망한 플레이어가 살아있는 플레이어를 관찰할 수 있게 합니다.
|
||||
/// </summary>
|
||||
public class PlayerSpectator : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[Tooltip("PlayerCamera 컴포넌트")]
|
||||
[SerializeField] private PlayerCamera playerCamera;
|
||||
|
||||
[Tooltip("PlayerNetworkController 컴포넌트")]
|
||||
[SerializeField] private PlayerNetworkController networkController;
|
||||
|
||||
[Header("Spectate Settings")]
|
||||
[Tooltip("관전 대상 전환 키")]
|
||||
[SerializeField] private KeyCode nextTargetKey = KeyCode.Tab;
|
||||
|
||||
[Tooltip("관전 UI 표시 여부")]
|
||||
[SerializeField] private bool showSpectateUI = true;
|
||||
|
||||
// 관전 상태
|
||||
private bool isSpectating = false;
|
||||
private int currentSpectateIndex = 0;
|
||||
private List<PlayerNetworkController> alivePlayers = new List<PlayerNetworkController>();
|
||||
|
||||
// 이벤트
|
||||
public event System.Action<bool> OnSpectateModeChanged; // (isSpectating)
|
||||
public event System.Action<PlayerNetworkController> OnSpectateTargetChanged;
|
||||
|
||||
// Properties
|
||||
public bool IsSpectating => isSpectating;
|
||||
public PlayerNetworkController CurrentTarget => alivePlayers.Count > currentSpectateIndex ? alivePlayers[currentSpectateIndex] : null;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 컴포넌트 자동 참조
|
||||
if (playerCamera == null)
|
||||
playerCamera = GetComponent<PlayerCamera>();
|
||||
if (networkController == null)
|
||||
networkController = GetComponentInParent<PlayerNetworkController>();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (networkController != null)
|
||||
{
|
||||
networkController.OnDeathStateChanged += HandleDeathStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (networkController != null)
|
||||
{
|
||||
networkController.OnDeathStateChanged -= HandleDeathStateChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!isSpectating) return;
|
||||
|
||||
// Tab 키로 다음 관전 대상 전환
|
||||
if (Input.GetKeyDown(nextTargetKey))
|
||||
{
|
||||
CycleToNextTarget();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDeathStateChanged(bool dead)
|
||||
{
|
||||
if (dead)
|
||||
{
|
||||
StartSpectating();
|
||||
}
|
||||
else
|
||||
{
|
||||
StopSpectating();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 관전 모드 시작
|
||||
/// </summary>
|
||||
private void StartSpectating()
|
||||
{
|
||||
// 살아있는 플레이어 목록 갱신
|
||||
RefreshAlivePlayers();
|
||||
|
||||
if (alivePlayers.Count == 0)
|
||||
{
|
||||
// 관전할 플레이어가 없음 (게임 오버)
|
||||
Debug.Log("[PlayerSpectator] No alive players to spectate");
|
||||
return;
|
||||
}
|
||||
|
||||
isSpectating = true;
|
||||
currentSpectateIndex = 0;
|
||||
|
||||
// 첫 번째 살아있는 플레이어 관전
|
||||
SetSpectateTarget(alivePlayers[currentSpectateIndex]);
|
||||
|
||||
OnSpectateModeChanged?.Invoke(true);
|
||||
|
||||
Debug.Log($"[PlayerSpectator] Started spectating. {alivePlayers.Count} players alive.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 관전 모드 종료
|
||||
/// </summary>
|
||||
private void StopSpectating()
|
||||
{
|
||||
isSpectating = false;
|
||||
alivePlayers.Clear();
|
||||
currentSpectateIndex = 0;
|
||||
|
||||
// 원래 플레이어로 카메라 복귀
|
||||
if (playerCamera != null && networkController != null)
|
||||
{
|
||||
playerCamera.ResetToPlayer(networkController.transform);
|
||||
}
|
||||
|
||||
OnSpectateModeChanged?.Invoke(false);
|
||||
|
||||
Debug.Log("[PlayerSpectator] Stopped spectating");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 살아있는 플레이어 목록 갱신
|
||||
/// </summary>
|
||||
private void RefreshAlivePlayers()
|
||||
{
|
||||
alivePlayers.Clear();
|
||||
|
||||
var allPlayers = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
|
||||
foreach (var player in allPlayers)
|
||||
{
|
||||
// 자신이 아니고, 살아있는 플레이어만 추가
|
||||
if (player != networkController && !player.IsDead)
|
||||
{
|
||||
alivePlayers.Add(player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다음 관전 대상으로 전환
|
||||
/// </summary>
|
||||
private void CycleToNextTarget()
|
||||
{
|
||||
if (alivePlayers.Count == 0) return;
|
||||
|
||||
// 목록 갱신 (중간에 사망했을 수 있음)
|
||||
RefreshAlivePlayers();
|
||||
|
||||
if (alivePlayers.Count == 0)
|
||||
{
|
||||
Debug.Log("[PlayerSpectator] No more alive players");
|
||||
return;
|
||||
}
|
||||
|
||||
currentSpectateIndex = (currentSpectateIndex + 1) % alivePlayers.Count;
|
||||
SetSpectateTarget(alivePlayers[currentSpectateIndex]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 관전 대상 설정
|
||||
/// </summary>
|
||||
private void SetSpectateTarget(PlayerNetworkController target)
|
||||
{
|
||||
if (target == null || playerCamera == null) return;
|
||||
|
||||
playerCamera.SetTarget(target.transform);
|
||||
OnSpectateTargetChanged?.Invoke(target);
|
||||
|
||||
Debug.Log($"[PlayerSpectator] Now spectating: Player {target.OwnerClientId}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 현재 관전 대상 이름 반환 (UI용)
|
||||
/// </summary>
|
||||
public string GetCurrentTargetName()
|
||||
{
|
||||
var target = CurrentTarget;
|
||||
if (target == null) return "None";
|
||||
return $"Player {target.OwnerClientId}";
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Game/Scripts/Player/PlayerSpectator.cs.meta
Normal file
2
Assets/_Game/Scripts/Player/PlayerSpectator.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ace7240dc9e5c834892fc1a0e4ea657e
|
||||
8
Assets/_Game/Scripts/Skills.meta
Normal file
8
Assets/_Game/Scripts/Skills.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5d5453fda75bcc743a40d05357360fa5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/_Game/Scripts/Skills/Effects.meta
Normal file
8
Assets/_Game/Scripts/Skills/Effects.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1401ae499769cb64c9eca36823c46714
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user