mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-02-15 11:40:52 +01:00
Compare commits
5 Commits
serializat
...
v241.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da2bfdaa10 | ||
|
|
af6cac14d6 | ||
|
|
3f37846731 | ||
|
|
d9bf1d1afb | ||
|
|
b9b80192e7 |
@@ -1,4 +1,4 @@
|
||||
<Project>
|
||||
|
||||
<!-- This file automatically reset by Tools/version.py -->
|
||||
<!-- This file automatically reset by Tools/version.py -->
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
515
Robust.UnitTesting/Shared/Physics/CollisionPredictionTest.cs
Normal file
515
Robust.UnitTesting/Shared/Physics/CollisionPredictionTest.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user