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:
2026-03-16 19:08:27 +09:00
parent 309bf5f48b
commit c265f980db
17251 changed files with 2630777 additions and 206 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: dd7f6bc7e9d2e9140802e1bc3a3ebffd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7648a6805082131489f501769b3c0f18
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 09f6a44dd1b90c348a0dfb17312c5804
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14296786101ccd742ac9f752f1fd3393

View File

@@ -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;
}
}

View File

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

View File

@@ -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()
{
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3d2c81704b2be4c4289bcf5555059b87

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3bc57afe07d75aa4e80ef5394b57f774

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 33182146e0ade8443a539cf7780735e2

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 77cf4f2ff35fe3040af16374f428a648

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 02cd3ab41f67bf344b667b6a0c12a4d0

View File

@@ -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;
}
}

View File

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

View File

@@ -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;
}
}

View File

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

View File

@@ -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;
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 324b69471491c9448ae5d71a426dd596

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 422be729ebdf10b43b44b80b8593258a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

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

View File

@@ -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;
}
}
}

View File

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

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 10007872b79b2b641980c0d8dfd4f6a4

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0035b82da5a602d44b552684970273a8

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3abe3d08ab4653b43a5a1708770fd3a1

View 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;
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bc6434195fb88a443939a5a0b2747f0a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}

View File

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

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a766b6ab825c1445a3385079bb32cc5

View 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);
}
}
}
}

View File

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

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 597e1695ce4bb584f96f8673b0cf7a6a
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cfe0b5a63c8965949b1c8885d4232e7a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7bde02fc6ca2ab0468bb3ce777206089

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 33a0f0f245adbf64791b38c182c48062

View 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;
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d3cd76473ef5dcf44afccfab5fbdbfc6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 867ffff975b9a7a4694783f4a5ee1c6e

View 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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 088a15576b464764b85a18f4dacb1a43

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ba44fce3757f70c42acd5b75c3ced354
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9598f5b3fb42a1945ab57c2dc55b2815
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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");
}
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8f6dc132c1fce114da1ae74c46fd57dd
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1735054b0ca6d674b99668aeb74ba273

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4a49d1cf004a0c944be905fe6fabf936

View 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,
}
}

View File

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

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5443105bfa8e570438bb5dc62c58aca9

View 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);
}
}
}

View File

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

View 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;
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View 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:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee7b4209244032546a087316c8f2f2ed
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

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

View 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);
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9b58faa7a971b2c4aa9cadf4132ac7a6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

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

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3ffd5f6c47d39e94f92515c69e69a9a1

View 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));
}
}
}

View File

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

View 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
}
}

View File

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

View 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;
}
}
}

View File

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

View 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}";
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5d5453fda75bcc743a40d05357360fa5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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