mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-02-15 03:30:53 +01:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db7de0a99f | ||
|
|
47f18703af | ||
|
|
97c1548301 | ||
|
|
cd97f1583f | ||
|
|
5fbe25ec9d | ||
|
|
516ee47b51 | ||
|
|
89be682e24 | ||
|
|
6086076559 | ||
|
|
5bd90c908a | ||
|
|
a3d0921cc9 | ||
|
|
15d5b9aa02 | ||
|
|
d24854d94f | ||
|
|
b3cf427013 | ||
|
|
c458abdc69 | ||
|
|
c76444a33f | ||
|
|
4754661467 | ||
|
|
2a8b776ee9 | ||
|
|
7d8e5a5841 | ||
|
|
8e416e4519 | ||
|
|
65f74943d3 | ||
|
|
eb5ed12270 | ||
|
|
c43b7b16c0 | ||
|
|
aee03f0805 | ||
|
|
cfd2b03248 | ||
|
|
8905a3fe14 | ||
|
|
a878da5b80 | ||
|
|
806c23e034 | ||
|
|
e80f5d13a1 | ||
|
|
a6905151b6 | ||
|
|
e742f021fa |
@@ -1,4 +1,4 @@
|
||||
<Project>
|
||||
|
||||
<!-- This file automatically reset by Tools/version.py -->
|
||||
<!-- This file automatically reset by Tools/version.py -->
|
||||
|
||||
|
||||
@@ -54,16 +54,87 @@ END TEMPLATE-->
|
||||
*None yet*
|
||||
|
||||
|
||||
## 257.0.2
|
||||
|
||||
### Bugfixes
|
||||
|
||||
* Fix unshaded sprite layers not rendering correctly.
|
||||
|
||||
|
||||
## 257.0.1
|
||||
|
||||
### Bugfixes
|
||||
|
||||
* Fix sprite layer bounding box calculations. This was causing various sprite rendering & render-tree lookup issues.
|
||||
|
||||
|
||||
## 257.0.0
|
||||
|
||||
### Breaking changes
|
||||
|
||||
* The client will now automatically pause any entities that leave their PVS range.
|
||||
* Contacts for terminating entities no longer raise wake events.
|
||||
|
||||
### New features
|
||||
|
||||
* Added `IPrototypeManager.IsIgnored()` for checking whether a given prototype kind has been marked as ignored via `RegisterIgnore()`.
|
||||
* Added `PoolManager` & `TestPair` classes to `Robust.UnitTesting`. These classes make it easier to create & use pooled server/client instance pairs in integration tests.
|
||||
* Catch NotYamlSerializable DataFields with an analyzer.
|
||||
* Optimized RSI preloading and texture atlas creation.
|
||||
|
||||
### Bugfixes
|
||||
|
||||
* Fix clients unintentionally un-pausing paused entities that re-enter pvs range
|
||||
|
||||
### Other
|
||||
|
||||
* The yaml prototype id serialiser now provides better feedback when trying to validate an id for a prototype kind that has been ignored via `IPrototypeManager.RegisterIgnore()`
|
||||
* Several SpriteComponent methods have been marked as obsolete, and should be replaced with new methods in SpriteSystem.
|
||||
* Rotation events no longer check for grid traversal.
|
||||
|
||||
|
||||
## 256.0.0
|
||||
|
||||
### Breaking changes
|
||||
|
||||
* `ITypeReaderWriter<TType, TNode>` has been removed due to being unused. Implement `ITypeSerializer<TType, TNode>` instead
|
||||
* Moved AsNullable extension methods to the Entity struct.
|
||||
|
||||
### New features
|
||||
|
||||
* Add DevWindow tab to show all loaded textures.
|
||||
* Add Vector2i / bitmask converfsion helpers.
|
||||
* Allow texture preload to be skipped for some textures.
|
||||
* Check audio file signatures instead of extensions.
|
||||
* Add CancellationTokenRegistration to sandbox.
|
||||
* Add the ability to serialize TimeSpan from text.
|
||||
* Add support for rotated / mirrored tiles.
|
||||
|
||||
### Bugfixes
|
||||
|
||||
* Fix yaml hot reloading.
|
||||
* Fix a linear dictionary lookup in PlacementManager.
|
||||
|
||||
### Other
|
||||
|
||||
* Make ItemList not run deselection callback on all items if they aren't selected.
|
||||
* Cleanup warnings for CS0649 & CS0414.
|
||||
|
||||
### Internal
|
||||
|
||||
* Move PointLight component states to shared.
|
||||
|
||||
|
||||
## 255.1.0
|
||||
|
||||
### New features
|
||||
|
||||
* The client localisation manager now supports hot-reloading ftl files.
|
||||
* The client localisation manager now supports hot-reloading ftl files.
|
||||
* TransformSystem can now raise `GridUidChangedEvent` and `MapUidChangedEvent` when a entity's grid or map changes. This event is only raised if the `ExtraTransformEvents` metadata flag is enabled.
|
||||
|
||||
### Bugfixes
|
||||
|
||||
* Fixed a server crash due to a `NullReferenceException` in PVS system when a player's local entity is also one of their view subscriptions.
|
||||
* Fixed a server crash due to a `NullReferenceException` in PVS system when a player's local entity is also one of their view subscriptions.
|
||||
* Fix CompileRobustXamlTask for benchmarks.
|
||||
* .ftl files will now hot reload.
|
||||
* Fix placementmanager sometimes not clearing.
|
||||
|
||||
@@ -9,6 +9,7 @@ entity-spawn-window-override-menu-tooltip = Override placement
|
||||
## TileSpawnWindow
|
||||
|
||||
tile-spawn-window-title = Place Tiles
|
||||
tile-spawn-window-mirror-button-text = Mirror Tiles
|
||||
|
||||
## Console
|
||||
|
||||
|
||||
10
Resources/Locale/en-US/dev-window.ftl
Normal file
10
Resources/Locale/en-US/dev-window.ftl
Normal file
@@ -0,0 +1,10 @@
|
||||
## "Textures" dev window tab
|
||||
|
||||
dev-window-tab-textures-title = Textures
|
||||
dev-window-tab-textures-reload = Reload
|
||||
dev-window-tab-textures-filter = Filter
|
||||
dev-window-tab-textures-summary = Total (est): { $bytes }
|
||||
dev-window-tab-textures-info = Width: { $width } Height: { $height }
|
||||
PixelType: { $pixelType } sRGB: { $srgb }
|
||||
Name: { $name }
|
||||
Est. memory usage: { $bytes }
|
||||
@@ -21,47 +21,53 @@ public sealed class DataDefinitionAnalyzerTest
|
||||
},
|
||||
};
|
||||
|
||||
test.TestState.Sources.Add(("TestTypeDefs.cs", TestTypeDefs));
|
||||
|
||||
// ExpectedDiagnostics cannot be set, so we need to AddRange here...
|
||||
test.TestState.ExpectedDiagnostics.AddRange(expected);
|
||||
|
||||
return test.RunAsync();
|
||||
}
|
||||
|
||||
private const string TestTypeDefs = """
|
||||
using System;
|
||||
|
||||
namespace Robust.Shared.ViewVariables
|
||||
{
|
||||
public sealed class ViewVariablesAttribute : Attribute
|
||||
{
|
||||
public readonly VVAccess Access = VVAccess.ReadOnly;
|
||||
|
||||
public ViewVariablesAttribute() { }
|
||||
|
||||
public ViewVariablesAttribute(VVAccess access)
|
||||
{
|
||||
Access = access;
|
||||
}
|
||||
}
|
||||
public enum VVAccess : byte
|
||||
{
|
||||
ReadOnly = 0,
|
||||
ReadWrite = 1,
|
||||
}
|
||||
}
|
||||
|
||||
namespace Robust.Shared.Serialization.Manager.Attributes
|
||||
{
|
||||
public class DataFieldBaseAttribute : Attribute;
|
||||
public class DataFieldAttribute : DataFieldBaseAttribute;
|
||||
public sealed class DataDefinitionAttribute : Attribute;
|
||||
public sealed class NotYamlSerializableAttribute : Attribute;
|
||||
}
|
||||
""";
|
||||
|
||||
[Test]
|
||||
public async Task Test()
|
||||
public async Task NoVVReadOnlyTest()
|
||||
{
|
||||
const string code = """
|
||||
using System;
|
||||
using Robust.Shared.ViewVariables;
|
||||
using Robust.Shared.Serialization.Manager.Attributes;
|
||||
|
||||
namespace Robust.Shared.ViewVariables
|
||||
{
|
||||
public sealed class ViewVariablesAttribute : Attribute
|
||||
{
|
||||
public readonly VVAccess Access = VVAccess.ReadOnly;
|
||||
|
||||
public ViewVariablesAttribute() { }
|
||||
|
||||
public ViewVariablesAttribute(VVAccess access)
|
||||
{
|
||||
Access = access;
|
||||
}
|
||||
}
|
||||
public enum VVAccess : byte
|
||||
{
|
||||
ReadOnly = 0,
|
||||
ReadWrite = 1,
|
||||
}
|
||||
}
|
||||
|
||||
namespace Robust.Shared.Serialization.Manager.Attributes
|
||||
{
|
||||
public class DataFieldBaseAttribute : Attribute;
|
||||
public class DataFieldAttribute : DataFieldBaseAttribute;
|
||||
public sealed class DataDefinitionAttribute : Attribute;
|
||||
}
|
||||
|
||||
[DataDefinition]
|
||||
public sealed partial class Foo
|
||||
{
|
||||
@@ -83,8 +89,8 @@ public sealed class DataDefinitionAnalyzerTest
|
||||
""";
|
||||
|
||||
await Verifier(code,
|
||||
// /0/Test0.cs(35,17): info RA0028: Data field Bad in data definition Foo has ViewVariables attribute with ReadWrite access, which is redundant
|
||||
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldNoVVReadWriteRule).WithSpan(35, 17, 35, 50).WithArguments("Bad", "Foo")
|
||||
// /0/Test0.cs(7,17): info RA0028: Data field Bad in data definition Foo has ViewVariables attribute with ReadWrite access, which is redundant
|
||||
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldNoVVReadWriteRule).WithSpan(7, 17, 7, 50).WithArguments("Bad", "Foo")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,16 +98,8 @@ public sealed class DataDefinitionAnalyzerTest
|
||||
public async Task ReadOnlyFieldTest()
|
||||
{
|
||||
const string code = """
|
||||
using System;
|
||||
using Robust.Shared.Serialization.Manager.Attributes;
|
||||
|
||||
namespace Robust.Shared.Serialization.Manager.Attributes
|
||||
{
|
||||
public class DataFieldBaseAttribute : Attribute;
|
||||
public class DataFieldAttribute : DataFieldBaseAttribute;
|
||||
public sealed class DataDefinitionAttribute : Attribute;
|
||||
}
|
||||
|
||||
[DataDefinition]
|
||||
public sealed partial class Foo
|
||||
{
|
||||
@@ -114,8 +112,8 @@ public sealed class DataDefinitionAnalyzerTest
|
||||
""";
|
||||
|
||||
await Verifier(code,
|
||||
// /0/Test0.cs(15,12): error RA0019: Data field Bad in data definition Foo is readonly
|
||||
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldWritableRule).WithSpan(15, 12, 15, 20).WithArguments("Bad", "Foo")
|
||||
// /0/Test0.cs(7,12): error RA0019: Data field Bad in data definition Foo is readonly
|
||||
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldWritableRule).WithSpan(7, 12, 7, 20).WithArguments("Bad", "Foo")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,16 +121,8 @@ public sealed class DataDefinitionAnalyzerTest
|
||||
public async Task ReadOnlyPropertyTest()
|
||||
{
|
||||
const string code = """
|
||||
using System;
|
||||
using Robust.Shared.Serialization.Manager.Attributes;
|
||||
|
||||
namespace Robust.Shared.Serialization.Manager.Attributes
|
||||
{
|
||||
public class DataFieldBaseAttribute : Attribute;
|
||||
public class DataFieldAttribute : DataFieldBaseAttribute;
|
||||
public sealed class DataDefinitionAttribute : Attribute;
|
||||
}
|
||||
|
||||
[DataDefinition]
|
||||
public sealed partial class Foo
|
||||
{
|
||||
@@ -145,8 +135,40 @@ public sealed class DataDefinitionAnalyzerTest
|
||||
""";
|
||||
|
||||
await Verifier(code,
|
||||
// /0/Test0.cs(15,20): error RA0020: Data field property Bad in data definition Foo does not have a setter
|
||||
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldPropertyWritableRule).WithSpan(15, 20, 15, 28).WithArguments("Bad", "Foo")
|
||||
// /0/Test0.cs(7,20): error RA0020: Data field property Bad in data definition Foo does not have a setter
|
||||
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldPropertyWritableRule).WithSpan(7, 20, 7, 28).WithArguments("Bad", "Foo")
|
||||
);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NotYamlSerializableTest()
|
||||
{
|
||||
const string code = """
|
||||
using Robust.Shared.Serialization.Manager.Attributes;
|
||||
|
||||
[NotYamlSerializable]
|
||||
public sealed class NotSerializableClass { }
|
||||
|
||||
[DataDefinition]
|
||||
public sealed partial class Foo
|
||||
{
|
||||
[DataField]
|
||||
public NotSerializableClass BadField;
|
||||
|
||||
[DataField]
|
||||
public NotSerializableClass BadProperty { get; set; }
|
||||
|
||||
public NotSerializableClass GoodField; // Not a DataField, not a problem
|
||||
|
||||
public NotSerializableClass GoodProperty { get; set; } // Not a DataField, not a problem
|
||||
}
|
||||
""";
|
||||
|
||||
await Verifier(code,
|
||||
// /0/Test0.cs(10,12): error RA0033: Data field BadField in data definition Foo is type NotSerializableClass, which is not YAML serializable
|
||||
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldYamlSerializableRule).WithSpan(10, 12, 10, 32).WithArguments("BadField", "Foo", "NotSerializableClass"),
|
||||
// /0/Test0.cs(13,12): error RA0033: Data field BadProperty in data definition Foo is type NotSerializableClass, which is not YAML serializable
|
||||
VerifyCS.Diagnostic(DataDefinitionAnalyzer.DataFieldYamlSerializableRule).WithSpan(13, 12, 13, 32).WithArguments("BadProperty", "Foo", "NotSerializableClass")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
|
||||
private const string ImplicitDataDefinitionNamespace = "Robust.Shared.Serialization.Manager.Attributes.ImplicitDataDefinitionForInheritorsAttribute";
|
||||
private const string DataFieldBaseNamespace = "Robust.Shared.Serialization.Manager.Attributes.DataFieldBaseAttribute";
|
||||
private const string ViewVariablesNamespace = "Robust.Shared.ViewVariables.ViewVariablesAttribute";
|
||||
private const string NotYamlSerializableName = "Robust.Shared.Serialization.Manager.Attributes.NotYamlSerializableAttribute";
|
||||
private const string DataFieldAttributeName = "DataField";
|
||||
private const string ViewVariablesAttributeName = "ViewVariables";
|
||||
|
||||
@@ -81,9 +82,19 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
|
||||
"Make sure to remove the ViewVariables attribute."
|
||||
);
|
||||
|
||||
public static readonly DiagnosticDescriptor DataFieldYamlSerializableRule = new(
|
||||
Diagnostics.IdDataFieldYamlSerializable,
|
||||
"Data field type is not YAML serializable",
|
||||
"Data field {0} in data definition {1} is type {2}, which is not YAML serializable",
|
||||
"Usage",
|
||||
DiagnosticSeverity.Error,
|
||||
true,
|
||||
"Make sure to use a type that is YAML serializable."
|
||||
);
|
||||
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(
|
||||
DataDefinitionPartialRule, NestedDataDefinitionPartialRule, DataFieldWritableRule, DataFieldPropertyWritableRule,
|
||||
DataFieldRedundantTagRule, DataFieldNoVVReadWriteRule
|
||||
DataFieldRedundantTagRule, DataFieldNoVVReadWriteRule, DataFieldYamlSerializableRule
|
||||
);
|
||||
|
||||
public override void Initialize(AnalysisContext context)
|
||||
@@ -164,6 +175,19 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
|
||||
TryGetAttributeLocation(field, ViewVariablesAttributeName, out var location);
|
||||
context.ReportDiagnostic(Diagnostic.Create(DataFieldNoVVReadWriteRule, location, fieldSymbol.Name, type.Name));
|
||||
}
|
||||
|
||||
if (context.SemanticModel.GetSymbolInfo(field.Declaration.Type).Symbol is not ITypeSymbol fieldTypeSymbol)
|
||||
continue;
|
||||
|
||||
if (IsNotYamlSerializable(fieldSymbol, fieldTypeSymbol))
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(DataFieldYamlSerializableRule,
|
||||
(context.Node as FieldDeclarationSyntax)?.Declaration.Type.GetLocation(),
|
||||
fieldSymbol.Name,
|
||||
type.Name,
|
||||
fieldTypeSymbol.MetadataName
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +225,19 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
|
||||
TryGetAttributeLocation(property, ViewVariablesAttributeName, out var location);
|
||||
context.ReportDiagnostic(Diagnostic.Create(DataFieldNoVVReadWriteRule, location, propertySymbol.Name, type.Name));
|
||||
}
|
||||
|
||||
if (context.SemanticModel.GetSymbolInfo(property.Type).Symbol is not ITypeSymbol propertyTypeSymbol)
|
||||
return;
|
||||
|
||||
if (IsNotYamlSerializable(propertySymbol, propertyTypeSymbol))
|
||||
{
|
||||
context.ReportDiagnostic(Diagnostic.Create(DataFieldYamlSerializableRule,
|
||||
(context.Node as PropertyDeclarationSyntax)?.Type.GetLocation(),
|
||||
propertySymbol.Name,
|
||||
type.Name,
|
||||
propertyTypeSymbol.Name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsReadOnlyDataField(ITypeSymbol type, ISymbol field)
|
||||
@@ -383,6 +420,14 @@ public sealed class DataDefinitionAnalyzer : DiagnosticAnalyzer
|
||||
return (VVAccess)accessByte == VVAccess.ReadWrite;
|
||||
}
|
||||
|
||||
private static bool IsNotYamlSerializable(ISymbol field, ITypeSymbol type)
|
||||
{
|
||||
if (!IsDataField(field, out _, out _))
|
||||
return false;
|
||||
|
||||
return HasAttribute(type, NotYamlSerializableName);
|
||||
}
|
||||
|
||||
private static bool IsImplicitDataDefinition(ITypeSymbol type)
|
||||
{
|
||||
if (HasAttribute(type, ImplicitDataDefinitionNamespace))
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Numerics;
|
||||
using Robust.Client.GameObjects;
|
||||
using Robust.Shared.ComponentTrees;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Maths;
|
||||
using Robust.Shared.Physics;
|
||||
|
||||
@@ -9,25 +10,7 @@ namespace Robust.Client.ComponentTrees;
|
||||
|
||||
public sealed class SpriteTreeSystem : ComponentTreeSystem<SpriteTreeComponent, SpriteComponent>
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
SubscribeLocalEvent<SpriteComponent, QueueSpriteTreeUpdateEvent>(OnQueueUpdate);
|
||||
}
|
||||
|
||||
private void OnQueueUpdate(EntityUid uid, SpriteComponent component, ref QueueSpriteTreeUpdateEvent args)
|
||||
=> QueueTreeUpdate(uid, component, args.Xform);
|
||||
|
||||
// TODO remove this when finally ECSing sprite components
|
||||
[ByRefEvent]
|
||||
internal readonly struct QueueSpriteTreeUpdateEvent
|
||||
{
|
||||
public readonly TransformComponent Xform;
|
||||
public QueueSpriteTreeUpdateEvent(TransformComponent xform)
|
||||
{
|
||||
Xform = xform;
|
||||
}
|
||||
}
|
||||
[Dependency] private readonly SpriteSystem _sprite = default!;
|
||||
|
||||
#region Component Tree Overrides
|
||||
protected override bool DoFrameUpdate => true;
|
||||
@@ -36,6 +19,11 @@ public sealed class SpriteTreeSystem : ComponentTreeSystem<SpriteTreeComponent,
|
||||
protected override int InitialCapacity => 1024;
|
||||
|
||||
protected override Box2 ExtractAabb(in ComponentTreeEntry<SpriteComponent> entry, Vector2 pos, Angle rot)
|
||||
=> entry.Component.CalculateRotatedBoundingBox(pos, rot, default).CalcBoundingBox();
|
||||
{
|
||||
// TODO SPRITE optimize this
|
||||
// Because the just take the BB of the rotated BB, I'mt pretty sure we do a lot of unnecessary maths.
|
||||
return _sprite.CalculateBounds((entry.Uid, entry.Component), pos, rot, default).CalcBoundingBox();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Robust.Shared.GameObjects;
|
||||
using System;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Maths;
|
||||
|
||||
namespace Robust.Client.GameObjects
|
||||
{
|
||||
[Obsolete]
|
||||
public partial interface IRenderableComponent : IComponent
|
||||
{
|
||||
int DrawDepth { get; set; }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Numerics;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Shared.Graphics;
|
||||
using Robust.Shared.Graphics.RSI;
|
||||
using Robust.Shared.Maths;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,9 @@ namespace Robust.Client.GameObjects
|
||||
private EntityQuery<AnimationPlayerComponent> _playerQuery;
|
||||
private EntityQuery<MetaDataComponent> _metaQuery;
|
||||
|
||||
#pragma warning disable CS0414
|
||||
[Dependency] private readonly IComponentFactory _compFact = default!;
|
||||
#pragma warning restore CS0414
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace Robust.Client.GameObjects
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
SubscribeLocalEvent<PointLightComponent, ComponentGetState>(OnLightGetState);
|
||||
SubscribeLocalEvent<PointLightComponent, ComponentInit>(HandleInit);
|
||||
SubscribeLocalEvent<PointLightComponent, ComponentHandleState>(OnLightHandleState);
|
||||
}
|
||||
|
||||
149
Robust.Client/GameObjects/EntitySystems/SpriteSystem.Bounds.cs
Normal file
149
Robust.Client/GameObjects/EntitySystems/SpriteSystem.Bounds.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Graphics.RSI;
|
||||
using Robust.Shared.Maths;
|
||||
using Robust.Shared.Utility;
|
||||
using static Robust.Client.GameObjects.SpriteComponent;
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
// This partial class contains code related to updating a sprites bounding boxes and its position in the sprite tree.
|
||||
public sealed partial class SpriteSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a sprite's local bounding box. The returned bounds do factor in the sprite's scale but not the rotation or
|
||||
/// offset.
|
||||
/// </summary>
|
||||
public Box2 GetLocalBounds(Entity<SpriteComponent> sprite)
|
||||
{
|
||||
if (!sprite.Comp.BoundsDirty)
|
||||
{
|
||||
DebugTools.Assert(sprite.Comp.Layers.All(x => !x.BoundsDirty || !x.Drawn));
|
||||
return sprite.Comp._bounds;
|
||||
}
|
||||
|
||||
var bounds = new Box2();
|
||||
foreach (var layer in sprite.Comp.Layers)
|
||||
{
|
||||
if (layer.Drawn)
|
||||
bounds = bounds.Union(GetLocalBounds(layer));
|
||||
}
|
||||
|
||||
sprite.Comp._bounds = bounds;
|
||||
sprite.Comp.BoundsDirty = false;
|
||||
return sprite.Comp._bounds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a layer's local bounding box relative to its owning sprite. Unlike the sprite variant of this method, this
|
||||
/// does account for the layer's rotation and offset.
|
||||
/// </summary>
|
||||
public Box2 GetLocalBounds(Layer layer)
|
||||
{
|
||||
if (!layer.BoundsDirty)
|
||||
{
|
||||
DebugTools.Assert(layer.Bounds.EqualsApprox(CalculateLocalBounds(layer)));
|
||||
return layer.Bounds;
|
||||
}
|
||||
|
||||
layer.Bounds = CalculateLocalBounds(layer);
|
||||
layer.BoundsDirty = false;
|
||||
return layer.Bounds;
|
||||
}
|
||||
|
||||
internal Box2 CalculateLocalBounds(Layer layer)
|
||||
{
|
||||
var textureSize = (Vector2) layer.PixelSize / EyeManager.PixelsPerMeter;
|
||||
var longestSide = MathF.Max(textureSize.X, textureSize.Y);
|
||||
var longestRotatedSide = Math.Max(longestSide, (textureSize.X + textureSize.Y) / MathF.Sqrt(2));
|
||||
|
||||
Vector2 size;
|
||||
var sprite = layer.Owner.Comp;
|
||||
|
||||
// If this layer has any form of arbitrary rotation, return a bounding box big enough to cover
|
||||
// any possible rotation.
|
||||
if (layer._rotation != 0)
|
||||
{
|
||||
size = new Vector2(longestRotatedSide, longestRotatedSide);
|
||||
return Box2.CenteredAround(layer.Offset, size * layer._scale);
|
||||
}
|
||||
|
||||
var snapToCardinals = sprite.SnapCardinals;
|
||||
if (sprite.GranularLayersRendering && layer.RenderingStrategy != LayerRenderingStrategy.UseSpriteStrategy)
|
||||
{
|
||||
snapToCardinals = layer.RenderingStrategy == LayerRenderingStrategy.SnapToCardinals;
|
||||
}
|
||||
|
||||
if (snapToCardinals)
|
||||
{
|
||||
// Snapping to cardinals only makes sense for 1-directional layers/sprites
|
||||
DebugTools.Assert(layer._actualState == null || layer._actualState.RsiDirections == RsiDirectionType.Dir1);
|
||||
|
||||
// We won't know the actual direction it snaps to, so we ahve to assume the box is given by the longest side.
|
||||
size = new Vector2(longestSide, longestSide);
|
||||
return Box2.CenteredAround(layer.Offset, size * layer._scale);
|
||||
}
|
||||
|
||||
// Build the bounding box based on how many directions the sprite has
|
||||
size = (layer._actualState?.RsiDirections) switch
|
||||
{
|
||||
RsiDirectionType.Dir4 => new Vector2(longestSide, longestSide),
|
||||
RsiDirectionType.Dir8 => new Vector2(longestRotatedSide, longestRotatedSide),
|
||||
_ => textureSize
|
||||
};
|
||||
|
||||
return Box2.CenteredAround(layer.Offset, size * layer._scale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a sprite's bounding box in world coordinates.
|
||||
/// </summary>
|
||||
public Box2Rotated CalculateBounds(Entity<SpriteComponent> sprite, Vector2 worldPos, Angle worldRot, Angle eyeRot)
|
||||
{
|
||||
// fast check for invisible sprites
|
||||
if (!sprite.Comp.Visible || sprite.Comp.Layers.Count == 0)
|
||||
return new Box2Rotated(new Box2(worldPos, worldPos), Angle.Zero, worldPos);
|
||||
|
||||
// We need to modify world rotation so that it lies between 0 and 2pi.
|
||||
// This matters for 4 or 8 directional sprites deciding which quadrant (octant?) they lie in.
|
||||
// the 0->2pi convention is set by the sprite-rendering code that selects the layers.
|
||||
// See RenderInternal().
|
||||
|
||||
worldRot = worldRot.Reduced();
|
||||
if (worldRot.Theta < 0)
|
||||
worldRot = new Angle(worldRot.Theta + Math.Tau);
|
||||
|
||||
// Next, what we do is take the box2 and apply the sprite's transform, and then the entity's transform. We
|
||||
// could do this via Matrix3.TransformBox, but that only yields bounding boxes. So instead we manually
|
||||
// transform our box by the combination of these matrices:
|
||||
|
||||
var finalRotation = sprite.Comp.NoRotation
|
||||
? sprite.Comp.Rotation - eyeRot
|
||||
: sprite.Comp.Rotation + worldRot;
|
||||
|
||||
var bounds = GetLocalBounds(sprite);
|
||||
|
||||
// slightly faster path if offset == 0 (true for 99.9% of sprites)
|
||||
if (sprite.Comp.Offset == Vector2.Zero)
|
||||
return new Box2Rotated(bounds.Translated(worldPos), finalRotation, worldPos);
|
||||
|
||||
var adjustedOffset = sprite.Comp.NoRotation
|
||||
? (-eyeRot).RotateVec(sprite.Comp.Offset)
|
||||
: worldRot.RotateVec(sprite.Comp.Offset);
|
||||
|
||||
var position = adjustedOffset + worldPos;
|
||||
return new Box2Rotated(bounds.Translated(position), finalRotation, position);
|
||||
}
|
||||
|
||||
private void DirtyBounds(Entity<SpriteComponent> sprite)
|
||||
{
|
||||
sprite.Comp.BoundsDirty = true;
|
||||
foreach (var layer in sprite.Comp.Layers)
|
||||
{
|
||||
layer.BoundsDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Maths;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
@@ -41,4 +45,66 @@ public sealed partial class SpriteSystem
|
||||
layer.AnimationTimeLeft = (float) -(time % state.TotalDelay);
|
||||
layer.AnimationFrame = 0;
|
||||
}
|
||||
|
||||
public void CopySprite(Entity<SpriteComponent?> source, Entity<SpriteComponent?> target)
|
||||
{
|
||||
if (!Resolve(source.Owner, ref source.Comp))
|
||||
return;
|
||||
|
||||
if (!Resolve(target.Owner, ref target.Comp))
|
||||
return;
|
||||
|
||||
target.Comp._baseRsi = source.Comp._baseRsi;
|
||||
target.Comp._bounds = source.Comp._bounds;
|
||||
target.Comp._visible = source.Comp._visible;
|
||||
target.Comp.color = source.Comp.color;
|
||||
target.Comp.offset = source.Comp.offset;
|
||||
target.Comp.rotation = source.Comp.rotation;
|
||||
target.Comp.scale = source.Comp.scale;
|
||||
target.Comp.LocalMatrix = Matrix3Helpers.CreateTransform(
|
||||
in target.Comp.offset,
|
||||
in target.Comp.rotation,
|
||||
in target
|
||||
.Comp.scale);
|
||||
|
||||
target.Comp.drawDepth = source.Comp.drawDepth;
|
||||
target.Comp.NoRotation = source.Comp.NoRotation;
|
||||
target.Comp.DirectionOverride = source.Comp.DirectionOverride;
|
||||
target.Comp.EnableDirectionOverride = source.Comp.EnableDirectionOverride;
|
||||
target.Comp.Layers = new List<SpriteComponent.Layer>(source.Comp.Layers.Count);
|
||||
foreach (var otherLayer in source.Comp.Layers)
|
||||
{
|
||||
var layer = new SpriteComponent.Layer(otherLayer, target.Comp);
|
||||
layer.Index = target.Comp.Layers.Count;
|
||||
layer.Owner = target!;
|
||||
target.Comp.Layers.Add(layer);
|
||||
}
|
||||
|
||||
target.Comp.IsInert = source.Comp.IsInert;
|
||||
target.Comp.LayerMap = source.Comp.LayerMap.ShallowClone();
|
||||
target.Comp.PostShader = source.Comp.PostShader is {Mutable: true}
|
||||
? source.Comp.PostShader.Duplicate()
|
||||
: source.Comp.PostShader;
|
||||
|
||||
target.Comp.RenderOrder = source.Comp.RenderOrder;
|
||||
target.Comp.GranularLayersRendering = source.Comp.GranularLayersRendering;
|
||||
|
||||
DirtyBounds(target!);
|
||||
_tree.QueueTreeUpdate(target!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a sprite to a queue that will update <see cref="SpriteComponent.IsInert"/> next frame.
|
||||
/// </summary>
|
||||
public void QueueUpdateIsInert(Entity<SpriteComponent> sprite)
|
||||
{
|
||||
if (sprite.Comp._inertUpdateQueued)
|
||||
return;
|
||||
|
||||
sprite.Comp._inertUpdateQueued = true;
|
||||
_inertUpdateQueue.Enqueue(sprite);
|
||||
}
|
||||
|
||||
[Obsolete("Use QueueUpdateIsInert")]
|
||||
public void QueueUpdateInert(EntityUid uid, SpriteComponent sprite) => QueueUpdateIsInert(new (uid, sprite));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.ResourceManagement;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
// This partial class contains various public helper methods, including methods for extracting textures/icons from
|
||||
// sprite specifiers and entity prototypes.
|
||||
public sealed partial class SpriteSystem
|
||||
{
|
||||
private readonly Dictionary<string, IRsiStateLike> _cachedPrototypeIcons = new();
|
||||
|
||||
public Texture Frame0(EntityPrototype prototype)
|
||||
{
|
||||
return GetPrototypeIcon(prototype).Default;
|
||||
}
|
||||
|
||||
public Texture Frame0(SpriteSpecifier specifier)
|
||||
{
|
||||
return RsiStateLike(specifier).Default;
|
||||
}
|
||||
|
||||
public IRsiStateLike RsiStateLike(SpriteSpecifier specifier)
|
||||
{
|
||||
switch (specifier)
|
||||
{
|
||||
case SpriteSpecifier.Texture tex:
|
||||
return GetTexture(tex);
|
||||
|
||||
case SpriteSpecifier.Rsi rsi:
|
||||
return GetState(rsi);
|
||||
|
||||
case SpriteSpecifier.EntityPrototype prototypeIcon:
|
||||
return GetPrototypeIcon(prototypeIcon.EntityPrototypeId);
|
||||
|
||||
default:
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
public Texture GetIcon(IconComponent icon)
|
||||
{
|
||||
return GetState(icon.Icon).Frame0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an icon for a given <see cref="EntityPrototype"/> ID, or a fallback in case of an error.
|
||||
/// This method caches the result based on the prototype identifier.
|
||||
/// </summary>
|
||||
public IRsiStateLike GetPrototypeIcon(string prototype)
|
||||
{
|
||||
// Check if this prototype has been cached before, and if so return the result.
|
||||
if (_cachedPrototypeIcons.TryGetValue(prototype, out var cachedResult))
|
||||
return cachedResult;
|
||||
|
||||
if (!_proto.TryIndex<EntityPrototype>(prototype, out var entityPrototype))
|
||||
{
|
||||
// The specified prototype doesn't exist, return the fallback "error" sprite.
|
||||
_sawmill.Error("Failed to load PrototypeIcon {0}", prototype);
|
||||
return GetFallbackState();
|
||||
}
|
||||
|
||||
// Generate the icon and cache it in case it's ever needed again.
|
||||
var result = GetPrototypeIcon(entityPrototype);
|
||||
_cachedPrototypeIcons[prototype] = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an icon for a given <see cref="EntityPrototype"/> ID, or a fallback in case of an error.
|
||||
/// This method does NOT cache the result.
|
||||
/// </summary>
|
||||
public IRsiStateLike GetPrototypeIcon(EntityPrototype prototype)
|
||||
{
|
||||
// IconComponent takes precedence. If it has a valid icon, return that. Otherwise, continue as normal.
|
||||
if (prototype.Components.TryGetValue("Icon", out var compData)
|
||||
&& compData.Component is IconComponent icon)
|
||||
{
|
||||
return GetIcon(icon);
|
||||
}
|
||||
|
||||
// If the prototype doesn't have a SpriteComponent, then there's nothing we can do but return the fallback.
|
||||
if (!prototype.Components.ContainsKey("Sprite"))
|
||||
{
|
||||
return GetFallbackState();
|
||||
}
|
||||
|
||||
// Finally, we use spawn a dummy entity to get its icon.
|
||||
var dummy = Spawn(prototype.ID, MapCoordinates.Nullspace);
|
||||
var spriteComponent = EnsureComp<SpriteComponent>(dummy);
|
||||
var result = spriteComponent.Icon ?? GetFallbackState();
|
||||
Del(dummy);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[Pure]
|
||||
public RSI.State GetFallbackState()
|
||||
{
|
||||
return _resourceCache.GetFallback<RSIResource>().RSI["error"];
|
||||
}
|
||||
|
||||
public Texture GetFallbackTexture()
|
||||
{
|
||||
return _resourceCache.GetFallback<TextureResource>().Texture;
|
||||
}
|
||||
|
||||
[Pure]
|
||||
public RSI.State GetState(SpriteSpecifier.Rsi rsiSpecifier)
|
||||
{
|
||||
if (_resourceCache.TryGetResource<RSIResource>(
|
||||
TextureRoot / rsiSpecifier.RsiPath,
|
||||
out var theRsi) &&
|
||||
theRsi.RSI.TryGetState(rsiSpecifier.RsiState, out var state))
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
_sawmill.Error("Failed to load RSI {0}", rsiSpecifier.RsiPath);
|
||||
return GetFallbackState();
|
||||
}
|
||||
|
||||
public Texture GetTexture(SpriteSpecifier.Texture texSpecifier)
|
||||
{
|
||||
return _resourceCache
|
||||
.GetResource<TextureResource>(TextureRoot / texSpecifier.TexturePath)
|
||||
.Texture;
|
||||
}
|
||||
|
||||
private void OnPrototypesReloaded(PrototypesReloadedEventArgs args)
|
||||
{
|
||||
if (!args.TryGetModified<EntityPrototype>(out var modified))
|
||||
return;
|
||||
|
||||
// Remove all changed prototypes from the cache, if they're there.
|
||||
foreach (var prototype in modified)
|
||||
{
|
||||
// Let's be lazy and not regenerate them until something needs them again.
|
||||
_cachedPrototypeIcons.Remove(prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an entity's sprite position in world terms.
|
||||
/// </summary>
|
||||
|
||||
257
Robust.Client/GameObjects/EntitySystems/SpriteSystem.Layer.cs
Normal file
257
Robust.Client/GameObjects/EntitySystems/SpriteSystem.Layer.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.ResourceManagement;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Utility;
|
||||
using static Robust.Client.GameObjects.SpriteComponent;
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
// This partial class contains various public methods for managing a sprite's layers.
|
||||
// This setter methods for modifying a layer's properties are in a separate file.
|
||||
public sealed partial class SpriteSystem
|
||||
{
|
||||
public bool LayerExists(Entity<SpriteComponent?> sprite, int index)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return false;
|
||||
|
||||
return index > 0 && index < sprite.Comp.Layers.Count;
|
||||
}
|
||||
|
||||
public bool TryGetLayer(
|
||||
Entity<SpriteComponent?> sprite,
|
||||
int index,
|
||||
[NotNullWhen(true)] out Layer? layer,
|
||||
bool logMissing)
|
||||
{
|
||||
layer = null;
|
||||
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
return false;
|
||||
|
||||
if (index >= 0 && index < sprite.Comp.Layers.Count)
|
||||
{
|
||||
layer = sprite.Comp.Layers[index];
|
||||
DebugTools.AssertEqual(layer.Owner, sprite!);
|
||||
DebugTools.AssertEqual(layer.Index, index);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (logMissing)
|
||||
Log.Error($"Layer index '{index}' on entity {ToPrettyString(sprite)} does not exist! Trace:\n{Environment.StackTrace}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool RemoveLayer(Entity<SpriteComponent?> sprite, int index, bool logMissing = true)
|
||||
{
|
||||
return RemoveLayer(sprite.Owner, index, out _, logMissing);
|
||||
}
|
||||
|
||||
public bool RemoveLayer(
|
||||
Entity<SpriteComponent?> sprite,
|
||||
int index,
|
||||
[NotNullWhen(true)] out Layer? layer,
|
||||
bool logMissing = true)
|
||||
{
|
||||
layer = null;
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
return false;
|
||||
|
||||
if (!TryGetLayer(sprite, index, out layer, logMissing))
|
||||
return false;
|
||||
|
||||
sprite.Comp.Layers.RemoveAt(index);
|
||||
|
||||
foreach (var otherLayer in sprite.Comp.Layers[index..])
|
||||
{
|
||||
otherLayer.Index--;
|
||||
}
|
||||
|
||||
// TODO SPRITE track inverse-mapping?
|
||||
foreach (var (key, value) in sprite.Comp.LayerMap)
|
||||
{
|
||||
if (value == index)
|
||||
sprite.Comp.LayerMap.Remove(key);
|
||||
else if (value > index)
|
||||
{
|
||||
sprite.Comp.LayerMap[key]--;
|
||||
}
|
||||
}
|
||||
|
||||
layer.Owner = default;
|
||||
layer.Index = -1;
|
||||
|
||||
#if DEBUG
|
||||
foreach (var otherLayer in sprite.Comp.Layers)
|
||||
{
|
||||
DebugTools.AssertEqual(otherLayer, sprite.Comp.Layers[otherLayer.Index]);
|
||||
}
|
||||
#endif
|
||||
|
||||
sprite.Comp.BoundsDirty = true;
|
||||
_tree.QueueTreeUpdate(sprite!);
|
||||
QueueUpdateIsInert(sprite!);
|
||||
return true;
|
||||
}
|
||||
|
||||
#region AddLayer
|
||||
|
||||
/// <summary>
|
||||
/// Add the given sprite layer. If an index is specified, this will insert the layer with the given index, resulting
|
||||
/// in all other layers being reshuffled.
|
||||
/// </summary>
|
||||
public int AddLayer(Entity<SpriteComponent?> sprite, Layer layer, int? index = null)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
{
|
||||
layer.Index = -1;
|
||||
layer.Owner = default;
|
||||
return -1;
|
||||
}
|
||||
|
||||
layer.Owner = sprite!;
|
||||
|
||||
if (index is { } i && i != sprite.Comp.Layers.Count)
|
||||
{
|
||||
foreach (var otherLayer in sprite.Comp.Layers[i..])
|
||||
{
|
||||
otherLayer.Index++;
|
||||
}
|
||||
|
||||
// TODO SPRITE track inverse-mapping?
|
||||
sprite.Comp.Layers.Insert(i, layer);
|
||||
layer.Index = i;
|
||||
|
||||
foreach (var (key, value) in sprite.Comp.LayerMap)
|
||||
{
|
||||
if (value >= i)
|
||||
sprite.Comp.LayerMap[key]++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
layer.Index = sprite.Comp.Layers.Count;
|
||||
sprite.Comp.Layers.Add(layer);
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
foreach (var otherLayer in sprite.Comp.Layers)
|
||||
{
|
||||
DebugTools.AssertEqual(otherLayer, sprite.Comp.Layers[otherLayer.Index]);
|
||||
}
|
||||
#endif
|
||||
|
||||
layer.BoundsDirty = true;
|
||||
if (!layer.Blank)
|
||||
{
|
||||
sprite.Comp.BoundsDirty = true;
|
||||
_tree.QueueTreeUpdate(sprite!);
|
||||
QueueUpdateIsInert(sprite!);
|
||||
}
|
||||
return layer.Index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a layer corresponding to the given RSI state.
|
||||
/// </summary>
|
||||
/// <param name="sprite">The sprite</param>
|
||||
/// <param name="stateId">The RSI state</param>
|
||||
/// <param name="rsi">The RSI to use. If not specified, it will default to using <see cref="SpriteComponent.BaseRSI"/></param>
|
||||
/// <param name="index">The layer index to use for the new sprite.</param>
|
||||
/// <returns></returns>
|
||||
public int AddRsiLayer(Entity<SpriteComponent?> sprite, RSI.StateId stateId, RSI? rsi = null, int? index = null)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
var layer = AddBlankLayer(sprite!, index);
|
||||
|
||||
if (rsi != null)
|
||||
LayerSetRsi(layer, rsi, stateId);
|
||||
else
|
||||
LayerSetRsiState(layer, stateId);
|
||||
|
||||
return layer.Index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a layer corresponding to the given RSI state.
|
||||
/// </summary>
|
||||
/// <param name="sprite">The sprite</param>
|
||||
/// <param name="state">The RSI state</param>
|
||||
/// <param name="path">The path to the RSI.</param>
|
||||
/// <param name="index">The layer index to use for the new sprite.</param>
|
||||
/// <returns></returns>
|
||||
public int AddRsiLayer(Entity<SpriteComponent?> sprite, RSI.StateId state, ResPath path, int? index = null)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
if (!_resourceCache.TryGetResource<RSIResource>(TextureRoot / path, out var res))
|
||||
Log.Error($"Unable to load RSI '{path}'. Trace:\n{Environment.StackTrace}");
|
||||
|
||||
if (path.Extension != "rsi")
|
||||
Log.Error($"Expected rsi path but got '{path}'?");
|
||||
|
||||
return AddRsiLayer(sprite, state, res?.RSI, index);
|
||||
}
|
||||
|
||||
public int AddTextureLayer(Entity<SpriteComponent?> sprite, ResPath path, int? index = null)
|
||||
{
|
||||
if (_resourceCache.TryGetResource<TextureResource>(TextureRoot / path, out var texture))
|
||||
return AddTextureLayer(sprite, texture?.Texture, index);
|
||||
|
||||
if (path.Extension == "rsi")
|
||||
Log.Error($"Expected texture but got rsi '{path}', did you mean 'sprite:' instead of 'texture:'?");
|
||||
|
||||
Log.Error($"Unable to load texture '{path}'. Trace:\n{Environment.StackTrace}");
|
||||
return AddTextureLayer(sprite, texture?.Texture, index);
|
||||
}
|
||||
|
||||
public int AddTextureLayer(Entity<SpriteComponent?> sprite, Texture? texture, int? index = null)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
var layer = new Layer {Texture = texture};
|
||||
return AddLayer(sprite, layer, index);
|
||||
}
|
||||
|
||||
public int AddLayer(Entity<SpriteComponent?> sprite, SpriteSpecifier specifier, int? newIndex = null)
|
||||
{
|
||||
return specifier switch
|
||||
{
|
||||
SpriteSpecifier.Texture tex => AddTextureLayer(sprite, tex.TexturePath, newIndex),
|
||||
SpriteSpecifier.Rsi rsi => AddRsiLayer(sprite, rsi.RsiState, rsi.RsiPath, newIndex),
|
||||
_ => throw new NotImplementedException()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a new sprite layer and populate it using the provided layer data.
|
||||
/// </summary>
|
||||
public int AddLayer(Entity<SpriteComponent?> sprite, PrototypeLayerData layerDatum, int? index)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
var layer = AddBlankLayer(sprite!, index);
|
||||
LayerSetData(layer, layerDatum);
|
||||
return layer.Index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a blank sprite layer.
|
||||
/// </summary>
|
||||
public Layer AddBlankLayer(Entity<SpriteComponent> sprite, int? index = null)
|
||||
{
|
||||
var layer = new Layer();
|
||||
AddLayer(sprite!, layer, index);
|
||||
return layer;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Graphics.RSI;
|
||||
using static Robust.Client.GameObjects.SpriteComponent;
|
||||
using static Robust.Client.Graphics.RSI;
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
// This partial class contains various public methods for reading a layer's properties
|
||||
public sealed partial class SpriteSystem
|
||||
{
|
||||
#region RsiState
|
||||
|
||||
/// <summary>
|
||||
/// Get the RSI state being used by the current layer. Note that the return value may be an invalid state. E.g.,
|
||||
/// this might be a texture layer that does not use RSIs.
|
||||
/// </summary>
|
||||
public StateId LayerGetRsiState(Entity<SpriteComponent?> sprite, int index)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
return layer.StateId;
|
||||
|
||||
return StateId.Invalid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the RSI state being used by the current layer. Note that the return value may be an invalid state. E.g.,
|
||||
/// this might be a texture layer that does not use RSIs.
|
||||
/// </summary>
|
||||
public StateId LayerGetRsiState(Entity<SpriteComponent?> sprite, string key, StateId state)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
return layer.StateId;
|
||||
|
||||
return StateId.Invalid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the RSI state being used by the current layer. Note that the return value may be an invalid state. E.g.,
|
||||
/// this might be a texture layer that does not use RSIs.
|
||||
/// </summary>
|
||||
public StateId LayerGetRsiState(Entity<SpriteComponent?> sprite, Enum key, StateId state)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
return layer.StateId;
|
||||
|
||||
return StateId.Invalid;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RsiState
|
||||
|
||||
/// <summary>
|
||||
/// Returns the RSI being used by the layer to resolve it's RSI state. If the layer does not specify an RSI, this
|
||||
/// will just be the base RSI of the owning sprite (<see cref="SpriteComponent.BaseRSI"/>).
|
||||
/// </summary>
|
||||
public RSI? LayerGetEffectiveRsi(Entity<SpriteComponent?> sprite, int index)
|
||||
{
|
||||
TryGetLayer(sprite, index, out var layer, true);
|
||||
return layer?.ActualRsi;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the RSI being used by the layer to resolve it's RSI state. If the layer does not specify an RSI, this
|
||||
/// will just be the base RSI of the owning sprite (<see cref="SpriteComponent.BaseRSI"/>).
|
||||
/// </summary>
|
||||
public RSI? LayerGetEffectiveRsi(Entity<SpriteComponent?> sprite, string key, StateId state)
|
||||
{
|
||||
TryGetLayer(sprite, key, out var layer, true);
|
||||
return layer?.ActualRsi;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the RSI being used by the layer to resolve it's RSI state. If the layer does not specify an RSI, this
|
||||
/// will just be the base RSI of the owning sprite (<see cref="SpriteComponent.BaseRSI"/>).
|
||||
/// </summary>
|
||||
public RSI? LayerGetEffectiveRsi(Entity<SpriteComponent?> sprite, Enum key, StateId state)
|
||||
{
|
||||
TryGetLayer(sprite, key, out var layer, true);
|
||||
return layer?.ActualRsi;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Directions
|
||||
|
||||
public RsiDirectionType LayerGetDirections(Entity<SpriteComponent?> sprite, int index)
|
||||
{
|
||||
return TryGetLayer(sprite, index, out var layer, true)
|
||||
? LayerGetDirections(layer)
|
||||
: RsiDirectionType.Dir1;
|
||||
}
|
||||
|
||||
|
||||
public RsiDirectionType LayerGetDirections(Entity<SpriteComponent?> sprite, Enum key)
|
||||
{
|
||||
return TryGetLayer(sprite, key, out var layer, true)
|
||||
? LayerGetDirections(layer)
|
||||
: RsiDirectionType.Dir1;
|
||||
}
|
||||
|
||||
public RsiDirectionType LayerGetDirections(Entity<SpriteComponent?> sprite, string key)
|
||||
{
|
||||
return TryGetLayer(sprite, key, out var layer, true)
|
||||
? LayerGetDirections(layer)
|
||||
: RsiDirectionType.Dir1;
|
||||
}
|
||||
|
||||
public RsiDirectionType LayerGetDirections(Layer layer)
|
||||
{
|
||||
if (!layer.StateId.IsValid)
|
||||
return RsiDirectionType.Dir1;
|
||||
|
||||
// Pull texture from RSI state instead.
|
||||
if (layer.ActualRsi is not {} rsi || !rsi.TryGetState(layer.StateId, out var state))
|
||||
return RsiDirectionType.Dir1;
|
||||
|
||||
return state.RsiDirections;
|
||||
}
|
||||
|
||||
public int LayerGetDirectionCount(Entity<SpriteComponent?> sprite, int index)
|
||||
{
|
||||
return TryGetLayer(sprite, index, out var layer, true) ? LayerGetDirectionCount(layer) : 1;
|
||||
}
|
||||
|
||||
public int LayerGetDirectionCount(Entity<SpriteComponent?> sprite, Enum key)
|
||||
{
|
||||
return TryGetLayer(sprite, key, out var layer, true) ? LayerGetDirectionCount(layer) : 1;
|
||||
}
|
||||
|
||||
public int LayerGetDirectionCount(Entity<SpriteComponent?> sprite, string key)
|
||||
{
|
||||
return TryGetLayer(sprite, key, out var layer, true) ? LayerGetDirectionCount(layer) : 1;
|
||||
}
|
||||
|
||||
public int LayerGetDirectionCount(Layer layer)
|
||||
{
|
||||
return LayerGetDirections(layer) switch
|
||||
{
|
||||
RsiDirectionType.Dir1 => 1,
|
||||
RsiDirectionType.Dir4 => 4,
|
||||
RsiDirectionType.Dir8 => 8,
|
||||
_ => throw new ArgumentOutOfRangeException()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
301
Robust.Client/GameObjects/EntitySystems/SpriteSystem.LayerMap.cs
Normal file
301
Robust.Client/GameObjects/EntitySystems/SpriteSystem.LayerMap.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Robust.Shared.GameObjects;
|
||||
using static Robust.Client.GameObjects.SpriteComponent;
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
// This partial class contains various public methods for manipulating layer mappings.
|
||||
public sealed partial class SpriteSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Map an enum to a layer index.
|
||||
/// </summary>
|
||||
public void LayerMapSet(Entity<SpriteComponent?> sprite, Enum key, int index)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
if (index < 0 || index >= sprite.Comp.Layers.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
|
||||
sprite.Comp.LayerMap[key] = index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map string to a layer index. If possible, it is preferred to use an enum key.
|
||||
/// string keys mainly exist to make it easier to define custom layer keys in yaml.
|
||||
/// </summary>
|
||||
public void LayerMapSet(Entity<SpriteComponent?> sprite, string key, int index)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
if (index < 0 || index >= sprite.Comp.Layers.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
|
||||
sprite.Comp.LayerMap[key] = index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map an enum to a layer index.
|
||||
/// </summary>
|
||||
public void LayerMapAdd(Entity<SpriteComponent?> sprite, Enum key, int index)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
if (index < 0 || index >= sprite.Comp.Layers.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
|
||||
sprite.Comp.LayerMap.Add(key, index);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a string to a layer index. If possible, it is preferred to use an enum key.
|
||||
/// string keys mainly exist to make it easier to define custom layer keys in yaml.
|
||||
/// </summary>
|
||||
public void LayerMapAdd(Entity<SpriteComponent?> sprite, string key, int index)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
if (index < 0 || index >= sprite.Comp.Layers.Count)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
|
||||
sprite.Comp.LayerMap.Add(key, index);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove an enum mapping.
|
||||
/// </summary>
|
||||
public bool LayerMapRemove(Entity<SpriteComponent?> sprite, Enum key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return false;
|
||||
|
||||
return sprite.Comp.LayerMap.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a string mapping.
|
||||
/// </summary>
|
||||
public bool LayerMapRemove(Entity<SpriteComponent?> sprite, string key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return false;
|
||||
|
||||
return sprite.Comp.LayerMap.Remove(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove an enum mapping.
|
||||
/// </summary>
|
||||
public bool LayerMapRemove(Entity<SpriteComponent?> sprite, Enum key, out int index)
|
||||
{
|
||||
if (_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return sprite.Comp.LayerMap.Remove(key, out index);
|
||||
|
||||
index = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a string mapping.
|
||||
/// </summary>
|
||||
public bool LayerMapRemove(Entity<SpriteComponent?> sprite, string key, out int index)
|
||||
{
|
||||
if (_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return sprite.Comp.LayerMap.Remove(key, out index);
|
||||
|
||||
index = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to resolve an enum mapping.
|
||||
/// </summary>
|
||||
public bool LayerMapTryGet(Entity<SpriteComponent?> sprite, Enum key, out int index, bool logMissing)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
{
|
||||
index = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sprite.Comp.LayerMap.TryGetValue(key, out index))
|
||||
return true;
|
||||
|
||||
if (logMissing)
|
||||
Log.Error($"Layer with key '{key}' does not exist on entity {ToPrettyString(sprite)}! Trace:\n{Environment.StackTrace}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to resolve a string mapping.
|
||||
/// </summary>
|
||||
public bool LayerMapTryGet(Entity<SpriteComponent?> sprite, string key, out int index, bool logMissing)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
{
|
||||
index = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sprite.Comp.LayerMap.TryGetValue(key, out index))
|
||||
return true;
|
||||
|
||||
if (logMissing)
|
||||
Log.Error($"Layer with key '{key}' does not exist on entity {ToPrettyString(sprite)}! Trace:\n{Environment.StackTrace}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to resolve an enum mapping.
|
||||
/// </summary>
|
||||
public bool TryGetLayer(Entity<SpriteComponent?> sprite, Enum key, [NotNullWhen(true)] out Layer? layer, bool logMissing)
|
||||
{
|
||||
layer = null;
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
return false;
|
||||
|
||||
return LayerMapTryGet(sprite, key, out var index, logMissing)
|
||||
&& TryGetLayer(sprite, index, out layer, logMissing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to resolve a string mapping.
|
||||
/// </summary>
|
||||
public bool TryGetLayer(Entity<SpriteComponent?> sprite, string key, [NotNullWhen(true)] out Layer? layer, bool logMissing)
|
||||
{
|
||||
layer = null;
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
return false;
|
||||
|
||||
return LayerMapTryGet(sprite, key, out var index, logMissing)
|
||||
&& TryGetLayer(sprite, index, out layer, logMissing);
|
||||
}
|
||||
|
||||
public int LayerMapGet(Entity<SpriteComponent?> sprite, Enum key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
return sprite.Comp.LayerMap[key];
|
||||
}
|
||||
|
||||
public int LayerMapGet(Entity<SpriteComponent?> sprite, string key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
return sprite.Comp.LayerMap[key];
|
||||
}
|
||||
|
||||
public bool LayerExists(Entity<SpriteComponent?> sprite, string key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return false;
|
||||
|
||||
return sprite.Comp.LayerMap.TryGetValue(key, out var index)
|
||||
&& LayerExists(sprite, index);
|
||||
}
|
||||
|
||||
public bool LayerExists(Entity<SpriteComponent?> sprite, Enum key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return false;
|
||||
|
||||
return sprite.Comp.LayerMap.TryGetValue(key, out var index)
|
||||
&& LayerExists(sprite, index);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new blank layer and map the given key to it.
|
||||
/// </summary>
|
||||
public int LayerMapReserve(Entity<SpriteComponent?> sprite, Enum key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
if (LayerExists(sprite, key))
|
||||
throw new Exception("Layer already exists");
|
||||
|
||||
var layer = AddBlankLayer(sprite!);
|
||||
LayerMapSet(sprite, key, layer.Index);
|
||||
return layer.Index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A create a new blank layer and map the given key to it. If possible, it is preferred to use an enum key.
|
||||
/// string keys mainly exist to make it easier to define custom layer keys in yaml.
|
||||
/// </summary>
|
||||
public int LayerMapReserve(Entity<SpriteComponent?> sprite, string key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
if (LayerExists(sprite, key))
|
||||
throw new Exception("Layer already exists");
|
||||
|
||||
var layer = AddBlankLayer(sprite!);
|
||||
LayerMapSet(sprite, key, layer.Index);
|
||||
return layer.Index;
|
||||
}
|
||||
|
||||
public bool RemoveLayer(Entity<SpriteComponent?> sprite, string key, bool logMissing = true)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
return false;
|
||||
|
||||
if (!LayerMapTryGet(sprite, key, out var index, logMissing))
|
||||
return false;
|
||||
|
||||
return RemoveLayer(sprite, index, logMissing);
|
||||
}
|
||||
|
||||
public bool RemoveLayer(Entity<SpriteComponent?> sprite, Enum key, bool logMissing = true)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
return false;
|
||||
|
||||
if (!LayerMapTryGet(sprite, key, out var index, logMissing))
|
||||
return false;
|
||||
|
||||
return RemoveLayer(sprite, index, logMissing);
|
||||
}
|
||||
|
||||
public bool RemoveLayer(
|
||||
Entity<SpriteComponent?> sprite,
|
||||
string key,
|
||||
[NotNullWhen(true)] out Layer? layer,
|
||||
bool logMissing = true)
|
||||
{
|
||||
layer = null;
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
return false;
|
||||
|
||||
if (!LayerMapTryGet(sprite, key, out var index, logMissing))
|
||||
return false;
|
||||
|
||||
return RemoveLayer(sprite, index, out layer, logMissing);
|
||||
}
|
||||
|
||||
public bool RemoveLayer(
|
||||
Entity<SpriteComponent?> sprite,
|
||||
Enum key,
|
||||
[NotNullWhen(true)] out Layer? layer,
|
||||
bool logMissing = true)
|
||||
{
|
||||
layer = null;
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp, logMissing))
|
||||
return false;
|
||||
|
||||
if (!LayerMapTryGet(sprite, key, out var index, logMissing))
|
||||
return false;
|
||||
|
||||
return RemoveLayer(sprite, index, out layer, logMissing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.ResourceManagement;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Maths;
|
||||
using Robust.Shared.Utility;
|
||||
using static Robust.Client.GameObjects.SpriteComponent;
|
||||
using static Robust.Client.Graphics.RSI;
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
// This partial class contains various public methods for modifying a layer's properties.
|
||||
public sealed partial class SpriteSystem
|
||||
{
|
||||
#region SetData
|
||||
|
||||
public void LayerSetData(Entity<SpriteComponent?> sprite, int index, PrototypeLayerData data)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetData(layer, data);
|
||||
}
|
||||
|
||||
public void LayerSetData(Entity<SpriteComponent?> sprite, string key, PrototypeLayerData data)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetData(layer, data);
|
||||
}
|
||||
|
||||
public void LayerSetData(Entity<SpriteComponent?> sprite, Enum key, PrototypeLayerData data)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetData(layer, data);
|
||||
}
|
||||
|
||||
public void LayerSetData(Layer layer, PrototypeLayerData data)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
// TODO SPRITE ECS
|
||||
layer._parent.LayerSetData(layer, data);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SpriteSpecifier
|
||||
|
||||
public void LayerSetSprite(Entity<SpriteComponent?> sprite, int index, SpriteSpecifier specifier)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetSprite(layer, specifier);
|
||||
}
|
||||
|
||||
public void LayerSetSprite(Entity<SpriteComponent?> sprite, string key, SpriteSpecifier specifier)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetSprite(layer, specifier);
|
||||
}
|
||||
|
||||
public void LayerSetSprite(Entity<SpriteComponent?> sprite, Enum key, SpriteSpecifier specifier)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetSprite(layer, specifier);
|
||||
}
|
||||
|
||||
public void LayerSetSprite(Layer layer, SpriteSpecifier specifier)
|
||||
{
|
||||
switch (specifier)
|
||||
{
|
||||
case SpriteSpecifier.Texture tex:
|
||||
LayerSetTexture(layer, tex.TexturePath);
|
||||
break;
|
||||
|
||||
case SpriteSpecifier.Rsi rsi:
|
||||
LayerSetRsi(layer, rsi.RsiPath, rsi.RsiState);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Texture
|
||||
|
||||
public void LayerSetTexture(Entity<SpriteComponent?> sprite, int index, Texture? texture)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetTexture(layer, texture);
|
||||
}
|
||||
|
||||
public void LayerSetTexture(Entity<SpriteComponent?> sprite, string key, Texture? texture)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetTexture(layer, texture);
|
||||
}
|
||||
|
||||
public void LayerSetTexture(Entity<SpriteComponent?> sprite, Enum key, Texture? texture)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetTexture(layer, texture);
|
||||
}
|
||||
|
||||
public void LayerSetTexture(Layer layer, Texture? texture)
|
||||
{
|
||||
LayerSetRsiState(layer, StateId.Invalid, refresh: true);
|
||||
layer.Texture = texture;
|
||||
}
|
||||
|
||||
public void LayerSetTexture(Entity<SpriteComponent?> sprite, int index, ResPath path)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetTexture(layer, path);
|
||||
}
|
||||
|
||||
public void LayerSetTexture(Entity<SpriteComponent?> sprite, string key, ResPath path)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetTexture(layer, path);
|
||||
}
|
||||
|
||||
public void LayerSetTexture(Entity<SpriteComponent?> sprite, Enum key, ResPath path)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetTexture(layer, path);
|
||||
}
|
||||
|
||||
private void LayerSetTexture(Layer layer, ResPath path)
|
||||
{
|
||||
if (!_resourceCache.TryGetResource<TextureResource>(TextureRoot / path, out var texture))
|
||||
{
|
||||
if (path.Extension == "rsi")
|
||||
Log.Error($"Expected texture but got rsi '{path}', did you mean 'sprite:' instead of 'texture:'?");
|
||||
Log.Error($"Unable to load texture '{path}'. Trace:\n{Environment.StackTrace}");
|
||||
}
|
||||
|
||||
LayerSetTexture(layer, texture?.Texture);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RsiState
|
||||
|
||||
public void LayerSetRsiState(Entity<SpriteComponent?> sprite, int index, StateId state)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetRsiState(layer, state);
|
||||
}
|
||||
|
||||
public void LayerSetRsiState(Entity<SpriteComponent?> sprite, string key, StateId state)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRsiState(layer, state);
|
||||
}
|
||||
|
||||
public void LayerSetRsiState(Entity<SpriteComponent?> sprite, Enum key, StateId state)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRsiState(layer, state);
|
||||
}
|
||||
|
||||
public void LayerSetRsiState(Layer layer, StateId state, bool refresh = false)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
if (layer.StateId == state && !refresh)
|
||||
return;
|
||||
|
||||
layer.StateId = state;
|
||||
RefreshCachedState(layer, true, null);
|
||||
_tree.QueueTreeUpdate(layer.Owner);
|
||||
QueueUpdateIsInert(layer.Owner);
|
||||
layer.BoundsDirty = true;
|
||||
layer.Owner.Comp.BoundsDirty = true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rsi
|
||||
|
||||
public void LayerSetRsi(Entity<SpriteComponent?> sprite, int index, RSI? rsi, StateId? state = null)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetRsi(layer, rsi, state);
|
||||
}
|
||||
|
||||
public void LayerSetRsi(Entity<SpriteComponent?> sprite, string key, RSI? rsi, StateId? state = null)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRsi(layer, rsi, state);
|
||||
}
|
||||
|
||||
public void LayerSetRsi(Entity<SpriteComponent?> sprite, Enum key, RSI? rsi, StateId? state = null)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRsi(layer, rsi, state);
|
||||
}
|
||||
|
||||
public void LayerSetRsi(Layer layer, RSI? rsi, StateId? state = null)
|
||||
{
|
||||
layer._rsi = rsi;
|
||||
LayerSetRsiState(layer, state ?? layer.StateId, refresh: true);
|
||||
}
|
||||
|
||||
public void LayerSetRsi(Entity<SpriteComponent?> sprite, int index, ResPath rsi, StateId? state = null)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetRsi(layer, rsi, state);
|
||||
}
|
||||
|
||||
public void LayerSetRsi(Entity<SpriteComponent?> sprite, string key, ResPath rsi, StateId? state = null)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRsi(layer, rsi, state);
|
||||
}
|
||||
|
||||
public void LayerSetRsi(Entity<SpriteComponent?> sprite, Enum key, ResPath rsi, StateId? state = null)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRsi(layer, rsi, state);
|
||||
}
|
||||
|
||||
public void LayerSetRsi(Layer layer, ResPath rsi, StateId? state = null)
|
||||
{
|
||||
if (!_resourceCache.TryGetResource<RSIResource>(TextureRoot / rsi, out var res))
|
||||
Log.Error($"Unable to load RSI '{rsi}' for entity {ToPrettyString(layer.Owner)}. Trace:\n{Environment.StackTrace}");
|
||||
|
||||
LayerSetRsi(layer, res?.RSI, state);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scale
|
||||
|
||||
public void LayerSetScale(Entity<SpriteComponent?> sprite, int index, Vector2 value)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetScale(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetScale(Entity<SpriteComponent?> sprite, string key, Vector2 value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetScale(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetScale(Entity<SpriteComponent?> sprite, Enum key, Vector2 value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetScale(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetScale(Layer layer, Vector2 value)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
if (layer._scale.EqualsApprox(value))
|
||||
return;
|
||||
|
||||
if (!ValidateScale(layer.Owner, value))
|
||||
return;
|
||||
|
||||
layer._scale = value;
|
||||
layer.UpdateLocalMatrix();
|
||||
_tree.QueueTreeUpdate(layer.Owner);
|
||||
layer.BoundsDirty = true;
|
||||
layer.Owner.Comp.BoundsDirty = true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rotation
|
||||
|
||||
public void LayerSetRotation(Entity<SpriteComponent?> sprite, int index, Angle value)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetRotation(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetRotation(Entity<SpriteComponent?> sprite, string key, Angle value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRotation(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetRotation(Entity<SpriteComponent?> sprite, Enum key, Angle value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRotation(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetRotation(Layer layer, Angle value)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
if (layer._rotation.EqualsApprox(value))
|
||||
return;
|
||||
|
||||
layer._rotation = value;
|
||||
layer.UpdateLocalMatrix();
|
||||
_tree.QueueTreeUpdate(layer.Owner);
|
||||
layer.BoundsDirty = true;
|
||||
layer.Owner.Comp.BoundsDirty = true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Offset
|
||||
|
||||
public void LayerSetOffset(Entity<SpriteComponent?> sprite, int index, Vector2 value)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetOffset(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetOffset(Entity<SpriteComponent?> sprite, string key, Vector2 value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetOffset(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetOffset(Entity<SpriteComponent?> sprite, Enum key, Vector2 value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetOffset(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetOffset(Layer layer, Vector2 value)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
if (layer._offset.EqualsApprox(value))
|
||||
return;
|
||||
|
||||
layer._offset = value;
|
||||
layer.UpdateLocalMatrix();
|
||||
_tree.QueueTreeUpdate(layer.Owner);
|
||||
layer.BoundsDirty = true;
|
||||
layer.Owner.Comp.BoundsDirty = true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Visible
|
||||
|
||||
public void LayerSetVisible(Entity<SpriteComponent?> sprite, int index, bool value)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetVisible(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetVisible(Entity<SpriteComponent?> sprite, string key, bool value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetVisible(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetVisible(Entity<SpriteComponent?> sprite, Enum key, bool value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetVisible(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetVisible(Layer layer, bool value)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
if (layer._visible == value)
|
||||
return;
|
||||
|
||||
layer._visible = value;
|
||||
QueueUpdateIsInert(layer.Owner);
|
||||
_tree.QueueTreeUpdate(layer.Owner);
|
||||
layer.Owner.Comp.BoundsDirty = true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Color
|
||||
|
||||
public void LayerSetColor(Entity<SpriteComponent?> sprite, int index, Color value)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetColor(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetColor(Entity<SpriteComponent?> sprite, string key, Color value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetColor(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetColor(Entity<SpriteComponent?> sprite, Enum key, Color value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetColor(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetColor(Layer layer, Color value)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
layer.Color = value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DirOffset
|
||||
|
||||
public void LayerSetDirOffset(Entity<SpriteComponent?> sprite, int index, DirectionOffset value)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetDirOffset(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetDirOffset(Entity<SpriteComponent?> sprite, string key, DirectionOffset value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetDirOffset(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetDirOffset(Entity<SpriteComponent?> sprite, Enum key, DirectionOffset value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetDirOffset(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetDirOffset(Layer layer, DirectionOffset value)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
layer.DirOffset = value;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AnimationTime
|
||||
|
||||
public void LayerSetAnimationTime(Entity<SpriteComponent?> sprite, int index, float value)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetAnimationTime(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetAnimationTime(Entity<SpriteComponent?> sprite, string key, float value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetAnimationTime(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetAnimationTime(Entity<SpriteComponent?> sprite, Enum key, float value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetAnimationTime(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetAnimationTime(Layer layer, float value)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
if (!layer.StateId.IsValid)
|
||||
return;
|
||||
|
||||
if (layer.ActualRsi is not { } rsi)
|
||||
return;
|
||||
|
||||
var state = rsi[layer.StateId];
|
||||
if (value > layer.AnimationTime)
|
||||
{
|
||||
// Handle advancing differently from going backwards.
|
||||
layer.AnimationTimeLeft -= (value - layer.AnimationTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Going backwards we re-calculate from zero.
|
||||
// Definitely possible to optimize this for going backwards but I'm too lazy to figure that out.
|
||||
layer.AnimationTimeLeft = -value + state.GetDelay(0);
|
||||
layer.AnimationFrame = 0;
|
||||
}
|
||||
|
||||
layer.AnimationTime = value;
|
||||
layer.AdvanceFrameAnimation(state);
|
||||
layer.SetAnimationTime(value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AutoAnimated
|
||||
|
||||
public void LayerSetAutoAnimated(Entity<SpriteComponent?> sprite, int index, bool value)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetAutoAnimated(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetAutoAnimated(Entity<SpriteComponent?> sprite, string key, bool value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetAutoAnimated(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetAutoAnimated(Entity<SpriteComponent?> sprite, Enum key, bool value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetAutoAnimated(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetAutoAnimated(Layer layer, bool value)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
if (layer._autoAnimated == value)
|
||||
return;
|
||||
|
||||
layer._autoAnimated = value;
|
||||
QueueUpdateIsInert(layer.Owner);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region LayerSetRenderingStrategy
|
||||
|
||||
public void LayerSetRenderingStrategy(Entity<SpriteComponent?> sprite, int index, LayerRenderingStrategy value)
|
||||
{
|
||||
if (TryGetLayer(sprite, index, out var layer, true))
|
||||
LayerSetRenderingStrategy(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetRenderingStrategy(Entity<SpriteComponent?> sprite, string key, LayerRenderingStrategy value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRenderingStrategy(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetRenderingStrategy(Entity<SpriteComponent?> sprite, Enum key, LayerRenderingStrategy value)
|
||||
{
|
||||
if (TryGetLayer(sprite, key, out var layer, true))
|
||||
LayerSetRenderingStrategy(layer, value);
|
||||
}
|
||||
|
||||
public void LayerSetRenderingStrategy(Layer layer, LayerRenderingStrategy value)
|
||||
{
|
||||
DebugTools.Assert(layer.Owner != default);
|
||||
DebugTools.AssertNotNull(layer.Owner.Comp);
|
||||
DebugTools.AssertEqual(layer.Owner.Comp.Layers[layer.Index], layer);
|
||||
|
||||
layer.RenderingStrategy = value;
|
||||
layer.BoundsDirty = true;
|
||||
layer.Owner.Comp.BoundsDirty = true;
|
||||
_tree.QueueTreeUpdate(layer.Owner);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes an RSI layer's cached RSI state.
|
||||
/// </summary>
|
||||
private void RefreshCachedState(Layer layer, bool logErrors, RSI.State? fallback)
|
||||
{
|
||||
if (!layer.StateId.IsValid)
|
||||
{
|
||||
layer._actualState = null;
|
||||
}
|
||||
else if (layer.ActualRsi is not { } rsi)
|
||||
{
|
||||
layer._actualState = fallback ?? GetFallbackState();
|
||||
if (logErrors)
|
||||
Log.Error(
|
||||
$"{ToPrettyString(layer.Owner)} has no RSI to pull new state from! Trace:\n{Environment.StackTrace}");
|
||||
}
|
||||
else if (!rsi.TryGetState(layer.StateId, out layer._actualState))
|
||||
{
|
||||
layer._actualState = fallback ?? GetFallbackState();
|
||||
if (logErrors)
|
||||
Log.Error(
|
||||
$"{ToPrettyString(layer.Owner)}'s state '{layer.StateId}' does not exist in RSI {rsi.Path}. Trace:\n{Environment.StackTrace}");
|
||||
}
|
||||
|
||||
layer.AnimationFrame = 0;
|
||||
layer.AnimationTime = 0;
|
||||
layer.AnimationTimeLeft = layer._actualState?.GetDelay(0) ?? 0f;
|
||||
}
|
||||
}
|
||||
196
Robust.Client/GameObjects/EntitySystems/SpriteSystem.Render.cs
Normal file
196
Robust.Client/GameObjects/EntitySystems/SpriteSystem.Render.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using System.Numerics;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.Graphics.Clyde;
|
||||
using Robust.Client.Utility;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Graphics.RSI;
|
||||
using Robust.Shared.Maths;
|
||||
using Robust.Shared.Utility;
|
||||
using static Robust.Client.GameObjects.SpriteComponent;
|
||||
using Vector4 = Robust.Shared.Maths.Vector4;
|
||||
using SysVec4 = System.Numerics.Vector4;
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
// This partial class contains code related to actually rendering sprites.
|
||||
public sealed partial class SpriteSystem
|
||||
{
|
||||
public void RenderSprite(
|
||||
Entity<SpriteComponent> sprite,
|
||||
DrawingHandleWorld drawingHandle,
|
||||
Angle eyeRotation,
|
||||
Angle worldRotation,
|
||||
Vector2 worldPosition)
|
||||
{
|
||||
RenderSprite(sprite,
|
||||
drawingHandle,
|
||||
eyeRotation,
|
||||
worldRotation,
|
||||
worldPosition,
|
||||
sprite.Comp.EnableDirectionOverride ? sprite.Comp.DirectionOverride : null);
|
||||
}
|
||||
|
||||
public void RenderSprite(
|
||||
Entity<SpriteComponent> sprite,
|
||||
DrawingHandleWorld drawingHandle,
|
||||
Angle eyeRotation,
|
||||
Angle worldRotation,
|
||||
Vector2 worldPosition,
|
||||
Direction? overrideDirection)
|
||||
{
|
||||
// TODO SPRITE RENDERING
|
||||
// Add fast path for simple sprites.
|
||||
// I.e., when a sprite is modified, check if it is "simple". If it is. cache texture information in a struct
|
||||
// and use a fast path here.
|
||||
// E.g., simple 1-directional, 1-layer sprites can basically become a direct texture draw call. (most in game items).
|
||||
// Similarly, 1-directional multi-layer sprites can become a sequence of direct draw calls (most in game walls).
|
||||
|
||||
if (!sprite.Comp.IsInert)
|
||||
_queuedFrameUpdate.Add(sprite);
|
||||
|
||||
var angle = worldRotation + eyeRotation; // angle on-screen. Used to decide the direction of 4/8 directional RSIs
|
||||
angle = angle.Reduced().FlipPositive(); // Reduce the angles to fix math shenanigans
|
||||
|
||||
var cardinal = Angle.Zero;
|
||||
|
||||
// If we have a 1-directional sprite then snap it to try and always face it south if applicable.
|
||||
if (sprite.Comp is {NoRotation: false, SnapCardinals: true})
|
||||
cardinal = angle.RoundToCardinalAngle();
|
||||
|
||||
// worldRotation + eyeRotation should be the angle of the entity on-screen. If no-rot is enabled this is just set to zero.
|
||||
// However, at some point later the eye-matrix is applied separately, so we subtract -eye rotation for now:
|
||||
var entityMatrix = Matrix3Helpers.CreateTransform(worldPosition, sprite.Comp.NoRotation ? -eyeRotation : worldRotation - cardinal);
|
||||
var spriteMatrix = Matrix3x2.Multiply(sprite.Comp.LocalMatrix, entityMatrix);
|
||||
|
||||
// Fast path for when all sprites use the same transform matrix
|
||||
if (!sprite.Comp.GranularLayersRendering)
|
||||
{
|
||||
foreach (var layer in sprite.Comp.Layers)
|
||||
{
|
||||
RenderLayer(layer, drawingHandle, ref spriteMatrix, angle, overrideDirection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//Default rendering (NoRotation = false)
|
||||
entityMatrix = Matrix3Helpers.CreateTransform(worldPosition, worldRotation);
|
||||
var transformDefault = Matrix3x2.Multiply(sprite.Comp.LocalMatrix, entityMatrix);
|
||||
|
||||
//Snap to cardinals
|
||||
entityMatrix = Matrix3Helpers.CreateTransform(worldPosition, worldRotation - angle.RoundToCardinalAngle());
|
||||
var transformSnap = Matrix3x2.Multiply(sprite.Comp.LocalMatrix, entityMatrix);
|
||||
|
||||
//No rotation
|
||||
entityMatrix = Matrix3Helpers.CreateTransform(worldPosition, -eyeRotation);
|
||||
var transformNoRot = Matrix3x2.Multiply(sprite.Comp.LocalMatrix, entityMatrix);
|
||||
|
||||
foreach (var layer in sprite.Comp.Layers)
|
||||
{
|
||||
switch (layer.RenderingStrategy)
|
||||
{
|
||||
case LayerRenderingStrategy.UseSpriteStrategy:
|
||||
RenderLayer(layer, drawingHandle, ref spriteMatrix, angle, overrideDirection);
|
||||
break;
|
||||
case LayerRenderingStrategy.Default:
|
||||
RenderLayer(layer, drawingHandle, ref transformDefault, angle, overrideDirection);
|
||||
break;
|
||||
case LayerRenderingStrategy.NoRotation:
|
||||
RenderLayer(layer, drawingHandle, ref transformNoRot, angle, overrideDirection);
|
||||
break;
|
||||
case LayerRenderingStrategy.SnapToCardinals:
|
||||
RenderLayer(layer, drawingHandle, ref transformSnap, angle, overrideDirection);
|
||||
break;
|
||||
default:
|
||||
Log.Error($"Tried to render a layer with unknown rendering stragegy: {layer.RenderingStrategy}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a layer. This assumes that the input angle is between 0 and 2pi.
|
||||
/// </summary>
|
||||
private void RenderLayer(Layer layer, DrawingHandleWorld drawingHandle, ref Matrix3x2 spriteMatrix, Angle angle, Direction? overrideDirection)
|
||||
{
|
||||
if (!layer.Visible || layer.Blank)
|
||||
return;
|
||||
|
||||
var state = layer._actualState;
|
||||
var dir = state == null ? RsiDirection.South : Layer.GetDirection(state.RsiDirections, angle);
|
||||
|
||||
// Set the drawing transform for this layer
|
||||
layer.GetLayerDrawMatrix(dir, out var layerMatrix, layer.Owner.Comp.NoRotation);
|
||||
|
||||
// The direction used to draw the sprite can differ from the one that the angle would naively suggest,
|
||||
// due to direction overrides or offsets.
|
||||
if (overrideDirection != null && state != null)
|
||||
dir = overrideDirection.Value.Convert(state.RsiDirections);
|
||||
dir = dir.OffsetRsiDir(layer.DirOffset);
|
||||
|
||||
var texture = state?.GetFrame(dir, layer.AnimationFrame) ?? layer.Texture ?? GetFallbackTexture();
|
||||
|
||||
// TODO SPRITE
|
||||
// Refactor shader-param-layers to a separate layer type after layers are split into types & collections.
|
||||
// I.e., separate Layer -> RsiLayer, TextureLayer, LayerCollection, SpriteLayer, and ShaderLayer
|
||||
if (layer.CopyToShaderParameters != null)
|
||||
{
|
||||
HandleShaderLayer(layer, texture, layer.CopyToShaderParameters);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the drawing transform for this layer
|
||||
var transformMatrix = Matrix3x2.Multiply(layerMatrix, spriteMatrix);
|
||||
drawingHandle.SetTransform(in transformMatrix);
|
||||
|
||||
if (layer.Shader != null)
|
||||
drawingHandle.UseShader(layer.Shader);
|
||||
|
||||
var layerColor = layer.Owner.Comp.color * layer.Color;
|
||||
var textureSize = texture.Size / (float) EyeManager.PixelsPerMeter;
|
||||
var quad = Box2.FromDimensions(textureSize / -2, textureSize);
|
||||
|
||||
if (layer.UnShaded)
|
||||
{
|
||||
DebugTools.AssertNull(layer.Shader);
|
||||
DebugTools.Assert(layerColor is {R: >= 0, G: >= 0, B: >= 0, A: >= 0}, "Default shader should not be used with negative color modulation.");
|
||||
|
||||
// Negative color modulation values are by the default shader to disable light shading.
|
||||
// Specifically we set colour = - 1 - colour
|
||||
// This is good enough to ensure that non-negative values become negative & is trivially invertible.
|
||||
layerColor = new(new SysVec4(-1) - layerColor.RGBA);
|
||||
}
|
||||
|
||||
drawingHandle.DrawTextureRectRegion(texture, quad, layerColor);
|
||||
|
||||
if (layer.Shader != null)
|
||||
drawingHandle.UseShader(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle a a "fake layer" that just exists to modify the parameters of a shader being used by some other
|
||||
/// layer.
|
||||
/// </summary>
|
||||
private void HandleShaderLayer(Layer layer, Texture texture, CopyToShaderParameters @params)
|
||||
{
|
||||
// Multiple atrocities to god being committed right here.
|
||||
var otherLayerIdx = layer._parent.LayerMap[@params.LayerKey!];
|
||||
var otherLayer = layer._parent.Layers[otherLayerIdx];
|
||||
if (otherLayer.Shader is not { } shader)
|
||||
return;
|
||||
|
||||
if (!shader.Mutable)
|
||||
otherLayer.Shader = shader = shader.Duplicate();
|
||||
|
||||
var clydeTexture = Clyde.RenderHandle.ExtractTexture(texture, null, out var csr);
|
||||
|
||||
if (@params.ParameterTexture is { } paramTexture)
|
||||
shader.SetParameter(paramTexture, clydeTexture);
|
||||
|
||||
if (@params.ParameterUV is not { } paramUV)
|
||||
return;
|
||||
|
||||
var sr = Clyde.RenderHandle.WorldTextureBoundsToUV(clydeTexture, csr);
|
||||
var uv = new Vector4(sr.Left, sr.Bottom, sr.Right, sr.Top);
|
||||
shader.SetParameter(paramUV, uv);
|
||||
}
|
||||
}
|
||||
166
Robust.Client/GameObjects/EntitySystems/SpriteSystem.Setters.cs
Normal file
166
Robust.Client/GameObjects/EntitySystems/SpriteSystem.Setters.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Numerics;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Maths;
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
// This partial class contains various public methods for setting sprite component data.
|
||||
public sealed partial class SpriteSystem
|
||||
{
|
||||
private bool ValidateScale(Entity<SpriteComponent> sprite, Vector2 scale)
|
||||
{
|
||||
if (!(MathF.Abs(scale.X) < 0.005f) && !(MathF.Abs(scale.Y) < 0.005f))
|
||||
return true;
|
||||
|
||||
// Scales of ~0.0025 or lower can lead to singular matrices due to rounding errors.
|
||||
Log.Error(
|
||||
$"Attempted to set layer sprite scale to very small values. Entity: {ToPrettyString(sprite)}. Scale: {scale}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#region Transform
|
||||
public void SetScale(Entity<SpriteComponent?> sprite, Vector2 value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
if (!ValidateScale(sprite!, value))
|
||||
return;
|
||||
|
||||
sprite.Comp._bounds = sprite.Comp._bounds.Scale(value / sprite.Comp.scale);
|
||||
sprite.Comp.scale = value;
|
||||
sprite.Comp.LocalMatrix = Matrix3Helpers.CreateTransform(
|
||||
in sprite.Comp.offset,
|
||||
in sprite.Comp.rotation,
|
||||
in sprite.Comp.scale);
|
||||
}
|
||||
|
||||
public void SetRotation(Entity<SpriteComponent?> sprite, Angle value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
sprite.Comp.rotation = value;
|
||||
sprite.Comp.LocalMatrix = Matrix3Helpers.CreateTransform(
|
||||
in sprite.Comp.offset,
|
||||
in sprite.Comp.rotation,
|
||||
in sprite.Comp.scale);
|
||||
}
|
||||
|
||||
public void SetOffset(Entity<SpriteComponent?> sprite, Vector2 value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
sprite.Comp.offset = value;
|
||||
sprite.Comp.LocalMatrix = Matrix3Helpers.CreateTransform(
|
||||
in sprite.Comp.offset,
|
||||
in sprite.Comp.rotation,
|
||||
in sprite.Comp.scale);
|
||||
}
|
||||
#endregion
|
||||
|
||||
public void SetVisible(Entity<SpriteComponent?> sprite, bool value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
if (sprite.Comp.Visible == value)
|
||||
return;
|
||||
|
||||
sprite.Comp._visible = value;
|
||||
_tree.QueueTreeUpdate(sprite!);
|
||||
}
|
||||
|
||||
public void SetDrawDepth(Entity<SpriteComponent?> sprite, int value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
sprite.Comp.drawDepth = value;
|
||||
}
|
||||
|
||||
public void SetColor(Entity<SpriteComponent?> sprite, Color value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
sprite.Comp.color = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Modify a sprites base RSI. This is the RSI that is used by any RSI layers that do not specify their own.
|
||||
/// Note that changing the base RSI may result in existing layers having an invalid state. This will not log errors
|
||||
/// under the assumption that the states of each layers will be updated after the base RSI has changed.
|
||||
/// </summary>
|
||||
public void SetBaseRsi(Entity<SpriteComponent?> sprite, RSI? value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
if (value == sprite.Comp._baseRsi)
|
||||
return;
|
||||
|
||||
sprite.Comp._baseRsi = value;
|
||||
if (value == null)
|
||||
return;
|
||||
|
||||
var fallback = GetFallbackState();
|
||||
for (var i = 0; i < sprite.Comp.Layers.Count; i++)
|
||||
{
|
||||
var layer = sprite.Comp.Layers[i];
|
||||
if (!layer.State.IsValid || layer.RSI != null)
|
||||
continue;
|
||||
|
||||
RefreshCachedState(layer, logErrors: false, fallback);
|
||||
|
||||
if (value.TryGetState(layer.State, out var state))
|
||||
{
|
||||
layer.AnimationTimeLeft = state.GetDelay(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Error($"Layer {i} no longer has state '{layer.State}' due to base RSI change. Trace:\n{Environment.StackTrace}");
|
||||
layer.Texture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetContainerOccluded(Entity<SpriteComponent?> sprite, bool value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
sprite.Comp._containerOccluded = value;
|
||||
_tree.QueueTreeUpdate(sprite!);
|
||||
}
|
||||
|
||||
public void SetSnapCardinals(Entity<SpriteComponent?> sprite, bool value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
if (value == sprite.Comp._snapCardinals)
|
||||
return;
|
||||
|
||||
sprite.Comp._snapCardinals = value;
|
||||
_tree.QueueTreeUpdate(sprite!);
|
||||
DirtyBounds(sprite!);
|
||||
}
|
||||
|
||||
public void SetGranularLayersRendering(Entity<SpriteComponent?> sprite, bool value)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return;
|
||||
|
||||
if (value == sprite.Comp.GranularLayersRendering)
|
||||
return;
|
||||
|
||||
sprite.Comp.GranularLayersRendering = value;
|
||||
_tree.QueueTreeUpdate(sprite!);
|
||||
DirtyBounds(sprite!);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.ResourceManagement;
|
||||
using Robust.Client.Utility;
|
||||
using Robust.Shared.Graphics;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.GameObjects;
|
||||
|
||||
public sealed partial class SpriteSystem
|
||||
{
|
||||
private readonly Dictionary<string, IRsiStateLike> _cachedPrototypeIcons = new();
|
||||
|
||||
public Texture Frame0(EntityPrototype prototype)
|
||||
{
|
||||
return GetPrototypeIcon(prototype).Default;
|
||||
}
|
||||
|
||||
public Texture Frame0(SpriteSpecifier specifier)
|
||||
{
|
||||
return RsiStateLike(specifier).Default;
|
||||
}
|
||||
|
||||
public IRsiStateLike RsiStateLike(SpriteSpecifier specifier)
|
||||
{
|
||||
switch (specifier)
|
||||
{
|
||||
case SpriteSpecifier.Texture tex:
|
||||
return tex.GetTexture(_resourceCache);
|
||||
|
||||
case SpriteSpecifier.Rsi rsi:
|
||||
return GetState(rsi);
|
||||
|
||||
case SpriteSpecifier.EntityPrototype prototypeIcon:
|
||||
return GetPrototypeIcon(prototypeIcon.EntityPrototypeId);
|
||||
|
||||
default:
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
public Texture GetIcon(IconComponent icon)
|
||||
{
|
||||
return GetState(icon.Icon).Frame0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an icon for a given <see cref="EntityPrototype"/> ID, or a fallback in case of an error.
|
||||
/// This method caches the result based on the prototype identifier.
|
||||
/// </summary>
|
||||
public IRsiStateLike GetPrototypeIcon(string prototype)
|
||||
{
|
||||
// Check if this prototype has been cached before, and if so return the result.
|
||||
if (_cachedPrototypeIcons.TryGetValue(prototype, out var cachedResult))
|
||||
return cachedResult;
|
||||
|
||||
if (!_proto.TryIndex<EntityPrototype>(prototype, out var entityPrototype))
|
||||
{
|
||||
// The specified prototype doesn't exist, return the fallback "error" sprite.
|
||||
_sawmill.Error("Failed to load PrototypeIcon {0}", prototype);
|
||||
return GetFallbackState();
|
||||
}
|
||||
|
||||
// Generate the icon and cache it in case it's ever needed again.
|
||||
var result = GetPrototypeIcon(entityPrototype);
|
||||
_cachedPrototypeIcons[prototype] = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an icon for a given <see cref="EntityPrototype"/> ID, or a fallback in case of an error.
|
||||
/// This method does NOT cache the result.
|
||||
/// </summary>
|
||||
public IRsiStateLike GetPrototypeIcon(EntityPrototype prototype)
|
||||
{
|
||||
// IconComponent takes precedence. If it has a valid icon, return that. Otherwise, continue as normal.
|
||||
if (prototype.Components.TryGetValue("Icon", out var compData)
|
||||
&& compData.Component is IconComponent icon)
|
||||
{
|
||||
return GetIcon(icon);
|
||||
}
|
||||
|
||||
// If the prototype doesn't have a SpriteComponent, then there's nothing we can do but return the fallback.
|
||||
if (!prototype.Components.ContainsKey("Sprite"))
|
||||
{
|
||||
return GetFallbackState();
|
||||
}
|
||||
|
||||
// Finally, we use spawn a dummy entity to get its icon.
|
||||
var dummy = Spawn(prototype.ID, MapCoordinates.Nullspace);
|
||||
var spriteComponent = EnsureComp<SpriteComponent>(dummy);
|
||||
var result = spriteComponent.Icon ?? GetFallbackState();
|
||||
Del(dummy);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[Pure]
|
||||
public RSI.State GetFallbackState()
|
||||
{
|
||||
return _resourceCache.GetFallback<RSIResource>().RSI["error"];
|
||||
}
|
||||
|
||||
[Pure]
|
||||
public RSI.State GetState(SpriteSpecifier.Rsi rsiSpecifier)
|
||||
{
|
||||
if (_resourceCache.TryGetResource<RSIResource>(
|
||||
SpriteSpecifierSerializer.TextureRoot / rsiSpecifier.RsiPath,
|
||||
out var theRsi) &&
|
||||
theRsi.RSI.TryGetState(rsiSpecifier.RsiState, out var state))
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
_sawmill.Error("Failed to load RSI {0}", rsiSpecifier.RsiPath);
|
||||
return GetFallbackState();
|
||||
}
|
||||
|
||||
private void OnPrototypesReloaded(PrototypesReloadedEventArgs args)
|
||||
{
|
||||
if (!args.TryGetModified<EntityPrototype>(out var modified))
|
||||
return;
|
||||
|
||||
// Remove all changed prototypes from the cache, if they're there.
|
||||
foreach (var prototype in modified)
|
||||
{
|
||||
// Let's be lazy and not regenerate them until something needs them again.
|
||||
_cachedPrototypeIcons.Remove(prototype);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,9 @@ using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Graphics.RSI;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Log;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Maths;
|
||||
using Robust.Shared.Physics;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.Shared.Utility;
|
||||
using static Robust.Client.GameObjects.SpriteComponent;
|
||||
@@ -36,24 +35,20 @@ namespace Robust.Client.GameObjects
|
||||
[Dependency] private readonly IResourceCache _resourceCache = default!;
|
||||
[Dependency] private readonly ILogManager _logManager = default!;
|
||||
[Dependency] private readonly SharedTransformSystem _xforms = default!;
|
||||
[Dependency] private readonly SpriteTreeSystem _tree = default!;
|
||||
|
||||
public static readonly ProtoId<ShaderPrototype> UnshadedId = "unshaded";
|
||||
private readonly Queue<SpriteComponent> _inertUpdateQueue = new();
|
||||
|
||||
public static readonly ResPath TextureRoot = SpriteSpecifierSerializer.TextureRoot;
|
||||
|
||||
/// <summary>
|
||||
/// Entities that require a sprite frame update.
|
||||
/// </summary>
|
||||
private readonly HashSet<EntityUid> _queuedFrameUpdate = new();
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
|
||||
internal void Render(EntityUid uid, SpriteComponent sprite, DrawingHandleWorld drawingHandle, Angle eyeRotation, in Angle worldRotation, in Vector2 worldPosition)
|
||||
{
|
||||
if (!sprite.IsInert)
|
||||
_queuedFrameUpdate.Add(uid);
|
||||
|
||||
sprite.RenderInternal(drawingHandle, eyeRotation, worldRotation, worldPosition, sprite.EnableDirectionOverride ? sprite.DirectionOverride : null);
|
||||
}
|
||||
private EntityQuery<SpriteComponent> _query;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
@@ -62,11 +57,11 @@ namespace Robust.Client.GameObjects
|
||||
UpdatesAfter.Add(typeof(SpriteTreeSystem));
|
||||
|
||||
SubscribeLocalEvent<PrototypesReloadedEventArgs>(OnPrototypesReloaded);
|
||||
SubscribeLocalEvent<SpriteComponent, SpriteUpdateInertEvent>(QueueUpdateInert);
|
||||
SubscribeLocalEvent<SpriteComponent, ComponentInit>(OnInit);
|
||||
|
||||
Subs.CVar(_cfg, CVars.RenderSpriteDirectionBias, OnBiasChanged, true);
|
||||
_sawmill = _logManager.GetSawmill("sprite");
|
||||
_query = GetEntityQuery<SpriteComponent>();
|
||||
}
|
||||
|
||||
public bool IsVisible(Layer layer)
|
||||
@@ -85,18 +80,6 @@ namespace Robust.Client.GameObjects
|
||||
SpriteComponent.DirectionBias = value;
|
||||
}
|
||||
|
||||
private void QueueUpdateInert(EntityUid uid, SpriteComponent sprite, ref SpriteUpdateInertEvent ev)
|
||||
=> QueueUpdateInert(uid, sprite);
|
||||
|
||||
public void QueueUpdateInert(EntityUid uid, SpriteComponent sprite)
|
||||
{
|
||||
if (sprite._inertUpdateQueued)
|
||||
return;
|
||||
|
||||
sprite._inertUpdateQueued = true;
|
||||
_inertUpdateQueue.Enqueue(sprite);
|
||||
}
|
||||
|
||||
private void DoUpdateIsInert(SpriteComponent component)
|
||||
{
|
||||
component._inertUpdateQueued = false;
|
||||
|
||||
@@ -1306,6 +1306,11 @@ namespace Robust.Client.GameStates
|
||||
meta.LastStateApplied = lastStateApplied.Value;
|
||||
|
||||
var xform = xforms.GetComponent(ent.Value);
|
||||
|
||||
// TODO PVS DETACH
|
||||
// Why is this if block here again? If a null-space entity gets sent to a player via some PVS override,
|
||||
// and then later on it gets removed, you would assume that the client marks it as detached?
|
||||
// I.e., modifying the metadata flag & pausing the entity should probably happen outside of this block.
|
||||
if (xform.ParentUid.IsValid())
|
||||
{
|
||||
lookupSys.RemoveFromEntityTree(ent.Value, xform);
|
||||
@@ -1326,6 +1331,13 @@ namespace Robust.Client.GameStates
|
||||
xformSys.DetachEntity(ent.Value, xform);
|
||||
DebugTools.Assert((meta.Flags & MetaDataFlags.InContainer) == 0);
|
||||
|
||||
// We mark the entity as paused, without raising a pause-event.
|
||||
// The entity gets un-paused when the metadata's comp-state is reapplied (which also does not raise
|
||||
// an un-pause event). The assumption is that game logic that has to handle the pausing should be
|
||||
// getting networked anyway. And if its some client-side timer on a networked entity, the timer
|
||||
// shouldn't actually be getting paused just because the entity has left the players view.
|
||||
meta.PauseTime = TimeSpan.Zero;
|
||||
|
||||
if (container != null)
|
||||
containerSys.AddExpectedEntity(netEntity, container);
|
||||
}
|
||||
|
||||
@@ -254,7 +254,11 @@ namespace Robust.Client.Graphics.Clyde
|
||||
region = regionMaybe[tile.Variant];
|
||||
}
|
||||
|
||||
WriteTileToBuffers(i, gridX, gridY, vertexBuffer, indexBuffer, region);
|
||||
var rotationMirroring = _tileDefinitionManager[tile.TypeId].AllowRotationMirror
|
||||
? tile.RotationMirroring
|
||||
: 0;
|
||||
|
||||
WriteTileToBuffers(i, gridX, gridY, vertexBuffer, indexBuffer, region, rotationMirroring);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
@@ -325,7 +329,7 @@ namespace Robust.Client.Graphics.Clyde
|
||||
continue;
|
||||
|
||||
var region = regionMaybe[0];
|
||||
WriteTileToBuffers(i, gridX, gridY, vertexBuffer, indexBuffer, region);
|
||||
WriteTileToBuffers(i, gridX, gridY, vertexBuffer, indexBuffer, region, 0);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
@@ -445,13 +449,57 @@ namespace Robust.Client.Graphics.Clyde
|
||||
int gridY,
|
||||
Span<Vertex2D> vertexBuffer,
|
||||
Span<ushort> indexBuffer,
|
||||
Box2 region)
|
||||
Box2 region,
|
||||
int rotationMirroring)
|
||||
{
|
||||
var rLeftBottom = (region.Left, region.Bottom);
|
||||
var rRightBottom = (region.Right, region.Bottom);
|
||||
var rRightTop = (region.Right, region.Top);
|
||||
var rLeftTop = (region.Left, region.Top);
|
||||
|
||||
// The vertices must be changed if there's any rotation or mirroring to the tile
|
||||
if (rotationMirroring != 0)
|
||||
{
|
||||
// Rotate the tile
|
||||
for (int r = 0; r < rotationMirroring % 4; r++)
|
||||
{
|
||||
(rLeftBottom, rRightBottom, rRightTop, rLeftTop) =
|
||||
(rLeftTop, rLeftBottom, rRightBottom, rRightTop);
|
||||
}
|
||||
|
||||
// Mirror on the x-axis
|
||||
if (rotationMirroring >= 4)
|
||||
{
|
||||
if (rotationMirroring % 2 == 0)
|
||||
{
|
||||
rLeftBottom = (rLeftBottom.Item1.Equals(region.Left) ? region.Right : region.Left,
|
||||
rLeftBottom.Item2);
|
||||
rRightBottom = (rRightBottom.Item1.Equals(region.Left) ? region.Right : region.Left,
|
||||
rRightBottom.Item2);
|
||||
rRightTop = (rRightTop.Item1.Equals(region.Left) ? region.Right : region.Left,
|
||||
rRightTop.Item2);
|
||||
rLeftTop = (rLeftTop.Item1.Equals(region.Left) ? region.Right : region.Left,
|
||||
rLeftTop.Item2);
|
||||
}
|
||||
else
|
||||
{
|
||||
rLeftBottom = (rLeftBottom.Item1,
|
||||
rLeftBottom.Item2.Equals(region.Bottom) ? region.Top : region.Bottom);
|
||||
rRightBottom = (rRightBottom.Item1,
|
||||
rRightBottom.Item2.Equals(region.Bottom) ? region.Top : region.Bottom);
|
||||
rRightTop = (rRightTop.Item1,
|
||||
rRightTop.Item2.Equals(region.Bottom) ? region.Top : region.Bottom);
|
||||
rLeftTop = (rLeftTop.Item1,
|
||||
rLeftTop.Item2.Equals(region.Bottom) ? region.Top : region.Bottom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var vIdx = i * 4;
|
||||
vertexBuffer[vIdx + 0] = new Vertex2D(gridX, gridY, region.Left, region.Bottom, Color.White);
|
||||
vertexBuffer[vIdx + 1] = new Vertex2D(gridX + 1, gridY, region.Right, region.Bottom, Color.White);
|
||||
vertexBuffer[vIdx + 2] = new Vertex2D(gridX + 1, gridY + 1, region.Right, region.Top, Color.White);
|
||||
vertexBuffer[vIdx + 3] = new Vertex2D(gridX, gridY + 1, region.Left, region.Top, Color.White);
|
||||
vertexBuffer[vIdx + 0] = new Vertex2D(gridX, gridY, rLeftBottom.Left, rLeftBottom.Bottom, Color.White);
|
||||
vertexBuffer[vIdx + 1] = new Vertex2D(gridX + 1, gridY, rRightBottom.Right, rRightBottom.Bottom, Color.White);
|
||||
vertexBuffer[vIdx + 2] = new Vertex2D(gridX + 1, gridY + 1, rRightTop.Right, rRightTop.Top, Color.White);
|
||||
vertexBuffer[vIdx + 3] = new Vertex2D(gridX, gridY + 1, rLeftTop.Left, rLeftTop.Top, Color.White);
|
||||
var nIdx = i * GetQuadBatchIndexCount();
|
||||
var tIdx = (ushort)(i * 4);
|
||||
QuadBatchIndexWrite(indexBuffer, ref nIdx, tIdx);
|
||||
|
||||
@@ -318,7 +318,7 @@ namespace Robust.Client.Graphics.Clyde
|
||||
screenSpriteSize.Y++;
|
||||
|
||||
bool exit = false;
|
||||
if (entry.Sprite.GetScreenTexture)
|
||||
if (entry.Sprite.GetScreenTexture && entry.Sprite.PostShader != null)
|
||||
{
|
||||
FlushRenderQueue();
|
||||
var tex = CopyScreenTexture(viewport.RenderTarget);
|
||||
@@ -369,7 +369,7 @@ namespace Robust.Client.Graphics.Clyde
|
||||
}
|
||||
}
|
||||
|
||||
spriteSystem.Render(entry.Uid, entry.Sprite, _renderHandle.DrawingHandleWorld, eye.Rotation, in entry.WorldRot, in entry.WorldPos);
|
||||
spriteSystem.RenderSprite(new(entry.Uid, entry.Sprite), _renderHandle.DrawingHandleWorld, eye.Rotation, entry.WorldRot, entry.WorldPos);
|
||||
|
||||
if (entry.Sprite.PostShader != null && entityPostRenderTarget != null)
|
||||
{
|
||||
|
||||
@@ -613,6 +613,8 @@ namespace Robust.Client.Graphics.Clyde
|
||||
EnsureBatchSpaceAvailable(4, GetQuadBatchIndexCount());
|
||||
EnsureBatchState(texture, true, GetQuadBatchPrimitiveType(), _queuedShader);
|
||||
|
||||
// TODO RENDERING
|
||||
// It's probably better to do this on the GPU.
|
||||
bl = Vector2.Transform(bl, _currentMatrixModel);
|
||||
br = Vector2.Transform(br, _currentMatrixModel);
|
||||
tr = Vector2.Transform(tr, _currentMatrixModel);
|
||||
|
||||
@@ -305,8 +305,8 @@ namespace Robust.Client.Graphics.Clyde
|
||||
IsSrgb = srgb,
|
||||
Name = name,
|
||||
MemoryPressure = memoryPressure,
|
||||
TexturePixelType = pixType
|
||||
// TextureInstance = new WeakReference<ClydeTexture>(instance)
|
||||
TexturePixelType = pixType,
|
||||
TextureInstance = new WeakReference<ClydeTexture>(instance)
|
||||
};
|
||||
|
||||
_loadedTextures.Add(id, loaded);
|
||||
@@ -466,15 +466,15 @@ namespace Robust.Client.Graphics.Clyde
|
||||
{
|
||||
var white = new Image<Rgba32>(1, 1);
|
||||
white[0, 0] = new Rgba32(255, 255, 255, 255);
|
||||
_stockTextureWhite = (ClydeTexture) Texture.LoadFromImage(white);
|
||||
_stockTextureWhite = (ClydeTexture) Texture.LoadFromImage(white, name: "StockTextureWhite");
|
||||
|
||||
var black = new Image<Rgba32>(1, 1);
|
||||
black[0, 0] = new Rgba32(0, 0, 0, 255);
|
||||
_stockTextureBlack = (ClydeTexture) Texture.LoadFromImage(black);
|
||||
_stockTextureBlack = (ClydeTexture) Texture.LoadFromImage(black, name: "StockTextureBlack");
|
||||
|
||||
var blank = new Image<Rgba32>(1, 1);
|
||||
blank[0, 0] = new Rgba32(0, 0, 0, 0);
|
||||
_stockTextureTransparent = (ClydeTexture) Texture.LoadFromImage(blank);
|
||||
_stockTextureTransparent = (ClydeTexture) Texture.LoadFromImage(blank, name: "StockTextureTransparent");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -571,7 +571,7 @@ namespace Robust.Client.Graphics.Clyde
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LoadedTexture
|
||||
internal sealed class LoadedTexture
|
||||
{
|
||||
public GLHandle OpenGLObject;
|
||||
public int Width;
|
||||
@@ -582,10 +582,10 @@ namespace Robust.Client.Graphics.Clyde
|
||||
public TexturePixelType TexturePixelType;
|
||||
|
||||
public Vector2i Size => (Width, Height);
|
||||
// public WeakReference<ClydeTexture> TextureInstance;
|
||||
public required WeakReference<ClydeTexture> TextureInstance;
|
||||
}
|
||||
|
||||
private enum TexturePixelType : byte
|
||||
internal enum TexturePixelType : byte
|
||||
{
|
||||
RenderTarget = 0,
|
||||
Rgba32,
|
||||
@@ -686,5 +686,16 @@ namespace Robust.Client.Graphics.Clyde
|
||||
_ => throw new ArgumentException(nameof(stockTexture))
|
||||
};
|
||||
}
|
||||
|
||||
public IEnumerable<(ClydeTexture, LoadedTexture)> GetLoadedTextures()
|
||||
{
|
||||
foreach (var loaded in _loadedTextures.Values)
|
||||
{
|
||||
if (!loaded.TextureInstance.TryGetTarget(out var textureInstance))
|
||||
continue;
|
||||
|
||||
yield return (textureInstance, loaded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ namespace Robust.Client.Graphics.Clyde
|
||||
return new DummyTexture((1, 1));
|
||||
}
|
||||
|
||||
public IEnumerable<(Clyde.ClydeTexture, Clyde.LoadedTexture)> GetLoadedTextures()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public ClydeDebugLayers DebugLayers { get; set; }
|
||||
|
||||
public string GetKeyName(Keyboard.Key key) => string.Empty;
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Robust.Client.Graphics.Clyde
|
||||
/// Basically just a handle around the integer object handles returned by OpenGL.
|
||||
/// </summary>
|
||||
[PublicAPI]
|
||||
private struct GLHandle : IEquatable<GLHandle>
|
||||
internal struct GLHandle : IEquatable<GLHandle>
|
||||
{
|
||||
public readonly uint Handle;
|
||||
|
||||
|
||||
@@ -87,11 +87,13 @@ namespace Robust.Client.Graphics.Clyde
|
||||
if (cmd.Cursor != default)
|
||||
ptr = _winThreadCursors[cmd.Cursor].Ptr;
|
||||
|
||||
#if DEBUG
|
||||
if (_win32Experience)
|
||||
{
|
||||
// Based on a true story.
|
||||
Thread.Sleep(15);
|
||||
}
|
||||
#endif
|
||||
|
||||
GLFW.SetCursor(window, ptr);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ namespace Robust.Client.Graphics.Clyde
|
||||
private readonly ISawmill _sawmillGlfw;
|
||||
|
||||
private bool _glfwInitialized;
|
||||
#if DEBUG
|
||||
private bool _win32Experience;
|
||||
#endif
|
||||
|
||||
public GlfwWindowingImpl(Clyde clyde, IDependencyCollection deps)
|
||||
{
|
||||
|
||||
@@ -54,6 +54,7 @@ namespace Robust.Client.Graphics
|
||||
IClydeDebugStats DebugStats { get; }
|
||||
|
||||
Texture GetStockTexture(ClydeStockTexture stockTexture);
|
||||
IEnumerable<(Clyde.Clyde.ClydeTexture, Clyde.Clyde.LoadedTexture)> GetLoadedTextures();
|
||||
|
||||
ClydeDebugLayers DebugLayers { get; set; }
|
||||
|
||||
|
||||
@@ -24,10 +24,20 @@ namespace Robust.Client.Placement
|
||||
Direction Direction { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets called when Direction changed (presently for EntitySpawnWindow UI)
|
||||
/// Whether a tile placement should be mirrored or not.
|
||||
/// </summary>
|
||||
bool Mirrored { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets called when Direction changed (presently for EntitySpawnWindow/TileSpawnWindow UI)
|
||||
/// </summary>
|
||||
event EventHandler DirectionChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets called when Mirrored changed (presently for TileSpawnWindow UI)
|
||||
/// </summary>
|
||||
event EventHandler MirroredChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets called when the PlacementManager changed its build/erase mode or when the hijacks changed
|
||||
/// </summary>
|
||||
|
||||
@@ -174,6 +174,18 @@ namespace Robust.Client.Placement
|
||||
|
||||
private Direction _direction = Direction.South;
|
||||
|
||||
private bool _mirrored;
|
||||
|
||||
public bool Mirrored
|
||||
{
|
||||
get => _mirrored;
|
||||
set
|
||||
{
|
||||
_mirrored = value;
|
||||
MirroredChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Direction Direction
|
||||
{
|
||||
@@ -188,6 +200,9 @@ namespace Robust.Client.Placement
|
||||
/// <inheritdoc />
|
||||
public event EventHandler? DirectionChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler? MirroredChanged;
|
||||
|
||||
private PlacementOverlay _drawOverlay = default!;
|
||||
private bool _isActive;
|
||||
|
||||
@@ -487,16 +502,15 @@ namespace Robust.Client.Placement
|
||||
{
|
||||
ClearWithoutDeactivation();
|
||||
|
||||
CurrentPermission = info;
|
||||
|
||||
if (!_modeDictionary.TryFirstOrNull(pair => pair.Key.Equals(CurrentPermission.PlacementOption), out KeyValuePair<string, Type>? placeMode))
|
||||
if (info.PlacementOption is not { } option || !_modeDictionary.TryGetValue(option, out var placeMode))
|
||||
{
|
||||
_sawmill.Log(LogLevel.Warning, $"Invalid placement mode `{CurrentPermission.PlacementOption}`");
|
||||
_sawmill.Log(LogLevel.Warning, $"Invalid placement mode `{info.PlacementOption}`");
|
||||
Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentMode = (PlacementMode) Activator.CreateInstance(placeMode.Value.Value, this)!;
|
||||
CurrentPermission = info;
|
||||
CurrentMode = (PlacementMode) Activator.CreateInstance(placeMode, this)!;
|
||||
|
||||
if (hijack != null)
|
||||
{
|
||||
@@ -777,7 +791,9 @@ namespace Robust.Client.Placement
|
||||
var grid = EntityManager.GetComponent<MapGridComponent>(gridId);
|
||||
|
||||
// no point changing the tile to the same thing.
|
||||
if (Maps.GetTileRef(gridId, grid, coordinates).Tile.TypeId == CurrentPermission.TileType)
|
||||
var tileRef = Maps.GetTileRef(gridId, grid, coordinates).Tile;
|
||||
if (tileRef.TypeId == CurrentPermission.TileType &&
|
||||
tileRef.RotationMirroring == Tile.DirectionToByte(Direction) + (Mirrored ? 4 : 0))
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -801,9 +817,14 @@ namespace Robust.Client.Placement
|
||||
};
|
||||
|
||||
if (CurrentPermission.IsTile)
|
||||
{
|
||||
message.TileType = CurrentPermission.TileType;
|
||||
message.Mirrored = Mirrored;
|
||||
}
|
||||
else
|
||||
{
|
||||
message.EntityTemplateName = CurrentPermission.EntityType;
|
||||
}
|
||||
|
||||
// world x and y
|
||||
message.NetCoordinates = EntityManager.GetNetCoordinates(coordinates);
|
||||
|
||||
@@ -126,7 +126,7 @@ namespace Robust.Client.Placement
|
||||
|
||||
sprite.Color = IsValidPosition(coordinate) ? ValidPlaceColor : InvalidPlaceColor;
|
||||
var rot = args.Viewport.Eye?.Rotation ?? default;
|
||||
spriteSys.Render(uid.Value, sprite, args.WorldHandle, rot, worldRot, worldPos);
|
||||
spriteSys.RenderSprite((uid.Value, sprite), args.WorldHandle, rot, worldRot, worldPos);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ namespace Robust.Client.Prototypes
|
||||
public sealed class ClientPrototypeManager : PrototypeManager
|
||||
{
|
||||
[Dependency] private readonly INetManager _netManager = default!;
|
||||
#pragma warning disable CS0414
|
||||
[Dependency] private readonly IClientGameTiming _timing = default!;
|
||||
#pragma warning restore CS0414
|
||||
[Dependency] private readonly IGameControllerInternal _controller = default!;
|
||||
[Dependency] private readonly IReloadManager _reload = default!;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -8,6 +9,7 @@ using Robust.Client.Graphics;
|
||||
using Robust.Client.Utility;
|
||||
using Robust.Shared;
|
||||
using Robust.Shared.Audio;
|
||||
using Robust.Shared.Collections;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.ContentPack;
|
||||
using Robust.Shared.Graphics;
|
||||
@@ -59,7 +61,14 @@ namespace Robust.Client.ResourceManagement
|
||||
{
|
||||
try
|
||||
{
|
||||
TextureResource.LoadPreTexture(_manager, data);
|
||||
TextureResource.LoadTextureParameters(_manager, data);
|
||||
if (!data.LoadParameters.Preload)
|
||||
{
|
||||
data.Skip = true;
|
||||
return;
|
||||
}
|
||||
|
||||
TextureResource.LoadPreTextureData(_manager, data);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -72,7 +81,7 @@ namespace Robust.Client.ResourceManagement
|
||||
|
||||
foreach (var data in texList)
|
||||
{
|
||||
if (data.Bad)
|
||||
if (data.Bad || data.Skip)
|
||||
continue;
|
||||
|
||||
try
|
||||
@@ -87,6 +96,7 @@ namespace Robust.Client.ResourceManagement
|
||||
}
|
||||
|
||||
var errors = 0;
|
||||
var skipped = 0;
|
||||
foreach (var data in texList)
|
||||
{
|
||||
if (data.Bad)
|
||||
@@ -95,6 +105,12 @@ namespace Robust.Client.ResourceManagement
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.Skip)
|
||||
{
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var texResource = new TextureResource();
|
||||
@@ -110,9 +126,10 @@ namespace Robust.Client.ResourceManagement
|
||||
}
|
||||
|
||||
sawmill.Debug(
|
||||
"Preloaded {CountLoaded} textures ({CountErrored} errored) in {LoadTime}",
|
||||
texList.Length,
|
||||
"Preloaded {CountLoaded} textures ({CountErrored} errored, {CountSkipped} skipped) in {LoadTime}",
|
||||
texList.Length - skipped - errors,
|
||||
errors,
|
||||
skipped,
|
||||
sw.Elapsed);
|
||||
}
|
||||
|
||||
@@ -176,65 +193,143 @@ namespace Robust.Client.ResourceManagement
|
||||
// TODO allow RSIs to opt out (useful for very big & rare RSIs)
|
||||
// TODO combine with (non-rsi) texture atlas?
|
||||
|
||||
Array.Sort(atlasList, (b, a) => (b.AtlasSheet?.Height ?? 0).CompareTo(a.AtlasSheet?.Height ?? 0));
|
||||
// We now need to insert the RSIs into the atlas. This specific problem is 2BP|O|F - the items are oriented
|
||||
// and cutting is free. The sorting is done by a slightly modified FFDH algorithm. The algorithm is exactly
|
||||
// the same as the standard FFDH algorithm with one main difference: We create new "levels" above placed
|
||||
// blocks. For example if the first block was 10x20, then the second was 10x10 units, we would create a
|
||||
// 10x10 level above the second block that would be treated as a normal level. This increases the packing
|
||||
// efficiency from ~85% to ~95% with very little extra computational effort. The algorithm appears to be
|
||||
// ~97% effective for storing SS14s RSIs.
|
||||
//
|
||||
// Here are some more resources about the strip packing problem!
|
||||
// - https://en.wikipedia.org/w/index.php?title=Strip_packing_problem&oldid=1263496949#First-fit_decreasing-height_(FFDH)
|
||||
// - https://www.csc.liv.ac.uk/~epa/surveyhtml.html
|
||||
// - https://www.dei.unipd.it/~fisch/ricop/tesi/tesi_dottorato_Lodi_1999.pdf
|
||||
|
||||
// The array must be sorted from biggest to smallest first.
|
||||
Array.Sort(atlasList, (b, a) => a.AtlasSheet.Height.CompareTo(b.AtlasSheet.Height));
|
||||
|
||||
// Each RSI sub atlas has a different size.
|
||||
// Even if we iterate through them once to estimate total area, I have NFI how to sanely estimate an optimal square-texture size.
|
||||
// So fuck it, just default to letting it be as large as it needs to and crop it as needed?
|
||||
var maxSize = Math.Min(GL.GetInteger(GetPName.MaxTextureSize), _configurationManager.GetCVar(CVars.ResRSIAtlasSize));
|
||||
var sheet = new Image<Rgba32>(maxSize, maxSize);
|
||||
|
||||
var deltaY = 0;
|
||||
Vector2i offset = default;
|
||||
int finalized = -1;
|
||||
int atlasCount = 0;
|
||||
for (int i = 0; i < atlasList.Length; i++)
|
||||
// THIS IS NOT GUARANTEED TO HAVE ANY PARTICULARLY LOGICAL ORDERING.
|
||||
// E.G you could have atlas 1 RSIs appear *before* you're done seeing atlas 2 RSIs.
|
||||
var levels = new ValueList<Level>();
|
||||
|
||||
// List of all the image atlases.
|
||||
var imageAtlases = new ValueList<Image<Rgba32>>();
|
||||
|
||||
// List of all the actual atlases.
|
||||
var finalAtlases = new ValueList<OwnedTexture>();
|
||||
|
||||
// Number of total pixels in each atlas.
|
||||
var finalPixels = new ValueList<int>();
|
||||
|
||||
// First we just find the location of all the RSIs in the atlas before actually placing them.
|
||||
// This allows us to effectively determine how much space we need to allocate for the images.
|
||||
var currentHeight = 0;
|
||||
var currentAtlasIndex = 0;
|
||||
foreach (var rsi in atlasList)
|
||||
{
|
||||
var rsi = atlasList[i];
|
||||
if (rsi.Bad)
|
||||
var insertHeight = rsi.AtlasSheet.Height;
|
||||
var insertWidth = rsi.AtlasSheet.Width;
|
||||
|
||||
var found = false;
|
||||
for (var i = 0; i < levels.Count && !found; i++)
|
||||
{
|
||||
var levelPosition = levels[i].Position;
|
||||
var levelWidth = levels[i].Width;
|
||||
var levelHeight = levels[i].Height;
|
||||
|
||||
// Check if it can fit in this level.
|
||||
if (levelHeight < insertHeight || levelWidth + insertWidth > levels[i].MaxWidth)
|
||||
continue;
|
||||
|
||||
found = true;
|
||||
|
||||
levels[i].Width += insertWidth;
|
||||
rsi.AtlasOffset = levelPosition + new Vector2i(levelWidth, 0);
|
||||
levels[i].RSIList.Add(rsi);
|
||||
|
||||
// Creating the extra "free" space above blocks that can be used for inserting more items.
|
||||
// This differs from the FFDH spec which just ignores this space.
|
||||
Debug.Assert(levelHeight >= insertHeight); // Must be true because the array needs to be sorted
|
||||
if (levelHeight - insertHeight == 0)
|
||||
continue;
|
||||
|
||||
var freeLevel = new Level
|
||||
{
|
||||
AtlasId = levels[i].AtlasId,
|
||||
Position = levelPosition + new Vector2i(levelWidth, insertHeight),
|
||||
Height = levelHeight - insertHeight,
|
||||
Width = 0,
|
||||
MaxWidth = insertWidth,
|
||||
RSIList = [ ]
|
||||
};
|
||||
|
||||
levels.Add(freeLevel);
|
||||
}
|
||||
|
||||
if (found)
|
||||
continue;
|
||||
|
||||
DebugTools.Assert(rsi.AtlasSheet.Width < sheet.Width);
|
||||
DebugTools.Assert(rsi.AtlasSheet.Height < sheet.Height);
|
||||
|
||||
if (offset.X + rsi.AtlasSheet.Width > sheet.Width)
|
||||
// Ran out of space, we need to move on to the next atlas.
|
||||
// This also isn't in the normal FFDH algorithm (obviously) but its close enough.
|
||||
if (currentHeight + insertHeight > maxSize)
|
||||
{
|
||||
offset.X = 0;
|
||||
offset.Y += deltaY;
|
||||
imageAtlases.Add(new Image<Rgba32>(maxSize, currentHeight));
|
||||
finalPixels.Add(0);
|
||||
currentHeight = 0;
|
||||
currentAtlasIndex++;
|
||||
}
|
||||
|
||||
if (offset.Y + rsi.AtlasSheet.Height > sheet.Height)
|
||||
{
|
||||
FinalizeMetaAtlas(i-1, sheet);
|
||||
sheet = new Image<Rgba32>(maxSize, maxSize);
|
||||
deltaY = 0;
|
||||
offset = default;
|
||||
}
|
||||
rsi.AtlasOffset = new Vector2i(0, currentHeight);
|
||||
|
||||
deltaY = Math.Max(deltaY, rsi.AtlasSheet.Height);
|
||||
var box = new UIBox2i(0, 0, rsi.AtlasSheet.Width, rsi.AtlasSheet.Height);
|
||||
rsi.AtlasSheet.Blit(box, sheet, offset);
|
||||
rsi.AtlasOffset = offset;
|
||||
offset.X += rsi.AtlasSheet.Width;
|
||||
var newLevel = new Level
|
||||
{
|
||||
AtlasId = currentAtlasIndex,
|
||||
Position = new Vector2i(0, currentHeight),
|
||||
Height = insertHeight,
|
||||
Width = insertWidth,
|
||||
MaxWidth = maxSize,
|
||||
RSIList = [ rsi ]
|
||||
};
|
||||
levels.Add(newLevel);
|
||||
|
||||
currentHeight += insertHeight;
|
||||
}
|
||||
|
||||
var height = offset.Y + deltaY;
|
||||
var croppedSheet = new Image<Rgba32>(maxSize, height);
|
||||
sheet.Blit(new UIBox2i(0, 0, maxSize, height), croppedSheet, default);
|
||||
FinalizeMetaAtlas(atlasList.Length - 1, croppedSheet);
|
||||
// This allocation takes a long time.
|
||||
imageAtlases.Add(new Image<Rgba32>(maxSize, currentHeight));
|
||||
finalPixels.Add(0);
|
||||
|
||||
void FinalizeMetaAtlas(int toIndex, Image<Rgba32> sheet)
|
||||
// Put all textures on the atlases
|
||||
foreach (var level in levels)
|
||||
{
|
||||
var fromIndex = finalized + 1;
|
||||
var atlas = Clyde.LoadTextureFromImage(sheet, $"Meta atlas {fromIndex}-{toIndex}");
|
||||
for (int i = fromIndex; i <= toIndex; i++)
|
||||
foreach (var rsi in level.RSIList)
|
||||
{
|
||||
var rsi = atlasList[i];
|
||||
rsi.AtlasTexture = atlas;
|
||||
}
|
||||
var box = new UIBox2i(0, 0, rsi.AtlasSheet.Width, rsi.AtlasSheet.Height);
|
||||
|
||||
finalized = toIndex;
|
||||
atlasCount++;
|
||||
rsi.AtlasSheet.Blit(box, imageAtlases[level.AtlasId], rsi.AtlasOffset);
|
||||
finalPixels[level.AtlasId] += rsi.AtlasSheet.Width * rsi.AtlasSheet.Height;
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the atlases.
|
||||
for (var i = 0; i < imageAtlases.Count; i++)
|
||||
{
|
||||
var atlasTexture = Clyde.LoadTextureFromImage(imageAtlases[i], $"Meta atlas {i}");
|
||||
finalAtlases.Add(atlasTexture);
|
||||
|
||||
sawmill.Debug($"(Meta atlas {i}) - cropped utilization: {(float)finalPixels[i] / (maxSize * imageAtlases[i].Height):P2}, fill percentage: {(float)imageAtlases[i].Height / maxSize:P2}");
|
||||
}
|
||||
|
||||
// Finally, reference the actual atlas from the RSIs.
|
||||
foreach (var level in levels)
|
||||
{
|
||||
foreach (var rsi in level.RSIList)
|
||||
{
|
||||
rsi.AtlasTexture = finalAtlases[level.AtlasId];
|
||||
}
|
||||
}
|
||||
|
||||
Parallel.ForEach(rsiList, data =>
|
||||
@@ -279,7 +374,7 @@ namespace Robust.Client.ResourceManagement
|
||||
sawmill.Debug(
|
||||
"Preloaded {CountLoaded} RSIs into {CountAtlas} Atlas(es?) ({CountNotAtlas} not atlassed, {CountErrored} errored) in {LoadTime}",
|
||||
rsiList.Length,
|
||||
atlasCount,
|
||||
finalAtlases.Count,
|
||||
nonAtlasList.Length,
|
||||
errors,
|
||||
sw.Elapsed);
|
||||
@@ -290,4 +385,38 @@ namespace Robust.Client.ResourceManagement
|
||||
return rsi.MetaAtlas && rsi.LoadParameters == TextureLoadParameters.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A "Level" to place boxes. Similar to FFDH levels, but with more parameters so we can fit in "free" levels
|
||||
/// above placed boxes.
|
||||
/// </summary>
|
||||
internal sealed class Level
|
||||
{
|
||||
/// <summary>
|
||||
/// Index of the atlas this is located.
|
||||
/// </summary>
|
||||
public required int AtlasId;
|
||||
/// <summary>
|
||||
/// Bottom left of the location for the RSIs.
|
||||
/// </summary>
|
||||
public required Vector2i Position;
|
||||
/// <summary>
|
||||
/// The current width of the level.
|
||||
/// </summary>
|
||||
/// <remarks>This can (and will) be 0. Will change.</remarks>
|
||||
public required int Width;
|
||||
/// <summary>
|
||||
/// The current height of the level.
|
||||
/// </summary>
|
||||
/// <remarks>This value should never change.</remarks>
|
||||
public required int Height;
|
||||
/// <summary>
|
||||
/// Maximum width of the level.
|
||||
/// </summary>
|
||||
public required int MaxWidth;
|
||||
/// <summary>
|
||||
/// List of all the RSIs stored in this level. RSIs are ordered from tallest to smallest per level.
|
||||
/// </summary>
|
||||
public required List<RSIResource.LoadStepData> RSIList;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Robust.Client.Audio;
|
||||
using Robust.Shared.Audio;
|
||||
using Robust.Shared.ContentPack;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Utility;
|
||||
@@ -11,6 +11,13 @@ namespace Robust.Client.ResourceManagement;
|
||||
|
||||
public sealed class AudioResource : BaseResource
|
||||
{
|
||||
// from: https://en.wikipedia.org/wiki/List_of_file_signatures
|
||||
private static readonly byte[] OggSignature = "OggS"u8.ToArray();
|
||||
private static readonly byte[] RiffSignature = "RIFF"u8.ToArray();
|
||||
private const int WavSignatureStart = 8; // RIFF????
|
||||
private static readonly byte[] WavSignature = "WAVE"u8.ToArray();
|
||||
private const int MaxSignatureLength = 12; // RIFF????WAVE
|
||||
|
||||
public AudioStream AudioStream { get; private set; } = default!;
|
||||
|
||||
public void Load(AudioStream stream)
|
||||
@@ -28,14 +35,19 @@ public sealed class AudioResource : BaseResource
|
||||
}
|
||||
|
||||
using var fileStream = cache.ContentFileRead(path);
|
||||
var seekableStream = fileStream.CanSeek ? fileStream : fileStream.CopyToMemoryStream();
|
||||
byte[] signature = seekableStream.ReadExact(MaxSignatureLength);
|
||||
seekableStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
var audioManager = dependencies.Resolve<IAudioInternal>();
|
||||
if (path.Extension == "ogg")
|
||||
if (signature[..OggSignature.Length].SequenceEqual(OggSignature))
|
||||
{
|
||||
AudioStream = audioManager.LoadAudioOggVorbis(fileStream, path.ToString());
|
||||
AudioStream = audioManager.LoadAudioOggVorbis(seekableStream, path.ToString());
|
||||
}
|
||||
else if (path.Extension == "wav")
|
||||
else if (signature[..RiffSignature.Length].SequenceEqual(RiffSignature)
|
||||
&& signature[WavSignatureStart..MaxSignatureLength].SequenceEqual(WavSignature))
|
||||
{
|
||||
AudioStream = audioManager.LoadAudioWav(fileStream, path.ToString());
|
||||
AudioStream = audioManager.LoadAudioWav(seekableStream, path.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -32,18 +32,22 @@ namespace Robust.Client.ResourceManagement
|
||||
|
||||
var data = new LoadStepData {Path = path};
|
||||
|
||||
LoadPreTexture(dependencies.Resolve<IResourceManager>(), data);
|
||||
LoadTextureParameters(dependencies.Resolve<IResourceManager>(), data);
|
||||
LoadPreTextureData(dependencies.Resolve<IResourceManager>(), data);
|
||||
LoadTexture(dependencies.Resolve<IClyde>(), data);
|
||||
LoadFinish(dependencies.Resolve<IResourceCache>(), data);
|
||||
}
|
||||
|
||||
internal static void LoadPreTexture(IResourceManager cache, LoadStepData data)
|
||||
internal static void LoadPreTextureData(IResourceManager cache, LoadStepData data)
|
||||
{
|
||||
using (var stream = cache.ContentFileRead(data.Path))
|
||||
{
|
||||
data.Image = Image.Load<Rgba32>(stream);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void LoadTextureParameters(IResourceManager cache, LoadStepData data)
|
||||
{
|
||||
data.LoadParameters = TryLoadTextureParameters(cache, data.Path) ?? TextureLoadParameters.Default;
|
||||
}
|
||||
|
||||
@@ -95,7 +99,8 @@ namespace Robust.Client.ResourceManagement
|
||||
{
|
||||
var data = new LoadStepData {Path = path};
|
||||
|
||||
LoadPreTexture(dependencies.Resolve<IResourceManager>(), data);
|
||||
LoadTextureParameters(dependencies.Resolve<IResourceManager>(), data);
|
||||
LoadPreTextureData(dependencies.Resolve<IResourceManager>(), data);
|
||||
|
||||
if (data.Image.Width == Texture.Width && data.Image.Height == Texture.Height)
|
||||
{
|
||||
@@ -119,6 +124,7 @@ namespace Robust.Client.ResourceManagement
|
||||
public Image<Rgba32> Image = default!;
|
||||
public TextureLoadParameters LoadParameters;
|
||||
public OwnedTexture Texture = default!;
|
||||
public bool Skip;
|
||||
public bool Bad;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.Placement;
|
||||
using Robust.Client.Placement.Modes;
|
||||
using Robust.Client.ResourceManagement;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.CustomControls;
|
||||
@@ -28,19 +30,23 @@ public sealed class TileSpawningUIController : UIController
|
||||
private readonly List<ITileDefinition> _shownTiles = new();
|
||||
private bool _clearingTileSelections;
|
||||
private bool _eraseTile;
|
||||
private bool _mirrorableTile; // Tracks if the chosen tile even can be mirrored.
|
||||
private bool _mirroredTile;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
DebugTools.Assert(_init == false);
|
||||
_init = true;
|
||||
_placement.PlacementChanged += ClearTileSelection;
|
||||
_placement.DirectionChanged += OnDirectionChanged;
|
||||
_placement.MirroredChanged += OnMirroredChanged;
|
||||
}
|
||||
|
||||
private void StartTilePlacement(int tileType)
|
||||
{
|
||||
var newObjInfo = new PlacementInformation
|
||||
{
|
||||
PlacementOption = "AlignTileAny",
|
||||
PlacementOption = nameof(AlignTileAny),
|
||||
TileType = tileType,
|
||||
Range = 400,
|
||||
IsTile = true
|
||||
@@ -67,6 +73,17 @@ public sealed class TileSpawningUIController : UIController
|
||||
args.Button.Pressed = args.Pressed;
|
||||
}
|
||||
|
||||
private void OnTileMirroredToggled(ButtonToggledEventArgs args)
|
||||
{
|
||||
if (_window == null || _window.Disposed)
|
||||
return;
|
||||
|
||||
_placement.Mirrored = args.Pressed;
|
||||
_mirroredTile = _placement.Mirrored;
|
||||
|
||||
args.Button.Pressed = args.Pressed;
|
||||
}
|
||||
|
||||
public void ToggleWindow()
|
||||
{
|
||||
EnsureWindow();
|
||||
@@ -78,6 +95,9 @@ public sealed class TileSpawningUIController : UIController
|
||||
else
|
||||
{
|
||||
_window.Open();
|
||||
UpdateEntityDirectionLabel();
|
||||
UpdateMirroredButton();
|
||||
_window.SearchBar.GrabKeyboardFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +113,10 @@ public sealed class TileSpawningUIController : UIController
|
||||
_window.TileList.OnItemDeselected += OnTileItemDeselected;
|
||||
_window.EraseButton.Pressed = _eraseTile;
|
||||
_window.EraseButton.OnToggled += OnTileEraseToggled;
|
||||
_window.MirroredButton.Disabled = !_mirrorableTile;
|
||||
_window.RotationLabel.FontColorOverride = _mirrorableTile ? Color.White : Color.Gray;
|
||||
_window.MirroredButton.Pressed = _mirroredTile;
|
||||
_window.MirroredButton.OnToggled += OnTileMirroredToggled;
|
||||
BuildTileList();
|
||||
}
|
||||
|
||||
@@ -110,6 +134,7 @@ public sealed class TileSpawningUIController : UIController
|
||||
_window.TileList.ClearSelected();
|
||||
_clearingTileSelections = false;
|
||||
_window.EraseButton.Pressed = false;
|
||||
_window.MirroredButton.Pressed = _placement.Mirrored;
|
||||
}
|
||||
|
||||
private void OnTileClearPressed(ButtonEventArgs args)
|
||||
@@ -137,6 +162,7 @@ public sealed class TileSpawningUIController : UIController
|
||||
{
|
||||
var definition = _shownTiles[args.ItemIndex];
|
||||
StartTilePlacement(definition.TileId);
|
||||
UpdateMirroredButton();
|
||||
}
|
||||
|
||||
private void OnTileItemDeselected(ItemList.ItemListDeselectedEventArgs args)
|
||||
@@ -149,6 +175,41 @@ public sealed class TileSpawningUIController : UIController
|
||||
_placement.Clear();
|
||||
}
|
||||
|
||||
private void OnDirectionChanged(object? sender, EventArgs e)
|
||||
{
|
||||
UpdateEntityDirectionLabel();
|
||||
}
|
||||
|
||||
private void UpdateEntityDirectionLabel()
|
||||
{
|
||||
if (_window == null || _window.Disposed)
|
||||
return;
|
||||
|
||||
_window.RotationLabel.Text = _placement.Direction.ToString();
|
||||
}
|
||||
|
||||
private void OnMirroredChanged(object? sender, EventArgs e)
|
||||
{
|
||||
UpdateMirroredButton();
|
||||
}
|
||||
|
||||
private void UpdateMirroredButton()
|
||||
{
|
||||
if (_window == null || _window.Disposed)
|
||||
return;
|
||||
|
||||
if (_placement.CurrentPermission != null && _placement.CurrentPermission.IsTile)
|
||||
{
|
||||
var allowed = _tiles[_placement.CurrentPermission.TileType].AllowRotationMirror;
|
||||
_mirrorableTile = allowed;
|
||||
_window.MirroredButton.Disabled = !_mirrorableTile;
|
||||
_window.RotationLabel.FontColorOverride = _mirrorableTile ? Color.White : Color.Gray;
|
||||
}
|
||||
|
||||
_mirroredTile = _placement.Mirrored;
|
||||
_window.MirroredButton.Pressed = _mirroredTile;
|
||||
}
|
||||
|
||||
private void BuildTileList(string? searchStr = null)
|
||||
{
|
||||
if (_window == null || _window.Disposed) return;
|
||||
|
||||
@@ -741,7 +741,7 @@ namespace Robust.Client.UserInterface.Controls
|
||||
get => _selected;
|
||||
set
|
||||
{
|
||||
if (!Selectable) return;
|
||||
if (!Selectable || _selected == value) return;
|
||||
_selected = value;
|
||||
if(_selected) OnSelected?.Invoke(this);
|
||||
else OnDeselected?.Invoke(this);
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
</BoxContainer>
|
||||
<ItemList Name="TileList" Access="Public" VerticalExpand="True"/>
|
||||
<BoxContainer Orientation="Horizontal">
|
||||
<Button Name="MirroredButton" Access="Public" ToggleMode="True" Text="{Loc tile-spawn-window-mirror-button-text}"/>
|
||||
<Button Name="EraseButton" Access="Public" ToggleMode="True" Text="{Loc window-erase-button-text}"/>
|
||||
</BoxContainer>
|
||||
<Label Name="RotationLabel" Access="Public"/>
|
||||
</BoxContainer>
|
||||
</TileSpawnWindow>
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
<DebugConsole Name="DebugConsole" />
|
||||
<DevWindowTabUI Name="UI" />
|
||||
<DevWindowTabPerf Name="Perf" />
|
||||
<DevWindowTabTextures Name="Textures" />
|
||||
</TabContainer>
|
||||
</Control>
|
||||
|
||||
@@ -8,6 +8,7 @@ using Robust.Client.UserInterface.Stylesheets;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Shared.Console;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Localization;
|
||||
|
||||
namespace Robust.Client.UserInterface
|
||||
{
|
||||
@@ -26,6 +27,7 @@ namespace Robust.Client.UserInterface
|
||||
TabContainer.SetTabTitle(DebugConsole, "Debug Console");
|
||||
TabContainer.SetTabTitle(UI, "User Interface");
|
||||
TabContainer.SetTabTitle(Perf, "Profiling");
|
||||
TabContainer.SetTabTitle(Textures, Loc.GetString("dev-window-tab-textures-title"));
|
||||
|
||||
Stylesheet =
|
||||
new DefaultStylesheet(IoCManager.Resolve<IResourceCache>(), IoCManager.Resolve<IUserInterfaceManager>()).Stylesheet;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<Control xmlns="https://spacestation14.io"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Robust.Client.UserInterface.DevWindowTabTextures">
|
||||
<SplitContainer Orientation="Horizontal">
|
||||
<!-- Left pane: list of textures -->
|
||||
<BoxContainer Orientation="Vertical" MinWidth="200">
|
||||
<Button Name="ReloadButton" Text="{Loc 'dev-window-tab-textures-reload'}" />
|
||||
<LineEdit Name="SearchBar" PlaceHolder="{Loc 'dev-window-tab-textures-filter'}" />
|
||||
|
||||
<ScrollContainer HScrollEnabled="False" VerticalExpand="True">
|
||||
<BoxContainer Name="TextureList" Orientation="Vertical" />
|
||||
</ScrollContainer>
|
||||
|
||||
<Label Name="SummaryLabel" Margin="4" />
|
||||
</BoxContainer>
|
||||
|
||||
<!-- Right pane: show the selected texture info -->
|
||||
<Control MinWidth="400">
|
||||
<BoxContainer Orientation="Vertical">
|
||||
<TextureRect Name="SelectedTextureDisplay" VerticalExpand="True" CanShrink="True"
|
||||
Stretch="KeepAspectCentered" />
|
||||
<Label Name="SelectedTextureInfo" />
|
||||
</BoxContainer>
|
||||
</Control>
|
||||
</SplitContainer>
|
||||
</Control>
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.Graphics.Clyde;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Localization;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.UserInterface;
|
||||
|
||||
/// <summary>
|
||||
/// Shows all loaded textures in the game.
|
||||
/// </summary>
|
||||
[GenerateTypedNameReferences]
|
||||
internal sealed partial class DevWindowTabTextures : Control
|
||||
{
|
||||
[Dependency] private readonly IClydeInternal _clyde = null!;
|
||||
[Dependency] private readonly ILocalizationManager _loc = null!;
|
||||
|
||||
public DevWindowTabTextures()
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
IoCManager.InjectDependencies(this);
|
||||
|
||||
ReloadButton.OnPressed += _ => Reload();
|
||||
SearchBar.OnTextChanged += _ => Reload();
|
||||
}
|
||||
|
||||
protected override void VisibilityChanged(bool newVisible)
|
||||
{
|
||||
if (newVisible)
|
||||
{
|
||||
Reload();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clear to release memory when tab not visible.
|
||||
Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void Clear()
|
||||
{
|
||||
TextureList.RemoveAllChildren();
|
||||
}
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
Clear();
|
||||
|
||||
var total = 0L;
|
||||
|
||||
foreach (var (clydeTexture, loadedTexture) in _clyde.GetLoadedTextures()
|
||||
.OrderByDescending(tup => tup.Item2.MemoryPressure))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(SearchBar.Text))
|
||||
{
|
||||
if (loadedTexture.Name is not { } name)
|
||||
continue;
|
||||
|
||||
if (!name.Contains(SearchBar.Text, StringComparison.CurrentCultureIgnoreCase))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (loadedTexture.MemoryPressure == 0)
|
||||
{
|
||||
// Bad hack to avoid showing render targets lol.
|
||||
continue;
|
||||
}
|
||||
|
||||
var button = new ContainerButton
|
||||
{
|
||||
Children = { new TextureEntry(loadedTexture, clydeTexture) }
|
||||
};
|
||||
button.OnPressed += _ => SelectTexture(loadedTexture, clydeTexture);
|
||||
|
||||
TextureList.AddChild(button);
|
||||
|
||||
total += loadedTexture.MemoryPressure;
|
||||
}
|
||||
|
||||
SummaryLabel.Text =
|
||||
_loc.GetString("dev-window-tab-textures-summary", ("bytes", ByteHelpers.FormatBytes(total)));
|
||||
}
|
||||
|
||||
private void SelectTexture(Clyde.LoadedTexture loaded, Clyde.ClydeTexture texture)
|
||||
{
|
||||
SelectedTextureDisplay.Texture = texture;
|
||||
SelectedTextureInfo.Text = _loc.GetString("dev-window-tab-textures-info",
|
||||
("width", loaded.Width),
|
||||
("height", loaded.Height),
|
||||
("pixelType", loaded.TexturePixelType),
|
||||
("srgb", loaded.IsSrgb),
|
||||
("name", loaded.Name ?? ""),
|
||||
("bytes", ByteHelpers.FormatBytes(loaded.MemoryPressure)));
|
||||
}
|
||||
|
||||
private sealed class TextureEntry : Control
|
||||
{
|
||||
public TextureEntry(Clyde.LoadedTexture loaded, Clyde.ClydeTexture texture)
|
||||
{
|
||||
SetHeight = 64;
|
||||
|
||||
var bytes = ByteHelpers.FormatBytes(loaded.MemoryPressure);
|
||||
|
||||
var label = loaded.Name == null
|
||||
? $"{texture.TextureId} ({bytes})"
|
||||
: $"{loaded.Name}\n{texture.TextureId} ({bytes})";
|
||||
|
||||
AddChild(new BoxContainer
|
||||
{
|
||||
Orientation = BoxContainer.LayoutOrientation.Horizontal,
|
||||
Children =
|
||||
{
|
||||
new TextureRect
|
||||
{
|
||||
SetWidth = 64,
|
||||
Texture = texture,
|
||||
CanShrink = true,
|
||||
RectClipContent = true,
|
||||
Stretch = TextureRect.StretchMode.Scale
|
||||
},
|
||||
new Label
|
||||
{
|
||||
Text = label,
|
||||
ClipText = true,
|
||||
HorizontalExpand = true
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,14 @@ internal sealed class ReloadManager : IReloadManager
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
[Dependency] private readonly ILogManager _logMan = default!;
|
||||
[Dependency] private readonly IResourceManager _res = default!;
|
||||
#pragma warning disable CS0414
|
||||
[Dependency] private readonly ITaskManager _tasks = default!;
|
||||
#pragma warning restore CS0414
|
||||
|
||||
private readonly TimeSpan _reloadDelay = TimeSpan.FromMilliseconds(10);
|
||||
private CancellationTokenSource _reloadToken = new();
|
||||
private readonly HashSet<ResPath> _reloadQueue = new();
|
||||
private List<FileSystemWatcher> _watchers = new(); // this list is never used but needed to prevent them from being garbage collected
|
||||
|
||||
public event Action<ResPath>? OnChanged;
|
||||
|
||||
@@ -93,6 +96,7 @@ internal sealed class ReloadManager : IReloadManager
|
||||
NotifyFilter = NotifyFilters.LastWrite
|
||||
};
|
||||
|
||||
_watchers.Add(watcher); // prevent garbage collection
|
||||
|
||||
watcher.Changed += OnWatch;
|
||||
|
||||
@@ -138,6 +142,6 @@ internal sealed class ReloadManager : IReloadManager
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace Robust.Client.Utility
|
||||
/// </summary>
|
||||
public static class SpriteSpecifierExt
|
||||
{
|
||||
[Obsolete("Use SpriteSystem.GetTexture() instead")]
|
||||
public static Texture GetTexture(this SpriteSpecifier.Texture texSpecifier, IResourceCache cache)
|
||||
{
|
||||
return cache
|
||||
@@ -24,13 +25,14 @@ namespace Robust.Client.Utility
|
||||
.Texture;
|
||||
}
|
||||
|
||||
[Obsolete("Use SpriteSystem")]
|
||||
[Obsolete("Use SpriteSystem.GetState() instead")]
|
||||
public static RSI.State GetState(this SpriteSpecifier.Rsi rsiSpecifier, IResourceCache cache)
|
||||
{
|
||||
if (!cache.TryGetResource<RSIResource>(SpriteSpecifierSerializer.TextureRoot / rsiSpecifier.RsiPath, out var theRsi))
|
||||
{
|
||||
var sys = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
|
||||
Logger.Error("SpriteSpecifier failed to load RSI {0}", rsiSpecifier.RsiPath);
|
||||
return SpriteComponent.GetFallbackState(cache);
|
||||
return sys.GetFallbackState();
|
||||
}
|
||||
|
||||
if (theRsi.RSI.TryGetState(rsiSpecifier.RsiState, out var state))
|
||||
@@ -39,21 +41,22 @@ namespace Robust.Client.Utility
|
||||
}
|
||||
|
||||
Logger.Error($"SpriteSpecifier has invalid RSI state '{rsiSpecifier.RsiState}' for RSI: {rsiSpecifier.RsiPath}");
|
||||
return SpriteComponent.GetFallbackState(cache);
|
||||
return IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>().GetFallbackState();
|
||||
}
|
||||
|
||||
[Obsolete("Use SpriteSystem")]
|
||||
[Obsolete("Use SpriteSystem.Frame0() instead")]
|
||||
public static Texture Frame0(this SpriteSpecifier specifier)
|
||||
{
|
||||
return specifier.RsiStateLike().Default;
|
||||
}
|
||||
|
||||
[Obsolete("Use SpriteSystem.RsiStateLike() instead")]
|
||||
public static IDirectionalTextureProvider DirFrame0(this SpriteSpecifier specifier)
|
||||
{
|
||||
return specifier.RsiStateLike();
|
||||
}
|
||||
|
||||
[Obsolete("Use SpriteSystem")]
|
||||
[Obsolete("Use SpriteSystem.RsiStateLike() instead")]
|
||||
public static IRsiStateLike RsiStateLike(this SpriteSpecifier specifier)
|
||||
{
|
||||
var resC = IoCManager.Resolve<IResourceCache>();
|
||||
@@ -67,10 +70,11 @@ namespace Robust.Client.Utility
|
||||
|
||||
case SpriteSpecifier.EntityPrototype prototypeIcon:
|
||||
var protMgr = IoCManager.Resolve<IPrototypeManager>();
|
||||
var sys = IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<SpriteSystem>();
|
||||
if (!protMgr.TryIndex<EntityPrototype>(prototypeIcon.EntityPrototypeId, out var prototype))
|
||||
{
|
||||
Logger.Error("Failed to load PrototypeIcon {0}", prototypeIcon.EntityPrototypeId);
|
||||
return SpriteComponent.GetFallbackState(resC);
|
||||
return sys.GetFallbackState();
|
||||
}
|
||||
|
||||
return SpriteComponent.GetPrototypeIcon(prototype, resC);
|
||||
|
||||
@@ -39,6 +39,7 @@ public static class Diagnostics
|
||||
public const string IdForbidLiteral = "RA0033";
|
||||
public const string IdObsoleteInheritance = "RA0034";
|
||||
public const string IdObsoleteInheritanceWithMessage = "RA0035";
|
||||
public const string IdDataFieldYamlSerializable = "RA0036";
|
||||
|
||||
public static SuppressionDescriptor MeansImplicitAssignment =>
|
||||
new SuppressionDescriptor("RADC1000", "CS0649", "Marked as implicitly assigned.");
|
||||
|
||||
@@ -44,20 +44,6 @@ public sealed class PointLightSystem : SharedPointLightSystem
|
||||
_metadata.SetFlag((uid, meta), MetaDataFlags.PvsPriority, IsHighPriority(comp));
|
||||
}
|
||||
|
||||
private void OnLightGetState(EntityUid uid, PointLightComponent component, ref ComponentGetState args)
|
||||
{
|
||||
args.State = new PointLightComponentState()
|
||||
{
|
||||
Color = component.Color,
|
||||
Enabled = component.Enabled,
|
||||
Energy = component.Energy,
|
||||
Offset = component.Offset,
|
||||
Radius = component.Radius,
|
||||
Softness = component.Softness,
|
||||
CastShadows = component.CastShadows,
|
||||
};
|
||||
}
|
||||
|
||||
public override SharedPointLightComponent EnsureLight(EntityUid uid)
|
||||
{
|
||||
return EnsureComp<PointLightComponent>(uid);
|
||||
|
||||
@@ -179,11 +179,14 @@ namespace Robust.Server.Placement
|
||||
}
|
||||
else
|
||||
{
|
||||
PlaceNewTile(tileType, coordinates, msg.MsgChannel.UserId);
|
||||
if (_tileDefinitionManager[tileType].AllowRotationMirror)
|
||||
PlaceNewTile(tileType, coordinates, msg.MsgChannel.UserId, Tile.DirectionToByte(dirRcv), msg.Mirrored);
|
||||
else
|
||||
PlaceNewTile(tileType, coordinates, msg.MsgChannel.UserId, Tile.DirectionToByte(Direction.South), false);
|
||||
}
|
||||
}
|
||||
|
||||
private void PlaceNewTile(int tileType, EntityCoordinates coordinates, NetUserId placingUserId)
|
||||
private void PlaceNewTile(int tileType, EntityCoordinates coordinates, NetUserId placingUserId, byte direction, bool mirrored)
|
||||
{
|
||||
if (!coordinates.IsValid(_entityManager)) return;
|
||||
|
||||
@@ -193,7 +196,7 @@ namespace Robust.Server.Placement
|
||||
if (_entityManager.TryGetComponent(coordinates.EntityId, out grid)
|
||||
|| _mapManager.TryFindGridAt(_xformSystem.ToMapCoordinates(coordinates), out gridId, out grid))
|
||||
{
|
||||
_maps.SetTile(gridId, grid, coordinates, new Tile(tileType));
|
||||
_maps.SetTile(gridId, grid, coordinates, new Tile(tileType, rotationMirroring: (byte)(direction + (mirrored ? 4 : 0))));
|
||||
|
||||
var placementEraseEvent = new PlacementTileEvent(tileType, coordinates, placingUserId);
|
||||
_entityManager.EventBus.RaiseEvent(EventSource.Local, placementEraseEvent);
|
||||
@@ -207,7 +210,7 @@ namespace Robust.Server.Placement
|
||||
|
||||
_xformSystem.SetWorldPosition(newGridXform, coordinates.Position - newGrid.Comp.TileSizeHalfVector); // assume bottom left tile origin
|
||||
var tilePos = _maps.WorldToTile(newGrid.Owner, newGrid.Comp, coordinates.Position);
|
||||
_maps.SetTile(newGrid.Owner, newGrid.Comp, tilePos, new Tile(tileType));
|
||||
_maps.SetTile(newGrid.Owner, newGrid.Comp, tilePos, new Tile(tileType, rotationMirroring: (byte)(direction + (mirrored ? 4 : 0))));
|
||||
|
||||
var placementEraseEvent = new PlacementTileEvent(tileType, coordinates, placingUserId);
|
||||
_entityManager.EventBus.RaiseEvent(EventSource.Local, placementEraseEvent);
|
||||
|
||||
@@ -13,8 +13,10 @@ namespace Robust.Server.Prototypes
|
||||
{
|
||||
public sealed class ServerPrototypeManager : PrototypeManager
|
||||
{
|
||||
#pragma warning disable CS0414
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
[Dependency] private readonly IConGroupController _conGroups = default!;
|
||||
#pragma warning restore CS0414
|
||||
[Dependency] private readonly INetManager _netManager = default!;
|
||||
[Dependency] private readonly IBaseServerInternal _server = default!;
|
||||
|
||||
|
||||
@@ -104,6 +104,16 @@ namespace Robust.Shared.Maths
|
||||
return (Direction) (Math.Floor((ang + CardinalOffset) / CardinalSegment) * 2 % 8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rounds the angle to the nearest cardinal direction. This behaves similarly to a combination of
|
||||
/// <see cref="GetCardinalDir"/> and Direction.ToAngle(), however this may return an angle outside of the range
|
||||
/// returned by those methods (-pi to pi).
|
||||
/// </summary>
|
||||
public Angle RoundToCardinalAngle()
|
||||
{
|
||||
return new Angle(CardinalSegment * Math.Floor((Theta + CardinalOffset) / CardinalSegment));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotates the vector counter-clockwise around its origin by the value of Theta.
|
||||
/// </summary>
|
||||
|
||||
@@ -111,6 +111,11 @@ public abstract class ComponentTreeSystem<TTreeComp, TComp> : EntitySystem
|
||||
component.TreeUpdateQueued = true;
|
||||
_updateQueue.Enqueue((component, xform));
|
||||
}
|
||||
|
||||
public void QueueTreeUpdate(Entity<TComp> entity, TransformComponent? xform = null)
|
||||
{
|
||||
QueueTreeUpdate(entity.Owner, entity.Comp, xform);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Component Management
|
||||
|
||||
@@ -913,6 +913,7 @@ Types:
|
||||
System.Threading:
|
||||
CancellationToken: { All: True }
|
||||
CancellationTokenSource: { All: True }
|
||||
CancellationTokenRegistration: { All: True }
|
||||
Interlocked: { All: True }
|
||||
Monitor: { All: True }
|
||||
System.Web:
|
||||
|
||||
@@ -72,19 +72,42 @@ internal sealed class MapChunkSerializer : ITypeSerializer<MapChunk, MappingData
|
||||
node.TryGetValue("version", out var versionNode);
|
||||
var version = ((ValueDataNode?) versionNode)?.AsInt() ?? 1;
|
||||
|
||||
for (ushort y = 0; y < chunk.ChunkSize; y++)
|
||||
// Old map files will throw an error if they do not have a rotation/mirror byte stored.
|
||||
if (version >= 7)
|
||||
{
|
||||
for (ushort x = 0; x < chunk.ChunkSize; x++)
|
||||
for (ushort y = 0; y < chunk.ChunkSize; y++)
|
||||
{
|
||||
var id = version < 6 ? reader.ReadUInt16() : reader.ReadInt32();
|
||||
var flags = reader.ReadByte();
|
||||
var variant = reader.ReadByte();
|
||||
for (ushort x = 0; x < chunk.ChunkSize; x++)
|
||||
{
|
||||
var id = reader.ReadInt32();
|
||||
var flags = reader.ReadByte();
|
||||
var variant = reader.ReadByte();
|
||||
var rotationMirroring = reader.ReadByte();
|
||||
|
||||
var defName = tileMap[id];
|
||||
id = tileDefinitionManager[defName].TileId;
|
||||
var defName = tileMap[id];
|
||||
id = tileDefinitionManager[defName].TileId;
|
||||
|
||||
var tile = new Tile(id, flags, variant);
|
||||
chunk.TrySetTile(x, y, tile, out _, out _);
|
||||
var tile = new Tile(id, flags, variant, rotationMirroring);
|
||||
chunk.TrySetTile(x, y, tile, out _, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (ushort y = 0; y < chunk.ChunkSize; y++)
|
||||
{
|
||||
for (ushort x = 0; x < chunk.ChunkSize; x++)
|
||||
{
|
||||
var id = version < 6 ? reader.ReadUInt16() : reader.ReadInt32();
|
||||
var flags = reader.ReadByte();
|
||||
var variant = reader.ReadByte();
|
||||
|
||||
var defName = tileMap[id];
|
||||
id = tileDefinitionManager[defName].TileId;
|
||||
|
||||
var tile = new Tile(id, flags, variant);
|
||||
chunk.TrySetTile(x, y, tile, out _, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +129,7 @@ internal sealed class MapChunkSerializer : ITypeSerializer<MapChunk, MappingData
|
||||
var gridNode = new ValueDataNode();
|
||||
root.Add("tiles", gridNode);
|
||||
|
||||
root.Add("version", new ValueDataNode("6"));
|
||||
root.Add("version", new ValueDataNode("7"));
|
||||
|
||||
gridNode.Value = SerializeTiles(value, context as EntitySerializer);
|
||||
|
||||
@@ -116,7 +139,7 @@ internal sealed class MapChunkSerializer : ITypeSerializer<MapChunk, MappingData
|
||||
private static string SerializeTiles(MapChunk chunk, EntitySerializer? serializer)
|
||||
{
|
||||
// number of bytes written per tile, because sizeof(Tile) is useless.
|
||||
const int structSize = 6;
|
||||
const int structSize = 7;
|
||||
|
||||
var nTiles = chunk.ChunkSize * chunk.ChunkSize * structSize;
|
||||
var barr = new byte[nTiles];
|
||||
@@ -134,6 +157,7 @@ internal sealed class MapChunkSerializer : ITypeSerializer<MapChunk, MappingData
|
||||
writer.Write(tile.TypeId);
|
||||
writer.Write((byte) tile.Flags);
|
||||
writer.Write(tile.Variant);
|
||||
writer.Write(tile.RotationMirroring);
|
||||
}
|
||||
}
|
||||
return Convert.ToBase64String(barr);
|
||||
@@ -153,6 +177,7 @@ internal sealed class MapChunkSerializer : ITypeSerializer<MapChunk, MappingData
|
||||
writer.Write(yamlId);
|
||||
writer.Write((byte) tile.Flags);
|
||||
writer.Write(tile.Variant);
|
||||
writer.Write(tile.RotationMirroring);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,4 +80,8 @@ public enum LayerRenderingStrategy
|
||||
SnapToCardinals,
|
||||
NoRotation,
|
||||
UseSpriteStrategy
|
||||
// TODO SPRITE
|
||||
// Refactor this make the sprites strategy the actual default.
|
||||
// That way layers have to opt in to having a custom strategy, instead of opt out.
|
||||
// Also rename default to make it clear that its not actually the default, instead I guess its "WithRotation"?
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ namespace Robust.Shared.GameObjects
|
||||
if (!Initialized)
|
||||
return;
|
||||
|
||||
_entMan.System<SharedTransformSystem>().RaiseMoveEvent((Owner, this, meta), _parent, _localPosition, oldRotation, MapUid);
|
||||
_entMan.System<SharedTransformSystem>().RaiseMoveEvent((Owner, this, meta), _parent, _localPosition, oldRotation, MapUid, checkTraversal: false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +47,10 @@ public record struct Entity<T> : IFluentEntityUid, IAsType<EntityUid>
|
||||
comp = Comp;
|
||||
}
|
||||
|
||||
public EntityUid AsType() => Owner;
|
||||
|
||||
public override int GetHashCode() => Owner.GetHashCode();
|
||||
public Entity<T?> AsNullable() => new(Owner, Comp);
|
||||
public EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
[NotYamlSerializable]
|
||||
@@ -118,6 +119,8 @@ public record struct Entity<T1, T2> : IFluentEntityUid, IAsType<EntityUid>
|
||||
return new Entity<T1>(ent.Owner, ent.Comp1);
|
||||
}
|
||||
|
||||
public override int GetHashCode() => Owner.GetHashCode();
|
||||
public Entity<T1?, T2?> AsNullable() => new(Owner, Comp1, Comp2);
|
||||
public EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
@@ -223,6 +226,8 @@ public record struct Entity<T1, T2, T3> : IFluentEntityUid, IAsType<EntityUid>
|
||||
|
||||
#endregion
|
||||
|
||||
public override int GetHashCode() => Owner.GetHashCode();
|
||||
public Entity<T1?, T2?, T3?> AsNullable() => new(Owner, Comp1, Comp2, Comp3);
|
||||
public EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
@@ -352,6 +357,8 @@ public record struct Entity<T1, T2, T3, T4> : IFluentEntityUid, IAsType<EntityUi
|
||||
|
||||
#endregion
|
||||
|
||||
public override int GetHashCode() => Owner.GetHashCode();
|
||||
public Entity<T1?, T2?, T3?, T4?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4);
|
||||
public EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
@@ -505,6 +512,8 @@ public record struct Entity<T1, T2, T3, T4, T5> : IFluentEntityUid, IAsType<Enti
|
||||
|
||||
#endregion
|
||||
|
||||
public override int GetHashCode() => Owner.GetHashCode();
|
||||
public Entity<T1?, T2?, T3?, T4?, T5?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5);
|
||||
public EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
@@ -682,6 +691,8 @@ public record struct Entity<T1, T2, T3, T4, T5, T6> : IFluentEntityUid, IAsType<
|
||||
|
||||
#endregion
|
||||
|
||||
public override int GetHashCode() => Owner.GetHashCode();
|
||||
public Entity<T1?, T2?, T3?, T4?, T5?, T6?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6);
|
||||
public EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
@@ -883,8 +894,9 @@ public record struct Entity<T1, T2, T3, T4, T5, T6, T7> : IFluentEntityUid, IAsT
|
||||
|
||||
#endregion
|
||||
|
||||
public override int GetHashCode() => Owner.GetHashCode();
|
||||
public Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6, Comp7);
|
||||
public EntityUid AsType() => Owner;
|
||||
|
||||
}
|
||||
|
||||
[NotYamlSerializable]
|
||||
@@ -1109,5 +1121,7 @@ public record struct Entity<T1, T2, T3, T4, T5, T6, T7, T8> : IFluentEntityUid,
|
||||
|
||||
#endregion
|
||||
|
||||
public override int GetHashCode() => Owner.GetHashCode();
|
||||
public Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?, T8?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6, Comp7, Comp8);
|
||||
public EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
namespace Robust.Shared.GameObjects;
|
||||
|
||||
public static class EntityExt
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an Entity{T} to Entity{T?}, making it compatible with methods that take nullable components.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The component type. Must implement IComponent.</typeparam>
|
||||
/// <param name="ent">The source entity to convert.</param>
|
||||
/// <returns>An Entity{T?} with the same owner and component as the source entity.</returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// // Instead of:
|
||||
/// Entity{MyComponent?} nullable = (ent, ent.Comp);
|
||||
///
|
||||
/// // You can write:
|
||||
/// Entity{MyComponent?} nullable = ent.AsNullable();
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static Entity<T?> AsNullable<T>(this Entity<T> ent) where T : IComponent
|
||||
{
|
||||
return new(ent.Owner, ent.Comp);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="AsNullable{T}" />
|
||||
public static Entity<T1?, T2?> AsNullable<T1, T2>(this Entity<T1, T2> ent)
|
||||
where T1 : IComponent
|
||||
where T2 : IComponent
|
||||
{
|
||||
return new(ent.Owner, ent.Comp1, ent.Comp2);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="AsNullable{T}" />
|
||||
public static Entity<T1?, T2?, T3?> AsNullable<T1, T2, T3>(this Entity<T1, T2, T3> ent)
|
||||
where T1 : IComponent
|
||||
where T2 : IComponent
|
||||
where T3 : IComponent
|
||||
{
|
||||
return new(ent.Owner, ent.Comp1, ent.Comp2, ent.Comp3);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="AsNullable{T}" />
|
||||
public static Entity<T1?, T2?, T3?, T4?> AsNullable<T1, T2, T3, T4>(this Entity<T1, T2, T3, T4> ent)
|
||||
where T1 : IComponent
|
||||
where T2 : IComponent
|
||||
where T3 : IComponent
|
||||
where T4 : IComponent
|
||||
{
|
||||
return new(ent.Owner, ent.Comp1, ent.Comp2, ent.Comp3, ent.Comp4);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="AsNullable{T}" />
|
||||
public static Entity<T1?, T2?, T3?, T4?, T5?> AsNullable<T1, T2, T3, T4, T5>(this Entity<T1, T2, T3, T4, T5> ent)
|
||||
where T1 : IComponent
|
||||
where T2 : IComponent
|
||||
where T3 : IComponent
|
||||
where T4 : IComponent
|
||||
where T5 : IComponent
|
||||
{
|
||||
return new(ent.Owner, ent.Comp1, ent.Comp2, ent.Comp3, ent.Comp4, ent.Comp5);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="AsNullable{T}" />
|
||||
public static Entity<T1?, T2?, T3?, T4?, T5?, T6?> AsNullable<T1, T2, T3, T4, T5, T6>(
|
||||
this Entity<T1, T2, T3, T4, T5, T6> ent)
|
||||
where T1 : IComponent
|
||||
where T2 : IComponent
|
||||
where T3 : IComponent
|
||||
where T4 : IComponent
|
||||
where T5 : IComponent
|
||||
where T6 : IComponent
|
||||
{
|
||||
return new(ent.Owner, ent.Comp1, ent.Comp2, ent.Comp3, ent.Comp4, ent.Comp5, ent.Comp6);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="AsNullable{T}" />
|
||||
public static Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?> AsNullable<T1, T2, T3, T4, T5, T6, T7>(
|
||||
this Entity<T1, T2, T3, T4, T5, T6, T7> ent)
|
||||
where T1 : IComponent
|
||||
where T2 : IComponent
|
||||
where T3 : IComponent
|
||||
where T4 : IComponent
|
||||
where T5 : IComponent
|
||||
where T6 : IComponent
|
||||
where T7 : IComponent
|
||||
{
|
||||
return new(ent.Owner, ent.Comp1, ent.Comp2, ent.Comp3, ent.Comp4, ent.Comp5, ent.Comp6, ent.Comp7);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="AsNullable{T}" />
|
||||
public static Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?, T8?> AsNullable<T1, T2, T3, T4, T5, T6, T7, T8>(
|
||||
this Entity<T1, T2, T3, T4, T5, T6, T7, T8> ent)
|
||||
where T1 : IComponent
|
||||
where T2 : IComponent
|
||||
where T3 : IComponent
|
||||
where T4 : IComponent
|
||||
where T5 : IComponent
|
||||
where T6 : IComponent
|
||||
where T7 : IComponent
|
||||
where T8 : IComponent
|
||||
{
|
||||
return new(ent.Owner, ent.Comp1, ent.Comp2, ent.Comp3, ent.Comp4, ent.Comp5, ent.Comp6, ent.Comp7, ent.Comp8);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ public sealed class SharedGridTraversalSystem : EntitySystem
|
||||
|
||||
public void CheckTraversal(EntityUid entity, TransformComponent xform, EntityUid map)
|
||||
{
|
||||
// TODO: This should probably check center of mass.
|
||||
DebugTools.Assert(!HasComp<MapGridComponent>(entity));
|
||||
DebugTools.Assert(!HasComp<MapComponent>(entity));
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Contracts;
|
||||
using Robust.Shared.GameStates;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Map;
|
||||
@@ -9,6 +10,7 @@ using Robust.Shared.Network;
|
||||
using Robust.Shared.Physics;
|
||||
using Robust.Shared.Physics.Systems;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Shared.GameObjects
|
||||
{
|
||||
@@ -55,6 +57,27 @@ namespace Robust.Shared.GameObjects
|
||||
SubscribeLocalEvent<MapLightComponent, ComponentGetState>(OnMapLightGetState);
|
||||
SubscribeLocalEvent<MapLightComponent, ComponentHandleState>(OnMapLightHandleState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the specified index to a bitmask with the specified chunksize.
|
||||
/// </summary>
|
||||
[Pure]
|
||||
public static ulong ToBitmask(Vector2i index, byte chunkSize = 8)
|
||||
{
|
||||
DebugTools.Assert(chunkSize <= 8);
|
||||
DebugTools.Assert((index.X + index.Y * chunkSize) < 64);
|
||||
|
||||
return (ulong) 1 << (index.X + index.Y * chunkSize);
|
||||
}
|
||||
|
||||
/// <returns>True if the specified bitflag is set for this index.</returns>
|
||||
[Pure]
|
||||
public static bool FromBitmask(Vector2i index, ulong bitmask, byte chunkSize = 8)
|
||||
{
|
||||
var flag = ToBitmask(index, chunkSize);
|
||||
|
||||
return (flag & bitmask) == flag;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Robust.Shared.GameStates;
|
||||
using Robust.Shared.Maths;
|
||||
|
||||
namespace Robust.Shared.GameObjects;
|
||||
@@ -87,4 +88,21 @@ public abstract class SharedPointLightSystem : EntitySystem
|
||||
comp.Softness = value;
|
||||
Dirty(uid, comp);
|
||||
}
|
||||
|
||||
protected static void OnLightGetState(
|
||||
EntityUid uid,
|
||||
SharedPointLightComponent component,
|
||||
ref ComponentGetState args)
|
||||
{
|
||||
args.State = new PointLightComponentState()
|
||||
{
|
||||
Color = component.Color,
|
||||
Enabled = component.Enabled,
|
||||
Energy = component.Energy,
|
||||
Offset = component.Offset,
|
||||
Radius = component.Radius,
|
||||
Softness = component.Softness,
|
||||
CastShadows = component.CastShadows,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,19 +651,30 @@ public abstract partial class SharedTransformSystem
|
||||
if (newMapId == ent.Comp1.MapID)
|
||||
return;
|
||||
|
||||
EntityUid? newMap = newMapId == MapId.Nullspace ? null : _map.GetMap(newMapId);
|
||||
var mapPaused = _map.IsPaused(newMapId);
|
||||
EntityUid? newUid = newMapId == MapId.Nullspace ? null : _map.GetMap(newMapId);
|
||||
bool? mapPaused = null;
|
||||
|
||||
ChangeMapIdRecursive(ent, newMap, newMapId, mapPaused);
|
||||
// Client may be moving entities across maps due to things leaving or entering PVS range.
|
||||
// In that case, we don't want to pause or unpause entities.
|
||||
if (!_gameTiming.ApplyingState)
|
||||
{
|
||||
mapPaused = _map.IsPaused(newMapId);
|
||||
_metaData.SetEntityPaused(ent.Owner, mapPaused.Value, ent.Comp2);
|
||||
}
|
||||
|
||||
ChangeMapIdRecursive(ent, newUid, newMapId, mapPaused);
|
||||
}
|
||||
|
||||
private void ChangeMapIdRecursive(
|
||||
Entity<TransformComponent, MetaDataComponent> ent,
|
||||
EntityUid? newMap,
|
||||
MapId newMapId,
|
||||
bool paused)
|
||||
bool? paused)
|
||||
{
|
||||
_metaData.SetEntityPaused(ent.Owner, paused, ent.Comp2);
|
||||
if (paused is { } p)
|
||||
{
|
||||
_metaData.SetEntityPaused(ent.Owner, p, ent.Comp2);
|
||||
}
|
||||
|
||||
if ((ent.Comp2.Flags & MetaDataFlags.ExtraTransformEvents) != 0)
|
||||
{
|
||||
@@ -1258,7 +1269,13 @@ public abstract partial class SharedTransformSystem
|
||||
if (!xform.Initialized)
|
||||
return;
|
||||
|
||||
RaiseMoveEvent((uid, xform, meta), oldParent, oldPosition, oldRotation, xform.MapUid);
|
||||
RaiseMoveEvent(
|
||||
(uid, xform, meta),
|
||||
oldParent,
|
||||
oldPosition,
|
||||
oldRotation,
|
||||
xform.MapUid,
|
||||
checkTraversal: !oldPosition.Equals(pos));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -265,7 +265,8 @@ namespace Robust.Shared.GameObjects
|
||||
EntityUid oldParent,
|
||||
Vector2 oldPosition,
|
||||
Angle oldRotation,
|
||||
EntityUid? oldMap)
|
||||
EntityUid? oldMap,
|
||||
bool checkTraversal = true)
|
||||
{
|
||||
var pos = ent.Comp1._parent == EntityUid.Invalid
|
||||
? default
|
||||
@@ -295,7 +296,10 @@ namespace Robust.Shared.GameObjects
|
||||
// Finally, handle grid traversal. This is handled separately to avoid out-of-order move events.
|
||||
// I.e., if the traversal raises its own move event, this ensures that all the old move event handlers
|
||||
// have finished running first. Ideally this shouldn't be required, but this is here just in case
|
||||
_traversal.CheckTraverse(ent);
|
||||
if (checkTraversal)
|
||||
{
|
||||
_traversal.CheckTraverse(ent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ public struct TextureLoadParameters : IEquatable<TextureLoadParameters>
|
||||
/// </summary>
|
||||
public bool Srgb { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If false, this texture should not be preloaded on game startup.
|
||||
/// </summary>
|
||||
public bool Preload { get; set; }
|
||||
|
||||
public static TextureLoadParameters FromYaml(YamlMappingNode yaml)
|
||||
{
|
||||
var loadParams = Default;
|
||||
@@ -34,18 +39,24 @@ public struct TextureLoadParameters : IEquatable<TextureLoadParameters>
|
||||
loadParams.Srgb = srgb.AsBool();
|
||||
}
|
||||
|
||||
if (yaml.TryGetNode("preload", out var preload))
|
||||
{
|
||||
loadParams.Preload = preload.AsBool();
|
||||
}
|
||||
|
||||
return loadParams;
|
||||
}
|
||||
|
||||
public static readonly TextureLoadParameters Default = new()
|
||||
{
|
||||
SampleParameters = TextureSampleParameters.Default,
|
||||
Srgb = true
|
||||
Srgb = true,
|
||||
Preload = true
|
||||
};
|
||||
|
||||
public bool Equals(TextureLoadParameters other)
|
||||
{
|
||||
return SampleParameters.Equals(other.SampleParameters) && Srgb == other.Srgb;
|
||||
return SampleParameters.Equals(other.SampleParameters) && Srgb == other.Srgb && Preload == other.Preload;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.Contracts;
|
||||
using System.Numerics;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.GameStates;
|
||||
@@ -29,7 +30,7 @@ namespace Robust.Shared.Map.Components
|
||||
// the grid section now writes the grid's EntityUID. as long as existing maps get updated (just a load+save),
|
||||
// this can be removed
|
||||
|
||||
[DataField("chunkSize")] internal ushort ChunkSize = 16;
|
||||
[DataField] internal ushort ChunkSize = 16;
|
||||
|
||||
[ViewVariables]
|
||||
public int ChunkCount => Chunks.Count;
|
||||
@@ -261,6 +262,13 @@ namespace Robust.Shared.Map.Components
|
||||
{
|
||||
return MapSystem.TryGetTileRef(Owner, this, coords, out tile);
|
||||
}
|
||||
|
||||
/// <returns>True if the specified chunk exists on this grid.</returns>
|
||||
[Pure]
|
||||
public bool HasChunk(Vector2i indices)
|
||||
{
|
||||
return Chunks.ContainsKey(indices);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -51,6 +51,11 @@ namespace Robust.Shared.Map
|
||||
/// </summary>
|
||||
byte Variants { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Allows the tile to be rotated/mirrored when placed on a grid.
|
||||
/// </summary>
|
||||
bool AllowRotationMirror => false;
|
||||
|
||||
/// <summary>
|
||||
/// Assign a new value to <see cref="TileId"/>, used when registering the tile definition.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Shared.Serialization;
|
||||
using Robust.Shared.Maths;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Shared.Map;
|
||||
@@ -26,6 +27,11 @@ public readonly struct Tile : IEquatable<Tile>, ISpanFormattable
|
||||
/// </summary>
|
||||
public readonly byte Variant;
|
||||
|
||||
/// <summary>
|
||||
/// Rotation and mirroring of this tile to render. 0-3 is normal, 4-7 is mirrored.
|
||||
/// </summary>
|
||||
public readonly byte RotationMirroring;
|
||||
|
||||
/// <summary>
|
||||
/// An empty tile that can be compared against.
|
||||
/// </summary>
|
||||
@@ -42,11 +48,33 @@ public readonly struct Tile : IEquatable<Tile>, ISpanFormattable
|
||||
/// <param name="typeId">Internal type ID.</param>
|
||||
/// <param name="flags">Custom tile flags for usage.</param>
|
||||
/// <param name="variant">The visual variant this tile is using.</param>
|
||||
public Tile(int typeId, byte flags = 0, byte variant = 0)
|
||||
/// <param name="rotationMirroring">The rotation and mirroring this tile is using.</param>
|
||||
public Tile(int typeId, byte flags = 0, byte variant = 0, byte rotationMirroring = 0)
|
||||
{
|
||||
TypeId = typeId;
|
||||
Flags = flags;
|
||||
Variant = variant;
|
||||
RotationMirroring = rotationMirroring;
|
||||
}
|
||||
|
||||
public static byte DirectionToByte(Direction direction, bool throwIfDiagonal = false)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case Direction.South:
|
||||
return 0;
|
||||
case Direction.East:
|
||||
return 1;
|
||||
case Direction.North:
|
||||
return 2;
|
||||
case Direction.West:
|
||||
return 3;
|
||||
default:
|
||||
if (throwIfDiagonal)
|
||||
throw new InvalidEnumArgumentException("direction", (int)direction, typeof(Direction));
|
||||
else
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -71,7 +99,7 @@ public readonly struct Tile : IEquatable<Tile>, ISpanFormattable
|
||||
/// <returns>String representation of this Tile.</returns>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"Tile {TypeId}, {Flags}, {Variant}";
|
||||
return $"Tile {TypeId}, {Flags}, {Variant}, {RotationMirroring}";
|
||||
}
|
||||
|
||||
public string ToString(string? format, IFormatProvider? formatProvider)
|
||||
@@ -94,7 +122,7 @@ public readonly struct Tile : IEquatable<Tile>, ISpanFormattable
|
||||
/// <inheritdoc />
|
||||
public bool Equals(Tile other)
|
||||
{
|
||||
return TypeId == other.TypeId && Flags == other.Flags && Variant == other.Variant;
|
||||
return TypeId == other.TypeId && Flags == other.Flags && Variant == other.Variant && RotationMirroring == other.RotationMirroring;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -110,7 +138,7 @@ public readonly struct Tile : IEquatable<Tile>, ISpanFormattable
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
return (TypeId.GetHashCode() * 397) ^ Flags.GetHashCode() ^ Variant.GetHashCode();
|
||||
return (TypeId.GetHashCode() * 397) ^ Flags.GetHashCode() ^ Variant.GetHashCode() ^ RotationMirroring.GetHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ namespace Robust.Shared.Network.Messages
|
||||
public string AlignOption { get; set; }
|
||||
public Vector2 RectSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Used to determine tile sprite mirroring
|
||||
/// </summary>
|
||||
public bool Mirrored { get; set; }
|
||||
|
||||
public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
|
||||
{
|
||||
PlaceType = (PlacementManagerMessage) buffer.ReadByte();
|
||||
@@ -49,6 +54,7 @@ namespace Robust.Shared.Network.Messages
|
||||
|
||||
NetCoordinates = buffer.ReadNetCoordinates();
|
||||
DirRcv = (Direction)buffer.ReadByte();
|
||||
Mirrored = buffer.ReadBoolean();
|
||||
break;
|
||||
case PlacementManagerMessage.StartPlacement:
|
||||
Range = buffer.ReadInt32();
|
||||
@@ -84,6 +90,7 @@ namespace Robust.Shared.Network.Messages
|
||||
|
||||
buffer.Write(NetCoordinates);
|
||||
buffer.Write((byte)DirRcv);
|
||||
buffer.Write(Mirrored);
|
||||
break;
|
||||
case PlacementManagerMessage.StartPlacement:
|
||||
buffer.Write(Range);
|
||||
|
||||
@@ -24,7 +24,9 @@ namespace Robust.Shared.Physics.Systems
|
||||
[Dependency] private readonly EntityLookupSystem _lookup = default!;
|
||||
[Dependency] private readonly SharedBroadphaseSystem _broadphase = default!;
|
||||
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
|
||||
#pragma warning disable CS0414
|
||||
[Dependency] private readonly IGameTiming _timing = default!;
|
||||
#pragma warning restore CS0414
|
||||
private EntityQuery<PhysicsMapComponent> _mapQuery;
|
||||
private EntityQuery<PhysicsComponent> _physicsQuery;
|
||||
private EntityQuery<FixturesComponent> _fixtureQuery;
|
||||
|
||||
@@ -350,10 +350,10 @@ public abstract partial class SharedPhysicsSystem
|
||||
|
||||
if (contact.Manifold.PointCount > 0 && contact.FixtureA?.Hard == true && contact.FixtureB?.Hard == true)
|
||||
{
|
||||
if (bodyA.CanCollide)
|
||||
if (bodyA.CanCollide && !TerminatingOrDeleted(aUid))
|
||||
SetAwake((aUid, bodyA), true);
|
||||
|
||||
if (bodyB.CanCollide)
|
||||
if (bodyB.CanCollide && !TerminatingOrDeleted(bUid))
|
||||
SetAwake((bUid, bodyB), true);
|
||||
}
|
||||
|
||||
@@ -621,24 +621,6 @@ public abstract partial class SharedPhysicsSystem
|
||||
ArrayPool<Vector2>.Shared.Return(worldPoints);
|
||||
}
|
||||
|
||||
private record struct UpdateTreesJob : IRobustJob
|
||||
{
|
||||
public IEntityManager EntManager;
|
||||
|
||||
public void Execute()
|
||||
{
|
||||
var query = EntManager.AllEntityQueryEnumerator<BroadphaseComponent>();
|
||||
|
||||
while (query.MoveNext(out var broadphase))
|
||||
{
|
||||
broadphase.DynamicTree.Rebuild(false);
|
||||
broadphase.StaticTree.Rebuild(false);
|
||||
broadphase.SundriesTree._b2Tree.Rebuild(false);
|
||||
broadphase.StaticSundriesTree._b2Tree.Rebuild(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildManifolds(Contact[] contacts, int count, ContactStatus[] status, Vector2[] worldPoints)
|
||||
{
|
||||
if (count == 0)
|
||||
|
||||
@@ -21,8 +21,10 @@ namespace Robust.Shared.Physics.Systems
|
||||
*/
|
||||
public partial class SharedPhysicsSystem
|
||||
{
|
||||
#pragma warning disable CS0414
|
||||
[Dependency] private readonly SharedDebugRayDrawingSystem _sharedDebugRaySystem = default!;
|
||||
[Dependency] private readonly INetManager _netMan = default!;
|
||||
#pragma warning restore CS0414
|
||||
|
||||
/// <summary>
|
||||
/// Checks to see if the specified collision rectangle collides with any of the physBodies under management.
|
||||
|
||||
@@ -306,6 +306,11 @@ public interface IPrototypeManager
|
||||
/// </summary>
|
||||
void RegisterIgnore(string name);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given gind name has been marked as ignored via <see cref="RegisterIgnore"/>
|
||||
/// </summary>
|
||||
bool IsIgnored(string name);
|
||||
|
||||
/// <summary>
|
||||
/// Loads several prototype kinds into the manager. Note that this will re-build a frozen dictionary and should be avoided if possible.
|
||||
/// </summary>
|
||||
|
||||
@@ -920,6 +920,8 @@ namespace Robust.Shared.Prototypes
|
||||
return TryGetKindFrom(typeof(T), out kind);
|
||||
}
|
||||
|
||||
public bool IsIgnored(string name) => _ignoredPrototypeTypes.Contains(name);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RegisterIgnore(string name)
|
||||
{
|
||||
@@ -973,6 +975,17 @@ namespace Robust.Shared.Prototypes
|
||||
|
||||
var name = attribute.Type ?? CalculatePrototypeName(kind);
|
||||
|
||||
if (_ignoredPrototypeTypes.Contains(name))
|
||||
{
|
||||
// For whatever reason, we are registering a prototype despite it having been marked as ignored.
|
||||
// This often happens when someone is moving a server or client prototype to shared. Maybe this should
|
||||
// log an error, but I want to avoid breaking changes and maaaaybe there some weird instance where you
|
||||
// want the client to know that a prototype kind exists, without having the client load information
|
||||
// about the individual prototypes? So for now lets just log a warning instead of introducing breaking
|
||||
// changes.
|
||||
Sawmill.Warning($"Registering an ignored prototype {kind}");
|
||||
}
|
||||
|
||||
if (_kindNames.TryGetValue(name, out var existing))
|
||||
{
|
||||
throw new InvalidImplementationException(kind,
|
||||
|
||||
@@ -3,6 +3,9 @@ using JetBrains.Annotations;
|
||||
|
||||
namespace Robust.Shared.Serialization.Manager.Attributes
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a <see cref="Robust.Shared.Serialization.TypeSerializers.Interfaces.ITypeSerializer"/> as a default serializer for its type
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
|
||||
[MeansImplicitUse]
|
||||
public sealed class TypeSerializerAttribute : Attribute
|
||||
|
||||
@@ -3,6 +3,7 @@ using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization.Manager;
|
||||
using Robust.Shared.Serialization.Markdown.Validation;
|
||||
using Robust.Shared.Serialization.Markdown.Value;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Generic;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Interfaces;
|
||||
|
||||
namespace Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype
|
||||
@@ -30,10 +31,7 @@ namespace Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Pro
|
||||
public virtual ValidationNode Validate(ISerializationManager serializationManager, ValueDataNode node,
|
||||
IDependencyCollection dependencies, ISerializationContext? context = null)
|
||||
{
|
||||
var protoMan = dependencies.Resolve<IPrototypeManager>();
|
||||
return protoMan.TryGetKindFrom<TPrototype>(out _) && protoMan.HasIndex<TPrototype>(node.Value)
|
||||
? new ValidatedValueNode(node)
|
||||
: new ErrorNode(node, $"PrototypeID {node.Value} for type {typeof(TPrototype)} at {node.Start} not found");
|
||||
return ProtoIdSerializer<TPrototype>.Validate(dependencies, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,19 @@ public sealed class ProtoIdSerializer<T> : ITypeSerializer<ProtoId<T>, ValueData
|
||||
{
|
||||
public ValidationNode Validate(ISerializationManager serialization, ValueDataNode node, IDependencyCollection dependencies, ISerializationContext? context = null)
|
||||
{
|
||||
var prototypes = dependencies.Resolve<IPrototypeManager>();
|
||||
if (prototypes.TryGetKindFrom<T>(out _) && prototypes.HasMapping<T>(node.Value))
|
||||
return Validate(dependencies, node);
|
||||
}
|
||||
|
||||
public static ValidationNode Validate(IDependencyCollection deps, ValueDataNode node)
|
||||
{
|
||||
var proto = deps.Resolve<IPrototypeManager>();
|
||||
if (!proto.TryGetKindFrom<T>(out var kind))
|
||||
return new ErrorNode(node, $"Unknown prototype kind: {typeof(T)}");
|
||||
|
||||
if (proto.IsIgnored(kind))
|
||||
return new ErrorNode(node,$"Attempting to validate an ignored prototype: {typeof(T)}.\nDid you forget to remove the IPrototypeManager.RegisterIgnore(\"{kind}\") call when moving a prototype to Shared?");
|
||||
|
||||
if (proto.HasMapping<T>(node.Value))
|
||||
return new ValidatedValueNode(node);
|
||||
|
||||
return new ErrorNode(node, $"No {typeof(T)} found with id {node.Value}");
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace Robust.Shared.Serialization.TypeSerializers.Implementations
|
||||
ITypeCopier<Rsi>,
|
||||
ITypeCopier<Texture>
|
||||
{
|
||||
// Should probably be in SpriteComponent, but is needed for server to validate paths.
|
||||
// Should probably be in SpriteSystem, but is needed for server to validate paths.
|
||||
// So I guess it might as well go here?
|
||||
public static readonly ResPath TextureRoot = new("/Textures");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Shared.IoC;
|
||||
@@ -9,42 +10,98 @@ using Robust.Shared.Serialization.Markdown.Validation;
|
||||
using Robust.Shared.Serialization.Markdown.Value;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Interfaces;
|
||||
|
||||
namespace Robust.Shared.Serialization.TypeSerializers.Implementations
|
||||
namespace Robust.Shared.Serialization.TypeSerializers.Implementations;
|
||||
|
||||
[TypeSerializer]
|
||||
public sealed class TimespanSerializer : ITypeSerializer<TimeSpan, ValueDataNode>, ITypeCopyCreator<TimeSpan>
|
||||
{
|
||||
[TypeSerializer]
|
||||
public sealed class TimespanSerializer : ITypeSerializer<TimeSpan, ValueDataNode>, ITypeCopyCreator<TimeSpan>
|
||||
public TimeSpan Read(
|
||||
ISerializationManager serializationManager,
|
||||
ValueDataNode node,
|
||||
IDependencyCollection dependencies,
|
||||
SerializationHookContext hookCtx,
|
||||
ISerializationContext? context = null,
|
||||
ISerializationManager.InstantiationDelegate<TimeSpan>? instanceProvider = null)
|
||||
{
|
||||
public TimeSpan Read(ISerializationManager serializationManager, ValueDataNode node,
|
||||
IDependencyCollection dependencies,
|
||||
SerializationHookContext hookCtx,
|
||||
ISerializationContext? context = null,
|
||||
ISerializationManager.InstantiationDelegate<TimeSpan>? instanceProvider = null)
|
||||
if (TryTimeSpan(node, out var time))
|
||||
return time.Value;
|
||||
|
||||
var seconds = double.Parse(node.Value, CultureInfo.InvariantCulture);
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
}
|
||||
|
||||
public ValidationNode Validate(
|
||||
ISerializationManager serializationManager,
|
||||
ValueDataNode node,
|
||||
IDependencyCollection dependencies,
|
||||
ISerializationContext? context = null)
|
||||
{
|
||||
return TryTimeSpan(node, out _)
|
||||
|| double.TryParse(node.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out _)
|
||||
? new ValidatedValueNode(node)
|
||||
: new ErrorNode(node, "Failed parsing TimeSpan");
|
||||
}
|
||||
|
||||
public DataNode Write(
|
||||
ISerializationManager serializationManager,
|
||||
TimeSpan value,
|
||||
IDependencyCollection dependencies,
|
||||
bool alwaysWrite = false,
|
||||
ISerializationContext? context = null)
|
||||
{
|
||||
return new ValueDataNode(value.TotalSeconds.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
[MustUseReturnValue]
|
||||
public TimeSpan CreateCopy(
|
||||
ISerializationManager serializationManager,
|
||||
TimeSpan source,
|
||||
IDependencyCollection dependencies,
|
||||
SerializationHookContext hookCtx,
|
||||
ISerializationContext? context = null)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert strings from the compatible format (or just numbers) into TimeSpan and output it. Returns true if successful.
|
||||
/// The string must start with a number and end with a single letter referring to the time unit used.
|
||||
/// It can NOT combine multiple types (like "1h30m"), but it CAN use decimals ("1.5h")
|
||||
/// </summary>
|
||||
private bool TryTimeSpan(ValueDataNode node, [NotNullWhen(true)] out TimeSpan? timeSpan)
|
||||
{
|
||||
timeSpan = null;
|
||||
|
||||
// A lot of the checks will be for plain numbers, so might as well rule them out right away, instead of
|
||||
// running all the other checks on them. They will need to get parsed later anyway, if they weren't now.
|
||||
if (double.TryParse(node.Value, out var v))
|
||||
{
|
||||
var seconds = double.Parse(node.Value, CultureInfo.InvariantCulture);
|
||||
return TimeSpan.FromSeconds(seconds);
|
||||
timeSpan = TimeSpan.FromSeconds(v);
|
||||
return true;
|
||||
}
|
||||
|
||||
public ValidationNode Validate(ISerializationManager serializationManager, ValueDataNode node,
|
||||
IDependencyCollection dependencies,
|
||||
ISerializationContext? context = null)
|
||||
{
|
||||
return double.TryParse(node.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out _)
|
||||
? new ValidatedValueNode(node)
|
||||
: new ErrorNode(node, "Failed parsing TimeSpan");
|
||||
}
|
||||
// If there aren't even enough characters for a number and a time unit, exit
|
||||
if (node.Value.Length <= 1)
|
||||
return false;
|
||||
|
||||
public DataNode Write(ISerializationManager serializationManager, TimeSpan value,
|
||||
IDependencyCollection dependencies, bool alwaysWrite = false,
|
||||
ISerializationContext? context = null)
|
||||
{
|
||||
return new ValueDataNode(value.TotalSeconds.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
// If the input without the last character is still not a valid number, exit
|
||||
if (!double.TryParse(node.Value.AsSpan()[..^1], out var number))
|
||||
return false;
|
||||
|
||||
[MustUseReturnValue]
|
||||
public TimeSpan CreateCopy(ISerializationManager serializationManager, TimeSpan source,
|
||||
IDependencyCollection dependencies, SerializationHookContext hookCtx, ISerializationContext? context = null)
|
||||
// Check the last character of the input for time unit indicators
|
||||
switch (node.Value[^1])
|
||||
{
|
||||
return source;
|
||||
case 's':
|
||||
timeSpan = TimeSpan.FromSeconds(number);
|
||||
return true;
|
||||
case 'm':
|
||||
timeSpan = TimeSpan.FromMinutes(number);
|
||||
return true;
|
||||
case 'h':
|
||||
timeSpan = TimeSpan.FromHours(number);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace Robust.Shared.Serialization.TypeSerializers.Interfaces
|
||||
{
|
||||
public interface ITypeReader<TType, TNode> : ITypeValidator<TType, TNode> where TNode : DataNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Method to read <see cref="TType"/> from <see cref="TNode"/>. When throwing errors try to include the line number from node.Start
|
||||
/// </summary>
|
||||
TType Read(ISerializationManager serializationManager,
|
||||
TNode node,
|
||||
IDependencyCollection dependencies,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
using Robust.Shared.Serialization.Markdown;
|
||||
|
||||
namespace Robust.Shared.Serialization.TypeSerializers.Interfaces
|
||||
{
|
||||
public interface ITypeReaderWriter<TType, TNode> :
|
||||
ITypeReader<TType, TNode>,
|
||||
ITypeWriter<TType>
|
||||
where TNode : DataNode
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
using Robust.Shared.Serialization.Markdown;
|
||||
|
||||
namespace Robust.Shared.Serialization.TypeSerializers.Interfaces
|
||||
{
|
||||
public interface ITypeSerializer<TType, TNode> :
|
||||
ITypeReaderWriter<TType, TNode>
|
||||
where TNode : DataNode
|
||||
{
|
||||
}
|
||||
}
|
||||
namespace Robust.Shared.Serialization.TypeSerializers.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// A serializer for a given type that supports reading and writing. Can be used as a default serializer or a custom serializer on a datafield.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Types that implement this may be annotated with <see cref="Robust.Shared.Serialization.Manager.Attributes.TypeSerializerAttribute"/>
|
||||
/// to register it as the default serializer for a type
|
||||
/// </remarks>
|
||||
/// <typeparam name="TType">The type that we want to serialize to/from</typeparam>
|
||||
/// <typeparam name="TNode">The YAML node that can represent the type</typeparam>
|
||||
public interface ITypeSerializer<TType, TNode> :
|
||||
ITypeReader<TType, TNode>,
|
||||
ITypeWriter<TType>
|
||||
where TNode : DataNode;
|
||||
|
||||
@@ -8,6 +8,9 @@ namespace Robust.Shared.Serialization.TypeSerializers.Interfaces
|
||||
{
|
||||
public interface ITypeValidator<[UsedImplicitly]TType, TNode> : BaseSerializerInterfaces.ITypeNodeInterface<TType, TNode> where TNode : DataNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Preform computationally expensive validation of the value. Only called from the Yaml linter.
|
||||
/// </summary>
|
||||
ValidationNode Validate(
|
||||
ISerializationManager serializationManager,
|
||||
TNode node,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Serialization.Manager;
|
||||
using Robust.Shared.Serialization.Markdown;
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace Robust.Shared.Serialization.TypeSerializers.Interfaces
|
||||
{
|
||||
public interface ITypeWriter<TType> : BaseSerializerInterfaces.ITypeInterface<TType>
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts value into its DataNode representation.
|
||||
/// </summary>
|
||||
DataNode Write(ISerializationManager serializationManager, TType value, IDependencyCollection dependencies,
|
||||
bool alwaysWrite = false,
|
||||
ISerializationContext? context = null);
|
||||
|
||||
132
Robust.UnitTesting/Client/Sprite/SpriteBoundsTest.cs
Normal file
132
Robust.UnitTesting/Client/Sprite/SpriteBoundsTest.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System.Numerics;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Robust.Client;
|
||||
using Robust.Client.GameObjects;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Maths;
|
||||
|
||||
namespace Robust.UnitTesting.Client.Sprite;
|
||||
|
||||
public sealed class SpriteBoundsTest : RobustIntegrationTest
|
||||
{
|
||||
private static readonly string Prototypes = @"
|
||||
- type: entity
|
||||
id: spriteBoundsTest1
|
||||
components:
|
||||
- type: Sprite
|
||||
sprite: debugRotation.rsi
|
||||
layers:
|
||||
- state: direction1
|
||||
|
||||
- type: entity
|
||||
id: spriteBoundsTest2
|
||||
components:
|
||||
- type: Sprite
|
||||
sprite: debugRotation.rsi
|
||||
layers:
|
||||
- state: direction1
|
||||
- state: direction1
|
||||
rotation: 0.1
|
||||
|
||||
- type: entity
|
||||
id: spriteBoundsTest3
|
||||
components:
|
||||
- type: Sprite
|
||||
sprite: debugRotation.rsi
|
||||
layers:
|
||||
- state: direction1
|
||||
- state: direction1
|
||||
rotation: 0.1
|
||||
visible: false
|
||||
";
|
||||
|
||||
[Test]
|
||||
public async Task TestSpriteBounds()
|
||||
{
|
||||
var client = StartClient(new ClientIntegrationOptions {ExtraPrototypes = Prototypes});
|
||||
await client.WaitIdleAsync();
|
||||
var baseClient = client.Resolve<IBaseClient>();
|
||||
|
||||
await client.WaitPost(() => baseClient.StartSinglePlayer());
|
||||
await client.WaitIdleAsync();
|
||||
|
||||
var entMan = client.EntMan;
|
||||
var sys = client.System<SpriteSystem>();
|
||||
|
||||
EntityUid uid1 = default;
|
||||
EntityUid uid2 = default;
|
||||
EntityUid uid3 = default;
|
||||
|
||||
await client.WaitPost(() =>
|
||||
{
|
||||
uid1 = entMan.Spawn("spriteBoundsTest1");
|
||||
uid2 = entMan.Spawn("spriteBoundsTest2");
|
||||
uid3 = entMan.Spawn("spriteBoundsTest3");
|
||||
});
|
||||
|
||||
var ent1 = new Entity<SpriteComponent>(uid1, entMan.GetComponent<SpriteComponent>(uid1));
|
||||
var ent2 = new Entity<SpriteComponent>(uid2, entMan.GetComponent<SpriteComponent>(uid2));
|
||||
var ent3 = new Entity<SpriteComponent>(uid3, entMan.GetComponent<SpriteComponent>(uid3));
|
||||
|
||||
// None of the entities have empty bounding boxes
|
||||
var box1 = sys.GetLocalBounds(ent1);
|
||||
var box2 = sys.GetLocalBounds(ent2);
|
||||
var box3 = sys.GetLocalBounds(ent3);
|
||||
Assert.That(!box1.EqualsApprox(Box2.Empty));
|
||||
Assert.That(!box2.EqualsApprox(Box2.Empty));
|
||||
Assert.That(!box3.EqualsApprox(Box2.Empty));
|
||||
|
||||
// ent2 should have a larger bb than ent1 as it has a visible rotated layer
|
||||
// ents 1 & 3 should have the same bounds, as the rotated layer is invisible in ent3
|
||||
Assert.That(box1.EqualsApprox(box3));
|
||||
Assert.That(!box1.EqualsApprox(box2));
|
||||
Assert.That(box2.EqualsApprox(ent2.Comp.Layers[1].Bounds));
|
||||
Assert.That(box2.Size.X, Is.GreaterThan(box1.Size.X));
|
||||
Assert.That(box2.Size.Y, Is.GreaterThan(box1.Size.Y));
|
||||
|
||||
// Toggling layer visibility updates the bounds
|
||||
sys.LayerSetVisible(ent2!, 1, false);
|
||||
sys.LayerSetVisible(ent3!, 1, true);
|
||||
|
||||
var newBox2 = sys.GetLocalBounds(ent2);
|
||||
var newBox3 = sys.GetLocalBounds(ent3);
|
||||
Assert.That(!newBox2.EqualsApprox(Box2.Empty));
|
||||
Assert.That(!newBox3.EqualsApprox(Box2.Empty));
|
||||
|
||||
Assert.That(box1.EqualsApprox(newBox2));
|
||||
Assert.That(!box1.EqualsApprox(newBox3));
|
||||
Assert.That(newBox3.EqualsApprox(ent3.Comp.Layers[1].Bounds));
|
||||
Assert.That(newBox3.Size.X, Is.GreaterThan(box1.Size.X));
|
||||
Assert.That(newBox3.Size.Y, Is.GreaterThan(box1.Size.Y));
|
||||
|
||||
// Changing the rotation, offset, scale, all trigger a bounds updatge
|
||||
sys.LayerSetRotation(ent3!, 1, Angle.Zero);
|
||||
var box = sys.GetLocalBounds(ent3);
|
||||
Assert.That(box1.EqualsApprox(box));
|
||||
|
||||
// scale
|
||||
sys.LayerSetScale(ent3!, 1, Vector2.One * 2);
|
||||
box = sys.GetLocalBounds(ent3);
|
||||
Assert.That(box1.EqualsApprox(box.Scale(0.5f)));
|
||||
sys.LayerSetScale(ent3!, 1, Vector2.One);
|
||||
box = sys.GetLocalBounds(ent3);
|
||||
Assert.That(box1.EqualsApprox(box));
|
||||
|
||||
// offset
|
||||
Assert.That(box.Center, Is.Approximately(Vector2.Zero));
|
||||
sys.LayerSetOffset(ent3!, 1, Vector2.One);
|
||||
box = sys.GetLocalBounds(ent3);
|
||||
Assert.That(box.Size.X, Is.GreaterThan(box1.Size.X));
|
||||
Assert.That(box.Size.Y, Is.GreaterThan(box1.Size.Y));
|
||||
Assert.That(box.Center.X, Is.GreaterThan(0));
|
||||
Assert.That(box.Center.Y, Is.GreaterThan(0));
|
||||
|
||||
await client.WaitPost(() =>
|
||||
{
|
||||
entMan.DeleteEntity(uid1);
|
||||
entMan.DeleteEntity(uid2);
|
||||
entMan.DeleteEntity(uid3);
|
||||
});
|
||||
}
|
||||
}
|
||||
118
Robust.UnitTesting/IIntegrationInstance.cs
Normal file
118
Robust.UnitTesting/IIntegrationInstance.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Diagnostics.Contracts;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Log;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Player;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Timing;
|
||||
|
||||
namespace Robust.UnitTesting;
|
||||
|
||||
public interface IIntegrationInstance : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the instance is still alive.
|
||||
/// "Alive" indicates that it is able to receive and process commands.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown if you did not ensure that the instance is idle via <see cref="WaitIdleAsync"/> first.
|
||||
/// </exception>
|
||||
bool IsAlive { get; }
|
||||
|
||||
Exception? UnhandledException { get; }
|
||||
|
||||
EntityManager EntMan { get; }
|
||||
IPrototypeManager ProtoMan { get; }
|
||||
IConfigurationManager CfgMan { get; }
|
||||
ISharedPlayerManager PlayerMan { get; }
|
||||
INetManager NetMan { get; }
|
||||
IMapManager MapMan { get; }
|
||||
IGameTiming Timing { get; }
|
||||
ISawmill Log { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a dependency inside the instance.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown if you did not ensure that the instance is idle via <see cref="WaitIdleAsync"/> first.
|
||||
/// </exception>
|
||||
[Pure] T Resolve<T>();
|
||||
|
||||
[Pure] T System<T>() where T : IEntitySystem;
|
||||
|
||||
TransformComponent Transform(EntityUid uid);
|
||||
MetaDataComponent MetaData(EntityUid uid);
|
||||
MetaDataComponent MetaData(NetEntity uid);
|
||||
TransformComponent Transform(NetEntity uid);
|
||||
|
||||
Task ExecuteCommand(string cmd);
|
||||
|
||||
/// <summary>
|
||||
/// Wait for the instance to go idle, either through finishing all commands or shutting down/crashing.
|
||||
/// </summary>
|
||||
/// <param name="throwOnUnhandled">
|
||||
/// If true, throw an exception if the server dies on an unhandled exception.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <exception cref="Exception">
|
||||
/// Thrown if <paramref name="throwOnUnhandled"/> is true and the instance shuts down on an unhandled exception.
|
||||
/// </exception>
|
||||
Task WaitIdleAsync(bool throwOnUnhandled = true, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queue for the server to run n ticks.
|
||||
/// </summary>
|
||||
/// <param name="ticks">The amount of ticks to run.</param>
|
||||
void RunTicks(int ticks);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="RunTicks"/> followed by <see cref="WaitIdleAsync"/>
|
||||
/// </summary>
|
||||
Task WaitRunTicks(int ticks);
|
||||
|
||||
/// <summary>
|
||||
/// Queue for a delegate to be ran inside the main loop of the instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Do not run NUnit assertions inside <see cref="Post"/>. Use <see cref="Assert"/> instead.
|
||||
/// </remarks>
|
||||
void Post(Action post);
|
||||
|
||||
/// <inheritdoc cref="Post"/>
|
||||
Task WaitPost(Action post);
|
||||
|
||||
/// <summary>
|
||||
/// Queue for a delegate to be ran inside the main loop of the instance,
|
||||
/// rethrowing any exceptions in <see cref="WaitIdleAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exceptions raised inside this callback will be rethrown by <see cref="WaitIdleAsync"/>.
|
||||
/// This makes it ideal for NUnit assertions,
|
||||
/// since rethrowing the NUnit assertion directly provides less noise.
|
||||
/// </remarks>
|
||||
void Assert(Action assertion);
|
||||
|
||||
/// <inheritdoc cref="Assert"/>
|
||||
Task WaitAssertion(Action assertion);
|
||||
|
||||
/// <summary>
|
||||
/// Post-test cleanup
|
||||
/// </summary>
|
||||
Task Cleanup();
|
||||
}
|
||||
|
||||
public interface IClientIntegrationInstance : IIntegrationInstance
|
||||
{
|
||||
IClientNetManager CNetMan => (IClientNetManager) NetMan;
|
||||
ICommonSession? Session { get; }
|
||||
NetUserId? User { get; }
|
||||
EntityUid? AttachedEntity { get; }
|
||||
Task Connect(IServerIntegrationInstance target);
|
||||
}
|
||||
|
||||
public interface IServerIntegrationInstance : IIntegrationInstance;
|
||||
39
Robust.UnitTesting/Pool/ITestPair.cs
Normal file
39
Robust.UnitTesting/Pool/ITestPair.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Robust.Shared.Timing;
|
||||
|
||||
namespace Robust.UnitTesting.Pool;
|
||||
|
||||
public interface ITestPair
|
||||
{
|
||||
int Id { get; }
|
||||
public Stopwatch Watch { get; }
|
||||
public PairState State { get; }
|
||||
public bool Initialized { get; }
|
||||
void Kill();
|
||||
List<string> TestHistory { get; }
|
||||
PairSettings Settings { get; set; }
|
||||
|
||||
int ServerSeed { get; }
|
||||
int ClientSeed { get; }
|
||||
|
||||
void ActivateContext(TextWriter testOut);
|
||||
void ValidateSettings(PairSettings settings);
|
||||
void SetupSeed();
|
||||
void ClearModifiedCvars();
|
||||
void Use();
|
||||
Task Init(int id, BasePoolManager manager, PairSettings settings, TextWriter testOut);
|
||||
Task RecycleInternal(PairSettings next, TextWriter testOut);
|
||||
Task ApplySettings(PairSettings settings);
|
||||
Task RunTicksSync(int ticks);
|
||||
Task SyncTicks(int targetDelta = 1);
|
||||
}
|
||||
|
||||
public enum PairState : byte
|
||||
{
|
||||
Ready = 0,
|
||||
InUse = 1,
|
||||
CleanDisposed = 2,
|
||||
Dead = 3,
|
||||
}
|
||||
95
Robust.UnitTesting/Pool/PairSettings.cs
Normal file
95
Robust.UnitTesting/Pool/PairSettings.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Random;
|
||||
|
||||
namespace Robust.UnitTesting.Pool;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for a server-client pair. These settings may change over a pair's lifetime.
|
||||
/// The pool manager handles fetching pairs with a given setting, including applying new settings to re-used pairs.
|
||||
/// </summary>
|
||||
[Virtual]
|
||||
public class PairSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Set to true if the test will ruin the server/client pair.
|
||||
/// </summary>
|
||||
public virtual bool Destructive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Set to true if the given server/client pair should be created fresh.
|
||||
/// </summary>
|
||||
public virtual bool Fresh { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Set to true if the given server/client pair should be connected from each other.
|
||||
/// Defaults to disconnected as it makes dirty recycling slightly faster.
|
||||
/// </summary>
|
||||
public virtual bool Connected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// This will return a server-client pair that has not loaded test prototypes.
|
||||
/// Try avoiding this whenever possible, as this will always create & destroy a new pair.
|
||||
/// Use <see cref="RobustTestPair.IsTestPrototype(EntityPrototype)"/> if you need to
|
||||
/// exclude test prototypes.
|
||||
/// </summary>
|
||||
public virtual bool NoLoadTestPrototypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Set this to true to disable the NetInterp CVar on the given server/client pair
|
||||
/// </summary>
|
||||
public virtual bool DisableInterpolate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Set this to true to always clean up the server/client pair before giving it to another borrower
|
||||
/// </summary>
|
||||
public virtual bool Dirty { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the test name detection, and uses this in the test history instead
|
||||
/// </summary>
|
||||
public virtual string? TestName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If set, this will be used to call <see cref="IRobustRandom.SetSeed"/>
|
||||
/// </summary>
|
||||
public virtual int? ServerSeed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If set, this will be used to call <see cref="IRobustRandom.SetSeed"/>
|
||||
/// </summary>
|
||||
public virtual int? ClientSeed { get; set; }
|
||||
|
||||
#region Inferred Properties
|
||||
|
||||
/// <summary>
|
||||
/// If the returned pair must not be reused
|
||||
/// </summary>
|
||||
public virtual bool MustNotBeReused => Destructive || NoLoadTestPrototypes;
|
||||
|
||||
/// <summary>
|
||||
/// If the given pair must be brand new
|
||||
/// </summary>
|
||||
public virtual bool MustBeNew => Fresh || NoLoadTestPrototypes;
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Tries to guess if we can skip recycling the server/client pair.
|
||||
/// </summary>
|
||||
/// <param name="nextSettings">The next set of settings the old pair will be set to</param>
|
||||
/// <returns>If we can skip cleaning it up</returns>
|
||||
public virtual bool CanFastRecycle(PairSettings nextSettings)
|
||||
{
|
||||
if (MustNotBeReused)
|
||||
throw new InvalidOperationException("Attempting to recycle a non-reusable test.");
|
||||
|
||||
if (nextSettings.MustBeNew)
|
||||
throw new InvalidOperationException("Attempting to recycle a test while requesting a fresh test.");
|
||||
|
||||
if (Dirty)
|
||||
return false;
|
||||
|
||||
return Connected == nextSettings.Connected;
|
||||
}
|
||||
}
|
||||
332
Robust.UnitTesting/Pool/PoolManager.cs
Normal file
332
Robust.UnitTesting/Pool/PoolManager.cs
Normal file
@@ -0,0 +1,332 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Robust.Shared;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.UnitTesting.Pool;
|
||||
|
||||
public abstract class BasePoolManager
|
||||
{
|
||||
internal abstract void Return(ITestPair pair);
|
||||
public abstract Assembly[] ClientAssemblies { get; }
|
||||
public abstract Assembly[] ServerAssemblies { get; }
|
||||
public readonly List<string> TestPrototypes = new();
|
||||
|
||||
// default cvar overrides to use when creating test pairs.
|
||||
public readonly List<(string cvar, string value)> DefaultCvars =
|
||||
[
|
||||
(CVars.NetPVS.Name, "false"),
|
||||
(CVars.ThreadParallelCount.Name, "1"),
|
||||
(CVars.ReplayClientRecordingEnabled.Name, "false"),
|
||||
(CVars.ReplayServerRecordingEnabled.Name, "false"),
|
||||
(CVars.NetBufferSize.Name, "0")
|
||||
];
|
||||
}
|
||||
|
||||
[Virtual]
|
||||
public class PoolManager<TPair> : BasePoolManager where TPair : class, ITestPair, new()
|
||||
{
|
||||
private int _nextPairId;
|
||||
private readonly Lock _pairLock = new();
|
||||
private bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Set of all pairs, and whether they are currently in-use
|
||||
/// </summary>
|
||||
protected readonly Dictionary<TPair, bool> Pairs = new();
|
||||
private bool _dead;
|
||||
private Exception? _poolFailureReason;
|
||||
|
||||
private Assembly[] _clientAssemblies = [];
|
||||
private Assembly[] _serverAssemblies = [];
|
||||
|
||||
public override Assembly[] ClientAssemblies => _clientAssemblies;
|
||||
public override Assembly[] ServerAssemblies => _serverAssemblies;
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the pool manager. Override this to configure what assemblies should get loaded.
|
||||
/// </summary>
|
||||
public virtual void Startup(params Assembly[] extraAssemblies)
|
||||
{
|
||||
// By default, load no content assemblies, but make both server & client load the testing assembly.
|
||||
Startup([], [], extraAssemblies);
|
||||
}
|
||||
|
||||
protected void Startup(Assembly[] clientAssemblies, Assembly[] serverAssemblies, Assembly[] sharedAssemblies)
|
||||
{
|
||||
if (_initialized)
|
||||
throw new InvalidOperationException("Already initialized");
|
||||
|
||||
DebugTools.AssertEqual(clientAssemblies.Intersect(sharedAssemblies).Count(), 0);
|
||||
DebugTools.AssertEqual(serverAssemblies.Intersect(sharedAssemblies).Count(), 0);
|
||||
DebugTools.AssertEqual(serverAssemblies.Intersect(clientAssemblies).Count(), 0);
|
||||
|
||||
foreach (var assembly in sharedAssemblies)
|
||||
{
|
||||
DiscoverTestPrototypes(assembly);
|
||||
}
|
||||
|
||||
foreach (var assembly in clientAssemblies)
|
||||
{
|
||||
DiscoverTestPrototypes(assembly);
|
||||
}
|
||||
|
||||
foreach (var assembly in serverAssemblies)
|
||||
{
|
||||
DiscoverTestPrototypes(assembly);
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
_clientAssemblies = clientAssemblies.Concat(sharedAssemblies).ToArray();
|
||||
_serverAssemblies = serverAssemblies.Concat(sharedAssemblies).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This shuts down the pool, and disposes all the server/client pairs.
|
||||
/// This is a one time operation to be used when the testing program is exiting.
|
||||
/// </summary>
|
||||
public void Shutdown()
|
||||
{
|
||||
List<TPair> localPairs;
|
||||
lock (_pairLock)
|
||||
{
|
||||
if (_dead)
|
||||
return;
|
||||
_dead = true;
|
||||
localPairs = Pairs.Keys.ToList();
|
||||
}
|
||||
|
||||
foreach (var pair in localPairs)
|
||||
{
|
||||
pair.Kill();
|
||||
}
|
||||
|
||||
_initialized = false;
|
||||
TestPrototypes.Clear();
|
||||
}
|
||||
|
||||
protected virtual string GetDefaultTestName(TestContext testContext)
|
||||
{
|
||||
return testContext.Test.FullName.Replace("Robust.UnitTesting.", "");
|
||||
}
|
||||
|
||||
public string DeathReport()
|
||||
{
|
||||
lock (_pairLock)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
var pairs = Pairs.Keys.OrderBy(pair => pair.Id);
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var borrowed = Pairs[pair];
|
||||
builder.AppendLine($"Pair {pair.Id}, Tests Run: {pair.TestHistory.Count}, Borrowed: {borrowed}");
|
||||
for (var i = 0; i < pair.TestHistory.Count; i++)
|
||||
{
|
||||
builder.AppendLine($"#{i}: {pair.TestHistory[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public virtual PairSettings DefaultSettings => new();
|
||||
|
||||
public async Task<TPair> GetPair(PairSettings? settings = null)
|
||||
{
|
||||
if (!_initialized)
|
||||
throw new InvalidOperationException($"Pool manager has not been initialized");
|
||||
|
||||
settings ??= DefaultSettings;
|
||||
|
||||
// Trust issues with the AsyncLocal that backs this.
|
||||
var testContext = TestContext.CurrentContext;
|
||||
var testOut = TestContext.Out;
|
||||
|
||||
DieIfPoolFailure();
|
||||
var currentTestName = settings.TestName ?? GetDefaultTestName(testContext);
|
||||
var watch = new Stopwatch();
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Called by test {currentTestName}");
|
||||
TPair? pair = null;
|
||||
try
|
||||
{
|
||||
watch.Start();
|
||||
if (settings.MustBeNew)
|
||||
{
|
||||
await testOut.WriteLineAsync(
|
||||
$"{nameof(GetPair)}: Creating pair, because settings of pool settings");
|
||||
pair = await CreateServerClientPair(settings, testOut);
|
||||
}
|
||||
else
|
||||
{
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Looking in pool for a suitable pair");
|
||||
pair = GrabOptimalPair(settings);
|
||||
if (pair != null)
|
||||
{
|
||||
pair.ActivateContext(testOut);
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Suitable pair found");
|
||||
|
||||
if (pair.Settings.CanFastRecycle(settings))
|
||||
{
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Cleanup not needed, Skipping cleanup of pair");
|
||||
await pair.ApplySettings(settings);
|
||||
}
|
||||
else
|
||||
{
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Cleaning existing pair");
|
||||
await pair.RecycleInternal(settings, testOut);
|
||||
}
|
||||
|
||||
await pair.RunTicksSync(5);
|
||||
await pair.SyncTicks(targetDelta: 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Creating a new pair, no suitable pair found in pool");
|
||||
pair = await CreateServerClientPair(settings, testOut);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pair != null && pair.TestHistory.Count > 0)
|
||||
{
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Pair {pair.Id} Test History Start");
|
||||
for (var i = 0; i < pair.TestHistory.Count; i++)
|
||||
{
|
||||
await testOut.WriteLineAsync($"- Pair {pair.Id} Test #{i}: {pair.TestHistory[i]}");
|
||||
}
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Pair {pair.Id} Test History End");
|
||||
}
|
||||
}
|
||||
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Retrieving pair {pair.Id} from pool took {watch.Elapsed.TotalMilliseconds} ms");
|
||||
|
||||
pair.ValidateSettings(settings);
|
||||
pair.ClearModifiedCvars();
|
||||
pair.Settings = settings;
|
||||
pair.TestHistory.Add(currentTestName);
|
||||
pair.SetupSeed();
|
||||
|
||||
await testOut.WriteLineAsync($"{nameof(GetPair)}: Returning pair {pair.Id} with client/server seeds: {pair.ClientSeed}/{pair.ServerSeed}");
|
||||
|
||||
pair.Watch.Restart();
|
||||
return pair;
|
||||
}
|
||||
|
||||
private TPair? GrabOptimalPair(PairSettings poolSettings)
|
||||
{
|
||||
lock (_pairLock)
|
||||
{
|
||||
TPair? fallback = null;
|
||||
foreach (var pair in Pairs.Keys)
|
||||
{
|
||||
if (Pairs[pair])
|
||||
continue;
|
||||
|
||||
if (!pair.Settings.CanFastRecycle(poolSettings))
|
||||
{
|
||||
fallback = pair;
|
||||
continue;
|
||||
}
|
||||
|
||||
pair.Use();
|
||||
Pairs[pair] = true;
|
||||
return pair;
|
||||
}
|
||||
|
||||
if (fallback == null)
|
||||
return null;
|
||||
|
||||
fallback.Use();
|
||||
Pairs[fallback!] = true;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used by TestPair after checking the server/client pair, Don't use this.
|
||||
/// </summary>
|
||||
internal override void Return(ITestPair pair)
|
||||
{
|
||||
lock (_pairLock)
|
||||
{
|
||||
if (pair.State == PairState.Dead)
|
||||
Pairs.Remove((TPair)pair);
|
||||
else if (pair.State == PairState.Ready)
|
||||
Pairs[(TPair) pair] = false;
|
||||
else
|
||||
throw new InvalidOperationException($"Attempted to return a pair in an invalid state. Pair: {pair.Id}. State: {pair.State}.");
|
||||
}
|
||||
}
|
||||
|
||||
private void DieIfPoolFailure()
|
||||
{
|
||||
if (_poolFailureReason != null)
|
||||
{
|
||||
// If the _poolFailureReason is not null, we can assume at least one test failed.
|
||||
// So we say inconclusive so we don't add more failed tests to search through.
|
||||
Assert.Inconclusive(@$"
|
||||
In a different test, the pool manager had an exception when trying to create a server/client pair.
|
||||
Instead of risking that the pool manager will fail at creating a server/client pairs for every single test,
|
||||
we are just going to end this here to save a lot of time. This is the exception that started this:\n {_poolFailureReason}");
|
||||
}
|
||||
|
||||
if (_dead)
|
||||
{
|
||||
// If Pairs is null, we ran out of time, we can't assume a test failed.
|
||||
// So we are going to tell it all future tests are a failure.
|
||||
Assert.Fail("The pool was shut down");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TPair> CreateServerClientPair(PairSettings settings, TextWriter testOut)
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextPairId);
|
||||
var pair = new TPair();
|
||||
await pair.Init(id, this, settings, testOut);
|
||||
pair.Use();
|
||||
await pair.RunTicksSync(5);
|
||||
await pair.SyncTicks(targetDelta: 1);
|
||||
return pair;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_poolFailureReason = ex;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void DiscoverTestPrototypes(Assembly assembly)
|
||||
{
|
||||
const BindingFlags flags = BindingFlags.Static
|
||||
| BindingFlags.NonPublic
|
||||
| BindingFlags.Public
|
||||
| BindingFlags.DeclaredOnly;
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
foreach (var field in type.GetFields(flags))
|
||||
{
|
||||
if (!field.HasCustomAttribute<TestPrototypesAttribute>())
|
||||
continue;
|
||||
|
||||
var val = field.GetValue(null);
|
||||
if (val is not string str)
|
||||
throw new Exception($"{nameof(TestPrototypesAttribute)} is only valid on non-null string fields");
|
||||
|
||||
TestPrototypes.Add(str);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
Robust.UnitTesting/Pool/PoolTestLogHandler.cs
Normal file
77
Robust.UnitTesting/Pool/PoolTestLogHandler.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using NUnit.Framework;
|
||||
using Robust.Shared.Log;
|
||||
using Robust.Shared.Timing;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace Robust.UnitTesting.Pool;
|
||||
|
||||
/// <summary>
|
||||
/// Log handler intended for pooled integration tests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This class logs to two places: an NUnit <see cref="TestContext"/> (so it nicely gets attributed to a test in your IDE),
|
||||
/// and an in-memory ring buffer for diagnostic purposes. If test pooling breaks, the ring buffer can be used to see what the broken instance has gone through.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The active test context can be swapped out so pooled instances can correctly have their logs attributed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class PoolTestLogHandler : ILogHandler
|
||||
{
|
||||
private readonly string? _prefix;
|
||||
|
||||
private RStopwatch _stopwatch;
|
||||
|
||||
public TextWriter? ActiveContext { get; private set; }
|
||||
|
||||
public LogLevel? FailureLevel { get; set; }
|
||||
|
||||
public PoolTestLogHandler(string? prefix)
|
||||
{
|
||||
_prefix = prefix != null ? $"{prefix}: " : "";
|
||||
}
|
||||
|
||||
public bool ShuttingDown;
|
||||
|
||||
public void Log(string sawmillName, LogEvent message)
|
||||
{
|
||||
var level = message.Level.ToRobust();
|
||||
|
||||
if (ShuttingDown && (FailureLevel == null || level < FailureLevel))
|
||||
return;
|
||||
|
||||
if (ActiveContext is not { } testContext)
|
||||
{
|
||||
// If this gets hit it means something is logging to this instance while it's "between" tests.
|
||||
// This is a bug in either the game or the testing system, and must always be investigated.
|
||||
throw new InvalidOperationException("Log to pool test log handler without active test context");
|
||||
}
|
||||
|
||||
var name = LogMessage.LogLevelToName(level);
|
||||
var seconds = _stopwatch.Elapsed.TotalSeconds;
|
||||
var rendered = message.RenderMessage();
|
||||
var line = $"{_prefix}{seconds:F3}s [{name}] {sawmillName}: {rendered}";
|
||||
|
||||
testContext.WriteLine(line);
|
||||
|
||||
if (FailureLevel == null || level < FailureLevel)
|
||||
return;
|
||||
|
||||
testContext.Flush();
|
||||
Assert.Fail($"{line} Exception: {message.Exception}");
|
||||
}
|
||||
|
||||
public void ClearContext()
|
||||
{
|
||||
ActiveContext = null;
|
||||
}
|
||||
|
||||
public void ActivateContext(TextWriter context)
|
||||
{
|
||||
_stopwatch.Restart();
|
||||
ActiveContext = context;
|
||||
}
|
||||
}
|
||||
23
Robust.UnitTesting/Pool/TestMapData.cs
Normal file
23
Robust.UnitTesting/Pool/TestMapData.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Map.Components;
|
||||
|
||||
namespace Robust.UnitTesting.Pool;
|
||||
|
||||
/// <summary>
|
||||
/// Simple data class that stored information about a map being used by a test.
|
||||
/// </summary>
|
||||
public sealed class TestMapData
|
||||
{
|
||||
public EntityUid MapUid { get; set; }
|
||||
public Entity<MapGridComponent> Grid;
|
||||
public MapId MapId;
|
||||
public EntityCoordinates GridCoords { get; set; }
|
||||
public MapCoordinates MapCoords { get; set; }
|
||||
public TileRef Tile { get; set; }
|
||||
|
||||
// Client-side uids
|
||||
public EntityUid CMapUid { get; set; }
|
||||
public EntityUid CGridUid { get; set; }
|
||||
public EntityCoordinates CGridCoords { get; set; }
|
||||
}
|
||||
285
Robust.UnitTesting/Pool/TestPair.Helpers.cs
Normal file
285
Robust.UnitTesting/Pool/TestPair.Helpers.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Prototypes;
|
||||
|
||||
namespace Robust.UnitTesting.Pool;
|
||||
|
||||
// This partial file contains misc helper functions to make writing tests easier.
|
||||
public partial class TestPair<TServer, TClient>
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert a client-side uid into a server-side uid
|
||||
/// </summary>
|
||||
public EntityUid ToServerUid(EntityUid uid) => ConvertUid(uid, Client, Server);
|
||||
|
||||
/// <summary>
|
||||
/// Convert a server-side uid into a client-side uid
|
||||
/// </summary>
|
||||
public EntityUid ToClientUid(EntityUid uid) => ConvertUid(uid, Server, Client);
|
||||
|
||||
private static EntityUid ConvertUid(EntityUid uid, IIntegrationInstance source, IIntegrationInstance destination)
|
||||
{
|
||||
if (!uid.IsValid())
|
||||
return EntityUid.Invalid;
|
||||
|
||||
if (!source.EntMan.TryGetComponent<MetaDataComponent>(uid, out var meta))
|
||||
{
|
||||
Assert.Fail($"Failed to resolve MetaData while converting the EntityUid for entity {uid}");
|
||||
return EntityUid.Invalid;
|
||||
}
|
||||
|
||||
if (!destination.EntMan.TryGetEntity(meta.NetEntity, out var otherUid))
|
||||
{
|
||||
Assert.Fail($"Failed to resolve net ID while converting the EntityUid entity {source.EntMan.ToPrettyString(uid)}");
|
||||
return EntityUid.Invalid;
|
||||
}
|
||||
|
||||
return otherUid.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a command on the server and wait some number of ticks.
|
||||
/// </summary>
|
||||
public async Task WaitCommand(string cmd, int numTicks = 10)
|
||||
{
|
||||
await Server.ExecuteCommand(cmd);
|
||||
await RunTicksSync(numTicks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute a command on the client and wait some number of ticks.
|
||||
/// </summary>
|
||||
public async Task WaitClientCommand(string cmd, int numTicks = 10)
|
||||
{
|
||||
await Client.ExecuteCommand(cmd);
|
||||
await RunTicksSync(numTicks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve all entity prototypes that have some component.
|
||||
/// </summary>
|
||||
public List<(EntityPrototype, T)> GetPrototypesWithComponent<T>(
|
||||
HashSet<string>? ignored = null,
|
||||
bool ignoreAbstract = true,
|
||||
bool ignoreTestPrototypes = true)
|
||||
where T : IComponent, new()
|
||||
{
|
||||
if (!Server.Resolve<IComponentFactory>().TryGetRegistration<T>(out var reg)
|
||||
&& !Client.Resolve<IComponentFactory>().TryGetRegistration<T>(out reg))
|
||||
{
|
||||
Assert.Fail($"Unknown component: {typeof(T).Name}");
|
||||
return new();
|
||||
}
|
||||
|
||||
var id = reg.Name;
|
||||
var list = new List<(EntityPrototype, T)>();
|
||||
foreach (var proto in Server.ProtoMan.EnumeratePrototypes<EntityPrototype>())
|
||||
{
|
||||
if (ignored != null && ignored.Contains(proto.ID))
|
||||
continue;
|
||||
|
||||
if (ignoreAbstract && proto.Abstract)
|
||||
continue;
|
||||
|
||||
if (ignoreTestPrototypes && IsTestPrototype(proto))
|
||||
continue;
|
||||
|
||||
if (proto.Components.TryGetComponent(id, out var cmp))
|
||||
list.Add((proto, (T)cmp));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve all entity prototypes that have some component.
|
||||
/// </summary>
|
||||
public List<EntityPrototype> GetPrototypesWithComponent(
|
||||
Type type,
|
||||
HashSet<string>? ignored = null,
|
||||
bool ignoreAbstract = true,
|
||||
bool ignoreTestPrototypes = true)
|
||||
{
|
||||
if (!Server.Resolve<IComponentFactory>().TryGetRegistration(type, out var reg)
|
||||
&& !Client.Resolve<IComponentFactory>().TryGetRegistration(type, out reg))
|
||||
{
|
||||
Assert.Fail($"Unknown component: {type.Name}");
|
||||
return new();
|
||||
}
|
||||
|
||||
var id = reg.Name;
|
||||
var list = new List<EntityPrototype>();
|
||||
foreach (var proto in Server.ProtoMan.EnumeratePrototypes<EntityPrototype>())
|
||||
{
|
||||
if (ignored != null && ignored.Contains(proto.ID))
|
||||
continue;
|
||||
|
||||
if (ignoreAbstract && proto.Abstract)
|
||||
continue;
|
||||
|
||||
if (ignoreTestPrototypes && IsTestPrototype(proto))
|
||||
continue;
|
||||
|
||||
if (proto.Components.ContainsKey(id))
|
||||
list.Add((proto));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task Connect()
|
||||
{
|
||||
Assert.That(Client.NetMan.IsConnected, Is.False);
|
||||
await Client.Connect(Server);
|
||||
await ReallyBeIdle(10);
|
||||
await Client.WaitRunTicks(1);
|
||||
}
|
||||
|
||||
public async Task Disconnect(string reason = "")
|
||||
{
|
||||
await Client.WaitPost(() => Client.CNetMan.ClientDisconnect(reason));
|
||||
await ReallyBeIdle(10);
|
||||
}
|
||||
|
||||
public bool IsTestPrototype(EntityPrototype proto)
|
||||
{
|
||||
return _loadedEntityPrototypes.Contains(proto.ID);
|
||||
}
|
||||
|
||||
public bool IsTestEntityPrototype(string id)
|
||||
{
|
||||
return _loadedEntityPrototypes.Contains(id);
|
||||
}
|
||||
|
||||
public bool IsTestPrototype<TPrototype>(string id) where TPrototype : IPrototype
|
||||
{
|
||||
return IsTestPrototype(typeof(TPrototype), id);
|
||||
}
|
||||
|
||||
public bool IsTestPrototype<TPrototype>(TPrototype proto) where TPrototype : IPrototype
|
||||
{
|
||||
return IsTestPrototype(typeof(TPrototype), proto.ID);
|
||||
}
|
||||
|
||||
public bool IsTestPrototype(Type kind, string id)
|
||||
{
|
||||
return _loadedPrototypes.TryGetValue(kind, out var ids) && ids.Contains(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the server-client pair in sync
|
||||
/// </summary>
|
||||
/// <param name="ticks">How many ticks to run them for</param>
|
||||
public async Task RunTicksSync(int ticks)
|
||||
{
|
||||
for (var i = 0; i < ticks; i++)
|
||||
{
|
||||
await Server.WaitRunTicks(1);
|
||||
await Client.WaitRunTicks(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a time interval to some number of ticks.
|
||||
/// </summary>
|
||||
public int SecondsToTicks(float seconds)
|
||||
{
|
||||
return (int) Math.Ceiling(seconds / Server.Timing.TickPeriod.TotalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run the server & client in sync for some amount of time
|
||||
/// </summary>
|
||||
public async Task RunSeconds(float seconds)
|
||||
{
|
||||
await RunTicksSync(SecondsToTicks(seconds));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the server-client pair in sync, but also ensures they are both idle each tick.
|
||||
/// </summary>
|
||||
/// <param name="runTicks">How many ticks to run</param>
|
||||
public async Task ReallyBeIdle(int runTicks = 25)
|
||||
{
|
||||
for (var i = 0; i < runTicks; i++)
|
||||
{
|
||||
await Client.WaitRunTicks(1);
|
||||
await Server.WaitRunTicks(1);
|
||||
for (var idleCycles = 0; idleCycles < 4; idleCycles++)
|
||||
{
|
||||
await Client.WaitIdleAsync();
|
||||
await Server.WaitIdleAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run the server/clients until the ticks are synchronized.
|
||||
/// By default the client will be one tick ahead of the server.
|
||||
/// </summary>
|
||||
public async Task SyncTicks(int targetDelta = 1)
|
||||
{
|
||||
var sTick = (int)Server.Timing.CurTick.Value;
|
||||
var cTick = (int)Client.Timing.CurTick.Value;
|
||||
var delta = cTick - sTick;
|
||||
|
||||
if (delta == targetDelta)
|
||||
return;
|
||||
if (delta > targetDelta)
|
||||
await Server.WaitRunTicks(delta - targetDelta);
|
||||
else
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a map with a single grid consisting of one tile.
|
||||
/// </summary>
|
||||
[MemberNotNull(nameof(TestMap))]
|
||||
public async Task<TestMapData> CreateTestMap(bool initialized, ushort tileTypeId)
|
||||
{
|
||||
TestMap = new TestMapData();
|
||||
await Server.WaitIdleAsync();
|
||||
var sys = Server.System<SharedMapSystem>();
|
||||
|
||||
await Server.WaitPost(() =>
|
||||
{
|
||||
TestMap.MapUid = sys.CreateMap(out TestMap.MapId, runMapInit: initialized);
|
||||
TestMap.Grid = Server.MapMan.CreateGridEntity(TestMap.MapId);
|
||||
TestMap.GridCoords = new EntityCoordinates(TestMap.Grid, 0, 0);
|
||||
TestMap.MapCoords = new MapCoordinates(0, 0, TestMap.MapId);
|
||||
sys.SetTile(TestMap.Grid.Owner, TestMap.Grid.Comp, TestMap.GridCoords, new Tile(tileTypeId));
|
||||
TestMap.Tile = sys.GetAllTiles(TestMap.Grid.Owner, TestMap.Grid.Comp).First();
|
||||
});
|
||||
|
||||
if (!Settings.Connected)
|
||||
return TestMap;
|
||||
|
||||
await RunTicksSync(10);
|
||||
TestMap.CMapUid = ToClientUid(TestMap.MapUid);
|
||||
TestMap.CGridUid = ToClientUid(TestMap.Grid);
|
||||
TestMap.CGridCoords = new EntityCoordinates(TestMap.CGridUid, 0, 0);
|
||||
|
||||
return TestMap;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="CreateTestMap(bool, ushort)"/>
|
||||
[MemberNotNull(nameof(TestMap))]
|
||||
public async Task<TestMapData> CreateTestMap(bool initialized, string tileName)
|
||||
{
|
||||
var defMan = Server.Resolve<ITileDefinitionManager>();
|
||||
if (!defMan.TryGetDefinition(tileName, out var def))
|
||||
Assert.Fail($"Unknown tile: {tileName}");
|
||||
return await CreateTestMap(initialized, def?.TileId ?? 1);
|
||||
}
|
||||
}
|
||||
208
Robust.UnitTesting/Pool/TestPair.Recycle.cs
Normal file
208
Robust.UnitTesting/Pool/TestPair.Recycle.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Robust.Client;
|
||||
using Robust.Shared;
|
||||
using Robust.Shared.Exceptions;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Player;
|
||||
|
||||
namespace Robust.UnitTesting.Pool;
|
||||
|
||||
// This partial file contains logic related to recycling & disposing test pairs.
|
||||
public partial class TestPair<TServer, TClient>
|
||||
{
|
||||
private async Task OnDirtyDispose()
|
||||
{
|
||||
var usageTime = Watch.Elapsed;
|
||||
Watch.Restart();
|
||||
await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Test gave back pair {Id} in {usageTime.TotalMilliseconds} ms");
|
||||
Kill();
|
||||
var disposeTime = Watch.Elapsed;
|
||||
await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Disposed pair {Id} in {disposeTime.TotalMilliseconds} ms");
|
||||
// Test pairs should only dirty dispose if they are failing. If they are not failing, this probably happened
|
||||
// because someone forgot to clean-return the pair.
|
||||
Assert.Warn("Test was dirty-disposed.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method gets called before any test pair gets returned to the pool.
|
||||
/// </summary>
|
||||
protected virtual async Task Cleanup()
|
||||
{
|
||||
if (TestMap != null)
|
||||
{
|
||||
await Server.WaitPost(() => Server.EntMan.DeleteEntity(TestMap.MapUid));
|
||||
TestMap = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnCleanDispose()
|
||||
{
|
||||
await Server.WaitIdleAsync();
|
||||
await Client.WaitIdleAsync();
|
||||
await Cleanup();
|
||||
await Server.Cleanup();
|
||||
await Client.Cleanup();
|
||||
await RevertModifiedCvars();
|
||||
|
||||
var usageTime = Watch.Elapsed;
|
||||
Watch.Restart();
|
||||
await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Test borrowed pair {Id} for {usageTime.TotalMilliseconds} ms");
|
||||
// Let any last minute failures the test cause happen.
|
||||
await ReallyBeIdle();
|
||||
if (!Settings.Destructive)
|
||||
{
|
||||
if (Client.IsAlive == false)
|
||||
throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the client in pair {Id}:", Client.UnhandledException);
|
||||
|
||||
if (Server.IsAlive == false)
|
||||
throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the server in pair {Id}:", Server.UnhandledException);
|
||||
}
|
||||
|
||||
if (Settings.MustNotBeReused)
|
||||
{
|
||||
Kill();
|
||||
await ReallyBeIdle();
|
||||
await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Clean disposed in {Watch.Elapsed.TotalMilliseconds} ms");
|
||||
return;
|
||||
}
|
||||
|
||||
var sRuntimeLog = Server.Resolve<IRuntimeLog>();
|
||||
if (sRuntimeLog.ExceptionCount > 0)
|
||||
throw new Exception($"{nameof(CleanReturnAsync)}: Server logged exceptions");
|
||||
var cRuntimeLog = Client.Resolve<IRuntimeLog>();
|
||||
if (cRuntimeLog.ExceptionCount > 0)
|
||||
throw new Exception($"{nameof(CleanReturnAsync)}: Client logged exceptions");
|
||||
|
||||
var returnTime = Watch.Elapsed;
|
||||
await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: PoolManager took {returnTime.TotalMilliseconds} ms to put pair {Id} back into the pool");
|
||||
}
|
||||
|
||||
public async ValueTask CleanReturnAsync()
|
||||
{
|
||||
if (State != PairState.InUse)
|
||||
throw new Exception($"{nameof(CleanReturnAsync)}: Unexpected state. Pair: {Id}. State: {State}.");
|
||||
|
||||
await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Return of pair {Id} started");
|
||||
State = PairState.CleanDisposed;
|
||||
await OnCleanDispose();
|
||||
State = PairState.Ready;
|
||||
Manager.Return(this);
|
||||
ClearContext();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
switch (State)
|
||||
{
|
||||
case PairState.Dead:
|
||||
case PairState.Ready:
|
||||
break;
|
||||
case PairState.InUse:
|
||||
await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Dirty return of pair {Id} started");
|
||||
await OnDirtyDispose();
|
||||
Manager.Return(this);
|
||||
ClearContext();
|
||||
break;
|
||||
default:
|
||||
throw new Exception($"{nameof(DisposeAsync)}: Unexpected state. Pair: {Id}. State: {State}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method gets called when a previously used test pair is being retrieved from the pool.
|
||||
/// Note that in some instances this method may get skipped (See <see cref="PairSettings.CanFastRecycle"/>).
|
||||
/// </summary>
|
||||
public async Task RecycleInternal(PairSettings settings, TextWriter testOut)
|
||||
{
|
||||
Watch.Restart();
|
||||
await testOut.WriteLineAsync($"Recycling...");
|
||||
await RunTicksSync(1);
|
||||
|
||||
// Disconnect the client if they are connected.
|
||||
if (Client.CNetMan.IsConnected)
|
||||
{
|
||||
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Disconnecting client.");
|
||||
await Client.WaitPost(() => Client.CNetMan.ClientDisconnect("Test pooling cleanup disconnect"));
|
||||
await RunTicksSync(1);
|
||||
}
|
||||
|
||||
await Recycle(settings, testOut);
|
||||
ClearModifiedCvars();
|
||||
|
||||
// (possibly) reconnect the client
|
||||
if (settings.Connected)
|
||||
{
|
||||
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Connecting client");
|
||||
await Client.Connect(Server);
|
||||
}
|
||||
|
||||
Settings = default!;
|
||||
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Idling");
|
||||
await ReallyBeIdle();
|
||||
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Done recycling");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This method gets called when a previously used test pair is being retrieved from the pool.
|
||||
/// If the next settings are compatible with the previous settings, this step may get skipped (See <see cref="PairSettings.CanFastRecycle"/>).
|
||||
/// In general, this method should also call <see cref="ApplySettings"/>.
|
||||
/// </summary>
|
||||
protected virtual async Task Recycle(PairSettings next, TextWriter testOut)
|
||||
{
|
||||
//Apply Cvars
|
||||
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Setting CVar ");
|
||||
await ApplySettings(next);
|
||||
await RunTicksSync(1);
|
||||
|
||||
// flush server entities.
|
||||
await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Flushing server entities");
|
||||
await Server.WaitPost(() => Server.EntMan.FlushEntities());
|
||||
await RunTicksSync(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply settings to the test pair. This method is always called when a pair is fetched from the pool. There should
|
||||
/// be no need to apply settings that require a pair to be recycled, as in those cases the
|
||||
/// <see cref="PairSettings.CanFastRecycle"/> should have caused <see cref="Recycle"/> to be invoked, which should
|
||||
/// already have applied those settings.
|
||||
/// </summary>
|
||||
public async Task ApplySettings(PairSettings next)
|
||||
{
|
||||
await ApplySettings(Client, next);
|
||||
await ApplySettings(Server, next);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ApplySettings(PairSettings)"/>
|
||||
[MustCallBase]
|
||||
protected internal virtual async Task ApplySettings(IIntegrationInstance instance, PairSettings next)
|
||||
{
|
||||
if (instance.CfgMan.IsCVarRegistered(CVars.NetInterp.Name))
|
||||
await instance.WaitPost(() => instance.CfgMan.SetCVar(CVars.NetInterp, !next.DisableInterpolate));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invoked after a test pair has been recycled to validate that the settings have been properly applied.
|
||||
/// </summary>
|
||||
[MustCallBase]
|
||||
public virtual void ValidateSettings(PairSettings settings)
|
||||
{
|
||||
var netMan = Client.Resolve<INetManager>();
|
||||
Assert.That(netMan.IsConnected, Is.EqualTo(settings.Connected));
|
||||
|
||||
if (!settings.Connected)
|
||||
return;
|
||||
|
||||
var baseClient = Client.Resolve<IBaseClient>();
|
||||
var cPlayer = Client.Resolve<ISharedPlayerManager>();
|
||||
var sPlayer = Server.Resolve<ISharedPlayerManager>();
|
||||
|
||||
Assert.That(baseClient.RunLevel, Is.EqualTo(ClientRunLevel.InGame));
|
||||
Assert.That(sPlayer.Sessions.Length, Is.EqualTo(1));
|
||||
var session = sPlayer.Sessions.Single();
|
||||
Assert.That(cPlayer.LocalSession?.UserId, Is.EqualTo(session.UserId));
|
||||
}
|
||||
}
|
||||
244
Robust.UnitTesting/Pool/TestPair.cs
Normal file
244
Robust.UnitTesting/Pool/TestPair.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Player;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Random;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.UnitTesting.Pool;
|
||||
|
||||
/// <summary>
|
||||
/// This object wraps a pooled server+client pair.
|
||||
/// </summary>
|
||||
public abstract partial class TestPair<TServer, TClient> : ITestPair, IAsyncDisposable
|
||||
where TServer : IServerIntegrationInstance
|
||||
where TClient : IClientIntegrationInstance
|
||||
{
|
||||
public int Id { get; internal set; }
|
||||
protected BasePoolManager Manager = default!;
|
||||
public PairState State { get; private set; } = PairState.Ready;
|
||||
public bool Initialized { get; private set; }
|
||||
protected TextWriter TestOut = default!;
|
||||
public Stopwatch Watch { get; } = new();
|
||||
public List<string> TestHistory { get; } = new();
|
||||
public PairSettings Settings { get; set; } = default!;
|
||||
|
||||
public readonly PoolTestLogHandler ServerLogHandler = new("SERVER");
|
||||
public readonly PoolTestLogHandler ClientLogHandler = new("CLIENT");
|
||||
public TestMapData? TestMap;
|
||||
|
||||
private int _nextServerSeed;
|
||||
private int _nextClientSeed;
|
||||
|
||||
public int ServerSeed { get; set; }
|
||||
public int ClientSeed { get; set; }
|
||||
|
||||
public TServer Server { get; private set; } = default!;
|
||||
public TClient Client { get; private set; } = default!;
|
||||
|
||||
public ICommonSession? Player => Server.PlayerMan.SessionsDict.GetValueOrDefault(Client.User ?? default);
|
||||
|
||||
private Dictionary<Type, HashSet<string>> _loadedPrototypes = new();
|
||||
private HashSet<string> _loadedEntityPrototypes = new();
|
||||
protected readonly Dictionary<string, object> ModifiedClientCvars = new();
|
||||
protected readonly Dictionary<string, object> ModifiedServerCvars = new();
|
||||
|
||||
public async Task LoadPrototypes(List<string> prototypes)
|
||||
{
|
||||
await LoadPrototypes(Server, prototypes);
|
||||
await LoadPrototypes(Client, prototypes);
|
||||
}
|
||||
|
||||
public async Task Init(
|
||||
int id,
|
||||
BasePoolManager manager,
|
||||
PairSettings settings,
|
||||
TextWriter testOut)
|
||||
{
|
||||
if (Initialized)
|
||||
throw new InvalidOperationException("Already initialized");
|
||||
|
||||
Id = id;
|
||||
Manager = manager;
|
||||
Settings = settings;
|
||||
Initialized = true;
|
||||
|
||||
ClientLogHandler.ActivateContext(testOut);
|
||||
ServerLogHandler.ActivateContext(testOut);
|
||||
Client = await GenerateClient();
|
||||
Server = await GenerateServer();
|
||||
ActivateContext(testOut);
|
||||
await ApplySettings(settings);
|
||||
|
||||
Client.CfgMan.OnCVarValueChanged += OnClientCvarChanged;
|
||||
Server.CfgMan.OnCVarValueChanged += OnServerCvarChanged;
|
||||
|
||||
if (!settings.NoLoadTestPrototypes)
|
||||
await LoadPrototypes(Manager.TestPrototypes);
|
||||
|
||||
var cRand = Client.Resolve<IRobustRandom>();
|
||||
var sRand = Server.Resolve<IRobustRandom>();
|
||||
_nextClientSeed = cRand.Next();
|
||||
_nextServerSeed = sRand.Next();
|
||||
|
||||
await Initialize();
|
||||
|
||||
// Always initially connect clients.
|
||||
// This is done in case the server does randomization when client first connects
|
||||
// This is to try and prevent issues where if the first test that connects the client is consistently some test
|
||||
// that uses a fixed seed, it would effectively prevent the initial configuration from being randomized.
|
||||
await Connect();
|
||||
|
||||
if (!Settings.Connected)
|
||||
await Disconnect("Initial disconnect");
|
||||
}
|
||||
|
||||
protected virtual Task Initialize()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected abstract Task<TClient> GenerateClient();
|
||||
protected abstract Task<TServer> GenerateServer();
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
State = PairState.Dead;
|
||||
ServerLogHandler.ShuttingDown = true;
|
||||
ClientLogHandler.ShuttingDown = true;
|
||||
Server.Dispose();
|
||||
Client.Dispose();
|
||||
}
|
||||
|
||||
private void ClearContext()
|
||||
{
|
||||
TestOut = default!;
|
||||
ServerLogHandler.ClearContext();
|
||||
ClientLogHandler.ClearContext();
|
||||
}
|
||||
|
||||
public void ActivateContext(TextWriter testOut)
|
||||
{
|
||||
TestOut = testOut;
|
||||
ServerLogHandler.ActivateContext(testOut);
|
||||
ClientLogHandler.ActivateContext(testOut);
|
||||
}
|
||||
|
||||
public void Use()
|
||||
{
|
||||
if (State != PairState.Ready)
|
||||
throw new InvalidOperationException($"Pair is not ready to use. State: {State}");
|
||||
State = PairState.InUse;
|
||||
}
|
||||
|
||||
public void SetupSeed()
|
||||
{
|
||||
var sRand = Server.Resolve<IRobustRandom>();
|
||||
if (Settings.ServerSeed is { } severSeed)
|
||||
{
|
||||
ServerSeed = severSeed;
|
||||
sRand.SetSeed(ServerSeed);
|
||||
}
|
||||
else
|
||||
{
|
||||
ServerSeed = _nextServerSeed;
|
||||
sRand.SetSeed(ServerSeed);
|
||||
_nextServerSeed = sRand.Next();
|
||||
}
|
||||
|
||||
var cRand = Client.Resolve<IRobustRandom>();
|
||||
if (Settings.ClientSeed is { } clientSeed)
|
||||
{
|
||||
ClientSeed = clientSeed;
|
||||
cRand.SetSeed(ClientSeed);
|
||||
}
|
||||
else
|
||||
{
|
||||
ClientSeed = _nextClientSeed;
|
||||
cRand.SetSeed(ClientSeed);
|
||||
_nextClientSeed = cRand.Next();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPrototypes(IIntegrationInstance instance, List<string> prototypes)
|
||||
{
|
||||
var changed = new Dictionary<Type, HashSet<string>>();
|
||||
foreach (var file in prototypes)
|
||||
{
|
||||
instance.ProtoMan.LoadString(file, changed: changed);
|
||||
}
|
||||
|
||||
await instance.WaitPost(() => instance.ProtoMan.ReloadPrototypes(changed));
|
||||
|
||||
foreach (var (kind, ids) in changed)
|
||||
{
|
||||
_loadedPrototypes.GetOrNew(kind).UnionWith(ids);
|
||||
}
|
||||
|
||||
if (_loadedPrototypes.TryGetValue(typeof(EntityPrototype), out var entIds))
|
||||
_loadedEntityPrototypes.UnionWith(entIds);
|
||||
}
|
||||
|
||||
public void Deconstruct(out TServer server, out TClient client)
|
||||
{
|
||||
server = Server;
|
||||
client = Client;
|
||||
}
|
||||
|
||||
private void OnServerCvarChanged(CVarChangeInfo args)
|
||||
{
|
||||
ModifiedServerCvars.TryAdd(args.Name, args.OldValue);
|
||||
}
|
||||
|
||||
private void OnClientCvarChanged(CVarChangeInfo args)
|
||||
{
|
||||
ModifiedClientCvars.TryAdd(args.Name, args.OldValue);
|
||||
}
|
||||
|
||||
public void ClearModifiedCvars()
|
||||
{
|
||||
ModifiedClientCvars.Clear();
|
||||
ModifiedServerCvars.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverts any cvars that were modified during a test back to their original values.
|
||||
/// </summary>
|
||||
public virtual async Task RevertModifiedCvars()
|
||||
{
|
||||
await Server.WaitPost(() =>
|
||||
{
|
||||
foreach (var (name, value) in ModifiedServerCvars)
|
||||
{
|
||||
if (Server.CfgMan.GetCVar(name).Equals(value))
|
||||
continue;
|
||||
|
||||
Server.Log.Info($"Resetting cvar {name} to {value}");
|
||||
Server.CfgMan.SetCVar(name, value);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
await Client.WaitPost(() =>
|
||||
{
|
||||
foreach (var (name, value) in ModifiedClientCvars)
|
||||
{
|
||||
if (Client.CfgMan.GetCVar(name).Equals(value))
|
||||
continue;
|
||||
|
||||
var flags = Client.CfgMan.GetCVarFlags(name);
|
||||
if (flags.HasFlag(CVar.REPLICATED) && flags.HasFlag(CVar.SERVER))
|
||||
continue;
|
||||
|
||||
Client.Log.Info($"Resetting cvar {name} to {value}");
|
||||
Client.CfgMan.SetCVar(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
ClearModifiedCvars();
|
||||
}
|
||||
}
|
||||
13
Robust.UnitTesting/Pool/TestPrototypesAttribute.cs
Normal file
13
Robust.UnitTesting/Pool/TestPrototypesAttribute.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using JetBrains.Annotations;
|
||||
|
||||
namespace Robust.UnitTesting.Pool;
|
||||
|
||||
/// <summary>
|
||||
/// Attribute that indicates that a string contains yaml prototype data that should be loaded by integration tests.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
[MeansImplicitUse]
|
||||
public sealed class TestPrototypesAttribute : Attribute
|
||||
{
|
||||
}
|
||||
80
Robust.UnitTesting/RobustIntegrationTest.TestPair.cs
Normal file
80
Robust.UnitTesting/RobustIntegrationTest.TestPair.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System.Threading.Tasks;
|
||||
using Robust.Client;
|
||||
using Robust.Server;
|
||||
using Robust.Shared.Log;
|
||||
using Robust.UnitTesting.Pool;
|
||||
|
||||
namespace Robust.UnitTesting;
|
||||
|
||||
public partial class RobustIntegrationTest
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="TestPair{TServer,TClient}"/> implementation using <see cref="RobustIntegrationTest"/> instances.
|
||||
/// </summary>
|
||||
[Virtual]
|
||||
public class TestPair : TestPair<ServerIntegrationInstance, ClientIntegrationInstance>
|
||||
{
|
||||
protected override async Task<ClientIntegrationInstance> GenerateClient()
|
||||
{
|
||||
var client = new ClientIntegrationInstance(ClientOptions());
|
||||
await client.WaitIdleAsync();
|
||||
client.Resolve<ILogManager>().GetSawmill("loc").Level = LogLevel.Error;
|
||||
client.CfgMan.OnValueChanged(RTCVars.FailureLogLevel, value => ClientLogHandler.FailureLevel = value, true);
|
||||
await client.WaitIdleAsync();
|
||||
return client;
|
||||
}
|
||||
|
||||
protected override async Task<ServerIntegrationInstance> GenerateServer()
|
||||
{
|
||||
var server = new ServerIntegrationInstance(ServerOptions());
|
||||
await server.WaitIdleAsync();
|
||||
server.Resolve<ILogManager>().GetSawmill("loc").Level = LogLevel.Error;
|
||||
server.CfgMan.OnValueChanged(RTCVars.FailureLogLevel, value => ServerLogHandler.FailureLevel = value, true);
|
||||
return server;
|
||||
}
|
||||
|
||||
protected virtual ClientIntegrationOptions ClientOptions()
|
||||
{
|
||||
var options = new ClientIntegrationOptions
|
||||
{
|
||||
ContentAssemblies = Manager.ClientAssemblies,
|
||||
OverrideLogHandler = () => ClientLogHandler
|
||||
};
|
||||
|
||||
options.Options = new()
|
||||
{
|
||||
LoadConfigAndUserData = false,
|
||||
LoadContentResources = false,
|
||||
};
|
||||
|
||||
foreach (var (cvar, value) in Manager.DefaultCvars)
|
||||
{
|
||||
options.CVarOverrides[cvar] = value;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
protected virtual ServerIntegrationOptions ServerOptions()
|
||||
{
|
||||
var options = new ServerIntegrationOptions
|
||||
{
|
||||
ContentAssemblies = Manager.ServerAssemblies,
|
||||
OverrideLogHandler = () => ServerLogHandler
|
||||
};
|
||||
|
||||
options.Options = new()
|
||||
{
|
||||
LoadConfigAndUserData = false,
|
||||
LoadContentResources = false,
|
||||
};
|
||||
|
||||
foreach (var (cvar, value) in Manager.DefaultCvars)
|
||||
{
|
||||
options.CVarOverrides[cvar] = value;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,6 @@ using Moq;
|
||||
using NUnit.Framework;
|
||||
using Robust.Client;
|
||||
using Robust.Client.Console;
|
||||
using Robust.Client.GameStates;
|
||||
using Robust.Client.Player;
|
||||
using Robust.Client.Timing;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Client.UserInterface.XAML.Proxy;
|
||||
@@ -33,13 +31,13 @@ using Robust.Shared.Input;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Log;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Map.Components;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Player;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Reflection;
|
||||
using Robust.Shared.Serialization;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.UnitTesting.Pool;
|
||||
using ServerProgram = Robust.Server.Program;
|
||||
|
||||
namespace Robust.UnitTesting
|
||||
@@ -281,7 +279,7 @@ namespace Robust.UnitTesting
|
||||
/// This method must be used before trying to access any state like <see cref="ResolveDependency{T}"/>,
|
||||
/// to prevent race conditions.
|
||||
/// </remarks>
|
||||
public abstract class IntegrationInstance : IDisposable
|
||||
public abstract class IntegrationInstance : IIntegrationInstance
|
||||
{
|
||||
private protected Thread? InstanceThread;
|
||||
private protected IDependencyCollection DependencyCollection = default!;
|
||||
@@ -305,10 +303,11 @@ namespace Robust.UnitTesting
|
||||
|
||||
public virtual IntegrationOptions? Options { get; internal set; }
|
||||
|
||||
public IEntityManager EntMan { get; private set; } = default!;
|
||||
public EntityManager EntMan { get; private set; } = default!;
|
||||
public IPrototypeManager ProtoMan { get; private set; } = default!;
|
||||
public IConfigurationManager CfgMan { get; private set; } = default!;
|
||||
public ISharedPlayerManager PlayerMan { get; private set; } = default!;
|
||||
public INetManager NetMan { get; private set; } = default!;
|
||||
public IGameTiming Timing { get; private set; } = default!;
|
||||
public IMapManager MapMan { get; private set; } = default!;
|
||||
public IConsoleHost ConsoleHost { get; private set; } = default!;
|
||||
@@ -316,21 +315,26 @@ namespace Robust.UnitTesting
|
||||
|
||||
protected virtual void ResolveIoC(IDependencyCollection deps)
|
||||
{
|
||||
EntMan = deps.Resolve<IEntityManager>();
|
||||
EntMan = deps.Resolve<EntityManager>();
|
||||
ProtoMan = deps.Resolve<IPrototypeManager>();
|
||||
CfgMan = deps.Resolve<IConfigurationManager>();
|
||||
PlayerMan = deps.Resolve<ISharedPlayerManager>();
|
||||
Timing = deps.Resolve<IGameTiming>();
|
||||
NetMan = deps.Resolve<INetManager>();
|
||||
MapMan = deps.Resolve<IMapManager>();
|
||||
ConsoleHost = deps.Resolve<IConsoleHost>();
|
||||
Log = deps.Resolve<ILogManager>().GetSawmill("test");
|
||||
}
|
||||
|
||||
[Pure]
|
||||
public T System<T>() where T : IEntitySystem
|
||||
{
|
||||
return EntMan.System<T>();
|
||||
}
|
||||
|
||||
[Pure]
|
||||
public T Resolve<T>() => ResolveDependency<T>();
|
||||
|
||||
public TransformComponent Transform(EntityUid uid)
|
||||
{
|
||||
return EntMan.GetComponent<TransformComponent>(uid);
|
||||
@@ -352,13 +356,7 @@ namespace Robust.UnitTesting
|
||||
await WaitPost(() => ConsoleHost.ExecuteCommand(cmd));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the instance is still alive.
|
||||
/// "Alive" indicates that it is able to receive and process commands.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown if you did not ensure that the instance is idle via <see cref="WaitIdleAsync"/> first.
|
||||
/// </exception>
|
||||
/// <inheritdoc/>
|
||||
public bool IsAlive
|
||||
{
|
||||
get
|
||||
@@ -438,16 +436,7 @@ namespace Robust.UnitTesting
|
||||
return DependencyCollection.Resolve<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wait for the instance to go idle, either through finishing all commands or shutting down/crashing.
|
||||
/// </summary>
|
||||
/// <param name="throwOnUnhandled">
|
||||
/// If true, throw an exception if the server dies on an unhandled exception.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <exception cref="Exception">
|
||||
/// Thrown if <paramref name="throwOnUnhandled"/> is true and the instance shuts down on an unhandled exception.
|
||||
/// </exception>
|
||||
/// <inheritdoc/>
|
||||
public Task WaitIdleAsync(bool throwOnUnhandled = true, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Options?.Asynchronous != false)
|
||||
@@ -558,10 +547,7 @@ namespace Robust.UnitTesting
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue for the server to run n ticks.
|
||||
/// </summary>
|
||||
/// <param name="ticks">The amount of ticks to run.</param>
|
||||
/// <inheritdoc/>
|
||||
public void RunTicks(int ticks)
|
||||
{
|
||||
_isSurelyIdle = false;
|
||||
@@ -569,9 +555,7 @@ namespace Robust.UnitTesting
|
||||
_toInstanceWriter.TryWrite(new RunTicksMessage(ticks, _currentTicksId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="RunTicks"/> followed by <see cref="WaitIdleAsync"/>
|
||||
/// </summary>
|
||||
/// <inheritdoc/>
|
||||
public async Task WaitRunTicks(int ticks)
|
||||
{
|
||||
RunTicks(ticks);
|
||||
@@ -590,12 +574,7 @@ namespace Robust.UnitTesting
|
||||
_toInstanceWriter.TryComplete();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue for a delegate to be ran inside the main loop of the instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Do not run NUnit assertions inside <see cref="Post"/>. Use <see cref="Assert"/> instead.
|
||||
/// </remarks>
|
||||
/// <inheritdoc/>
|
||||
public void Post(Action post)
|
||||
{
|
||||
_isSurelyIdle = false;
|
||||
@@ -609,15 +588,7 @@ namespace Robust.UnitTesting
|
||||
await WaitIdleAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue for a delegate to be ran inside the main loop of the instance,
|
||||
/// rethrowing any exceptions in <see cref="WaitIdleAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exceptions raised inside this callback will be rethrown by <see cref="WaitIdleAsync"/>.
|
||||
/// This makes it ideal for NUnit assertions,
|
||||
/// since rethrowing the NUnit assertion directly provides less noise.
|
||||
/// </remarks>
|
||||
/// <inheritdoc/>
|
||||
public void Assert(Action assertion)
|
||||
{
|
||||
_isSurelyIdle = false;
|
||||
@@ -631,6 +602,8 @@ namespace Robust.UnitTesting
|
||||
await WaitIdleAsync();
|
||||
}
|
||||
|
||||
public virtual Task Cleanup() => Task.CompletedTask;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
@@ -654,7 +627,7 @@ namespace Robust.UnitTesting
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ServerIntegrationInstance : IntegrationInstance
|
||||
public sealed class ServerIntegrationInstance : IntegrationInstance, IServerIntegrationInstance
|
||||
{
|
||||
public ServerIntegrationInstance(ServerIntegrationOptions? options) : base(options)
|
||||
{
|
||||
@@ -724,7 +697,9 @@ namespace Robust.UnitTesting
|
||||
deps.BuildGraph();
|
||||
//ServerProgram.SetupLogging();
|
||||
ServerProgram.InitReflectionManager(deps);
|
||||
deps.Resolve<IReflectionManager>().LoadAssemblies(typeof(RobustIntegrationTest).Assembly);
|
||||
|
||||
if (Options?.LoadTestAssembly != false)
|
||||
deps.Resolve<IReflectionManager>().LoadAssemblies(typeof(RobustIntegrationTest).Assembly);
|
||||
|
||||
var server = DependencyCollection.Resolve<BaseServer>();
|
||||
|
||||
@@ -858,15 +833,17 @@ namespace Robust.UnitTesting
|
||||
}
|
||||
}
|
||||
|
||||
public override Task Cleanup() => RemoveAllDummySessions();
|
||||
|
||||
private Dictionary<string, NetUserId> _dummyUsers = new();
|
||||
private Dictionary<NetUserId, ICommonSession> _dummySessions = new();
|
||||
public IReadOnlyDictionary<string, NetUserId> DummyUsers => _dummyUsers;
|
||||
public IReadOnlyDictionary<NetUserId, ICommonSession> DummySessions => _dummySessions;
|
||||
}
|
||||
|
||||
public sealed class ClientIntegrationInstance : IntegrationInstance
|
||||
public sealed class ClientIntegrationInstance : IntegrationInstance, IClientIntegrationInstance
|
||||
{
|
||||
public ICommonSession? Session => ((IPlayerManager) PlayerMan).LocalSession;
|
||||
public ICommonSession? Session => PlayerMan.LocalSession;
|
||||
public NetUserId? User => Session?.UserId;
|
||||
public EntityUid? AttachedEntity => Session?.AttachedEntity;
|
||||
|
||||
@@ -903,10 +880,10 @@ namespace Robust.UnitTesting
|
||||
/// <summary>
|
||||
/// Wire up the server to connect to when <see cref="IClientNetManager.ClientConnect"/> gets called.
|
||||
/// </summary>
|
||||
public void SetConnectTarget(ServerIntegrationInstance server)
|
||||
public void SetConnectTarget(IServerIntegrationInstance server)
|
||||
{
|
||||
var clientNetManager = ResolveDependency<IntegrationNetManager>();
|
||||
var serverNetManager = server.ResolveDependency<IntegrationNetManager>();
|
||||
var serverNetManager = server.Resolve<IntegrationNetManager>();
|
||||
|
||||
if (!serverNetManager.IsRunning)
|
||||
{
|
||||
@@ -916,6 +893,14 @@ namespace Robust.UnitTesting
|
||||
clientNetManager.NextConnectChannel = serverNetManager.MessageChannelWriter;
|
||||
}
|
||||
|
||||
public async Task Connect(IServerIntegrationInstance target)
|
||||
{
|
||||
await WaitIdleAsync();
|
||||
await target.WaitIdleAsync();
|
||||
SetConnectTarget(target);
|
||||
await WaitPost(() => ((IClientNetManager) NetMan).ClientConnect(null!, 0, null!));
|
||||
}
|
||||
|
||||
public async Task CheckSandboxed(Assembly assembly)
|
||||
{
|
||||
await WaitIdleAsync();
|
||||
@@ -970,7 +955,9 @@ namespace Robust.UnitTesting
|
||||
deps.BuildGraph();
|
||||
|
||||
GameController.RegisterReflection(deps);
|
||||
deps.Resolve<IReflectionManager>().LoadAssemblies(typeof(RobustIntegrationTest).Assembly);
|
||||
|
||||
if (Options?.LoadTestAssembly != false)
|
||||
deps.Resolve<IReflectionManager>().LoadAssemblies(typeof(RobustIntegrationTest).Assembly);
|
||||
|
||||
var client = DependencyCollection.Resolve<GameController>();
|
||||
|
||||
@@ -1202,6 +1189,8 @@ namespace Robust.UnitTesting
|
||||
public Action? BeforeStart { get; set; }
|
||||
public Assembly[]? ContentAssemblies { get; set; }
|
||||
|
||||
public bool LoadTestAssembly { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// String containing extra prototypes to load. Contents of the string are treated like a yaml file in the
|
||||
/// resources folder.
|
||||
|
||||
170
Robust.UnitTesting/Server/GameStates/PvsPauseTest.cs
Normal file
170
Robust.UnitTesting/Server/GameStates/PvsPauseTest.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Threading.Tasks;
|
||||
using NUnit.Framework;
|
||||
using Robust.Client.GameStates;
|
||||
using Robust.Shared;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Network;
|
||||
|
||||
namespace Robust.UnitTesting.Server.GameStates;
|
||||
|
||||
public sealed class PvsPauseTest : RobustIntegrationTest
|
||||
{
|
||||
/// <summary>
|
||||
/// Check that the client "pauses" entities that have left their PVS range.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task PauseTest()
|
||||
{
|
||||
var server = StartServer();
|
||||
var client = StartClient();
|
||||
|
||||
await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync());
|
||||
|
||||
var sEntMan = server.EntMan;
|
||||
var confMan = server.CfgMan;
|
||||
var sPlayerMan = server.PlayerMan;
|
||||
var xforms = sEntMan.System<SharedTransformSystem>();
|
||||
var metaSys = sEntMan.System<MetaDataSystem>();
|
||||
var stateMan = (ClientGameStateManager) client.ResolveDependency<IClientGameStateManager>();
|
||||
|
||||
var cEntMan = client.EntMan;
|
||||
var cPlayerMan = client.PlayerMan;
|
||||
var netMan = client.ResolveDependency<IClientNetManager>();
|
||||
|
||||
Assert.DoesNotThrow(() => client.SetConnectTarget(server));
|
||||
client.Post(() => netMan.ClientConnect(null!, 0, null!));
|
||||
server.Post(() => confMan.SetCVar(CVars.NetPVS, true));
|
||||
|
||||
async Task RunTicks()
|
||||
{
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await server.WaitRunTicks(1);
|
||||
await client.WaitRunTicks(1);
|
||||
}
|
||||
}
|
||||
|
||||
await RunTicks();
|
||||
|
||||
// Set up map and spawn player
|
||||
EntityUid map = default;
|
||||
EntityUid playerUid = default;
|
||||
EntityUid sEnt = default;
|
||||
EntityCoordinates coords = default;
|
||||
await server.WaitPost(() =>
|
||||
{
|
||||
map = server.System<SharedMapSystem>().CreateMap();
|
||||
coords = new(map, default);
|
||||
|
||||
playerUid = sEntMan.SpawnEntity(null, coords);
|
||||
sEnt = sEntMan.SpawnEntity(null, coords);
|
||||
// Attach player.
|
||||
var session = sPlayerMan.Sessions.First();
|
||||
server.PlayerMan.SetAttachedEntity(session, playerUid);
|
||||
sPlayerMan.JoinGame(session);
|
||||
});
|
||||
|
||||
await RunTicks();
|
||||
var farAway = new EntityCoordinates(map, new Vector2(100, 100));
|
||||
var netEnt = sEntMan.GetNetEntity(sEnt);
|
||||
var player = sEntMan.GetNetEntity(playerUid);
|
||||
Assert.That(player, Is.Not.EqualTo(NetEntity.Invalid));
|
||||
Assert.That(netEnt, Is.Not.EqualTo(NetEntity.Invalid));
|
||||
|
||||
// Check player got properly attached, and has received the other entity.
|
||||
Assert.That(cEntMan.TryGetEntity(netEnt, out var uid));
|
||||
Assert.That(cEntMan.TryGetEntity(player, out var cPlayerUid));
|
||||
var cEnt = uid!.Value;
|
||||
Assert.That(cPlayerMan.LocalEntity, Is.EqualTo(cPlayerUid));
|
||||
|
||||
void AssertEnt(bool paused, bool detached, bool clientPaused)
|
||||
{
|
||||
var cMeta = client.MetaData(cEnt);
|
||||
var sMeta = server.MetaData(sEnt);
|
||||
|
||||
Assert.That(cMeta.Flags.HasFlag(MetaDataFlags.Detached), Is.EqualTo(detached));
|
||||
Assert.That(sMeta.Flags.HasFlag(MetaDataFlags.Detached), Is.False);
|
||||
|
||||
Assert.That(stateMan.IsQueuedForDetach(netEnt), Is.False);
|
||||
Assert.That(sMeta.EntityPaused, Is.EqualTo(paused));
|
||||
Assert.That(cMeta.EntityPaused, Is.EqualTo(clientPaused));
|
||||
|
||||
if (detached)
|
||||
Assert.That(cMeta.PauseTime, Is.EqualTo(TimeSpan.Zero));
|
||||
if (clientPaused)
|
||||
Assert.That(cMeta.PauseTime, Is.GreaterThanOrEqualTo(TimeSpan.Zero));
|
||||
else
|
||||
Assert.That(cMeta.PauseTime, Is.Null);
|
||||
|
||||
if (paused)
|
||||
Assert.That(sMeta.PauseTime, Is.GreaterThan(TimeSpan.Zero));
|
||||
else
|
||||
Assert.That(sMeta.PauseTime, Is.Null);
|
||||
}
|
||||
|
||||
// Entity is initially in view and not paused.
|
||||
AssertEnt(paused: false, detached: false, clientPaused: false);
|
||||
|
||||
// Move the player out of the entity's PVS range
|
||||
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), farAway));
|
||||
await RunTicks();
|
||||
|
||||
// Client should now have detached & locally paused the entity.
|
||||
AssertEnt(paused: false, detached: true, clientPaused: true);
|
||||
|
||||
// Move the player back into range
|
||||
await server.WaitPost( () => xforms.SetCoordinates(sEntMan.GetEntity(player), coords));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: false, detached: false, clientPaused: false);
|
||||
|
||||
// Actually pause the entity.
|
||||
await server.WaitPost(() => metaSys.SetEntityPaused(sEnt, true));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: true, detached: false, clientPaused: true);
|
||||
|
||||
// Out of range
|
||||
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), farAway));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: true, detached: true, clientPaused: true);
|
||||
|
||||
// Back in range
|
||||
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), coords));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: true, detached: false, clientPaused: true);
|
||||
|
||||
// Unpause the entity while out of range
|
||||
{
|
||||
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), farAway));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: true, detached: true, clientPaused: true);
|
||||
|
||||
await server.WaitPost(() => metaSys.SetEntityPaused(sEnt, false));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: false, detached: true, clientPaused: true);
|
||||
|
||||
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), coords));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: false, detached: false, clientPaused: false);
|
||||
}
|
||||
|
||||
// Pause the entity while out of range
|
||||
{
|
||||
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), farAway));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: false, detached: true, clientPaused: true);
|
||||
|
||||
await server.WaitPost(() => metaSys.SetEntityPaused(sEnt, true));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: true, detached: true, clientPaused: true);
|
||||
|
||||
await server.WaitPost(() => xforms.SetCoordinates(sEntMan.GetEntity(player), coords));
|
||||
await RunTicks();
|
||||
AssertEnt(paused: true, detached: false, clientPaused: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user