World/MapDoor.cs

MapDoor is a world door component for the Horizon RP map. It reads its open/locked state from HorizonGame and animates the panel by rotating or sliding toward that state, plays open/close/lock sounds locally, lets players press E to request open/close, and lets staff hold G to request a lock toggled via RequestMapDoorLock. It also computes which side to open away from a player and traces to avoid opening into geometry.

namespace HorizonRP;

/// <summary>
/// Une porte de Truenorth (portes-modèles prop_door_rotating, et portes en blocs func_door /
/// func_door_rotating) : la touche E l'ouvre ou la ferme. Pas encore de propriétaire (la gestion
/// des portes et de l'immobilier viendra), mais le staff la verrouille pour de bon (31/10/2026) :
/// G maintenu <see cref="LockHold"/> secondes, enregistré en base (hz_map_locks), par
/// <see cref="Key"/>, la position du battant fermé. La même barre servira aux clés des joueurs.
///
/// Chaque machine construit la map, donc ses portes ; l'état ouvert ou fermé et le verrou, eux,
/// viennent de l'hôte (<see cref="HorizonGame.MapDoors"/>, <see cref="HorizonGame.LockedMapDoors"/>,
/// par numéro de porte), et chaque machine anime la porte vers cet état. Une porte pivote autour
/// d'un axe de son repère, ou glisse. Chaque machine joue aussi les sons là où est la porte, quand
/// son état change : tout le monde les entend, pas seulement celui qui l'ouvre.
/// </summary>
[Title( "Horizon RP – Porte de la map" )]
[Icon( "door_front" )]
public sealed class MapDoor : Component, Component.IPressable
{
	/// <summary>Le numéro de la porte, le même sur toutes les machines (ordre de construction).</summary>
	public int Index { get; set; }

	/// <summary>Pivot : l'axe (repère de la porte) et l'angle d'ouverture, en degrés.</summary>
	public Vector3 Axis { get; set; } = Vector3.Up;
	public float Degrees { get; set; } = 90f;

	/// <summary>Glissement (repère du parent) : null pour une porte qui pivote.</summary>
	public Vector3? Slide { get; set; }

	/// <summary>0 : s'ouvre des deux côtés (loin de celui qui l'ouvre) ; 1 : vers l'avant seulement ; 2 : vers l'arrière.</summary>
	public int OpenDirection { get; set; }

	/// <summary>Le centre du battant, repère de la porte (sert à l'ouvrir loin du joueur).</summary>
	public Vector3 PanelCenter { get; set; }

	/// <summary>Durée d'une ouverture complète, en secondes.</summary>
	public const float Duration = 0.9f;

	/// <summary>Portée de la touche E (pouces).</summary>
	public const float Reach = 140f;

	/// <summary>Le temps de G maintenu pour verrouiller ou déverrouiller : un peu moins que pour monter en voiture (Vehicle.EnterHold).</summary>
	public const float LockHold = 0.4f;

	public const string OpenSound = "sounds/portes/door_open.mp3";
	public const string CloseSound = "sounds/portes/door_close.mp3";
	public const string LockedSound = "sounds/portes/door_closed.mp3";
	public const string LockSound = "sounds/portes/door_lock.mp3";
	public const string UnlockSound = "sounds/portes/door_unlock.mp3";

	/// <summary>
	/// La porte dans la base : la position de son battant fermé, arrondie au pouce (« x,y,z »), la
	/// même sur toutes les machines et d'un démarrage à l'autre, même si l'ordre de construction (son
	/// numéro) change avec la carte.
	/// </summary>
	public string Key
	{
		get
		{
			// Calculée à la demande, depuis la position fermée : une porte déjà construite au
			// rechargement du code n'en avait pas.
			if ( key is null && hasClosed && GameObject.Parent is { } parent )
			{
				var p = parent.WorldTransform.PointToWorld( closed.Position );
				key = $"{p.x.FloorToInt()},{p.y.FloorToInt()},{p.z.FloorToInt()}";
			}
			return key;
		}
	}
	string key;

	Transform closed;
	bool hasClosed;
	float shown;
	RealTimeSince sinceStart;
	int heardTarget;
	bool heardLocked;

	protected override void OnStart()
	{
		closed = LocalTransform;
		hasClosed = true;
		sinceStart = 0;
	}

	/// <summary>L'état voulu : 0 fermée, 1 ou -1 ouverte d'un côté ou de l'autre.</summary>
	int Target => HorizonGame.Current is { } game && game.MapDoors.TryGetValue( Index, out var state ) ? state : 0;

	/// <summary>Ouverte (d'un côté ou de l'autre), d'après l'état reçu de l'hôte.</summary>
	public bool IsOpen => Target != 0;

	/// <summary>Verrouillée par le staff : elle ne s'ouvre plus.</summary>
	public bool Locked => HorizonGame.Current?.LockedMapDoors.Contains( Index ) == true;

	/// <summary>Où jouer ses sons : le milieu du battant.</summary>
	public Vector3 SoundPosition => WorldTransform.PointToWorld( PanelCenter );

	protected override void OnUpdate()
	{
		if ( !hasClosed )
			return;
		UpdateSounds();
		UpdateLockHold();
		var target = (float)Target;
		var rattle = RattleAngle();
		if ( shown == target && rattle == 0 && !rattling )
			return;
		rattling = rattle != 0;
		var step = Time.Delta / Duration * 2f;
		shown = MathF.Abs( target - shown ) <= step ? target : shown + MathF.Sign( target - shown ) * step;
		var k = Ease( MathF.Abs( shown ) ) * MathF.Sign( shown );
		if ( Slide is { } slide )
			LocalPosition = closed.Position + slide * (MathF.Abs( k ) + rattle * 0.004f);
		else
			LocalRotation = closed.Rotation * Rotation.FromAxis( Axis, Degrees * k + rattle );
	}

	// Une porte verrouillée qu'on essaie d'ouvrir tremble un peu, chez tous (HorizonGame.PlayMapDoorDenied).

	const float RattleSeconds = 0.45f;
	const float RattleDegrees = 1.6f;
	float rattleAt = -100;
	bool rattling;

	public void Rattle() => rattleAt = RealTime.Now;

	/// <summary>Le battement en cours (degrés) : vif, amorti, puis plus rien.</summary>
	float RattleAngle()
	{
		var t = RealTime.Now - rattleAt;
		if ( t < 0 || t > RattleSeconds )
			return 0;
		return MathF.Sin( t * 55f ) * RattleDegrees * (1 - t / RattleSeconds);
	}

	static float Ease( float t ) => t * t * (3 - 2 * t);

	/// <summary>
	/// Hôte : le côté vers lequel ouvrir, loin de <paramref name="from"/> — ou celui que la map
	/// impose. Dans tous les cas, jamais dans un mur : si le côté choisi heurte le décor et que
	/// l'autre est libre, la porte part de l'autre côté (Julien, 30/10/2026 : « certaines portes
	/// s'ouvrent à l'envers » ; le sens de la map d'origine ne se traduit pas toujours).
	/// </summary>
	public int SideAwayFrom( Vector3 from )
	{
		if ( Slide is not null )
			return 1;
		int side;
		if ( OpenDirection == 1 )
			side = 1;
		else if ( OpenDirection == 2 )
			side = -1;
		else
		{
			// Le battant s'écarte du joueur : signe vérifié en jeu (le premier calcul l'ouvrait vers lui).
			var local = (hasClosed ? closed : LocalTransform).PointToLocal( GameObject.Parent.WorldTransform.PointToLocal( from ) );
			var sweep = Vector3.Cross( Axis, PanelCenter );
			side = Vector3.Dot( local, sweep ) > 0 ? 1 : -1;
		}
		return Blocked( side ) && !Blocked( -side ) ? -side : side;
	}

	readonly Dictionary<int, bool> blocked = new();

	/// <summary>
	/// Hôte : ouverte de ce côté, la porte heurte-t-elle le décor ? Des rayons du pivot vers le
	/// bord libre du battant, à mi-course et en fin de course, à la hauteur de son centre (depuis
	/// un cinquième du rayon : le pivot est dans le chambranle). Calculé une fois par côté.
	/// </summary>
	bool Blocked( int side )
	{
		if ( blocked.TryGetValue( side, out var known ) )
			return known;
		var rest = hasClosed ? closed : LocalTransform;
		var parent = GameObject.Parent.WorldTransform;
		var along = Axis * Vector3.Dot( PanelCenter, Axis );
		var radial = PanelCenter - along;
		var hit = false;
		foreach ( var part in new[] { 0.5f, 1f } )
		{
			var turned = new Transform( rest.Position, rest.Rotation * Rotation.FromAxis( Axis, Degrees * part * side ) );
			var start = parent.PointToWorld( turned.PointToWorld( along + radial * 0.4f ) );
			var end = parent.PointToWorld( turned.PointToWorld( along + radial * 1.9f ) );
			if ( Scene.Trace.Ray( start, end ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "player", "trigger" ).Run().Hit )
				hit = true;
		}
		blocked[side] = hit;
		return hit;
	}

	/// <summary>
	/// Les sons, quand l'état reçu change : ouverte, fermée, verrouillée, déverrouillée. Les premières
	/// secondes ne jouent rien : un joueur qui arrive reçoit l'état de toutes les portes d'un coup.
	/// </summary>
	void UpdateSounds()
	{
		var target = Target;
		var locked = Locked;
		if ( sinceStart < 3f )
		{
			heardTarget = target;
			heardLocked = locked;
			return;
		}
		if ( target != heardTarget )
		{
			if ( (heardTarget == 0) != (target == 0) )
				Sounds.PlayFileAt( target != 0 ? OpenSound : CloseSound, SoundPosition, 0.8f );
			heardTarget = target;
		}
		if ( locked != heardLocked )
		{
			Sounds.PlayFileAt( locked ? LockSound : UnlockSound, SoundPosition, 0.8f );
			heardLocked = locked;
		}
	}

	// G maintenu sur la porte visée : verrouiller ou déverrouiller (le staff, pour l'instant).

	/// <summary>Chez soi : la porte dont on remplit la barre, et où en est la barre (0 à 1).</summary>
	public static MapDoor LockTarget { get; private set; }
	public static float LockProgress { get; private set; }
	static RealTimeSince lockHeld;
	static bool holding, lockDone;

	/// <summary>Chez soi : peut-on verrouiller cette porte (le staff ; plus tard, qui en a la clé) ?</summary>
	public static bool CanLock( MapDoor door ) => door.IsValid() && RoleplayPlayer.Local?.IsStaff == true;

	void UpdateLockHold()
	{
		var aimed = RoleplayPlayer.Local?.GetComponent<PlayerController>()?.Hovered == this && CanLock( this ) && !UiLock.IsLocked;
		if ( !aimed )
		{
			if ( LockTarget == this )
			{
				LockTarget = null;
				LockProgress = 0;
			}
			return;
		}
		// Une autre porte visée : on recommence (la barre ne passe pas d'une porte à l'autre).
		if ( LockTarget != this )
		{
			LockTarget = this;
			holding = lockDone = false;
			LockProgress = 0;
		}
		if ( !Input.Down( "Search" ) )
		{
			holding = lockDone = false;
			LockProgress = 0;
			return;
		}
		if ( !holding )
		{
			holding = true;
			lockHeld = 0;
		}
		// Une fois faite, il faut relâcher G avant de recommencer.
		if ( lockDone )
			return;
		LockProgress = Math.Clamp( lockHeld / LockHold, 0f, 1f );
		if ( LockProgress >= 1f )
		{
			lockDone = true;
			LockProgress = 0;
			HorizonGame.Current?.RequestMapDoorLock( Index, !Locked );
		}
	}

	public bool CanPress( IPressable.Event e ) =>
		RoleplayPlayer.Local is { } me && me.WorldPosition.Distance( WorldTransform.PointToWorld( PanelCenter ) ) <= Reach + 60;

	public bool Press( IPressable.Event e )
	{
		HorizonGame.Current?.RequestMapDoor( Index );
		return true;
	}

	public IPressable.Tooltip? GetTooltip( IPressable.Event e ) =>
		new IPressable.Tooltip( Target != 0 ? "Fermer" : Locked ? "Verrouillée" : "Ouvrir", Locked ? "lock" : "door_front", "Porte" );
}