单帧测试进行中
This commit is contained in:
@@ -99,7 +99,7 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(serverRuntime.AuthoritativeMovementCadence, Is.EqualTo(TimeSpan.FromMilliseconds(50)));
|
||||
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var localServerState), Is.True);
|
||||
Assert.That(localServerState.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(localServerState.PositionX, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(localServerState.PositionZ, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(serverRuntime.TryGetAuthoritativeCombatState(RemotePeer, out var remoteCombatState), Is.True);
|
||||
Assert.That(remoteCombatState.PlayerId, Is.EqualTo("player-b"));
|
||||
Assert.That(remoteCombatState.Hp, Is.EqualTo(70));
|
||||
@@ -107,7 +107,7 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(clientHarness.TryGetState("player-a", out var localClientState), Is.True);
|
||||
Assert.That(localClientState.Tick, Is.EqualTo(1));
|
||||
Assert.That(localClientState.AcknowledgedMoveTick, Is.EqualTo(1));
|
||||
Assert.That(localClientState.Position.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(localClientState.Position.z, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(clientHarness.TryGetState("player-b", out var remoteClientState), Is.True);
|
||||
Assert.That(remoteClientState.Hp, Is.EqualTo(70));
|
||||
Assert.That(clientHarness.TryGetCombatPresentation("player-b", out var remoteCombatPresentation), Is.True);
|
||||
@@ -228,7 +228,6 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var localMovementState), Is.True);
|
||||
Assert.That(localMovementState.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(localMovementState.LastAcceptedMoveTick, Is.EqualTo(0));
|
||||
Assert.That(localMovementState.PositionX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(localMovementState.PositionZ, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(serverRuntime.TryGetAuthoritativeCombatState(ClientPeer, out var localCombatState), Is.True);
|
||||
Assert.That(localCombatState.LastAcceptedShootTick, Is.EqualTo(3));
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using System;
|
||||
using Network.Defines;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Tests.EditMode.Network
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证客户端 ApplyTankMovement 和服务端 IntegrateState
|
||||
/// 在相同输入下产生相同的移动结果。
|
||||
///
|
||||
/// 使用纯函数对比,不依赖任何状态对象。
|
||||
/// </summary>
|
||||
public class MovementAlgorithmConsistencyTests
|
||||
{
|
||||
private const float MoveSpeed = 4f;
|
||||
private const float TurnSpeed = 180f;
|
||||
private const float DeltaTime = 0.05f; // 50ms
|
||||
|
||||
[Test]
|
||||
public void ServerForward_ZeroRotation_MovesInPositiveZ()
|
||||
{
|
||||
// Arrange: 服务端 rotation=0,throttle=1
|
||||
float rotation = 0f;
|
||||
float throttleInput = 1f;
|
||||
|
||||
// Act: 服务端 forward 计算(新公式)
|
||||
var rotationRadians = rotation * (MathF.PI / 180f);
|
||||
var forwardX = MathF.Sin(rotationRadians); // 新公式
|
||||
var forwardZ = MathF.Cos(rotationRadians); // 新公式
|
||||
var velocityX = forwardX * (throttleInput * MoveSpeed);
|
||||
var velocityZ = forwardZ * (throttleInput * MoveSpeed);
|
||||
|
||||
// Assert: rotation=0° → forward=(0,0,1) = +Z
|
||||
Assert.That(forwardX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(forwardZ, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(velocityX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(velocityZ, Is.EqualTo(4f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServerForward_90DegreeRotation_MovesInPositiveX()
|
||||
{
|
||||
// Arrange: 服务端 rotation=90°,throttle=1
|
||||
float rotation = 90f;
|
||||
float throttleInput = 1f;
|
||||
|
||||
// Act: 服务端 forward 计算(新公式)
|
||||
var rotationRadians = rotation * (MathF.PI / 180f);
|
||||
var forwardX = MathF.Sin(rotationRadians); // 新公式
|
||||
var forwardZ = MathF.Cos(rotationRadians); // 新公式
|
||||
var velocityX = forwardX * (throttleInput * MoveSpeed);
|
||||
var velocityZ = forwardZ * (throttleInput * MoveSpeed);
|
||||
|
||||
// Assert: rotation=90° → forward=(1,0,0) = +X
|
||||
Assert.That(forwardX, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(forwardZ, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(velocityX, Is.EqualTo(4f).Within(0.0001f));
|
||||
Assert.That(velocityZ, Is.EqualTo(0f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientForward_UnityYawZero_MovesInPositiveZ()
|
||||
{
|
||||
// Arrange: Unity Yaw = 0°,客户端转换后 heading = 90°
|
||||
float unityYaw = 0f;
|
||||
float throttleInput = 1f;
|
||||
|
||||
// Act: 客户端 heading 计算
|
||||
var heading = NormalizeDegrees(UnityYawToHeading(unityYaw));
|
||||
|
||||
// 客户端 ResolveHeadingForward: forward = (cos, 0, sin)
|
||||
var rotationRadians = heading * Mathf.Deg2Rad;
|
||||
var forwardX = Mathf.Cos(rotationRadians);
|
||||
var forwardZ = Mathf.Sin(rotationRadians);
|
||||
var velocityX = forwardX * throttleInput * MoveSpeed;
|
||||
var velocityZ = forwardZ * throttleInput * MoveSpeed;
|
||||
|
||||
// Assert: Unity Yaw=0 → heading=90° → forward=(cos(90°), sin(90°))=(0,1) = +Z
|
||||
Assert.That(heading, Is.EqualTo(90f).Within(0.0001f));
|
||||
Assert.That(forwardX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(forwardZ, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(velocityX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(velocityZ, Is.EqualTo(4f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientServer_IdenticalInputs_ProduceIdenticalOutput()
|
||||
{
|
||||
// 这个测试验证:相同的输入在客户端和服务端产生相同的最终位置
|
||||
//
|
||||
// 场景:初始位置 (0,0,0),初始 heading=90°(Unity Yaw=0),
|
||||
// 向前移动 4 个 50ms 步长
|
||||
|
||||
// ===== 共享参数 =====
|
||||
float moveSpeed = MoveSpeed;
|
||||
float turnSpeed = TurnSpeed;
|
||||
float dt = DeltaTime;
|
||||
|
||||
// ===== 服务端计算 =====
|
||||
float serverPosX = 0f, serverPosZ = 0f;
|
||||
float serverRotation = 0f; // 服务端 rotation 直接是 heading
|
||||
float serverThrottle = 1f;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
// 速度计算(服务端新 forward 公式:forwardX = sin, forwardZ = cos)
|
||||
var rotRad = serverRotation * (MathF.PI / 180f);
|
||||
var fwdX = MathF.Sin(rotRad);
|
||||
var fwdZ = MathF.Cos(rotRad);
|
||||
var velX = fwdX * (serverThrottle * moveSpeed);
|
||||
var velZ = fwdZ * (serverThrottle * moveSpeed);
|
||||
|
||||
// 位置积分
|
||||
serverPosX += velX * dt;
|
||||
serverPosZ += velZ * dt;
|
||||
}
|
||||
|
||||
// ===== 客户端计算 =====
|
||||
float clientPosX = 0f, clientPosZ = 0f;
|
||||
float clientUnityYaw = 0f; // Unity Yaw = 0 → heading = 90°
|
||||
float clientThrottle = 1f;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
// 旋转(客户端需要转换)
|
||||
var heading = NormalizeDegrees(UnityYawToHeading(clientUnityYaw) + (0f * turnSpeed * dt));
|
||||
clientUnityYaw = HeadingToUnityYaw(heading);
|
||||
|
||||
// 客户端 ResolveHeadingForward: forward = (cos, 0, sin)
|
||||
var headingRad = heading * Mathf.Deg2Rad;
|
||||
var fwdX = Mathf.Cos(headingRad);
|
||||
var fwdZ = Mathf.Sin(headingRad);
|
||||
var velX = fwdX * (clientThrottle * moveSpeed);
|
||||
var velZ = fwdZ * (clientThrottle * moveSpeed);
|
||||
|
||||
// 位置积分
|
||||
clientPosX += velX * dt;
|
||||
clientPosZ += velZ * dt;
|
||||
}
|
||||
|
||||
// ===== 对比 =====
|
||||
// 服务端期望:rotation=0° → forward=(0,0,1) → velocity=(0,0,4) → 每步 0.2
|
||||
// 4步后:pos = (0, 0, 0.8)
|
||||
Assert.That(serverPosX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(serverPosZ, Is.EqualTo(0.8f).Within(0.0001f));
|
||||
|
||||
// 客户端应与服务端一致
|
||||
Assert.That(clientPosX, Is.EqualTo(serverPosX).Within(0.0001f),
|
||||
$"Client X ({clientPosX}) should match Server X ({serverPosX})");
|
||||
Assert.That(clientPosZ, Is.EqualTo(serverPosZ).Within(0.0001f),
|
||||
$"Client Z ({clientPosZ}) should match Server Z ({serverPosZ})");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientServer_TurnRight90Degrees_ThenMove_ProduceIdenticalOutput()
|
||||
{
|
||||
// 场景:初始向前走,然后右转90°,再走
|
||||
// 验证转向后的方向一致
|
||||
|
||||
float moveSpeed = MoveSpeed;
|
||||
float turnSpeed = TurnSpeed;
|
||||
float dt = DeltaTime;
|
||||
|
||||
// ===== 服务端计算 =====
|
||||
float serverPosX = 0f, serverPosZ = 0f;
|
||||
float serverRotation = 0f; // heading=0°,向前走
|
||||
float serverThrottle = 1f;
|
||||
float serverTurnInput = 1f; // 右转
|
||||
|
||||
// 先走2步
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
var rotRad = serverRotation * (MathF.PI / 180f);
|
||||
var fwdX = MathF.Sin(rotRad);
|
||||
var fwdZ = MathF.Cos(rotRad);
|
||||
var velX = fwdX * serverThrottle * moveSpeed;
|
||||
var velZ = fwdZ * serverThrottle * moveSpeed;
|
||||
serverPosX += velX * dt;
|
||||
serverPosZ += velZ * dt;
|
||||
}
|
||||
|
||||
// 右转1步(turnInput=1,转 180*0.05=9°)
|
||||
serverRotation = NormalizeDegreesServer(serverRotation + (serverTurnInput * turnSpeed * dt));
|
||||
|
||||
// 再走2步
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
var rotRad = serverRotation * (MathF.PI / 180f);
|
||||
var fwdX = MathF.Sin(rotRad);
|
||||
var fwdZ = MathF.Cos(rotRad);
|
||||
var velX = fwdX * serverThrottle * moveSpeed;
|
||||
var velZ = fwdZ * serverThrottle * moveSpeed;
|
||||
serverPosX += velX * dt;
|
||||
serverPosZ += velZ * dt;
|
||||
}
|
||||
|
||||
// ===== 客户端计算 =====
|
||||
float clientPosX = 0f, clientPosZ = 0f;
|
||||
float clientUnityYaw = 0f; // 初始朝前
|
||||
float clientThrottle = 1f;
|
||||
float clientTurnInput = -1f; // Unity 中右转 = -input.x
|
||||
|
||||
// 先走2步
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
var heading = NormalizeDegrees(UnityYawToHeading(clientUnityYaw));
|
||||
var headingRad = heading * Mathf.Deg2Rad;
|
||||
var fwdX = Mathf.Cos(headingRad);
|
||||
var fwdZ = Mathf.Sin(headingRad);
|
||||
var velX = fwdX * clientThrottle * moveSpeed;
|
||||
var velZ = fwdZ * clientThrottle * moveSpeed;
|
||||
clientPosX += velX * dt;
|
||||
clientPosZ += velZ * dt;
|
||||
}
|
||||
|
||||
// 右转1步
|
||||
var newHeading = NormalizeDegrees(UnityYawToHeading(clientUnityYaw) + (clientTurnInput * turnSpeed * dt));
|
||||
clientUnityYaw = HeadingToUnityYaw(newHeading);
|
||||
|
||||
// 再走2步
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
var heading = NormalizeDegrees(UnityYawToHeading(clientUnityYaw));
|
||||
var headingRad = heading * Mathf.Deg2Rad;
|
||||
var fwdX = Mathf.Cos(headingRad);
|
||||
var fwdZ = Mathf.Sin(headingRad);
|
||||
var velX = fwdX * clientThrottle * moveSpeed;
|
||||
var velZ = fwdZ * clientThrottle * moveSpeed;
|
||||
clientPosX += velX * dt;
|
||||
clientPosZ += velZ * dt;
|
||||
}
|
||||
|
||||
// ===== 对比 =====
|
||||
Assert.That(clientPosX, Is.EqualTo(serverPosX).Within(0.0001f),
|
||||
$"Client X ({clientPosX}) should match Server X ({serverPosX})");
|
||||
Assert.That(clientPosZ, Is.EqualTo(serverPosZ).Within(0.0001f),
|
||||
$"Client Z ({clientPosZ}) should match Server Z ({serverPosZ})");
|
||||
}
|
||||
|
||||
// ===== 辅助方法(从 MovementComponent 复制) =====
|
||||
|
||||
private static float UnityYawToHeading(float unityYawDegrees)
|
||||
{
|
||||
return NormalizeDegrees(90f - unityYawDegrees);
|
||||
}
|
||||
|
||||
private static float HeadingToUnityYaw(float headingDegrees)
|
||||
{
|
||||
return NormalizeDegrees(90f - headingDegrees);
|
||||
}
|
||||
|
||||
private static float NormalizeDegrees(float degrees)
|
||||
{
|
||||
var normalized = degrees % 360f;
|
||||
if (normalized < 0f)
|
||||
{
|
||||
normalized += 360f;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static float NormalizeDegreesServer(float degrees)
|
||||
{
|
||||
var normalized = degrees % 360f;
|
||||
if (normalized <= -180f)
|
||||
{
|
||||
normalized += 360f;
|
||||
}
|
||||
else if (normalized > 180f)
|
||||
{
|
||||
normalized -= 360f;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 351f8ed6c1e2cf047bb173a387d41942
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -55,13 +55,13 @@ namespace Tests.EditMode.Network
|
||||
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(1));
|
||||
|
||||
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterFirstStep), Is.True);
|
||||
Assert.That(stateAfterFirstStep.PositionX, Is.EqualTo(0.2f).Within(0.0001f));
|
||||
Assert.That(stateAfterFirstStep.PositionZ, Is.EqualTo(0.2f).Within(0.0001f));
|
||||
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(0));
|
||||
|
||||
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterSecondStep), Is.True);
|
||||
Assert.That(stateAfterSecondStep.PositionX, Is.EqualTo(0.4f).Within(0.0001f));
|
||||
Assert.That(stateAfterSecondStep.PositionZ, Is.EqualTo(0.4f).Within(0.0001f));
|
||||
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
@@ -116,12 +116,12 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerB, out var stateB), Is.True);
|
||||
Assert.That(stateA.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(stateA.LastAcceptedMoveTick, Is.EqualTo(10));
|
||||
Assert.That(stateA.PositionX, Is.EqualTo(0.2f).Within(0.0001f));
|
||||
Assert.That(stateA.PositionZ, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(stateA.PositionZ, Is.EqualTo(0.2f).Within(0.0001f));
|
||||
Assert.That(stateA.PositionX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(stateB.PlayerId, Is.EqualTo("player-b"));
|
||||
Assert.That(stateB.LastAcceptedMoveTick, Is.EqualTo(3));
|
||||
Assert.That(stateB.PositionX, Is.EqualTo(-0.2f).Within(0.0001f));
|
||||
Assert.That(stateB.PositionZ, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(stateB.PositionZ, Is.EqualTo(-0.2f).Within(0.0001f));
|
||||
Assert.That(stateB.PositionX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
@@ -168,9 +168,9 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(firstBroadcast.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(firstBroadcast.Tick, Is.EqualTo(1));
|
||||
Assert.That(firstBroadcast.AcknowledgedMoveTick, Is.EqualTo(1));
|
||||
Assert.That(firstBroadcast.Position.X, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.X, Is.EqualTo(10f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Position.Z, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(10f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
|
||||
createdTransports[9001].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
|
||||
{
|
||||
@@ -192,9 +192,9 @@ namespace Tests.EditMode.Network
|
||||
var secondBroadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[1]);
|
||||
Assert.That(secondBroadcast.Tick, Is.EqualTo(2));
|
||||
Assert.That(secondBroadcast.AcknowledgedMoveTick, Is.EqualTo(2));
|
||||
Assert.That(secondBroadcast.Position.X, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Position.Z, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -281,9 +281,9 @@ namespace Tests.EditMode.Network
|
||||
var broadcast = ParsePlayerState(createdTransports[9000].BroadcastMessages[0]);
|
||||
Assert.That(broadcast.Tick, Is.EqualTo(1));
|
||||
Assert.That(broadcast.AcknowledgedMoveTick, Is.EqualTo(5));
|
||||
Assert.That(broadcast.Position.X, Is.EqualTo(-0.3f).Within(0.0001f));
|
||||
Assert.That(broadcast.Position.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(broadcast.Velocity.X, Is.EqualTo(-6f).Within(0.0001f));
|
||||
Assert.That(broadcast.Position.Z, Is.EqualTo(-0.3f).Within(0.0001f));
|
||||
Assert.That(broadcast.Position.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(broadcast.Velocity.Z, Is.EqualTo(-6f).Within(0.0001f));
|
||||
}
|
||||
|
||||
private static FakeTransport CreateTransport(IDictionary<int, FakeTransport> createdTransports, int port)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6aee45b7c7c7b734c9b6895d254f1cde
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e1bbd442159abc24a8f56fc7bbad78a8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using Network.NetworkHost;
|
||||
using Network.NetworkTransport;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Vector3 = UnityEngine.Vector3;
|
||||
|
||||
namespace Tests.PlayMode.Network
|
||||
{
|
||||
/// <summary>
|
||||
/// PlayMode 测试:使用协程控制帧推进,验证客户端移动与服务端 authoritative state 的一致性。
|
||||
/// 使用真实的 ServerRuntimeEntryPoint,直接获取服务端的 authoritative state 进行对比。
|
||||
/// </summary>
|
||||
public class MovementConsistencyPlayModeTests
|
||||
{
|
||||
private static readonly IPEndPoint ClientPeer = new(IPAddress.Loopback, 9701);
|
||||
|
||||
// 测试参数
|
||||
private const float MoveSpeed = 4f;
|
||||
private const float TurnSpeed = 180f;
|
||||
private const float DeltaTime = 0.05f; // 50ms 模拟步长
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator OneFrame_MoveForward_ClientAndServerPositionMatch()
|
||||
{
|
||||
yield return RunMovementTest(
|
||||
playerId: "player-1frame",
|
||||
inputSequence: new[] { (0f, 1f) }, // 1帧:前进
|
||||
expectedTotalMovement: MoveSpeed * DeltaTime // 4 * 0.05 = 0.2
|
||||
);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator FiveFrames_MoveForward_ClientAndServerPositionMatch()
|
||||
{
|
||||
yield return RunMovementTest(
|
||||
playerId: "player-5frames",
|
||||
inputSequence: new[] { (0f, 1f), (0f, 1f), (0f, 1f), (0f, 1f), (0f, 1f) }, // 5帧:连续前进
|
||||
expectedTotalMovement: MoveSpeed * DeltaTime * 5 // 4 * 0.05 * 5 = 1.0
|
||||
);
|
||||
}
|
||||
|
||||
[UnityTest]
|
||||
public IEnumerator IntermittentMovement_StartStopStart_ClientAndServerPositionMatch()
|
||||
{
|
||||
// 场景:前进2帧 -> 停止1帧 -> 前进2帧
|
||||
yield return RunMovementTest(
|
||||
playerId: "player-intermittent",
|
||||
inputSequence: new[]
|
||||
{
|
||||
(0f, 1f), // 帧1:前进
|
||||
(0f, 1f), // 帧2:前进
|
||||
(0f, 0f), // 帧3:停止
|
||||
(0f, 1f), // 帧4:前进
|
||||
(0f, 1f), // 帧5:前进
|
||||
},
|
||||
expectedTotalMovement: MoveSpeed * DeltaTime * 4 // 4 * 0.05 * 4 = 0.8(只有4帧在移动)
|
||||
);
|
||||
}
|
||||
|
||||
private IEnumerator RunMovementTest(
|
||||
string playerId,
|
||||
(float turn, float throttle)[] inputSequence,
|
||||
float expectedTotalMovement)
|
||||
{
|
||||
// ========== 创建服务器 ==========
|
||||
var serverTransports = new Dictionary<int, FakeTestTransport>();
|
||||
var configuration = new ServerRuntimeConfiguration(9700)
|
||||
{
|
||||
SyncPort = 9701,
|
||||
Dispatcher = new MainThreadNetworkDispatcher(),
|
||||
TransportFactory = port =>
|
||||
{
|
||||
var transport = new FakeTestTransport();
|
||||
serverTransports[port] = transport;
|
||||
return transport;
|
||||
},
|
||||
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration
|
||||
{
|
||||
MoveSpeed = MoveSpeed,
|
||||
SimulationInterval = TimeSpan.FromMilliseconds(50),
|
||||
BroadcastInterval = TimeSpan.FromMilliseconds(50)
|
||||
}
|
||||
};
|
||||
|
||||
var serverRuntime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
|
||||
|
||||
// ========== 登录 ==========
|
||||
serverRuntime.Host.NotifyLoginStarted(ClientPeer);
|
||||
serverRuntime.Host.NotifyLoginSucceeded(ClientPeer, playerId, MoveSpeed);
|
||||
|
||||
yield return null; // 让服务器初始化
|
||||
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Debug.Log($"[Setup] Login complete. BroadcastMessages on sync transport: {serverTransports[9701].BroadcastMessages.Count}");
|
||||
|
||||
// ========== 客户端本地计算状态 ==========
|
||||
float clientRotation = 0f; // Unity Yaw = 0 -> heading = 90
|
||||
Vector3 clientPosition = Vector3.zero;
|
||||
|
||||
// ========== 运行帧模拟 ==========
|
||||
int tick = 1;
|
||||
List<Vector3> serverPositions = new List<Vector3>();
|
||||
List<Vector3> clientPositions = new List<Vector3>();
|
||||
|
||||
foreach (var (turnInput, throttleInput) in inputSequence)
|
||||
{
|
||||
// --- 构造 MoveInput ---
|
||||
var moveInput = new MoveInput
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Tick = tick,
|
||||
TurnInput = turnInput,
|
||||
ThrottleInput = throttleInput
|
||||
};
|
||||
|
||||
// --- 发送到服务器 ---
|
||||
var envelope = new Envelope
|
||||
{
|
||||
Type = (int)MessageType.MoveInput,
|
||||
Payload = ByteString.CopyFrom(moveInput.ToByteArray())
|
||||
};
|
||||
Debug.Log($"[Tick {tick}] EmitReceive MoveInput to sync transport (turn={turnInput}, throttle={throttleInput}), BroadcastMessages before: {serverTransports[9701].BroadcastMessages.Count}");
|
||||
serverTransports[9701].EmitReceive(envelope.ToByteArray(), ClientPeer);
|
||||
|
||||
// --- 服务器处理 ---
|
||||
Debug.Log($"[Tick {tick}] Before DrainPendingMessagesAsync, BroadcastMessages: {serverTransports[9701].BroadcastMessages.Count}");
|
||||
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
|
||||
Debug.Log($"[Tick {tick}] After DrainPendingMessagesAsync + UpdateAuthoritativeMovement, BroadcastMessages: {serverTransports[9701].BroadcastMessages.Count}");
|
||||
|
||||
// --- 获取服务端 authoritative state(不需要等待广播)---
|
||||
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var serverState), Is.True);
|
||||
var serverPos = new Vector3(serverState.PositionX, serverState.PositionY, serverState.PositionZ);
|
||||
serverPositions.Add(serverPos);
|
||||
|
||||
// --- 客户端本地计算(与服务端相同的算法) ---
|
||||
var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f);
|
||||
var clampedThrottleInput = Mathf.Clamp(throttleInput, -1f, 1f);
|
||||
|
||||
// 旋转(客户端 Tank Control)
|
||||
var heading = NormalizeDegrees(UnityYawToHeading(clientRotation) + (clampedTurnInput * TurnSpeed * DeltaTime));
|
||||
clientRotation = HeadingToUnityYaw(heading);
|
||||
|
||||
// 速度(客户端 ResolveHeadingForward: forward = (cos, 0, sin))
|
||||
var headingRad = heading * Mathf.Deg2Rad;
|
||||
var forwardX = Mathf.Cos(headingRad);
|
||||
var forwardZ = Mathf.Sin(headingRad);
|
||||
var velocityX = forwardX * (clampedThrottleInput * MoveSpeed);
|
||||
var velocityZ = forwardZ * (clampedThrottleInput * MoveSpeed);
|
||||
|
||||
clientPosition.x += velocityX * DeltaTime;
|
||||
clientPosition.z += velocityZ * DeltaTime;
|
||||
clientPositions.Add(clientPosition);
|
||||
|
||||
Debug.Log($"[Tick {tick}] Turn={turnInput}, Throttle={throttleInput} | " +
|
||||
$"Client=({clientPosition.x:F3}, {clientPosition.y:F3}, {clientPosition.z:F3}) | " +
|
||||
$"Server=({serverPos.x:F3}, {serverPos.y:F3}, {serverPos.z:F3})");
|
||||
|
||||
tick++;
|
||||
yield return null; // 推进一帧
|
||||
}
|
||||
|
||||
// ========== 验证 ==========
|
||||
var finalClientPos = clientPositions[^1];
|
||||
var finalServerPos = serverPositions[^1];
|
||||
|
||||
Debug.Log($"========================================");
|
||||
Debug.Log($"[Test: {playerId}]");
|
||||
Debug.Log($"Expected Z Movement: {expectedTotalMovement}");
|
||||
Debug.Log($"Client Final Z: {finalClientPos.z:F4}");
|
||||
Debug.Log($"Server Final Z: {finalServerPos.z:F4}");
|
||||
Debug.Log($"Server Final Full: ({finalServerPos.x:F4}, {finalServerPos.y:F4}, {finalServerPos.z:F4})");
|
||||
Debug.Log($"========================================");
|
||||
|
||||
float clientMovement = finalClientPos.z;
|
||||
float serverMovement = finalServerPos.z;
|
||||
|
||||
Assert.That(Math.Abs(clientMovement - serverMovement), Is.LessThan(0.01f),
|
||||
$"Client Z movement ({clientMovement:F4}) should match Server Z movement ({serverMovement:F4})");
|
||||
|
||||
Assert.That(Math.Abs(clientMovement - expectedTotalMovement), Is.LessThan(0.01f),
|
||||
$"Movement ({clientMovement:F4}) should match expected ({expectedTotalMovement:F4})");
|
||||
|
||||
// ========== 清理 ==========
|
||||
serverRuntime.Stop();
|
||||
}
|
||||
|
||||
private static float NormalizeDegrees(float degrees)
|
||||
{
|
||||
var normalized = degrees % 360f;
|
||||
if (normalized < 0f) normalized += 360f;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static float UnityYawToHeading(float unityYawDegrees)
|
||||
{
|
||||
return NormalizeDegrees(90f - unityYawDegrees);
|
||||
}
|
||||
|
||||
private static float HeadingToUnityYaw(float headingDegrees)
|
||||
{
|
||||
return NormalizeDegrees(90f - headingDegrees);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 测试辅助类 ==========
|
||||
|
||||
/// <summary>
|
||||
/// 用于 PlayMode 测试的 FakeTransport
|
||||
/// </summary>
|
||||
public class FakeTestTransport : ITransport
|
||||
{
|
||||
private readonly List<byte[]> _receivedMessages = new();
|
||||
public List<byte[]> BroadcastMessages { get; } = new();
|
||||
public event Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
public void EmitReceive(byte[] data, IPEndPoint sender)
|
||||
{
|
||||
_receivedMessages.Add(data);
|
||||
OnReceive?.Invoke(data, sender);
|
||||
}
|
||||
|
||||
public void Send(byte[] data) { }
|
||||
public void SendTo(byte[] data, IPEndPoint target) { }
|
||||
public void SendToAll(byte[] data)
|
||||
{
|
||||
BroadcastMessages.Add(data);
|
||||
OnReceive?.Invoke(data, new IPEndPoint(IPAddress.Loopback, 0));
|
||||
}
|
||||
|
||||
public Task StartAsync() => Task.CompletedTask;
|
||||
public void Stop() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c61c8ae5a68e0fb4195a729749a21019
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Network.PlayMode.Tests",
|
||||
"rootNamespace": "Tests.PlayMode.Network",
|
||||
"references": [
|
||||
"Network.Runtime",
|
||||
"UnityEngine.TestRunner",
|
||||
"UnityEditor.TestRunner",
|
||||
"NetworkFramework"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [
|
||||
"nunit.framework.dll",
|
||||
"Google.Protobuf.dll"
|
||||
],
|
||||
"autoReferenced": false,
|
||||
"defineConstraints": [
|
||||
"UNITY_INCLUDE_TESTS"
|
||||
],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00dbd4533a02ad04cb7d9dada7210593
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user