본문 바로가기

공부/이득우의 언리얼 프로그래밍

[Study] Part 3 - 캐릭터 공격 구현 개선 (10/15)

728x90
반응형

 

 

  • 정리
    1. 네트웍 멀티플레이를 클라이언트의 반응성을 높이는 구조로 설계하는 방법의 학습
    2. 강화된 클라이언트 역할에 맞서 서버쪽 검증 처리를 강화하는 방법의 학습
    3. 네트웍 통신 전송양을 줄이는 여러 기법의 학습

 

  • 현재 코드의 캐릭터 공격 구현의 문제점
    • 클라이언트의 모든 행동은 서버를 거친 후에 수행되도록 설계되어 있음
    • 통신 부하가 발생하는 경우 사용자 경험이 나빠짐
    • 의도적으로 패킷 렉을 발생 시킨 후 결과 확인

 

 

[PacketSimulationSettings]
PktLag = 500
  • DefaultEngine.ini

 

 

의도적으로 패킷 렉을 발생시킨 결과

 

  • 기존에 구현한 공격 기능의 리뷰
    • 렉이 발생하는 경우 클라이언트의 반응이 많이 느려지는 문제가 발생

입력이 서버로 전달되는 과정에 시간이 걸릴 경우 입력이 무시되는 현상이 발생

 

  • 서버로부터 입력에 대한 응답이 올 때까지 대기하고 있어야한다.
  • 서버에서 모든 것을 처리하는 방식이 데이터 관리 측면에서는 안전하지만, 통신 상태가 나쁘다면 사용자는 한 박자 늦은 플레이를 진행할 수 밖에 없어서 정확한 판정을 내기 어렵다.

 

 

  • 캐릭터 공격 구현의 개선을 위해서
    • 기본 원칙
      • 클라이언트의 명령Server RPC를 사용
      • 중요한 게임 플레이 판정은 서버에서 처리
      • 게임 플레이에 영향을 주는 중요한 정보는 프로퍼티 리플리케이션을 사용
      • 클라이언트의 시각적인 효과(Cosmetic)는 Client RPCMulticast RPC를 사용
    • 개선점
      • 클라이언트에서 처리할 수 있는 기능은 최대한 클라이언트에서 직접 처리하여 반응성 높이기
      • 최종 판정은 서버에서 진행하되 다양한 로직을 활용해 자세하게 검증
      • 네트웍 데이터 전송을 최소화

 

 

 

  • 개선된 공격 기능의 설계
    • 클라이언트의 반응성을 개선, 서버에서는 클라이언트의 요청을 검증을 통해 구현

 

개선된 공격 기능의 설계

 

  • 서버는 패킷을 받으면 클라이언트와 동일한 작업을 수행, 이 과정에서 렉이 있기 때문에 한박자 늦게 시작할 것임
  • 그렇기 때문에 패킷을 전달할 때 클라이언트는 시간 정보를 함께 전달해 서버는 받은 시간과 계산하여 얼마나 렉이 걸렸는지를 측정한다.
  • 이를 반영하여 최대한 클라이언트와 동일한 타이밍에 서버에서의 공격이 마치도록 구성
  • 판정도 클라이언트에서 판정을 진행한다. 하지만 확정은 x. 판정 결과를 서버로 보내고 서버에서 이를 수용할지말지 판단하도록

 

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();

	// 판정을 위해 서버로 보낼 RPC
	// Hit판정 RPC
	UFUNCTION(Server, Reliable, WithValidation)
	void ServerRPCNotifyHit(const FHitResult& HitResult, float HitCheckTime);
	// Miss판정 RPC
	UFUNCTION(Server, Reliable, WithValidation)
	void ServerRPCNotifyMiss(FVector TraceStart, FVector TraceEnd, FVector 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;
  • ABCharacterPlayer.h
void AABCharacterPlayer::Attack()
{
	//ProcessComboCommand();

	if (bCanAttack)
	{
		// RPC 함수를 바로 실행하는 것이 아니라 / 서버와 클라이언트 부분을 구분해서 애니메이션과 공격을 바로 실행할 수 있도록 구현
		// 기존에 MulticastRPC에 있던 애니메이션과 공격 로직을 가져와준다.
		// 클라이언트
		if (!HasAuthority())
		{
			// bCanAttack을 변수에서 프로퍼티로 승격시켜 서버에서 해당 값을 바꿀 때 자동으로 모든 클라이언트에 바뀐 값이 전송되도록
			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();
		}
		
		// 현재 월드의 시간을 RPC를 통해 같이 보내주기
		// 주의할 점. 서버와 클라이언트의 월드는 서로 다른 월드이다. 클라이언트는 서버보다 늦게 생성하기 때문에 서버보다 늦게 흘러갈 수밖에 없다. 그렇기 때문에 서버의 시간을 가져와서 넘겨줘야 한다.
		ServerRPCAttack(GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
	}
}

void AABCharacterPlayer::PlayAttackAnimation()
{
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
	AnimInstance->StopAllMontages(0.0f);
	AnimInstance->Montage_Play(ComboActionMontage);
}

// 공격에 대한 판정
void AABCharacterPlayer::AttackHitCheck()
{
	// 서버에서만 동작하도록
	// 소유권을 가진 클라이언트에서 동작하도록 변경
	// HasAuthority()에서 IsLocallyControlled()로 변경
	if (IsLocallyControlled())
	{
		AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));

		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 + Forward * AttackRange;

		bool HitDetected = GetWorld()->SweepSingleByChannel(OutHitResult, Start, End, FQuat::Identity, CCHANNEL_ABACTION, FCollisionShape::MakeSphere(AttackRadius), Params);
		float HitCheckTime = GetWorld()->GetGameState()->GetServerWorldTimeSeconds();

		// 클라 / 서버
		// 클라이언트의 경우 서버 쪽으로 보내 검증을 거쳐줘야 한다.
		// 서버의 경우 그냥 검증해주면 된다.
		// 클라
		if (!HasAuthority())
		{
			if (HitDetected)
			{
				// Hit 판정
				ServerRPCNotifyHit(OutHitResult, HitCheckTime);
			}
			else
			{
				// Miss 판정
				ServerRPCNotifyMiss(Start, End, Forward, HitCheckTime);
			}
		}
		// 서버
		else
		{
			FColor DebugColor = HitDetected ? FColor::Green : FColor::Red;
			DrawDebugAttackRange(DebugColor, Start, End, Forward);

			if (HitDetected)
			{
				AttackHitConfirm(OutHitResult.GetActor());
			}
		}
	}
}

// Attack에 대한 Hit 판정 수용
void AABCharacterPlayer::AttackHitConfirm(AActor* HitActor)
{
	AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));

	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
}

// ServerRPCNotifyHit 검증
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))
	{
		// Hit한 위치와 Hit된 액터의 히트 박스를 가져와 맞았는지 검증하기
		const FVector HitLocation = HitResult.Location;
		const FBox HitBox = HitActor->GetComponentsBoundingBox();
		const FVector ActorBoxCenter = (HitBox.Min + HitBox.Max) * 0.5f;

		if (FVector::DistSquared(HitLocation, ActorBoxCenter) <= AcceptCheckDistance * AcceptCheckDistance)
		{
			AttackHitConfirm(HitActor);
		}
		else
		{
			AB_LOG(LogABNetwork, Warning, TEXT("%s"), TEXT("HitTest Rejected!"));
		}

#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());
	}
}

// ServerRPCNotifyMiss 검증
bool AABCharacterPlayer::ServerRPCNotifyMiss_Validate(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitCheckTime)
{
	return (HitCheckTime - LastAttackStartTime) > AcceptMinCheckTime;
}

void AABCharacterPlayer::ServerRPCNotifyMiss_Implementation(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitCheckTime)
{
	DrawDebugAttackRange(FColor::Red, TraceStart, TraceEnd, TraceDir);
}

// ServerRPCAttack 검증
bool AABCharacterPlayer::ServerRPCAttack_Validate(float AttackStartTime)
{
	// 검증 강화
	if (LastAttackStartTime == 0.0f)
	{
		return true;
	}

	return (AttackStartTime - LastAttackStartTime) > AttackTime;
}

// 신호를 받은 서버는 ServerRPCAttack_Implementation를 실행하게 된다.
void AABCharacterPlayer::ServerRPCAttack_Implementation(float AttackStartTime)
{
	AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));

	// Multicast에서 진행 했던걸 해당 부분에서 진행
	// 
	// bCanAttack을 변수에서 프로퍼티로 승격시켜 서버에서 해당 값을 바꿀 때 자동으로 모든 클라이언트에 바뀐 값이 전송되도록
	bCanAttack = false;

	// OnRep 이벤트 함수의 경우 서버에서는 실행되지 않고, 클라이언트에서만 실행됨.
	// 그렇기 때문에 서버에서는 자동으로 호출되지않기 때문에 명시적으로 호출해줘야 함
	
	// 클라이언트에서는 모션을 재생했기 때문에
	// 서버 RPC에서는 서버에서 해야 될 일을 진행해주면 된다.
	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();

	// 서버는 서버와 모든 클라이언트에게 명령을 보내도록
	// MulticastRPC의 경우 게임에 중요한 내용보다는 무관한 어떤 효과들을 재생하는데 사용하는 것이 좋다.
	MulticastRPCAttack();
}

// Multicast RPC
// 서버는 로컬로 실행이 되기 때문에 바로 호출, 다른 모든 클라이언트는 네트워크를 통해서 패킷이 전송되면 함수가 호출
void AABCharacterPlayer::MulticastRPCAttack_Implementation()
{
	// 다른 클라이언트의 프록시로써 동작하는 캐릭터 플레이어에 대해서만 어택 애니메이션을 구동시키도록
	if (!IsLocallyControlled())
	{
		PlayAttackAnimation();
	}
}
  • ABCharacterPlayer.cpp

 

  • Attack()
    • RPC함수를 바로 실행하지 않고, MulticastRPC함수에 있던 애니메이션과 공격 로직을 가져와준다.
    • 서버와 클라이언트를 구분해서 구현해주고, 서버와 클라이언트는 서로 다른 월드이고 클라이언트는 서버보다 늦게 생성되기 때문에 ServerRPCAttack(GetWorld()->GetGameState()->GetServerWorldTimeSeconds()); 를 통해 ServerRPCAttack에 서버 시간을 넘겨준다.
  • PlayAttackAnimation()
    • AttackAnimation을 플레이하는 함수
  • AttackHitCheck()
    • 공격에 대한 판정을 구현
    • HasAuthority()에서 IsLocallyControlled()로 변경해줌으로 소유권을 가진 클라이언트에서 동작하도록 변경해준다.
    • 클라이언트와 서버 나눠서 구현해준다. 클라이언트의 경우 Hit 판정을 위해 서버 쪽으로 RPC를 보내 검증을 거쳐줘야 한다. 서버의 경우 그냥 검증만 해주면 된다.
  • AttackHitConfirm(AActor* HitActor)
    • Hit 판정에 대한 검증을 마치고 판정을 수용한다.
  • DrawDebugAttackRange(const FColor& DrawColor, FVector TraceStart, FVector TraceEnd, FVector Forward)
    • DrawDebug기능을 자주 쓰다 보니 따로 함수로 빼줘서 사용
  • ServerRPCNotifyHit_Validate(const FHitResult& HitResult, float HitCheckTime)
    • ServerRPCNotifyHit에 대한 검증을 한다.
  • ServerRPCNotifyHit_Implementation(const FHitResult& HitResult, float HitCheckTime)
    • Hit에 대해 서버쪽으로 RPC를 보내는 함수. Hit한 위치와 Hit된 액터의 히트박스를 가져와 맞았는지를 검증한다.
    • AccepetCheckDistance변수를 임의로 설정하여 HitLocation에서 ActorBoxCenter까지의 거리가 더 가까우면 맞았다고 판정
  • ServerRPCNotifyMiss_Validate(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitCheckTime)
    • Miss 판정도 검증
  • ServerRPCNotifyMiss_Implementation(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitCheckTime)
    • Miss 판정이면 Color.RedDrawDebug
  • MulticastRPCAttack_Implementation()
    • 다른 클라이언트의 프록시로써 동작하는 캐릭터 플레이어에 대해서만 어택 애니메이션을 구동시켜준다.
  • ServerRPCAttack_Implementation()
    • MulticastRPC에서 진행했던 부분을 진행한다. 또한, 클라이언트에서는 모션을 재생했기 때문에 서버 RPC는 서버에서 해야 될 일을 진행해준다.
    • 서버에서는 공격 시간이 클라이언트와 차이가 있기 때문에 클라이언트로부터 패킷을 받은 시간과 서버의 시간의 차이를 반영해주고 마지막으로 공격한 시간을 저장해준다.
    • ServerRPC에서는 중요한 정보를, MulticastRPC의 경우 중요한 정보들보다는 무관한 어떤 효과들을 재생하는데 사용하는 것이 좋다.
  • ServerRPCAttack_Validate(float AttackStartTime)
    • 공격을 시작한 시간을 인풋으로 받아 검증을 해준다.

 

 

렉이 걸리긴 하지만 꽤나 공정한 판정

 

  • 모든 판정은 서버에서 판정하고, DrawDebug되는 모습. 렉은 걸리지만 판정에 있어서 꽤나 공정해졌다.

 

  • 최적화를 위한 몇 가지 방법
// ...

// Miss판정 RPC
// RPC를 전달할 때 FVector 구조체를 전달하고 있는데, 12바이트씩 소모. 정말 정밀한 경우 아닌 이상 조금 더 작은 사이즈로 전달할 수 있도록 최적화 할 수 있다.
// FVector -> FVector_NetQuantize
// ForwardVector의 경우 Normal값으로
UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCNotifyMiss(FVector_NetQuantize TraceStart, FVector_NetQuantize TraceEnd, FVector_NetQuantizeNormal TraceDir, float HitCheckTime);

// ...
  • ABCharacterPlayer.h

 

  • ServerRPCNotifyMiss에서 원래는 FVector 구조체를 RPC로 전달하는데, 이의 경우 floatx3의 총 12바이트를 전송하게 된다. 네트워크의 부담을 줄이기 위해서 언리얼에서는 FVector_NetQuantize라는 구조체를 지원한다.
  • 정말 정밀한 경우가 아닌 이상 조금 더 작은 사이즈인 FVector_NetQuantize로 전달하면 네트워크 전달에 대한 최적화를 진행할 수 있다.

 

// ...

// 신호를 받은 서버는 ServerRPCAttack_Implementation를 실행하게 된다.
void AABCharacterPlayer::ServerRPCAttack_Implementation(float AttackStartTime)
{
	AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));

	// Multicast에서 진행 했던걸 해당 부분에서 진행
	// bCanAttack을 변수에서 프로퍼티로 승격시켜 서버에서 해당 값을 바꿀 때 자동으로 모든 클라이언트에 바뀐 값이 전송되도록
	bCanAttack = false;

	// OnRep 이벤트 함수의 경우 서버에서는 실행되지 않고, 클라이언트에서만 실행됨.
	// 그렇기 때문에 서버에서는 자동으로 호출되지않기 때문에 명시적으로 호출해줘야 함
	
	// 클라이언트에서는 모션을 재생했기 때문에
	// 서버 RPC에서는 서버에서 해야 될 일을 진행해주면 된다.
	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();

	// 서버는 서버와 모든 클라이언트에게 명령을 보내도록
	// MulticastRPC의 경우 게임에 중요한 내용보다는 무관한 어떤 효과들을 재생하는데 사용하는 것이 좋다.
	for (APlayerController* PlayerController : TActorRange<APlayerController>(GetWorld()))
	{
		// 시뮬레이션 프록시인가?
		if (PlayerController && GetController() != PlayerController)
		{
			// PlayerController가 Local이면 이미 재생했기 때문에 스킵
			// 여기서 걸러지는 녀석은 서버도 아니고 공격 명령을 내린 플레이어도 아닌 폰을 재생하는 플레이어 컨트롤러
			if (!PlayerController->IsLocalController())
			{
				AABCharacterPlayer* OtherPlayer = Cast<AABCharacterPlayer>(PlayerController->GetPawn());
				if (OtherPlayer)
				{
					OtherPlayer->ClientRPCPlayAnimation(this);
				}
			}
		}
	}
}

// ClientRPC
void AABCharacterPlayer::ClientRPCPlayAnimation_Implementation(AABCharacterPlayer* CharacterToPlay)
{
	AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));

	if (CharacterToPlay)
	{
		CharacterToPlay->PlayAttackAnimation();
	}
}

// ...
  • ABCharacterPlayer.cpp

 

  • 신호를 받아 서버는 ServerRPCAttack_Implementation를 실행하게 되어 있는데, 이때 다른 클라이언트들에게 애니메이션을 재생하도록 하기 위해 MulticastRPCAttack을 사용했는데, 서버의 경우 이미 재생을 했기 때문에 서버는 필요가 없다.
  • 그리고 서버에서 MulticastRPCAttack를 구현하게 되면 서버에서 모든 클라이언트에게로 패킷이 전송될 것이다. 다만 MulticastRPCAttack에서도 IsLocallyControlled를 통해 걸러줬다고 해도, 서버에서 해당 클라이언트쪽으로 패킷이 전송된다.
  • 이와 같이 한정된 공간, 한정된 플레이어를 사용하는 경우 클라이언트 RPC를 사용하는 게 좋을 수도 있다.

 

 

MulticastRPCAttack →ClientRPCPlayAnimation

 

  • Log를 보면 Server에서는 각 ClientClientRPC가, Client에서는 ServerRPC와 다른 클라이언트에게는 ClientRPC가 실행되는 모습을 볼 수 있다.
  • 만약 MulticastRPC였다면 모든 클라이언트에 MulticastRPC가 전송되었을 것이다.

 

 

 

 

 

해당 포스트는 인프런의 <이득우의 언리얼 프로그래밍 Part3 - 네트웍 멀티플레이어 프레임웍의 이해>
강의를 수강하고 정리한 내용입니다.
 

이득우의 언리얼 프로그래밍 Part3 - 네트웍 멀티플레이 프레임웍의 이해 강의 | 이득우 - 인프런

이득우 | 또 하나의 언리얼 엔진이라고도 불리는 네트웍 멀티플레이어 프레임웍을 학습합니다. 네트웍 멀티플레이어 게임을 제작할 때 반드시 알아야 하는 주요 개념, 내부 동작 원리, 최적화

www.inflearn.com

 

 

728x90