Code/TeleportTrigger.cs
namespace TeleportSystem;
[Title( "Teleport Trigger" )]
[Category( "Teleport" )]
public sealed class TeleportTrigger : Component, Component.ExecuteInEditor, Component.ITriggerListener
{
private static readonly Dictionary<GameObject, float> NextAllowedTeleportTime = new();
private static readonly Dictionary<GameObject, PendingArrivalRotation> PendingArrivalRotations = new();
private readonly record struct PendingArrivalRotation( Rotation Rotation, Vector3 Position, float ExpiresAt );
private readonly HashSet<GameObject> _overlappingActors = new();
private readonly HashSet<GameObject> _actorsWaitingForExit = new();
[Property] public TeleportTrigger Destination { get; set; }
[Property, Group( "Filter" )] public bool PlayersOnly { get; set; } = true;
[Property, Group( "Filter" )] public bool RequireEnabledCharacterController { get; set; } = true;
[Property, Group( "Teleport" )] public GameObject DestinationPoint { get; set; }
[Property, Group( "Teleport" )] public bool MatchDestinationRotation { get; set; } = true;
[Property, Group( "Teleport" )] public float CooldownSeconds { get; set; } = 0.35f;
private Collider _zoneCollider;
protected override void OnStart()
{
ResolveZoneCollider();
}
protected override void OnValidate()
{
ResolveZoneCollider();
}
protected override void OnUpdate()
{
if ( !Game.IsPlaying )
return;
ApplyPendingArrivalRotations();
if ( Destination is null || !Destination.IsValid() )
return;
if ( _zoneCollider is null || !_zoneCollider.IsValid() )
return;
foreach ( var actor in _overlappingActors.ToArray() )
{
if ( actor is null || !actor.IsValid() )
{
_overlappingActors.Remove( actor );
continue;
}
if ( !CanTeleport( actor ) )
continue;
if ( _actorsWaitingForExit.Contains( actor ) )
continue;
TeleportActorToDestination( actor );
}
}
public void OnTriggerEnter( Collider other ) => TryTrackActorFromCollider( other );
public void OnTriggerEnter( Collider self, Collider other )
{
if ( _zoneCollider is not null && self != _zoneCollider )
return;
TryTrackActorFromCollider( other );
}
public void OnTriggerEnter( GameObject other )
{
if ( other is null || !other.IsValid() )
return;
TryTrackActorFromGameObject( other );
}
public void OnTriggerEnter( Collider self, GameObject other )
{
if ( _zoneCollider is not null && self != _zoneCollider )
return;
if ( other is null || !other.IsValid() )
return;
TryTrackActorFromGameObject( other );
}
public void OnTriggerExit( Collider other ) => StopTrackingActorFromCollider( other );
public void OnTriggerExit( Collider self, Collider other )
{
if ( _zoneCollider is not null && self != _zoneCollider )
return;
StopTrackingActorFromCollider( other );
}
public void OnTriggerExit( GameObject other ) => StopTrackingActorFromGameObject( other );
public void OnTriggerExit( Collider self, GameObject other )
{
if ( _zoneCollider is not null && self != _zoneCollider )
return;
if ( other is null || !other.IsValid() )
return;
StopTrackingActorFromGameObject( other );
}
private void TryTrackActorFromCollider( Collider touchedCollider )
{
if ( touchedCollider is null || !touchedCollider.IsValid() )
return;
TryTrackActorFromGameObject( touchedCollider.GameObject );
}
private void TryTrackActorFromGameObject( GameObject touchedObject )
{
var actor = ResolveActorForTeleport( touchedObject );
if ( actor is null || !actor.IsValid() )
return;
_overlappingActors.Add( actor );
if ( !CanTeleport( actor ) )
return;
if ( Destination is null || !Destination.IsValid() )
return;
TeleportActorToDestination( actor );
}
private void StopTrackingActorFromCollider( Collider touchedCollider )
{
if ( touchedCollider is null || !touchedCollider.IsValid() )
return;
StopTrackingActorFromGameObject( touchedCollider.GameObject );
}
private void StopTrackingActorFromGameObject( GameObject touchedObject )
{
var actor = ResolveActorForTeleport( touchedObject );
if ( actor is null || !actor.IsValid() )
return;
_overlappingActors.Remove( actor );
_actorsWaitingForExit.Remove( actor );
}
private GameObject ResolveActorForTeleport( GameObject touchedObject )
{
if ( touchedObject is null || !touchedObject.IsValid() )
return null;
if ( !PlayersOnly )
return touchedObject;
var walker = touchedObject;
while ( walker is not null && walker.IsValid() )
{
if ( TryGetSupportedPlayerController( walker, out var isControllerEnabled ) )
{
if ( RequireEnabledCharacterController && !isControllerEnabled )
return null;
return walker;
}
walker = walker.Parent;
}
return null;
}
private static bool TryGetSupportedPlayerController( GameObject go, out bool isEnabled )
{
isEnabled = false;
if ( go is null || !go.IsValid() )
return false;
var characterController = go.Components.Get<CharacterController>();
if ( characterController is not null )
{
isEnabled = characterController.Enabled;
return true;
}
var sandboxPlayerController = go.Components.Get<Sandbox.PlayerController>();
if ( sandboxPlayerController is not null )
{
isEnabled = sandboxPlayerController.Enabled;
return true;
}
return false;
}
private bool CanTeleport( GameObject actor )
{
if ( actor == GameObject )
return false;
if ( NextAllowedTeleportTime.TryGetValue( actor, out var nextAllowed ) && Time.Now < nextAllowed )
return false;
return true;
}
private void TeleportActorToDestination( GameObject actor )
{
var destinationTransform = Destination.GameObject.WorldTransform;
if ( DestinationPoint is not null && DestinationPoint.IsValid() )
destinationTransform = DestinationPoint.WorldTransform;
var rb = actor.Components.Get<Rigidbody>();
var characterController = actor.Components.Get<CharacterController>();
var playerController = actor.Components.Get<Sandbox.PlayerController>();
var velocity = rb?.Velocity
?? playerController?.Velocity
?? characterController?.Velocity
?? Vector3.Zero;
var previousRotation = actor.WorldRotation;
var actorArrivalRotation = ResolveActorArrivalRotation( destinationTransform );
actor.WorldPosition = destinationTransform.Position;
if ( actorArrivalRotation is not null )
{
actor.WorldRotation = actorArrivalRotation.Value;
velocity = RotateVelocityForArrival( velocity, previousRotation, actorArrivalRotation.Value );
SyncArrivalPresentationRotation( actor, actorArrivalRotation.Value, actorArrivalRotation.Value );
PendingArrivalRotations[actor] = new PendingArrivalRotation( actorArrivalRotation.Value, destinationTransform.Position, Time.Now + 0.15f );
}
if ( rb is not null )
{
rb.Velocity = Vector3.Zero;
rb.Sleeping = false;
}
if ( characterController is not null )
{
characterController.MoveTo( destinationTransform.Position, false );
characterController.Velocity = Vector3.Zero;
}
if ( playerController is not null )
{
playerController.WishVelocity = Vector3.Zero;
}
var lockUntil = Time.Now + Math.Max( CooldownSeconds, 0.01f );
NextAllowedTeleportTime[actor] = lockUntil;
MarkActorWaitingForExit( actor );
Destination.BlockActorForCooldown( actor, lockUntil );
Destination.MarkActorWaitingForExit( actor );
}
private void BlockActorForCooldown( GameObject actor, float lockUntil )
{
if ( actor is null || !actor.IsValid() )
return;
NextAllowedTeleportTime[actor] = Math.Max( lockUntil, Time.Now + 0.01f );
}
private void MarkActorWaitingForExit( GameObject actor )
{
if ( actor is null || !actor.IsValid() )
return;
_actorsWaitingForExit.Add( actor );
}
private Rotation? ResolveActorArrivalRotation( Transform destinationTransform )
{
if ( MatchDestinationRotation )
{
return Rotation.FromYaw( destinationTransform.Rotation.Angles().yaw );
}
return null;
}
private static Vector3 RotateVelocityForArrival( Vector3 velocity, Rotation previousRotation, Rotation arrivalRotation )
{
if ( velocity.Length.AlmostEqual( 0f ) )
return velocity;
var yawDelta = arrivalRotation.Angles().yaw - previousRotation.Angles().yaw;
return Rotation.FromYaw( yawDelta ) * velocity;
}
private static void SyncArrivalPresentationRotation( GameObject actor, Rotation presentationRotation, Rotation actorRotation )
{
var actorAngles = actorRotation.Angles();
var walker = actor;
while ( walker is not null && walker.IsValid() )
{
var playerController = walker.Components.Get<Sandbox.PlayerController>();
if ( playerController is not null )
{
walker.WorldRotation = actorRotation;
playerController.EyeAngles = actorAngles;
if ( playerController.Renderer is not null && playerController.Renderer.IsValid() )
{
playerController.Renderer.GameObject.LocalRotation = Rotation.Identity;
playerController.Renderer.GameObject.WorldRotation = presentationRotation;
playerController.UpdateAnimation( playerController.Renderer );
}
AlignVisualBodyRotation( walker, presentationRotation );
return;
}
walker = walker.Parent;
}
}
private static void AlignVisualBodyRotation( GameObject actor, Rotation arrivalRotation )
{
var bodyObject = FindBodyObject( actor );
if ( bodyObject is null || !bodyObject.IsValid() )
return;
bodyObject.WorldRotation = arrivalRotation;
}
private static void ApplyPendingArrivalRotations()
{
if ( PendingArrivalRotations.Count == 0 )
return;
foreach ( var (actor, pending) in PendingArrivalRotations.ToArray() )
{
if ( actor is null || !actor.IsValid() || Time.Now >= pending.ExpiresAt )
{
PendingArrivalRotations.Remove( actor );
continue;
}
var characterController = actor.Components.Get<CharacterController>();
if ( characterController is not null )
characterController.MoveTo( pending.Position, false );
actor.WorldPosition = pending.Position;
SyncArrivalPresentationRotation( actor, pending.Rotation, actor.WorldRotation );
}
}
private static GameObject FindBodyObject( GameObject actor )
{
foreach ( var child in actor.Children )
{
if ( child is null || !child.IsValid() )
continue;
if ( child.Name == "Body" )
return child;
if ( child.Components.Get<SkinnedModelRenderer>() is not null )
return child;
var nested = FindBodyObject( child );
if ( nested is not null )
return nested;
}
return null;
}
private void ResolveZoneCollider()
{
_zoneCollider ??= Components.Get<Collider>();
if ( _zoneCollider is not null && _zoneCollider.IsValid() )
return;
_zoneCollider = Components.GetAll<Collider>().FirstOrDefault();
if ( _zoneCollider is null || !_zoneCollider.IsValid() )
Log.Warning( $"[TeleportTrigger:{GameObject.Name}] needs an existing Collider on the same object." );
}
}