diff --git a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs index 3040f28f..cee6d1cf 100644 --- a/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs +++ b/S1API.Tests/Internal/Utils/ManagedEventRegistrationTrackerTests.cs @@ -12,12 +12,14 @@ public void DuplicateAddsAreRemovedOneAtATimeInReverseRegistrationOrder() tracker.Add(handler, "first"); tracker.Add(handler, "second"); + Assert.False(tracker.IsEmpty); Assert.True(tracker.TryTakeLast(handler, out string? second)); Assert.Equal("second", second); Assert.True(tracker.TryTakeLast(handler, out string? first)); Assert.Equal("first", first); Assert.False(tracker.TryTakeLast(handler, out _)); + Assert.True(tracker.IsEmpty); } [Fact] @@ -30,6 +32,7 @@ public void TakeAllReturnsEveryRegistrationAndClearsTheTracker() tracker.Add(firstHandler, "first"); tracker.Add(firstHandler, "second"); tracker.Add(secondHandler, "third"); + Assert.False(tracker.IsEmpty); var registrations = tracker.TakeAll(); @@ -42,5 +45,6 @@ public void TakeAllReturnsEveryRegistrationAndClearsTheTracker() registration.ManagedHandler.Equals(secondHandler) && registration.NativeHandler == "third"); Assert.False(tracker.TryTakeLast(firstHandler, out _)); Assert.False(tracker.TryTakeLast(secondHandler, out _)); + Assert.True(tracker.IsEmpty); } } diff --git a/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs b/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs new file mode 100644 index 00000000..548db2b2 --- /dev/null +++ b/S1API.Tests/Temperature/TemperatureApiCompileFixture.cs @@ -0,0 +1,37 @@ +using System; +using S1API.Temperature; +using UnityEngine; + +namespace S1API.Tests.Temperature; + +internal static class TemperatureApiCompileFixture +{ + internal static void ConfigureAndQuery(GameObject gameObject) + { + TemperatureEmitter emitter = TemperatureEmitter.GetOrAddComponent(gameObject); + emitter.SetTemperature(20f); + emitter.SetRange(5f); + emitter.SetPosition(Vector3.zero); + + Action changed = () => { }; + emitter.OnChanged += changed; + emitter.OnChanged -= changed; + + TemperatureEmitter? existing = TemperatureEmitter.FromGameObject(gameObject); + TemperatureEmitterInfo[] emitters = { emitter.ToInfo() }; + float temperature = TemperatureAlgorithm.GetTemperatureAtPoint( + ambientTemperature: 20f, + originPoint: Vector3.zero, + point: Vector3.one, + emitters: emitters); + + _ = existing; + _ = temperature; + _ = TemperatureUtility.TemperatureSystemEnabled; + _ = TemperatureUtility.ToFahrenheit(20f); + _ = TemperatureUtility.FormatCelsiusTemperature(20f, decimalPoints: 1); + _ = TemperatureUtility.FormatFahrenheitTemperature(68f, decimalPoints: 1); + _ = TemperatureUtility.FormatTemperatureWithAppropriateUnit(20f); + _ = TemperatureUtility.NormalizeTemperature(20f); + } +} diff --git a/S1API.Tests/Temperature/TemperatureContractTests.cs b/S1API.Tests/Temperature/TemperatureContractTests.cs new file mode 100644 index 00000000..01685252 --- /dev/null +++ b/S1API.Tests/Temperature/TemperatureContractTests.cs @@ -0,0 +1,128 @@ +using System.Reflection; +using S1API.Internal.Temperature; +using S1API.Temperature; +using UnityEngine; + +namespace S1API.Tests.Temperature; + +public sealed class TemperatureContractTests +{ +#if MONOMELON + [Fact] + public void SnapshotUsesLinearRangeAndClampsToNativeEmitterBounds() + { + var minimum = new TemperatureEmitterInfo(-10f, -5f, Vector3.zero); + var maximum = new TemperatureEmitterInfo(50f, 200f, Vector3.one); + + Assert.Equal(TemperatureEmitter.MinTemperature, minimum.Temperature); + Assert.Equal(TemperatureEmitter.MinRange, minimum.Range); + Assert.Equal(TemperatureEmitter.MaxTemperature, maximum.Temperature); + Assert.Equal(TemperatureEmitter.MaxRange, maximum.Range); + Assert.Null(typeof(TemperatureEmitterInfo).GetProperty("SqrRange", BindingFlags.Instance | BindingFlags.Public)); + } + + [Fact] + public void SnapshotRejectsNonFiniteValues() + { + foreach (float value in new[] { float.NaN, float.NegativeInfinity, float.PositiveInfinity }) + { + Assert.Throws(() => + new TemperatureEmitterInfo(value, TemperatureEmitter.DefaultRange, Vector3.zero)); + Assert.Throws(() => + new TemperatureEmitterInfo(TemperatureEmitter.DefaultAmbientTemperature, value, Vector3.zero)); + } + + Assert.Throws(() => + new TemperatureEmitterInfo( + TemperatureEmitter.DefaultAmbientTemperature, + TemperatureEmitter.DefaultRange, + new Vector3(float.NaN, 0f, 0f))); + } + + [Fact] + public void QueryRejectsNullSnapshotsBeforeCallingTheNativeRuntime() + { + Assert.Throws(() => TemperatureAlgorithm.GetTemperatureAtPoint( + TemperatureEmitter.DefaultAmbientTemperature, + Vector3.zero, + Vector3.zero, + null!)); + } +#endif + + [Fact] + public void ManagedScalarValidationMatchesNativeEmitterBounds() + { + Assert.Equal( + TemperatureValidation.MinTemperature, + TemperatureValidation.ClampTemperature(-10f, "temperature")); + Assert.Equal( + TemperatureValidation.MaxTemperature, + TemperatureValidation.ClampTemperature(50f, "temperature")); + Assert.Equal( + TemperatureValidation.MinRange, + TemperatureValidation.ClampRange(-5f, "range")); + Assert.Equal( + TemperatureValidation.MaxRange, + TemperatureValidation.ClampRange(200f, "range")); + + foreach (float value in new[] { float.NaN, float.NegativeInfinity, float.PositiveInfinity }) + { + Assert.Throws(() => + TemperatureValidation.ClampTemperature(value, "temperature")); + Assert.Throws(() => + TemperatureValidation.ClampRange(value, "range")); + } + } + + [Fact] + public void PublicSurfaceExposesManagedTemperatureContractsOnly() + { + Assert.NotNull(typeof(TemperatureUtility).GetProperty( + nameof(TemperatureUtility.TemperatureSystemEnabled), + BindingFlags.Public | BindingFlags.Static)); + Assert.NotNull(typeof(TemperatureEmitter).GetMethod( + nameof(TemperatureEmitter.ToInfo), + BindingFlags.Public | BindingFlags.Instance)); + + Type[] publicTypes = + { + typeof(TemperatureEmitter), + typeof(TemperatureEmitterInfo), + typeof(TemperatureAlgorithm), + typeof(TemperatureUtility) + }; + foreach (Type type in publicTypes) + { + foreach (MemberInfo member in type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + foreach (Type exposedType in GetExposedTypes(member)) + { + Assert.DoesNotContain("ScheduleOne", exposedType.Namespace ?? string.Empty, StringComparison.Ordinal); + Assert.DoesNotContain("Il2Cpp", exposedType.FullName ?? string.Empty, StringComparison.Ordinal); + } + } + } + } + + private static IEnumerable GetExposedTypes(MemberInfo member) + { + switch (member) + { + case MethodInfo method: + yield return method.ReturnType; + foreach (ParameterInfo parameter in method.GetParameters()) + yield return parameter.ParameterType; + break; + case PropertyInfo property: + yield return property.PropertyType; + break; + case FieldInfo field: + yield return field.FieldType; + break; + case EventInfo eventInfo: + yield return eventInfo.EventHandlerType!; + break; + } + } +} diff --git a/S1API/Internal/Temperature/TemperatureValidation.cs b/S1API/Internal/Temperature/TemperatureValidation.cs new file mode 100644 index 00000000..c9790e5c --- /dev/null +++ b/S1API/Internal/Temperature/TemperatureValidation.cs @@ -0,0 +1,45 @@ +using System; +using UnityEngine; + +namespace S1API.Internal.Temperature +{ + internal static class TemperatureValidation + { + internal const float DefaultAmbientTemperature = 20f; + internal const float MinTemperature = 0f; + internal const float MaxTemperature = 40f; + internal const float DefaultRange = 5f; + internal const float MinRange = 0.1f; + internal const float MaxRange = 100f; + + internal static float ClampTemperature(float temperature, string parameterName) + { + EnsureFinite(temperature, parameterName); + return Clamp(temperature, MinTemperature, MaxTemperature); + } + + internal static float ClampRange(float range, string parameterName) + { + EnsureFinite(range, parameterName); + return Clamp(range, MinRange, MaxRange); + } + + internal static void EnsureFinite(Vector3 position, string parameterName) + { + if (!IsFinite(position.x) || !IsFinite(position.y) || !IsFinite(position.z)) + throw new ArgumentOutOfRangeException(parameterName, "Vector components must be finite."); + } + + internal static void EnsureFinite(float value, string parameterName) + { + if (!IsFinite(value)) + throw new ArgumentOutOfRangeException(parameterName, "Value must be finite."); + } + + private static float Clamp(float value, float minimum, float maximum) => + value < minimum ? minimum : value > maximum ? maximum : value; + + private static bool IsFinite(float value) => + !float.IsNaN(value) && !float.IsInfinity(value); + } +} diff --git a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs index 09c8926e..97aed17f 100644 --- a/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs +++ b/S1API/Internal/Utils/ManagedEventRegistrationTracker.cs @@ -11,6 +11,9 @@ internal sealed class ManagedEventRegistrationTracker { private readonly Dictionary> _registrations = new Dictionary>(); + internal bool IsEmpty => + _registrations.Count == 0; + internal void Add(Delegate managedHandler, TNativeHandler nativeHandler) { if (!_registrations.TryGetValue(managedHandler, out var nativeHandlers)) diff --git a/S1API/Temperature/TemperatureAlgorithm.cs b/S1API/Temperature/TemperatureAlgorithm.cs new file mode 100644 index 00000000..43548e9a --- /dev/null +++ b/S1API/Temperature/TemperatureAlgorithm.cs @@ -0,0 +1,77 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using S1Temperature = Il2CppScheduleOne.Temperature; +#elif MONOMELON +using S1Temperature = ScheduleOne.Temperature; +#endif + +using System; +using S1API.Internal.Temperature; +using UnityEngine; + +namespace S1API.Temperature +{ + /// + /// Queries temperatures with the game's native temperature algorithm. + /// + public static class TemperatureAlgorithm + { + /// + /// Calculates the temperature at a world position from an ambient temperature and emitter snapshots. + /// + /// The ambient temperature in degrees Celsius. + /// + /// The world origin forwarded to the native API for signature compatibility. The current native implementation + /// evaluates world-space emitter and query positions directly and does not otherwise use this value. + /// + /// The world position to query. + /// The emitter snapshots to include in the calculation. + /// The temperature at in degrees Celsius. + /// is null. + /// A scalar or vector input is not finite. + /// + /// This method does not discover scene emitters or register the supplied snapshots with a grid. + /// + public static float GetTemperatureAtPoint( + float ambientTemperature, + Vector3 originPoint, + Vector3 point, + TemperatureEmitterInfo[] emitters) + { + if (emitters == null) + throw new ArgumentNullException(nameof(emitters)); + + TemperatureValidation.EnsureFinite(ambientTemperature, nameof(ambientTemperature)); + TemperatureValidation.EnsureFinite(originPoint, nameof(originPoint)); + TemperatureValidation.EnsureFinite(point, nameof(point)); +#if IL2CPPMELON + var nativeEmitters = new Il2CppStructArray(emitters.Length); +#else + var nativeEmitters = new S1Temperature.TemperatureEmitterInfo[emitters.Length]; +#endif + for (int i = 0; i < emitters.Length; i++) + { + TemperatureEmitterInfo emitter = emitters[i]; + float temperature = TemperatureValidation.ClampTemperature( + emitter.Temperature, + $"{nameof(emitters)}[{i}].{nameof(TemperatureEmitterInfo.Temperature)}"); + float range = TemperatureValidation.ClampRange( + emitter.Range, + $"{nameof(emitters)}[{i}].{nameof(TemperatureEmitterInfo.Range)}"); + TemperatureValidation.EnsureFinite( + emitter.Position, + $"{nameof(emitters)}[{i}].{nameof(TemperatureEmitterInfo.Position)}"); + nativeEmitters[i] = new S1Temperature.TemperatureEmitterInfo( + temperature, + range * range, + emitter.Position); + } + + return S1Temperature.TemperatureAlgorithm.GetTemperatureAtPoint( + ambientTemperature, + originPoint, + point, + nativeEmitters); + } + } +} diff --git a/S1API/Temperature/TemperatureEmitter.cs b/S1API/Temperature/TemperatureEmitter.cs new file mode 100644 index 00000000..b0cf4fe9 --- /dev/null +++ b/S1API/Temperature/TemperatureEmitter.cs @@ -0,0 +1,314 @@ +#if IL2CPPMELON +using Il2CppInterop.Runtime; +using NativeEmitterChangedAction = Il2CppSystem.Action; +using S1Temperature = Il2CppScheduleOne.Temperature; +#elif MONOMELON +using NativeEmitterChangedAction = System.Action; +using S1Temperature = ScheduleOne.Temperature; +#endif + +using System; +using System.Collections.Generic; +using S1API.Internal.Temperature; +using S1API.Internal.Utils; +using UnityEngine; + +namespace S1API.Temperature +{ + /// + /// Wraps a native temperature emitter attached to a mod-owned game object. + /// + /// + /// Adding this component does not register it with a native grid, persist its configuration, or synchronize it over the network. + /// + public sealed class TemperatureEmitter + { + /// + /// INTERNAL: The native temperature emitter component. + /// + internal readonly S1Temperature.TemperatureEmitter S1TemperatureEmitter; + + /// + /// INTERNAL: Creates a wrapper around a native temperature emitter component. + /// + /// The native temperature emitter component. + internal TemperatureEmitter(S1Temperature.TemperatureEmitter temperatureEmitter) + { + S1TemperatureEmitter = temperatureEmitter; + } + + /// + /// Gets the default ambient temperature, in degrees Celsius. + /// + public const float DefaultAmbientTemperature = TemperatureValidation.DefaultAmbientTemperature; + + /// + /// Gets the minimum emitter temperature, in degrees Celsius. + /// + public const float MinTemperature = TemperatureValidation.MinTemperature; + + /// + /// Gets the maximum emitter temperature, in degrees Celsius. + /// + public const float MaxTemperature = TemperatureValidation.MaxTemperature; + + /// + /// Gets the default emitter range, in world units. + /// + public const float DefaultRange = TemperatureValidation.DefaultRange; + + /// + /// Gets the minimum emitter range, in world units. + /// + public const float MinRange = TemperatureValidation.MinRange; + + /// + /// Gets the maximum emitter range, in world units. + /// + public const float MaxRange = TemperatureValidation.MaxRange; + + /// + /// Gets the native temperature emitter attached to a game object. + /// + /// The game object to inspect. + /// A temperature-emitter wrapper, or null when the game object has no emitter component. + /// is null or destroyed. + public static TemperatureEmitter? FromGameObject(GameObject gameObject) + { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + PruneDestroyedRegistrationStates(); + S1Temperature.TemperatureEmitter? emitter = + gameObject.GetComponent(); + return emitter == null ? null : new TemperatureEmitter(emitter); + } + + /// + /// Gets the first native temperature emitter on a game object, or adds one when none exists. + /// + /// The game object that owns the emitter component. + /// A wrapper around the existing or newly added emitter. + /// is null or destroyed. + /// + /// When a game object has multiple native emitter components, this method returns the first component selected by Unity. + /// + public static TemperatureEmitter GetOrAddComponent(GameObject gameObject) + { + if (gameObject == null) + throw new ArgumentNullException(nameof(gameObject)); + + PruneDestroyedRegistrationStates(); + S1Temperature.TemperatureEmitter? emitter = + gameObject.GetComponent(); + return new TemperatureEmitter( + emitter ?? gameObject.AddComponent()); + } + + /// + /// Gets the emitter temperature in degrees Celsius. + /// + public float Temperature => + S1TemperatureEmitter.Temperature; + + /// + /// Gets the emitter range in world units. + /// + public float Range => + S1TemperatureEmitter.Range; + + /// + /// Gets the emitter position in world space. + /// + public Vector3 EmissionPoint => + S1TemperatureEmitter.EmissionPoint; + + /// + /// Creates an immutable managed snapshot of the emitter's current values. + /// + /// A snapshot suitable for . + public TemperatureEmitterInfo ToInfo() => + new TemperatureEmitterInfo(Temperature, Range, EmissionPoint); + + /// + /// Occurs when the native emitter reports a change. + /// + public event Action OnChanged + { + add + { + if (value == null) + return; + + NativeEmitterChangedAction nativeHandler = CreateNativeChangedHandler(value); + Subscribe(nativeHandler); + GetChangedRegistrationState().Registrations.Add(value, nativeHandler); + } + remove + { + if (value == null || !TryTakeChangedRegistration(value, out ChangedRegistrationState state, + out NativeEmitterChangedAction nativeHandler)) + return; + + try + { + Unsubscribe(nativeHandler); + } + catch + { + state.Registrations.Add(value, nativeHandler); + throw; + } + + if (state.Registrations.IsEmpty) + ChangedRegistrations.Remove(S1TemperatureEmitter.GetInstanceID()); + } + } + + /// + /// Updates the emitter position in world space. + /// + /// The new emitter position in world space. Every component must be finite. + /// A position component is not finite. + public void SetPosition(Vector3 position) + { + TemperatureValidation.EnsureFinite(position, nameof(position)); + S1TemperatureEmitter.SetPosition(position); + } + + /// + /// Updates the emitter temperature. + /// + /// + /// The new temperature in degrees Celsius. Finite values are clamped to + /// through . + /// + /// is not finite. + public void SetTemperature(float temperature) + { + float clampedTemperature = TemperatureValidation.ClampTemperature(temperature, nameof(temperature)); + S1TemperatureEmitter.SetTemperature(clampedTemperature); + } + + /// + /// Updates the emitter range in world units. + /// + /// + /// The new range in world units. Finite values are clamped to through + /// . + /// + /// is not finite. + public void SetRange(float range) + { + float clampedRange = TemperatureValidation.ClampRange(range, nameof(range)); + S1TemperatureEmitter.SetRange(clampedRange); + } + + /// + /// Informs native listeners that the emitter changed. + /// + public void NotifyChanged() => + S1TemperatureEmitter.NotifyChanged(); + + private static readonly Dictionary ChangedRegistrations = + new Dictionary(); + + private static NativeEmitterChangedAction CreateNativeChangedHandler(Action handler) + { +#if IL2CPPMELON + return DelegateSupport.ConvertDelegate(handler) + ?? throw new InvalidOperationException("Could not create the native temperature-emitter delegate."); +#else + return handler; +#endif + } + + private ChangedRegistrationState GetChangedRegistrationState() + { + PruneDestroyedRegistrationStates(); + int instanceId = S1TemperatureEmitter.GetInstanceID(); + if (ChangedRegistrations.TryGetValue(instanceId, out ChangedRegistrationState? state)) + return state; + + state = new ChangedRegistrationState(S1TemperatureEmitter); + ChangedRegistrations.Add(instanceId, state); + return state; + } + + private bool TryTakeChangedRegistration( + Action managedHandler, + out ChangedRegistrationState state, + out NativeEmitterChangedAction nativeHandler) + { + PruneDestroyedRegistrationStates(); + if (ChangedRegistrations.TryGetValue( + S1TemperatureEmitter.GetInstanceID(), + out ChangedRegistrationState? registrationState) + && registrationState.Registrations.TryTakeLast(managedHandler, out nativeHandler)) + { + state = registrationState; + return true; + } + + state = null!; + nativeHandler = default!; + return false; + } + + private static void PruneDestroyedRegistrationStates() + { + List? destroyedIds = null; + foreach (KeyValuePair registration in ChangedRegistrations) + { + if (registration.Value.S1TemperatureEmitter != null) + continue; + + destroyedIds ??= new List(); + destroyedIds.Add(registration.Key); + } + + if (destroyedIds == null) + return; + + foreach (int destroyedId in destroyedIds) + ChangedRegistrations.Remove(destroyedId); + } + + private void Subscribe(NativeEmitterChangedAction handler) + { +#if IL2CPPMELON + S1TemperatureEmitter.OnEmitterChanged = S1TemperatureEmitter.OnEmitterChanged == null + ? handler + : Il2CppSystem.Delegate.Combine(S1TemperatureEmitter.OnEmitterChanged, handler) + .Cast(); +#else + S1TemperatureEmitter.OnEmitterChanged += handler; +#endif + } + + private void Unsubscribe(NativeEmitterChangedAction handler) + { +#if IL2CPPMELON + Il2CppSystem.Delegate? remaining = Il2CppSystem.Delegate.Remove( + S1TemperatureEmitter.OnEmitterChanged, + handler); + S1TemperatureEmitter.OnEmitterChanged = remaining?.Cast(); +#else + S1TemperatureEmitter.OnEmitterChanged -= handler; +#endif + } + + private sealed class ChangedRegistrationState + { + internal S1Temperature.TemperatureEmitter S1TemperatureEmitter { get; } + + internal ManagedEventRegistrationTracker Registrations { get; } = + new ManagedEventRegistrationTracker(); + + internal ChangedRegistrationState(S1Temperature.TemperatureEmitter temperatureEmitter) + { + S1TemperatureEmitter = temperatureEmitter; + } + } + } +} diff --git a/S1API/Temperature/TemperatureEmitterInfo.cs b/S1API/Temperature/TemperatureEmitterInfo.cs new file mode 100644 index 00000000..b81f141a --- /dev/null +++ b/S1API/Temperature/TemperatureEmitterInfo.cs @@ -0,0 +1,44 @@ +using System; +using S1API.Internal.Temperature; +using UnityEngine; + +namespace S1API.Temperature +{ + /// + /// Describes one temperature emitter for a point-temperature query. + /// + public readonly struct TemperatureEmitterInfo + { + /// + /// Creates a temperature-emitter snapshot. + /// + /// + /// The emitter temperature in degrees Celsius. Finite values are clamped to the game's supported range. + /// + /// The emitter range in world units. Finite values are clamped to the game's supported range. + /// The emitter position in world space. Every component must be finite. + /// An argument is not finite. + public TemperatureEmitterInfo(float temperature, float range, Vector3 position) + { + Temperature = TemperatureValidation.ClampTemperature(temperature, nameof(temperature)); + Range = TemperatureValidation.ClampRange(range, nameof(range)); + TemperatureValidation.EnsureFinite(position, nameof(position)); + Position = position; + } + + /// + /// Gets the emitter temperature in degrees Celsius. + /// + public float Temperature { get; } + + /// + /// Gets the emitter range in world units. + /// + public float Range { get; } + + /// + /// Gets the emitter position in world space. + /// + public Vector3 Position { get; } + } +} diff --git a/S1API/Temperature/TemperatureUtility.cs b/S1API/Temperature/TemperatureUtility.cs new file mode 100644 index 00000000..f40ba45d --- /dev/null +++ b/S1API/Temperature/TemperatureUtility.cs @@ -0,0 +1,65 @@ +#if IL2CPPMELON +using S1Temperature = Il2CppScheduleOne.Temperature; +#elif MONOMELON +using S1Temperature = ScheduleOne.Temperature; +#endif + +namespace S1API.Temperature +{ + /// + /// Formats and converts temperatures with the game's native temperature helpers. + /// + public static class TemperatureUtility + { + /// + /// Gets whether the game's temperature system is currently enabled. + /// + public static bool TemperatureSystemEnabled => + S1Temperature.TemperatureUtility.TemperatureSystemEnabled; + + /// + /// Converts a Celsius temperature to Fahrenheit. + /// + /// The temperature in degrees Celsius. + /// The temperature in degrees Fahrenheit. + public static float ToFahrenheit(float celsius) => + S1Temperature.TemperatureUtility.ToFahrenheit(celsius); + + /// + /// Formats a Celsius temperature with the game's Celsius unit display. + /// + /// The temperature in degrees Celsius. + /// The number of decimal places to display. + /// The formatted Celsius temperature. + public static string FormatCelsiusTemperature(float celsius, int decimalPoints) => + S1Temperature.TemperatureUtility.FormatCelsiusTemperature(celsius, decimalPoints); + + /// + /// Formats a Fahrenheit temperature with the game's Fahrenheit unit display. + /// + /// The temperature in degrees Fahrenheit. + /// The number of decimal places to display. + /// The formatted Fahrenheit temperature. + public static string FormatFahrenheitTemperature(float fahrenheit, int decimalPoints) => + S1Temperature.TemperatureUtility.FormatFahrenheitTemperature(fahrenheit, decimalPoints); + + /// + /// Formats a Celsius temperature with the unit selected by the game. + /// + /// The temperature in degrees Celsius. + /// The number of decimal places to display. + /// The formatted temperature. + public static string FormatTemperatureWithAppropriateUnit( + float celsius, + int decimalPoints = 1) => + S1Temperature.TemperatureUtility.FormatTemperatureWithAppropriateUnit(celsius, decimalPoints); + + /// + /// Normalizes a Celsius temperature with the game's native temperature range. + /// + /// The temperature in degrees Celsius. + /// The normalized temperature. + public static float NormalizeTemperature(float celsius) => + S1Temperature.TemperatureUtility.NormalizeTemperature(celsius); + } +}