The staff physgun and noclip tool for the Horizon RP gamemode. The client aims and grabs props, players, or city objects, then sends grab, move, release, unfreeze, and remove requests to the host through AdminActions, which rechecks staff rights. Also drives the staff noclip flight mode and stashes the player's weapon while the tool is out.
namespace HorizonRP;
/// <summary>
/// Le physgun du staff, à la façon de Garry's Mod : X l'active, clic gauche
/// maintenu saisit ce qui est sous le viseur (un joueur, un objet de la ville,
/// un objet au sol, un véhicule vide), la molette rapproche ou éloigne, E maintenu
/// et la souris le font tourner (la vue ne bouge plus ; Maj : par crans de 15°), R
/// remet l'objet droit. Relâcher le clic le pose ; clic droit pendant la prise
/// le fige sur place (un joueur reste gelé). La touche « Lâcher » (Drop) supprime
/// le prop tenu ou visé (Julien, 30/10/2026) : il est ramassé comme par le menu P ;
/// et depuis le 31/10/2026 l'objet de la ville visé (posé par Construire : retiré de
/// la base, comme par l'onglet Objets posés).
///
/// Tourne sur la machine du staff, qui ne fait que viser : chaque position part
/// à l'hôte (AdminActions.Physgun*), qui revérifie que l'on est staff et
/// déplace lui-même l'objet, ou impose sa position au joueur tenu.
///
/// Le même composant tient le noclip du staff (touche N) : on vole à travers les murs,
/// ZQSD dans la direction du regard, saut pour monter, accroupi pour descendre,
/// course pour aller vite. Le personnage est simulé par son propriétaire, donc
/// la position se propage d'elle-même aux autres.
/// </summary>
[Title( "Horizon RP – Physgun du staff" )]
[Icon( "pan_tool" )]
public sealed class StaffTool : Component
{
/// <summary>Portée de l'outil (unités s&box, ~50 m).</summary>
public const float MaxDistance = 2000f;
const float MinDistance = 50f;
/// <summary>Positions envoyées à l'hôte par seconde pendant une prise.</summary>
const float SendRate = 20f;
public static bool Equipped { get; private set; }
/// <summary>Ce qui est tenu, pour la barre du HUD (null sinon).</summary>
public static string HeldLabel { get; private set; }
public static float HeldDistance { get; private set; }
/// <summary>Dernier refus (décor de la carte, véhicule occupé…), affiché un instant.</summary>
public static string Hint { get; private set; }
static RealTimeSince sinceHint;
/// <summary>Demandé depuis le menu d'administration (bouton), traité à la prochaine image.</summary>
static bool toggleRequested;
public static void Toggle() => toggleRequested = true;
/// <summary>Le noclip, depuis le menu d'administration.</summary>
static bool noclipRequested;
public static void ToggleNoclip() => noclipRequested = true;
/// <summary>Noclip du staff local en cours.</summary>
public static bool Noclip { get; private set; }
const float NoclipSpeed = 500f;
const float NoclipFastSpeed = 1600f;
GameObject held;
float distance;
Vector3 offset;
Rotation relativeRotation;
RealTimeSince sinceSend;
/// <summary>Degrés de rotation par point de souris, E maintenu (et le cran, Maj maintenu).</summary>
const float RotateSpeed = 0.35f;
const float RotateStep = 15f;
float pendingYaw, pendingPitch;
/// <summary>La vue du joueur coupée par nous (pendant une rotation) : à rendre au lâcher.</summary>
bool lookLocked;
protected override void OnUpdate()
{
var me = RoleplayPlayer.Local;
if ( me is null || !me.IsStaff )
{
if ( Equipped ) SetActive( false );
if ( Noclip ) SetNoclip( me, false );
return;
}
if ( Input.Pressed( "Noclip" ) && !UiLock.IsLocked || noclipRequested )
{
noclipRequested = false;
SetNoclip( me, !Noclip );
}
if ( Noclip )
Fly( me );
if ( Input.Pressed( "StaffTool" ) || toggleRequested )
{
toggleRequested = false;
SetActive( !Equipped );
}
// Une arme sortie alors que le marteau est en main (sa case de l'inventaire) : le marteau
// se range, les deux ne se superposent pas (Julien, 30/10/2026). Après un délai : à la
// prise du marteau, l'arme qu'il range met un instant à quitter la main.
if ( Equipped && sinceEquipped > 1.5f && WeaponHolder.Local?.ActiveSerial is { Length: > 0 } )
{
stashedWeapon = null;
SetActive( false );
}
if ( Hint is not null && sinceHint > 3 )
Hint = null;
if ( !Equipped || UiLock.IsLocked )
{
if ( held.IsValid() ) Drop( false );
return;
}
var camera = Scene.Camera;
if ( camera is null )
return;
if ( held is null )
TryGrab( me, camera );
else
UpdateHold( camera );
}
protected override void OnDisabled()
{
SetActive( false );
if ( Noclip ) SetNoclip( RoleplayPlayer.Local, false );
}
// Noclip
void SetNoclip( RoleplayPlayer me, bool on )
{
Noclip = on;
// Invisible pour les autres joueurs le temps du noclip (Julien).
if ( me.IsValid() )
me.StaffInvisible = on;
var controller = me?.GetComponent<PlayerController>();
if ( !controller.IsValid() )
return;
// Corps figé (plus de gravité ni de collisions qui repoussent), commandes
// de marche coupées : c'est Fly qui déplace.
if ( controller.Body.IsValid() )
{
controller.Body.MotionEnabled = !on;
controller.Body.Velocity = Vector3.Zero;
}
controller.UseInputControls = !on && UiLock.CanMove && !me.Restrained;
Hint = on ? "Noclip activé (N pour le couper)." : "Noclip coupé.";
sinceHint = 0;
}
void Fly( RoleplayPlayer me )
{
var camera = Scene.Camera;
if ( camera is null || UiLock.IsLocked )
return;
var move = Input.AnalogMove;
var direction = camera.WorldRotation.Forward * move.x + camera.WorldRotation.Left * move.y;
if ( Input.Down( "Jump" ) ) direction += Vector3.Up;
if ( Input.Down( "Duck" ) ) direction += Vector3.Down;
if ( direction.IsNearlyZero() )
return;
var speed = Input.Down( "Run" ) ? NoclipFastSpeed : NoclipSpeed;
me.WorldPosition += direction.Normal * speed * Time.Delta;
}
/// <summary>L'arme rangée pour prendre le marteau (son numéro), ressortie quand on le pose.</summary>
static string stashedWeapon;
static RealTimeSince sinceEquipped;
void SetActive( bool active )
{
if ( !active && held.IsValid() )
Drop( false );
Equipped = active;
HeldLabel = null;
if ( active )
sinceEquipped = 0;
// Le marteau se voit en main (VehicleKeySystem). Il passe devant la clé ou les billets
// tenus sans les remplacer : rangé, ils reviennent d'eux-mêmes. Une arme sortie est rangée
// le temps du marteau, puis ressortie (Julien, 29/10/2026).
var me = RoleplayPlayer.Local;
if ( me is null )
return;
me.StaffHammer = active;
if ( active && WeaponHolder.Local?.ActiveSerial is { Length: > 0 } serial )
{
stashedWeapon = serial;
UseWeapon( me, serial );
}
else if ( !active && stashedWeapon is { } back )
{
stashedWeapon = null;
if ( WeaponHolder.Local?.ActiveSerial is not { Length: > 0 } )
UseWeapon( me, back );
}
}
/// <summary>Sort ou range l'arme de ce numéro, comme un clic sur sa case.</summary>
static void UseWeapon( RoleplayPlayer me, string serial )
{
var slot = me.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial );
if ( slot is not null )
InventoryActions.RequestUse( slot.Slot );
}
void TryGrab( RoleplayPlayer me, CameraComponent camera )
{
var unfreeze = Input.Pressed( "attack2" );
var remove = Input.Pressed( "Drop" );
if ( !Input.Pressed( "attack1" ) && !unfreeze && !remove )
return;
var start = camera.WorldPosition;
var result = Scene.Trace.Ray( start, start + camera.WorldRotation.Forward * MaxDistance )
.IgnoreGameObjectHierarchy( me.GameObject )
.Run();
if ( !result.Hit || !result.GameObject.IsValid() )
return;
// La touche « Lâcher » sur un prop visé : il est supprimé.
if ( remove )
{
Remove( result.GameObject.Root );
return;
}
// Clic droit sur un objet figé, sans rien tenir : il retrouve sa physique.
if ( unfreeze )
{
var target = result.GameObject.Root;
Hint = target.Tags.Has( AdminActions.FrozenTag ) ? "Dégelé." : "Cet objet n'est pas figé.";
sinceHint = 0;
if ( target.Tags.Has( AdminActions.FrozenTag ) )
AdminActions.PhysgunUnfreeze( target.Id );
return;
}
// Même contrôle que l'hôte, pour refuser tout de suite (il revérifie).
var refusal = AdminActions.CanGrab( result.GameObject, me );
if ( refusal is not null )
{
Hint = refusal;
sinceHint = 0;
return;
}
var player = result.GameObject.GetComponentInParent<RoleplayPlayer>();
held = player.IsValid() ? player.GameObject : result.GameObject.Root;
distance = Math.Clamp( result.Distance, MinDistance, MaxDistance );
offset = held.WorldPosition - result.EndPosition;
relativeRotation = Rotation.FromYaw( camera.WorldRotation.Yaw() ).Inverse * held.WorldRotation;
HeldLabel = player.IsValid() ? player.RpName : NameOf( held );
sinceSend = 0;
AdminActions.PhysgunGrab( held.Id );
}
void UpdateHold( CameraComponent camera )
{
if ( !held.IsValid() )
{
held = null;
HeldLabel = null;
return;
}
distance = Math.Clamp( distance + Input.MouseWheel.y * 25f, MinDistance, MaxDistance );
HeldDistance = distance;
if ( Input.Pressed( "reload" ) )
relativeRotation = Rotation.Identity;
// E maintenu : la souris tourne l'objet autour de la verticale (gauche, droite) et de l'axe
// de la vue (haut, bas), comme dans Garry's Mod ; la vue reste immobile pendant ce temps
// (Julien, 31/10/2026 : on ne pouvait pas pivoter un prop). Maj : par crans de 15°.
var rotating = Input.Down( "use" );
LockLook( rotating );
if ( rotating )
Rotate( camera );
var position = camera.WorldPosition + camera.WorldRotation.Forward * distance + offset;
var rotation = Rotation.FromYaw( camera.WorldRotation.Yaw() ) * relativeRotation;
if ( sinceSend >= 1f / SendRate )
{
sinceSend = 0;
AdminActions.PhysgunMove( position, rotation );
}
if ( Input.Pressed( "Drop" ) )
{
var target = held;
if ( target.GetComponent<PlacedProp>().IsValid() || target.GetComponent<CityObject>().IsValid() )
Drop( false );
Remove( target );
}
else if ( Input.Pressed( "attack2" ) )
Drop( true );
else if ( !Input.Down( "attack1" ) )
Drop( false );
}
void Rotate( CameraComponent camera )
{
var mouse = Input.MouseDelta;
pendingYaw += mouse.x * RotateSpeed;
pendingPitch += mouse.y * RotateSpeed;
float yaw, pitch;
if ( Input.Down( "Run" ) )
{
yaw = MathF.Truncate( pendingYaw / RotateStep ) * RotateStep;
pitch = MathF.Truncate( pendingPitch / RotateStep ) * RotateStep;
}
else
{
yaw = pendingYaw;
pitch = pendingPitch;
}
pendingYaw -= yaw;
pendingPitch -= pitch;
if ( yaw == 0 && pitch == 0 )
return;
var frame = Rotation.FromYaw( camera.WorldRotation.Yaw() );
var world = frame * relativeRotation;
world = Rotation.FromAxis( Vector3.Up, -yaw ) * Rotation.FromAxis( camera.WorldRotation.Right, pitch ) * world;
relativeRotation = frame.Inverse * world;
}
/// <summary>Coupe (ou rend) la vue du joueur local pendant une rotation.</summary>
void LockLook( bool locked )
{
if ( locked == lookLocked )
return;
lookLocked = locked;
pendingYaw = pendingPitch = 0;
if ( RoleplayPlayer.Local?.GetComponent<PlayerController>() is { } controller )
controller.UseLookControls = !locked;
}
/// <summary>
/// Supprime un prop posé : l'hôte le ramasse (PropSystem.RequestRemove, qui revérifie le staff) ;
/// venu des poches de son propriétaire, il y retourne. Un objet de la ville (posé par Construire)
/// est retiré de la base par l'hôte (AdminActions.RequestRemove, qui revérifie le staff). Le reste
/// (véhicules, décor de la carte, joueurs) ne se supprime pas ici.
/// </summary>
void Remove( GameObject target )
{
if ( target?.GetComponent<CityObject>() is { } city && city.PlacementId > 0 )
{
AdminActions.RequestRemove( city.PlacementId );
Hint = $"{NameOf( target )} supprimé (objet n°{city.PlacementId}).";
sinceHint = 0;
return;
}
if ( target?.GetComponent<PlacedProp>() is not { } prop )
{
Hint = "Seuls les props et les objets posés se suppriment au physgun.";
sinceHint = 0;
return;
}
PropSystem.Current?.RequestRemove( prop.GameObject.Id );
Hint = $"{prop.Label} supprimé.";
sinceHint = 0;
}
void Drop( bool freeze )
{
LockLook( false );
AdminActions.PhysgunRelease( freeze );
held = null;
HeldLabel = null;
if ( freeze )
{
Hint = "Figé sur place.";
sinceHint = 0;
}
}
static string NameOf( GameObject go )
{
if ( go.GetComponent<CityObject>() is { } city )
return string.IsNullOrWhiteSpace( city.Label ) ? city.KindInfo.Name : city.Label;
if ( go.GetComponent<Vehicle>().IsValid() )
return "Véhicule";
if ( go.GetComponent<DroppedItem>().IsValid() )
return "Objet au sol";
return go.Name;
}
}