mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-02-15 03:30:53 +01:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a47ecc5b5 | ||
|
|
f694f0e311 | ||
|
|
cd90cc6e26 | ||
|
|
3923dd39ae | ||
|
|
3caffa04da | ||
|
|
06b377d1d5 | ||
|
|
41fb191dda | ||
|
|
d4bcc1dc05 | ||
|
|
84dcd658aa | ||
|
|
a634d6bd04 | ||
|
|
36f9df3079 | ||
|
|
824c018a69 | ||
|
|
4b6b688c72 | ||
|
|
71df25b251 | ||
|
|
be14a3c249 | ||
|
|
3c2a4d5c79 | ||
|
|
44180b3ee0 | ||
|
|
bb0e77e937 | ||
|
|
684b9bc852 | ||
|
|
9f3db6693e | ||
|
|
40d869948d | ||
|
|
5c97b15849 | ||
|
|
3d8a9a41fa | ||
|
|
92fc8722da | ||
|
|
73f6555624 | ||
|
|
2ac7bc3ce4 | ||
|
|
05cb4bb1c9 | ||
|
|
a393efc87a | ||
|
|
4d47cfa1a6 | ||
|
|
2b1d755d9f | ||
|
|
db7de0a99f | ||
|
|
47f18703af | ||
|
|
97c1548301 | ||
|
|
cd97f1583f | ||
|
|
5fbe25ec9d | ||
|
|
516ee47b51 | ||
|
|
89be682e24 | ||
|
|
6086076559 | ||
|
|
5bd90c908a | ||
|
|
a3d0921cc9 | ||
|
|
15d5b9aa02 | ||
|
|
d24854d94f | ||
|
|
b3cf427013 | ||
|
|
c458abdc69 |
@@ -57,7 +57,7 @@
|
||||
<PackageVersion Include="SharpZstd.Interop" Version="1.5.2-beta2" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.7" />
|
||||
<PackageVersion Include="SpaceWizards.HttpListener" Version="0.1.1" />
|
||||
<PackageVersion Include="SpaceWizards.NFluidsynth" Version="0.1.1" />
|
||||
<PackageVersion Include="SpaceWizards.NFluidsynth" Version="0.2.2" />
|
||||
<PackageVersion Include="SpaceWizards.SharpFont" Version="1.0.2" />
|
||||
<PackageVersion Include="SpaceWizards.Sodium" Version="0.2.1" />
|
||||
<PackageVersion Include="TerraFX.Interop.Windows" Version="10.0.26100.1" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project>
|
||||
|
||||
<!-- This file automatically reset by Tools/version.py -->
|
||||
<!-- This file automatically reset by Tools/version.py -->
|
||||
|
||||
|
||||
122
RELEASE-NOTES.md
122
RELEASE-NOTES.md
@@ -54,6 +54,128 @@ END TEMPLATE-->
|
||||
*None yet*
|
||||
|
||||
|
||||
## 260.2.4
|
||||
|
||||
|
||||
## 260.2.3
|
||||
|
||||
|
||||
## 260.2.2
|
||||
|
||||
|
||||
## 260.2.1
|
||||
|
||||
|
||||
## 260.2.0
|
||||
|
||||
### New features
|
||||
|
||||
* Add `StringBuilder.Insert(int, string)` to sandbox.
|
||||
* Add the WorldNormal to the StartCollideEvent.
|
||||
|
||||
|
||||
## 260.1.0
|
||||
|
||||
### New features
|
||||
|
||||
* `ComponentFactory` is now exposed to `EntitySystem` as `Factory`
|
||||
|
||||
### Other
|
||||
|
||||
* Cleanup warnings in PLacementManager
|
||||
* Cleanup warnings in Clide.Sprite
|
||||
|
||||
## 260.0.0
|
||||
|
||||
### Breaking changes
|
||||
|
||||
* Fix / change `StartCollideEvent.WorldPoint` to return all points for the collision which may be up to 2 instead of 1.
|
||||
|
||||
### New features
|
||||
|
||||
* Add SpriteSystem dependency to VisualizerSystem.
|
||||
* Add Vertical property to progress bars
|
||||
* Add some `EntProtoId` overloads for group entity spawn methods.
|
||||
|
||||
|
||||
## 259.0.0
|
||||
|
||||
### Breaking changes
|
||||
|
||||
* TileChangedEvent now has an array of tile changed entries rather than raising an individual event for every single tile changed.
|
||||
|
||||
### Other
|
||||
|
||||
* `Entity<T>` methods were marked as `readonly` as appropriate.
|
||||
|
||||
|
||||
## 258.0.1
|
||||
|
||||
### Bugfixes
|
||||
|
||||
* Fix static physics bodies not generating contacts if they spawn onto sleeping bodies.
|
||||
|
||||
|
||||
## 258.0.0
|
||||
|
||||
### Breaking changes
|
||||
|
||||
* `IMarkupTag` and related methods in `MarkupTagManager` have been obsoleted and should be replaced with the new `IMarkupTagHandler` interface. Various engine tags (e.g., `BoldTag`, `ColorTag`, etc) no longer implement the old interface.
|
||||
|
||||
### New features
|
||||
|
||||
* Add IsValidPath to ResPath and make some minor performance improvements.
|
||||
|
||||
### Bugfixes
|
||||
|
||||
* OutputPanel and RichTextLabel now remove controls associated with rich text tags when the text is updated.
|
||||
* Fix `SpriteComponent.Visible` datafield not being read from yaml.
|
||||
* Fix container state handling not forcing inserts.
|
||||
|
||||
### Other
|
||||
|
||||
* `SpriteSystem.LayerMapReserve()` no longer throws an exception if the specified layer already exists. This makes it behave like the obsoleted `SpriteComponent.LayerMapReserveBlank()`.
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -6,7 +6,7 @@ using Xilium.CefGlue;
|
||||
|
||||
namespace Robust.Client.WebView.Cef
|
||||
{
|
||||
public static class Program
|
||||
internal static class Program
|
||||
{
|
||||
// This was supposed to be the main entry for the subprocess program... It doesn't work.
|
||||
public static int Main(string[] args)
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Net;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Robust.Client.Console;
|
||||
using Robust.Client.Utility;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.ContentPack;
|
||||
using Robust.Shared.IoC;
|
||||
@@ -24,6 +25,7 @@ namespace Robust.Client.WebView.Cef
|
||||
|
||||
[Dependency] private readonly IDependencyCollection _dependencyCollection = default!;
|
||||
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
|
||||
[Dependency] private readonly IGameControllerInternal _gameController = default!;
|
||||
[Dependency] private readonly IResourceManagerInternal _resourceManager = default!;
|
||||
[Dependency] private readonly IClientConsoleHost _consoleHost = default!;
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
@@ -61,7 +63,10 @@ namespace Robust.Client.WebView.Cef
|
||||
|
||||
var cachePath = "";
|
||||
if (_resourceManager.UserData is WritableDirProvider userData)
|
||||
cachePath = userData.GetFullPath(new ResPath("/cef_cache"));
|
||||
{
|
||||
var rootDir = UserDataDir.GetRootUserDataDir(_gameController);
|
||||
cachePath = Path.Combine(rootDir, "cef_cache", "0");
|
||||
}
|
||||
|
||||
var settings = new CefSettings()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@ namespace Robust.Client
|
||||
|
||||
_prof.Initialize();
|
||||
|
||||
_resManager.Initialize(Options.LoadConfigAndUserData ? userDataDir : null);
|
||||
_resManager.Initialize(Options.LoadConfigAndUserData ? userDataDir : null, hideUserDataDir: true);
|
||||
|
||||
var mountOptions = _commandLineArgs != null
|
||||
? MountOptions.Merge(_commandLineArgs.MountOptions, Options.MountOptions)
|
||||
|
||||
@@ -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
@@ -1,15 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Robust.Shared.Collections;
|
||||
using Robust.Shared.Containers;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.GameStates;
|
||||
using Robust.Shared.IoC;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Utility;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Robust.Shared.Serialization;
|
||||
using Robust.Shared.Utility;
|
||||
using static Robust.Shared.Containers.ContainerManagerComponent;
|
||||
|
||||
namespace Robust.Client.GameObjects
|
||||
@@ -58,7 +57,7 @@ namespace Robust.Client.GameObjects
|
||||
if (!RemoveExpectedEntity(meta.NetEntity, out var container))
|
||||
return;
|
||||
|
||||
Insert((uid, TransformQuery.GetComponent(uid), MetaQuery.GetComponent(uid), null), container);
|
||||
Insert((uid, TransformQuery.GetComponent(uid), MetaQuery.GetComponent(uid), null), container, force: true);
|
||||
}
|
||||
|
||||
public override void ShutdownContainer(BaseContainer container)
|
||||
@@ -232,7 +231,7 @@ namespace Robust.Client.GameObjects
|
||||
return;
|
||||
}
|
||||
|
||||
Insert(message.Entity, container);
|
||||
Insert(message.Entity, container, force: true);
|
||||
}
|
||||
|
||||
public void AddExpectedEntity(NetEntity netEntity, BaseContainer container)
|
||||
|
||||
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
|
||||
}
|
||||
299
Robust.Client/GameObjects/EntitySystems/SpriteSystem.LayerMap.cs
Normal file
299
Robust.Client/GameObjects/EntitySystems/SpriteSystem.LayerMap.cs
Normal file
@@ -0,0 +1,299 @@
|
||||
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>
|
||||
/// Ensures that a layer with the given key exists and return the layer's index.
|
||||
/// If the layer does not yet exist, this will create and add a blank layer.
|
||||
/// </summary>
|
||||
public int LayerMapReserve(Entity<SpriteComponent?> sprite, Enum key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
if (LayerMapTryGet(sprite, key, out var layerIndex, false))
|
||||
return layerIndex;
|
||||
|
||||
var layer = AddBlankLayer(sprite!);
|
||||
LayerMapSet(sprite, key, layer.Index);
|
||||
return layer.Index;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="LayerMapReserve(Entity{SpriteComponent?},System.Enum)"/>
|
||||
public int LayerMapReserve(Entity<SpriteComponent?> sprite, string key)
|
||||
{
|
||||
if (!_query.Resolve(sprite.Owner, ref sprite.Comp))
|
||||
return -1;
|
||||
|
||||
if (LayerMapTryGet(sprite, key, out var layerIndex, false))
|
||||
return layerIndex;
|
||||
|
||||
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;
|
||||
|
||||
@@ -11,6 +11,7 @@ public abstract class VisualizerSystem<T> : EntitySystem
|
||||
{
|
||||
[Dependency] protected readonly AppearanceSystem AppearanceSystem = default!;
|
||||
[Dependency] protected readonly AnimationPlayerSystem AnimationSystem = default!;
|
||||
[Dependency] protected readonly SpriteSystem SpriteSystem = default!;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -412,8 +412,11 @@ namespace Robust.Client.Graphics.Clyde
|
||||
private void _updateTileMapOnUpdate(ref TileChangedEvent args)
|
||||
{
|
||||
var gridData = _mapChunkData.GetOrNew(args.Entity);
|
||||
if (gridData.TryGetValue(args.ChunkIndex, out var data))
|
||||
data.Dirty = true;
|
||||
foreach (var change in args.Changes)
|
||||
{
|
||||
if (gridData.TryGetValue(change.ChunkIndex, out var data))
|
||||
data.Dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void _updateOnGridCreated(GridStartupEvent ev)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -153,7 +153,7 @@ internal partial class Clyde
|
||||
|
||||
// special casing angle = n*pi/2 to avoid box rotation & bounding calculations doesn't seem to give significant speedups.
|
||||
data.SpriteScreenBB = TransformCenteredBox(
|
||||
data.Sprite.Bounds,
|
||||
_spriteSystem.GetLocalBounds((data.Uid, data.Sprite)),
|
||||
finalRotation,
|
||||
pos + batch.PreScaleViewOffset,
|
||||
batch.ViewScale);
|
||||
|
||||
@@ -10,6 +10,7 @@ internal sealed partial class Clyde
|
||||
private MapSystem _mapSystem = default!;
|
||||
private LightTreeSystem _lightTreeSystem = default!;
|
||||
private TransformSystem _transformSystem = default!;
|
||||
private SpriteSystem _spriteSystem = default!;
|
||||
private SpriteTreeSystem _spriteTreeSystem = default!;
|
||||
private ClientOccluderSystem _occluderSystem = default!;
|
||||
|
||||
@@ -24,6 +25,7 @@ internal sealed partial class Clyde
|
||||
_mapSystem = _entitySystemManager.GetEntitySystem<MapSystem>();
|
||||
_lightTreeSystem = _entitySystemManager.GetEntitySystem<LightTreeSystem>();
|
||||
_transformSystem = _entitySystemManager.GetEntitySystem<TransformSystem>();
|
||||
_spriteSystem = _entitySystemManager.GetEntitySystem<SpriteSystem>();
|
||||
_spriteTreeSystem = _entitySystemManager.GetEntitySystem<SpriteTreeSystem>();
|
||||
_occluderSystem = _entitySystemManager.GetEntitySystem<ClientOccluderSystem>();
|
||||
}
|
||||
@@ -33,6 +35,7 @@ internal sealed partial class Clyde
|
||||
_mapSystem = null!;
|
||||
_lightTreeSystem = null!;
|
||||
_transformSystem = null!;
|
||||
_spriteSystem = null!;
|
||||
_spriteTreeSystem = null!;
|
||||
_occluderSystem = null!;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ namespace Robust.Client.Placement
|
||||
|
||||
private SharedMapSystem Maps => EntityManager.System<SharedMapSystem>();
|
||||
private SharedTransformSystem XformSystem => EntityManager.System<SharedTransformSystem>();
|
||||
private SpriteSystem Sprite => EntityManager.System<SpriteSystem>();
|
||||
|
||||
/// <summary>
|
||||
/// How long before a pending tile change is dropped.
|
||||
@@ -359,12 +360,15 @@ namespace Robust.Client.Placement
|
||||
|
||||
private void HandleTileChanged(ref TileChangedEvent args)
|
||||
{
|
||||
var coords = Maps.GridTileToLocal(
|
||||
args.NewTile.GridUid,
|
||||
EntityManager.GetComponent<MapGridComponent>(args.NewTile.GridUid),
|
||||
args.NewTile.GridIndices);
|
||||
foreach (var change in args.Changes)
|
||||
{
|
||||
var coords = Maps.GridTileToLocal(
|
||||
args.Entity,
|
||||
args.Entity.Comp,
|
||||
change.GridIndices);
|
||||
|
||||
_pendingTileChanges.RemoveAll(c => c.Item1 == coords);
|
||||
_pendingTileChanges.RemoveAll(c => c.Item1 == coords);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -708,11 +712,11 @@ namespace Robust.Client.Placement
|
||||
CurrentPlacementOverlayEntity = null;
|
||||
}
|
||||
|
||||
private SpriteComponent SetupPlacementOverlayEntity()
|
||||
private Entity<SpriteComponent> SetupPlacementOverlayEntity()
|
||||
{
|
||||
EnsureNoPlacementOverlayEntity();
|
||||
CurrentPlacementOverlayEntity = EntityManager.SpawnEntity(null, MapCoordinates.Nullspace);
|
||||
return EntityManager.EnsureComponent<SpriteComponent>(CurrentPlacementOverlayEntity.Value);
|
||||
return (CurrentPlacementOverlayEntity.Value, EntityManager.EnsureComponent<SpriteComponent>(CurrentPlacementOverlayEntity.Value));
|
||||
}
|
||||
|
||||
private void PreparePlacement(string templateName)
|
||||
@@ -729,10 +733,16 @@ namespace Robust.Client.Placement
|
||||
EntityManager.GetComponent<MetaDataComponent>(CurrentPlacementOverlayEntity.Value));
|
||||
}
|
||||
|
||||
public void PreparePlacementSprite(SpriteComponent sprite)
|
||||
public void PreparePlacementSprite(Entity<SpriteComponent> sprite)
|
||||
{
|
||||
var sc = SetupPlacementOverlayEntity();
|
||||
sc.CopyFrom(sprite);
|
||||
Sprite.CopySprite(sprite.AsNullable(), sc.AsNullable());
|
||||
}
|
||||
|
||||
[Obsolete("Use the Entity<SpriteComponent> overload.")]
|
||||
public void PreparePlacementSprite(SpriteComponent sprite)
|
||||
{
|
||||
PreparePlacementSprite((sprite.Owner, sprite));
|
||||
}
|
||||
|
||||
public void PreparePlacementTexList(List<IDirectionalTextureProvider>? texs, bool noRot, EntityPrototype? prototype)
|
||||
@@ -743,27 +753,27 @@ namespace Robust.Client.Placement
|
||||
// This one covers most cases (including Construction)
|
||||
foreach (var v in texs)
|
||||
{
|
||||
if (v is RSI.State)
|
||||
if (v is RSI.State st)
|
||||
{
|
||||
var st = (RSI.State) v;
|
||||
sc.AddLayer(st.StateId, st.RSI);
|
||||
Sprite.AddRsiLayer(sc.AsNullable(), st.StateId, st.RSI);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback
|
||||
sc.AddLayer(v.Default);
|
||||
Sprite.AddTextureLayer(sc.AsNullable(), v.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sc.AddLayer(new ResPath("/Textures/Interface/tilebuildoverlay.png"));
|
||||
Sprite.AddTextureLayer(sc.AsNullable(), new ResPath("/Textures/Interface/tilebuildoverlay.png"));
|
||||
}
|
||||
sc.NoRotation = noRot;
|
||||
|
||||
sc.Comp.NoRotation = noRot;
|
||||
|
||||
if (prototype != null && prototype.TryGetComponent<SpriteComponent>("Sprite", out var spriteComp))
|
||||
{
|
||||
sc.Scale = spriteComp.Scale;
|
||||
Sprite.SetScale(sc.AsNullable(), spriteComp.Scale);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -771,7 +781,7 @@ namespace Robust.Client.Placement
|
||||
private void PreparePlacementTile()
|
||||
{
|
||||
var sc = SetupPlacementOverlayEntity();
|
||||
sc.AddLayer(new ResPath("/Textures/Interface/tilebuildoverlay.png"));
|
||||
Sprite.AddTextureLayer(sc.AsNullable(), new ResPath("/Textures/Interface/tilebuildoverlay.png"));
|
||||
|
||||
IsActive = true;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ namespace Robust.Client.Player
|
||||
{
|
||||
if (_client.RunLevel != ClientRunLevel.SinglePlayerGame)
|
||||
Sawmill.Warning($"Attaching local player to an entity {EntManager.ToPrettyString(uid)} without an eye. This eye will not be netsynced and may cause issues.");
|
||||
var eye = (EyeComponent) Factory.GetComponent(typeof(EyeComponent));
|
||||
var eye = Factory.GetComponent<EyeComponent>();
|
||||
eye.NetSyncEnabled = false;
|
||||
EntManager.AddComponent(uid.Value, eye);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -191,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 =>
|
||||
@@ -294,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);
|
||||
@@ -305,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ namespace Robust.Client.UserInterface.Controls
|
||||
public void Clear()
|
||||
{
|
||||
_firstLine = true;
|
||||
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
entry.RemoveControls();
|
||||
}
|
||||
|
||||
_entries.Clear();
|
||||
_totalContentHeight = 0;
|
||||
_scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight);
|
||||
@@ -104,6 +110,7 @@ namespace Robust.Client.UserInterface.Controls
|
||||
public void RemoveEntry(Index index)
|
||||
{
|
||||
var entry = _entries[index];
|
||||
entry.RemoveControls();
|
||||
_entries.RemoveAt(index.GetOffset(_entries.Count));
|
||||
|
||||
var font = _getFont();
|
||||
@@ -189,6 +196,9 @@ namespace Robust.Client.UserInterface.Controls
|
||||
if (entryOffset > contentBox.Height)
|
||||
{
|
||||
entry.HideControls();
|
||||
|
||||
// We know that every subsequent entry will also fail the test, but we also need to
|
||||
// hide all the controls, so we cannot simply break out of the loop
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,27 @@ namespace Robust.Client.UserInterface.Controls
|
||||
private StyleBox? _backgroundStyleBoxOverride;
|
||||
private StyleBox? _foregroundStyleBoxOverride;
|
||||
|
||||
private bool _vertical;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the progress bar is oriented vertically.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A vertical progress bar fills from bottom to top.
|
||||
/// </remarks>
|
||||
public bool Vertical
|
||||
{
|
||||
get => _vertical;
|
||||
set
|
||||
{
|
||||
if (_vertical != value)
|
||||
{
|
||||
_vertical = value;
|
||||
InvalidateMeasure();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public StyleBox? BackgroundStyleBoxOverride
|
||||
{
|
||||
get => _backgroundStyleBoxOverride;
|
||||
@@ -70,11 +91,23 @@ namespace Robust.Client.UserInterface.Controls
|
||||
{
|
||||
return;
|
||||
}
|
||||
var minSize = fg.MinimumSize;
|
||||
var size = PixelWidth * GetAsRatio() - minSize.X;
|
||||
if (size > 0)
|
||||
|
||||
if (_vertical)
|
||||
{
|
||||
fg.Draw(handle, UIBox2.FromDimensions(0, 0, minSize.X + size, PixelHeight), UIScale);
|
||||
var size = PixelHeight * GetAsRatio();
|
||||
if (size > 0)
|
||||
{
|
||||
fg.Draw(handle, UIBox2.FromDimensions(0, PixelHeight - size, PixelWidth, size), UIScale);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var minSize = fg.MinimumSize;
|
||||
var size = PixelWidth * GetAsRatio() - minSize.X;
|
||||
if (size > 0)
|
||||
{
|
||||
fg.Draw(handle, UIBox2.FromDimensions(0, 0, minSize.X + size, PixelHeight), UIScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using JetBrains.Annotations;
|
||||
using Robust.Client.Graphics;
|
||||
@@ -15,8 +17,7 @@ namespace Robust.Client.UserInterface.Controls
|
||||
{
|
||||
[Dependency] private readonly MarkupTagManager _tagManager = default!;
|
||||
|
||||
private FormattedMessage? _message;
|
||||
private RichTextEntry _entry;
|
||||
private RichTextEntry? _entry;
|
||||
private float _lineHeightScale = 1;
|
||||
private bool _lineHeightOverride;
|
||||
|
||||
@@ -40,19 +41,26 @@ namespace Robust.Client.UserInterface.Controls
|
||||
|
||||
public string? Text
|
||||
{
|
||||
get => _message?.ToMarkup();
|
||||
get => _entry?.Message.ToMarkup();
|
||||
set
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
_message?.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
SetMessage(FormattedMessage.FromMarkupPermissive(value));
|
||||
Clear();
|
||||
else
|
||||
SetMessage(FormattedMessage.FromMarkupPermissive(value));
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_entry?.RemoveControls();
|
||||
_entry = null;
|
||||
InvalidateMeasure();
|
||||
}
|
||||
|
||||
public IEnumerable<Control> Controls => _entry?.Controls?.Values ?? Enumerable.Empty<Control>();
|
||||
public IReadOnlyList<MarkupNode> Nodes => _entry?.Message.Nodes ?? Array.Empty<MarkupNode>();
|
||||
|
||||
public RichTextLabel()
|
||||
{
|
||||
IoCManager.InjectDependencies(this);
|
||||
@@ -61,8 +69,8 @@ namespace Robust.Client.UserInterface.Controls
|
||||
|
||||
public void SetMessage(FormattedMessage message, Type[]? tagsAllowed = null, Color? defaultColor = null)
|
||||
{
|
||||
_message = message;
|
||||
_entry = new RichTextEntry(_message, this, _tagManager, tagsAllowed, defaultColor);
|
||||
_entry?.RemoveControls();
|
||||
_entry = new RichTextEntry(message, this, _tagManager, tagsAllowed, defaultColor);
|
||||
InvalidateMeasure();
|
||||
}
|
||||
|
||||
@@ -73,31 +81,31 @@ namespace Robust.Client.UserInterface.Controls
|
||||
SetMessage(msg, tagsAllowed, defaultColor);
|
||||
}
|
||||
|
||||
public string? GetMessage() => _message?.ToMarkup();
|
||||
public string? GetMessage() => _entry?.Message.ToMarkup();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a copy of the currently used formatted message.
|
||||
/// </summary>
|
||||
public FormattedMessage? GetFormattedMessage() => _entry == null ? null : new FormattedMessage(_entry.Value.Message);
|
||||
|
||||
protected override Vector2 MeasureOverride(Vector2 availableSize)
|
||||
{
|
||||
if (_message == null)
|
||||
{
|
||||
if (_entry == null)
|
||||
return Vector2.Zero;
|
||||
}
|
||||
|
||||
var font = _getFont();
|
||||
_entry.Update(_tagManager, font, availableSize.X * UIScale, UIScale, LineHeightScale);
|
||||
|
||||
return new Vector2(_entry.Width / UIScale, _entry.Height / UIScale);
|
||||
// _entry is nullable struct.
|
||||
// cannot just call _entry.Value.Update() as that doesn't actually update _entry.
|
||||
_entry = _entry.Value.Update(_tagManager, font, availableSize.X * UIScale, UIScale, LineHeightScale);
|
||||
|
||||
return new Vector2(_entry.Value.Width / UIScale, _entry.Value.Height / UIScale);
|
||||
}
|
||||
|
||||
protected internal override void Draw(DrawingHandleScreen handle)
|
||||
{
|
||||
base.Draw(handle);
|
||||
|
||||
if (_message == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_entry.Draw(_tagManager, handle, _getFont(), SizeBox, 0, new MarkupDrawingContext(), UIScale, LineHeightScale);
|
||||
_entry?.Draw(_tagManager, handle, _getFont(), SizeBox, 0, new MarkupDrawingContext(), UIScale, LineHeightScale);
|
||||
}
|
||||
|
||||
[Pure]
|
||||
|
||||
@@ -5,7 +5,7 @@ using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.UserInterface.RichText;
|
||||
|
||||
public sealed class BoldItalicTag : IMarkupTag
|
||||
public sealed class BoldItalicTag : IMarkupTagHandler
|
||||
{
|
||||
public const string BoldItalicFont = "DefaultBoldItalic";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.UserInterface.RichText;
|
||||
|
||||
public sealed class BoldTag : IMarkupTag
|
||||
public sealed class BoldTag : IMarkupTagHandler
|
||||
{
|
||||
public const string BoldFont = "DefaultBold";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.UserInterface.RichText;
|
||||
|
||||
public sealed class BulletTag : IMarkupTag
|
||||
public sealed class BulletTag : IMarkupTagHandler
|
||||
{
|
||||
public string Name => "bullet";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Robust.Client.UserInterface.RichText;
|
||||
/// <summary>
|
||||
/// Colors the text inside its opening and closing nodes
|
||||
/// </summary>
|
||||
public sealed class ColorTag : IMarkupTag
|
||||
public sealed class ColorTag : IMarkupTagHandler
|
||||
{
|
||||
public static readonly Color DefaultColor = new(200, 200, 200);
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.UserInterface.RichText;
|
||||
|
||||
public sealed class CommandLinkTag : IMarkupTag
|
||||
public sealed class CommandLinkTag : IMarkupTagHandler
|
||||
{
|
||||
[Dependency] private readonly IClientConsoleHost _clientConsoleHost = default!;
|
||||
|
||||
public string Name => "cmdlink";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool TryGetControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
|
||||
public bool TryCreateControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
|
||||
{
|
||||
if (!node.Value.TryGetString(out var text)
|
||||
|| !node.Attributes.TryGetValue("command", out var commandParameter)
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Robust.Client.UserInterface.RichText;
|
||||
/// Applies the font provided as the tags parameter to the markup drawing context.
|
||||
/// Definitely not save for user supplied markup
|
||||
/// </summary>
|
||||
public sealed class FontTag : IMarkupTag
|
||||
public sealed class FontTag : IMarkupTagHandler
|
||||
{
|
||||
public const string DefaultFont = "Default";
|
||||
public const int DefaultSize = 12;
|
||||
|
||||
@@ -6,7 +6,7 @@ using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.UserInterface.RichText;
|
||||
|
||||
public sealed class HeadingTag : IMarkupTag
|
||||
public sealed class HeadingTag : IMarkupTagHandler
|
||||
{
|
||||
[Dependency] private readonly IResourceCache _resourceCache = default!;
|
||||
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.UserInterface.RichText;
|
||||
|
||||
public interface IMarkupTag
|
||||
/// <summary>
|
||||
/// Classes that implement this interface will be instantiated by <see cref="MarkupTagManager"/> and used to handle
|
||||
/// the parsing and behaviour of markup tags. Note that each class is only ever instantiated once by the tag manager,
|
||||
/// and wil be used to handle all tags of that kind, and thus should not contain state information relevant to a
|
||||
/// specific tag.
|
||||
/// </summary>
|
||||
public interface IMarkupTagHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// The string used as the tags name when writing rich text
|
||||
@@ -54,17 +61,32 @@ public interface IMarkupTag
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called inside the constructor of <see cref="RichTextEntry"/> to
|
||||
/// supply a control that gets rendered inline before this tags children<br/>
|
||||
/// Text continues to the right of the control until the next line and then continues bellow it
|
||||
/// Called inside the constructor of <see cref="RichTextEntry"/> to supply a control that gets rendered inline
|
||||
/// before this tags children. The returned control must be new instance to avoid issues with shallow cloning
|
||||
/// <see cref="FormattedMessage"/> nodes. Text continues to the right of the control until the next line and
|
||||
/// then continues bellow it.
|
||||
/// </summary>
|
||||
/// <param name="node">The markup node containing the parameter and attributes</param>
|
||||
/// <param name="control">A UI control for placing in line with this tags children</param>
|
||||
/// <returns>true if this tag supplies a control</returns>
|
||||
public bool TryCreateControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
|
||||
{
|
||||
control = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete("Use IMarkupTagHandler")]
|
||||
public interface IMarkupTag : IMarkupTagHandler
|
||||
{
|
||||
bool IMarkupTagHandler.TryCreateControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
|
||||
{
|
||||
return TryGetControl(node, out control);
|
||||
}
|
||||
|
||||
public bool TryGetControl(MarkupNode node, [NotNullWhen(true)] out Control? control)
|
||||
{
|
||||
control = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Client.UserInterface.RichText;
|
||||
|
||||
public sealed class ItalicTag : IMarkupTag
|
||||
public sealed class ItalicTag : IMarkupTagHandler
|
||||
{
|
||||
public const string ItalicFont = "DefaultItalic";
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class MarkupTagManager
|
||||
/// <summary>
|
||||
/// Tags defined in engine need to be instantiated here because of sandboxing
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, IMarkupTag> _markupTagTypes = new IMarkupTag[] {
|
||||
private readonly Dictionary<string, IMarkupTagHandler> _markupTagTypes = new IMarkupTagHandler[] {
|
||||
new BoldItalicTag(),
|
||||
new BoldTag(),
|
||||
new BulletTag(),
|
||||
@@ -44,13 +44,13 @@ public sealed class MarkupTagManager
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
foreach (var type in _reflectionManager.GetAllChildren<IMarkupTag>())
|
||||
foreach (var type in _reflectionManager.GetAllChildren<IMarkupTagHandler>())
|
||||
{
|
||||
//Prevent tags defined inside engine from being instantiated
|
||||
if (_engineTypes.Contains(type))
|
||||
continue;
|
||||
|
||||
var instance = (IMarkupTag)_sandboxHelper.CreateInstance(type);
|
||||
var instance = (IMarkupTagHandler)_sandboxHelper.CreateInstance(type);
|
||||
_markupTagTypes[instance.Name.ToLower()] = instance;
|
||||
}
|
||||
|
||||
@@ -60,22 +60,48 @@ public sealed class MarkupTagManager
|
||||
}
|
||||
}
|
||||
|
||||
[Obsolete("Use GetMarkupTagHandler")]
|
||||
public IMarkupTag? GetMarkupTag(string name)
|
||||
{
|
||||
return _markupTagTypes.GetValueOrDefault(name) as IMarkupTag;
|
||||
}
|
||||
|
||||
public IMarkupTagHandler? GetMarkupTagHandler(string name)
|
||||
{
|
||||
return _markupTagTypes.GetValueOrDefault(name);
|
||||
}
|
||||
|
||||
public bool TryGetMarkupTag(string name, Type[]? tagsAllowed, [NotNullWhen(true)] out IMarkupTag? tag)
|
||||
/// <summary>
|
||||
/// Attempt to get the tag handler with the corresponding name.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the tag, as specified by <see cref="IMarkupTag.Name"/></param>
|
||||
/// <param name="tagsAllowed">List of allowed tag types. If null, all types are allowed.</param>
|
||||
/// <param name="handler">The instance responsible for handling tags of this type.</param>
|
||||
/// <returns></returns>
|
||||
public bool TryGetMarkupTagHandler(string name, Type[]? tagsAllowed, [NotNullWhen(true)] out IMarkupTagHandler? handler)
|
||||
{
|
||||
if (_markupTagTypes.TryGetValue(name, out var markupTag)
|
||||
// Using a whitelist prevents new tags from sneaking in.
|
||||
&& (tagsAllowed == null || Array.IndexOf(tagsAllowed, markupTag.GetType()) != -1))
|
||||
{
|
||||
tag = markupTag;
|
||||
handler = markupTag;
|
||||
return true;
|
||||
}
|
||||
|
||||
tag = null;
|
||||
handler = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
[Obsolete("Use TryGetMarkupTagHandler")]
|
||||
public bool TryGetMarkupTag(string name, Type[]? tagsAllowed, [NotNullWhen(true)] out IMarkupTag? tag)
|
||||
{
|
||||
if (!TryGetMarkupTagHandler(name, tagsAllowed, out var handler) || handler is not IMarkupTag cast)
|
||||
{
|
||||
tag = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
tag = cast;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ namespace Robust.Client.UserInterface
|
||||
{
|
||||
/// <summary>
|
||||
/// Used by <see cref="OutputPanel"/> and <see cref="RichTextLabel"/> to handle rich text layout.
|
||||
/// Note that if this text is ever removed or modified without removing the owning control,
|
||||
/// then <see cref="RemoveControls"/> should be called to ensure that any controls that were added by this
|
||||
/// entry are also removed.
|
||||
/// </summary>
|
||||
internal struct RichTextEntry
|
||||
{
|
||||
@@ -36,7 +39,7 @@ namespace Robust.Client.UserInterface
|
||||
/// </summary>
|
||||
public ValueList<int> LineBreaks;
|
||||
|
||||
private readonly Dictionary<int, Control>? _tagControls;
|
||||
public readonly Dictionary<int, Control>? Controls;
|
||||
|
||||
public RichTextEntry(FormattedMessage message, Control parent, MarkupTagManager tagManager, Type[]? tagsAllowed = null, Color? defaultColor = null)
|
||||
{
|
||||
@@ -56,15 +59,35 @@ namespace Robust.Client.UserInterface
|
||||
if (node.Name == null)
|
||||
continue;
|
||||
|
||||
if (!tagManager.TryGetMarkupTag(node.Name, _tagsAllowed, out var tag) || !tag.TryGetControl(node, out var control))
|
||||
if (!tagManager.TryGetMarkupTagHandler(node.Name, _tagsAllowed, out var handler) || !handler.TryCreateControl(node, out var control))
|
||||
continue;
|
||||
|
||||
// Markup tag handler instances are shared across controls. We need to ensure that the hanlder doesn't
|
||||
// store state information and return the same control for each rich text entry.
|
||||
DebugTools.Assert(handler.TryCreateControl(node, out var other) && other != control);
|
||||
|
||||
parent.Children.Add(control);
|
||||
tagControls ??= new Dictionary<int, Control>();
|
||||
tagControls.Add(nodeIndex, control);
|
||||
}
|
||||
|
||||
_tagControls = tagControls;
|
||||
Controls = tagControls;
|
||||
}
|
||||
|
||||
// TODO RICH TEXT
|
||||
// Somehow ensure that this **has** to be called when removing rich text from some control.
|
||||
/// <summary>
|
||||
/// Remove all owned controls from their parents.
|
||||
/// </summary>
|
||||
public readonly void RemoveControls()
|
||||
{
|
||||
if (Controls == null)
|
||||
return;
|
||||
|
||||
foreach (var ctrl in Controls.Values)
|
||||
{
|
||||
ctrl.Orphan();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -74,7 +97,7 @@ namespace Robust.Client.UserInterface
|
||||
/// <param name="maxSizeX">The maximum horizontal size of the container of this entry.</param>
|
||||
/// <param name="uiScale"></param>
|
||||
/// <param name="lineHeightScale"></param>
|
||||
public void Update(MarkupTagManager tagManager, Font defaultFont, float maxSizeX, float uiScale, float lineHeightScale = 1)
|
||||
public RichTextEntry Update(MarkupTagManager tagManager, Font defaultFont, float maxSizeX, float uiScale, float lineHeightScale = 1)
|
||||
{
|
||||
// This method is gonna suck due to complexity.
|
||||
// Bear with me here.
|
||||
@@ -112,10 +135,10 @@ namespace Robust.Client.UserInterface
|
||||
continue;
|
||||
|
||||
if (ProcessMetric(ref this, metrics, out breakLine))
|
||||
return;
|
||||
return this;
|
||||
}
|
||||
|
||||
if (_tagControls == null || !_tagControls.TryGetValue(nodeIndex, out var control))
|
||||
if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control))
|
||||
continue;
|
||||
|
||||
control.Measure(new Vector2(Width, Height));
|
||||
@@ -128,12 +151,14 @@ namespace Robust.Client.UserInterface
|
||||
desiredSize.Y);
|
||||
|
||||
if (ProcessMetric(ref this, controlMetrics, out breakLine))
|
||||
return;
|
||||
return this;
|
||||
}
|
||||
|
||||
Width = wordWrap.FinalizeText(out breakLine);
|
||||
CheckLineBreak(ref this, breakLine);
|
||||
|
||||
return this;
|
||||
|
||||
bool ProcessRune(ref RichTextEntry src, Rune rune, out int? outBreakLine)
|
||||
{
|
||||
wordWrap.NextRune(rune, out breakLine, out var breakNewLine, out var skip);
|
||||
@@ -166,9 +191,10 @@ namespace Robust.Client.UserInterface
|
||||
|
||||
internal readonly void HideControls()
|
||||
{
|
||||
if (_tagControls == null)
|
||||
if (Controls == null)
|
||||
return;
|
||||
foreach (var control in _tagControls.Values)
|
||||
|
||||
foreach (var control in Controls.Values)
|
||||
{
|
||||
control.Visible = false;
|
||||
}
|
||||
@@ -220,7 +246,7 @@ namespace Robust.Client.UserInterface
|
||||
globalBreakCounter += 1;
|
||||
}
|
||||
|
||||
if (_tagControls == null || !_tagControls.TryGetValue(nodeIndex, out var control))
|
||||
if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control))
|
||||
continue;
|
||||
|
||||
// Controls may have been previously hidden via HideControls due to being "out-of frame".
|
||||
@@ -243,7 +269,7 @@ namespace Robust.Client.UserInterface
|
||||
return node.Value.StringValue ?? "";
|
||||
|
||||
//Skip the node if there is no markup tag for it.
|
||||
if (!tagManager.TryGetMarkupTag(node.Name, _tagsAllowed, out var tag))
|
||||
if (!tagManager.TryGetMarkupTagHandler(node.Name, _tagsAllowed, out var tag))
|
||||
return "";
|
||||
|
||||
if (!node.Closing)
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -297,7 +297,7 @@ namespace Robust.Server
|
||||
: null;
|
||||
|
||||
// Set up the VFS
|
||||
_resources.Initialize(dataDir);
|
||||
_resources.Initialize(dataDir, hideUserDataDir: false);
|
||||
|
||||
var mountOptions = _commandLineArgs != null
|
||||
? MountOptions.Merge(_commandLineArgs.MountOptions, Options.MountOptions) : Options.MountOptions;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,4 +6,3 @@
|
||||
|
||||
[assembly: InternalsVisibleTo("Robust.Client")]
|
||||
[assembly: InternalsVisibleTo("Robust.UnitTesting")]
|
||||
[assembly: InternalsVisibleTo("Content.Benchmarks")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,6 +88,7 @@ namespace Robust.Shared.ContentPack
|
||||
public string SystemAssemblyName = default!;
|
||||
public HashSet<VerifierError> AllowedVerifierErrors = default!;
|
||||
public List<string> WhitelistedNamespaces = default!;
|
||||
public List<string> AllowedAssemblyPrefixes = default!;
|
||||
public Dictionary<string, Dictionary<string, TypeConfig>> Types = default!;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,16 @@ namespace Robust.Shared.ContentPack
|
||||
return false;
|
||||
}
|
||||
|
||||
#pragma warning disable RA0004
|
||||
var loadedConfig = _config.Result;
|
||||
#pragma warning restore RA0004
|
||||
|
||||
if (!loadedConfig.AllowedAssemblyPrefixes.Any(allowedNamePrefix => asmName.StartsWith(allowedNamePrefix)))
|
||||
{
|
||||
_sawmill.Error($"Assembly name '{asmName}' is not allowed for a content assembly");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (VerifyIL)
|
||||
{
|
||||
if (!DoVerifyIL(asmName, resolver, peReader, reader))
|
||||
@@ -179,10 +189,6 @@ namespace Robust.Shared.ContentPack
|
||||
return true;
|
||||
}
|
||||
|
||||
#pragma warning disable RA0004
|
||||
var loadedConfig = _config.Result;
|
||||
#pragma warning restore RA0004
|
||||
|
||||
var badRefs = new ConcurrentBag<EntityHandle>();
|
||||
|
||||
// We still do explicit type reference scanning, even though the actual whitelists work with raw members.
|
||||
|
||||
@@ -60,9 +60,7 @@ namespace Robust.Shared.ContentPack
|
||||
|
||||
internal string GetPath(ResPath relPath)
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(_directory.FullName, relPath.ToRelativeSystemPath()))
|
||||
// Sanitise platform-specific path and standardize it for engine use.
|
||||
.Replace(Path.DirectorySeparatorChar, '/');
|
||||
return PathHelpers.SafeGetResourcePath(_directory.FullName, relPath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -14,7 +14,11 @@ namespace Robust.Shared.ContentPack
|
||||
/// The directory to use for user data.
|
||||
/// If null, a virtual temporary file system is used instead.
|
||||
/// </param>
|
||||
void Initialize(string? userData);
|
||||
/// <param name="hideUserDataDir">
|
||||
/// If true, <see cref="IWritableDirProvider.RootDir"/> will be hidden on
|
||||
/// <see cref="IResourceManager.UserData"/>.
|
||||
/// </param>
|
||||
void Initialize(string? userData, bool hideUserDataDir);
|
||||
|
||||
/// <summary>
|
||||
/// Mounts a single stream as a content file. Useful for unit testing.
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace Robust.Shared.ContentPack
|
||||
{
|
||||
/// <summary>
|
||||
/// The root path of this provider.
|
||||
/// Can be null if it's a virtual provider.
|
||||
/// Can be null if it's a virtual provider or the path is protected (e.g. on the client).
|
||||
/// </summary>
|
||||
string? RootDir { get; }
|
||||
|
||||
|
||||
@@ -93,19 +93,23 @@ namespace Robust.Shared.ContentPack
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
Sawmill.Debug("LOADING modules");
|
||||
var files = new Dictionary<string, (ResPath Path, string[] references)>();
|
||||
var files = new Dictionary<string, (ResPath Path, MemoryStream data, string[] references)>();
|
||||
|
||||
// Find all modules we want to load.
|
||||
foreach (var fullPath in paths)
|
||||
{
|
||||
using var asmFile = _res.ContentFileRead(fullPath);
|
||||
var refData = GetAssemblyReferenceData(asmFile);
|
||||
var ms = new MemoryStream();
|
||||
asmFile.CopyTo(ms);
|
||||
|
||||
ms.Position = 0;
|
||||
var refData = GetAssemblyReferenceData(ms);
|
||||
if (refData == null)
|
||||
continue;
|
||||
|
||||
var (asmRefs, asmName) = refData.Value;
|
||||
|
||||
if (!files.TryAdd(asmName, (fullPath, asmRefs)))
|
||||
if (!files.TryAdd(asmName, (fullPath, ms, asmRefs)))
|
||||
{
|
||||
Sawmill.Error("Found multiple modules with the same assembly name " +
|
||||
$"'{asmName}', A: {files[asmName].Path}, B: {fullPath}.");
|
||||
@@ -122,10 +126,10 @@ namespace Robust.Shared.ContentPack
|
||||
|
||||
Parallel.ForEach(files, pair =>
|
||||
{
|
||||
var (name, (path, _)) = pair;
|
||||
var (name, (_, data, _)) = pair;
|
||||
|
||||
using var stream = _res.ContentFileRead(path);
|
||||
if (!typeChecker.CheckAssembly(stream, resolver))
|
||||
data.Position = 0;
|
||||
if (!typeChecker.CheckAssembly(data, resolver))
|
||||
{
|
||||
throw new TypeCheckFailedException($"Assembly {name} failed type checks.");
|
||||
}
|
||||
@@ -137,14 +141,15 @@ namespace Robust.Shared.ContentPack
|
||||
var nodes = TopologicalSort.FromBeforeAfter(
|
||||
files,
|
||||
kv => kv.Key,
|
||||
kv => kv.Value.Path,
|
||||
kv => kv.Value,
|
||||
_ => Array.Empty<string>(),
|
||||
kv => kv.Value.references,
|
||||
allowMissing: true); // missing refs would be non-content assemblies so allow that.
|
||||
|
||||
// Actually load them in the order they depend on each other.
|
||||
foreach (var path in TopologicalSort.Sort(nodes))
|
||||
foreach (var item in TopologicalSort.Sort(nodes))
|
||||
{
|
||||
var (path, memory, _) = item;
|
||||
Sawmill.Debug($"Loading module: '{path}'");
|
||||
try
|
||||
{
|
||||
@@ -156,9 +161,9 @@ namespace Robust.Shared.ContentPack
|
||||
}
|
||||
else
|
||||
{
|
||||
using var assemblyStream = _res.ContentFileRead(path);
|
||||
memory.Position = 0;
|
||||
using var symbolsStream = _res.ContentFileReadOrNull(path.WithExtension("pdb"));
|
||||
LoadGameAssembly(assemblyStream, symbolsStream, skipVerify: true);
|
||||
LoadGameAssembly(memory, symbolsStream, skipVerify: true);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
@@ -174,7 +179,7 @@ namespace Robust.Shared.ContentPack
|
||||
|
||||
private (string[] refs, string name)? GetAssemblyReferenceData(Stream stream)
|
||||
{
|
||||
using var reader = ModLoader.MakePEReader(stream);
|
||||
using var reader = ModLoader.MakePEReader(stream, leaveOpen: true);
|
||||
var metaReader = reader.GetMetadataReader();
|
||||
|
||||
var name = metaReader.GetString(metaReader.GetAssemblyDefinition().Name);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Shared.ContentPack
|
||||
{
|
||||
@@ -63,5 +64,27 @@ namespace Robust.Shared.ContentPack
|
||||
!OperatingSystem.IsWindows()
|
||||
&& !OperatingSystem.IsMacOS();
|
||||
|
||||
|
||||
internal static string SafeGetResourcePath(string baseDir, ResPath path)
|
||||
{
|
||||
var relSysPath = path.ToRelativeSystemPath();
|
||||
if (relSysPath.Contains("\\..") || relSysPath.Contains("/.."))
|
||||
{
|
||||
// Hard cap on any exploit smuggling a .. in there.
|
||||
// Since that could allow leaving sandbox.
|
||||
throw new InvalidOperationException($"This branch should never be reached. Path: {path}");
|
||||
}
|
||||
|
||||
var retPath = Path.GetFullPath(Path.Join(baseDir, relSysPath));
|
||||
// better safe than sorry check
|
||||
if (!retPath.StartsWith(baseDir))
|
||||
{
|
||||
// Allow path to match if it's just missing the directory separator at the end.
|
||||
if (retPath != baseDir.TrimEnd(Path.DirectorySeparatorChar))
|
||||
throw new InvalidOperationException($"This branch should never be reached. Path: {path}");
|
||||
}
|
||||
|
||||
return retPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,13 +41,13 @@ namespace Robust.Shared.ContentPack
|
||||
public IWritableDirProvider UserData { get; private set; } = default!;
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual void Initialize(string? userData)
|
||||
public virtual void Initialize(string? userData, bool hideRootDir)
|
||||
{
|
||||
Sawmill = _logManager.GetSawmill("res");
|
||||
|
||||
if (userData != null)
|
||||
{
|
||||
UserData = new WritableDirProvider(Directory.CreateDirectory(userData));
|
||||
UserData = new WritableDirProvider(Directory.CreateDirectory(userData), hideRootDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -379,6 +379,10 @@ namespace Robust.Shared.ContentPack
|
||||
{
|
||||
var rootDir = loader.GetPath(new ResPath(@"/"));
|
||||
|
||||
// TODO: GET RID OF THIS.
|
||||
// This code shouldn't be passing OS disk paths through ResPath.
|
||||
rootDir = rootDir.Replace(Path.DirectorySeparatorChar, '/');
|
||||
|
||||
yield return new ResPath(rootDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ WhitelistedNamespaces:
|
||||
- Content
|
||||
- OpenDreamShared
|
||||
|
||||
AllowedAssemblyPrefixes:
|
||||
- OpenDream
|
||||
- Content
|
||||
|
||||
# The type whitelist does NOT care about which assembly types come from.
|
||||
# This is because types switch assembly all the time.
|
||||
# Just look up stuff like StreamReader on https://apisof.net.
|
||||
@@ -875,6 +879,7 @@ Types:
|
||||
- "System.Text.StringBuilder Insert(int, object)"
|
||||
- "System.Text.StringBuilder Insert(int, sbyte)"
|
||||
- "System.Text.StringBuilder Insert(int, short)"
|
||||
- "System.Text.StringBuilder Insert(int, string)"
|
||||
- "System.Text.StringBuilder Insert(int, string, int)"
|
||||
- "System.Text.StringBuilder Insert(int, System.Decimal)"
|
||||
- "System.Text.StringBuilder Insert(int, System.ReadOnlySpan`1<char>)"
|
||||
|
||||
@@ -10,17 +10,22 @@ namespace Robust.Shared.ContentPack
|
||||
/// <inheritdoc />
|
||||
internal sealed class WritableDirProvider : IWritableDirProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
private readonly bool _hideRootDir;
|
||||
|
||||
public string RootDir { get; }
|
||||
|
||||
string? IWritableDirProvider.RootDir => _hideRootDir ? null : RootDir;
|
||||
|
||||
/// <summary>
|
||||
/// Constructs an instance of <see cref="WritableDirProvider"/>.
|
||||
/// </summary>
|
||||
/// <param name="rootDir">Root file system directory to allow writing.</param>
|
||||
public WritableDirProvider(DirectoryInfo rootDir)
|
||||
/// <param name="hideRootDir">If true, <see cref="IWritableDirProvider.RootDir"/> is reported as null.</param>
|
||||
public WritableDirProvider(DirectoryInfo rootDir, bool hideRootDir)
|
||||
{
|
||||
// FullName does not have a trailing separator, and we MUST have a separator.
|
||||
RootDir = rootDir.FullName + Path.DirectorySeparatorChar.ToString();
|
||||
_hideRootDir = hideRootDir;
|
||||
}
|
||||
|
||||
#region File Access
|
||||
@@ -119,7 +124,7 @@ namespace Robust.Shared.ContentPack
|
||||
throw new FileNotFoundException();
|
||||
|
||||
var dirInfo = new DirectoryInfo(GetFullPath(path));
|
||||
return new WritableDirProvider(dirInfo);
|
||||
return new WritableDirProvider(dirInfo, _hideRootDir);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -180,20 +185,7 @@ namespace Robust.Shared.ContentPack
|
||||
|
||||
path = path.Clean();
|
||||
|
||||
return GetFullPath(RootDir, path);
|
||||
}
|
||||
|
||||
private static string GetFullPath(string root, ResPath path)
|
||||
{
|
||||
var relPath = path.ToRelativeSystemPath();
|
||||
if (relPath.Contains("\\..") || relPath.Contains("/.."))
|
||||
{
|
||||
// Hard cap on any exploit smuggling a .. in there.
|
||||
// Since that could allow leaving sandbox.
|
||||
throw new InvalidOperationException($"This branch should never be reached. Path: {path}");
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(root, relPath));
|
||||
return PathHelpers.SafeGetResourcePath(RootDir, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,11 +273,11 @@ namespace Robust.Shared.GameObjects
|
||||
|
||||
public IComponent GetComponent(Type componentType)
|
||||
{
|
||||
if (!_types.ContainsKey(componentType))
|
||||
if (!_types.TryGetValue(componentType, out var value))
|
||||
{
|
||||
throw new InvalidOperationException($"{componentType} is not a registered component.");
|
||||
}
|
||||
return _typeFactory.CreateInstanceUnchecked<IComponent>(_types[componentType].Type);
|
||||
return _typeFactory.CreateInstanceUnchecked<IComponent>(value.Type);
|
||||
}
|
||||
|
||||
public IComponent GetComponent(CompIdx componentType)
|
||||
@@ -287,11 +287,11 @@ namespace Robust.Shared.GameObjects
|
||||
|
||||
public T GetComponent<T>() where T : IComponent, new()
|
||||
{
|
||||
if (!_types.ContainsKey(typeof(T)))
|
||||
if (!_types.TryGetValue(typeof(T), out var reg))
|
||||
{
|
||||
throw new InvalidOperationException($"{typeof(T)} is not a registered component.");
|
||||
}
|
||||
return _typeFactory.CreateInstanceUnchecked<T>(_types[typeof(T)].Type);
|
||||
return _typeFactory.CreateInstanceUnchecked<T>(reg.Type);
|
||||
}
|
||||
|
||||
public IComponent GetComponent(ComponentRegistration reg)
|
||||
|
||||
@@ -150,6 +150,7 @@ namespace Robust.Shared.GameObjects
|
||||
[ViewVariables, Access(typeof(EntityManager), Other = AccessPermissions.ReadExecute)]
|
||||
public EntityLifeStage EntityLifeStage { get; internal set; }
|
||||
|
||||
[ViewVariables(VVAccess.ReadOnly)]
|
||||
public MetaDataFlags Flags
|
||||
{
|
||||
get => _flags;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ public record struct Entity<T> : IFluentEntityUid, IAsType<EntityUid>
|
||||
{
|
||||
public EntityUid Owner;
|
||||
public T Comp;
|
||||
EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
|
||||
public Entity(EntityUid owner, T comp)
|
||||
{
|
||||
@@ -48,9 +48,9 @@ public record struct Entity<T> : IFluentEntityUid, IAsType<EntityUid>
|
||||
}
|
||||
|
||||
|
||||
public override int GetHashCode() => Owner.GetHashCode();
|
||||
public Entity<T?> AsNullable() => new(Owner, Comp);
|
||||
public EntityUid AsType() => Owner;
|
||||
public override readonly int GetHashCode() => Owner.GetHashCode();
|
||||
public readonly Entity<T?> AsNullable() => new(Owner, Comp);
|
||||
public readonly EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
[NotYamlSerializable]
|
||||
@@ -60,7 +60,7 @@ public record struct Entity<T1, T2> : IFluentEntityUid, IAsType<EntityUid>
|
||||
public EntityUid Owner;
|
||||
public T1 Comp1;
|
||||
public T2 Comp2;
|
||||
EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
|
||||
public Entity(EntityUid owner, T1 comp1, T2 comp2)
|
||||
{
|
||||
@@ -119,9 +119,9 @@ 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;
|
||||
public override readonly int GetHashCode() => Owner.GetHashCode();
|
||||
public readonly Entity<T1?, T2?> AsNullable() => new(Owner, Comp1, Comp2);
|
||||
public readonly EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
[NotYamlSerializable]
|
||||
@@ -132,7 +132,7 @@ public record struct Entity<T1, T2, T3> : IFluentEntityUid, IAsType<EntityUid>
|
||||
public T1 Comp1;
|
||||
public T2 Comp2;
|
||||
public T3 Comp3;
|
||||
EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
|
||||
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3)
|
||||
{
|
||||
@@ -226,9 +226,9 @@ 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;
|
||||
public override readonly int GetHashCode() => Owner.GetHashCode();
|
||||
public readonly Entity<T1?, T2?, T3?> AsNullable() => new(Owner, Comp1, Comp2, Comp3);
|
||||
public readonly EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
[NotYamlSerializable]
|
||||
@@ -240,7 +240,7 @@ public record struct Entity<T1, T2, T3, T4> : IFluentEntityUid, IAsType<EntityUi
|
||||
public T2 Comp2;
|
||||
public T3 Comp3;
|
||||
public T4 Comp4;
|
||||
EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
|
||||
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4)
|
||||
{
|
||||
@@ -357,9 +357,9 @@ 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;
|
||||
public override readonly int GetHashCode() => Owner.GetHashCode();
|
||||
public readonly Entity<T1?, T2?, T3?, T4?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4);
|
||||
public readonly EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
[NotYamlSerializable]
|
||||
@@ -372,7 +372,7 @@ public record struct Entity<T1, T2, T3, T4, T5> : IFluentEntityUid, IAsType<Enti
|
||||
public T3 Comp3;
|
||||
public T4 Comp4;
|
||||
public T5 Comp5;
|
||||
EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
|
||||
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4, T5 comp5)
|
||||
{
|
||||
@@ -512,9 +512,9 @@ 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;
|
||||
public override readonly int GetHashCode() => Owner.GetHashCode();
|
||||
public readonly Entity<T1?, T2?, T3?, T4?, T5?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5);
|
||||
public readonly EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
[NotYamlSerializable]
|
||||
@@ -528,7 +528,7 @@ public record struct Entity<T1, T2, T3, T4, T5, T6> : IFluentEntityUid, IAsType<
|
||||
public T4 Comp4;
|
||||
public T5 Comp5;
|
||||
public T6 Comp6;
|
||||
EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
|
||||
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4, T5 comp5, T6 comp6)
|
||||
{
|
||||
@@ -691,9 +691,9 @@ 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;
|
||||
public override readonly int GetHashCode() => Owner.GetHashCode();
|
||||
public readonly Entity<T1?, T2?, T3?, T4?, T5?, T6?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6);
|
||||
public readonly EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
[NotYamlSerializable]
|
||||
@@ -708,7 +708,7 @@ public record struct Entity<T1, T2, T3, T4, T5, T6, T7> : IFluentEntityUid, IAsT
|
||||
public T5 Comp5;
|
||||
public T6 Comp6;
|
||||
public T7 Comp7;
|
||||
EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
|
||||
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4, T5 comp5, T6 comp6, T7 comp7)
|
||||
{
|
||||
@@ -894,9 +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;
|
||||
public override readonly int GetHashCode() => Owner.GetHashCode();
|
||||
public readonly Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6, Comp7);
|
||||
public readonly EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
[NotYamlSerializable]
|
||||
@@ -912,7 +912,7 @@ public record struct Entity<T1, T2, T3, T4, T5, T6, T7, T8> : IFluentEntityUid,
|
||||
public T6 Comp6;
|
||||
public T7 Comp7;
|
||||
public T8 Comp8;
|
||||
EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
readonly EntityUid IFluentEntityUid.FluentOwner => Owner;
|
||||
|
||||
public Entity(EntityUid owner, T1 comp1, T2 comp2, T3 comp3, T4 comp4, T5 comp5, T6 comp6, T7 comp7, T8 comp8)
|
||||
{
|
||||
@@ -1121,7 +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;
|
||||
public override readonly int GetHashCode() => Owner.GetHashCode();
|
||||
public readonly Entity<T1?, T2?, T3?, T4?, T5?, T6?, T7?, T8?> AsNullable() => new(Owner, Comp1, Comp2, Comp3, Comp4, Comp5, Comp6, Comp7, Comp8);
|
||||
public readonly EntityUid AsType() => Owner;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,16 @@ public partial class EntityManager
|
||||
return ents;
|
||||
}
|
||||
|
||||
public EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, params EntProtoId[] protoNames)
|
||||
{
|
||||
var ents = new EntityUid[protoNames.Length];
|
||||
for (var i = 0; i < protoNames.Length; i++)
|
||||
{
|
||||
ents[i] = SpawnAttachedTo(protoNames[i], coordinates);
|
||||
}
|
||||
return ents;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, List<string?> protoNames)
|
||||
{
|
||||
@@ -74,6 +84,14 @@ public partial class EntityManager
|
||||
return ents;
|
||||
}
|
||||
|
||||
public void SpawnEntitiesAttachedTo(EntityCoordinates coordinates, IEnumerable<EntProtoId> protoNames)
|
||||
{
|
||||
foreach (var protoName in protoNames)
|
||||
{
|
||||
SpawnAttachedTo(protoName, coordinates);
|
||||
}
|
||||
}
|
||||
|
||||
public virtual EntityUid SpawnAttachedTo(string? protoName, EntityCoordinates coordinates, ComponentRegistry? overrides = null, Angle rotation = default)
|
||||
{
|
||||
if (!coordinates.IsValid(this))
|
||||
|
||||
@@ -29,6 +29,8 @@ namespace Robust.Shared.GameObjects
|
||||
[Dependency] private readonly IReplayRecordingManager _replayMan = default!;
|
||||
[Dependency] protected readonly ILocalizationManager Loc = default!;
|
||||
|
||||
protected IComponentFactory Factory => EntityManager.ComponentFactory;
|
||||
|
||||
public ISawmill Log { get; private set; } = default!;
|
||||
|
||||
protected virtual string SawmillName
|
||||
|
||||
@@ -22,6 +22,8 @@ public partial interface IEntityManager
|
||||
EntityUid[] SpawnEntities(MapCoordinates coordinates, params string?[] protoNames);
|
||||
EntityUid[] SpawnEntities(MapCoordinates coordinates, string? prototype, int count);
|
||||
EntityUid[] SpawnEntities(MapCoordinates coordinates, List<string?> protoNames);
|
||||
void SpawnEntitiesAttachedTo(EntityCoordinates coordinates, IEnumerable<EntProtoId> protoNames);
|
||||
EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, params EntProtoId[] protoNames);
|
||||
EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, List<string?> protoNames);
|
||||
EntityUid[] SpawnEntitiesAttachedTo(EntityCoordinates coordinates, params string?[] protoNames);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Robust.Shared.Collections;
|
||||
using Robust.Shared.GameStates;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Map.Components;
|
||||
@@ -833,7 +834,7 @@ public abstract partial class SharedMapSystem
|
||||
}
|
||||
|
||||
var offset = chunk.GridTileToChunkTile(gridIndices);
|
||||
SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile);
|
||||
SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile, out _);
|
||||
}
|
||||
|
||||
public void SetTiles(EntityUid uid, MapGridComponent grid, List<(Vector2i GridIndices, Tile Tile)> tiles)
|
||||
@@ -842,6 +843,11 @@ public abstract partial class SharedMapSystem
|
||||
return;
|
||||
|
||||
var modified = new HashSet<MapChunk>(Math.Max(1, tiles.Count / grid.ChunkSize));
|
||||
var tileChanges = new ValueList<TileChangedEntry>(tiles.Count);
|
||||
|
||||
// Suppress sending out events for each tile changed
|
||||
// We're going to send them all out together at the end
|
||||
MapManager.SuppressOnTileChanged = true;
|
||||
|
||||
foreach (var (gridIndices, tile) in tiles)
|
||||
{
|
||||
@@ -859,8 +865,11 @@ public abstract partial class SharedMapSystem
|
||||
|
||||
var offset = chunk.GridTileToChunkTile(gridIndices);
|
||||
chunk.SuppressCollisionRegeneration = true;
|
||||
if (SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile))
|
||||
if (SetChunkTile(uid, grid, chunk, (ushort)offset.X, (ushort)offset.Y, tile, out var oldTile))
|
||||
{
|
||||
modified.Add(chunk);
|
||||
tileChanges.Add(new TileChangedEntry(tile, oldTile, offset, gridIndices));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var chunk in modified)
|
||||
@@ -869,6 +878,13 @@ public abstract partial class SharedMapSystem
|
||||
}
|
||||
|
||||
RegenerateCollision(uid, grid, modified);
|
||||
|
||||
// Notify of all tile changes in one event
|
||||
var ev = new TileChangedEvent((uid, grid), tileChanges.ToArray());
|
||||
RaiseLocalEvent(uid, ref ev, true);
|
||||
|
||||
// Back to normal
|
||||
MapManager.SuppressOnTileChanged = false;
|
||||
}
|
||||
|
||||
public TilesEnumerator GetLocalTilesEnumerator(EntityUid uid, MapGridComponent grid, Box2 aabb,
|
||||
|
||||
@@ -14,9 +14,9 @@ public abstract partial class SharedMapSystem
|
||||
/// <param name="xIndex">The X tile index relative to the chunk.</param>
|
||||
/// <param name="yIndex">The Y tile index relative to the chunk.</param>
|
||||
/// <param name="tile">The new tile to insert.</param>
|
||||
internal bool SetChunkTile(EntityUid uid, MapGridComponent grid, MapChunk chunk, ushort xIndex, ushort yIndex, Tile tile)
|
||||
internal bool SetChunkTile(EntityUid uid, MapGridComponent grid, MapChunk chunk, ushort xIndex, ushort yIndex, Tile tile, out Tile oldTile)
|
||||
{
|
||||
if (!chunk.TrySetTile(xIndex, yIndex, tile, out var oldTile, out var shapeChanged))
|
||||
if (!chunk.TrySetTile(xIndex, yIndex, tile, out oldTile, out var shapeChanged))
|
||||
return false;
|
||||
|
||||
var tileIndices = new Vector2i(xIndex, yIndex);
|
||||
|
||||
@@ -173,45 +173,61 @@ namespace Robust.Shared.GameObjects
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arguments for when a single tile on a grid is changed locally or remotely.
|
||||
/// Raised directed at the grid when tiles are changed locally or remotely.
|
||||
/// </summary>
|
||||
[ByRefEvent]
|
||||
public readonly record struct TileChangedEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new instance of this class.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="TileChangedEvent(Entity{MapGridComponent}, Tile, Tile, Vector2i, Vector2i)"/>
|
||||
public TileChangedEvent(Entity<MapGridComponent> entity, TileRef newTile, Tile oldTile, Vector2i chunkIndex)
|
||||
: this(entity, newTile.Tile, oldTile, chunkIndex, newTile.GridIndices) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of this event for a single changed tile.
|
||||
/// </summary>
|
||||
/// <param name="entity">The grid entity containing the changed tile(s)</param>
|
||||
/// <param name="newTile">New tile that replaced the old one.</param>
|
||||
/// <param name="oldTile">Old tile that was replaced.</param>
|
||||
/// <param name="chunkIndex">The index of the grid-chunk that this tile belongs to.</param>
|
||||
/// <param name="gridIndices">The positional indices of this tile on the grid.</param>
|
||||
public TileChangedEvent(Entity<MapGridComponent> entity, Tile newTile, Tile oldTile, Vector2i chunkIndex, Vector2i gridIndices)
|
||||
{
|
||||
Entity = entity;
|
||||
NewTile = newTile;
|
||||
OldTile = oldTile;
|
||||
ChunkIndex = chunkIndex;
|
||||
Changes = [new TileChangedEntry(newTile, oldTile, chunkIndex, gridIndices)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Was the tile previously empty or is it now empty.
|
||||
/// Creates a new instance of this event for multiple changed tiles.
|
||||
/// </summary>
|
||||
public bool EmptyChanged => OldTile.IsEmpty != NewTile.Tile.IsEmpty;
|
||||
public TileChangedEvent(Entity<MapGridComponent> entity, TileChangedEntry[] changes)
|
||||
{
|
||||
Entity = entity;
|
||||
Changes = changes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entity of the grid with the tile-change. TileRef stores the GridId.
|
||||
/// Entity of the grid with the tile-change. TileRef stores the GridId.
|
||||
/// </summary>
|
||||
public readonly Entity<MapGridComponent> Entity;
|
||||
|
||||
/// <summary>
|
||||
/// New tile that replaced the old one.
|
||||
/// An array of all the tiles that were changed.
|
||||
/// </summary>
|
||||
public readonly TileRef NewTile;
|
||||
public readonly TileChangedEntry[] Changes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data about a single tile that was changed as part of a <see cref="TileChangedEvent"/>.
|
||||
/// </summary>
|
||||
/// <param name="NewTile">New tile that replaced the old one.</param>
|
||||
/// <param name="OldTile">Old tile that was replaced.</param>
|
||||
/// <param name="ChunkIndex">The index of the grid-chunk that this tile belongs to.</param>
|
||||
/// <param name="GridIndices">The positional indices of this tile on the grid.</param>
|
||||
public readonly record struct TileChangedEntry(Tile NewTile, Tile OldTile, Vector2i ChunkIndex, Vector2i GridIndices)
|
||||
{
|
||||
/// <summary>
|
||||
/// Old tile that was replaced.
|
||||
/// Was the tile previously empty or is it now empty.
|
||||
/// </summary>
|
||||
public readonly Tile OldTile;
|
||||
|
||||
/// <summary>
|
||||
/// The index of the grid-chunk that this tile belongs to.
|
||||
/// </summary>
|
||||
public readonly Vector2i ChunkIndex;
|
||||
public bool EmptyChanged => OldTile.IsEmpty != NewTile.IsEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,11 +70,14 @@ namespace Robust.Shared.GameObjects
|
||||
|
||||
private void MapManagerOnTileChanged(ref TileChangedEvent e)
|
||||
{
|
||||
if(e.NewTile.Tile != Tile.Empty)
|
||||
return;
|
||||
foreach (var change in e.Changes)
|
||||
{
|
||||
if(change.NewTile != Tile.Empty)
|
||||
continue;
|
||||
|
||||
// TODO optimize this for when multiple tiles get empties simultaneously (e.g., explosions).
|
||||
DeparentAllEntsOnTile(e.NewTile.GridUid, e.NewTile.GridIndices);
|
||||
// TODO optimize this for when multiple tiles get empties simultaneously (e.g., explosions).
|
||||
DeparentAllEntsOnTile(e.Entity, change.GridIndices);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -265,7 +268,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 +299,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Numerics;
|
||||
using Robust.Shared.GameObjects;
|
||||
using Robust.Shared.Maths;
|
||||
using Robust.Shared.Physics.Components;
|
||||
using Robust.Shared.Physics.Dynamics;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Robust.Shared.Physics.Events;
|
||||
|
||||
@@ -20,9 +20,16 @@ public readonly struct StartCollideEvent
|
||||
|
||||
public readonly Fixture OurFixture;
|
||||
public readonly Fixture OtherFixture;
|
||||
public readonly Vector2 WorldPoint;
|
||||
|
||||
public StartCollideEvent(
|
||||
internal readonly FixedArray2<Vector2> _worldPoints;
|
||||
|
||||
public readonly int PointCount;
|
||||
public readonly Vector2 WorldNormal;
|
||||
|
||||
public Vector2[] WorldPoints => _worldPoints.AsSpan[..PointCount].ToArray();
|
||||
|
||||
|
||||
internal StartCollideEvent(
|
||||
EntityUid ourEntity,
|
||||
EntityUid otherEntity,
|
||||
string ourFixtureId,
|
||||
@@ -31,7 +38,9 @@ public readonly struct StartCollideEvent
|
||||
Fixture otherFixture,
|
||||
PhysicsComponent ourBody,
|
||||
PhysicsComponent otherBody,
|
||||
Vector2 worldPoint)
|
||||
FixedArray2<Vector2> worldPoints,
|
||||
int pointCount,
|
||||
Vector2 worldNormal)
|
||||
{
|
||||
OurEntity = ourEntity;
|
||||
OtherEntity = otherEntity;
|
||||
@@ -39,8 +48,10 @@ public readonly struct StartCollideEvent
|
||||
OtherFixtureId = otherFixtureId;
|
||||
OurFixture = ourFixture;
|
||||
OtherFixture = otherFixture;
|
||||
WorldPoint = worldPoint;
|
||||
OtherBody = otherBody;
|
||||
OurBody = ourBody;
|
||||
_worldPoints = worldPoints;
|
||||
PointCount = pointCount;
|
||||
WorldNormal = worldNormal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +294,14 @@ public abstract partial class SharedPhysicsSystem
|
||||
DebugTools.Assert(!fixB.Contacts.ContainsKey(fixA));
|
||||
fixB.Contacts.Add(fixA, contact);
|
||||
bodB.Contacts.AddLast(contact.BodyBNode);
|
||||
|
||||
// If it's a spawned static ent then need to wake any contacting entities.
|
||||
// The issue is that static ents can never be awake and if it spawns on an asleep entity never gets a contact.
|
||||
// Checking only bodyA should be okay because if bodyA is the other ent (i.e. dynamic / kinematic) then it should already be awake.
|
||||
if (bodyA.BodyType == BodyType.Static && !bodyB.Awake)
|
||||
{
|
||||
WakeBody(uidB, body: bodyB);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -350,10 +358,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);
|
||||
}
|
||||
|
||||
@@ -543,7 +551,7 @@ public abstract partial class SharedPhysicsSystem
|
||||
}
|
||||
|
||||
var status = ArrayPool<ContactStatus>.Shared.Rent(index);
|
||||
var worldPoints = ArrayPool<Vector2>.Shared.Rent(index);
|
||||
var worldPoints = ArrayPool<FixedArray4<Vector2>>.Shared.Rent(index);
|
||||
|
||||
// Update contacts all at once.
|
||||
BuildManifolds(contacts, index, status, worldPoints);
|
||||
@@ -578,9 +586,11 @@ public abstract partial class SharedPhysicsSystem
|
||||
var uidA = contact.EntityA;
|
||||
var uidB = contact.EntityB;
|
||||
var worldPoint = worldPoints[i];
|
||||
var points = new FixedArray2<Vector2>(worldPoint._00, worldPoint._01);
|
||||
var worldNormal = worldPoint._02;
|
||||
|
||||
var ev1 = new StartCollideEvent(uidA, uidB, contact.FixtureAId, contact.FixtureBId, fixtureA, fixtureB, bodyA, bodyB, worldPoint);
|
||||
var ev2 = new StartCollideEvent(uidB, uidA, contact.FixtureBId, contact.FixtureAId, fixtureB, fixtureA, bodyB, bodyA, worldPoint);
|
||||
var ev1 = new StartCollideEvent(uidA, uidB, contact.FixtureAId, contact.FixtureBId, fixtureA, fixtureB, bodyA, bodyB, points, contact.Manifold.PointCount, worldNormal);
|
||||
var ev2 = new StartCollideEvent(uidB, uidA, contact.FixtureBId, contact.FixtureAId, fixtureB, fixtureA, bodyB, bodyA, points, contact.Manifold.PointCount, worldNormal);
|
||||
|
||||
RaiseLocalEvent(uidA, ref ev1, true);
|
||||
RaiseLocalEvent(uidB, ref ev2, true);
|
||||
@@ -618,10 +628,10 @@ public abstract partial class SharedPhysicsSystem
|
||||
|
||||
ArrayPool<Contact>.Shared.Return(contacts);
|
||||
ArrayPool<ContactStatus>.Shared.Return(status);
|
||||
ArrayPool<Vector2>.Shared.Return(worldPoints);
|
||||
ArrayPool<FixedArray4<Vector2>>.Shared.Return(worldPoints);
|
||||
}
|
||||
|
||||
private void BuildManifolds(Contact[] contacts, int count, ContactStatus[] status, Vector2[] worldPoints)
|
||||
private void BuildManifolds(Contact[] contacts, int count, ContactStatus[] status, FixedArray4<Vector2>[] worldPoints)
|
||||
{
|
||||
if (count == 0)
|
||||
return;
|
||||
@@ -664,7 +674,7 @@ public abstract partial class SharedPhysicsSystem
|
||||
|
||||
public Contact[] Contacts;
|
||||
public ContactStatus[] Status;
|
||||
public Vector2[] WorldPoints;
|
||||
public FixedArray4<Vector2>[] WorldPoints;
|
||||
public bool[] Wake;
|
||||
|
||||
public void Execute(int index)
|
||||
@@ -673,7 +683,7 @@ public abstract partial class SharedPhysicsSystem
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateContact(Contact[] contacts, int index, ContactStatus[] status, bool[] wake, Vector2[] worldPoints)
|
||||
private void UpdateContact(Contact[] contacts, int index, ContactStatus[] status, bool[] wake, FixedArray4<Vector2>[] worldPoints)
|
||||
{
|
||||
var contact = contacts[index];
|
||||
|
||||
@@ -698,7 +708,11 @@ public abstract partial class SharedPhysicsSystem
|
||||
|
||||
if (contactStatus == ContactStatus.StartTouching)
|
||||
{
|
||||
worldPoints[index] = Physics.Transform.Mul(bodyATransform, contacts[index].Manifold.LocalPoint);
|
||||
var points = new FixedArray4<Vector2>();
|
||||
contact.GetWorldManifold(bodyATransform, bodyBTransform, out var worldNormal, points.AsSpan);
|
||||
// Use the 3rd Vector2 as the world normal, 4th is blank.
|
||||
points._02 = worldNormal;
|
||||
worldPoints[index] = points;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
[assembly: InternalsVisibleTo("Robust.UnitTesting")]
|
||||
[assembly: InternalsVisibleTo("OpenToolkit.GraphicsLibraryFramework")]
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Gives access to Castle(Moq)
|
||||
[assembly: InternalsVisibleTo("Content.Benchmarks")]
|
||||
[assembly: InternalsVisibleTo("Robust.Benchmarks")]
|
||||
[assembly: InternalsVisibleTo("Robust.Client.WebView")]
|
||||
[assembly: InternalsVisibleTo("Robust.Packaging")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
78
Robust.Shared/Serialization/NetBitArraySerializer.cs
Normal file
78
Robust.Shared/Serialization/NetBitArraySerializer.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using JetBrains.Annotations;
|
||||
using NetSerializer;
|
||||
|
||||
namespace Robust.Shared.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Custom serializer implementation for <see cref="BitArray"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This type is necessary as, since .NET 10, the internal layout of <see cref="BitArray"/> was changed.
|
||||
/// The type now (internally) implements <see cref="ISerializable"/> for backwards compatibility with existing
|
||||
/// <c>BinaryFormatter</c> code, but NetSerializer does not support <see cref="ISerializable"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This code is designed to be backportable & network compatible with the previous behavior on .NET 9.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class NetBitArraySerializer : IStaticTypeSerializer
|
||||
{
|
||||
// For reference, the layout of BitArray before .NET 10 was:
|
||||
// private int[] m_array;
|
||||
// private int m_length;
|
||||
// private int _version;
|
||||
// NetSerializer serialized these in the following order (sorted by name):
|
||||
// _version, m_array, m_length
|
||||
|
||||
public bool Handles(Type type)
|
||||
{
|
||||
return type == typeof(BitArray);
|
||||
}
|
||||
|
||||
public IEnumerable<Type> GetSubtypes(Type type)
|
||||
{
|
||||
return [typeof(int[]), typeof(int)];
|
||||
}
|
||||
|
||||
public MethodInfo GetStaticWriter(Type type)
|
||||
{
|
||||
return typeof(NetBitArraySerializer).GetMethod("Write", BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
}
|
||||
|
||||
public MethodInfo GetStaticReader(Type type)
|
||||
{
|
||||
return typeof(NetBitArraySerializer).GetMethod("Read", BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
private static void Write(Serializer serializer, Stream stream, BitArray value)
|
||||
{
|
||||
var intCount = (31 + value.Length) >> 5;
|
||||
var ints = new int[intCount];
|
||||
value.CopyTo(ints, 0);
|
||||
|
||||
serializer.SerializeDirect(stream, 0); // _version
|
||||
serializer.SerializeDirect(stream, ints); // m_array
|
||||
serializer.SerializeDirect(stream, value.Length); // m_length
|
||||
}
|
||||
|
||||
[UsedImplicitly]
|
||||
private static void Read(Serializer serializer, Stream stream, out BitArray value)
|
||||
{
|
||||
serializer.DeserializeDirect<int>(stream, out _); // _version
|
||||
serializer.DeserializeDirect<int[]>(stream, out var array); // m_array
|
||||
serializer.DeserializeDirect<int>(stream, out var length); // m_length
|
||||
|
||||
value = new BitArray(array)
|
||||
{
|
||||
Length = length
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ namespace Robust.Shared.Serialization
|
||||
MappedStringSerializer.TypeSerializer,
|
||||
new Vector2Serializer(),
|
||||
new Matrix3x2Serializer(),
|
||||
new NetBitArraySerializer()
|
||||
}
|
||||
};
|
||||
_serializer = new Serializer(types, settings);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ public readonly struct ResPath : IEquatable<ResPath>
|
||||
public ResPath(string canonPath)
|
||||
{
|
||||
// Paths should never have non-standardised directory separators passed in, the caller should have already sanitised it.
|
||||
DebugTools.Assert(!canonPath.Contains('\\'));
|
||||
DebugTools.Assert(IsValidPath(canonPath));
|
||||
CanonPath = canonPath;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,21 @@ public readonly struct ResPath : IEquatable<ResPath>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check whether the given string paths contains any non-standard directory separators.
|
||||
/// </summary>
|
||||
public static bool IsValidPath(string path) => !path.Contains('\\');
|
||||
|
||||
/// <summary>
|
||||
/// Check whether a string is a valid path (<see cref="IsValidPath"/>) and corresponds to a simple file name.
|
||||
/// </summary>
|
||||
public static bool IsValidFilename([NotNullWhen(true)] string? filename)
|
||||
=> !string.IsNullOrEmpty(filename)
|
||||
&& IsValidPath(filename)
|
||||
&& !filename.Contains('/')
|
||||
&& filename != "."
|
||||
&& filename != "..";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the path is equal to "."
|
||||
/// </summary>
|
||||
@@ -106,7 +121,7 @@ public readonly struct ResPath : IEquatable<ResPath>
|
||||
}
|
||||
|
||||
var ind = CanonPath.Length > 1 && CanonPath[^1] == '/'
|
||||
? CanonPath[..^1].LastIndexOf('/')
|
||||
? CanonPath.LastIndexOf('/', CanonPath.Length - 2)
|
||||
: CanonPath.LastIndexOf('/');
|
||||
return ind switch
|
||||
{
|
||||
@@ -206,7 +221,7 @@ public readonly struct ResPath : IEquatable<ResPath>
|
||||
// it's a filename
|
||||
// Uses +1 to skip `/` found in or starts from beginning of string
|
||||
// if we found nothing (ind == -1)
|
||||
var ind = CanonPath[..^1].LastIndexOf('/') + 1;
|
||||
var ind = CanonPath.LastIndexOf('/', CanonPath.Length - 2) + 1;
|
||||
return CanonPath[^1] == '/'
|
||||
? CanonPath[ind .. ^1] // Omit last `/`
|
||||
: CanonPath[ind..];
|
||||
@@ -242,7 +257,6 @@ public readonly struct ResPath : IEquatable<ResPath>
|
||||
return CanonPath.GetHashCode();
|
||||
}
|
||||
|
||||
|
||||
public static bool operator ==(ResPath left, ResPath right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
@@ -283,7 +297,7 @@ public readonly struct ResPath : IEquatable<ResPath>
|
||||
}
|
||||
|
||||
// Avoid double separators
|
||||
if (left.CanonPath.EndsWith("/"))
|
||||
if (left.CanonPath.EndsWith('/'))
|
||||
{
|
||||
return new ResPath(left.CanonPath + right.CanonPath);
|
||||
}
|
||||
@@ -475,7 +489,7 @@ public readonly struct ResPath : IEquatable<ResPath>
|
||||
/// </summary>
|
||||
public string ToRelativeSystemPath()
|
||||
{
|
||||
return ToRelativePath().ChangeSeparator(SystemSeparatorStr);
|
||||
return ToRelativePath().ChangeSeparator(SystemSeparator);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -495,14 +509,19 @@ public readonly struct ResPath : IEquatable<ResPath>
|
||||
/// </summary>
|
||||
public string ChangeSeparator(string newSeparator)
|
||||
{
|
||||
if (newSeparator is "." or "\0")
|
||||
{
|
||||
throw new ArgumentException("New separator can't be `.` or `NULL`");
|
||||
}
|
||||
if (newSeparator.Length != 1)
|
||||
throw new InvalidOperationException("new separator must be a single character.");
|
||||
return ChangeSeparator(newSeparator[0]);
|
||||
}
|
||||
|
||||
return newSeparator == "/"
|
||||
? CanonPath
|
||||
: CanonPath.Replace("/", newSeparator);
|
||||
/// <inheritdoc cref="ChangeSeparator(string)"/>
|
||||
public string ChangeSeparator(char newSeparator)
|
||||
{
|
||||
if (newSeparator is '.' or '\0')
|
||||
throw new ArgumentException("New separator can't be `.` or `NULL`");
|
||||
|
||||
// String.Replace() already checks if newSeparator == '/'
|
||||
return CanonPath.Replace('/', newSeparator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user