Files
ZMMO/Source/ZMMO/Game/Entity/ZeusPlayerProxy.cpp
Mateus Rodrigues de294f4075 fix(yaw): proxy replica o yaw do CORPO do dono (orient-to-movement), nao a mira
[YAW-BODY-ORIENT] commit conjunto cliente + servidor.

Sintoma: ao apertar D o proxy virava ~45 graus de uma vez, em vez de virar
igual ao player local (suave, so um pouco), e parado seguia o mouse.

Raiz: a fonte de verdade do yaw estava errada. O servidor derivava
simpleYawDeg de atan2(velocidade) INSTANTANEO ao mover (snap pra direcao do
movimento) e da mira (ControlRotation) ao parar -- nenhum dos dois e o yaw
do corpo do dono.

Correcao ponta-a-ponta:
- Cliente: envia GetActorRotation().Yaw (yaw do CORPO; o CMC ja orienta pra
  direcao do movimento a 500 deg/s e bUseControllerRotationYaw=false faz o
  MOUSE nao girar o corpo) no lugar de GetControlRotation().Yaw.
- Servidor: usa o yaw recebido DIRETO em simpleYawDeg (remove atan2
  instantaneo + mira-no-idle).
- Proxy: replica Snapshot.YawDeg interpolado shortest-path (remove a
  derivacao por Atan2 da velocidade).

Resultado: o proxy vira identico ao corpo do dono e o mouse nao gira o corpo.
Futuro (aim/combate/Motion Matching): adicionar um campo aimYawDeg dedicado em
vez de reusar este.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 02:22:07 -03:00

424 lines
16 KiB
C++

#include "ZeusPlayerProxy.h"
#include "Animation/AnimInstance.h"
#include "Components/CapsuleComponent.h"
#include "Components/SkeletalMeshComponent.h"
#include "Engine/SkeletalMesh.h"
#include "Engine/World.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "UObject/ConstructorHelpers.h"
#include "ZMMO.h"
#include "ZeusProxyMovementComponent.h"
AZeusPlayerProxy::AZeusPlayerProxy(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer.SetDefaultSubobjectClass<UZeusProxyMovementComponent>(ACharacter::CharacterMovementComponentName))
{
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = true;
UCapsuleComponent* Capsule = GetCapsuleComponent();
if (Capsule)
{
Capsule->InitCapsuleSize(42.0f, 96.0f);
Capsule->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
}
UCharacterMovementComponent* CMC = GetCharacterMovement();
if (CMC)
{
// MOVE_Walking (em vez de MOVE_None) e necessario para o AnimBP do Quinn
// transitar entre Idle/Run — a state machine consulta `IsFalling()` /
// `MovementMode` no CMC mesmo quando este nao integra fisica.
CMC->bOrientRotationToMovement = false;
CMC->RotationRate = FRotator::ZeroRotator;
CMC->bRunPhysicsWithNoController = false;
CMC->MaxWalkSpeed = 600.0f;
CMC->SetMovementMode(MOVE_Walking);
// Sem input local, o CMC zerava `Velocity` por braking/friction antes do
// `NativeUpdateAnimation` lê-la, deixando o AnimBP em Idle. Zerar friction
// preserva a Velocity escrita por `RefreshAnimationDrivers` entre snapshots.
CMC->BrakingDecelerationWalking = 0.0f;
CMC->GroundFriction = 0.0f;
CMC->BrakingFrictionFactor = 0.0f;
}
// Defaults visuais (mesh + AnimBP) em paridade com `AZeusCharacter`.
if (USkeletalMeshComponent* MeshComponent = GetMesh())
{
static ConstructorHelpers::FObjectFinder<USkeletalMesh> QuinnMesh(
TEXT("/Game/Characters/Mannequins/Meshes/SKM_Quinn_Simple.SKM_Quinn_Simple"));
static ConstructorHelpers::FClassFinder<UAnimInstance> QuinnAnimBp(
TEXT("/Game/Characters/Mannequins/Anims/Unarmed/ABP_Unarmed"));
if (QuinnMesh.Succeeded())
{
MeshComponent->SetSkeletalMesh(QuinnMesh.Object);
MeshComponent->SetRelativeLocation(FVector(0.0f, 0.0f, -90.0f));
MeshComponent->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
}
else
{
UE_LOG(LogZMMO, Warning,
TEXT("AZeusPlayerProxy: SKM_Quinn_Simple nao encontrado em /Game/Characters/Mannequins/Meshes."));
}
if (QuinnAnimBp.Succeeded())
{
MeshComponent->SetAnimationMode(EAnimationMode::AnimationBlueprint);
MeshComponent->SetAnimInstanceClass(QuinnAnimBp.Class);
}
else
{
UE_LOG(LogZMMO, Warning,
TEXT("AZeusPlayerProxy: ABP_Unarmed nao encontrado em /Game/Characters/Mannequins/Anims/Unarmed."));
}
}
bUseControllerRotationPitch = false;
bUseControllerRotationYaw = false;
bUseControllerRotationRoll = false;
}
void AZeusPlayerProxy::BeginPlay()
{
Super::BeginPlay();
// Tick do proxy precisa rodar **antes** do tick do mesh para que
// `RefreshAnimationDrivers` populate `Velocity`+`Acceleration` no CMC custom
// antes que `NativeUpdateAnimation` do AnimBP os leia. Sem isto, AnimBP
// recalcula `ShouldMove` com Acceleration=0 → state machine presa em Idle.
if (USkeletalMeshComponent* MeshComponent = GetMesh())
{
MeshComponent->AddTickPrerequisiteActor(this);
}
}
void AZeusPlayerProxy::Tick(const float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
if (SnapshotBuffer.Num() == 0)
{
return;
}
// Tempo do servidor estimado: relogio local em ms - offset estabelecido na
// primeira amostra. RenderMs = tempo "atrasado" usado para procurar o par
// de snapshots a interpolar (snapshot interpolation a la Source/Quake).
const int64 LocalNowMs = static_cast<int64>(FPlatformTime::Seconds() * 1000.0);
const int64 ServerNowMs = LocalNowMs - ServerClockOffsetMs;
const int64 RenderMs = ServerNowMs - static_cast<int64>(InterpolationDelayMs);
// Procura o snapshot B tal que A.t <= RenderMs <= B.t (A = B-1).
int32 IdxB = INDEX_NONE;
for (int32 i = 1; i < SnapshotBuffer.Num(); ++i)
{
if (SnapshotBuffer[i].ServerTimeMs >= RenderMs)
{
IdxB = i;
break;
}
}
FVector NewPos;
FVector NewVel;
float InterpAlpha = 0.0f;
bool bExtrapolating = false;
if (IdxB == INDEX_NONE)
{
// Sem snapshot futuro: extrapolacao curta com a ultima velocidade
// conhecida, clampada por `MaxExtrapolationSeconds` para nao deixar
// o proxy fugir se a rede ficar muda.
const FZeusProxySnapshot& Last = SnapshotBuffer.Last();
const float RawExtrapSec =
static_cast<float>(RenderMs - Last.ServerTimeMs) / 1000.0f;
const float ExtrapSec = FMath::Clamp(RawExtrapSec, 0.0f, MaxExtrapolationSeconds);
NewPos = Last.PosCm + Last.VelCmS * ExtrapSec;
// STARVATION (buffer=1 por muito tempo -- ex: 1o login sem DELTA_BATCH por
// segundos): a extrapolacao satura no clamp e a POSE congela, mas manter a
// velocidade faz o AnimBP rodar locomocao PARADO ("andar no lugar"/moonwalk
// = o "jitter" reportado). Se ja passamos bem do clamp, zera a vel visual
// pra o proxy ir pra Idle ate' os deltas voltarem (em vez de animar
// andando travado). Quando os deltas chegam, o ramo de interpolacao retoma.
const bool bStarved = RawExtrapSec > (MaxExtrapolationSeconds + 0.15f);
NewVel = bStarved ? FVector::ZeroVector : Last.VelCmS;
bExtrapolating = ExtrapSec > KINDA_SMALL_NUMBER;
}
else
{
const FZeusProxySnapshot& A = SnapshotBuffer[IdxB - 1];
const FZeusProxySnapshot& B = SnapshotBuffer[IdxB];
const double Span = FMath::Max<double>(1.0, static_cast<double>(B.ServerTimeMs - A.ServerTimeMs));
InterpAlpha = static_cast<float>(FMath::Clamp(
static_cast<double>(RenderMs - A.ServerTimeMs) / Span, 0.0, 1.0));
NewPos = FMath::Lerp(A.PosCm, B.PosCm, InterpAlpha);
NewVel = FMath::Lerp(A.VelCmS, B.VelCmS, InterpAlpha);
}
// ORIENTACAO DO CORPO = REPLICA do yaw autoritativo do servidor (Snapshot.YawDeg),
// interpolado shortest-path entre os MESMOS A/B da posicao. Esse yaw agora carrega
// o yaw do CORPO do dono (orient-to-movement do CMC: vira pra direcao do MOVIMENTO
// /WASD, suave a 500 deg/s; parado mantem; o MOUSE nao gira o corpo) -- o cliente
// dono manda GetActorRotation().Yaw e o servidor repassa direto. Por isso o proxy
// so' REPLICA: vira identico ao corpo do dono, sem re-derivar nada.
//
// NAO derivamos de Atan2(velocidade): isso era a fonte do bug 2026-06-13 ("virava
// demais 45 graus") -- a direcao da velocidade salta, o yaw do corpo nao. A
// suavizacao correta ja' aconteceu no CMC do dono; aqui so' interpolamos amostras.
//
// FUTURO -- Motion Matching/aim: quando "a mira dita a locomocao" (strafe/aim
// offset), o servidor passara a mandar um aimYawDeg separado e o AnimBP usara a
// velocidade relativa a este yaw pra escolher fwd/back/left/right. Por ORA, 1 yaw.
FRotator NewRot = GetActorRotation();
if (IdxB == INDEX_NONE)
{
NewRot.Yaw = SnapshotBuffer.Last().YawDeg; // extrapolando: segura o ultimo yaw
}
else
{
const float YawA = SnapshotBuffer[IdxB - 1].YawDeg;
const float YawB = SnapshotBuffer[IdxB].YawDeg;
// FindDeltaAngleDegrees -> menor arco (-180..180); evita girar "o lado longo".
const float DeltaYaw = FMath::FindDeltaAngleDegrees(YawA, YawB);
NewRot.Yaw = YawA + DeltaYaw * InterpAlpha;
}
NewRot.Pitch = 0.0f;
NewRot.Roll = 0.0f;
// Aceleracao derivada da curva interpolada (mais estavel que a derivada
// snapshot-a-snapshot que tinhamos antes; AnimBP do Quinn precisa de
// `Acceleration != 0` para sair de Idle).
FVector DerivedAccel = FVector::ZeroVector;
if (bHasPreviousSnapshot && DeltaSeconds > KINDA_SMALL_NUMBER)
{
DerivedAccel = (NewVel - PreviousVisualVelocity) / DeltaSeconds;
DerivedAccel = DerivedAccel.GetClampedToMaxSize(8000.0f);
}
constexpr float AccelSmoothingAlpha = 0.35f;
LastDerivedAccelerationCmS2 = bHasPreviousSnapshot
? FMath::Lerp(LastDerivedAccelerationCmS2, DerivedAccel, AccelSmoothingAlpha)
: DerivedAccel;
// Fallback sintetico: locomotion a velocidade constante => derivada ~ 0.
// Sem este nudge o AnimBP voltaria para Idle.
const FVector2D Vel2D(NewVel.X, NewVel.Y);
const float Speed2D = Vel2D.Size();
const FVector2D Accel2D(LastDerivedAccelerationCmS2.X, LastDerivedAccelerationCmS2.Y);
constexpr float MinSpeedForFallbackCmS = 50.0f;
constexpr float MinAccelFallbackCmS2 = 200.0f;
const bool bGroundedNow = SnapshotBuffer.Last().bGrounded;
if (bGroundedNow && Speed2D >= MinSpeedForFallbackCmS && Accel2D.Size() < MinAccelFallbackCmS2)
{
const FVector2D VelDir = Vel2D.GetSafeNormal();
const FVector2D FallbackAccel2D = VelDir * MinAccelFallbackCmS2;
LastDerivedAccelerationCmS2.X = FallbackAccel2D.X;
LastDerivedAccelerationCmS2.Y = FallbackAccel2D.Y;
LastDerivedAccelerationCmS2.Z = 0.0f;
}
PreviousVisualVelocity = NewVel;
bHasPreviousSnapshot = true;
InterpolatedPosCm = NewPos;
VisualVelocity = NewVel;
SetActorLocationAndRotation(NewPos, NewRot);
RefreshAnimationDrivers();
// Diagnostico 1x/s: render lag, buffer, alpha, extrapolating?
const double NowSec = FPlatformTime::Seconds();
if (NowSec - LastDiagLogSec >= 1.0)
{
LastDiagLogSec = NowSec;
const int64 NewestMs = SnapshotBuffer.Last().ServerTimeMs;
const int64 RenderLagMs = ServerNowMs - NewestMs;
// === DIAG PERMANENTE -- jitter por FOME DE DELTAS ========================
// Bug investigado/resolvido 2026-06-13 (ver memoria
// project_proxy_delta_starvation_jitter). Este Warning FICA DE PROPOSITO
// (decisao do dono: NAO rebaixar p/ Verbose nem remover) -- e' a sonda pra
// cacar regressao do jitter de proxy. Como ler:
// secsSinceSnap = ha quanto tempo o proxy NAO recebe snapshot. Sobe = FOME
// (player remoto parado -> ComputeDelta suprime no server -> sem DELTA_BATCH).
// newestMs = serverTimeMs do ultimo snapshot; CONGELA na fome (deveria avancar).
// renderLagMs = ServerNowMs - newestMs; cresce 1:1 com o wall-clock na fome.
// speed=0 na fome = a mitigacao (zera a vel visual -> Idle, sem "moonwalk") agindo.
// Ao o player voltar a se mover sai "buffer STALE ... reset+reseed" (anti-stale)
// e renderLagMs/secsSinceSnap voltam a ~0 (snap limpo, sem salto/jitter).
// =========================================================================
const double SecsSinceSnap =
(LastSnapshotRecvSec > 0.0) ? (NowSec - LastSnapshotRecvSec) : -1.0;
UE_LOG(LogZMMO, Warning,
TEXT("ZeusPlayerProxy[%lld] buffer=%d renderLagMs=%lld newestMs=%lld secsSinceSnap=%.1f interpAlpha=%.2f extrap=%d speed=%.0f"),
EntityId, SnapshotBuffer.Num(), RenderLagMs, NewestMs, SecsSinceSnap, InterpAlpha,
bExtrapolating ? 1 : 0,
FVector(VisualVelocity.X, VisualVelocity.Y, 0.0f).Size());
}
}
void AZeusPlayerProxy::SetZeusIdentity(const int64 InEntityId, const EZeusEntityType InEntityType)
{
EntityId = InEntityId;
EntityType = InEntityType;
}
void AZeusPlayerProxy::ApplyEntitySnapshot(const FZeusEntitySnapshot& Snapshot)
{
LastSnapshotRecvSec = FPlatformTime::Seconds(); // DIAG: marca chegada (mede a fome no Tick)
// 2026-06-06 H3 fix — disciplina o ServerClockOffsetMs via EMA com clamp.
// Antes: offset congelado na 1a amostra. Em cross-server handoff a fonte
// do ServerTimeMs muda (publisher antigo -> novo) e o offset fica
// permanentemente skewed, alimentando tremor pos-handoff e drift cumulativo
// de clock cliente vs server. Agora cada amostra recalcula candidato e
// suaviza (alpha=0.02, ~50 amostras pra convergir) com clamp max 2ms/snapshot
// pra evitar salto visual durante transicao.
const int64 LocalNowMs = static_cast<int64>(FPlatformTime::Seconds() * 1000.0);
const int64 OffsetCandidate = LocalNowMs - Snapshot.ServerTimeMs;
// Anti-stale (2026-06-13): se este snapshot esta MUITO a frente do mais novo
// do buffer, o proxy ficou "mudo" por segundos -- player remoto parado ->
// ComputeDelta suprime -> sem DELTA_BATCH novo -> renderLagMs disparou pra
// dezenas de segundos. Manter os snapshots velhos faz a interpolacao usar um
// span gigante (pose de ~100s atras -> agora) quando o player volta a se mover,
// causando rasteio/salto (o jitter reportado). Descarta o buffer obsoleto +
// re-bootstrapa o relogio pra a interpolacao recomecar limpa neste keyframe.
if (SnapshotBuffer.Num() > 0
&& (Snapshot.ServerTimeMs - SnapshotBuffer.Last().ServerTimeMs) > 1000)
{
UE_LOG(LogZMMO, Warning,
TEXT("ZeusPlayerProxy[%lld] buffer STALE gap=%lldms -> reset+reseed"),
EntityId, Snapshot.ServerTimeMs - SnapshotBuffer.Last().ServerTimeMs);
SnapshotBuffer.Reset();
ServerClockOffsetMs = OffsetCandidate;
}
if (SnapshotBuffer.Num() == 0)
{
// Primeira amostra: bootstrap direto (nao tem baseline pra suavizar).
ServerClockOffsetMs = OffsetCandidate;
// DIAG (jitter 1o login): confirma o seed do relogio. serverTimeMs deve ser
// != 0 e o offset razoavel; se vier 0 aqui, o keyframe/catch-up corrompeu o
// bootstrap (hipotese descartada, mas o log fecha a questao em campo).
UE_LOG(LogZMMO, Warning,
TEXT("ZeusPlayerProxy[%lld] SEED clock serverTimeMs=%lld localNowMs=%lld offset=%lld"),
EntityId, Snapshot.ServerTimeMs, LocalNowMs, ServerClockOffsetMs);
}
else
{
const double Alpha = 0.02;
const int64 RawTarget = static_cast<int64>(
static_cast<double>(ServerClockOffsetMs) * (1.0 - Alpha)
+ static_cast<double>(OffsetCandidate) * Alpha);
// Clamp max 2ms por amostra — em 30Hz isso converge a 60ms/s, mais que
// o suficiente pra absorver drift normal sem causar tremor visual.
const int64 Delta = RawTarget - ServerClockOffsetMs;
const int64 ClampedDelta = (Delta > 2) ? 2
: (Delta < -2) ? -2
: Delta;
ServerClockOffsetMs += ClampedDelta;
}
FZeusProxySnapshot S;
S.PosCm = Snapshot.PositionCm;
S.VelCmS = Snapshot.VelocityCmS;
S.YawDeg = Snapshot.YawDeg; // yaw do CORPO do dono (orient-to-movement; mouse nao gira)
S.bGrounded = Snapshot.bGrounded;
S.ServerTimeMs = Snapshot.ServerTimeMs;
// Insercao ordenada defensiva (UDP pode trocar a ordem de pacotes).
int32 InsertAt = SnapshotBuffer.Num();
for (int32 i = SnapshotBuffer.Num() - 1; i >= 0; --i)
{
if (SnapshotBuffer[i].ServerTimeMs < S.ServerTimeMs)
{
InsertAt = i + 1;
break;
}
if (SnapshotBuffer[i].ServerTimeMs == S.ServerTimeMs)
{
// Dedupe: substitui pelo mais recente (este).
SnapshotBuffer[i] = S;
InsertAt = INDEX_NONE;
break;
}
InsertAt = i;
}
if (InsertAt != INDEX_NONE)
{
SnapshotBuffer.Insert(S, InsertAt);
}
while (SnapshotBuffer.Num() > SnapshotBufferCapacity)
{
SnapshotBuffer.RemoveAt(0);
}
AuthoritativePosCm = Snapshot.PositionCm;
LastSnapshotPosition = Snapshot.PositionCm;
bHasFirstSnapshot = true;
// `bGrounded` e evento — aplicar `MovementMode` imediatamente para que o
// pulo nao espere `InterpolationDelayMs` para mudar a state machine do AnimBP.
if (UCharacterMovementComponent* CMC = GetCharacterMovement())
{
const EMovementMode DesiredMode = Snapshot.bGrounded ? MOVE_Walking : MOVE_Falling;
if (CMC->MovementMode != DesiredMode)
{
CMC->SetMovementMode(DesiredMode);
}
}
ApplyCollisionPolicy();
}
void AZeusPlayerProxy::SetEntityRelevant(const bool bRelevant)
{
if (bIsZeusRelevant == bRelevant)
{
return;
}
bIsZeusRelevant = bRelevant;
SetActorHiddenInGame(!bRelevant);
SetActorTickEnabled(bRelevant);
ApplyCollisionPolicy();
}
void AZeusPlayerProxy::RefreshAnimationDrivers()
{
UCharacterMovementComponent* CMC = GetCharacterMovement();
if (!CMC)
{
return;
}
// Defesa: snapshots em walking não devem injetar Z residual (jitter de
// terminal velocity / drift) no CMC, que o AnimBP veria como queda.
FVector VelForCMC = VisualVelocity;
if (CMC->MovementMode == MOVE_Walking)
{
VelForCMC.Z = 0.0f;
}
CMC->Velocity = VelForCMC;
// Acceleration externa via CMC custom — alimenta `Get Current Acceleration`
// que o AnimBP do Quinn usa em `ShouldMove` e nas transições de Jump_Start.
if (UZeusProxyMovementComponent* ProxyCMC = Cast<UZeusProxyMovementComponent>(CMC))
{
ProxyCMC->SetZeusExternalAcceleration(LastDerivedAccelerationCmS2);
}
}
void AZeusPlayerProxy::ApplyCollisionPolicy()
{
UCapsuleComponent* Capsule = GetCapsuleComponent();
if (!Capsule)
{
return;
}
const bool bShouldCollide = bIsZeusRelevant && bHasFirstSnapshot;
Capsule->SetCollisionEnabled(bShouldCollide ? ECollisionEnabled::QueryOnly : ECollisionEnabled::NoCollision);
}