process TODO.md step 5

This commit is contained in:
SepComet
2026-03-29 09:31:46 +08:00
parent f0064e2ae5
commit d3d25c8921
19 changed files with 3923 additions and 181 deletions
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ using Vector3 = UnityEngine.Vector3;
public sealed class ClientAuthoritativePlayerState
{
public ClientAuthoritativePlayerStateSnapshot Current { get; private set; }
public ClientCombatPresentationSnapshot CombatPresentation { get; private set; } = ClientCombatPresentationSnapshot.Empty;
public bool TryAccept(PlayerState state, out ClientAuthoritativePlayerStateSnapshot snapshot)
{
@@ -22,8 +23,67 @@ public sealed class ClientAuthoritativePlayerState
snapshot = new ClientAuthoritativePlayerStateSnapshot(state);
Current = snapshot;
CombatPresentation = CombatPresentation.WithAuthoritativeSnapshot(snapshot);
return true;
}
public bool TryApplyCombatEvent(CombatEvent combatEvent, string playerId, out ClientAuthoritativePlayerStateSnapshot snapshot, out ClientCombatPresentationSnapshot combatSnapshot)
{
if (combatEvent == null)
{
throw new ArgumentNullException(nameof(combatEvent));
}
if (string.IsNullOrEmpty(playerId))
{
throw new ArgumentException("Player id is required.", nameof(playerId));
}
if (!ClientCombatEventRouting.TryGetAffectedPlayerId(combatEvent, out var affectedPlayerId)
|| !string.Equals(affectedPlayerId, playerId, StringComparison.Ordinal))
{
snapshot = Current;
combatSnapshot = CombatPresentation;
return false;
}
Current = ApplyEventToCurrentSnapshot(combatEvent);
CombatPresentation = CombatPresentation.WithCombatEvent(combatEvent, Current);
snapshot = Current;
combatSnapshot = CombatPresentation;
return true;
}
private ClientAuthoritativePlayerStateSnapshot ApplyEventToCurrentSnapshot(CombatEvent combatEvent)
{
if (Current == null)
{
return null;
}
switch (combatEvent.EventType)
{
case CombatEventType.DamageApplied:
return CloneSnapshotWithHp(Mathf.Max(0, Current.Hp - Mathf.Max(0, combatEvent.Damage)));
case CombatEventType.Death:
return CloneSnapshotWithHp(0);
default:
return Current;
}
}
private ClientAuthoritativePlayerStateSnapshot CloneSnapshotWithHp(int hp)
{
if (Current == null)
{
return null;
}
var sourceState = Current.SourceState.Clone();
sourceState.Hp = hp;
return new ClientAuthoritativePlayerStateSnapshot(sourceState);
}
}
public sealed class ClientAuthoritativePlayerStateSnapshot
@@ -60,3 +120,74 @@ public sealed class ClientAuthoritativePlayerStateSnapshot
public Quaternion RotationQuaternion => Quaternion.Euler(0f, Rotation, 0f);
}
public sealed class ClientCombatPresentationSnapshot
{
public static readonly ClientCombatPresentationSnapshot Empty = new(false, CombatEventType.Unspecified, 0, 0, Vector3.zero, false);
public ClientCombatPresentationSnapshot(
bool hasLastEvent,
CombatEventType lastEventType,
long lastEventTick,
int lastDamage,
Vector3 lastHitPosition,
bool isDead)
{
HasLastEvent = hasLastEvent;
LastEventType = lastEventType;
LastEventTick = lastEventTick;
LastDamage = lastDamage;
LastHitPosition = lastHitPosition;
IsDead = isDead;
}
public bool HasLastEvent { get; }
public CombatEventType LastEventType { get; }
public long LastEventTick { get; }
public int LastDamage { get; }
public Vector3 LastHitPosition { get; }
public bool IsDead { get; }
public ClientCombatPresentationSnapshot WithAuthoritativeSnapshot(ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (snapshot == null)
{
return this;
}
return new ClientCombatPresentationSnapshot(
HasLastEvent,
LastEventType,
LastEventTick,
LastDamage,
LastHitPosition,
snapshot.Hp <= 0);
}
public ClientCombatPresentationSnapshot WithCombatEvent(CombatEvent combatEvent, ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (combatEvent == null)
{
throw new ArgumentNullException(nameof(combatEvent));
}
var isDead = combatEvent.EventType == CombatEventType.Death;
if (!isDead && snapshot != null)
{
isDead = snapshot.Hp <= 0;
}
return new ClientCombatPresentationSnapshot(
true,
combatEvent.EventType,
combatEvent.Tick,
combatEvent.Damage,
combatEvent.HitPosition != null ? combatEvent.HitPosition.ToVector3() : Vector3.zero,
isDead);
}
}
@@ -0,0 +1,28 @@
using System;
using Network.Defines;
public static class ClientCombatEventRouting
{
public static bool TryGetAffectedPlayerId(CombatEvent combatEvent, out string playerId)
{
if (combatEvent == null)
{
throw new ArgumentNullException(nameof(combatEvent));
}
switch (combatEvent.EventType)
{
case CombatEventType.ShootRejected:
playerId = combatEvent.AttackerId ?? string.Empty;
return !string.IsNullOrEmpty(playerId);
case CombatEventType.Hit:
case CombatEventType.DamageApplied:
case CombatEventType.Death:
playerId = combatEvent.TargetId ?? string.Empty;
return !string.IsNullOrEmpty(playerId);
default:
playerId = string.Empty;
return false;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f12e9d15fab40204e9740ac5641c7b95
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+24 -1
View File
@@ -73,8 +73,31 @@ public class MasterManager : MonoBehaviour
else Debug.LogWarning("Player not found");
}
public void ApplyCombatEvent(CombatEvent combatEvent)
{
if (combatEvent == null)
{
Debug.LogWarning("CombatEvent is null");
return;
}
if (!ClientCombatEventRouting.TryGetAffectedPlayerId(combatEvent, out var playerId))
{
Debug.LogWarning($"CombatEvent has no routable player: {combatEvent.EventType}");
return;
}
if (_players.TryGetValue(playerId, out var player))
{
player.ApplyCombatEvent(combatEvent);
return;
}
Debug.LogWarning($"Player not found for CombatEvent: {playerId}");
}
public Player GetCurrentPlayer()
{
return _players[LocalPlayerId];
}
}
}
+9
View File
@@ -126,6 +126,7 @@ public class NetworkManager : MonoBehaviour
{
_networkRuntime.MessageManager.RegisterHandler(MessageType.LoginResponse, HandleLoginResponse);
_networkRuntime.MessageManager.RegisterHandler(MessageType.PlayerState, HandlePlayerState);
_networkRuntime.MessageManager.RegisterHandler(MessageType.CombatEvent, HandleCombatEvent);
_networkRuntime.MessageManager.RegisterHandler(MessageType.HeartbeatResponse, HandleHeartbeatResponse);
_networkRuntime.MessageManager.RegisterHandler(MessageType.LogoutRequest, HandleLogoutRequest);
_networkRuntime.MessageManager.RegisterHandler(MessageType.PlayerJoin, HandlePlayerJoin);
@@ -178,6 +179,14 @@ public class NetworkManager : MonoBehaviour
}
}
private void HandleCombatEvent(byte[] data, IPEndPoint sender)
{
_networkRuntime.NotifyInboundActivity();
var combatEvent = CombatEvent.Parser.ParseFrom(data);
MasterManager.Instance.ApplyCombatEvent(combatEvent);
Debug.Log($"收到CombatEvent::Type={combatEvent.EventType},Attacker={combatEvent.AttackerId},Target={combatEvent.TargetId},Damage={combatEvent.Damage}");
}
private void HandleLogoutRequest(byte[] data, IPEndPoint sender)
{
_networkRuntime.NotifyInboundActivity();
+13 -1
View File
@@ -6,6 +6,7 @@ public class Player : MonoBehaviour
//[SerializeField] private float _moveSpeed = 10f;
public string PlayerId { get; private set; } = "1001";
public ClientAuthoritativePlayerStateSnapshot AuthoritativeState => _authoritativeState.Current;
public ClientCombatPresentationSnapshot CombatPresentation => _authoritativeState.CombatPresentation;
[SerializeField] private MeshRenderer _meshRenderer;
[SerializeField] private Material[] _materials;
[SerializeField] private Camera _camera;
@@ -56,10 +57,21 @@ public class Player : MonoBehaviour
return;
}
_playerUI?.SyncAuthoritativeState(snapshot);
_playerUI?.SyncAuthoritativeState(snapshot, CombatPresentation);
_movement?.OnAuthoritativeState(snapshot);
}
public bool ApplyCombatEvent(CombatEvent combatEvent)
{
if (!_authoritativeState.TryApplyCombatEvent(combatEvent, PlayerId, out var snapshot, out var combatSnapshot))
{
return false;
}
_playerUI?.SyncAuthoritativeState(snapshot, combatSnapshot);
return true;
}
public void SyncTick(long serverTick)
{
_movement.SetServerTick(serverTick);
+21 -6
View File
@@ -8,6 +8,8 @@ public class PlayerUI : MonoBehaviour
private Player _master;
private Camera _mainCamera;
private bool _isVisible = true;
private ClientAuthoritativePlayerStateSnapshot _authoritativeSnapshot;
private ClientCombatPresentationSnapshot _combatSnapshot = ClientCombatPresentationSnapshot.Empty;
public void Init(Player master)
{
@@ -17,9 +19,11 @@ public class PlayerUI : MonoBehaviour
RefreshText();
}
public void SyncAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
public void SyncAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot, ClientCombatPresentationSnapshot combatSnapshot)
{
RefreshText(snapshot);
_authoritativeSnapshot = snapshot;
_combatSnapshot = combatSnapshot ?? ClientCombatPresentationSnapshot.Empty;
RefreshText();
}
private void FixedUpdate()
@@ -47,19 +51,30 @@ public class PlayerUI : MonoBehaviour
_isVisible = false;
}
private void RefreshText(ClientAuthoritativePlayerStateSnapshot snapshot = null)
private void RefreshText()
{
if (_text == null || _master == null)
{
return;
}
if (snapshot == null)
if (_authoritativeSnapshot == null)
{
_text.text = _master.PlayerId;
_text.text = $"{_master.PlayerId}\nHP:?\nCombat:{FormatCombatLine()}";
return;
}
_text.text = $"{_master.PlayerId}\nHP:{snapshot.Hp} Tick:{snapshot.Tick}";
_text.text = $"{_master.PlayerId}\nHP:{_authoritativeSnapshot.Hp} Tick:{_authoritativeSnapshot.Tick}\nCombat:{FormatCombatLine()}";
}
private string FormatCombatLine()
{
if (_combatSnapshot == null || !_combatSnapshot.HasLastEvent)
{
return _combatSnapshot != null && _combatSnapshot.IsDead ? "Dead" : "None";
}
var deadSuffix = _combatSnapshot.IsDead ? " Dead" : string.Empty;
return $"{_combatSnapshot.LastEventType} Dmg:{_combatSnapshot.LastDamage} Tick:{_combatSnapshot.LastEventTick}{deadSuffix}";
}
}
@@ -156,6 +156,119 @@ namespace Tests.EditMode.Network
Assert.That(snapshot.SourceState.Hp, Is.EqualTo(88));
}
[Test]
public void ClientCombatEventRouting_DamageEvent_RoutesToTargetPlayer()
{
var routed = ClientCombatEventRouting.TryGetAffectedPlayerId(
new CombatEvent
{
EventType = CombatEventType.DamageApplied,
AttackerId = "player-a",
TargetId = "player-b",
Damage = 15
},
out var playerId);
Assert.That(routed, Is.True);
Assert.That(playerId, Is.EqualTo("player-b"));
}
[Test]
public void ClientCombatEventRouting_ShootRejected_RoutesToAttackerPlayer()
{
var routed = ClientCombatEventRouting.TryGetAffectedPlayerId(
new CombatEvent
{
EventType = CombatEventType.ShootRejected,
AttackerId = "player-a",
TargetId = "player-b"
},
out var playerId);
Assert.That(routed, Is.True);
Assert.That(playerId, Is.EqualTo("player-a"));
}
[Test]
public void ClientAuthoritativePlayerState_DamageCombatEvent_ReducesHpAndRecordsCombatResult()
{
var owner = new ClientAuthoritativePlayerState();
owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 10, Hp = 100 }, out _);
var applied = owner.TryApplyCombatEvent(
new CombatEvent
{
Tick = 11,
EventType = CombatEventType.DamageApplied,
AttackerId = "player-2",
TargetId = "player-1",
Damage = 30
},
"player-1",
out var snapshot,
out var combatSnapshot);
Assert.That(applied, Is.True);
Assert.That(snapshot, Is.Not.Null);
Assert.That(snapshot.Hp, Is.EqualTo(70));
Assert.That(owner.Current.Hp, Is.EqualTo(70));
Assert.That(combatSnapshot.HasLastEvent, Is.True);
Assert.That(combatSnapshot.LastEventType, Is.EqualTo(CombatEventType.DamageApplied));
Assert.That(combatSnapshot.LastDamage, Is.EqualTo(30));
Assert.That(combatSnapshot.IsDead, Is.False);
}
[Test]
public void ClientAuthoritativePlayerState_ShootRejected_LeavesHpUnchangedAndRecordsVisibility()
{
var owner = new ClientAuthoritativePlayerState();
owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 10, Hp = 90 }, out _);
var applied = owner.TryApplyCombatEvent(
new CombatEvent
{
Tick = 12,
EventType = CombatEventType.ShootRejected,
AttackerId = "player-1",
TargetId = "player-2"
},
"player-1",
out var snapshot,
out var combatSnapshot);
Assert.That(applied, Is.True);
Assert.That(snapshot.Hp, Is.EqualTo(90));
Assert.That(combatSnapshot.LastEventType, Is.EqualTo(CombatEventType.ShootRejected));
Assert.That(combatSnapshot.LastDamage, Is.EqualTo(0));
Assert.That(combatSnapshot.IsDead, Is.False);
}
[Test]
public void ClientAuthoritativePlayerState_DeathCombatEvent_MarksPlayerDeadAndAllowsLaterSnapshotRefresh()
{
var owner = new ClientAuthoritativePlayerState();
owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 10, Hp = 20 }, out _);
owner.TryApplyCombatEvent(
new CombatEvent
{
Tick = 11,
EventType = CombatEventType.Death,
AttackerId = "player-2",
TargetId = "player-1"
},
"player-1",
out var deathSnapshot,
out var deathCombatSnapshot);
var accepted = owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 12, Hp = 55 }, out var refreshedSnapshot);
Assert.That(deathSnapshot.Hp, Is.EqualTo(0));
Assert.That(deathCombatSnapshot.IsDead, Is.True);
Assert.That(accepted, Is.True);
Assert.That(refreshedSnapshot.Hp, Is.EqualTo(55));
Assert.That(owner.CombatPresentation.IsDead, Is.False);
}
[Test]
public void RemotePlayerSnapshotInterpolator_StaleOrDuplicateSnapshots_AreRejected()
{
+6 -2
View File
@@ -26,7 +26,7 @@ Material:
m_TexEnvs:
- _BaseMap:
m_Texture: {fileID: 2800000, guid: 79cfbbc88ce488e41aca90a3043b4786, type: 3}
m_Scale: {x: 2, y: 1}
m_Scale: {x: 10, y: 5}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
@@ -50,7 +50,7 @@ Material:
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 2800000, guid: 79cfbbc88ce488e41aca90a3043b4786, type: 3}
m_Scale: {x: 2, y: 1}
m_Scale: {x: 10, y: 5}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
@@ -83,7 +83,9 @@ Material:
m_Ints: []
m_Floats:
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BlendOp: 0
- _BumpScale: 1
- _ClearCoatMask: 0
@@ -93,6 +95,7 @@ Material:
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _GlossMapScale: 1
- _Glossiness: 0.5
@@ -108,6 +111,7 @@ Material:
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _UVSec: 0
- _WorkflowMode: 1