VaultGate.cs
using Sandbox;
using System;

// A gate that opens once, on cue, by moving and/or rotating itself from
// where it was placed to an "open" pose over a few seconds.
//
// Put this on the gate GameObject (the one carrying its model and
// collider) and assign it to MissionTimer.VaultGate. The timer calls Open()
// when the robbery finishes.
//
// The open pose is RELATIVE to how the gate sits in the editor, so you
// only describe the movement, not a world position:
//   - a portcullis sliding up:   OpenOffset = 0,0,110   OpenRotation = 0,0,0
//   - a gate swinging on a hinge: OpenOffset = 0,0,0     OpenRotation = 0,90,0
//     (a swing pivots on the object's ORIGIN, so for a hinge the origin
//     has to be on the hinge edge - parent the model under an empty
//     GameObject placed on the hinge and put this on that parent)
// OpenOffset is in the gate's own local axes, so "up" stays up however the
// gate is rotated in the level.
//
// Every machine runs Open() itself - MissionTimer runs independently on
// each client and they all reach the same moment - so the gate is never
// networked. It's pure presentation plus a collider moving out of the way.
//
// COLLIDER NOTE. For the collider to move smoothly with the gate, give the
// gate a Rigidbody with MotionEnabled off (that makes it "keyframed": moved
// by code, pushes players, not knocked around by physics). A plain static
// collider will still end up in the right place, but the physics engine
// treats a moving static as a teleport every frame.
public sealed class VaultGate : Component
{
	// How far the gate moves when open, in its own local axes.
	[Property]
	public Vector3 OpenOffset { get; set; } = Vector3.Up * 110f;

	// How much the gate turns when open (pitch, yaw, roll).
	[Property]
	public Angles OpenRotation { get; set; } = Angles.Zero;

	[Property]
	public float OpenDuration { get; set; } = 3f;

	public bool IsOpen { get; private set; }

	Vector3 _closedPosition;
	Rotation _closedRotation;

	// 0 = closed, 1 = fully open.
	float _progress;

	protected override void OnStart()
	{
		// Remembered once, from where the level designer put it. Local
		// rather than world so the gate still works if its parent (the
		// bank) gets moved around in the editor.
		_closedPosition = LocalPosition;
		_closedRotation = LocalRotation;
	}

	public void Open()
	{
		if ( IsOpen )
			return;

		IsOpen = true;
		Log.Info( $"VaultGate '{GameObject.Name}' opening." );
	}

	protected override void OnUpdate()
	{
		if ( !IsOpen || _progress >= 1f )
			return;

		_progress = MathF.Min( _progress + Time.Delta / MathF.Max( OpenDuration, 0.01f ), 1f );

		// Eased so it starts heavy and settles, rather than moving at a
		// constant, robotic speed. Standard smoothstep.
		var t = _progress * _progress * (3f - 2f * _progress);

		// The offset is in the gate's own frame, so rotate it by the
		// closed rotation before adding it to the closed position.
		LocalPosition = _closedPosition + _closedRotation * (OpenOffset * t);
		LocalRotation = _closedRotation * Rotation.From( OpenRotation * t );
	}
}