TipTriggerObject.cs

Component attached to a GameObject that retires a field-guide tip when a condition occurs. It supports modes: Interacted (manual call), PlayerEntered (player within a radius), LookedAt (aim ray hits the object), and Signal (raise a named signal to all TipsCoach components).

Reflection
using System;
using Sandbox;

namespace FieldGuide.Tips;

/// <summary>
/// Drop this on a GameObject (an NPC, a door, a pickup) to retire a tip when the player does something to
/// that object. Pick the tip id and the mode in the inspector; no code for the common cases. So "talk to
/// this NPC" is: add this component to the NPC, set <see cref="TipId"/>, set
/// <see cref="CompleteOn"/> = <see cref="Mode.Interacted"/>, and call <see cref="Interacted"/> (or the
/// static <see cref="NotifyInteracted"/>) from wherever your game already knows an interaction happened.
///
/// <see cref="Mode.PlayerEntered"/> and <see cref="Mode.LookedAt"/> read the <see cref="TipsWorld"/>
/// seams and are INERT until those seams are set (they never fire and never throw), so a game that skips
/// the seams still runs. Input, Signal and Timer completion do not use this component at all; they live on
/// the tip itself (declarative triggers or the coach's input pass).
/// </summary>
[Title( "Tip Trigger" )]
[Category( "Field Guide Tips" )]
[Icon( "ads_click" )]
public sealed class TipTriggerObject : Component
{
	public enum Mode
	{
		/// <summary>Your interaction code (or the fieldguide.interaction bridge) calls <see cref="Interacted"/>.</summary>
		Interacted,

		/// <summary>The local player is within <see cref="Radius"/> of this object (needs <see cref="TipsWorld.LocalPlayerPosition"/>).</summary>
		PlayerEntered,

		/// <summary>The aim ray hits this object within <see cref="Radius"/> (needs <see cref="TipsWorld.AimRay"/>).</summary>
		LookedAt,

		/// <summary>Raise a named signal instead of completing directly (fans out to every coach's Signal).</summary>
		Signal,
	}

	[Property] public string TipId { get; set; } = "";
	[Property] public Mode CompleteOn { get; set; } = Mode.Interacted;

	/// <summary>Trigger distance for PlayerEntered, and the aim-ray length for LookedAt, in world units.</summary>
	[Property] public float Radius { get; set; } = 128f;

	/// <summary>Signal name raised in <see cref="Mode.Signal"/>.</summary>
	[Property] public string SignalName { get; set; } = "";

	protected override void OnUpdate()
	{
		if ( CompleteOn == Mode.PlayerEntered && WithinPlayerRadius() )
			Fire();
		else if ( CompleteOn == Mode.LookedAt && AimRayHitsSelf() )
			Fire();
	}

	/// <summary>Call from your interaction code when this object is interacted with. Inert unless the mode
	/// is <see cref="Mode.Interacted"/>.</summary>
	public void Interacted()
	{
		if ( CompleteOn == Mode.Interacted )
			Fire();
	}

	/// <summary>
	/// Convenience for an interaction bridge: fire the <see cref="Interacted"/> trigger on a target
	/// GameObject if it carries a <see cref="TipTriggerObject"/>. Null-safe on the target and on a missing
	/// component, so a bridge can call it for every interacted object without guarding.
	/// </summary>
	public static void NotifyInteracted( GameObject target )
		=> target?.Components.Get<TipTriggerObject>( FindMode.EnabledInSelfAndDescendants )?.Interacted();

	private void Fire()
	{
		if ( CompleteOn == Mode.Signal )
		{
			if ( string.IsNullOrEmpty( SignalName ) )
				return;
			foreach ( var coach in Scene.GetAllComponents<TipsCoach>() )
				coach.Signal( SignalName );
			return;
		}

		TipsCoach.Complete( TipId );
	}

	private bool WithinPlayerRadius()
	{
		var seam = TipsWorld.LocalPlayerPosition; // fail-inert: null seam disables PlayerEntered
		if ( seam is null )
			return false;

		try
		{
			return WorldPosition.Distance( seam() ) <= Radius;
		}
		catch
		{
			return false;
		}
	}

	private bool AimRayHitsSelf()
	{
		var seam = TipsWorld.AimRay; // fail-inert: null seam disables LookedAt
		if ( seam is null )
			return false;

		try
		{
			var ray = seam();
			var tr = Scene.Trace.Ray( ray.Position, ray.Position + ray.Forward * Radius ).Run();
			return tr.Hit && tr.GameObject.IsValid()
				&& ( tr.GameObject == GameObject || GameObject.IsDescendant( tr.GameObject ) );
		}
		catch
		{
			return false;
		}
	}
}