Compare commits

...

5 Commits

Author SHA1 Message Date
metalgearsloth
da2bfdaa10 Version: 241.0.0 2025-01-27 21:31:31 +11:00
Leon Friedrich
af6cac14d6 Add CollisionPredictionTest (#5493)
Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
2025-01-27 21:23:23 +11:00
Leon Friedrich
3f37846731 Avoid unnecessary DirtyField() calls (#5620) 2025-01-27 21:21:53 +11:00
metalgearsloth
d9bf1d1afb BUI deferral tweaks (#5503) 2025-01-27 21:04:27 +11:00
metalgearsloth
b9b80192e7 Minor contact QOL (#5560) 2025-01-27 16:20:04 +11:00
20 changed files with 832 additions and 167 deletions

View File

@@ -1,4 +1,4 @@
<Project>
<!-- This file automatically reset by Tools/version.py -->
<!-- This file automatically reset by Tools/version.py -->

View File

@@ -54,6 +54,23 @@ END TEMPLATE-->
*None yet*
## 241.0.0
### Breaking changes
* Remove DeferredClose from BUIs.
### New features
* Added `EntityManager.DirtyFields()`, which allows components with delta states to simultaneously mark several fields as dirty at the same time.
* Add `CloserUserUIs<T>` to close keys of a specific key.
### Bugfixes
* Fixed `RaisePredictiveEvent()` not properly re-raising events during prediction for event handlers that did not take an `EntitySessionEventArgs` argument.
* BUI openings are now deferred to avoid having slight desync between deferred closes and opens occurring in the same tick.
## 240.1.2

View File

@@ -101,11 +101,33 @@ namespace Robust.Client.GameObjects
/// <inheritdoc />
public override void Dirty<T>(Entity<T> ent, MetaDataComponent? meta = null)
{
// Client only dirties during prediction
// Client only dirties during prediction
if (_gameTiming.InPrediction)
base.Dirty(ent, meta);
}
public override void DirtyField<T>(EntityUid uid, T comp, string fieldName, MetaDataComponent? metadata = null)
{
// TODO Prediction
// does the client actually need to dirty the field?
// I.e., can't it just dirty the whole component to trigger a reset?
// Client only dirties during prediction
if (_gameTiming.InPrediction)
base.DirtyField(uid, comp, fieldName, metadata);
}
public override void DirtyFields<T>(EntityUid uid, T comp, MetaDataComponent? meta, params ReadOnlySpan<string> fields)
{
// TODO Prediction
// does the client actually need to dirty the field?
// I.e., can't it just dirty the whole component to trigger a reset?
// Client only dirties during prediction
if (_gameTiming.InPrediction)
base.DirtyFields(uid, comp, meta, fields);
}
/// <inheritdoc />
public override void Dirty<T1, T2>(Entity<T1, T2> ent, MetaDataComponent? meta = null)
{

View File

@@ -42,7 +42,7 @@ namespace Robust.Client.GameStates
private uint _nextInputCmdSeq = 1;
private readonly Queue<FullInputCmdMessage> _pendingInputs = new();
private readonly Queue<(uint sequence, GameTick sourceTick, EntityEventArgs msg, object sessionMsg)>
private readonly Queue<(uint sequence, GameTick sourceTick, object msg, object sessionMsg)>
_pendingSystemMessages
= new();
@@ -504,9 +504,7 @@ namespace Robust.Client.GameStates
while (hasPendingMessage && pendingMessagesEnumerator.Current.sourceTick <= _timing.CurTick)
{
var msg = pendingMessagesEnumerator.Current.msg;
_entities.EventBus.RaiseEvent(EventSource.Local, msg);
_entities.EventBus.RaiseEvent(EventSource.Local, pendingMessagesEnumerator.Current.msg);
_entities.EventBus.RaiseEvent(EventSource.Local, pendingMessagesEnumerator.Current.sessionMsg);
hasPendingMessage = pendingMessagesEnumerator.MoveNext();
}

View File

@@ -93,7 +93,7 @@ public sealed partial class PhysicsSystem
var maps = new HashSet<EntityUid>();
var enumerator = AllEntityQuery<PredictedPhysicsComponent, PhysicsComponent, TransformComponent>();
while (enumerator.MoveNext(out var _, out var physics, out var xform))
while (enumerator.MoveNext(out _, out var physics, out var xform))
{
DebugTools.Assert(physics.Predict);

View File

@@ -313,7 +313,10 @@ internal partial class UserInterfaceManager
private void _clearTooltip()
{
if (!_showingTooltip) return;
_resetTooltipTimer();
if (!_showingTooltip)
return;
if (_suppliedTooltip != null)
{
@@ -322,7 +325,6 @@ internal partial class UserInterfaceManager
}
CurrentlyHovered?.PerformHideTooltip();
_resetTooltipTimer();
_showingTooltip = false;
}

View File

@@ -197,6 +197,10 @@ public abstract partial class SharedContainerSystem
{
if (entity.Comp2 is { } physics)
{
// TODO CONTAINER
// Is this actually needed?
// I.e., shouldn't this just do a if (_timing.ApplyingState) return
// Here we intentionally don't dirty the physics comp. Client-side state handling will apply these same
// changes. This also ensures that the server doesn't have to send the physics comp state to every
// player for any entity inside of a container during init.

View File

@@ -28,13 +28,6 @@ namespace Robust.Shared.GameObjects
/// </summary>
protected internal BoundUserInterfaceState? State { get; internal set; }
// Bandaid just for storage :)
/// <summary>
/// Defers state handling
/// </summary>
[Obsolete]
public virtual bool DeferredClose { get; } = true;
protected BoundUserInterface(EntityUid owner, Enum uiKey)
{
IoCManager.InjectDependencies(this);

View File

@@ -1,3 +1,4 @@
using System;
using Robust.Shared.Timing;
namespace Robust.Shared.GameObjects;
@@ -38,11 +39,14 @@ public abstract partial class EntityManager
Dirty(uid, comp, metadata);
}
public void DirtyField<T>(EntityUid uid, T comp, string fieldName, MetaDataComponent? metadata = null)
public virtual void DirtyField<T>(EntityUid uid, T comp, string fieldName, MetaDataComponent? metadata = null)
where T : IComponentDelta
{
var compReg = ComponentFactory.GetRegistration(CompIdx.Index<T>());
// TODO
// consider storing this on MetaDataComponent?
// We alsready store other dirtying information there anyways, and avoids having to fetch the registration.
if (!compReg.NetworkedFieldLookup.TryGetValue(fieldName, out var idx))
{
_sawmill.Error($"Tried to dirty delta field {fieldName} on {ToPrettyString(uid)} that isn't implemented.");
@@ -54,6 +58,24 @@ public abstract partial class EntityManager
comp.LastModifiedFields[idx] = curTick;
Dirty(uid, comp, metadata);
}
public virtual void DirtyFields<T>(EntityUid uid, T comp, MetaDataComponent? meta, params ReadOnlySpan<string> fields)
where T : IComponentDelta
{
var compReg = ComponentFactory.GetRegistration(CompIdx.Index<T>());
var curTick = _gameTiming.CurTick;
foreach (var field in fields)
{
if (!compReg.NetworkedFieldLookup.TryGetValue(field, out var idx))
_sawmill.Error($"Tried to dirty delta field {field} on {ToPrettyString(uid)} that isn't implemented.");
else
comp.LastModifiedFields[idx] = curTick;
}
comp.LastFieldUpdate = curTick;
Dirty(uid, comp, meta);
}
}
/// <summary>

View File

@@ -1694,7 +1694,6 @@ namespace Robust.Shared.GameObjects
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public bool Resolve(EntityUid uid, [NotNullWhen(true)] ref TComp1? component, bool logMissing = true)
{
if (component != null)
@@ -1717,7 +1716,7 @@ namespace Robust.Shared.GameObjects
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining), Pure]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool Resolve(ref Entity<TComp1?> entity, bool logMissing = true)
{
return Resolve(entity.Owner, ref entity.Comp, logMissing);

View File

@@ -171,6 +171,13 @@ public partial class EntitySystem
EntityManager.DirtyField(uid, component, fieldName, meta);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected void DirtyFields<T>(EntityUid uid, T comp, MetaDataComponent? meta, params ReadOnlySpan<string> fields)
where T : IComponentDelta
{
EntityManager.DirtyFields(uid, comp, meta);
}
/// <summary>
/// Marks a component as dirty. This also implicitly dirties the entity this component belongs to.
/// </summary>

View File

@@ -899,11 +899,11 @@ public abstract partial class SharedTransformSystem
_mapManager.TryFindGridAt(mapUid, coordinates.Position, out var targetGrid, out _))
{
var invWorldMatrix = GetInvWorldMatrix(targetGrid);
SetCoordinates(entity, new EntityCoordinates(targetGrid, Vector2.Transform(coordinates.Position, invWorldMatrix)));
SetCoordinates((entity.Owner, entity.Comp, MetaData(entity.Owner)), new EntityCoordinates(targetGrid, Vector2.Transform(coordinates.Position, invWorldMatrix)));
}
else
{
SetCoordinates(entity, new EntityCoordinates(mapUid, coordinates.Position));
SetCoordinates((entity.Owner, entity.Comp, MetaData(entity.Owner)), new EntityCoordinates(mapUid, coordinates.Position));
}
}

View File

@@ -36,9 +36,14 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
private ActorRangeCheckJob _rangeJob;
/// <summary>
/// Defer closing BUIs during state handling so client doesn't spam a BUI constantly during prediction.
/// Defer BUIs during state handling so client doesn't spam a BUI constantly during prediction.
/// </summary>
private readonly List<BoundUserInterface> _queuedCloses = new();
private readonly List<(BoundUserInterface Bui, bool value)> _queuedBuis = new();
/// <summary>
/// Temporary storage for BUI keys
/// </summary>
private ValueList<Enum> _keys = new();
public override void Initialize()
{
@@ -227,13 +232,7 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
if (ent.Comp.ClientOpenInterfaces.TryGetValue(key, out var cBui))
{
if (cBui.DeferredClose)
_queuedCloses.Add(cBui);
else
{
ent.Comp.ClientOpenInterfaces.Remove(key);
cBui.Dispose();
}
_queuedBuis.Add((cBui, false));
}
if (ent.Comp.Actors.Count == 0)
@@ -275,13 +274,7 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
// PlayerAttachedEvent will catch some of these.
foreach (var (key, bui) in ent.Comp.ClientOpenInterfaces)
{
bui.Open();
if (ent.Comp.States.TryGetValue(key, out var state))
{
bui.UpdateState(state);
bui.Update();
}
_queuedBuis.Add((bui, true));
}
}
@@ -301,7 +294,7 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
DebugTools.Assert(!ent.Comp.Actors.ContainsKey(key));
}
DebugTools.Assert(ent.Comp.ClientOpenInterfaces.Values.All(x => _queuedCloses.Contains(x)));
DebugTools.Assert(ent.Comp.ClientOpenInterfaces.Values.All(x => _queuedBuis.Contains((x, false))));
}
private void OnUserInterfaceGetState(Entity<UserInterfaceComponent> ent, ref ComponentGetState args)
@@ -463,14 +456,7 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
}
var bui = ent.Comp.ClientOpenInterfaces[key];
if (bui.DeferredClose)
_queuedCloses.Add(bui);
else
{
ent.Comp.ClientOpenInterfaces.Remove(key);
bui.Dispose();
}
_queuedBuis.Add((bui, false));
}
}
@@ -527,9 +513,7 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
// Existing BUI just keep it.
if (entity.Comp.ClientOpenInterfaces.TryGetValue(key, out var existing))
{
if (existing.DeferredClose)
_queuedCloses.Remove(existing);
_queuedBuis.Remove((existing, false));
return;
}
@@ -545,10 +529,6 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
// Try-catch to try prevent error loops / bricked clients that constantly throw exceptions while applying game
// states. E.g., stripping UI used to throw NREs in some instances while fetching the identity of unknown
// entities.
#if EXCEPTION_TOLERANCE
try
{
#endif
var type = _reflection.LooseGetType(data.ClientType);
var boundUserInterface = (BoundUserInterface) _factory.CreateInstance(type, [entity.Owner, key]);
entity.Comp.ClientOpenInterfaces[key] = boundUserInterface;
@@ -557,23 +537,7 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
if (!open)
return;
boundUserInterface.Open();
if (entity.Comp.States.TryGetValue(key, out var buiState))
{
boundUserInterface.State = buiState;
boundUserInterface.UpdateState(buiState);
boundUserInterface.Update();
}
#if EXCEPTION_TOLERANCE
}
catch (Exception e)
{
Log.Error(
$"Caught exception while attempting to create a BUI {key} with type {data.ClientType} on entity {ToPrettyString(entity.Owner)}. Exception: {e}");
return;
}
#endif
_queuedBuis.Add((boundUserInterface, true));
}
/// <summary>
@@ -887,6 +851,32 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
RaiseNetworkEvent(new BoundUIWrapMessage(GetNetEntity(entity.Owner), message, key));
}
/// <summary>
/// Closes the user's UIs that match the specified key.
/// </summary>
public void CloseUserUis<T>(Entity<UserInterfaceUserComponent?> actor) where T: Enum
{
if (!UserQuery.Resolve(actor.Owner, ref actor.Comp, false))
return;
if (actor.Comp.OpenInterfaces.Count == 0)
return;
foreach (var (uid, enums) in actor.Comp.OpenInterfaces)
{
_keys.Clear();
_keys.AddRange(enums);
foreach (var weh in _keys)
{
if (weh is not T)
continue;
CloseUiInternal(uid, weh, actor.Owner);
}
}
}
/// <summary>
/// Closes all Uis for the actor.
/// </summary>
@@ -898,13 +888,12 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
if (actor.Comp.OpenInterfaces.Count == 0)
return;
var enumCopy = new ValueList<Enum>();
foreach (var (uid, enums) in actor.Comp.OpenInterfaces)
{
enumCopy.Clear();
enumCopy.AddRange(enums);
_keys.Clear();
_keys.AddRange(enums);
foreach (var key in enumCopy)
foreach (var key in _keys)
{
CloseUiInternal(uid, key, actor.Owner);
}
@@ -1039,17 +1028,46 @@ public abstract class SharedUserInterfaceSystem : EntitySystem
{
if (_timing.IsFirstTimePredicted)
{
foreach (var bui in _queuedCloses)
foreach (var (bui, open) in _queuedBuis)
{
if (UIQuery.TryComp(bui.Owner, out var uiComp))
if (open)
{
uiComp.ClientOpenInterfaces.Remove(bui.UiKey);
}
bui.Open();
#if EXCEPTION_TOLERANCE
try
{
#endif
bui.Dispose();
if (UIQuery.TryComp(bui.Owner, out var uiComp))
{
if (uiComp.States.TryGetValue(bui.UiKey, out var buiState))
{
bui.State = buiState;
bui.UpdateState(buiState);
bui.Update();
}
}
#if EXCEPTION_TOLERANCE
}
catch (Exception e)
{
Log.Error(
$"Caught exception while attempting to create a BUI {bui.UiKey} with type {bui.GetType()} on entity {ToPrettyString(bui.Owner)}. Exception: {e}");
}
#endif
}
else
{
if (UIQuery.TryComp(bui.Owner, out var uiComp))
{
uiComp.ClientOpenInterfaces.Remove(bui.UiKey);
}
bui.Dispose();
}
}
_queuedCloses.Clear();
_queuedBuis.Clear();
}
var query = AllEntityQuery<ActiveUserInterfaceComponent, UserInterfaceComponent>();

View File

@@ -30,6 +30,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using JetBrains.Annotations;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Maths;
@@ -131,6 +132,14 @@ namespace Robust.Shared.Physics.Dynamics.Contacts
/// </summary>
public float TangentSpeed { get; set; }
[ViewVariables]
public bool Deleting => (Flags & ContactFlags.Deleting) == ContactFlags.Deleting;
/// <summary>
/// If either fixture is hard then it's a hard contact.
/// </summary>
public bool Hard => FixtureA != null && FixtureB != null && (FixtureA.Hard && FixtureB.Hard);
public void ResetRestitution()
{
Restitution = MathF.Max(FixtureA?.Restitution ?? 0.0f, FixtureB?.Restitution ?? 0.0f);
@@ -353,9 +362,21 @@ namespace Robust.Shared.Physics.Dynamics.Contacts
return HashCode.Combine(EntityA, EntityB);
}
[Pure]
public EntityUid OurEnt(EntityUid uid)
{
if (uid == EntityA)
return EntityA;
else if (uid == EntityB)
return EntityB;
throw new InvalidOperationException();
}
/// <summary>
/// Gets the other ent for this contact.
/// </summary>
[Pure]
public EntityUid OtherEnt(EntityUid uid)
{
if (uid == EntityA)
@@ -366,6 +387,18 @@ namespace Robust.Shared.Physics.Dynamics.Contacts
throw new InvalidOperationException();
}
[Pure, PublicAPI]
public (string Id, Fixture) OurFixture(EntityUid uid)
{
if (uid == EntityA)
return (FixtureAId, FixtureA!);
else if (uid == EntityB)
return (FixtureBId, FixtureB!);
throw new InvalidOperationException();
}
[Pure, PublicAPI]
public (string Id, Fixture) OtherFixture(EntityUid uid)
{
if (uid == EntityA)

View File

@@ -1,5 +1,4 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Numerics;
using System.Threading.Tasks;
@@ -8,7 +7,6 @@ using Robust.Shared.Collections;
using Robust.Shared.Configuration;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Maths;
@@ -413,24 +411,34 @@ namespace Robust.Shared.Physics.Systems
}, aabb, true);
}
[Obsolete("Use Entity<T> variant")]
public void RegenerateContacts(EntityUid uid, PhysicsComponent body, FixturesComponent? fixtures = null, TransformComponent? xform = null)
{
_physicsSystem.DestroyContacts(body);
if (!Resolve(uid, ref xform, ref fixtures))
RegenerateContacts((uid, body, fixtures, xform));
}
public void RegenerateContacts(Entity<PhysicsComponent?, FixturesComponent?, TransformComponent?> entity)
{
if (!Resolve(entity.Owner, ref entity.Comp1))
return;
if (xform.MapUid == null)
_physicsSystem.DestroyContacts(entity.Comp1);
if (!Resolve(entity.Owner, ref entity.Comp2 , ref entity.Comp3))
return;
if (!_xformQuery.TryGetComponent(xform.Broadphase?.Uid, out var broadphase))
if (entity.Comp3.MapUid == null)
return;
_physicsSystem.SetAwake((uid, body), true);
if (!_xformQuery.TryGetComponent(entity.Comp3.Broadphase?.Uid, out var broadphase))
return;
_physicsSystem.SetAwake(entity!, true);
var matrix = _transform.GetWorldMatrix(broadphase);
foreach (var fixture in fixtures.Fixtures.Values)
foreach (var fixture in entity.Comp2.Fixtures.Values)
{
TouchProxies(xform.MapUid.Value, matrix, fixture);
TouchProxies(entity.Comp3.MapUid.Value, matrix, fixture);
}
}

View File

@@ -141,26 +141,26 @@ public partial class SharedPhysicsSystem
if (args.Current is PhysicsLinearVelocityDeltaState linearState)
{
SetLinearVelocity(uid, linearState.LinearVelocity, body: component, manager: manager);
SetLinearVelocity(uid, linearState.LinearVelocity, dirty: false, body: component, manager: manager);
}
else if (args.Current is PhysicsVelocityDeltaState velocityState)
{
SetLinearVelocity(uid, velocityState.LinearVelocity, body: component, manager: manager);
SetAngularVelocity(uid, velocityState.AngularVelocity, body: component, manager: manager);
SetLinearVelocity(uid, velocityState.LinearVelocity, dirty: false, body: component, manager: manager);
SetAngularVelocity(uid, velocityState.AngularVelocity, dirty: false, body: component, manager: manager);
}
else if (args.Current is PhysicsComponentState newState)
{
SetSleepingAllowed(uid, component, newState.SleepingAllowed);
SetFixedRotation(uid, newState.FixedRotation, body: component);
SetCanCollide(uid, newState.CanCollide, body: component);
SetSleepingAllowed(uid, component, newState.SleepingAllowed, dirty: false);
SetFixedRotation(uid, newState.FixedRotation, body: component, dirty: false);
SetCanCollide(uid, newState.CanCollide, body: component, dirty: false);
component.BodyStatus = newState.Status;
SetLinearVelocity(uid, newState.LinearVelocity, body: component, manager: manager);
SetAngularVelocity(uid, newState.AngularVelocity, body: component, manager: manager);
SetLinearVelocity(uid, newState.LinearVelocity, dirty: false, body: component, manager: manager);
SetAngularVelocity(uid, newState.AngularVelocity, dirty: false, body: component, manager: manager);
SetBodyType(uid, newState.BodyType, manager, component);
SetFriction(uid, component, newState.Friction);
SetLinearDamping(uid, component, newState.LinearDamping);
SetAngularDamping(uid, component, newState.AngularDamping);
SetFriction(uid, component, newState.Friction, dirty: false);
SetLinearDamping(uid, component, newState.LinearDamping, dirty: false);
SetAngularDamping(uid, component, newState.AngularDamping, dirty: false);
component.Force = newState.Force;
component.Torque = newState.Torque;
}
@@ -276,29 +276,12 @@ public partial class SharedPhysicsSystem
/// </summary>
public void ResetDynamics(EntityUid uid, PhysicsComponent body, bool dirty = true)
{
if (body.Torque != 0f)
{
body.Torque = 0f;
DirtyField(uid, body, nameof(PhysicsComponent.Torque));
}
if (body.AngularVelocity != 0f)
{
body.AngularVelocity = 0f;
DirtyField(uid, body, nameof(PhysicsComponent.AngularVelocity));
}
if (body.Force != Vector2.Zero)
{
body.Force = Vector2.Zero;
DirtyField(uid, body, nameof(PhysicsComponent.Force));
}
if (body.LinearVelocity != Vector2.Zero)
{
body.LinearVelocity = Vector2.Zero;
DirtyField(uid, body, nameof(PhysicsComponent.LinearVelocity));
}
body.Torque = 0f;
body.AngularVelocity = 0f;
body.Force = Vector2.Zero;
body.LinearVelocity = Vector2.Zero;
if (dirty)
DirtyFields(uid, body, null, nameof(PhysicsComponent.Torque), nameof(PhysicsComponent.AngularVelocity), nameof(PhysicsComponent.Force), nameof(PhysicsComponent.LinearVelocity));
}
public void ResetMassData(EntityUid uid, FixturesComponent? manager = null, PhysicsComponent? body = null)
@@ -403,7 +386,8 @@ public partial class SharedPhysicsSystem
return false;
body.AngularVelocity = value;
DirtyField(uid, body, nameof(PhysicsComponent.AngularVelocity));
if (dirty)
DirtyField(uid, body, nameof(PhysicsComponent.AngularVelocity));
return true;
}
@@ -429,7 +413,9 @@ public partial class SharedPhysicsSystem
return false;
body.LinearVelocity = velocity;
DirtyField(uid, body, nameof(PhysicsComponent.LinearVelocity));
if (dirty)
DirtyField(uid, body, nameof(PhysicsComponent.LinearVelocity));
return true;
}
@@ -439,7 +425,8 @@ public partial class SharedPhysicsSystem
return;
body.AngularDamping = value;
DirtyField(uid, body, nameof(PhysicsComponent.AngularDamping));
if (dirty)
DirtyField(uid, body, nameof(PhysicsComponent.AngularDamping));
}
public void SetLinearDamping(EntityUid uid, PhysicsComponent body, float value, bool dirty = true)
@@ -448,7 +435,8 @@ public partial class SharedPhysicsSystem
return;
body.LinearDamping = value;
DirtyField(uid, body, nameof(PhysicsComponent.LinearDamping));
if (dirty)
DirtyField(uid, body, nameof(PhysicsComponent.LinearDamping));
}
[Obsolete("Use SetAwake with EntityUid<PhysicsComponent>")]
@@ -524,32 +512,27 @@ public partial class SharedPhysicsSystem
body.BodyType = value;
ResetMassData(uid, manager, body);
body.Force = Vector2.Zero;
body.Torque = 0f;
if (body.BodyType == BodyType.Static)
{
SetAwake((uid, body), false);
if (body.LinearVelocity != Vector2.Zero)
{
body.LinearVelocity = Vector2.Zero;
DirtyField(uid, body, nameof(PhysicsComponent.LinearVelocity));
}
body.LinearVelocity = Vector2.Zero;
body.AngularVelocity = 0f;
if (body.AngularVelocity != 0f)
{
body.AngularVelocity = 0f;
DirtyField(uid, body, nameof(PhysicsComponent.AngularVelocity));
}
DirtyFields(uid, body, null,
nameof(PhysicsComponent.LinearVelocity),
nameof(PhysicsComponent.AngularVelocity),
nameof(PhysicsComponent.Force),
nameof(PhysicsComponent.Torque));
}
// Even if it's dynamic if it can't collide then don't force it awake.
else if (body.CanCollide)
{
SetAwake((uid, body), true);
}
if (body.Torque != 0f)
{
body.Torque = 0f;
DirtyField(uid, body, nameof(PhysicsComponent.Torque));
DirtyFields(uid, body, null, nameof(PhysicsComponent.Force), nameof(PhysicsComponent.Torque));
}
_broadphase.RegenerateContacts(uid, body, manager, xform);
@@ -567,7 +550,8 @@ public partial class SharedPhysicsSystem
return;
body.BodyStatus = status;
DirtyField(uid, body, nameof(PhysicsComponent.BodyStatus));
if (dirty)
DirtyField(uid, body, nameof(PhysicsComponent.BodyStatus));
}
/// <summary>
@@ -618,7 +602,10 @@ public partial class SharedPhysicsSystem
var ev = new CollisionChangeEvent(uid, body, value);
RaiseLocalEvent(ref ev);
}
DirtyField(uid, body, nameof(PhysicsComponent.CanCollide));
if (dirty)
DirtyField(uid, body, nameof(PhysicsComponent.CanCollide));
return value;
}
@@ -628,13 +615,10 @@ public partial class SharedPhysicsSystem
return;
body.FixedRotation = value;
DirtyField(uid, body, nameof(PhysicsComponent.FixedRotation));
body.AngularVelocity = 0.0f;
if (body.AngularVelocity != 0f)
{
body.AngularVelocity = 0.0f;
DirtyField(uid, body, nameof(PhysicsComponent.AngularVelocity));
}
if (dirty)
DirtyFields(uid, body, null, nameof(PhysicsComponent.FixedRotation), nameof(PhysicsComponent.AngularVelocity));
ResetMassData(uid, manager: manager, body: body);
}
@@ -645,7 +629,8 @@ public partial class SharedPhysicsSystem
return;
body._friction = value;
DirtyField(uid, body, nameof(PhysicsComponent.Friction));
if (dirty)
DirtyField(uid, body, nameof(PhysicsComponent.Friction));
}
public void SetInertia(EntityUid uid, PhysicsComponent body, float value, bool dirty = true)
@@ -684,7 +669,8 @@ public partial class SharedPhysicsSystem
SetAwake((uid, body), true);
body.SleepingAllowed = value;
DirtyField(uid, body, nameof(PhysicsComponent.SleepingAllowed));
if (dirty)
DirtyField(uid, body, nameof(PhysicsComponent.SleepingAllowed));
}
public void SetSleepTime(PhysicsComponent body, float value)

View File

@@ -809,10 +809,10 @@ public abstract partial class SharedPhysicsSystem
/// Returns all of this entity's contacts.
/// </summary>
[Pure]
public ContactEnumerator GetContacts(Entity<FixturesComponent?> entity)
public ContactEnumerator GetContacts(Entity<FixturesComponent?> entity, bool includeDeleting = false)
{
_fixturesQuery.Resolve(entity.Owner, ref entity.Comp);
return new ContactEnumerator(entity.Comp);
return new ContactEnumerator(entity.Comp, includeDeleting);
}
}
@@ -823,8 +823,16 @@ public record struct ContactEnumerator
private Dictionary<string, Fixture>.ValueCollection.Enumerator _fixtureEnumerator;
private Dictionary<Fixture, Contact>.ValueCollection.Enumerator _contactEnumerator;
public ContactEnumerator(FixturesComponent? fixtures)
/// <summary>
/// Also include deleting contacts.
/// This typically includes the current contact if you're invoking this in the eventbus for an EndCollideEvent.
/// </summary>
public bool IncludeDeleting;
public ContactEnumerator(FixturesComponent? fixtures, bool includeDeleting = false)
{
IncludeDeleting = includeDeleting;
if (fixtures == null || fixtures.Fixtures.Count == 0)
{
this = Empty;
@@ -851,6 +859,10 @@ public record struct ContactEnumerator
}
contact = _contactEnumerator.Current;
if (!IncludeDeleting && contact.Deleting)
return MoveNext(out contact);
return true;
}
}

View File

@@ -202,27 +202,27 @@ namespace Robust.Shared.Physics.Systems
return bodies;
}
public HashSet<EntityUid> GetContactingEntities(EntityUid uid, PhysicsComponent? body = null, bool approximate = false)
public void GetContactingEntities(Entity<PhysicsComponent?> ent, HashSet<EntityUid> contacting, bool approximate = false)
{
// HashSet to ensure that we only return each entity once, instead of once per colliding fixture.
var result = new HashSet<EntityUid>();
if (!Resolve(ent.Owner, ref ent.Comp))
return;
if (!Resolve(uid, ref body))
return result;
var node = body.Contacts.First;
var node = ent.Comp.Contacts.First;
while (node != null)
{
var contact = node.Value;
node = node.Next;
if (!approximate && !contact.IsTouching)
continue;
result.Add(uid == contact.EntityA ? contact.EntityB : contact.EntityA);
if (approximate || contact.IsTouching)
contacting.Add(ent.Owner == contact.EntityA ? contact.EntityB : contact.EntityA);
}
}
public HashSet<EntityUid> GetContactingEntities(EntityUid uid, PhysicsComponent? body = null, bool approximate = false)
{
var result = new HashSet<EntityUid>();
GetContactingEntities((uid, body), result, approximate);
return result;
}

View File

@@ -83,6 +83,13 @@ namespace Robust.Shared.Physics.Systems
_physicsReg = EntityManager.ComponentFactory.GetRegistration(CompIdx.Index<PhysicsComponent>());
// TODO PHYSICS STATE
// Consider condensing the possible fields into just Linear velocity, angular velocity, and "Other"
// Or maybe even just "velocity" & "other"
// Then get-state doesn't have to iterate over a 10-element array.
// And it simplifies the DirtyField calls.
// Though I guess combining fixtures & physics will complicate it a bit more again.
// If you update this then update the delta state + GetState + HandleState!
EntityManager.ComponentFactory.RegisterNetworkedFields(_physicsReg,
nameof(PhysicsComponent.CanCollide),
@@ -320,6 +327,28 @@ namespace Robust.Shared.Physics.Systems
RaiseLocalEvent(ref updateMapBeforeSolve);
}
// TODO PHYSICS Fix Collision Mispredicts
// If a physics update induces a position update that brings fixtures into contact, the collision starts in the NEXT tick,
// as positions are updated after CollideContacts() gets called.
//
// If a player input induces a position update that brings fixtures into contact, the collision happens on the SAME tick,
// as inputs are handled before system updates.
//
// When applying a server's game state with new positions, the client won't know what caused the positions to update,
// and thus can't know whether the collision already occurred (i.e., whether its effects are already contained within the
// game state currently being applied), or whether it should start on the next tick and needs to predict the start of
// the collision.
//
// Currently the client assumes that any position updates happened due to physics steps. I.e., positions are reset, then
// contacts are reset via ResetContacts(), then positions are updated using the new game state. Alternatively, we could
// call ResetContacts() AFTER applying the server state, which would correspond to assuming that the collisions have
// already "started" in the state, and we don't want to re-raise the events.
//
// Currently there is no way to avoid mispredicts from happening. E.g., a simple collision-counter component will always
// either mispredict on physics induced position changes, or on player/input induced updates. The easiest way I can think
// of to fix this would be to always call `CollideContacts` again at the very end of a physics update.
// But that might be unnecessarily expensive for what are hopefully only infrequent mispredicts.
CollideContacts();
var enumerator = AllEntityQuery<PhysicsMapComponent>();

View File

@@ -0,0 +1,515 @@
using System;
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
using NUnit.Framework;
using Robust.Client.Physics;
using Robust.Shared;
using Robust.Shared.GameObjects;
using Robust.Shared.GameStates;
using Robust.Shared.IoC;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Physics.Collision.Shapes;
using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Dynamics;
using Robust.Shared.Physics.Events;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
namespace Robust.UnitTesting.Shared.Physics;
/// <summary>
/// This test is meant to check that collision start & stop events are raised correctly by the client.
/// The expectation is that start & stop events are only raised if the client predicts that two entities will move into
/// contact. They do not get raised as a result of applying component states received from the server.
/// I.e., the assumption is that if a collision results in changes to data on a component, then that data will already
/// have been sent to clients in the component's state, so we don't want to "double count" collisions.
/// </summary>
public sealed class CollisionPredictionTest : RobustIntegrationTest
{
private static readonly string Prototypes = @"
- type: entity
id: CollisionTest1
components:
- type: CollisionPredictionTest
- type: Physics
bodyType: Dynamic
sleepingAllowed: false
- type: entity
id: CollisionTest2
components:
- type: Physics
bodyType: Dynamic
sleepingAllowed: false
";
[Test]
[TestCase(true, true)]
[TestCase(true, false)]
[TestCase(false, true)]
[TestCase(false, false)]
public async Task TestCollisionPrediction(bool hard1, bool hard2)
{
var serverOpts = new ServerIntegrationOptions { Pool = false, ExtraPrototypes = Prototypes };
var clientOpts = new ClientIntegrationOptions { Pool = false, ExtraPrototypes = Prototypes };
var server = StartServer(serverOpts);
var client = StartClient(clientOpts);
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
var netMan = client.ResolveDependency<IClientNetManager>();
Assert.DoesNotThrow(() => client.SetConnectTarget(server));
await server.WaitPost(() => server.CfgMan.SetCVar(CVars.NetPVS, false));
await client.WaitPost(() => netMan.ClientConnect(null!, 0, null!));
var sFix = server.System<FixtureSystem>();
var sPhys = server.System<SharedPhysicsSystem>();
var sSys = server.System<CollisionPredictionTestSystem>();
// Set up entities
EntityUid map = default;
EntityUid sEntity1 = default;
EntityUid sEntity2 = default;
MapCoordinates coords1 = default;
MapCoordinates coords2 = default;
await server.WaitPost(() =>
{
var radius = 0.25f;
map = server.System<SharedMapSystem>().CreateMap(out var mapId);
coords1 = new(default, mapId);
coords2 = new(Vector2.One, mapId);
sEntity1 = server.EntMan.Spawn("CollisionTest1", coords1);
sEntity2 = server.EntMan.Spawn("CollisionTest2", new MapCoordinates(coords2.Position + new Vector2(0, radius), mapId));
sFix.CreateFixture(sEntity1, "a", new Fixture(new PhysShapeCircle(radius), 1, 1, hard1));
sFix.CreateFixture(sEntity2, "a", new Fixture(new PhysShapeCircle(radius), 1, 1, hard2));
sPhys.SetCanCollide(sEntity1, true);
sPhys.SetCanCollide(sEntity2, true);
sPhys.SetAwake((sEntity1, server.EntMan.GetComponent<PhysicsComponent>(sEntity1)), true);
sPhys.SetAwake((sEntity2, server.EntMan.GetComponent<PhysicsComponent>(sEntity2)), true);
});
for (var i = 0; i < 10; i++)
{
await server.WaitRunTicks(1);
await client.WaitRunTicks(1);
}
await server.WaitPost(() => server.PlayerMan.JoinGame(server.PlayerMan.Sessions.First()));
for (var i = 0; i < 10; i++)
{
await server.WaitRunTicks(1);
await client.WaitRunTicks(1);
}
// Ensure client & server ticks are synced.
// Client runs 2 tick ahead
{
var targetDelta = 2;
var sTick = (int)server.Timing.CurTick.Value;
var cTick = (int)client.Timing.CurTick.Value;
var delta = cTick - sTick;
if (delta > targetDelta)
await server.WaitRunTicks(delta - targetDelta);
else if (delta < targetDelta)
await client.WaitRunTicks(targetDelta - delta);
sTick = (int)server.Timing.CurTick.Value;
cTick = (int)client.Timing.CurTick.Value;
delta = cTick - sTick;
Assert.That(delta, Is.EqualTo(targetDelta));
}
var cPhys = client.System<SharedPhysicsSystem>();
var cSys = client.System<CollisionPredictionTestSystem>();
void ResetSystem()
{
sSys.CollisionEnded = false;
sSys.CollisionStarted = false;
cSys.CollisionEnded = false;
cSys.CollisionStarted = false;
}
async Task Tick()
{
ResetSystem();
await server.WaitRunTicks(1);
await client.WaitRunTicks(1);
}
var nEntity1 = server.EntMan.GetNetEntity(sEntity1);
var nEntity2 = server.EntMan.GetNetEntity(sEntity2);
var cEntity1 = client.EntMan.GetEntity(nEntity1);
var cEntity2 = client.EntMan.GetEntity(nEntity2);
var sComp = server.EntMan.GetComponent<CollisionPredictionTestComponent>(sEntity1);
var cComp = client.EntMan.GetComponent<CollisionPredictionTestComponent>(cEntity1);
cPhys.UpdateIsPredicted(cEntity1);
// Initially, the objects are not colliding.
{
Assert.That(sComp.IsTouching, Is.False);
Assert.That(cComp.IsTouching, Is.False);
Assert.That(cComp.WasTouching, Is.False);
Assert.That(cComp.LastState, Is.False);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty);
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty);
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.False);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.False);
}
// We now simulate a predictive event that gets raised due to some client-side input, causing the entities to
// move and start colliding. Instead of setting up a proper input / keybind handler, The predictive event will
// just be raised in the system update method, which updates before the physics system does.
{
cSys.Ev = new CollisionTestMoveEvent(nEntity1, coords2);
await Tick();
Assert.That(sComp.IsTouching, Is.False);
Assert.That(cComp.IsTouching, Is.True);
Assert.That(cComp.WasTouching, Is.False);
Assert.That(cComp.LastState, Is.False);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty);
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 }));
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 }));
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.True);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.False);
}
// Run another tick. Client should reset states, and re-predict the event.
{
await Tick();
Assert.That(sComp.IsTouching, Is.False);
Assert.That(cComp.IsTouching, Is.True);
Assert.That(cComp.WasTouching, Is.True);
Assert.That(cComp.LastState, Is.False);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty);
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 }));
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 }));
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.True);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.False);
}
// Next tick the server should raise the event received from the client, which will raise a serve-side
// collide-start event.
{
await Tick();
Assert.That(sComp.IsTouching, Is.True);
Assert.That(cComp.IsTouching, Is.True);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.WasTouching, Is.True);
Assert.That(cComp.LastState, Is.False);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 }));
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 }));
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 }));
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 }));
Assert.That(sSys.CollisionStarted, Is.True);
Assert.That(cSys.CollisionStarted, Is.True);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.False);
}
// The client will have received the server-state, but will take some time for it to leave the state buffer.
// In the meantime, the client will keep predicting that the collision will "starts"
for (var i = 0; i < 2; i ++)
{
await Tick();
Assert.That(sComp.IsTouching, Is.True);
Assert.That(cComp.IsTouching, Is.True);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.WasTouching, Is.True);
Assert.That(cComp.LastState, Is.False);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 }));
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 }));
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 }));
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 }));
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.True);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.False);
}
// Then in the next tick the client should apply the new server state, wherein the contacts were already touching.
// I.e., the contact start event never actually gets raised.
{
await Tick();
Assert.That(sComp.IsTouching, Is.True);
Assert.That(cComp.IsTouching, Is.True);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.WasTouching, Is.False); // IsTouching gets resets to false before server state is applied
Assert.That(cComp.LastState, Is.True);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 }));
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 }));
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 }));
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 }));
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.False);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.False);
}
// for the next few ticks, nothing should change
for (var i = 0; i < 10; i ++)
{
await Tick();
Assert.That(sComp.IsTouching, Is.True);
Assert.That(cComp.IsTouching, Is.True);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.WasTouching, Is.False);
Assert.That(cComp.LastState, Is.True);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 }));
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 }));
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.EquivalentTo(new []{ cEntity2 }));
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.EquivalentTo(new []{ cEntity1 }));
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.False);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.False);
}
// Next we move the entity away again, so the contact should stop
{
cSys.Ev = new CollisionTestMoveEvent(nEntity1, coords1);
await Tick();
Assert.That(sComp.IsTouching, Is.True);
Assert.That(cComp.IsTouching, Is.False);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.WasTouching, Is.False);
Assert.That(cComp.LastState, Is.True);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 }));
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 }));
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty);
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.False);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.True);
}
// Next tick, the client should reset to a state where the entities were touching, and then re-predict the stop-collide events
{
await Tick();
Assert.That(sComp.IsTouching, Is.True);
Assert.That(cComp.IsTouching, Is.False);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.WasTouching, Is.False);
Assert.That(cComp.LastState, Is.True);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.EquivalentTo(new []{ sEntity2 }));
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.EquivalentTo(new []{ sEntity1 }));
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty);
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.False);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.True);
}
// Next, the server should receive the networked event
{
await Tick();
Assert.That(sComp.IsTouching, Is.False);
Assert.That(cComp.IsTouching, Is.False);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.StopTick, Is.EqualTo(sComp.StopTick));
Assert.That(cComp.WasTouching, Is.False);
Assert.That(cComp.LastState, Is.True);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty);
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty);
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.False);
Assert.That(sSys.CollisionEnded, Is.True);
Assert.That(cSys.CollisionEnded, Is.True);
}
// nothing changes while waiting for the client to apply the new server state
for (var i = 0; i < 2; i ++)
{
await Tick();
Assert.That(sComp.IsTouching, Is.False);
Assert.That(cComp.IsTouching, Is.False);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.StopTick, Is.EqualTo(sComp.StopTick));
Assert.That(cComp.WasTouching, Is.False);
Assert.That(cComp.LastState, Is.True);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty);
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty);
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.False);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.True);
}
// And then the client should apply the new server state
{
await Tick();
Assert.That(sComp.IsTouching, Is.False);
Assert.That(cComp.IsTouching, Is.False);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.StopTick, Is.EqualTo(sComp.StopTick));
Assert.That(cComp.WasTouching, Is.True);
Assert.That(cComp.LastState, Is.False);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty);
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty);
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.False);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.False);
}
// Nothing should change in the next few ticks
for (var i = 0; i < 10; i ++)
{
await Tick();
Assert.That(sComp.IsTouching, Is.False);
Assert.That(cComp.IsTouching, Is.False);
Assert.That(cComp.StartTick, Is.EqualTo(sComp.StartTick));
Assert.That(cComp.StopTick, Is.EqualTo(sComp.StopTick));
Assert.That(cComp.WasTouching, Is.True);
Assert.That(cComp.LastState, Is.False);
Assert.That(sPhys.GetContactingEntities(sEntity1), Is.Empty);
Assert.That(sPhys.GetContactingEntities(sEntity2), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity1), Is.Empty);
Assert.That(cPhys.GetContactingEntities(cEntity2), Is.Empty);
Assert.That(sSys.CollisionStarted, Is.False);
Assert.That(cSys.CollisionStarted, Is.False);
Assert.That(sSys.CollisionEnded, Is.False);
Assert.That(cSys.CollisionEnded, Is.False);
}
}
}
[RegisterComponent, NetworkedComponent]
public sealed partial class CollisionPredictionTestComponent : Component
{
public bool IsTouching;
public bool WasTouching;
public bool LastState;
public GameTick StartTick;
public GameTick StopTick;
[Serializable, NetSerializable]
public sealed class State(bool isTouching) : ComponentState
{
public bool IsTouching = isTouching;
}
}
[Serializable, NetSerializable]
public sealed class CollisionTestMoveEvent(NetEntity ent, MapCoordinates coords) : EntityEventArgs
{
public NetEntity Ent = ent;
public MapCoordinates Coords = coords;
}
public sealed class CollisionPredictionTestSystem : EntitySystem
{
[Dependency] private readonly SharedTransformSystem _xform = default!;
[Dependency] private readonly IGameTiming _timing = default!;
public bool CollisionStarted;
public bool CollisionEnded;
public override void Initialize()
{
SubscribeLocalEvent<CollisionPredictionTestComponent, ComponentHandleState>(OnHandleState);
SubscribeLocalEvent<CollisionPredictionTestComponent, ComponentGetState>(OnGetState);
SubscribeLocalEvent<CollisionPredictionTestComponent, StartCollideEvent>(OnStartCollide);
SubscribeLocalEvent<CollisionPredictionTestComponent, EndCollideEvent>(OnEndCollide);
SubscribeLocalEvent<CollisionPredictionTestComponent, UpdateIsPredictedEvent>(OnIsPredicted);
SubscribeAllEvent<CollisionTestMoveEvent>(OnMove);
// Updates before physics to simulate input events.
// inputs are processed before systems update, but I CBF setting up a proper input / keybinding.
UpdatesBefore.Add(typeof(SharedPhysicsSystem));
}
public CollisionTestMoveEvent? Ev;
public override void Update(float frameTime)
{
if (Ev == null || !_timing.IsFirstTimePredicted)
return;
RaisePredictiveEvent(Ev);
Ev = null;
}
private void OnIsPredicted(Entity<CollisionPredictionTestComponent> ent, ref UpdateIsPredictedEvent args)
{
args.IsPredicted = true;
}
private void OnMove(CollisionTestMoveEvent ev)
{
_xform.SetMapCoordinates(GetEntity(ev.Ent), ev.Coords);
}
private void OnEndCollide(Entity<CollisionPredictionTestComponent> ent, ref EndCollideEvent args)
{
// TODO PHYSICS Collision Mispredicts
// Currently the client will raise collision start/stop events multiple times for each collision
// If this ever gets fixed, re-add the assert:
// Assert.That(ent.Comp.IsTouching, Is.True);
if (!ent.Comp.IsTouching)
return;
Assert.That(CollisionEnded, Is.False);
ent.Comp.StopTick = _timing.CurTick;
ent.Comp.IsTouching = false;
CollisionEnded = true;
Dirty(ent);
}
private void OnStartCollide(Entity<CollisionPredictionTestComponent> ent, ref StartCollideEvent args)
{
// TODO PHYSICS Collision Mispredicts
// Currently the client will raise collision start/stop events multiple times for each collision
// If this ever gets fixed, re-add the assert:
// Assert.That(ent.Comp.IsTouching, Is.False);
if (ent.Comp.IsTouching)
return;
Assert.That(CollisionStarted, Is.False);
ent.Comp.StartTick = _timing.CurTick;
ent.Comp.IsTouching = true;
CollisionStarted = true;
Dirty(ent);
}
private void OnGetState(Entity<CollisionPredictionTestComponent> ent, ref ComponentGetState args)
{
args.State = new CollisionPredictionTestComponent.State(ent.Comp.IsTouching);
}
private void OnHandleState(Entity<CollisionPredictionTestComponent> ent, ref ComponentHandleState args)
{
if (args.Current is not CollisionPredictionTestComponent.State state)
return;
ent.Comp.WasTouching = ent.Comp.IsTouching;
ent.Comp.LastState = state.IsTouching;
ent.Comp.IsTouching = state.IsTouching;
}
}