• 느린 통신 환경에도 대응되는 캐릭터의 공격 구현의 개선
  • 네트웍 최적화를 위한 다양한 고려 사항의 이해

 

 

인프런 이득우님의 '언리얼 프로그래밍 Part3 - 네트웍 멀티플레이 프레임웍의 이해' 강의를 참고하였습니다. 
😎 [이득우의 언리얼 프로그래밍 Part3 - 네트웍 멀티플레이 프레임웍의 이해] 강의 들으러 가기!

 

 

 

목차

     

     


     

     

    캐릭터 공격 구현의 개선


     

     

    목표

     

    의도적으로 패킷 랙을 유발시켜서 기존에 구현한 공격 기능의 문제점을 살펴보자.

    캐릭터의 공격 구현의 개선하자.


     

     

    캐릭터의 공격 구현의 문제점

     

    • 클라이언트의 모든 행동은 서버를 거친 후에 수행되도록 설계되어 있음
    • 통신 부하가 발생하는 경우 사용자 경험이 나빠짐.
      • 만약에 통신 랙이 심한 경우에는 사용자가 공격 명령을 입력해도 애니메이션이 늦게 재생되거나 시각적으로 보여지는 타이밍에서의 판정이 클라이언트와 서버가 서로 일치하지 않는 경우가 발생할 수 있다.
    • 의도적으로 패킷 랙을 발생시킨 후 이의 결과를 확인.

     

     

    DefaultEngine.ini

    학습을 위해 DefaultEngine.ini에 위의 키워드를 작성하여 의도적으로 패킷 통신 랙유발시킨다.

    이렇게 지정하게 되면 에디터에서는 0.5초(=500ms)의 딜레이를 가지고 패킷을 전송한다.


     

     

    기존에 구현한 공격 기능의 리뷰

     

    랙이 발생하는 경우, 클라이언트의 반응이 많이 느려지는 문제가 발생

    • 패킷 랙이 발생하면 입력서버로 전달되는 과정에서 시간오래 소요됨.
    • 서버에서 클라이언트다시 전달되는 과정에서 시간중복으로 소요됨. 이 중복으로 소요되는 기간 동안에 입력을 넣었지만 애니메이션이 재생하지 않고 대기하고 있는 상황이 발생하게 된다.
    • 공력을 입력했는데 서버로 응답이 올 때까지 대기하고 있어야 하는 상황이 발생한다.

     

     

    서버에서 모든 것을 처리하는 방식이 데이터 관리 측면에서는 안전한 방법일 수도 있지만 통신 상태가 나쁜 상황에서 사용자는 한 박자 늦은 플레이를 진행할 수밖에 없어서 공정한 플레이를 진행하기가 어렵고 정확한 판정을 내기가 어렵다.


     

     

     

    캐릭터 공격 구현의 개선

     

    [기본 원칙]

    • 클라이언트의 명령은 Server RPC를 사용한다.
    • 중요한 게임 플레이 판정은 서버에서 처리한다.
    • 게임 플레이에 영향을 주는 중요한 정보는 프로퍼티 리플리케이션을 사용한다.
    • 클라이언트의 시각적인 효과(Cosmetic)는 Client RPC와 Multicast RPC를 사용한다.

     

    [개선점]

    • 클라이언트에서 처리할 수 있는 기능 최대한 클라이언트에서 직접 처리하여 반응성을 높인다.
    • 최종 판정 서버에서 진행하되 다양한 로직을 활용해 자세하게 검증한다.
    • 네트웍 데이터 전송을 최소화한다.

     

     

    개선된 공격 기능의 설계

     

    클라이언트의 반응성을 개선하고 서버에서는 클라이언트의 요청을 검증을 통해 구현

     

    • 클라이언트에서 입력 명령 → 서버의 RPC 호출 / 애니메이션 재생 (동시에 진행) 
      • 애니메이션의 타이밍은 살짝 다르게 동작할 수 있다.
      • 중요한 정보는 동기화 되도록 설정 할 것임 → 이를 위해 시간 정보를 활용한다.
    • 클라이언트가 Server RPC를 보낼 때 현재 클라이언트의 시간 정보를 보냄
      • 서버는 패킷을 받은 시간Server RPC를 보낸 시간비교해 얼마나 이 걸렸는지 측정
    • 서버에서 판정을 하는 방법이 아닌 클라이언트에서 판정하는 방법으로 변경
      • 서로 동일한 타이밍에 진행해야 공정하다.
      • 클라이언트에서 판정을 확정하면 위조의 위험성이 있다. (그렇기 때문에 아래의 과정이 필요하다)
    • 클라이언트의 판정 결과를 서버로 보내서 서버에서 판정 결과를 수용할지말지 판단하는 과정을 거친다.
      • 서버가 판정 결과를 수용한다면 액터에게 데미지를 전달한다.

     

     

    서버(=GameMode)의 시간을 가져오는 방법

     

    	GetWorld()->GetGameState()->GetServerWorldTimeSeconds();

     

     

    ※ 참고)  서버의 월드와 클라이언트의 월드는 엄연히 서로 다른 월드다.

     

    클라이언트의 월드는 서버를 Simulate한 월드다.

    우리는 클라이언트는 늦게 생성하기 때문에 클라이언트의 시간은 서버의 시간보다 늦게 흘러갈 수밖에 없다.


     

     

     

     

    실습 예제


     

     

    예제 코드 - 공격 및 피격 판정

     

    ABCharacterPlayer.h

    더보기
    #pragma once
    #include "CoreMinimal.h"
    #include "Character/ABCharacterBase.h"
    #include "InputActionValue.h"
    #include "Interface/ABCharacterHUDInterface.h"
    #include "ABCharacterPlayer.generated.h"
    
    UCLASS()
    class ARENABATTLE_API AABCharacterPlayer : public AABCharacterBase, public IABCharacterHUDInterface
    {
    	GENERATED_BODY()
    	
    public:
    	AABCharacterPlayer();
    
    protected:
    	virtual void BeginPlay() override;
    	virtual void SetDead() override;
    	virtual void PossessedBy(AController* NewController) override;
    	virtual void OnRep_Owner() override;
    	virtual void PostNetInit() override;
    
    public:
    	virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
    
    // Character Control Section
    protected:
    	void ChangeCharacterControl();
    	void SetCharacterControl(ECharacterControlType NewCharacterControlType);
    	virtual void SetCharacterControlData(const class UABCharacterControlData* CharacterControlData) override;
    
    // Camera Section
    protected:
    	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, Meta = (AllowPrivateAccess = "true"))
    	TObjectPtr<class USpringArmComponent> CameraBoom;
    
    	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, Meta = (AllowPrivateAccess = "true"))
    	TObjectPtr<class UCameraComponent> FollowCamera;
    
    // Input Section
    protected:
    	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
    	TObjectPtr<class UInputAction> JumpAction;
    
    	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
    	TObjectPtr<class UInputAction> ChangeControlAction;
    
    	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
    	TObjectPtr<class UInputAction> ShoulderMoveAction;
    
    	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
    	TObjectPtr<class UInputAction> ShoulderLookAction;
    
    	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
    	TObjectPtr<class UInputAction> QuaterMoveAction;
    
    	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
    	TObjectPtr<class UInputAction> AttackAction;
    
    	void ShoulderMove(const FInputActionValue& Value);
    	void ShoulderLook(const FInputActionValue& Value);
    
    	void QuaterMove(const FInputActionValue& Value);
    
    	ECharacterControlType CurrentCharacterControlType;
    
    protected:
    	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
    	void Attack();
    	void PlayAttackAnimation();
    	virtual void AttackHitCheck() override;
    	void AttackHitConfirm(AActor* HitActor);
    	void DrawDebugAttackRange(const FColor& DrawColor, FVector TraceStart, FVector TraceEnd, FVector Forward);
    
    	UFUNCTION(Server, Reliable, WithValidation)
    	void ServerRPCAttack(float AttackStartTime);
    
    	UFUNCTION(NetMulticast, Unreliable)
    	void MulticastRPCAttack();
    
    	UFUNCTION(Client, Unreliable)
    	void ClientRPCPlayAnimation(AABCharacterPlayer* CharacterToPlay);
    
    	UFUNCTION(Server, Reliable, WithValidation)
    	void ServerRPCNotifyHit(const FHitResult& HitResult, float HitCheckTime);
    
    	UFUNCTION(Server, Reliable, WithValidation)
    	void ServerRPCNotifyMiss(FVector_NetQuantize TraceStart, FVector_NetQuantize TraceEnd, FVector_NetQuantizeNormal TraceDir, float HitCheckTime);
    
    	UPROPERTY(ReplicatedUsing = OnRep_CanAttack)
    	uint8 bCanAttack : 1;
    
    	UFUNCTION()
    	void OnRep_CanAttack();
    
    	float AttackTime = 1.4667f;
    	float LastAttackStartTime = 0.0f;
    	float AttackTimeDifference = 0.0f;
    	float AcceptCheckDistance = 300.0f;
    	float AcceptMinCheckTime = 0.15f;
    
    // UI Section
    protected:
    	virtual void SetupHUDWidget(class UABHUDWidget* InHUDWidget) override;
    };

    함수 추가

    • void PlayAttackAnimation();
    • UFUNCTION(Server, ReliableWithValidation)
      void ServerRPCNotifyHit(const FHitResult& HitResult, float HitChecktime); // 클라이언트에서 무언가 액터에 맞았을 때 서버로 판정을 보내는 함수
    • UFUNCTION(Server, ReliableWithValidation)
      void ServerRPCNotifyMiss(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitChecktime);
    • void AttackHitConfirm(AActor* HitActor); //  확인 후 데미지 전달 함수

    함수 수정

    • UFUNCTION(Server, ReliableWithValidation)
      void ServerRPCAttack(float AttackStartTime);
    • UFUNCTION(NetMulticast, Unreliable)
      void MulticastRPCAttack();
    • virtual void AttackHitCheck() override; // 공격 판정

    변수 추가

    • float LastAttackStartTime = 0.0f;     // 마지막에 공격한 시간
    • float AttackTimeDifference = 0.0f;   // 클라이언트와 서버와의 시간 차이
    • float AcceptMinCheckTime = 0.15f; // 최소한 이 시간 만큼은 지난 후에 판정을 진행 

     

     

    ABCharacterPlayer.cpp

    더보기
    #include "Character/ABCharacterPlayer.h"
    
    AABCharacterPlayer::AABCharacterPlayer()
    {
    	// Camera
    	...
    	// Input
    	...
    
    	CurrentCharacterControlType = ECharacterControlType::Quater;
    	bCanAttack = true;
    }
    
    void AABCharacterPlayer::BeginPlay()
    {
    	Super::BeginPlay();
    
    	APlayerController* PlayerController = Cast<APlayerController>(GetController());
    	if (PlayerController)
    	{
    		EnableInput(PlayerController);
    	}
    
    	SetCharacterControl(CurrentCharacterControlType);
    }
    
    void AABCharacterPlayer::SetDead()
    {
    	Super::SetDead();
    
    	APlayerController* PlayerController = Cast<APlayerController>(GetController());
    	if (PlayerController)
    	{
    		DisableInput(PlayerController);
    	}
    }
    
    void AABCharacterPlayer::PossessedBy(AController* NewController)
    {
    	AActor* OwnerActor = GetOwner();
    
    	Super::PossessedBy(NewController);
    
    	OwnerActor = GetOwner();
    }
    
    void AABCharacterPlayer::OnRep_Owner()
    {
    	Super::OnRep_Owner();
    
    	AActor* OwnerActor = GetOwner();
    }
    
    void AABCharacterPlayer::PostNetInit()
    {
    	Super::PostNetInit();
    }
    
    void AABCharacterPlayer::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
    {
    	Super::SetupPlayerInputComponent(PlayerInputComponent);
    
    	UEnhancedInputComponent* EnhancedInputComponent = CastChecked<UEnhancedInputComponent>(PlayerInputComponent);
    	EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Triggered, this, &ACharacter::Jump);
        //...
    }
    
    void AABCharacterPlayer::ChangeCharacterControl()
    {
    	if (CurrentCharacterControlType == ECharacterControlType::Quater)
    	{
    		SetCharacterControl(ECharacterControlType::Shoulder);
    	}
    	else if (CurrentCharacterControlType == ECharacterControlType::Shoulder)
    	{
    		SetCharacterControl(ECharacterControlType::Quater);
    	}
    }
    
    void AABCharacterPlayer::SetCharacterControl(ECharacterControlType NewCharacterControlType)
    {
    	if (!IsLocallyControlled())
    	{
    		return;
    	}
    
    	UABCharacterControlData* NewCharacterControl = CharacterControlManager[NewCharacterControlType];
    	check(NewCharacterControl);
    
    	SetCharacterControlData(NewCharacterControl);
    
    	APlayerController* PlayerController = CastChecked<APlayerController>(GetController());
    	if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer()))
    	{
    		Subsystem->ClearAllMappings();
    		UInputMappingContext* NewMappingContext = NewCharacterControl->InputMappingContext;
    		if (NewMappingContext)
    		{
    			Subsystem->AddMappingContext(NewMappingContext, 0);
    		}
    	}
    
    	CurrentCharacterControlType = NewCharacterControlType;
    }
    
    void AABCharacterPlayer::SetCharacterControlData(const UABCharacterControlData* CharacterControlData) { // ... }
    
    void AABCharacterPlayer::ShoulderMove(const FInputActionValue& Value) { ... }
    void AABCharacterPlayer::ShoulderLook(const FInputActionValue& Value) { ... }
    void AABCharacterPlayer::QuaterMove(const FInputActionValue& Value) { ... }
    
    void AABCharacterPlayer::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
    {
    	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    
    	DOREPLIFETIME(AABCharacterPlayer, bCanAttack);
    }
    
    void AABCharacterPlayer::Attack()
    {
    	//ProcessComboCommand();
    
    	if (bCanAttack)
    	{
    		if (!HasAuthority()) // 클라이언트
    		{
    			bCanAttack = false;
    			GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_None);
    
    			FTimerHandle Handle;
    			GetWorld()->GetTimerManager().SetTimer(Handle, FTimerDelegate::CreateLambda([&]
    				{
    					bCanAttack = true;
    					GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);
    				}
    			), AttackTime, false, -1.0f);
    
    			PlayAttackAnimation(); // 클라이언트에서 공격 몽타주 재생
    		}
    
    		ServerRPCAttack(GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
    	}
    }
    
    void AABCharacterPlayer::PlayAttackAnimation()
    {
    	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    	AnimInstance->StopAllMontages(0.0f);
    	AnimInstance->Montage_Play(ComboActionMontage);
    }
    
    void AABCharacterPlayer::AttackHitCheck() // 공격 판정
    {
    	if (IsLocallyControlled()) // 서버를 포함 플레이어에서 피격 체크를 한다(리슨 서버이니 서버도 플레이어가 있다)  
    	{
    		FHitResult OutHitResult;
    		FCollisionQueryParams Params(SCENE_QUERY_STAT(Attack), false, this);
    
    		const float AttackRange = Stat->GetTotalStat().AttackRange;
    		const float AttackRadius = Stat->GetAttackRadius();
    		const float AttackDamage = Stat->GetTotalStat().Attack;
    		const FVector Forward = GetActorForwardVector();
    		const FVector Start = GetActorLocation() + Forward * GetCapsuleComponent()->GetScaledCapsuleRadius();
    		const FVector End = Start + GetActorForwardVector() * AttackRange;
    
    		bool HitDetected = GetWorld()->SweepSingleByChannel(OutHitResult, Start, End, FQuat::Identity, CCHANNEL_ABACTION, FCollisionShape::MakeSphere(AttackRadius), Params);
    
    		float HitCheckTime = GetWorld()->GetGameState()->GetServerWorldTimeSeconds(); // 서버 기준 피격시간
    		if (!HasAuthority()) // 클라이언트
    		{
            	// 클라이언트의 경우, 서버쪽으로 RPC를 보내서 검증을 거쳐줘야 한다.
    			if (HitDetected) // 맞았다면
    			{
    				ServerRPCNotifyHit(OutHitResult, HitCheckTime);
    			}
    			else // 안 맞았다면
    			{
    				ServerRPCNotifyMiss(Start, End, Forward, HitCheckTime);
    			}
    		}
    		else // 서버 
    		{
            	// 리슨 서버의 서버쪽 플레이어는 검증 절차가 필요없다.
    			FColor DebugColor = HitDetected ? FColor::Green : FColor::Red;
    			DrawDebugAttackRange(DebugColor, Start, End, Forward);
    			if (HitDetected)
    			{
    				AttackHitConfirm(OutHitResult.GetActor());
    			}
    		}
    	}
    }
    
    void AABCharacterPlayer::AttackHitConfirm(AActor* HitActor) // 데미지 전달 함수 
    {
    	if (HasAuthority()) // 서버에서만 실행 
    	{
    		const float AttackDamage = Stat->GetTotalStat().Attack;
    		FDamageEvent DamageEvent;
    		HitActor->TakeDamage(AttackDamage, DamageEvent, GetController(), this);
    	}
    }
    
    void AABCharacterPlayer::DrawDebugAttackRange(const FColor& DrawColor, FVector TraceStart, FVector TraceEnd, FVector Forward)
    {
    #if ENABLE_DRAW_DEBUG
    
    	const float AttackRange = Stat->GetTotalStat().AttackRange;
    	const float AttackRadius = Stat->GetAttackRadius();
    	FVector CapsuleOrigin = TraceStart + (TraceEnd - TraceStart) * 0.5f;
    	float CapsuleHalfHeight = AttackRange * 0.5f;
    	DrawDebugCapsule(GetWorld(), CapsuleOrigin, CapsuleHalfHeight, AttackRadius, FRotationMatrix::MakeFromZ(Forward).ToQuat(), DrawColor, false, 5.0f);
    
    #endif
    }
    
    bool AABCharacterPlayer::ServerRPCAttack_Validate(float AttackStartTime)
    {
    	if (LastAttackStartTime == 0.0f)
    	{
    		return true;
    	}
    
    	return (AttackStartTime - LastAttackStartTime) > AttackTime;
    }
    
    void AABCharacterPlayer::ServerRPCAttack_Implementation(float AttackStartTime)
    {
    	AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));
    
    	bCanAttack = false;
    	OnRep_CanAttack();
    
    	AttackTimeDifference = GetWorld()->GetTimeSeconds() - AttackStartTime;
    	AB_LOG(LogABNetwork, Log, TEXT("LagTime : %f"), AttackTimeDifference);
    	AttackTimeDifference = FMath::Clamp(AttackTimeDifference, 0.0f, AttackTime - 0.01f);
    
    	FTimerHandle Handle;
    	GetWorld()->GetTimerManager().SetTimer(Handle, FTimerDelegate::CreateLambda([&]
    		{
    			bCanAttack = true;
    			OnRep_CanAttack();
    		}
    	), AttackTime - AttackTimeDifference, false, -1.0f);
    
    	LastAttackStartTime = AttackStartTime;
    	PlayAttackAnimation(); // 서버에서 공격 몽타주 재생
    
    	//MulticastRPCAttack();
    	for (APlayerController* PlayerController : TActorRange<APlayerController>(GetWorld()))
    	{
    		if (PlayerController && GetController() != PlayerController)
    		{
    			if(!PlayerController->IsLocalController())
    			{
    				AABCharacterPlayer* OtherPlayer = Cast<AABCharacterPlayer>(PlayerController->GetPawn());
    				if (OtherPlayer)
    				{
    					OtherPlayer->ClientRPCPlayAnimation(this);
    				}
    			}
    		}
    	}
    }
    
    void AABCharacterPlayer::ClientRPCPlayAnimation_Implementation(AABCharacterPlayer* CharacterToPlay)
    {
    	if (CharacterToPlay)
    	{
    		CharacterToPlay->PlayAttackAnimation();
    	}
    }
    
    void AABCharacterPlayer::MulticastRPCAttack_Implementation()
    {
    	if (!IsLocallyControlled())
    	{
    		PlayAttackAnimation();
    	}
    }
    
    bool AABCharacterPlayer::ServerRPCNotifyHit_Validate(const FHitResult& HitResult, float HitCheckTime)
    {
    	return (HitCheckTime - LastAttackStartTime) > AcceptMinCheckTime;
    }
    
    void AABCharacterPlayer::ServerRPCNotifyHit_Implementation(const FHitResult& HitResult, float HitCheckTime)
    {
    	AActor* HitActor = HitResult.GetActor();
    	if (::IsValid(HitActor))
    	{
    		const FVector HitLocation = HitResult.Location;
    		const FBox HitBox = HitActor->GetComponentsBoundingBox(); // 피격 받은 캐릭터를 전체로 감싸고 있는 BoundingBox 영역.
    		const FVector ActorBoxCenter = (HitBox.Min + HitBox.Max) * 0.5f; // BoundingBox의 중점값
    		if (FVector::DistSquared(HitLocation, ActorBoxCenter) <= AcceptCheckDistance * AcceptCheckDistance)
    		{
    			AttackHitConfirm(HitActor); // 피격O
    		}
    		else
    		{
    			AB_LOG(LogABNetwork, Warning, TEXT("%s"), TEXT("HitTest Rejected!")); // 피격X
    		}
    
    #if ENABLE_DRAW_DEBUG
    		DrawDebugPoint(GetWorld(), ActorBoxCenter, 50.0f, FColor::Cyan, false, 5.0f); // 캐릭터 영역 중심
    		DrawDebugPoint(GetWorld(), HitLocation, 50.0f, FColor::Magenta, false, 5.0f); // 맞은 위치
    #endif
    
    		DrawDebugAttackRange(FColor::Green, HitResult.TraceStart, HitResult.TraceEnd, HitActor->GetActorForwardVector());
    	}
    }
    
    bool AABCharacterPlayer::ServerRPCNotifyMiss_Validate(FVector_NetQuantize TraceStart, FVector_NetQuantize TraceEnd, FVector_NetQuantizeNormal TraceDir, float HitCheckTime)
    {
    	return (HitCheckTime - LastAttackStartTime) > AcceptMinCheckTime;
    }
    
    
    void AABCharacterPlayer::ServerRPCNotifyMiss_Implementation(FVector_NetQuantize TraceStart, FVector_NetQuantize TraceEnd, FVector_NetQuantizeNormal TraceDir, float HitCheckTime)
    {
    	DrawDebugAttackRange(FColor::Red, TraceStart, TraceEnd, TraceDir);
    }
    
    void AABCharacterPlayer::OnRep_CanAttack()
    {
    	if (!bCanAttack)
    	{
    		GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_None);
    	}
    	else
    	{
    		GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);
    	}
    }
    
    void AABCharacterPlayer::SetupHUDWidget(UABHUDWidget* InHUDWidget)
    {
    	// ...
    }

     


     

     

     

     

     

     

     

     

     


     

     

    실행화면

     

    서버 캐릭터에 데미지 전달