修改操作方式为 坦克式运动

This commit is contained in:
SepComet 2026-04-03 15:03:42 +08:00
parent f6ca6fa6e4
commit 0aab757091
43 changed files with 1284 additions and 342 deletions

3
.gitignore vendored
View File

@ -83,3 +83,6 @@ crashlytics-build.properties
/.dotnet /.dotnet
/.dotnet-home /.dotnet-home
EditMode-err.txt

View File

@ -1595,90 +1595,6 @@ MeshFilter:
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 490537900} m_GameObject: {fileID: 490537900}
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0} m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
--- !u!1 &520045882
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 520045884}
- component: {fileID: 520045883}
m_Layer: 0
m_Name: GameObject
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!212 &520045883
SpriteRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 520045882}
m_Enabled: 1
m_CastShadows: 0
m_ReceiveShadows: 0
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 0
m_RayTraceProcedural: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 10754, guid: 0000000000000000f000000000000000, type: 0}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 0
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_Sprite: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_FlipX: 0
m_FlipY: 0
m_DrawMode: 0
m_Size: {x: 1, y: 1}
m_AdaptiveModeThreshold: 0.5
m_SpriteTileMode: 0
m_WasSpriteAssigned: 0
m_MaskInteraction: 0
m_SpriteSortPoint: 0
--- !u!4 &520045884
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 520045882}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -23.49001, y: 0.5700337, z: 11.206667}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &632206104 --- !u!1 &632206104
GameObject: GameObject:
m_ObjectHideFlags: 0 m_ObjectHideFlags: 0
@ -3733,7 +3649,7 @@ Transform:
m_GameObject: {fileID: 1186082399} m_GameObject: {fileID: 1186082399}
serializedVersion: 2 serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalPosition: {x: 0, y: 0, z: -30}
m_LocalScale: {x: 1, y: 1, z: 1} m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0 m_ConstrainProportionsScale: 0
m_Children: m_Children:
@ -6194,4 +6110,3 @@ SceneRoots:
- {fileID: 1298430417} - {fileID: 1298430417}
- {fileID: 789249236} - {fileID: 789249236}
- {fileID: 679132025} - {fileID: 679132025}
- {fileID: 520045884}

View File

@ -118,7 +118,18 @@ public sealed class ClientAuthoritativePlayerStateSnapshot
public int Hp { get; } public int Hp { get; }
public Quaternion RotationQuaternion => Quaternion.Euler(0f, Rotation, 0f); public Quaternion RotationQuaternion => Quaternion.Euler(0f, NormalizeDegrees(90f - Rotation), 0f);
private static float NormalizeDegrees(float degrees)
{
var normalized = degrees % 360f;
if (normalized < 0f)
{
normalized += 360f;
}
return normalized;
}
} }
public sealed class ClientCombatPresentationSnapshot public sealed class ClientCombatPresentationSnapshot

View File

@ -23,8 +23,8 @@ public static class ClientGameplayInputFlow
{ {
PlayerId = playerId, PlayerId = playerId,
Tick = tick, Tick = tick,
MoveX = input.x, TurnInput = -input.x,
MoveY = input.z ThrottleInput = input.z
}; };
return true; return true;
} }
@ -104,6 +104,8 @@ public class MovementComponent : MonoBehaviour
{ {
[SerializeField] private float _sendInterval = 0.05f; [SerializeField] private float _sendInterval = 0.05f;
private Player _master; private Player _master;
private const float TurnSpeedDegreesPerSecond = 180f;
private const float UnityYawOffsetDegrees = 90f;
private int _speed = 2; private int _speed = 2;
[SerializeField] private Rigidbody _rigid; [SerializeField] private Rigidbody _rigid;
private float _lastSendTime = 0; private float _lastSendTime = 0;
@ -131,9 +133,10 @@ public class MovementComponent : MonoBehaviour
_isControlled = isControlled; _isControlled = isControlled;
_speed = speed; _speed = speed;
_startTickOffset = serverTick; _startTickOffset = serverTick;
_rigid.interpolation = RigidbodyInterpolation.Interpolate; _rigid.interpolation = isControlled ? RigidbodyInterpolation.None : RigidbodyInterpolation.Interpolate;
_rigid.isKinematic = !isControlled; _rigid.isKinematic = !isControlled;
_rigid.velocity = Vector3.zero; _rigid.velocity = Vector3.zero;
_rigid.angularVelocity = Vector3.zero;
if (serverTick != 0 && _isControlled && MainUI.Instance != null) MainUI.Instance.OnStartTickOffsetChanged(serverTick); if (serverTick != 0 && _isControlled && MainUI.Instance != null) MainUI.Instance.OnStartTickOffsetChanged(serverTick);
} }
@ -145,7 +148,6 @@ public class MovementComponent : MonoBehaviour
var hasMovement = ClientGameplayInputFlow.HasPlanarInput(_cachedMoveInput); var hasMovement = ClientGameplayInputFlow.HasPlanarInput(_cachedMoveInput);
if (hasMovement) if (hasMovement)
{ {
_lastAimDirection = _cachedMoveInput;
_stopMessagePending = false; _stopMessagePending = false;
} }
else if (_wasMovingLastFrame) else if (_wasMovingLastFrame)
@ -215,9 +217,10 @@ public class MovementComponent : MonoBehaviour
} }
_serverPosition = snapshot.Position; _serverPosition = snapshot.Position;
_rigid.position = Vector3.Lerp(_rigid.position, _serverPosition, _lerpRate); _rigid.position = _serverPosition;
_rigid.rotation = Quaternion.Slerp(_rigid.rotation, snapshot.RotationQuaternion, _lerpRate); _rigid.rotation = snapshot.RotationQuaternion;
_rigid.velocity = snapshot.Velocity; _rigid.velocity = snapshot.Velocity;
_rigid.angularVelocity = Vector3.zero;
ReplayPendingInputs(replayInputs); ReplayPendingInputs(replayInputs);
} }
@ -240,19 +243,19 @@ public class MovementComponent : MonoBehaviour
private Vector3 ResolveAimDirection() private Vector3 ResolveAimDirection()
{ {
if (ClientGameplayInputFlow.HasPlanarInput(_lastAimDirection)) var planarForward = Vector3.ProjectOnPlane(_rigid.transform.forward, Vector3.up);
if (ClientGameplayInputFlow.HasPlanarInput(planarForward))
{ {
return _lastAimDirection; _lastAimDirection = planarForward;
return planarForward;
} }
var forward = _master != null ? _master.transform.forward : transform.forward; return ClientGameplayInputFlow.HasPlanarInput(_lastAimDirection) ? _lastAimDirection : ResolveHeadingForward(UnityYawToHeading(_rigid.rotation.eulerAngles.y));
var planarForward = new Vector3(forward.x, 0f, forward.z);
return ClientGameplayInputFlow.HasPlanarInput(planarForward) ? planarForward : Vector3.forward;
} }
private void Simulate(Vector3 input) private void Simulate(Vector3 input)
{ {
_rigid.velocity = _speed * input; ApplyTankMovement(-input.x, input.z, Time.fixedDeltaTime);
if (_isControlled) if (_isControlled)
{ {
if (MainUI.Instance != null) if (MainUI.Instance != null)
@ -301,7 +304,7 @@ public class MovementComponent : MonoBehaviour
{ {
foreach (var replayInput in replayInputs) foreach (var replayInput in replayInputs)
{ {
_rigid.position += _speed * new Vector3(replayInput.MoveX, 0f, replayInput.MoveY) * _sendInterval; ApplyTankMovement(replayInput.TurnInput, replayInput.ThrottleInput, _sendInterval);
} }
if (_isControlled) if (_isControlled)
@ -312,4 +315,50 @@ public class MovementComponent : MonoBehaviour
} }
} }
} }
private void ApplyTankMovement(float turnInput, float throttleInput, float deltaTime)
{
if (deltaTime <= 0f)
{
_rigid.velocity = Vector3.zero;
return;
}
var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f);
var clampedThrottleInput = Mathf.Clamp(throttleInput, -1f, 1f);
var heading = NormalizeDegrees(UnityYawToHeading(_rigid.rotation.eulerAngles.y) + (clampedTurnInput * TurnSpeedDegreesPerSecond * deltaTime));
_rigid.rotation = Quaternion.Euler(0f, HeadingToUnityYaw(heading), 0f);
var forward = ResolveHeadingForward(heading);
var velocity = forward * (clampedThrottleInput * _speed);
_rigid.velocity = velocity;
_rigid.position += velocity * deltaTime;
}
private static Vector3 ResolveHeadingForward(float headingDegrees)
{
var rotationRadians = headingDegrees * Mathf.Deg2Rad;
return new Vector3(Mathf.Cos(rotationRadians), 0f, Mathf.Sin(rotationRadians));
}
private static float HeadingToUnityYaw(float headingDegrees)
{
return NormalizeDegrees(UnityYawOffsetDegrees - headingDegrees);
}
private static float UnityYawToHeading(float unityYawDegrees)
{
return NormalizeDegrees(UnityYawOffsetDegrees - unityYawDegrees);
}
private static float NormalizeDegrees(float degrees)
{
var normalized = degrees % 360f;
if (normalized < 0f)
{
normalized += 360f;
}
return normalized;
}
} }

View File

@ -60,7 +60,7 @@ namespace Network.Defines {
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.LoginResponse), global::Network.Defines.LoginResponse.Parser, new[]{ "PlayerId", "Positions", "Speed", "ServerTick", "Result" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.LoginResponse), global::Network.Defines.LoginResponse.Parser, new[]{ "PlayerId", "Positions", "Speed", "ServerTick", "Result" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.PlayerJoin), global::Network.Defines.PlayerJoin.Parser, new[]{ "PlayerId", "Position" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.PlayerJoin), global::Network.Defines.PlayerJoin.Parser, new[]{ "PlayerId", "Position" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.LogoutRequest), global::Network.Defines.LogoutRequest.Parser, new[]{ "PlayerId" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.LogoutRequest), global::Network.Defines.LogoutRequest.Parser, new[]{ "PlayerId" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.MoveInput), global::Network.Defines.MoveInput.Parser, new[]{ "PlayerId", "Tick", "MoveX", "MoveY" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.MoveInput), global::Network.Defines.MoveInput.Parser, new[]{ "PlayerId", "Tick", "TurnInput", "ThrottleInput" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.ShootInput), global::Network.Defines.ShootInput.Parser, new[]{ "PlayerId", "Tick", "DirX", "DirY", "TargetId" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.ShootInput), global::Network.Defines.ShootInput.Parser, new[]{ "PlayerId", "Tick", "DirX", "DirY", "TargetId" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.CombatEvent), global::Network.Defines.CombatEvent.Parser, new[]{ "Tick", "EventType", "AttackerId", "TargetId", "Damage", "HitPosition" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.CombatEvent), global::Network.Defines.CombatEvent.Parser, new[]{ "Tick", "EventType", "AttackerId", "TargetId", "Damage", "HitPosition" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.PlayerState), global::Network.Defines.PlayerState.Parser, new[]{ "PlayerId", "Position", "Velocity", "Rotation", "Tick", "Hp" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.PlayerState), global::Network.Defines.PlayerState.Parser, new[]{ "PlayerId", "Position", "Velocity", "Rotation", "Tick", "Hp" }, null, null, null, null),
@ -1628,8 +1628,8 @@ namespace Network.Defines {
public MoveInput(MoveInput other) : this() { public MoveInput(MoveInput other) : this() {
playerId_ = other.playerId_; playerId_ = other.playerId_;
tick_ = other.tick_; tick_ = other.tick_;
moveX_ = other.moveX_; turnInput_ = other.turnInput_;
moveY_ = other.moveY_; throttleInput_ = other.throttleInput_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
} }
@ -1663,27 +1663,27 @@ namespace Network.Defines {
} }
} }
/// <summary>Field number for the "move_x" field.</summary> /// <summary>Field number for the "turn_input" field.</summary>
public const int MoveXFieldNumber = 3; public const int TurnInputFieldNumber = 3;
private float moveX_; private float turnInput_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public float MoveX { public float TurnInput {
get { return moveX_; } get { return turnInput_; }
set { set {
moveX_ = value; turnInput_ = value;
} }
} }
/// <summary>Field number for the "move_y" field.</summary> /// <summary>Field number for the "throttle_input" field.</summary>
public const int MoveYFieldNumber = 4; public const int ThrottleInputFieldNumber = 4;
private float moveY_; private float throttleInput_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public float MoveY { public float ThrottleInput {
get { return moveY_; } get { return throttleInput_; }
set { set {
moveY_ = value; throttleInput_ = value;
} }
} }
@ -1704,8 +1704,8 @@ namespace Network.Defines {
} }
if (PlayerId != other.PlayerId) return false; if (PlayerId != other.PlayerId) return false;
if (Tick != other.Tick) return false; if (Tick != other.Tick) return false;
if (!pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.Equals(MoveX, other.MoveX)) return false; if (!pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.Equals(TurnInput, other.TurnInput)) return false;
if (!pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.Equals(MoveY, other.MoveY)) return false; if (!pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.Equals(ThrottleInput, other.ThrottleInput)) return false;
return Equals(_unknownFields, other._unknownFields); return Equals(_unknownFields, other._unknownFields);
} }
@ -1715,8 +1715,8 @@ namespace Network.Defines {
int hash = 1; int hash = 1;
if (PlayerId.Length != 0) hash ^= PlayerId.GetHashCode(); if (PlayerId.Length != 0) hash ^= PlayerId.GetHashCode();
if (Tick != 0L) hash ^= Tick.GetHashCode(); if (Tick != 0L) hash ^= Tick.GetHashCode();
if (MoveX != 0F) hash ^= pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.GetHashCode(MoveX); if (TurnInput != 0F) hash ^= pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.GetHashCode(TurnInput);
if (MoveY != 0F) hash ^= pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.GetHashCode(MoveY); if (ThrottleInput != 0F) hash ^= pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.GetHashCode(ThrottleInput);
if (_unknownFields != null) { if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode(); hash ^= _unknownFields.GetHashCode();
} }
@ -1743,13 +1743,13 @@ namespace Network.Defines {
output.WriteRawTag(16); output.WriteRawTag(16);
output.WriteInt64(Tick); output.WriteInt64(Tick);
} }
if (MoveX != 0F) { if (TurnInput != 0F) {
output.WriteRawTag(29); output.WriteRawTag(29);
output.WriteFloat(MoveX); output.WriteFloat(TurnInput);
} }
if (MoveY != 0F) { if (ThrottleInput != 0F) {
output.WriteRawTag(37); output.WriteRawTag(37);
output.WriteFloat(MoveY); output.WriteFloat(ThrottleInput);
} }
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(output); _unknownFields.WriteTo(output);
@ -1769,13 +1769,13 @@ namespace Network.Defines {
output.WriteRawTag(16); output.WriteRawTag(16);
output.WriteInt64(Tick); output.WriteInt64(Tick);
} }
if (MoveX != 0F) { if (TurnInput != 0F) {
output.WriteRawTag(29); output.WriteRawTag(29);
output.WriteFloat(MoveX); output.WriteFloat(TurnInput);
} }
if (MoveY != 0F) { if (ThrottleInput != 0F) {
output.WriteRawTag(37); output.WriteRawTag(37);
output.WriteFloat(MoveY); output.WriteFloat(ThrottleInput);
} }
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(ref output); _unknownFields.WriteTo(ref output);
@ -1793,10 +1793,10 @@ namespace Network.Defines {
if (Tick != 0L) { if (Tick != 0L) {
size += 1 + pb::CodedOutputStream.ComputeInt64Size(Tick); size += 1 + pb::CodedOutputStream.ComputeInt64Size(Tick);
} }
if (MoveX != 0F) { if (TurnInput != 0F) {
size += 1 + 4; size += 1 + 4;
} }
if (MoveY != 0F) { if (ThrottleInput != 0F) {
size += 1 + 4; size += 1 + 4;
} }
if (_unknownFields != null) { if (_unknownFields != null) {
@ -1817,11 +1817,11 @@ namespace Network.Defines {
if (other.Tick != 0L) { if (other.Tick != 0L) {
Tick = other.Tick; Tick = other.Tick;
} }
if (other.MoveX != 0F) { if (other.TurnInput != 0F) {
MoveX = other.MoveX; TurnInput = other.TurnInput;
} }
if (other.MoveY != 0F) { if (other.ThrottleInput != 0F) {
MoveY = other.MoveY; ThrottleInput = other.ThrottleInput;
} }
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
} }
@ -1851,11 +1851,11 @@ namespace Network.Defines {
break; break;
} }
case 29: { case 29: {
MoveX = input.ReadFloat(); TurnInput = input.ReadFloat();
break; break;
} }
case 37: { case 37: {
MoveY = input.ReadFloat(); ThrottleInput = input.ReadFloat();
break; break;
} }
} }
@ -1886,11 +1886,11 @@ namespace Network.Defines {
break; break;
} }
case 29: { case 29: {
MoveX = input.ReadFloat(); TurnInput = input.ReadFloat();
break; break;
} }
case 37: { case 37: {
MoveY = input.ReadFloat(); ThrottleInput = input.ReadFloat();
break; break;
} }
} }

View File

@ -4,33 +4,17 @@ namespace Network.Defines
{ {
Unknow = 0, Unknow = 0,
// Gameplay // Canonical dedicated-server MVP runtime vocabulary.
MoveInput = 1, MoveInput = 1,
PlayerState = 2, PlayerState = 2,
ShootInput = 3, ShootInput = 3,
CombatEvent = 4, CombatEvent = 4,
PlayerJoin = 5, PlayerJoin = 5,
PlayerLeave = 6,
PlayerAction = 7,
GameState = 8,
// Chat
ChatMessage = 10,
PrivateMessage = 11,
SystemMessage = 12,
// Session
HeartBeat = 20,
LoginRequest = 21, LoginRequest = 21,
LoginResponse = 22, LoginResponse = 22,
LogoutRequest = 23, LogoutRequest = 23,
// Room management
CreateRoom = 30,
JoinRoom = 31,
LeaveRoom = 32,
RoomList = 33,
Heartbeat = 40, Heartbeat = 40,
HeartbeatResponse = 41, HeartbeatResponse = 41,
} }

View File

@ -39,8 +39,8 @@ message LogoutRequest {
message MoveInput { message MoveInput {
string player_id = 1; string player_id = 1;
int64 tick = 2; int64 tick = 2;
float move_x = 3; float turn_input = 3;
float move_y = 4; float throttle_input = 4;
} }
message ShootInput { message ShootInput {

View File

@ -1,4 +1,4 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 3dc7b1ecbad541ea86a9d700dd5148e0 guid: 3dc7b1ecbad541ea86a9d700dd5148e0
MonoImporter: MonoImporter:
externalObjects: {} externalObjects: {}

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
@ -87,9 +87,10 @@ namespace Network.NetworkApplication
if (sessionManager.State == ConnectionState.Disconnected) if (sessionManager.State == ConnectionState.Disconnected)
{ {
sessionManager.NotifyTransportConnected(); sessionManager.NotifyTransportConnected();
return;
} }
sessionManager.NotifyInboundActivity(); sessionManager.NotifyTransportActivity();
} }
public void NotifyTransportConnected(IPEndPoint remoteEndPoint) public void NotifyTransportConnected(IPEndPoint remoteEndPoint)

View File

@ -60,7 +60,8 @@ namespace Network.NetworkApplication
SyncSequenceTracker syncSequenceTracker = null, SyncSequenceTracker syncSequenceTracker = null,
Func<int, ITransport> transportFactory = null, Func<int, ITransport> transportFactory = null,
ServerAuthoritativeMovementConfiguration authoritativeMovement = null, ServerAuthoritativeMovementConfiguration authoritativeMovement = null,
ServerAuthoritativeCombatConfiguration authoritativeCombat = null) ServerAuthoritativeCombatConfiguration authoritativeCombat = null,
IAuthoritativeMovementWorldValidator authoritativeMovementWorldValidator = null)
{ {
ValidateDualPortConfiguration(reliablePort, syncPort); ValidateDualPortConfiguration(reliablePort, syncPort);
@ -86,7 +87,8 @@ namespace Network.NetworkApplication
deliveryPolicyResolver, deliveryPolicyResolver,
syncSequenceTracker, syncSequenceTracker,
authoritativeMovement, authoritativeMovement,
authoritativeCombat); authoritativeCombat,
authoritativeMovementWorldValidator ?? PermissiveAuthoritativeMovementWorldValidator.Instance);
} }
public static Task<ServerRuntimeHandle> StartServerRuntimeAsync(ServerRuntimeConfiguration configuration) public static Task<ServerRuntimeHandle> StartServerRuntimeAsync(ServerRuntimeConfiguration configuration)

View File

@ -1,11 +1,12 @@
using System; using System;
namespace Network.NetworkApplication namespace Network.NetworkApplication
{ {
public sealed class SessionManager public sealed class SessionManager
{ {
private readonly Func<DateTimeOffset> utcNowProvider; private readonly Func<DateTimeOffset> utcNowProvider;
private DateTimeOffset? lastLivenessUtc; private DateTimeOffset? lastAcceptedLivenessUtc;
private DateTimeOffset? lastTransportActivityUtc;
private DateTimeOffset? lastHeartbeatSentUtc; private DateTimeOffset? lastHeartbeatSentUtc;
private DateTimeOffset? nextReconnectAtUtc; private DateTimeOffset? nextReconnectAtUtc;
@ -24,7 +25,9 @@ namespace Network.NetworkApplication
public SessionReconnectPolicy ReconnectPolicy { get; } public SessionReconnectPolicy ReconnectPolicy { get; }
public DateTimeOffset? LastLivenessUtc => lastLivenessUtc; public DateTimeOffset? LastLivenessUtc => lastAcceptedLivenessUtc;
public DateTimeOffset? LastTransportActivityUtc => lastTransportActivityUtc;
public DateTimeOffset? LastHeartbeatSentUtc => lastHeartbeatSentUtc; public DateTimeOffset? LastHeartbeatSentUtc => lastHeartbeatSentUtc;
@ -70,13 +73,18 @@ namespace Network.NetworkApplication
public void NotifyTransportConnected() public void NotifyTransportConnected()
{ {
var now = utcNowProvider(); var now = utcNowProvider();
lastLivenessUtc = now; lastTransportActivityUtc = now;
lastHeartbeatSentUtc = null; lastHeartbeatSentUtc = null;
nextReconnectAtUtc = null; nextReconnectAtUtc = null;
LastFailureReason = null; LastFailureReason = null;
TransitionTo(ConnectionState.TransportConnected, SessionEventKind.TransportConnected, now); TransitionTo(ConnectionState.TransportConnected, SessionEventKind.TransportConnected, now);
} }
public void NotifyTransportActivity()
{
lastTransportActivityUtc = utcNowProvider();
}
public void NotifyLoginStarted() public void NotifyLoginStarted()
{ {
TransitionTo(ConnectionState.LoginPending, SessionEventKind.LoginStarted, utcNowProvider()); TransitionTo(ConnectionState.LoginPending, SessionEventKind.LoginStarted, utcNowProvider());
@ -85,7 +93,8 @@ namespace Network.NetworkApplication
public void NotifyLoginSucceeded() public void NotifyLoginSucceeded()
{ {
var now = utcNowProvider(); var now = utcNowProvider();
lastLivenessUtc = now; lastTransportActivityUtc = now;
lastAcceptedLivenessUtc = now;
LastFailureReason = null; LastFailureReason = null;
TransitionTo(ConnectionState.LoggedIn, SessionEventKind.LoginSucceeded, now); TransitionTo(ConnectionState.LoggedIn, SessionEventKind.LoginSucceeded, now);
} }
@ -105,7 +114,8 @@ namespace Network.NetworkApplication
public void NotifyHeartbeatReceived() public void NotifyHeartbeatReceived()
{ {
var now = utcNowProvider(); var now = utcNowProvider();
lastLivenessUtc = now; lastTransportActivityUtc = now;
lastAcceptedLivenessUtc = now;
if (lastHeartbeatSentUtc.HasValue) if (lastHeartbeatSentUtc.HasValue)
{ {
LastRoundTripTime = now - lastHeartbeatSentUtc.Value; LastRoundTripTime = now - lastHeartbeatSentUtc.Value;
@ -116,7 +126,9 @@ namespace Network.NetworkApplication
public void NotifyInboundActivity() public void NotifyInboundActivity()
{ {
lastLivenessUtc = utcNowProvider(); var now = utcNowProvider();
lastTransportActivityUtc = now;
lastAcceptedLivenessUtc = now;
} }
public void NotifyTransportDisconnected(string reason = null) public void NotifyTransportDisconnected(string reason = null)
@ -152,17 +164,18 @@ namespace Network.NetworkApplication
private bool ShouldTimeout(DateTimeOffset now) private bool ShouldTimeout(DateTimeOffset now)
{ {
if (State != ConnectionState.TransportConnected && State != ConnectionState.LoginPending && State != ConnectionState.LoggedIn) switch (State)
{ {
case ConnectionState.TransportConnected:
case ConnectionState.LoginPending:
return lastTransportActivityUtc.HasValue &&
now - lastTransportActivityUtc.Value >= ReconnectPolicy.HeartbeatTimeout;
case ConnectionState.LoggedIn:
return lastAcceptedLivenessUtc.HasValue &&
now - lastAcceptedLivenessUtc.Value >= ReconnectPolicy.HeartbeatTimeout;
default:
return false; return false;
} }
if (!lastLivenessUtc.HasValue)
{
return false;
}
return now - lastLivenessUtc.Value >= ReconnectPolicy.HeartbeatTimeout;
} }
private void TransitionTo( private void TransitionTo(

View File

@ -0,0 +1,56 @@
using System;
using System.Net;
namespace Network.NetworkHost
{
public sealed class AuthoritativeMovementWorldValidationRequest
{
public AuthoritativeMovementWorldValidationRequest(
IPEndPoint remoteEndPoint,
string playerId,
float currentPositionX,
float currentPositionY,
float currentPositionZ,
float candidatePositionX,
float candidatePositionY,
float candidatePositionZ,
float velocityX,
float velocityY,
float velocityZ)
{
RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint));
PlayerId = playerId ?? throw new ArgumentNullException(nameof(playerId));
CurrentPositionX = currentPositionX;
CurrentPositionY = currentPositionY;
CurrentPositionZ = currentPositionZ;
CandidatePositionX = candidatePositionX;
CandidatePositionY = candidatePositionY;
CandidatePositionZ = candidatePositionZ;
VelocityX = velocityX;
VelocityY = velocityY;
VelocityZ = velocityZ;
}
public IPEndPoint RemoteEndPoint { get; }
public string PlayerId { get; }
public float CurrentPositionX { get; }
public float CurrentPositionY { get; }
public float CurrentPositionZ { get; }
public float CandidatePositionX { get; }
public float CandidatePositionY { get; }
public float CandidatePositionZ { get; }
public float VelocityX { get; }
public float VelocityY { get; }
public float VelocityZ { get; }
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 44fc9b6a7ea518c419516af9014d6a46
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,22 @@
namespace Network.NetworkHost
{
public readonly struct AuthoritativeMovementWorldValidationResult
{
private AuthoritativeMovementWorldValidationResult(bool isAllowed)
{
IsAllowed = isAllowed;
}
public bool IsAllowed { get; }
public static AuthoritativeMovementWorldValidationResult Allow()
{
return new AuthoritativeMovementWorldValidationResult(true);
}
public static AuthoritativeMovementWorldValidationResult Reject()
{
return new AuthoritativeMovementWorldValidationResult(false);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4838ecf8df980f8498d225a15ab84df3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,7 @@
namespace Network.NetworkHost
{
public interface IAuthoritativeMovementWorldValidator
{
AuthoritativeMovementWorldValidationResult Validate(AuthoritativeMovementWorldValidationRequest request);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b6ae924c07d515d44b00298c08c121b2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,23 @@
using System;
namespace Network.NetworkHost
{
public sealed class PermissiveAuthoritativeMovementWorldValidator : IAuthoritativeMovementWorldValidator
{
private PermissiveAuthoritativeMovementWorldValidator()
{
}
public static PermissiveAuthoritativeMovementWorldValidator Instance { get; } = new PermissiveAuthoritativeMovementWorldValidator();
public AuthoritativeMovementWorldValidationResult Validate(AuthoritativeMovementWorldValidationRequest request)
{
if (request == null)
{
throw new ArgumentNullException(nameof(request));
}
return AuthoritativeMovementWorldValidationResult.Allow();
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 251269ca4373a134b955c8a2a5786f95
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -11,16 +11,19 @@ namespace Network.NetworkHost
internal sealed class ServerAuthoritativeCombatCoordinator internal sealed class ServerAuthoritativeCombatCoordinator
{ {
private readonly object gate = new(); private readonly object gate = new();
private readonly ServerNetworkHost host;
private readonly MessageManager messageManager; private readonly MessageManager messageManager;
private readonly ServerAuthoritativeMovementCoordinator movementCoordinator; private readonly ServerAuthoritativeMovementCoordinator movementCoordinator;
private readonly Dictionary<string, ServerAuthoritativeCombatState> statesByPeer = new(); private readonly Dictionary<string, ServerAuthoritativeCombatState> statesByPeer = new();
private readonly ServerAuthoritativeCombatConfiguration configuration; private readonly ServerAuthoritativeCombatConfiguration configuration;
public ServerAuthoritativeCombatCoordinator( public ServerAuthoritativeCombatCoordinator(
ServerNetworkHost host,
MessageManager messageManager, MessageManager messageManager,
ServerAuthoritativeMovementCoordinator movementCoordinator, ServerAuthoritativeMovementCoordinator movementCoordinator,
ServerAuthoritativeCombatConfiguration configuration) ServerAuthoritativeCombatConfiguration configuration)
{ {
this.host = host ?? throw new ArgumentNullException(nameof(host));
this.messageManager = messageManager ?? throw new ArgumentNullException(nameof(messageManager)); this.messageManager = messageManager ?? throw new ArgumentNullException(nameof(messageManager));
this.movementCoordinator = movementCoordinator ?? throw new ArgumentNullException(nameof(movementCoordinator)); this.movementCoordinator = movementCoordinator ?? throw new ArgumentNullException(nameof(movementCoordinator));
this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
@ -56,13 +59,13 @@ namespace Network.NetworkHost
return Task.CompletedTask; return Task.CompletedTask;
} }
if (!TryValidateAcceptedShot(input, sender, out var attackerState, out var targetState)) if (!TryValidateAcceptedShot(input, sender, out var acceptedPeer, out var attackerState, out var targetState))
{ {
BroadcastRejectedShot(input); BroadcastRejectedShot(input);
return Task.CompletedTask; return Task.CompletedTask;
} }
movementCoordinator.TryUpdateState(sender, state => movementCoordinator.TryUpdateState(acceptedPeer, state =>
{ {
state.LastAcceptedShootTick = input.Tick; state.LastAcceptedShootTick = input.Tick;
}, out attackerState); }, out attackerState);
@ -85,6 +88,7 @@ namespace Network.NetworkHost
Z = targetState.PositionZ Z = targetState.PositionZ
}; };
// Keep the coordinator as the only gameplay-relevant authoritative combat-result emitter.
messageManager.BroadcastMessage(new CombatEvent messageManager.BroadcastMessage(new CombatEvent
{ {
Tick = input.Tick, Tick = input.Tick,
@ -157,16 +161,19 @@ namespace Network.NetworkHost
private bool TryValidateAcceptedShot( private bool TryValidateAcceptedShot(
ShootInput input, ShootInput input,
IPEndPoint sender, IPEndPoint sender,
out IPEndPoint acceptedPeer,
out ServerAuthoritativeMovementState attackerState, out ServerAuthoritativeMovementState attackerState,
out ServerAuthoritativeMovementState targetState) out ServerAuthoritativeMovementState targetState)
{ {
acceptedPeer = null;
attackerState = null; attackerState = null;
targetState = null; targetState = null;
if (input == null || if (input == null ||
string.IsNullOrWhiteSpace(input.PlayerId) || string.IsNullOrWhiteSpace(input.PlayerId) ||
!IsFinite(input.DirX) || !IsFinite(input.DirX) ||
!IsFinite(input.DirY)) !IsFinite(input.DirY) ||
!host.TryResolveAcceptedPeer(sender, input.PlayerId, out acceptedPeer))
{ {
return false; return false;
} }
@ -177,8 +184,8 @@ namespace Network.NetworkHost
return false; return false;
} }
if (!movementCoordinator.TryGetState(sender, out attackerState) && if (!movementCoordinator.TryGetState(acceptedPeer, out attackerState) &&
!movementCoordinator.EnsureState(sender, input.PlayerId, out attackerState)) !movementCoordinator.EnsureState(acceptedPeer, input.PlayerId, out attackerState))
{ {
return false; return false;
} }
@ -191,7 +198,12 @@ namespace Network.NetworkHost
return false; return false;
} }
return TryResolveTargetState(input, attackerState, out targetState); if (!TryResolveTargetState(input, attackerState, out targetState))
{
return false;
}
return host.TryRefreshAcceptedGameplayActivity(sender, input.PlayerId);
} }
private bool TryResolveTargetState( private bool TryResolveTargetState(

View File

@ -6,6 +6,8 @@ namespace Network.NetworkHost
{ {
public float MoveSpeed { get; set; } = 5f; public float MoveSpeed { get; set; } = 5f;
public float TurnSpeedDegreesPerSecond { get; set; } = 180f;
public TimeSpan BroadcastInterval { get; set; } = TimeSpan.FromMilliseconds(50); public TimeSpan BroadcastInterval { get; set; } = TimeSpan.FromMilliseconds(50);
public int DefaultHp { get; set; } = 100; public int DefaultHp { get; set; } = 100;
@ -17,6 +19,11 @@ namespace Network.NetworkHost
throw new ArgumentOutOfRangeException(nameof(MoveSpeed), "Move speed must be finite and non-negative."); throw new ArgumentOutOfRangeException(nameof(MoveSpeed), "Move speed must be finite and non-negative.");
} }
if (float.IsNaN(TurnSpeedDegreesPerSecond) || float.IsInfinity(TurnSpeedDegreesPerSecond) || TurnSpeedDegreesPerSecond < 0f)
{
throw new ArgumentOutOfRangeException(nameof(TurnSpeedDegreesPerSecond), "Turn speed must be finite and non-negative.");
}
if (BroadcastInterval <= TimeSpan.Zero) if (BroadcastInterval <= TimeSpan.Zero)
{ {
throw new ArgumentOutOfRangeException(nameof(BroadcastInterval), "Broadcast interval must be positive."); throw new ArgumentOutOfRangeException(nameof(BroadcastInterval), "Broadcast interval must be positive.");

View File

@ -14,6 +14,7 @@ namespace Network.NetworkHost
private readonly MessageManager messageManager; private readonly MessageManager messageManager;
private readonly ServerNetworkHost host; private readonly ServerNetworkHost host;
private readonly ServerAuthoritativeMovementConfiguration configuration; private readonly ServerAuthoritativeMovementConfiguration configuration;
private readonly IAuthoritativeMovementWorldValidator worldValidator;
private readonly Dictionary<string, ServerAuthoritativeMovementState> statesByPeer = new(); private readonly Dictionary<string, ServerAuthoritativeMovementState> statesByPeer = new();
private long nextBroadcastTick = 1; private long nextBroadcastTick = 1;
private TimeSpan accumulatedBroadcastTime; private TimeSpan accumulatedBroadcastTime;
@ -21,11 +22,13 @@ namespace Network.NetworkHost
public ServerAuthoritativeMovementCoordinator( public ServerAuthoritativeMovementCoordinator(
ServerNetworkHost host, ServerNetworkHost host,
MessageManager messageManager, MessageManager messageManager,
ServerAuthoritativeMovementConfiguration configuration) ServerAuthoritativeMovementConfiguration configuration,
IAuthoritativeMovementWorldValidator worldValidator)
{ {
this.host = host ?? throw new ArgumentNullException(nameof(host)); this.host = host ?? throw new ArgumentNullException(nameof(host));
this.messageManager = messageManager ?? throw new ArgumentNullException(nameof(messageManager)); this.messageManager = messageManager ?? throw new ArgumentNullException(nameof(messageManager));
this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
this.worldValidator = worldValidator ?? throw new ArgumentNullException(nameof(worldValidator));
} }
public IReadOnlyList<ServerAuthoritativeMovementState> States public IReadOnlyList<ServerAuthoritativeMovementState> States
@ -48,7 +51,7 @@ namespace Network.NetworkHost
throw new ArgumentNullException(nameof(remoteEndPoint)); throw new ArgumentNullException(nameof(remoteEndPoint));
} }
if (string.IsNullOrWhiteSpace(playerId)) if (string.IsNullOrWhiteSpace(playerId) || !host.IsAcceptedPlayer(remoteEndPoint, playerId))
{ {
state = null; state = null;
return false; return false;
@ -99,13 +102,14 @@ namespace Network.NetworkHost
} }
if (string.IsNullOrWhiteSpace(input.PlayerId) || if (string.IsNullOrWhiteSpace(input.PlayerId) ||
!IsFinite(input.MoveX) || !IsFinite(input.TurnInput) ||
!IsFinite(input.MoveY)) !IsFinite(input.ThrottleInput) ||
!host.TryResolveAcceptedPeer(sender, input.PlayerId, out var acceptedPeer))
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
var normalizedSender = Normalize(sender); var normalizedSender = Normalize(acceptedPeer);
var key = normalizedSender.ToString(); var key = normalizedSender.ToString();
lock (gate) lock (gate)
@ -113,7 +117,8 @@ namespace Network.NetworkHost
if (statesByPeer.TryGetValue(key, out var existingState)) if (statesByPeer.TryGetValue(key, out var existingState))
{ {
if (!string.Equals(existingState.PlayerId, input.PlayerId, StringComparison.Ordinal) || if (!string.Equals(existingState.PlayerId, input.PlayerId, StringComparison.Ordinal) ||
input.Tick <= existingState.LastAcceptedMoveTick) input.Tick <= existingState.LastAcceptedMoveTick ||
!host.TryRefreshAcceptedGameplayActivity(sender, input.PlayerId))
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -122,6 +127,11 @@ namespace Network.NetworkHost
return Task.CompletedTask; return Task.CompletedTask;
} }
if (!host.TryRefreshAcceptedGameplayActivity(sender, input.PlayerId))
{
return Task.CompletedTask;
}
var state = new ServerAuthoritativeMovementState( var state = new ServerAuthoritativeMovementState(
normalizedSender, normalizedSender,
input.PlayerId, input.PlayerId,
@ -172,6 +182,7 @@ namespace Network.NetworkHost
foreach (var pendingBroadcast in pendingBroadcasts) foreach (var pendingBroadcast in pendingBroadcasts)
{ {
// Keep the coordinator as the only gameplay-relevant PlayerState broadcast path.
messageManager.BroadcastMessage(pendingBroadcast.PlayerState, MessageType.PlayerState); messageManager.BroadcastMessage(pendingBroadcast.PlayerState, MessageType.PlayerState);
host.ObserveAuthoritativeState(pendingBroadcast.RemoteEndPoint, pendingBroadcast.PlayerState.Tick); host.ObserveAuthoritativeState(pendingBroadcast.RemoteEndPoint, pendingBroadcast.PlayerState.Tick);
} }
@ -330,32 +341,20 @@ namespace Network.NetworkHost
private static void ApplyInput(ServerAuthoritativeMovementState state, MoveInput input) private static void ApplyInput(ServerAuthoritativeMovementState state, MoveInput input)
{ {
state.LastAcceptedMoveTick = input.Tick; state.LastAcceptedMoveTick = input.Tick;
state.InputX = input.MoveX; state.InputX = ClampInput(input.TurnInput);
state.InputY = input.MoveY; state.InputY = ClampInput(input.ThrottleInput);
if (input.MoveX == 0f && input.MoveY == 0f) if (state.InputY == 0f)
{ {
state.VelocityX = 0f; state.VelocityX = 0f;
state.VelocityY = 0f; state.VelocityY = 0f;
state.VelocityZ = 0f; state.VelocityZ = 0f;
return;
} }
var length = MathF.Sqrt((input.MoveX * input.MoveX) + (input.MoveY * input.MoveY));
if (length <= 0f)
{
state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
state.Rotation = MathF.Atan2(input.MoveY, input.MoveX) * (180f / MathF.PI);
} }
private void IntegrateState(ServerAuthoritativeMovementState state, TimeSpan elapsed) private void IntegrateState(ServerAuthoritativeMovementState state, TimeSpan elapsed)
{ {
if (state.IsDead || (state.InputX == 0f && state.InputY == 0f)) if (state.IsDead)
{ {
state.VelocityX = 0f; state.VelocityX = 0f;
state.VelocityY = 0f; state.VelocityY = 0f;
@ -363,25 +362,83 @@ namespace Network.NetworkHost
return; return;
} }
var length = MathF.Sqrt((state.InputX * state.InputX) + (state.InputY * state.InputY));
if (length <= 0f)
{
state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
var normalizedX = state.InputX / length;
var normalizedY = state.InputY / length;
state.VelocityX = normalizedX * configuration.MoveSpeed;
state.VelocityY = 0f;
state.VelocityZ = normalizedY * configuration.MoveSpeed;
var deltaSeconds = (float)elapsed.TotalSeconds; var deltaSeconds = (float)elapsed.TotalSeconds;
state.PositionX += state.VelocityX * deltaSeconds; if (deltaSeconds <= 0f)
state.PositionY += state.VelocityY * deltaSeconds; {
state.PositionZ += state.VelocityZ * deltaSeconds; state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
var turnInput = ClampInput(state.InputX);
var throttleInput = ClampInput(state.InputY);
if (turnInput != 0f)
{
state.Rotation = NormalizeDegrees(state.Rotation + (turnInput * configuration.TurnSpeedDegreesPerSecond * deltaSeconds));
}
if (throttleInput == 0f)
{
state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
var rotationRadians = state.Rotation * (MathF.PI / 180f);
var forwardX = MathF.Cos(rotationRadians);
var forwardZ = MathF.Sin(rotationRadians);
state.VelocityX = forwardX * (throttleInput * configuration.MoveSpeed);
state.VelocityY = 0f;
state.VelocityZ = forwardZ * (throttleInput * configuration.MoveSpeed);
var candidatePositionX = state.PositionX + (state.VelocityX * deltaSeconds);
var candidatePositionY = state.PositionY + (state.VelocityY * deltaSeconds);
var candidatePositionZ = state.PositionZ + (state.VelocityZ * deltaSeconds);
var validationResult = worldValidator.Validate(new AuthoritativeMovementWorldValidationRequest(
state.RemoteEndPoint,
state.PlayerId,
state.PositionX,
state.PositionY,
state.PositionZ,
candidatePositionX,
candidatePositionY,
candidatePositionZ,
state.VelocityX,
state.VelocityY,
state.VelocityZ));
if (!validationResult.IsAllowed)
{
state.VelocityX = 0f;
state.VelocityY = 0f;
state.VelocityZ = 0f;
return;
}
state.PositionX = candidatePositionX;
state.PositionY = candidatePositionY;
state.PositionZ = candidatePositionZ;
}
private static float ClampInput(float value)
{
return MathF.Max(-1f, MathF.Min(1f, value));
}
private static float NormalizeDegrees(float degrees)
{
var normalized = degrees % 360f;
if (normalized <= -180f)
{
normalized += 360f;
}
else if (normalized > 180f)
{
normalized -= 360f;
}
return normalized;
} }
private static PlayerState BuildPlayerState(ServerAuthoritativeMovementState state, long tick) private static PlayerState BuildPlayerState(ServerAuthoritativeMovementState state, long tick)

View File

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net; using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using Network.Defines; using Network.Defines;
@ -17,6 +18,8 @@ namespace Network.NetworkHost
private readonly ServerAuthoritativeCombatCoordinator authoritativeCombatCoordinator; private readonly ServerAuthoritativeCombatCoordinator authoritativeCombatCoordinator;
private readonly object playerIdentityGate = new(); private readonly object playerIdentityGate = new();
private readonly Dictionary<string, string> playerIdsByPeer = new(); private readonly Dictionary<string, string> playerIdsByPeer = new();
private readonly Dictionary<string, IPEndPoint> canonicalPeersByPlayerId = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> peerKeysByPlayerId = new(StringComparer.Ordinal);
public ServerNetworkHost( public ServerNetworkHost(
ITransport transport, ITransport transport,
@ -27,7 +30,8 @@ namespace Network.NetworkHost
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null, IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
SyncSequenceTracker syncSequenceTracker = null, SyncSequenceTracker syncSequenceTracker = null,
ServerAuthoritativeMovementConfiguration authoritativeMovement = null, ServerAuthoritativeMovementConfiguration authoritativeMovement = null,
ServerAuthoritativeCombatConfiguration authoritativeCombat = null) ServerAuthoritativeCombatConfiguration authoritativeCombat = null,
IAuthoritativeMovementWorldValidator authoritativeMovementWorldValidator = null)
{ {
this.transport = transport ?? throw new ArgumentNullException(nameof(transport)); this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
this.syncTransport = syncTransport; this.syncTransport = syncTransport;
@ -44,11 +48,14 @@ namespace Network.NetworkHost
deliveryPolicyResolver ?? new DefaultMessageDeliveryPolicyResolver(), deliveryPolicyResolver ?? new DefaultMessageDeliveryPolicyResolver(),
this.syncTransport, this.syncTransport,
syncSequenceTracker ?? new SyncSequenceTracker()); syncSequenceTracker ?? new SyncSequenceTracker());
var resolvedWorldValidator = authoritativeMovementWorldValidator ?? PermissiveAuthoritativeMovementWorldValidator.Instance;
authoritativeMovementCoordinator = new ServerAuthoritativeMovementCoordinator( authoritativeMovementCoordinator = new ServerAuthoritativeMovementCoordinator(
this, this,
messageManager, messageManager,
authoritativeMovement ?? new ServerAuthoritativeMovementConfiguration()); authoritativeMovement ?? new ServerAuthoritativeMovementConfiguration(),
resolvedWorldValidator);
authoritativeCombatCoordinator = new ServerAuthoritativeCombatCoordinator( authoritativeCombatCoordinator = new ServerAuthoritativeCombatCoordinator(
this,
messageManager, messageManager,
authoritativeMovementCoordinator, authoritativeMovementCoordinator,
authoritativeCombat ?? new ServerAuthoritativeCombatConfiguration()); authoritativeCombat ?? new ServerAuthoritativeCombatConfiguration());
@ -102,6 +109,8 @@ namespace Network.NetworkHost
lock (playerIdentityGate) lock (playerIdentityGate)
{ {
playerIdsByPeer.Clear(); playerIdsByPeer.Clear();
canonicalPeersByPlayerId.Clear();
peerKeysByPlayerId.Clear();
} }
PublishMetricsSessionSnapshots(); PublishMetricsSessionSnapshots();
} }
@ -137,6 +146,117 @@ namespace Network.NetworkHost
return authoritativeCombatCoordinator.TryGetState(remoteEndPoint, out state); return authoritativeCombatCoordinator.TryGetState(remoteEndPoint, out state);
} }
public bool TryGetAcceptedPlayerId(IPEndPoint remoteEndPoint, out string playerId)
{
return TryGetKnownPlayerId(remoteEndPoint, out playerId);
}
public bool IsAcceptedPlayer(IPEndPoint remoteEndPoint, string playerId)
{
return !string.IsNullOrWhiteSpace(playerId) &&
TryGetKnownPlayerId(remoteEndPoint, out var acceptedPlayerId) &&
string.Equals(acceptedPlayerId, playerId, StringComparison.Ordinal);
}
public bool TryResolveAcceptedPeer(IPEndPoint remoteEndPoint, string playerId, out IPEndPoint acceptedPeer)
{
acceptedPeer = null;
if (remoteEndPoint == null || string.IsNullOrWhiteSpace(playerId))
{
return false;
}
var normalizedRemoteEndPoint = Normalize(remoteEndPoint);
var remoteKey = normalizedRemoteEndPoint.ToString();
lock (playerIdentityGate)
{
if (playerIdsByPeer.TryGetValue(remoteKey, out var mappedPlayerId))
{
if (!string.Equals(mappedPlayerId, playerId, StringComparison.Ordinal))
{
return false;
}
if (!canonicalPeersByPlayerId.TryGetValue(playerId, out acceptedPeer))
{
acceptedPeer = normalizedRemoteEndPoint;
}
return true;
}
if (!canonicalPeersByPlayerId.TryGetValue(playerId, out acceptedPeer))
{
return false;
}
playerIdsByPeer[remoteKey] = playerId;
if (!peerKeysByPlayerId.TryGetValue(playerId, out var peerKeys))
{
peerKeys = new HashSet<string>(StringComparer.Ordinal);
peerKeysByPlayerId[playerId] = peerKeys;
}
peerKeys.Add(remoteKey);
return true;
}
}
public bool IsPlayerIdInUse(string playerId)
{
if (string.IsNullOrWhiteSpace(playerId))
{
return false;
}
lock (playerIdentityGate)
{
foreach (var acceptedPlayerId in playerIdsByPeer.Values)
{
if (string.Equals(acceptedPlayerId, playerId, StringComparison.Ordinal))
{
return true;
}
}
}
foreach (var state in authoritativeMovementCoordinator.States)
{
if (string.Equals(state.PlayerId, playerId, StringComparison.Ordinal))
{
return true;
}
}
foreach (var state in authoritativeCombatCoordinator.States)
{
if (string.Equals(state.PlayerId, playerId, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
public bool TryRefreshAcceptedGameplayActivity(IPEndPoint remoteEndPoint, string playerId)
{
if (!TryResolveAcceptedPeer(remoteEndPoint, playerId, out var acceptedPeer))
{
return false;
}
if (!TryGetSession(acceptedPeer, out var session) ||
session.SessionManager.State != ConnectionState.LoggedIn)
{
return false;
}
NotifyInboundActivity(acceptedPeer);
return true;
}
public void NotifyLoginStarted(IPEndPoint remoteEndPoint) public void NotifyLoginStarted(IPEndPoint remoteEndPoint)
{ {
SessionCoordinator.NotifyLoginStarted(remoteEndPoint); SessionCoordinator.NotifyLoginStarted(remoteEndPoint);
@ -159,7 +279,7 @@ namespace Network.NetworkHost
public void NotifyLoginFailed(IPEndPoint remoteEndPoint, string reason = null) public void NotifyLoginFailed(IPEndPoint remoteEndPoint, string reason = null)
{ {
SessionCoordinator.NotifyLoginFailed(remoteEndPoint, reason); SessionCoordinator.NotifyLoginFailed(remoteEndPoint, reason);
ForgetPlayerId(remoteEndPoint); ForgetPeerIdentity(remoteEndPoint);
PublishMetricsSessionSnapshot(remoteEndPoint); PublishMetricsSessionSnapshot(remoteEndPoint);
} }
@ -189,20 +309,34 @@ namespace Network.NetworkHost
public bool RemoveSession(IPEndPoint remoteEndPoint, string reason = null) public bool RemoveSession(IPEndPoint remoteEndPoint, string reason = null)
{ {
if (!SessionCoordinator.TryGetSession(remoteEndPoint, out var session)) if (!TryGetKnownPlayerId(remoteEndPoint, out var playerId) ||
!TryResolveAcceptedPeer(remoteEndPoint, playerId, out var acceptedPeer) ||
!SessionCoordinator.TryGetSession(acceptedPeer, out var session))
{ {
return false; return false;
} }
var removed = SessionCoordinator.RemoveSession(remoteEndPoint, reason); var removed = SessionCoordinator.RemoveSession(acceptedPeer, reason);
if (!removed) if (!removed)
{ {
return false; return false;
} }
authoritativeMovementCoordinator.RemoveState(remoteEndPoint); authoritativeMovementCoordinator.RemoveState(acceptedPeer);
authoritativeCombatCoordinator.RemoveState(remoteEndPoint); authoritativeCombatCoordinator.RemoveState(acceptedPeer);
ForgetPlayerId(remoteEndPoint);
var knownPeerEndpoints = GetKnownPeerEndpointsForPlayerId(playerId);
ForgetPlayerId(playerId);
foreach (var peerEndPoint in knownPeerEndpoints)
{
SessionCoordinator.RemoveSession(peerEndPoint, reason);
RemoveTransportPeerSession(transport, peerEndPoint);
if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
{
RemoveTransportPeerSession(syncTransport, peerEndPoint);
}
}
RecordMetricsSessionSnapshot(transport, "server-host", session, ConnectionState.Disconnected); RecordMetricsSessionSnapshot(transport, "server-host", session, ConnectionState.Disconnected);
if (syncTransport != null && !ReferenceEquals(syncTransport, transport)) if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
@ -216,10 +350,17 @@ namespace Network.NetworkHost
private void HandleTransportReceive(byte[] data, IPEndPoint sender) private void HandleTransportReceive(byte[] data, IPEndPoint sender)
{ {
SessionCoordinator.ObserveTransportActivity(sender); SessionCoordinator.ObserveTransportActivity(sender);
ObservePlayerIdentity(data, sender);
PublishMetricsSessionSnapshot(sender); PublishMetricsSessionSnapshot(sender);
} }
private static void RemoveTransportPeerSession(ITransport transport, IPEndPoint remoteEndPoint)
{
if (transport is IPeerSessionTransport peerSessionTransport)
{
peerSessionTransport.RemovePeerSession(remoteEndPoint);
}
}
private void BootstrapAuthoritativeMovementState(IPEndPoint remoteEndPoint) private void BootstrapAuthoritativeMovementState(IPEndPoint remoteEndPoint)
{ {
if (!TryGetKnownPlayerId(remoteEndPoint, out var playerId)) if (!TryGetKnownPlayerId(remoteEndPoint, out var playerId))
@ -230,41 +371,6 @@ namespace Network.NetworkHost
authoritativeMovementCoordinator.EnsureState(remoteEndPoint, playerId, out _); authoritativeMovementCoordinator.EnsureState(remoteEndPoint, playerId, out _);
} }
private void ObservePlayerIdentity(byte[] data, IPEndPoint sender)
{
if (data == null || sender == null)
{
return;
}
Envelope envelope;
try
{
envelope = Envelope.Parser.ParseFrom(data);
}
catch
{
return;
}
if ((MessageType)envelope.Type != MessageType.LoginRequest)
{
return;
}
LoginRequest request;
try
{
request = LoginRequest.Parser.ParseFrom(envelope.Payload);
}
catch
{
return;
}
RememberPlayerId(sender, request.PlayerId);
}
private void RememberPlayerId(IPEndPoint remoteEndPoint, string playerId) private void RememberPlayerId(IPEndPoint remoteEndPoint, string playerId)
{ {
if (remoteEndPoint == null || string.IsNullOrWhiteSpace(playerId)) if (remoteEndPoint == null || string.IsNullOrWhiteSpace(playerId))
@ -272,10 +378,19 @@ namespace Network.NetworkHost
return; return;
} }
var key = Normalize(remoteEndPoint).ToString(); var normalizedRemoteEndPoint = Normalize(remoteEndPoint);
var key = normalizedRemoteEndPoint.ToString();
lock (playerIdentityGate) lock (playerIdentityGate)
{ {
playerIdsByPeer[key] = playerId; playerIdsByPeer[key] = playerId;
canonicalPeersByPlayerId[playerId] = normalizedRemoteEndPoint;
if (!peerKeysByPlayerId.TryGetValue(playerId, out var peerKeys))
{
peerKeys = new HashSet<string>(StringComparer.Ordinal);
peerKeysByPlayerId[playerId] = peerKeys;
}
peerKeys.Add(key);
} }
} }
@ -294,7 +409,51 @@ namespace Network.NetworkHost
} }
} }
private void ForgetPlayerId(IPEndPoint remoteEndPoint) private IReadOnlyList<IPEndPoint> GetKnownPeerEndpointsForPlayerId(string playerId)
{
if (string.IsNullOrWhiteSpace(playerId))
{
return Array.Empty<IPEndPoint>();
}
lock (playerIdentityGate)
{
if (!peerKeysByPlayerId.TryGetValue(playerId, out var peerKeys))
{
return Array.Empty<IPEndPoint>();
}
return peerKeys
.Select(ParseEndPoint)
.Where(static endpoint => endpoint != null)
.ToArray();
}
}
private void ForgetPlayerId(string playerId)
{
if (string.IsNullOrWhiteSpace(playerId))
{
return;
}
lock (playerIdentityGate)
{
if (peerKeysByPlayerId.TryGetValue(playerId, out var peerKeys))
{
foreach (var peerKey in peerKeys)
{
playerIdsByPeer.Remove(peerKey);
}
peerKeysByPlayerId.Remove(playerId);
}
canonicalPeersByPlayerId.Remove(playerId);
}
}
private void ForgetPeerIdentity(IPEndPoint remoteEndPoint)
{ {
if (remoteEndPoint == null) if (remoteEndPoint == null)
{ {
@ -304,8 +463,47 @@ namespace Network.NetworkHost
var key = Normalize(remoteEndPoint).ToString(); var key = Normalize(remoteEndPoint).ToString();
lock (playerIdentityGate) lock (playerIdentityGate)
{ {
playerIdsByPeer.Remove(key); if (!playerIdsByPeer.TryGetValue(key, out var playerId))
{
return;
} }
playerIdsByPeer.Remove(key);
if (peerKeysByPlayerId.TryGetValue(playerId, out var peerKeys))
{
peerKeys.Remove(key);
if (peerKeys.Count == 0)
{
peerKeysByPlayerId.Remove(playerId);
canonicalPeersByPlayerId.Remove(playerId);
}
}
}
}
private static IPEndPoint ParseEndPoint(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var lastColonIndex = value.LastIndexOf(':');
if (lastColonIndex <= 0 || lastColonIndex >= value.Length - 1)
{
return null;
}
var addressText = value.Substring(0, lastColonIndex);
if (addressText.Length > 1 && addressText[0] == '[' && addressText[addressText.Length - 1] == ']')
{
addressText = addressText.Substring(1, addressText.Length - 2);
}
return IPAddress.TryParse(addressText, out var address) &&
int.TryParse(value.Substring(lastColonIndex + 1), out var port)
? new IPEndPoint(address, port)
: null;
} }
private static IPEndPoint Normalize(IPEndPoint remoteEndPoint) private static IPEndPoint Normalize(IPEndPoint remoteEndPoint)

View File

@ -14,6 +14,7 @@ namespace Network.NetworkHost
} }
ReliablePort = reliablePort; ReliablePort = reliablePort;
AuthoritativeMovementWorldValidator = PermissiveAuthoritativeMovementWorldValidator.Instance;
} }
public int ReliablePort { get; } public int ReliablePort { get; }
@ -36,6 +37,8 @@ namespace Network.NetworkHost
public ServerAuthoritativeCombatConfiguration AuthoritativeCombat { get; set; } public ServerAuthoritativeCombatConfiguration AuthoritativeCombat { get; set; }
public IAuthoritativeMovementWorldValidator AuthoritativeMovementWorldValidator { get; set; }
internal void Validate() internal void Validate()
{ {
if (ReliablePort <= 0) if (ReliablePort <= 0)
@ -58,6 +61,10 @@ namespace Network.NetworkHost
AuthoritativeMovement?.Validate(); AuthoritativeMovement?.Validate();
AuthoritativeCombat?.Validate(); AuthoritativeCombat?.Validate();
if (AuthoritativeMovementWorldValidator == null)
{
throw new ArgumentNullException(nameof(AuthoritativeMovementWorldValidator));
}
} }
} }
} }

View File

@ -25,7 +25,8 @@ namespace Network.NetworkHost
configuration.SyncSequenceTracker, configuration.SyncSequenceTracker,
configuration.TransportFactory, configuration.TransportFactory,
configuration.AuthoritativeMovement, configuration.AuthoritativeMovement,
configuration.AuthoritativeCombat); configuration.AuthoritativeCombat,
configuration.AuthoritativeMovementWorldValidator);
try try
{ {

View File

@ -0,0 +1,64 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
namespace Network.NetworkTransport
{
public class ClientSession
{
public IPEndPoint EndPoint { get; }
public DateTime LastActivity { get; private set; }
public uint SendSequenceNumber { get; private set; } = 0;
//TODO: 数据结构——ConcurrentDictionary
public ConcurrentDictionary<uint, (Packet packet, DateTime sendTime)> PendingAcks { get; } =
new ConcurrentDictionary<uint, (Packet packet, DateTime sendTime)>();
public uint ExpectedReceiveSequence { get; private set; } = 0;
private HashSet<uint> _receivedSequences { get; } = new HashSet<uint>();
private readonly object _lockObj = new object();
public ClientSession(IPEndPoint endPoint)
{
EndPoint = endPoint;
LastActivity = DateTime.Now;
}
public uint GetNextSendSequence()
{
lock (_lockObj)
{
return SendSequenceNumber++;
}
}
public bool TryProcessReceiveSequence(uint sequenceNumber, out bool shouldDeliver)
{
lock (_lockObj)
{
LastActivity = DateTime.Now;
if (sequenceNumber == ExpectedReceiveSequence)
{
ExpectedReceiveSequence++;
_receivedSequences.Add(sequenceNumber);
shouldDeliver = true;
return true;
}
else if (sequenceNumber < ExpectedReceiveSequence)
{
shouldDeliver = false;
return _receivedSequences.Contains(sequenceNumber);
}
else
{
shouldDeliver = false;
return false;
}
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c007e0986e972b14cb4b4ab2151459c3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,9 @@
using System.Net;
namespace Network.NetworkTransport
{
public interface IPeerSessionTransport
{
bool RemovePeerSession(IPEndPoint remoteEndPoint);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b2637543abd875349aaa27b3e31f9dbe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,3 +1,11 @@
fileFormatVersion: 2 fileFormatVersion: 2
guid: 63fb533d620e4ac0bf15ba0a9a71331e guid: 63fb533d620e4ac0bf15ba0a9a71331e
timeCreated: 1774593005 MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
@ -10,7 +10,7 @@ using kcp;
namespace Network.NetworkTransport namespace Network.NetworkTransport
{ {
public partial class KcpTransport : ITransport, ITransportMetricsSink public partial class KcpTransport : ITransport, ITransportMetricsSink, IPeerSessionTransport
{ {
private const uint DefaultConv = 1; private const uint DefaultConv = 1;
private const int DefaultNoDelay = 1; private const int DefaultNoDelay = 1;
@ -233,6 +233,26 @@ namespace Network.NetworkTransport
} }
} }
public bool RemovePeerSession(IPEndPoint remoteEndPoint)
{
if (remoteEndPoint == null)
{
throw new ArgumentNullException(nameof(remoteEndPoint));
}
var normalizedEndPoint = NormalizeEndPoint(remoteEndPoint);
var key = normalizedEndPoint.ToString();
if (!_sessions.TryRemove(key, out var session))
{
return false;
}
RecordSessionDiagnostics(session, "removed");
session.Dispose();
_metricsModule.RecordSessionClosed(session.RemoteEndPoint);
return true;
}
private KcpSession GetOrCreateSession(IPEndPoint remoteEndPoint, uint conv) private KcpSession GetOrCreateSession(IPEndPoint remoteEndPoint, uint conv)
{ {
var normalizedEndPoint = NormalizeEndPoint(remoteEndPoint); var normalizedEndPoint = NormalizeEndPoint(remoteEndPoint);

View File

@ -0,0 +1,58 @@
using System;
using System.Linq;
namespace Network.NetworkTransport
{
public enum PacketType : byte
{
Data = 1,
Ack = 2,
}
public struct Packet
{
public PacketType Type;
public uint SequenceNumber;
public byte[] Data;
public byte[] ToBytes()
{
var result = new byte[1 + 4 + Data.Length];
result[0] = (byte)Type;
BitConverter.GetBytes(SequenceNumber).CopyTo(result, 1);
Data.CopyTo(result, 5);
return result;
}
public static Packet FromBytes(byte[] data)
{
return new Packet
{
Type = (PacketType)data[0],
SequenceNumber = BitConverter.ToUInt32(data, 1),
//TODO: 结构体——ArraySegment
Data = new ArraySegment<byte>(data, 5, data.Length - 5).ToArray()
};
}
public static Packet CreateDataPacket(uint seqNum, byte[] data)
{
return new Packet
{
Type = PacketType.Data,
SequenceNumber = seqNum,
Data = data
};
}
public static Packet CreateAckPacket(uint seqNum)
{
return new Packet
{
Type = PacketType.Ack,
SequenceNumber = seqNum,
Data = Array.Empty<byte>()
};
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a16c0628d84e6274bbe164e0e57533e4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,312 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace Network.NetworkTransport
{
internal sealed class LegacyUdpTransportAdapter : ITransport, IPeerSessionTransport
{
private readonly UdpClient _client;
private readonly IPEndPoint? _defaultRemoteEndPoint;
private readonly bool _isServer;
private readonly ConcurrentDictionary<string, ClientSession> _sessions = new ConcurrentDictionary<string, ClientSession>();
private readonly ConcurrentDictionary<Packet, int> _resentPacketTimes = new ConcurrentDictionary<Packet, int>();
private readonly Timer _retransmitTimer;
private readonly Timer _cleanupTimer;
//TODO: volatile 关键字
private volatile bool _isRunning;
// 配置参数
private const int RetransmitTimeoutMs = 1000;
private const int SessionTimeoutMs = 30000;
private const int MaxRetransmitAttempts = 5;
public event Action<byte[], IPEndPoint>? OnReceive;
// 构造函数——服务端模式
public LegacyUdpTransportAdapter(int listenPort)
{
_client = new UdpClient(listenPort);
_isServer = true;
_retransmitTimer = new Timer(CheckRetransmit, null, 100, 100);
_cleanupTimer = new Timer(CleanupSessions, null, 5000, 5000);
Console.WriteLine($"[Transport] 服务端模式,监听端口: {listenPort}");
}
// 构造函数——客户端模式
public LegacyUdpTransportAdapter(string serverIP, int serverPort)
{
_client = new UdpClient(0);
_defaultRemoteEndPoint = new IPEndPoint(IPAddress.Parse(serverIP), serverPort);
_isServer = false;
_retransmitTimer = new Timer(CheckRetransmit, null, 100, 100);
_cleanupTimer = new Timer(CleanupSessions, null, 5000, 5000);
Console.WriteLine($"[Transport] 客户端模式,目标: {_defaultRemoteEndPoint}");
}
public async Task StartAsync()
{
_sessions.Clear();
_isRunning = true;
Console.WriteLine("[Transport] 传输层启动");
// 开始接收数据
_ = Task.Run(ReceiveLoop);
await Task.Delay(100); // 给接收循环一点启动时间
}
public void Stop()
{
_isRunning = false;
_retransmitTimer.Dispose();
_cleanupTimer.Dispose();
_client.Close();
_sessions.Clear();
Console.WriteLine("[Transport] 传输层停止");
}
public void Send(byte[] data)
{
if (!_isServer && _defaultRemoteEndPoint != null)
{
SendTo(data, _defaultRemoteEndPoint);
}
else
{
throw new InvalidOperationException("服务端模式必须使用 SendTo 指定目标");
}
}
public void SendTo(byte[] data, IPEndPoint target)
{
if (!_isRunning)
{
return;
}
var session = GetOrCreateSession(target);
uint seqNum = session.GetNextSendSequence();
var packet = Packet.CreateDataPacket(seqNum, data);
session.PendingAcks[seqNum] = (packet, DateTime.Now);
SendPacketTo(packet, target);
Console.WriteLine($"[Transport] 发送数据包到 {target} SeqNum={seqNum}, DataLen={data.Length}");
}
public void SendToAll(byte[] data)
{
foreach (var session in _sessions.Values)
{
SendTo(data, session.EndPoint);
}
}
public bool RemovePeerSession(IPEndPoint remoteEndPoint)
{
if (remoteEndPoint == null)
{
throw new ArgumentNullException(nameof(remoteEndPoint));
}
return _sessions.TryRemove(remoteEndPoint.ToString(), out _);
}
private async void ReceiveLoop()
{
while (_isRunning)
{
try
{
var result = await _client.ReceiveAsync();
var packet = Packet.FromBytes(result.Buffer);
if (packet.Type == PacketType.Data)
{
HandleDataPacket(packet, result.RemoteEndPoint);
}
else if (packet.Type == PacketType.Ack)
{
HandleAckPacket(packet, result.RemoteEndPoint);
}
}
catch (ObjectDisposedException)
{
return; // 正常关闭
}
catch (Exception e)
{
Console.WriteLine($"[Transport] 接收错误:{e.Message}");
}
}
}
private void HandleDataPacket(Packet packet, IPEndPoint senderEndPoint)
{
var session = GetOrCreateSession(senderEndPoint);
Console.WriteLine(
$"[Transport] 收到数据包从{senderEndPoint} SeqNum={packet.SequenceNumber}, DataLen={packet.Data.Length}");
// 发送ACK
var ackPacket = Packet.CreateAckPacket(packet.SequenceNumber);
SendPacketTo(ackPacket, senderEndPoint);
Console.WriteLine($"[Transport] 发送ACK 到 {senderEndPoint} SeqNum={packet.SequenceNumber}");
// 检查是否应该交付
if (session.TryProcessReceiveSequence(packet.SequenceNumber, out bool shouldDeliver))
{
if (shouldDeliver)
{
OnReceive?.Invoke(packet.Data, senderEndPoint);
Console.WriteLine($"[Transport] 交付数据包从 {senderEndPoint} SeqNum={packet.SequenceNumber}");
}
else
{
Console.WriteLine($"[Transport] 重复包从 {senderEndPoint} SeqNum={packet.SequenceNumber},忽略");
}
}
else
{
// 乱序到达,暂存(简化处理:直接丢弃,依赖重传)
Console.WriteLine($"[Transport] 乱序包从 {senderEndPoint} SeqNum={packet.SequenceNumber},丢弃");
}
}
private void HandleAckPacket(Packet packet, IPEndPoint senderEndPoint)
{
var session = GetOrCreateSession(senderEndPoint);
Console.WriteLine($"[Transport] 收到ACK从 {senderEndPoint} SeqNum={packet.SequenceNumber}");
if (session.PendingAcks.TryRemove(packet.SequenceNumber, out _))
{
Console.WriteLine($"[Transport] 确认包到 {senderEndPoint} SeqNum={packet.SequenceNumber}");
}
}
private ClientSession GetOrCreateSession(IPEndPoint endPoint)
{
string key = endPoint.ToString();
return _sessions.GetOrAdd(key, _ =>
{
var session = new ClientSession(endPoint);
Console.WriteLine($"创建新会话:{endPoint}");
return session;
});
}
private void CheckRetransmit(object? state)
{
if (!_isRunning)
{
return;
}
var now = DateTime.Now;
var toRetransmit = new List<(IPEndPoint target, uint seqNum, Packet packet)>();
foreach (var sessionKvp in _sessions)
{
var session = sessionKvp.Value;
foreach (var ackKvp in session.PendingAcks)
{
var timeSinceLastSend = now - ackKvp.Value.sendTime;
if (timeSinceLastSend.TotalMilliseconds > RetransmitTimeoutMs)
{
toRetransmit.Add((session.EndPoint, ackKvp.Key, ackKvp.Value.packet));
_resentPacketTimes.TryAdd(ackKvp.Value.packet, 0);
}
}
}
foreach (var (target, seqNum, packet) in toRetransmit)
{
var session = GetOrCreateSession(target);
if (session.PendingAcks.ContainsKey(seqNum))
{
// 更新发送时间
session.PendingAcks[seqNum] = (packet, now);
SendPacketTo(packet, target);
Console.WriteLine($"[Transport] 重传包到 {target} SeqNum={seqNum}");
_resentPacketTimes[packet]++;
if (_resentPacketTimes[packet] >= MaxRetransmitAttempts)
{
// 达到最大重传次数,放弃该会话
Console.WriteLine($"[Transport] 达到最大重传次数,放弃会话 {target}");
_sessions.TryRemove(target.ToString(), out _);
}
}
}
}
private void CleanupSessions(object? state)
{
if (!_isRunning)
{
return;
}
var now = DateTime.Now;
var toRemove = new List<string>();
foreach (var sessionKvp in _sessions)
{
var session = sessionKvp.Value;
var timeSinceLastActivity = now - session.LastActivity;
if (timeSinceLastActivity.TotalMilliseconds > SessionTimeoutMs)
{
toRemove.Add(sessionKvp.Key);
}
}
foreach (string key in toRemove)
{
//TODO: 清理会话的同时清理PlayerManager中的玩家数据
if (_sessions.TryRemove(key, out var session))
{
Console.WriteLine($"[Transport] 清理超时会话:{session.EndPoint}");
}
}
if (_isServer)
{
PrintSessionInfo();
}
}
private async void SendPacketTo(Packet packet, IPEndPoint? endPoint)
{
try
{
var data = packet.ToBytes();
await _client.SendAsync(data, data.Length, endPoint);
}
catch (Exception e)
{
Console.WriteLine($"[Transport] 发送错误:{e.Message}");
}
}
private void PrintSessionInfo()
{
Console.WriteLine($"当前活跃会话数:{_sessions.Count}");
foreach (var sessionKvp in _sessions)
{
var session = sessionKvp.Value;
Console.WriteLine(
$" 会话:{session.EndPoint}发送SeqNum{session.SendSequenceNumber},期望接收:{session.ExpectedReceiveSequence},待确认: {session.PendingAcks.Count}");
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e740e71ab20f62d40bf5f56c2142bf02
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -49,6 +49,11 @@ namespace Tests.EditMode.Network
clientRuntime.StartAsync().GetAwaiter().GetResult(); clientRuntime.StartAsync().GetAwaiter().GetResult();
using var serverRuntime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult(); using var serverRuntime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
serverRuntime.Host.NotifyLoginStarted(ClientPeer);
serverRuntime.Host.NotifyLoginSucceeded(ClientPeer, "player-a");
serverRuntime.Host.NotifyLoginStarted(RemotePeer);
serverRuntime.Host.NotifyLoginSucceeded(RemotePeer, "player-b");
serverTransports[9001].EmitReceive( serverTransports[9001].EmitReceive(
GameplayFlowTestSupport.BuildEnvelope( GameplayFlowTestSupport.BuildEnvelope(
MessageType.MoveInput, MessageType.MoveInput,
@ -56,8 +61,8 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "player-b", PlayerId = "player-b",
Tick = 1, Tick = 1,
MoveX = 0f, TurnInput = 0f,
MoveY = 0f ThrottleInput = 0f
}), }),
RemotePeer); RemotePeer);
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult(); serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
@ -69,8 +74,8 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "player-a", PlayerId = "player-a",
Tick = 1, Tick = 1,
MoveX = 1f, TurnInput = 0f,
MoveY = 0f ThrottleInput = 1f
}, },
MessageType.MoveInput); MessageType.MoveInput);
ClientGameplayInputFlow.SendShootInput( ClientGameplayInputFlow.SendShootInput(
@ -162,9 +167,9 @@ namespace Tests.EditMode.Network
RemotePeer); RemotePeer);
serverRuntime.Host.NotifyLoginStarted(ClientPeer); serverRuntime.Host.NotifyLoginStarted(ClientPeer);
serverRuntime.Host.NotifyLoginSucceeded(ClientPeer); serverRuntime.Host.NotifyLoginSucceeded(ClientPeer, "player-a");
serverRuntime.Host.NotifyLoginStarted(RemotePeer); serverRuntime.Host.NotifyLoginStarted(RemotePeer);
serverRuntime.Host.NotifyLoginSucceeded(RemotePeer); serverRuntime.Host.NotifyLoginSucceeded(RemotePeer, "player-b");
serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50)); serverRuntime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
TransferBroadcastMessages(serverTransports[9001], clientSyncTransport, ServerSender); TransferBroadcastMessages(serverTransports[9001], clientSyncTransport, ServerSender);
@ -186,8 +191,8 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "player-b", PlayerId = "player-b",
Tick = 1, Tick = 1,
MoveX = 1f, TurnInput = 0f,
MoveY = 0f ThrottleInput = 1f
}), }),
RemotePeer); RemotePeer);
serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult(); serverRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();

View File

@ -68,8 +68,8 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "player-1", PlayerId = "player-1",
Tick = 12, Tick = 12,
MoveX = 1, TurnInput = 1,
MoveY = -1 ThrottleInput = -1
}; };
manager.SendMessage(message, MessageType.MoveInput); manager.SendMessage(message, MessageType.MoveInput);
@ -82,8 +82,8 @@ namespace Tests.EditMode.Network
Assert.That(envelope.Type, Is.EqualTo((int)MessageType.MoveInput)); Assert.That(envelope.Type, Is.EqualTo((int)MessageType.MoveInput));
Assert.That(parsed.PlayerId, Is.EqualTo("player-1")); Assert.That(parsed.PlayerId, Is.EqualTo("player-1"));
Assert.That(parsed.Tick, Is.EqualTo(12)); Assert.That(parsed.Tick, Is.EqualTo(12));
Assert.That(parsed.MoveX, Is.EqualTo(1)); Assert.That(parsed.TurnInput, Is.EqualTo(1));
Assert.That(parsed.MoveY, Is.EqualTo(-1)); Assert.That(parsed.ThrottleInput, Is.EqualTo(-1));
} }
[Test] [Test]
@ -100,8 +100,8 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "player-1", PlayerId = "player-1",
Tick = 13, Tick = 13,
MoveX = 0f, TurnInput = 0f,
MoveY = 0f ThrottleInput = 0f
}; };
manager.SendMessage(message, MessageType.MoveInput); manager.SendMessage(message, MessageType.MoveInput);
@ -114,8 +114,8 @@ namespace Tests.EditMode.Network
Assert.That(envelope.Type, Is.EqualTo((int)MessageType.MoveInput)); Assert.That(envelope.Type, Is.EqualTo((int)MessageType.MoveInput));
Assert.That(parsed.PlayerId, Is.EqualTo("player-1")); Assert.That(parsed.PlayerId, Is.EqualTo("player-1"));
Assert.That(parsed.Tick, Is.EqualTo(13)); Assert.That(parsed.Tick, Is.EqualTo(13));
Assert.That(parsed.MoveX, Is.EqualTo(0f)); Assert.That(parsed.TurnInput, Is.EqualTo(0f));
Assert.That(parsed.MoveY, Is.EqualTo(0f)); Assert.That(parsed.ThrottleInput, Is.EqualTo(0f));
} }
[Test] [Test]
@ -350,12 +350,12 @@ namespace Tests.EditMode.Network
}); });
transport.EmitReceive( transport.EmitReceive(
BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-1", Tick = 8, MoveX = 1 }), BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-1", Tick = 8, ThrottleInput = 1 }),
Sender); Sender);
manager.DrainPendingMessagesAsync().GetAwaiter().GetResult(); manager.DrainPendingMessagesAsync().GetAwaiter().GetResult();
transport.EmitReceive( transport.EmitReceive(
BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-1", Tick = 6, MoveX = -1 }), BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-1", Tick = 6, ThrottleInput = -1 }),
Sender); Sender);
manager.DrainPendingMessagesAsync().GetAwaiter().GetResult(); manager.DrainPendingMessagesAsync().GetAwaiter().GetResult();

View File

@ -39,8 +39,8 @@ namespace Tests.EditMode.Network
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult(); using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
PrimePlayer(createdTransports[9000], PeerA, "player-a", 1); PrimePlayer(runtime, PeerA, "player-a");
PrimePlayer(createdTransports[9000], PeerB, "player-b", 1); PrimePlayer(runtime, PeerB, "player-b");
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult(); runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput
@ -111,8 +111,8 @@ namespace Tests.EditMode.Network
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult(); using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
PrimePlayer(createdTransports[9001], PeerA, "player-a", 1); PrimePlayer(runtime, PeerA, "player-a");
PrimePlayer(createdTransports[9001], PeerB, "player-b", 1); PrimePlayer(runtime, PeerB, "player-b");
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult(); runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput
@ -166,8 +166,8 @@ namespace Tests.EditMode.Network
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult(); using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
PrimePlayer(createdTransports[9000], PeerA, "player-a", 1); PrimePlayer(runtime, PeerA, "player-a");
PrimePlayer(createdTransports[9000], PeerB, "player-b", 1); PrimePlayer(runtime, PeerB, "player-b");
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult(); runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.ShootInput, new ShootInput
@ -192,15 +192,10 @@ namespace Tests.EditMode.Network
Assert.That(runtime.AuthoritativeCombatStates.Count, Is.EqualTo(0)); Assert.That(runtime.AuthoritativeCombatStates.Count, Is.EqualTo(0));
} }
private static void PrimePlayer(FakeTransport transport, IPEndPoint peer, string playerId, long tick) private static void PrimePlayer(ServerRuntimeHandle runtime, IPEndPoint peer, string playerId)
{ {
transport.EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput runtime.Host.NotifyLoginStarted(peer);
{ runtime.Host.NotifyLoginSucceeded(peer, playerId);
PlayerId = playerId,
Tick = tick,
MoveX = 0f,
MoveY = 0f
}), peer);
} }
private static FakeTransport CreateTransport(IDictionary<int, FakeTransport> createdTransports, int port) private static FakeTransport CreateTransport(IDictionary<int, FakeTransport> createdTransports, int port)

View File

@ -33,26 +33,31 @@ namespace Tests.EditMode.Network
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult(); using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
runtime.Host.NotifyLoginStarted(PeerA);
runtime.Host.NotifyLoginSucceeded(PeerA, "player-a");
runtime.Host.NotifyLoginStarted(PeerB);
runtime.Host.NotifyLoginSucceeded(PeerB, "player-b");
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{ {
PlayerId = "player-a", PlayerId = "player-a",
Tick = 10, Tick = 10,
MoveX = 1f, TurnInput = 0f,
MoveY = 0f ThrottleInput = 1f
}), PeerA); }), PeerA);
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{ {
PlayerId = "player-a", PlayerId = "player-a",
Tick = 8, Tick = 8,
MoveX = 0f, TurnInput = 1f,
MoveY = 1f ThrottleInput = 0f
}), PeerA); }), PeerA);
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{ {
PlayerId = "player-b", PlayerId = "player-b",
Tick = 3, Tick = 3,
MoveX = 0f, TurnInput = 0f,
MoveY = 1f ThrottleInput = -1f
}), PeerB); }), PeerB);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult(); runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
@ -66,8 +71,8 @@ namespace Tests.EditMode.Network
Assert.That(stateA.PositionZ, Is.EqualTo(0f).Within(0.0001f)); Assert.That(stateA.PositionZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(stateB.PlayerId, Is.EqualTo("player-b")); Assert.That(stateB.PlayerId, Is.EqualTo("player-b"));
Assert.That(stateB.LastAcceptedMoveTick, Is.EqualTo(3)); Assert.That(stateB.LastAcceptedMoveTick, Is.EqualTo(3));
Assert.That(stateB.PositionX, Is.EqualTo(0f).Within(0.0001f)); Assert.That(stateB.PositionX, Is.EqualTo(-0.2f).Within(0.0001f));
Assert.That(stateB.PositionZ, Is.EqualTo(0.2f).Within(0.0001f)); Assert.That(stateB.PositionZ, Is.EqualTo(0f).Within(0.0001f));
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(2)); Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(2));
} }
@ -89,12 +94,15 @@ namespace Tests.EditMode.Network
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult(); using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
runtime.Host.NotifyLoginStarted(PeerA);
runtime.Host.NotifyLoginSucceeded(PeerA, "player-a");
createdTransports[9001].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput createdTransports[9001].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{ {
PlayerId = "player-a", PlayerId = "player-a",
Tick = 1, Tick = 1,
MoveX = 1f, TurnInput = 0f,
MoveY = 0f ThrottleInput = 1f
}), PeerA); }), PeerA);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult(); runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
@ -118,8 +126,8 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "player-a", PlayerId = "player-a",
Tick = 2, Tick = 2,
MoveX = 0f, TurnInput = 0f,
MoveY = 0f ThrottleInput = 0f
}), PeerA); }), PeerA);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult(); runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
@ -164,7 +172,7 @@ namespace Tests.EditMode.Network
}), PeerA); }), PeerA);
runtime.Host.NotifyLoginStarted(PeerA); runtime.Host.NotifyLoginStarted(PeerA);
runtime.Host.NotifyLoginSucceeded(PeerA); runtime.Host.NotifyLoginSucceeded(PeerA, "player-a");
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50)); runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var state), Is.True); Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var state), Is.True);
@ -202,12 +210,15 @@ namespace Tests.EditMode.Network
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult(); using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
runtime.Host.NotifyLoginStarted(PeerA);
runtime.Host.NotifyLoginSucceeded(PeerA, "player-a");
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
{ {
PlayerId = "player-a", PlayerId = "player-a",
Tick = 5, Tick = 5,
MoveX = 0f, TurnInput = 0f,
MoveY = -1f ThrottleInput = -1f
}), PeerA); }), PeerA);
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult(); runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
@ -217,9 +228,9 @@ namespace Tests.EditMode.Network
var broadcast = ParsePlayerState(createdTransports[9000].BroadcastMessages[0]); var broadcast = ParsePlayerState(createdTransports[9000].BroadcastMessages[0]);
Assert.That(broadcast.Tick, Is.EqualTo(1)); Assert.That(broadcast.Tick, Is.EqualTo(1));
Assert.That(broadcast.Position.X, Is.EqualTo(0f).Within(0.0001f)); Assert.That(broadcast.Position.X, Is.EqualTo(-0.3f).Within(0.0001f));
Assert.That(broadcast.Position.Z, Is.EqualTo(-0.3f).Within(0.0001f)); Assert.That(broadcast.Position.Z, Is.EqualTo(0f).Within(0.0001f));
Assert.That(broadcast.Velocity.Z, Is.EqualTo(-6f).Within(0.0001f)); Assert.That(broadcast.Velocity.X, Is.EqualTo(-6f).Within(0.0001f));
} }
private static FakeTransport CreateTransport(IDictionary<int, FakeTransport> createdTransports, int port) private static FakeTransport CreateTransport(IDictionary<int, FakeTransport> createdTransports, int port)

View File

@ -196,6 +196,10 @@ namespace Tests.EditMode.Network
host.StartAsync().GetAwaiter().GetResult(); host.StartAsync().GetAwaiter().GetResult();
transport.EmitReceive(CreateEnvelope(MessageType.Heartbeat), peerA); transport.EmitReceive(CreateEnvelope(MessageType.Heartbeat), peerA);
transport.EmitReceive(CreateEnvelope(MessageType.Heartbeat), peerB); transport.EmitReceive(CreateEnvelope(MessageType.Heartbeat), peerB);
host.NotifyLoginStarted(peerA);
host.NotifyLoginSucceeded(peerA, "player-a");
host.NotifyLoginStarted(peerB);
host.NotifyLoginSucceeded(peerB, "player-b");
var removed = host.RemoveSession(peerA, "peer closed"); var removed = host.RemoveSession(peerA, "peer closed");
@ -203,7 +207,7 @@ namespace Tests.EditMode.Network
Assert.That(host.ManagedSessions.Count, Is.EqualTo(1)); Assert.That(host.ManagedSessions.Count, Is.EqualTo(1));
Assert.That(host.TryGetSession(peerA, out _), Is.False); Assert.That(host.TryGetSession(peerA, out _), Is.False);
Assert.That(host.TryGetSession(peerB, out var sessionB), Is.True); Assert.That(host.TryGetSession(peerB, out var sessionB), Is.True);
Assert.That(sessionB.SessionManager.State, Is.EqualTo(ConnectionState.TransportConnected)); Assert.That(sessionB.SessionManager.State, Is.EqualTo(ConnectionState.LoggedIn));
} }
[Test] [Test]

View File

@ -110,8 +110,8 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "shared-player", PlayerId = "shared-player",
Tick = 33, Tick = 33,
MoveX = 1f, TurnInput = 1f,
MoveY = -1f ThrottleInput = -1f
}; };
runtime.MessageManager.SendMessage(message, MessageType.MoveInput); runtime.MessageManager.SendMessage(message, MessageType.MoveInput);
@ -161,7 +161,7 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "shared-player", PlayerId = "shared-player",
Tick = 77, Tick = 77,
MoveX = 1f ThrottleInput = 1f
}; };
runtime.MessageManager.SendMessage(moveInput, MessageType.MoveInput); runtime.MessageManager.SendMessage(moveInput, MessageType.MoveInput);
@ -191,7 +191,7 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "server-player", PlayerId = "server-player",
Tick = 88, Tick = 88,
MoveY = 1f ThrottleInput = 1f
}; };
host.MessageManager.SendMessage(moveInput, MessageType.MoveInput); host.MessageManager.SendMessage(moveInput, MessageType.MoveInput);
@ -215,7 +215,7 @@ namespace Tests.EditMode.Network
{ {
PlayerId = "fallback-player", PlayerId = "fallback-player",
Tick = 99, Tick = 99,
MoveX = -1f TurnInput = -1f
}; };
host.MessageManager.SendMessage(moveInput, MessageType.MoveInput); host.MessageManager.SendMessage(moveInput, MessageType.MoveInput);

View File

@ -33,8 +33,8 @@ namespace Tests.EditMode.Network
Assert.That(stopInput, Is.Not.Null); Assert.That(stopInput, Is.Not.Null);
Assert.That(stopInput.PlayerId, Is.EqualTo("player-1")); Assert.That(stopInput.PlayerId, Is.EqualTo("player-1"));
Assert.That(stopInput.Tick, Is.EqualTo(8)); Assert.That(stopInput.Tick, Is.EqualTo(8));
Assert.That(stopInput.MoveX, Is.EqualTo(0f)); Assert.That(stopInput.TurnInput, Is.EqualTo(0f));
Assert.That(stopInput.MoveY, Is.EqualTo(0f)); Assert.That(stopInput.ThrottleInput, Is.EqualTo(0f));
Assert.That(continuedIdle, Is.False); Assert.That(continuedIdle, Is.False);
Assert.That(idleInput, Is.Null); Assert.That(idleInput, Is.Null);
} }
@ -85,9 +85,9 @@ namespace Tests.EditMode.Network
public void ClientPredictionBuffer_AuthoritativeState_PrunesAcknowledgedMoveInputs() public void ClientPredictionBuffer_AuthoritativeState_PrunesAcknowledgedMoveInputs()
{ {
var buffer = new ClientPredictionBuffer(); var buffer = new ClientPredictionBuffer();
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, MoveX = 1f }); buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f });
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 11, MoveX = 1f }); buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 11, ThrottleInput = 1f });
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 12, MoveX = 1f }); buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 12, ThrottleInput = 1f });
var accepted = buffer.TryApplyAuthoritativeState( var accepted = buffer.TryApplyAuthoritativeState(
new PlayerState { PlayerId = "player-1", Tick = 11 }, new PlayerState { PlayerId = "player-1", Tick = 11 },
@ -104,7 +104,7 @@ namespace Tests.EditMode.Network
public void ClientPredictionBuffer_StaleAuthoritativeState_IsIgnored() public void ClientPredictionBuffer_StaleAuthoritativeState_IsIgnored()
{ {
var buffer = new ClientPredictionBuffer(); var buffer = new ClientPredictionBuffer();
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, MoveX = 1f }); buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f });
buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10 }, out _); buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10 }, out _);
var accepted = buffer.TryApplyAuthoritativeState( var accepted = buffer.TryApplyAuthoritativeState(
@ -139,7 +139,7 @@ namespace Tests.EditMode.Network
Assert.That(snapshot.Position, Is.EqualTo(new Vector3(5f, 0f, -3f))); Assert.That(snapshot.Position, Is.EqualTo(new Vector3(5f, 0f, -3f)));
Assert.That(snapshot.Velocity, Is.EqualTo(new Vector3(1.5f, 0f, 0.25f))); Assert.That(snapshot.Velocity, Is.EqualTo(new Vector3(1.5f, 0f, 0.25f)));
Assert.That(snapshot.Rotation, Is.EqualTo(90f)); Assert.That(snapshot.Rotation, Is.EqualTo(90f));
Assert.That(snapshot.RotationQuaternion.eulerAngles.y, Is.EqualTo(90f).Within(0.01f)); Assert.That(snapshot.RotationQuaternion.eulerAngles.y, Is.EqualTo(0f).Within(0.01f));
Assert.That(snapshot.Hp, Is.EqualTo(73)); Assert.That(snapshot.Hp, Is.EqualTo(73));
} }
@ -413,13 +413,13 @@ namespace Tests.EditMode.Network
}); });
transport.EmitReceive( transport.EmitReceive(
BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-a", Tick = 5, MoveX = 1f }), BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-a", Tick = 5, ThrottleInput = 1f }),
peerA); peerA);
transport.EmitReceive( transport.EmitReceive(
BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-a", Tick = 4, MoveX = -1f }), BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-a", Tick = 4, ThrottleInput = -1f }),
peerA); peerA);
transport.EmitReceive( transport.EmitReceive(
BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-b", Tick = 4, MoveY = 1f }), BuildEnvelope(MessageType.MoveInput, new MoveInput { PlayerId = "player-b", Tick = 4, TurnInput = 1f }),
peerB); peerB);
Assert.That(handledTicksByPeer[peerA.ToString()], Is.EqualTo(new long[] { 5 })); Assert.That(handledTicksByPeer[peerA.ToString()], Is.EqualTo(new long[] { 5 }));