diff --git a/S1API.Tests/Interaction/InteractionPromptApiCompatibilityTests.cs b/S1API.Tests/Interaction/InteractionPromptApiCompatibilityTests.cs new file mode 100644 index 00000000..14cb0a17 --- /dev/null +++ b/S1API.Tests/Interaction/InteractionPromptApiCompatibilityTests.cs @@ -0,0 +1,109 @@ +using System.Reflection; +using S1API.Interaction; +using UnityEngine; + +namespace S1API.Tests.Interaction; + +public sealed class InteractionPromptApiCompatibilityTests +{ + [Fact] + public void BuilderFactoriesPreserveSourceAndBinaryShape() + { + AssertStaticFactory( + typeof(InteractionPromptBuilder), + nameof(InteractionPromptBuilder.Create)); + AssertStaticFactory( + typeof(InteractionPrompt), + nameof(InteractionPrompt.CreateBuilder)); + } + + [Fact] + public void BuilderExposesExpectedFluentShape() + { + AssertBuilderMethod(nameof(InteractionPromptBuilder.WithMessage), typeof(string)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.WithInput), typeof(InteractionPromptInput)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.WithState), typeof(InteractionPromptState)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.WithRange), typeof(float)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.WithPriority), typeof(int)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.WithAngleLimit), typeof(float)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.WithoutAngleLimit)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.WithDisplayLocation), typeof(Transform)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.WithDisplayLocation), typeof(Collider)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.OnHovered), typeof(Action)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.OnInteractionStarted), typeof(Action)); + AssertBuilderMethod(nameof(InteractionPromptBuilder.OnInteractionEnded), typeof(Action)); + + MethodInfo build = typeof(InteractionPromptBuilder).GetMethod( + nameof(InteractionPromptBuilder.Build), + Type.EmptyTypes)!; + Assert.Equal(typeof(InteractionPrompt), build.ReturnType); + } + + [Fact] + public void HandleExposesExpectedRuntimeShape() + { + AssertHandleMethod(nameof(InteractionPrompt.SetMessage), typeof(string)); + AssertHandleMethod(nameof(InteractionPrompt.SetInput), typeof(InteractionPromptInput)); + AssertHandleMethod(nameof(InteractionPrompt.SetState), typeof(InteractionPromptState)); + AssertHandleMethod(nameof(InteractionPrompt.SetRange), typeof(float)); + AssertHandleMethod(nameof(InteractionPrompt.SetPriority), typeof(int)); + AssertHandleMethod(nameof(InteractionPrompt.SetAngleLimit), typeof(float)); + AssertHandleMethod(nameof(InteractionPrompt.WithoutAngleLimit)); + AssertHandleMethod(nameof(InteractionPrompt.SetDisplayLocation), typeof(Transform)); + AssertHandleMethod(nameof(InteractionPrompt.SetDisplayLocation), typeof(Collider)); + AssertHandleMethod(nameof(InteractionPrompt.ClearDisplayLocation)); + + MethodInfo remove = typeof(InteractionPrompt).GetMethod( + nameof(InteractionPrompt.Remove), + Type.EmptyTypes)!; + Assert.Equal(typeof(bool), remove.ReturnType); + Assert.Contains(typeof(IDisposable), typeof(InteractionPrompt).GetInterfaces()); + + AssertProperty(nameof(InteractionPrompt.Target), typeof(GameObject)); + AssertProperty(nameof(InteractionPrompt.Message), typeof(string)); + AssertProperty(nameof(InteractionPrompt.Input), typeof(InteractionPromptInput)); + AssertProperty(nameof(InteractionPrompt.State), typeof(InteractionPromptState)); + AssertProperty(nameof(InteractionPrompt.Range), typeof(float)); + AssertProperty(nameof(InteractionPrompt.Priority), typeof(int)); + AssertProperty(nameof(InteractionPrompt.IsAngleLimited), typeof(bool)); + AssertProperty(nameof(InteractionPrompt.AngleLimit), typeof(float)); + AssertProperty(nameof(InteractionPrompt.IsRemoved), typeof(bool)); + + AssertEvent(nameof(InteractionPrompt.Hovered)); + AssertEvent(nameof(InteractionPrompt.InteractionStarted)); + AssertEvent(nameof(InteractionPrompt.InteractionEnded)); + } + + private static void AssertStaticFactory(Type declaringType, string methodName) + { + MethodInfo method = declaringType.GetMethod(methodName, new[] { typeof(GameObject) })!; + Assert.True(method.IsStatic); + Assert.Equal(typeof(InteractionPromptBuilder), method.ReturnType); + Assert.Equal("target", Assert.Single(method.GetParameters()).Name); + } + + private static void AssertBuilderMethod(string methodName, params Type[] parameterTypes) + { + MethodInfo method = typeof(InteractionPromptBuilder).GetMethod(methodName, parameterTypes)!; + Assert.Equal(typeof(InteractionPromptBuilder), method.ReturnType); + } + + private static void AssertHandleMethod(string methodName, params Type[] parameterTypes) + { + MethodInfo method = typeof(InteractionPrompt).GetMethod(methodName, parameterTypes)!; + Assert.Equal(typeof(InteractionPrompt), method.ReturnType); + } + + private static void AssertProperty(string propertyName, Type propertyType) + { + PropertyInfo property = typeof(InteractionPrompt).GetProperty(propertyName)!; + Assert.Equal(propertyType, property.PropertyType); + Assert.NotNull(property.GetMethod); + } + + private static void AssertEvent(string eventName) + { + EventInfo eventInfo = typeof(InteractionPrompt).GetEvent(eventName)!; + Assert.Equal(typeof(Action), eventInfo.EventHandlerType); + } +} diff --git a/S1API.Tests/Interaction/InteractionPromptApiCompileFixture.cs b/S1API.Tests/Interaction/InteractionPromptApiCompileFixture.cs new file mode 100644 index 00000000..9f69db63 --- /dev/null +++ b/S1API.Tests/Interaction/InteractionPromptApiCompileFixture.cs @@ -0,0 +1,60 @@ +using System; +using S1API.Interaction; +using UnityEngine; + +namespace S1API.Tests.Interaction; + +internal static class InteractionPromptApiCompileFixture +{ + internal static InteractionPrompt Configure( + GameObject target, + Collider displayCollider, + Transform displayPoint, + Action onHovered, + Action onStarted, + Action onEnded) + { + return InteractionPrompt + .CreateBuilder(target) + .WithMessage("Use machine") + .WithInput(InteractionPromptInput.Interact) + .WithState(InteractionPromptState.Default) + .WithRange(3f) + .WithPriority(5) + .WithAngleLimit(75f) + .WithoutAngleLimit() + .WithDisplayLocation(displayPoint) + .WithDisplayLocation(displayCollider) + .OnHovered(onHovered) + .OnInteractionStarted(onStarted) + .OnInteractionEnded(onEnded) + .Build(); + } + + internal static void ConfigureRuntime(InteractionPrompt prompt, Collider displayCollider, Transform displayPoint) + { + prompt + .SetMessage("Stop machine") + .SetInput(InteractionPromptInput.PrimaryClick) + .SetState(InteractionPromptState.Invalid) + .SetRange(2f) + .SetPriority(10) + .SetAngleLimit(45f) + .WithoutAngleLimit() + .SetDisplayLocation(displayPoint) + .SetDisplayLocation(displayCollider) + .ClearDisplayLocation(); + + _ = prompt.Target; + _ = prompt.Message; + _ = prompt.Input; + _ = prompt.State; + _ = prompt.Range; + _ = prompt.Priority; + _ = prompt.IsAngleLimited; + _ = prompt.AngleLimit; + _ = prompt.IsRemoved; + prompt.Remove(); + prompt.Dispose(); + } +} diff --git a/S1API.Tests/Interaction/InteractionPromptContractTests.cs b/S1API.Tests/Interaction/InteractionPromptContractTests.cs new file mode 100644 index 00000000..929c3023 --- /dev/null +++ b/S1API.Tests/Interaction/InteractionPromptContractTests.cs @@ -0,0 +1,185 @@ +using System; +using System.Reflection; +using S1API.Interaction; +using UnityEngine; + +namespace S1API.Tests.Interaction; + +public sealed class InteractionPromptContractTests +{ + [Fact] + public void ExplicitBuilderConfigurationReplacesDefaults() + { + InteractionPromptBuilder builder = CreateManagedBuilderFixture(); + Action hovered = () => { }; + Action started = () => { }; + Action ended = () => { }; + + InteractionPromptBuilder result = builder + .WithMessage("Use") + .WithInput(InteractionPromptInput.PrimaryClick) + .WithState(InteractionPromptState.Label) + .WithRange(2.5f) + .WithPriority(8) + .WithAngleLimit(60f) + .OnHovered(hovered) + .OnHovered(hovered) + .OnInteractionStarted(started) + .OnInteractionStarted(started) + .OnInteractionEnded(ended) + .OnInteractionEnded(ended); + + Assert.Same(builder, result); + Assert.Equal("Use", builder.Message); + Assert.Equal(InteractionPromptInput.PrimaryClick, builder.Input); + Assert.Equal(InteractionPromptState.Label, builder.State); + Assert.Equal(2.5f, builder.Range); + Assert.Equal(8, builder.Priority); + Assert.True(builder.LimitAngle); + Assert.Equal(60f, builder.AngleLimit); + Assert.Same(hovered, Assert.Single(builder.HoveredCallbacks)); + Assert.Same(started, Assert.Single(builder.InteractionStartedCallbacks)); + Assert.Same(ended, Assert.Single(builder.InteractionEndedCallbacks)); + } + + [Fact] + public void FailedMessageValidationLeavesBuilderMutableForImmediateRetry() + { + InteractionPromptBuilder builder = CreateManagedBuilderFixture(); + + Assert.Throws(() => builder.Build()); + + Assert.Same(builder, builder.WithMessage("Retry")); + Assert.Equal("Retry", builder.Message); + } + + [Fact] + public void BuiltBuilderReturnsCachedHandleAndRejectsFurtherMutation() + { + InteractionPromptBuilder builder = CreateManagedBuilderFixture(); + InteractionPrompt prompt = TestObjectFactory.CreateUninitialized(); + SetBuiltPrompt(builder, prompt); + + Assert.Same(prompt, builder.Build()); + Assert.Throws(() => builder.WithMessage("Changed")); + Assert.Throws(() => builder.WithPriority(1)); + Assert.Throws(() => builder.OnHovered(() => { })); + } + + [Fact] + public void BuilderRejectsNullCallbacksAndUndefinedEnums() + { + InteractionPromptBuilder builder = CreateManagedBuilderFixture(); + + Assert.Throws(() => builder.OnHovered(null!)); + Assert.Throws(() => builder.OnInteractionStarted(null!)); + Assert.Throws(() => builder.OnInteractionEnded(null!)); + Assert.Throws( + () => builder.WithDisplayLocation((Transform)null!)); + Assert.Throws( + () => builder.WithDisplayLocation((Collider)null!)); + Assert.Throws( + () => builder.WithInput((InteractionPromptInput)99)); + Assert.Throws( + () => builder.WithState((InteractionPromptState)99)); + } + + [Fact] + public void PublicFactoriesRejectNullTargets() + { + Assert.Throws(() => InteractionPromptBuilder.Create(null!)); + Assert.Throws(() => InteractionPrompt.CreateBuilder(null!)); + } + + [Fact] + public void NativeRangeIsBoundedByInteractionManagerCast() + { + Assert.Equal(4f, InteractionPromptContract.NativeMaxInteractionRange); + Assert.Equal(90f, InteractionPromptContract.DefaultAngleLimit); + Assert.Equal(0.1f, InteractionPromptContract.NormalizeRange(0.1f)); + Assert.Equal(4f, InteractionPromptContract.NormalizeRange(4f)); + } + + [Theory] + [InlineData(0f)] + [InlineData(-1f)] + [InlineData(4.01f)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void RangeRejectsUnsupportedValues(float range) + { + Assert.Throws( + () => InteractionPromptContract.NormalizeRange(range)); + } + + [Theory] + [InlineData(0f)] + [InlineData(-1f)] + [InlineData(180.01f)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void AngleLimitRejectsUnsupportedValues(float angleLimit) + { + Assert.Throws( + () => InteractionPromptContract.NormalizeAngleLimit(angleLimit)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void MessageRejectsMissingText(string? message) + { + Assert.ThrowsAny( + () => InteractionPromptContract.NormalizeMessage(message!)); + } + + [Fact] + public void PublicEnumsRemainStableAndNativeOrdered() + { + Assert.Equal(0, (int)InteractionPromptInput.Interact); + Assert.Equal(1, (int)InteractionPromptInput.PrimaryClick); + Assert.Equal(0, (int)InteractionPromptState.Default); + Assert.Equal(1, (int)InteractionPromptState.Invalid); + Assert.Equal(2, (int)InteractionPromptState.Disabled); + Assert.Equal(3, (int)InteractionPromptState.Label); + } + + [Fact] + public void UndefinedEnumValuesAreRejected() + { + Assert.Throws( + () => InteractionPromptContract.ValidateEnum( + (InteractionPromptInput)99, + "input")); + Assert.Throws( + () => InteractionPromptContract.ValidateEnum( + (InteractionPromptState)99, + "state")); + } + + private static InteractionPromptBuilder CreateManagedBuilderFixture() + { + InteractionPromptBuilder builder = + TestObjectFactory.CreateUninitialized(); + SetPrivateField(builder, "_hoveredCallbacks", new List()); + SetPrivateField(builder, "_interactionStartedCallbacks", new List()); + SetPrivateField(builder, "_interactionEndedCallbacks", new List()); + return builder; + } + + private static void SetBuiltPrompt(InteractionPromptBuilder builder, InteractionPrompt prompt) + { + SetPrivateField(builder, "_builtPrompt", prompt); + } + + private static void SetPrivateField(InteractionPromptBuilder builder, string name, TValue value) + { + FieldInfo field = typeof(InteractionPromptBuilder).GetField( + name, + BindingFlags.Instance | BindingFlags.NonPublic)!; + field.SetValue(builder, value); + } +} diff --git a/S1API.Tests/S1API.Tests.csproj b/S1API.Tests/S1API.Tests.csproj index 7c5fa730..9723afa5 100644 --- a/S1API.Tests/S1API.Tests.csproj +++ b/S1API.Tests/S1API.Tests.csproj @@ -43,6 +43,9 @@ $(MonoAssembliesPath)/UnityEngine.CoreModule.dll + + $(MonoAssembliesPath)/UnityEngine.PhysicsModule.dll + @@ -55,5 +58,6 @@ + diff --git a/S1API/Interaction/InteractionPrompt.cs b/S1API/Interaction/InteractionPrompt.cs new file mode 100644 index 00000000..d92dfaa6 --- /dev/null +++ b/S1API/Interaction/InteractionPrompt.cs @@ -0,0 +1,223 @@ +using System; +using UnityEngine; + +namespace S1API.Interaction +{ + /// + /// Managed handle for a native Schedule One interaction prompt. + /// + public sealed class InteractionPrompt : IDisposable + { + private readonly Internal.Interaction.InteractionPromptRuntime _runtime; + private bool _removed; + + /// Creates a builder for a mod-owned GameObject. + /// The GameObject whose collider should be interactable. + /// A new interaction prompt builder. + public static InteractionPromptBuilder CreateBuilder(GameObject target) => + InteractionPromptBuilder.Create(target); + + /// The GameObject containing the native prompt component. + public GameObject Target => _runtime.Target; + + /// The current prompt message. + public string Message { get; private set; } + + /// The current native input action. + public InteractionPromptInput Input { get; private set; } + + /// The current native prompt state. + public InteractionPromptState State { get; private set; } + + /// The current maximum interaction range. + public float Range { get; private set; } + + /// The current overlap priority. + public int Priority { get; private set; } + + /// Whether the prompt currently applies an angle restriction. + public bool IsAngleLimited { get; private set; } + + /// The current angle restriction in degrees. + public float AngleLimit { get; private set; } + + /// Whether this handle has been removed or its target destroyed. + public bool IsRemoved => _removed || !_runtime.IsAttached; + + /// Invoked while the native interaction manager selects this prompt. + public event Action? Hovered; + + /// Invoked when the native interaction starts. + public event Action? InteractionStarted; + + /// Invoked when the native interaction ends. + public event Action? InteractionEnded; + + internal InteractionPrompt(InteractionPromptBuilder builder) + { + Message = builder.Message; + Input = builder.Input; + State = builder.State; + Range = builder.Range; + Priority = builder.Priority; + IsAngleLimited = builder.LimitAngle; + AngleLimit = builder.AngleLimit; + _runtime = new Internal.Interaction.InteractionPromptRuntime( + this, + builder.Target, + builder.Input, + builder.State, + builder.Message, + builder.Range, + builder.Priority, + builder.LimitAngle, + builder.AngleLimit, + builder.DisplayPoint, + builder.DisplayCollider); + + foreach (Action callback in builder.HoveredCallbacks) + Hovered += callback; + foreach (Action callback in builder.InteractionStartedCallbacks) + InteractionStarted += callback; + foreach (Action callback in builder.InteractionEndedCallbacks) + InteractionEnded += callback; + } + + /// Updates the native prompt message. + /// Non-empty player-facing prompt text. + /// This prompt for fluent chaining. + public InteractionPrompt SetMessage(string message) + { + EnsureUsable(); + Message = InteractionPromptContract.NormalizeMessage(message); + _runtime.SetMessage(Message); + return this; + } + + /// Updates the native input action. + /// The interaction input action. + /// This prompt for fluent chaining. + public InteractionPrompt SetInput(InteractionPromptInput input) + { + EnsureUsable(); + InteractionPromptContract.ValidateEnum(input, nameof(input)); + Input = input; + _runtime.SetInput(input); + return this; + } + + /// Updates the native prompt state. + /// The visual and interaction state. + /// This prompt for fluent chaining. + public InteractionPrompt SetState(InteractionPromptState state) + { + EnsureUsable(); + InteractionPromptContract.ValidateEnum(state, nameof(state)); + State = state; + _runtime.SetState(state); + return this; + } + + /// Updates the maximum selection distance. + /// A finite distance between zero and four metres. + /// This prompt for fluent chaining. + public InteractionPrompt SetRange(float range) + { + EnsureUsable(); + Range = InteractionPromptContract.NormalizeRange(range); + _runtime.SetRange(Range); + return this; + } + + /// Updates the overlap priority. + /// A higher value takes precedence. + /// This prompt for fluent chaining. + public InteractionPrompt SetPriority(int priority) + { + EnsureUsable(); + Priority = priority; + _runtime.SetPriority(priority); + return this; + } + + /// Applies a horizontal angle restriction. + /// A finite angle greater than zero and at most 180 degrees. + /// This prompt for fluent chaining. + public InteractionPrompt SetAngleLimit(float angleLimit) + { + EnsureUsable(); + AngleLimit = InteractionPromptContract.NormalizeAngleLimit(angleLimit); + IsAngleLimited = true; + _runtime.SetAngleLimit(AngleLimit); + return this; + } + + /// Clears the horizontal angle restriction. + /// This prompt for fluent chaining. + public InteractionPrompt WithoutAngleLimit() + { + EnsureUsable(); + IsAngleLimited = false; + _runtime.ClearAngleLimit(); + return this; + } + + /// Updates the prompt display anchor to a transform. + /// The transform whose position should be used. + /// This prompt for fluent chaining. + public InteractionPrompt SetDisplayLocation(Transform displayPoint) + { + EnsureUsable(); + if (ReferenceEquals(displayPoint, null) || displayPoint == null) + throw new ArgumentNullException(nameof(displayPoint)); + _runtime.SetDisplayLocation(displayPoint); + return this; + } + + /// Updates the prompt display anchor to a collider. + /// The collider used for display positioning. + /// This prompt for fluent chaining. + public InteractionPrompt SetDisplayLocation(Collider displayCollider) + { + EnsureUsable(); + if (ReferenceEquals(displayCollider, null) || displayCollider == null) + throw new ArgumentNullException(nameof(displayCollider)); + _runtime.SetDisplayLocation(displayCollider); + return this; + } + + /// Resets prompt positioning to the target transform. + /// This prompt for fluent chaining. + public InteractionPrompt ClearDisplayLocation() + { + EnsureUsable(); + _runtime.ClearDisplayLocation(); + return this; + } + + /// Removes the owned native prompt component. + /// when this call performed the removal. + public bool Remove() + { + if (IsRemoved) + return false; + + _removed = true; + _runtime.Dispose(); + return true; + } + + /// + public void Dispose() => Remove(); + + internal void RaiseHovered() => Hovered?.Invoke(); + internal void RaiseInteractionStarted() => InteractionStarted?.Invoke(); + internal void RaiseInteractionEnded() => InteractionEnded?.Invoke(); + + private void EnsureUsable() + { + if (IsRemoved) + throw new ObjectDisposedException(nameof(InteractionPrompt)); + } + } +} diff --git a/S1API/Interaction/InteractionPromptBuilder.cs b/S1API/Interaction/InteractionPromptBuilder.cs new file mode 100644 index 00000000..7ae31b8a --- /dev/null +++ b/S1API/Interaction/InteractionPromptBuilder.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace S1API.Interaction +{ + /// + /// Builds a native Schedule One interaction prompt for a mod-owned GameObject. + /// + /// + /// The target must contain a collider on a layer included by the game's interaction + /// search mask. The builder does not create colliders or change object layers. + /// + public sealed class InteractionPromptBuilder + { + private readonly GameObject _target; + private readonly List _hoveredCallbacks = new List(); + private readonly List _interactionStartedCallbacks = new List(); + private readonly List _interactionEndedCallbacks = new List(); + + private string? _message; + private InteractionPromptInput _input = InteractionPromptInput.Interact; + private InteractionPromptState _state = InteractionPromptState.Default; + private float _range = InteractionPromptContract.NativeMaxInteractionRange; + private int _priority; + private bool _limitAngle; + private float _angleLimit = InteractionPromptContract.DefaultAngleLimit; + private Transform? _displayPoint; + private Collider? _displayCollider; + private InteractionPrompt? _builtPrompt; + + internal GameObject Target => _target; + internal string Message => _message!; + internal InteractionPromptInput Input => _input; + internal InteractionPromptState State => _state; + internal float Range => _range; + internal int Priority => _priority; + internal bool LimitAngle => _limitAngle; + internal float AngleLimit => _angleLimit; + internal Transform? DisplayPoint => _displayPoint; + internal Collider? DisplayCollider => _displayCollider; + internal IReadOnlyList HoveredCallbacks => _hoveredCallbacks; + internal IReadOnlyList InteractionStartedCallbacks => _interactionStartedCallbacks; + internal IReadOnlyList InteractionEndedCallbacks => _interactionEndedCallbacks; + + internal InteractionPromptBuilder(GameObject target) + { + if (ReferenceEquals(target, null) || target == null) + throw new ArgumentNullException(nameof(target)); + + _target = target; + } + + /// + /// Creates a builder for a mod-owned GameObject. + /// + /// The GameObject whose collider should be interactable. + /// A new interaction prompt builder. + public static InteractionPromptBuilder Create(GameObject target) => + new InteractionPromptBuilder(target); + + /// Sets the text rendered by the native interaction prompt. + /// Non-empty player-facing prompt text. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithMessage(string message) + { + EnsureMutable(); + _message = InteractionPromptContract.NormalizeMessage(message); + return this; + } + + /// Sets the native input action used to begin the interaction. + /// The interaction input action. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithInput(InteractionPromptInput input) + { + EnsureMutable(); + InteractionPromptContract.ValidateEnum(input, nameof(input)); + _input = input; + return this; + } + + /// Sets the native prompt state. + /// The visual and interaction state. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithState(InteractionPromptState state) + { + EnsureMutable(); + InteractionPromptContract.ValidateEnum(state, nameof(state)); + _state = state; + return this; + } + + /// + /// Sets the maximum distance at which the prompt can be selected. + /// + /// A finite distance between zero and four metres. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithRange(float range) + { + EnsureMutable(); + _range = InteractionPromptContract.NormalizeRange(range); + return this; + } + + /// Sets the priority used when native prompts overlap. + /// A higher value takes precedence. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithPriority(int priority) + { + EnsureMutable(); + _priority = priority; + return this; + } + + /// + /// Limits selection to the specified horizontal angle around the target's forward direction. + /// + /// A finite angle greater than zero and at most 180 degrees. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithAngleLimit(float angleLimit) + { + EnsureMutable(); + _angleLimit = InteractionPromptContract.NormalizeAngleLimit(angleLimit); + _limitAngle = true; + return this; + } + + /// Clears any angle restriction. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithoutAngleLimit() + { + EnsureMutable(); + _limitAngle = false; + return this; + } + + /// + /// Uses a transform as the native prompt display anchor. + /// + /// The transform whose position should be used. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithDisplayLocation(Transform displayPoint) + { + EnsureMutable(); + if (ReferenceEquals(displayPoint, null) || displayPoint == null) + throw new ArgumentNullException(nameof(displayPoint)); + + _displayPoint = displayPoint; + _displayCollider = null; + return this; + } + + /// + /// Uses a collider's closest point to the player as the native prompt display anchor. + /// + /// The collider used for display positioning. + /// This builder for fluent chaining. + public InteractionPromptBuilder WithDisplayLocation(Collider displayCollider) + { + EnsureMutable(); + if (ReferenceEquals(displayCollider, null) || displayCollider == null) + throw new ArgumentNullException(nameof(displayCollider)); + + _displayCollider = displayCollider; + _displayPoint = null; + return this; + } + + /// Registers a callback invoked while this prompt is selected. + /// The callback invoked by the native hover lifecycle. + /// This builder for fluent chaining. + public InteractionPromptBuilder OnHovered(Action callback) + { + EnsureMutable(); + AddCallback(_hoveredCallbacks, callback, nameof(callback)); + return this; + } + + /// Registers a callback invoked when the native interaction starts. + /// The callback invoked by the native interaction lifecycle. + /// This builder for fluent chaining. + public InteractionPromptBuilder OnInteractionStarted(Action callback) + { + EnsureMutable(); + AddCallback(_interactionStartedCallbacks, callback, nameof(callback)); + return this; + } + + /// Registers a callback invoked when the native interaction ends. + /// The callback invoked by the native interaction lifecycle. + /// This builder for fluent chaining. + public InteractionPromptBuilder OnInteractionEnded(Action callback) + { + EnsureMutable(); + AddCallback(_interactionEndedCallbacks, callback, nameof(callback)); + return this; + } + + /// + /// Attaches the native interaction component and returns a managed runtime handle. + /// + /// The managed interaction prompt handle. + /// + /// Thrown when the message is missing, the target already has an interaction component, + /// or no usable collider exists beneath the target. + /// + public InteractionPrompt Build() + { + if (_builtPrompt != null) + return _builtPrompt; + if (_message == null) + throw new InvalidOperationException("WithMessage must be called before Build()."); + + _builtPrompt = new InteractionPrompt(this); + return _builtPrompt; + } + + private void EnsureMutable() + { + if (_builtPrompt != null) + throw new InvalidOperationException("This interaction prompt builder has already been built."); + } + + private static void AddCallback(List callbacks, Action callback, string parameterName) + { + if (callback == null) + throw new ArgumentNullException(parameterName); + if (!callbacks.Contains(callback)) + callbacks.Add(callback); + } + } +} diff --git a/S1API/Interaction/InteractionPromptContract.cs b/S1API/Interaction/InteractionPromptContract.cs new file mode 100644 index 00000000..d2f4481f --- /dev/null +++ b/S1API/Interaction/InteractionPromptContract.cs @@ -0,0 +1,59 @@ +using System; +using UnityEngine; + +namespace S1API.Interaction +{ + internal static class InteractionPromptContract + { + internal const float NativeMaxInteractionRange = 4f; + internal const float DefaultAngleLimit = 90f; + + internal static string NormalizeMessage(string message) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentException("Interaction prompt messages cannot be empty or whitespace.", nameof(message)); + + return message; + } + + internal static float NormalizeRange(float range) + { + if (float.IsNaN(range) || float.IsInfinity(range) || range <= 0f || range > NativeMaxInteractionRange) + { + throw new ArgumentOutOfRangeException( + nameof(range), + "Interaction prompt range must be finite, greater than zero, and at most four metres."); + } + + return range; + } + + internal static float NormalizeAngleLimit(float angleLimit) + { + if (float.IsNaN(angleLimit) || float.IsInfinity(angleLimit) || angleLimit <= 0f || angleLimit > 180f) + { + throw new ArgumentOutOfRangeException( + nameof(angleLimit), + "Interaction prompt angle limits must be finite, greater than zero, and at most 180 degrees."); + } + + return angleLimit; + } + + internal static void ValidateEnum(TEnum value, string parameterName) + where TEnum : struct, Enum + { + if (!Enum.IsDefined(typeof(TEnum), value)) + throw new ArgumentOutOfRangeException(parameterName, value, "The interaction prompt value is not defined."); + } + + internal static void ValidateTarget(GameObject target) + { + if (ReferenceEquals(target, null) || target == null) + throw new ArgumentNullException(nameof(target)); + } + } +} diff --git a/S1API/Interaction/InteractionPromptInput.cs b/S1API/Interaction/InteractionPromptInput.cs new file mode 100644 index 00000000..9d52d234 --- /dev/null +++ b/S1API/Interaction/InteractionPromptInput.cs @@ -0,0 +1,14 @@ +namespace S1API.Interaction +{ + /// + /// Selects the native input action used to start an interaction. + /// + public enum InteractionPromptInput + { + /// The configured keyboard or controller interaction action. + Interact, + + /// The configured primary-click action. + PrimaryClick + } +} diff --git a/S1API/Interaction/InteractionPromptState.cs b/S1API/Interaction/InteractionPromptState.cs new file mode 100644 index 00000000..79b1e9ee --- /dev/null +++ b/S1API/Interaction/InteractionPromptState.cs @@ -0,0 +1,20 @@ +namespace S1API.Interaction +{ + /// + /// Selects the native visual and interaction state shown by an interaction prompt. + /// + public enum InteractionPromptState + { + /// Shows the normal input prompt. + Default, + + /// Shows the native invalid prompt and prevents interaction start. + Invalid, + + /// Hides the prompt and prevents interaction selection. + Disabled, + + /// Shows the message without an input icon. + Label + } +} diff --git a/S1API/Internal/Interaction/InteractionPromptRuntime.cs b/S1API/Internal/Interaction/InteractionPromptRuntime.cs new file mode 100644 index 00000000..3ce7ee81 --- /dev/null +++ b/S1API/Internal/Interaction/InteractionPromptRuntime.cs @@ -0,0 +1,182 @@ +#if IL2CPPMELON +using S1Interaction = Il2CppScheduleOne.Interaction; +#elif MONOMELON +using S1Interaction = ScheduleOne.Interaction; +#endif + +using System; +using S1API.Utils; +using UnityEngine; + +namespace S1API.Internal.Interaction +{ + internal sealed class InteractionPromptRuntime : IDisposable + { + private readonly global::S1API.Interaction.InteractionPrompt _owner; + private readonly S1Interaction.InteractableObject _interactable; + private readonly Action _hoveredHandler; + private readonly Action _interactionStartedHandler; + private readonly Action _interactionEndedHandler; + private bool _disposed; + + internal GameObject Target { get; } + + internal bool IsAttached => + !_disposed && _interactable != null && Target != null; + + internal InteractionPromptRuntime( + global::S1API.Interaction.InteractionPrompt owner, + GameObject target, + global::S1API.Interaction.InteractionPromptInput input, + global::S1API.Interaction.InteractionPromptState state, + string message, + float range, + int priority, + bool limitAngle, + float angleLimit, + Transform? displayPoint, + Collider? displayCollider) + { + if (owner == null) + throw new ArgumentNullException(nameof(owner)); + global::S1API.Interaction.InteractionPromptContract.ValidateTarget(target); + if (target.GetComponent() != null) + { + throw new InvalidOperationException( + $"GameObject '{target.name}' already contains an InteractableObject component."); + } + ValidateColliderComposition(target); + + Target = target; + _owner = owner; + S1Interaction.InteractableObject? interactable = null; + try + { + interactable = target.AddComponent(); + _interactable = interactable; + _hoveredHandler = _owner.RaiseHovered; + _interactionStartedHandler = _owner.RaiseInteractionStarted; + _interactionEndedHandler = _owner.RaiseInteractionEnded; + ApplyInput(input); + ApplyState(state); + _interactable.SetMessage(message); + _interactable.MaxInteractionRange = range; + _interactable.Priority = priority; + _interactable.LimitInteractionAngle = limitAngle; + _interactable.AngleLimit = angleLimit; + ApplyDisplayLocation(displayPoint, displayCollider); + + EventHelper.AddListener(_hoveredHandler, _interactable.onHovered); + EventHelper.AddListener(_interactionStartedHandler, _interactable.onInteractStart); + EventHelper.AddListener(_interactionEndedHandler, _interactable.onInteractEnd); + } + catch + { + if (interactable != null) + UnityEngine.Object.Destroy(interactable); + throw; + } + } + + internal void SetMessage(string message) => _interactable.SetMessage(message); + + internal void SetInput(global::S1API.Interaction.InteractionPromptInput input) => ApplyInput(input); + + internal void SetState(global::S1API.Interaction.InteractionPromptState state) => ApplyState(state); + + internal void SetRange(float range) => _interactable.MaxInteractionRange = range; + + internal void SetPriority(int priority) => _interactable.Priority = priority; + + internal void SetAngleLimit(float angleLimit) + { + _interactable.AngleLimit = angleLimit; + _interactable.LimitInteractionAngle = true; + } + + internal void ClearAngleLimit() => _interactable.LimitInteractionAngle = false; + + internal void SetDisplayLocation(Transform displayPoint) => + ApplyDisplayLocation(displayPoint, null); + + internal void SetDisplayLocation(Collider displayCollider) => + ApplyDisplayLocation(null, displayCollider); + + internal void ClearDisplayLocation() => + ApplyDisplayLocation(null, null); + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + if (_interactable != null) + { + EventHelper.RemoveListener(_hoveredHandler, _interactable.onHovered); + EventHelper.RemoveListener(_interactionStartedHandler, _interactable.onInteractStart); + EventHelper.RemoveListener(_interactionEndedHandler, _interactable.onInteractEnd); + UnityEngine.Object.Destroy(_interactable); + } + } + + private void ApplyInput(global::S1API.Interaction.InteractionPromptInput input) + { + S1Interaction.InteractableObject.EInteractionType nativeInput = + input == global::S1API.Interaction.InteractionPromptInput.PrimaryClick + ? S1Interaction.InteractableObject.EInteractionType.LeftMouse_Click + : S1Interaction.InteractableObject.EInteractionType.Key_Press; + _interactable.SetInteractionType(nativeInput); + } + + private void ApplyState(global::S1API.Interaction.InteractionPromptState state) + { + S1Interaction.InteractableObject.EInteractableState nativeState = + (S1Interaction.InteractableObject.EInteractableState)(int)state; + _interactable.SetInteractableState(nativeState); + } + + private void ApplyDisplayLocation(Transform? displayPoint, Collider? displayCollider) + { + if (displayCollider != null) + { + if (!global::S1API.Internal.Utils.ReflectionUtils.TrySetFieldOrProperty( + _interactable, + "displayLocationCollider", + displayCollider)) + { + throw new InvalidOperationException( + "The native interaction component does not expose displayLocationCollider."); + } + + _interactable.displayLocationPoint = null; + return; + } + + if (!global::S1API.Internal.Utils.ReflectionUtils.TrySetFieldOrProperty( + _interactable, + "displayLocationCollider", + null)) + { + throw new InvalidOperationException( + "The native interaction component does not expose displayLocationCollider."); + } + + _interactable.displayLocationPoint = displayPoint; + } + + private static void ValidateColliderComposition(GameObject target) + { + Collider[] colliders = target.GetComponentsInChildren(true); + for (int i = 0; i < colliders.Length; i++) + { + Collider collider = colliders[i]; + if (collider != null) + return; + } + + throw new InvalidOperationException( + $"GameObject '{target.name}' and its children do not contain a collider usable by the interaction manager."); + } + } +} diff --git a/S1API/docs/interaction-prompts.md b/S1API/docs/interaction-prompts.md new file mode 100644 index 00000000..50c20158 --- /dev/null +++ b/S1API/docs/interaction-prompts.md @@ -0,0 +1,63 @@ +# Native Interaction Prompts + +`InteractionPrompt` attaches the game's native interaction prompt to a mod-owned `GameObject`. +The game remains responsible for raycasts, input-device glyphs, prompt rendering, overlap priority, +and the interaction lifecycle. + +```csharp +private InteractionPrompt? _prompt; + +void ConfigureMachine(GameObject machine, Collider interactionCollider) +{ + _prompt = InteractionPrompt + .CreateBuilder(machine) + .WithMessage("Start mixer") + .WithDisplayLocation(interactionCollider) + .WithRange(3f) + .WithPriority(5) + .OnInteractionStarted(StartMixer) + .OnInteractionEnded(StopHoldingMixer) + .Build(); +} +``` + +The target hierarchy must contain a collider on a layer included by the game's interaction search +mask. S1API does not create a collider or change the target's layer. A collider supplied through +`WithDisplayLocation(Collider)` controls only where the prompt is rendered; it is not a replacement +for the target's interaction collider. + +## Prompt configuration + +The builder supports the native input actions `Interact` and `PrimaryClick`, the native states +`Default`, `Invalid`, `Disabled`, and `Label`, a maximum range of four metres, overlap priority, +and an optional horizontal angle limit. The four-metre limit comes from the game's interaction +raycast, so a larger per-component value would not make the prompt reachable. + +If neither display location overload is used, the prompt renders at the target transform. A +transform uses its position directly. A collider uses the closest point to the player, which is +useful for large machines and furniture. + +## Runtime updates and callbacks + +The returned handle can update the message, input, state, range, priority, angle limit, and display +location: + +```csharp +_prompt + ?.SetState(canUse + ? InteractionPromptState.Default + : InteractionPromptState.Invalid) + .SetMessage(canUse ? "Start mixer" : "Mixer is busy"); +``` + +`OnHovered` follows the native event and may run every frame while the prompt is selected. +`OnInteractionStarted` and `OnInteractionEnded` follow the game's input-hold lifecycle. These +callbacks do not provide multiplayer authorization or synchronization. The mod must validate the +request and use its own network policy before changing shared state. + +Call `Remove()` or `Dispose()` when the mod-owned object is retired. Removing the prompt destroys +only the S1API-owned native component and leaves the target, colliders, and other components intact. + +The builder rejects targets that already contain an `InteractableObject`; configure an existing +native interaction component directly when a prefab already owns one or use a separate child +target for an additional prompt. diff --git a/S1API/docs/toc.yml b/S1API/docs/toc.yml index 0a712180..ca199298 100644 --- a/S1API/docs/toc.yml +++ b/S1API/docs/toc.yml @@ -58,6 +58,8 @@ href: quests-system.md - name: Map POIs href: map-pois.md + - name: Native Interaction Prompts + href: interaction-prompts.md - name: Cutscenes href: cutscenes.md - name: Products