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" );
}