]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Hitscans are now entities (#38035)
authorbeck-thompson <107373427+beck-thompson@users.noreply.github.com>
Sat, 18 Oct 2025 05:42:08 +0000 (22:42 -0700)
committerGitHub <noreply@github.com>
Sat, 18 Oct 2025 05:42:08 +0000 (05:42 +0000)
* Hitscans are now entities

* Cleanup

* Cleanup

* Silly mistakes but stop sign testing helps :)

* Address most of the review

* Reviews

* perry :(

* Final reviews

* Add comments

* Split event up

* better comment

* cleanup

21 files changed:
Content.Client/Weapons/Ranged/Systems/GunSystem.cs
Content.Server/Weapons/Ranged/Systems/GunSystem.cs
Content.Shared/Weapons/Hitscan/Components/HitscanAmmoComponent.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Components/HitscanBasicDamageComponent.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Components/HitscanBasicEffectsComponent.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Components/HitscanBasicRaycastComponent.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Components/HitscanBasicVisualsComponent.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Components/HitscanReflectComponent.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Components/HitscanStaminaDamageComponent.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Events/HitscanEvents.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Systems/HitscanBasicDamageSystem.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Systems/HitscanBasicEffectsSystem.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Systems/HitscanBasicRaycastSystem.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Systems/HitscanReflectSystem.cs [new file with mode: 0644]
Content.Shared/Weapons/Hitscan/Systems/HitscanStunSystem.cs [new file with mode: 0644]
Content.Shared/Weapons/Ranged/Components/HitscanBatteryAmmoProviderComponent.cs
Content.Shared/Weapons/Ranged/HitscanPrototype.cs [deleted file]
Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.Battery.cs
Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs
Content.Shared/Weapons/Reflect/ReflectComponent.cs
Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/hitscan.yml

index c27e81b5c799df8848cbafcfd73e7d4c4c66d767..adef067b60d5a1bc4184ae7f7e6011d5ca0ce425 100644 (file)
@@ -5,6 +5,8 @@ using Content.Client.Items;
 using Content.Client.Weapons.Ranged.Components;
 using Content.Shared.Camera;
 using Content.Shared.CombatMode;
+using Content.Shared.Damage;
+using Content.Shared.Weapons.Hitscan.Components;
 using Content.Shared.Weapons.Ranged;
 using Content.Shared.Weapons.Ranged.Components;
 using Content.Shared.Weapons.Ranged.Events;
@@ -16,6 +18,7 @@ using Robust.Client.Input;
 using Robust.Client.Player;
 using Robust.Client.State;
 using Robust.Shared.Animations;
+using Robust.Shared.Audio;
 using Robust.Shared.Input;
 using Robust.Shared.Map;
 using Robust.Shared.Map.Components;
@@ -234,6 +237,7 @@ public sealed partial class GunSystem : SharedGunSystem
                 continue;
             }
 
+            // TODO: Clean this up in a gun refactor at some point - too much copy pasting
             switch (shootable)
             {
                 case CartridgeAmmoComponent cartridge:
@@ -266,7 +270,7 @@ public sealed partial class GunSystem : SharedGunSystem
                     else
                         RemoveShootable(ent.Value);
                     break;
-                case HitscanPrototype:
+                case HitscanAmmoComponent:
                     Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
                     Recoil(user, direction, gun.CameraRecoilScalarModified);
                     break;
@@ -404,4 +408,7 @@ public sealed partial class GunSystem : SharedGunSystem
         _animPlayer.Stop(gunUid, uidPlayer, "muzzle-flash-light");
         _animPlayer.Play((gunUid, uidPlayer), animTwo, "muzzle-flash-light");
     }
+
+    // TODO: Move RangedDamageSoundComponent to shared so this can be predicted.
+    public override void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound) {}
 }
index e04fa9b64c4bdd44866b1ef39c0df9abb392dd84..dcfa3e6654ef1698e3e57f6ac7814a29ebed8a3a 100644 (file)
@@ -1,27 +1,22 @@
-using System.Linq;
 using System.Numerics;
 using Content.Server.Cargo.Systems;
 using Content.Server.Weapons.Ranged.Components;
 using Content.Shared.Cargo;
 using Content.Shared.Damage;
 using Content.Shared.Damage.Systems;
-using Content.Shared.Database;
-using Content.Shared.Effects;
 using Content.Shared.Projectiles;
 using Content.Shared.Weapons.Melee;
 using Content.Shared.Weapons.Ranged;
 using Content.Shared.Weapons.Ranged.Components;
 using Content.Shared.Weapons.Ranged.Events;
 using Content.Shared.Weapons.Ranged.Systems;
-using Content.Shared.Weapons.Reflect;
-using Content.Shared.Damage.Components;
+using Content.Shared.Weapons.Hitscan.Components;
+using Content.Shared.Weapons.Hitscan.Events;
 using Robust.Shared.Audio;
 using Robust.Shared.Map;
-using Robust.Shared.Physics;
 using Robust.Shared.Player;
 using Robust.Shared.Prototypes;
 using Robust.Shared.Utility;
-using Robust.Shared.Containers;
 
 namespace Content.Server.Weapons.Ranged.Systems;
 
@@ -29,9 +24,6 @@ public sealed partial class GunSystem : SharedGunSystem
 {
     [Dependency] private readonly DamageExamineSystem _damageExamine = default!;
     [Dependency] private readonly PricingSystem _pricing = default!;
-    [Dependency] private readonly SharedColorFlashEffectSystem _color = default!;
-    [Dependency] private readonly SharedStaminaSystem _stamina = default!;
-    [Dependency] private readonly SharedContainerSystem _container = default!;
     [Dependency] private readonly SharedMapSystem _map = default!;
 
     private const float DamagePitchVariation = 0.05f;
@@ -103,6 +95,7 @@ public sealed partial class GunSystem : SharedGunSystem
                 continue;
             }
 
+            // TODO: Clean this up in a gun refactor at some point - too much copy pasting
             switch (shootable)
             {
                 // Cartridge shoots something else
@@ -141,107 +134,21 @@ public sealed partial class GunSystem : SharedGunSystem
                     CreateAndFireProjectiles(ent.Value, newAmmo);
 
                     break;
-                case HitscanPrototype hitscan:
-
-                    EntityUid? lastHit = null;
-
-                    var from = fromMap;
-                    // can't use map coords above because funny FireEffects
-                    var fromEffect = fromCoordinates;
-                    var dir = mapDirection.Normalized();
-
-                    //in the situation when user == null, means that the cannon fires on its own (via signals). And we need the gun to not fire by itself in this case
-                    var lastUser = user ?? gunUid;
-
-                    if (hitscan.Reflective != ReflectType.None)
-                    {
-                        for (var reflectAttempt = 0; reflectAttempt < 3; reflectAttempt++)
-                        {
-                            var ray = new CollisionRay(from.Position, dir, hitscan.CollisionMask);
-                            var rayCastResults =
-                                Physics.IntersectRay(from.MapId, ray, hitscan.MaxLength, lastUser, false).ToList();
-                            if (!rayCastResults.Any())
-                                break;
-
-                            var result = rayCastResults[0];
-
-                            // Check if laser is shot from in a container
-                            if (!_container.IsEntityOrParentInContainer(lastUser))
-                            {
-                                // Checks if the laser should pass over unless targeted by its user
-                                foreach (var collide in rayCastResults)
-                                {
-                                    if (collide.HitEntity != gun.Target &&
-                                        CompOrNull<RequireProjectileTargetComponent>(collide.HitEntity)?.Active == true)
-                                    {
-                                        continue;
-                                    }
-
-                                    result = collide;
-                                    break;
-                                }
-                            }
-
-                            var hit = result.HitEntity;
-                            lastHit = hit;
-
-                            FireEffects(fromEffect, result.Distance, dir.Normalized().ToAngle(), hitscan, hit);
-
-                            var ev = new HitScanReflectAttemptEvent(user, gunUid, hitscan.Reflective, dir, false);
-                            RaiseLocalEvent(hit, ref ev);
-
-                            if (!ev.Reflected)
-                                break;
-
-                            fromEffect = Transform(hit).Coordinates;
-                            from = TransformSystem.ToMapCoordinates(fromEffect);
-                            dir = ev.Direction;
-                            lastUser = hit;
-                        }
-                    }
+                case HitscanAmmoComponent:
+                    if (ent == null)
+                        break;
 
-                    if (lastHit != null)
+                    var hitscanEv = new HitscanTraceEvent
                     {
-                        var hitEntity = lastHit.Value;
-                        if (hitscan.StaminaDamage > 0f)
-                            _stamina.TakeStaminaDamage(hitEntity, hitscan.StaminaDamage, source: user);
+                        FromCoordinates = fromCoordinates,
+                        ShotDirection = mapDirection.Normalized(),
+                        Gun = gunUid,
+                        Shooter = user,
+                        Target = gun.Target,
+                    };
+                    RaiseLocalEvent(ent.Value, ref hitscanEv);
 
-                        var dmg = hitscan.Damage;
-
-                        var hitName = ToPrettyString(hitEntity);
-                        if (dmg != null)
-                            dmg = Damageable.TryChangeDamage(hitEntity, dmg * Damageable.UniversalHitscanDamageModifier, origin: user);
-
-                        // check null again, as TryChangeDamage returns modified damage values
-                        if (dmg != null)
-                        {
-                            if (!Deleted(hitEntity))
-                            {
-                                if (dmg.AnyPositive())
-                                {
-                                    _color.RaiseEffect(Color.Red, new List<EntityUid>() { hitEntity }, Filter.Pvs(hitEntity, entityManager: EntityManager));
-                                }
-
-                                // TODO get fallback position for playing hit sound.
-                                PlayImpactSound(hitEntity, dmg, hitscan.Sound, hitscan.ForceSound);
-                            }
-
-                            if (user != null)
-                            {
-                                Logs.Add(LogType.HitScanHit,
-                                    $"{ToPrettyString(user.Value):user} hit {hitName:target} using hitscan and dealt {dmg.GetTotal():damage} damage");
-                            }
-                            else
-                            {
-                                Logs.Add(LogType.HitScanHit,
-                                    $"{hitName:target} hit by hitscan dealing {dmg.GetTotal():damage} damage");
-                            }
-                        }
-                    }
-                    else
-                    {
-                        FireEffects(fromEffect, hitscan.MaxLength, dir.ToAngle(), hitscan);
-                    }
+                    Del(ent);
 
                     Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
                     break;
@@ -353,7 +260,7 @@ public sealed partial class GunSystem : SharedGunSystem
         RaiseNetworkEvent(message, filter);
     }
 
-    public void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound)
+    public override void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound)
     {
         DebugTools.Assert(!Deleted(otherEntity), "Impact sound entity was deleted");
 
@@ -384,69 +291,4 @@ public sealed partial class GunSystem : SharedGunSystem
             Audio.PlayPvs(weaponSound, otherEntity);
         }
     }
-
-    // TODO: Pseudo RNG so the client can predict these.
-    #region Hitscan effects
-
-    private void FireEffects(EntityCoordinates fromCoordinates, float distance, Angle angle, HitscanPrototype hitscan, EntityUid? hitEntity = null)
-    {
-        // Lord
-        // Forgive me for the shitcode I am about to do
-        // Effects tempt me not
-        var sprites = new List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier sprite, float scale)>();
-        var fromXform = Transform(fromCoordinates.EntityId);
-
-        // We'll get the effects relative to the grid / map of the firer
-        // Look you could probably optimise this a bit with redundant transforms at this point.
-
-        var gridUid = fromXform.GridUid;
-        if (gridUid != fromCoordinates.EntityId && TryComp(gridUid, out TransformComponent? gridXform))
-        {
-            var (_, gridRot, gridInvMatrix) = TransformSystem.GetWorldPositionRotationInvMatrix(gridXform);
-            var map = TransformSystem.ToMapCoordinates(fromCoordinates);
-            fromCoordinates = new EntityCoordinates(gridUid.Value, Vector2.Transform(map.Position, gridInvMatrix));
-            angle -= gridRot;
-        }
-        else
-        {
-            angle -= TransformSystem.GetWorldRotation(fromXform);
-        }
-
-        if (distance >= 1f)
-        {
-            if (hitscan.MuzzleFlash != null)
-            {
-                var coords = fromCoordinates.Offset(angle.ToVec().Normalized() / 2);
-                var netCoords = GetNetCoordinates(coords);
-
-                sprites.Add((netCoords, angle, hitscan.MuzzleFlash, 1f));
-            }
-
-            if (hitscan.TravelFlash != null)
-            {
-                var coords = fromCoordinates.Offset(angle.ToVec() * (distance + 0.5f) / 2);
-                var netCoords = GetNetCoordinates(coords);
-
-                sprites.Add((netCoords, angle, hitscan.TravelFlash, distance - 1.5f));
-            }
-        }
-
-        if (hitscan.ImpactFlash != null)
-        {
-            var coords = fromCoordinates.Offset(angle.ToVec() * distance);
-            var netCoords = GetNetCoordinates(coords);
-
-            sprites.Add((netCoords, angle.FlipPositive(), hitscan.ImpactFlash, 1f));
-        }
-
-        if (sprites.Count > 0)
-        {
-            RaiseNetworkEvent(new HitscanEvent
-            {
-                Sprites = sprites,
-            }, Filter.Pvs(fromCoordinates, entityMan: EntityManager));
-        }
-    }
-
-    #endregion
 }
diff --git a/Content.Shared/Weapons/Hitscan/Components/HitscanAmmoComponent.cs b/Content.Shared/Weapons/Hitscan/Components/HitscanAmmoComponent.cs
new file mode 100644 (file)
index 0000000..3993d5a
--- /dev/null
@@ -0,0 +1,11 @@
+using Content.Shared.Weapons.Ranged;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Weapons.Hitscan.Components;
+
+/// <summary>
+/// This component is used to indicate an entity is shootable from a hitscan weapon.
+/// This is placed on the laser entity being shot, not the gun itself.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class HitscanAmmoComponent : Component, IShootable;
diff --git a/Content.Shared/Weapons/Hitscan/Components/HitscanBasicDamageComponent.cs b/Content.Shared/Weapons/Hitscan/Components/HitscanBasicDamageComponent.cs
new file mode 100644 (file)
index 0000000..dcbb192
--- /dev/null
@@ -0,0 +1,17 @@
+using Content.Shared.Damage;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Weapons.Hitscan.Components;
+
+/// <summary>
+/// Hitscan entities that have this component will do the damage specified to hit targets (Who didn't reflect it).
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class HitscanBasicDamageComponent : Component
+{
+    /// <summary>
+    /// How much damage the hitscan weapon will do when hitting a target.
+    /// </summary>
+    [DataField(required: true)]
+    public DamageSpecifier Damage;
+}
diff --git a/Content.Shared/Weapons/Hitscan/Components/HitscanBasicEffectsComponent.cs b/Content.Shared/Weapons/Hitscan/Components/HitscanBasicEffectsComponent.cs
new file mode 100644 (file)
index 0000000..05209f3
--- /dev/null
@@ -0,0 +1,29 @@
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Weapons.Hitscan.Components;
+
+/// <summary>
+/// System or basic "effects" like sounds and hit markers for hitscans.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class HitscanBasicEffectsComponent : Component
+{
+    /// <summary>
+    /// This will turn hit entities this color briefly.
+    /// </summary>
+    [DataField]
+    public Color? HitColor = Color.Red;
+
+    /// <summary>
+    /// Sound that plays upon the thing being hit.
+    /// </summary>
+    [DataField]
+    public SoundSpecifier? Sound;
+
+    /// <summary>
+    /// Force the hitscan sound to play rather than playing the entity's override sound (if it exists).
+    /// </summary>
+    [DataField]
+    public bool ForceSound;
+}
diff --git a/Content.Shared/Weapons/Hitscan/Components/HitscanBasicRaycastComponent.cs b/Content.Shared/Weapons/Hitscan/Components/HitscanBasicRaycastComponent.cs
new file mode 100644 (file)
index 0000000..e04ab0d
--- /dev/null
@@ -0,0 +1,23 @@
+using Content.Shared.Physics;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Weapons.Hitscan.Components;
+
+/// <summary>
+/// A basic raycast system that will shoot in a straight line when triggered.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class HitscanBasicRaycastComponent : Component
+{
+    /// <summary>
+    /// Maximum distance the raycast will travel before giving up. Reflections will reset the distance traveled
+    /// </summary>
+    [DataField]
+    public float MaxDistance = 20.0f;
+
+    /// <summary>
+    /// The collision mask the hitscan ray uses to collide with other objects. See the enum for more information
+    /// </summary>
+    [DataField]
+    public CollisionGroup CollisionMask = CollisionGroup.Opaque;
+}
diff --git a/Content.Shared/Weapons/Hitscan/Components/HitscanBasicVisualsComponent.cs b/Content.Shared/Weapons/Hitscan/Components/HitscanBasicVisualsComponent.cs
new file mode 100644 (file)
index 0000000..519425e
--- /dev/null
@@ -0,0 +1,29 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Weapons.Hitscan.Components;
+
+/// <summary>
+/// Provides basic visuals for hitscan weapons - works with <see cref="HitscanBasicRaycastComponent"/>
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class HitscanBasicVisualsComponent : Component
+{
+    /// <summary>
+    /// The muzzle flash from the hitscan weapon.
+    /// </summary>
+    [DataField]
+    public SpriteSpecifier? MuzzleFlash;
+
+    /// <summary>
+    /// The "travel" sprite, this gets repeated until it hits the target.
+    /// </summary>
+    [DataField]
+    public SpriteSpecifier? TravelFlash;
+
+    /// <summary>
+    /// The sprite that gets shown on the impact of the laser.
+    /// </summary>
+    [DataField]
+    public SpriteSpecifier? ImpactFlash;
+}
diff --git a/Content.Shared/Weapons/Hitscan/Components/HitscanReflectComponent.cs b/Content.Shared/Weapons/Hitscan/Components/HitscanReflectComponent.cs
new file mode 100644 (file)
index 0000000..a63f3ab
--- /dev/null
@@ -0,0 +1,29 @@
+using Content.Shared.Weapons.Reflect;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Weapons.Hitscan.Components;
+
+/// <summary>
+/// Hitscan entities with this component will get reflected by certain things (E.G energy swords).
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class HitscanReflectComponent : Component
+{
+    /// <summary>
+    /// The reflective type, will only reflect from entities that have a matching reflection type.
+    /// </summary>
+    [DataField]
+    public ReflectType ReflectiveType = ReflectType.Energy;
+
+    /// <summary>
+    /// The maximum number of reflections the laser will make. <see cref="CurrentReflections"/>
+    /// </summary>
+    [DataField]
+    public int MaxReflections = 3;
+
+    /// <summary>
+    /// Current number of times this hitscan entity was reflected. Will not be more than <see cref="MaxReflections"/>
+    /// </summary>
+    [DataField]
+    public int CurrentReflections;
+}
diff --git a/Content.Shared/Weapons/Hitscan/Components/HitscanStaminaDamageComponent.cs b/Content.Shared/Weapons/Hitscan/Components/HitscanStaminaDamageComponent.cs
new file mode 100644 (file)
index 0000000..35eb398
--- /dev/null
@@ -0,0 +1,16 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Weapons.Hitscan.Components;
+
+/// <summary>
+/// Hitscan entities that have this component will deal stamina damage to the target.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class HitscanStaminaDamageComponent : Component
+{
+    /// <summary>
+    /// How much stamania damage the hitscan weapon will do when hitting a target.
+    /// </summary>
+    [DataField]
+    public float StaminaDamage = 10.0f;
+}
diff --git a/Content.Shared/Weapons/Hitscan/Events/HitscanEvents.cs b/Content.Shared/Weapons/Hitscan/Events/HitscanEvents.cs
new file mode 100644 (file)
index 0000000..0ebc180
--- /dev/null
@@ -0,0 +1,110 @@
+using System.Numerics;
+using Content.Shared.Damage;
+using Robust.Shared.Map;
+
+namespace Content.Shared.Weapons.Hitscan.Events;
+
+/// <summary>
+/// Raised on the hitscan entity when "fired". This could be from reflections or from the gun. This is the catalyst that
+/// other systems will listen for to actually shoot the gun.
+/// </summary>
+[ByRefEvent]
+public record struct HitscanTraceEvent
+{
+    /// <summary>
+    /// Location the hitscan was fired from.
+    /// </summary>
+    public EntityCoordinates FromCoordinates;
+
+    /// <summary>
+    /// Direction that the ray was fired towards.
+    /// </summary>
+    public Vector2 ShotDirection;
+
+    /// <summary>
+    /// Gun that was fired - this will always be the original weapon even if reflected.
+    /// </summary>
+    public EntityUid Gun;
+
+    /// <summary>
+    /// Player who shot the gun, if null the gun was fired by itself.
+    /// </summary>
+    public EntityUid? Shooter;
+
+    /// <summary>
+    /// Target that was being aimed at (Not necessarily hit).
+    /// </summary>
+    public EntityUid? Target;
+}
+
+/// <summary>
+/// All data known data for when a hitscan is actually fired.
+/// </summary>
+public record struct HitscanRaycastFiredData
+{
+    /// <summary>
+    /// Direction that the ray was fired towards.
+    /// </summary>
+    public Vector2 ShotDirection;
+
+    /// <summary>
+    /// The entity that got hit, if null the raycast didn't hit anyone.
+    /// </summary>
+    public EntityUid? HitEntity;
+
+    /// <summary>
+    /// Gun that fired the raycast.
+    /// </summary>
+    public EntityUid Gun;
+
+    /// <summary>
+    /// Player who shot the gun, if null the gun was fired by itself.
+    /// </summary>
+    public EntityUid? Shooter;
+}
+
+/// <summary>
+/// Try to hit the targeted entity with a hitscan laser. Stuff like the reflection system should listen for this and
+/// cancel the event if the laser was reflected.
+/// </summary>
+[ByRefEvent]
+public struct AttemptHitscanRaycastFiredEvent
+{
+    /// <summary>
+    /// Data for the hitscan that was fired.
+    /// </summary>
+    public HitscanRaycastFiredData Data;
+
+    /// <summary>
+    /// Set to true the hitscan is cancelled (e.g. due to reflection).
+    /// Cancelled hitscans should not apply damage or trigger follow-up effects.
+    /// </summary>
+    public bool Cancelled;
+}
+
+/// <summary>
+/// Results of a hitscan raycast and will be raised on the raycast entity on itself. Stuff like the damage system should
+/// listen for this. At this point we KNOW the laser hit the entity.
+/// </summary>
+[ByRefEvent]
+public struct HitscanRaycastFiredEvent
+{
+    /// <summary>
+    /// Data for the hitscan that was fired.
+    /// </summary>
+    public HitscanRaycastFiredData Data;
+}
+
+[ByRefEvent]
+public record struct HitscanDamageDealtEvent
+{
+    /// <summary>
+    /// Target that was dealt damage.
+    /// </summary>
+    public EntityUid Target;
+
+    /// <summary>
+    /// The amount of damage that the target was dealt.
+    /// </summary>
+    public DamageSpecifier DamageDealt;
+}
diff --git a/Content.Shared/Weapons/Hitscan/Systems/HitscanBasicDamageSystem.cs b/Content.Shared/Weapons/Hitscan/Systems/HitscanBasicDamageSystem.cs
new file mode 100644 (file)
index 0000000..1306874
--- /dev/null
@@ -0,0 +1,38 @@
+using Content.Shared.Damage;
+using Content.Shared.Weapons.Hitscan.Components;
+using Content.Shared.Weapons.Hitscan.Events;
+
+namespace Content.Shared.Weapons.Hitscan.Systems;
+
+public sealed class HitscanBasicDamageSystem : EntitySystem
+{
+    [Dependency] private readonly DamageableSystem _damage = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<HitscanBasicDamageComponent, HitscanRaycastFiredEvent>(OnHitscanHit);
+    }
+
+    private void OnHitscanHit(Entity<HitscanBasicDamageComponent> ent, ref HitscanRaycastFiredEvent args)
+    {
+        if (args.Data.HitEntity == null)
+            return;
+
+        var dmg = ent.Comp.Damage * _damage.UniversalHitscanDamageModifier;
+
+        var damageDealt = _damage.TryChangeDamage(args.Data.HitEntity, dmg, origin: args.Data.Gun);
+
+        if (damageDealt == null)
+            return;
+
+        var damageEvent = new HitscanDamageDealtEvent
+        {
+            Target = args.Data.HitEntity.Value,
+            DamageDealt = damageDealt,
+        };
+
+        RaiseLocalEvent(ent, ref damageEvent);
+    }
+}
diff --git a/Content.Shared/Weapons/Hitscan/Systems/HitscanBasicEffectsSystem.cs b/Content.Shared/Weapons/Hitscan/Systems/HitscanBasicEffectsSystem.cs
new file mode 100644 (file)
index 0000000..4713734
--- /dev/null
@@ -0,0 +1,36 @@
+using Content.Shared.Damage;
+using Content.Shared.Effects;
+using Content.Shared.Weapons.Hitscan.Components;
+using Content.Shared.Weapons.Hitscan.Events;
+using Content.Shared.Weapons.Ranged.Systems;
+using Robust.Shared.Player;
+
+namespace Content.Shared.Weapons.Hitscan.Systems;
+
+public sealed class HitscanBasicEffectsSystem : EntitySystem
+{
+    [Dependency] private readonly SharedColorFlashEffectSystem _color = default!;
+    [Dependency] private readonly SharedGunSystem _gun = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<HitscanBasicEffectsComponent, HitscanDamageDealtEvent>(OnHitscanDamageDealt);
+    }
+
+    private void OnHitscanDamageDealt(Entity<HitscanBasicEffectsComponent> ent, ref HitscanDamageDealtEvent args)
+    {
+        if (Deleted(args.Target))
+            return;
+
+        if (ent.Comp.HitColor != null && args.DamageDealt.GetTotal() != 0)
+        {
+            _color.RaiseEffect(ent.Comp.HitColor.Value,
+                new List<EntityUid> { args.Target },
+                Filter.Pvs(args.Target, entityManager: EntityManager));
+        }
+
+        _gun.PlayImpactSound(args.Target, args.DamageDealt, ent.Comp.Sound, ent.Comp.ForceSound);
+    }
+}
diff --git a/Content.Shared/Weapons/Hitscan/Systems/HitscanBasicRaycastSystem.cs b/Content.Shared/Weapons/Hitscan/Systems/HitscanBasicRaycastSystem.cs
new file mode 100644 (file)
index 0000000..4bcfe8a
--- /dev/null
@@ -0,0 +1,150 @@
+using System.Numerics;
+using Content.Shared.Administration.Logs;
+using Content.Shared.Damage.Components;
+using Content.Shared.Database;
+using Content.Shared.Weapons.Hitscan.Components;
+using Content.Shared.Weapons.Hitscan.Events;
+using Content.Shared.Weapons.Ranged.Systems;
+using Robust.Shared.Containers;
+using Robust.Shared.Map;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Systems;
+using Robust.Shared.Player;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Weapons.Hitscan.Systems;
+
+public sealed class HitscanBasicRaycastSystem : EntitySystem
+{
+    [Dependency] private readonly SharedPhysicsSystem _physics = default!;
+    [Dependency] private readonly SharedContainerSystem _container = default!;
+    [Dependency] private readonly ISharedAdminLogManager _log = default!;
+    [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+    private EntityQuery<HitscanBasicVisualsComponent> _visualsQuery;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        _visualsQuery = GetEntityQuery<HitscanBasicVisualsComponent>();
+
+        SubscribeLocalEvent<HitscanBasicRaycastComponent, HitscanTraceEvent>(OnHitscanFired);
+    }
+
+    private void OnHitscanFired(Entity<HitscanBasicRaycastComponent> ent, ref HitscanTraceEvent args)
+    {
+        var shooter = args.Shooter ?? args.Gun;
+        var mapCords = _transform.ToMapCoordinates(args.FromCoordinates);
+        var ray = new CollisionRay(mapCords.Position, args.ShotDirection, (int) ent.Comp.CollisionMask);
+        var rayCastResults = _physics.IntersectRay(mapCords.MapId, ray, ent.Comp.MaxDistance, shooter, false);
+
+        var target = args.Target;
+        // If you are in a container, use the raycast result
+        // Otherwise:
+        //  1.) Hit the first entity that you targeted.
+        //  2.) Hit the first entity that doesn't require you to aim at it specifically to be hit.
+        var result = _container.IsEntityOrParentInContainer(shooter)
+            ? rayCastResults.FirstOrNull()
+            : rayCastResults.FirstOrNull(hit => hit.HitEntity == target
+                                                || CompOrNull<RequireProjectileTargetComponent>(hit.HitEntity)?.Active != true);
+
+        var distanceTried = result?.Distance ?? ent.Comp.MaxDistance;
+
+        // Do visuals without an event. They should always happen and putting it on the attempt event is weird!
+        // If more stuff gets added here, it should probably be turned into an event.
+        FireEffects(args.FromCoordinates, distanceTried, args.ShotDirection.ToAngle(), ent.Owner);
+
+        // Admin logging
+        if (result?.HitEntity != null)
+        {
+            _log.Add(LogType.HitScanHit,
+                $"{ToPrettyString(shooter):user} hit {ToPrettyString(result.Value.HitEntity):target}"
+                + $" using {ToPrettyString(args.Gun):entity}.");
+        }
+
+        var data = new HitscanRaycastFiredData
+        {
+            ShotDirection = args.ShotDirection,
+            Gun = args.Gun,
+            Shooter = args.Shooter,
+            HitEntity = result?.HitEntity,
+        };
+
+        var attemptEvent = new AttemptHitscanRaycastFiredEvent { Data = data };
+        RaiseLocalEvent(ent, ref attemptEvent);
+
+        if (attemptEvent.Cancelled)
+            return;
+
+        var hitEvent = new HitscanRaycastFiredEvent { Data = data };
+        RaiseLocalEvent(ent, ref hitEvent);
+    }
+
+    /// <summary>
+    /// Create visual effects for the fired hitscan weapon.
+    /// </summary>
+    /// <param name="fromCoordinates">Location to start the effect.</param>
+    /// <param name="distance">Distance of the hitscan shot.</param>
+    /// <param name="shotAngle">Angle of the shot.</param>
+    /// <param name="hitscanUid">The hitscan entity itself.</param>
+    private void FireEffects(EntityCoordinates fromCoordinates, float distance, Angle shotAngle, EntityUid hitscanUid)
+    {
+        if (distance == 0 || !_visualsQuery.TryComp(hitscanUid, out var vizComp))
+            return;
+
+        var sprites = new List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier sprite, float scale)>();
+        var fromXform = Transform(fromCoordinates.EntityId);
+
+        // We'll get the effects relative to the grid / map of the firer
+        // Look you could probably optimise this a bit with redundant transforms at this point.
+
+        var gridUid = fromXform.GridUid;
+        if (gridUid != fromCoordinates.EntityId && TryComp(gridUid, out TransformComponent? gridXform))
+        {
+            var (_, gridRot, gridInvMatrix) = _transform.GetWorldPositionRotationInvMatrix(gridXform);
+            var map = _transform.ToMapCoordinates(fromCoordinates);
+            fromCoordinates = new EntityCoordinates(gridUid.Value, Vector2.Transform(map.Position, gridInvMatrix));
+            shotAngle -= gridRot;
+        }
+        else
+        {
+            shotAngle -= _transform.GetWorldRotation(fromXform);
+        }
+
+        if (distance >= 1f)
+        {
+            if (vizComp.MuzzleFlash != null)
+            {
+                var coords = fromCoordinates.Offset(shotAngle.ToVec().Normalized() / 2);
+                var netCoords = GetNetCoordinates(coords);
+
+                sprites.Add((netCoords, shotAngle, vizComp.MuzzleFlash, 1f));
+            }
+
+            if (vizComp.TravelFlash != null)
+            {
+                var coords = fromCoordinates.Offset(shotAngle.ToVec() * (distance + 0.5f) / 2);
+                var netCoords = GetNetCoordinates(coords);
+
+                sprites.Add((netCoords, shotAngle, vizComp.TravelFlash, distance - 1.5f));
+            }
+        }
+
+        if (vizComp.ImpactFlash != null)
+        {
+            var coords = fromCoordinates.Offset(shotAngle.ToVec() * distance);
+            var netCoords = GetNetCoordinates(coords);
+
+            sprites.Add((netCoords, shotAngle.FlipPositive(), vizComp.ImpactFlash, 1f));
+        }
+
+        if (sprites.Count > 0)
+        {
+            RaiseNetworkEvent(new SharedGunSystem.HitscanEvent
+            {
+                Sprites = sprites,
+            }, Filter.Pvs(fromCoordinates, entityMan: EntityManager));
+        }
+    }
+}
diff --git a/Content.Shared/Weapons/Hitscan/Systems/HitscanReflectSystem.cs b/Content.Shared/Weapons/Hitscan/Systems/HitscanReflectSystem.cs
new file mode 100644 (file)
index 0000000..389e944
--- /dev/null
@@ -0,0 +1,50 @@
+using Content.Shared.Weapons.Hitscan.Components;
+using Content.Shared.Weapons.Hitscan.Events;
+using Content.Shared.Weapons.Ranged.Events;
+using Content.Shared.Weapons.Reflect;
+using Robust.Shared.Random;
+
+namespace Content.Shared.Weapons.Hitscan.Systems;
+
+public sealed class HitscanReflectSystem : EntitySystem
+{
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<HitscanReflectComponent, AttemptHitscanRaycastFiredEvent>(OnHitscanHit);
+    }
+
+    private void OnHitscanHit(Entity<HitscanReflectComponent> hitscan, ref AttemptHitscanRaycastFiredEvent args)
+    {
+        var data = args.Data;
+
+        if (hitscan.Comp.ReflectiveType == ReflectType.None || data.HitEntity == null)
+            return;
+
+        if (hitscan.Comp.CurrentReflections >= hitscan.Comp.MaxReflections)
+            return;
+
+        var ev = new HitScanReflectAttemptEvent(data.Shooter ?? data.Gun, data.Gun, hitscan.Comp.ReflectiveType, data.ShotDirection, false);
+        RaiseLocalEvent(data.HitEntity.Value, ref ev);
+
+        if (!ev.Reflected)
+            return;
+
+        hitscan.Comp.CurrentReflections++;
+
+        args.Cancelled = true;
+
+        var fromEffect = Transform(data.HitEntity.Value).Coordinates;
+
+        var hitFiredEvent = new HitscanTraceEvent
+        {
+            FromCoordinates = fromEffect,
+            ShotDirection = ev.Direction,
+            Gun = data.Gun,
+            Shooter = data.HitEntity.Value,
+        };
+
+        RaiseLocalEvent(hitscan, ref hitFiredEvent);
+    }
+}
diff --git a/Content.Shared/Weapons/Hitscan/Systems/HitscanStunSystem.cs b/Content.Shared/Weapons/Hitscan/Systems/HitscanStunSystem.cs
new file mode 100644 (file)
index 0000000..8c1d1b4
--- /dev/null
@@ -0,0 +1,25 @@
+using Content.Shared.Damage.Systems;
+using Content.Shared.Weapons.Hitscan.Components;
+using Content.Shared.Weapons.Hitscan.Events;
+
+namespace Content.Shared.Weapons.Hitscan.Systems;
+
+public sealed class HitscanStunSystem : EntitySystem
+{
+    [Dependency] private readonly SharedStaminaSystem _stamina = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<HitscanStaminaDamageComponent, HitscanRaycastFiredEvent>(OnHitscanHit);
+    }
+
+    private void OnHitscanHit(Entity<HitscanStaminaDamageComponent> hitscan, ref HitscanRaycastFiredEvent args)
+    {
+        if (args.Data.HitEntity == null)
+            return;
+
+        _stamina.TakeStaminaDamage(args.Data.HitEntity.Value, hitscan.Comp.StaminaDamage, source: args.Data.Shooter ?? args.Data.Gun);
+    }
+}
index be9d9805a8352ba3153eebe10ce9c890c9ad447f..cdbf51456e9916077f3f6988f2a82e55d7bfdbee 100644 (file)
@@ -1,11 +1,11 @@
 using Robust.Shared.GameStates;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+using Robust.Shared.Prototypes;
 
 namespace Content.Shared.Weapons.Ranged.Components;
 
 [RegisterComponent, NetworkedComponent]
 public sealed partial class HitscanBatteryAmmoProviderComponent : BatteryAmmoProviderComponent
 {
-    [ViewVariables(VVAccess.ReadWrite), DataField("proto", required: true, customTypeSerializer: typeof(PrototypeIdSerializer<HitscanPrototype>))]
-    public string Prototype = default!;
+    [DataField("proto", required: true)]
+    public EntProtoId HitscanEntityProto;
 }
diff --git a/Content.Shared/Weapons/Ranged/HitscanPrototype.cs b/Content.Shared/Weapons/Ranged/HitscanPrototype.cs
deleted file mode 100644 (file)
index 3e0c15b..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-using Content.Shared.Damage;
-using Content.Shared.Physics;
-using Content.Shared.Weapons.Reflect;
-using Robust.Shared.Audio;
-using Robust.Shared.Prototypes;
-using Robust.Shared.Utility;
-
-namespace Content.Shared.Weapons.Ranged;
-
-[Prototype]
-public sealed partial class HitscanPrototype : IPrototype, IShootable
-{
-    [ViewVariables]
-    [IdDataField]
-    public string ID { get; private set; } = default!;
-
-    [ViewVariables(VVAccess.ReadWrite), DataField("staminaDamage")]
-    public float StaminaDamage;
-
-    [ViewVariables(VVAccess.ReadWrite), DataField("damage")]
-    public DamageSpecifier? Damage;
-
-    [ViewVariables(VVAccess.ReadOnly), DataField("muzzleFlash")]
-    public SpriteSpecifier? MuzzleFlash;
-
-    [ViewVariables(VVAccess.ReadOnly), DataField("travelFlash")]
-    public SpriteSpecifier? TravelFlash;
-
-    [ViewVariables(VVAccess.ReadOnly), DataField("impactFlash")]
-    public SpriteSpecifier? ImpactFlash;
-
-    [DataField("collisionMask")]
-    public int CollisionMask = (int) CollisionGroup.Opaque;
-
-    /// <summary>
-    /// What we count as for reflection.
-    /// </summary>
-    [DataField("reflective")] public ReflectType Reflective = ReflectType.Energy;
-
-    /// <summary>
-    /// Sound that plays upon the thing being hit.
-    /// </summary>
-    [DataField("sound")]
-    public SoundSpecifier? Sound;
-
-    /// <summary>
-    /// Force the hitscan sound to play rather than potentially playing the entity's sound.
-    /// </summary>
-    [ViewVariables(VVAccess.ReadWrite), DataField("forceSound")]
-    public bool ForceSound;
-
-    /// <summary>
-    /// Try not to set this too high.
-    /// </summary>
-    [DataField("maxLength")]
-    public float MaxLength = 20f;
-}
index cb4e42f3cf0c717cca768ea9d1b955e3fd2ca4c1..663f5f1faa2924dea266f603d8f7a9a601292a72 100644 (file)
@@ -2,6 +2,7 @@ using Content.Shared.Damage;
 using Content.Shared.Damage.Events;
 using Content.Shared.Examine;
 using Content.Shared.Projectiles;
+using Content.Shared.Weapons.Hitscan.Components;
 using Content.Shared.Weapons.Ranged.Components;
 using Content.Shared.Weapons.Ranged.Events;
 using Robust.Shared.GameStates;
@@ -80,11 +81,10 @@ public abstract partial class SharedGunSystem
     {
         if (component is ProjectileBatteryAmmoProviderComponent battery)
         {
-            if (ProtoManager.Index<EntityPrototype>(battery.Prototype)
-                .Components
+            if (ProtoManager.Index<EntityPrototype>(battery.Prototype).Components
                 .TryGetValue(Factory.GetComponentName<ProjectileComponent>(), out var projectile))
             {
-                var p = (ProjectileComponent)projectile.Component;
+                var p = (ProjectileComponent) projectile.Component;
 
                 if (!p.Damage.Empty)
                 {
@@ -97,8 +97,11 @@ public abstract partial class SharedGunSystem
 
         if (component is HitscanBatteryAmmoProviderComponent hitscan)
         {
-            var dmg = ProtoManager.Index<HitscanPrototype>(hitscan.Prototype).Damage;
-            return dmg == null ? dmg : dmg * Damageable.UniversalHitscanDamageModifier;
+            var dmg = ProtoManager.Index(hitscan.HitscanEntityProto);
+            if (!dmg.TryGetComponent<HitscanBasicDamageComponent>(out var basicDamageComp, Factory))
+                return null;
+
+            return basicDamageComp.Damage * Damageable.UniversalHitscanDamageModifier;
         }
 
         return null;
@@ -155,7 +158,8 @@ public abstract partial class SharedGunSystem
                 var ent = Spawn(proj.Prototype, coordinates);
                 return (ent, EnsureShootable(ent));
             case HitscanBatteryAmmoProviderComponent hitscan:
-                return (null, ProtoManager.Index<HitscanPrototype>(hitscan.Prototype));
+                var hitscanEnt = Spawn(hitscan.HitscanEntityProto);
+                return (hitscanEnt, EnsureShootable(hitscanEnt));
             default:
                 throw new ArgumentOutOfRangeException();
         }
index 952b53acf251d1f0012a9e5e2725031ad19f7dea..bbfd4a051eba76396b83abd32e8a214837c0d31b 100644 (file)
@@ -16,6 +16,7 @@ using Content.Shared.Tag;
 using Content.Shared.Throwing;
 using Content.Shared.Timing;
 using Content.Shared.Verbs;
+using Content.Shared.Weapons.Hitscan.Components;
 using Content.Shared.Weapons.Melee;
 using Content.Shared.Weapons.Melee.Events;
 using Content.Shared.Weapons.Ranged.Components;
@@ -500,6 +501,9 @@ public abstract partial class SharedGunSystem : EntitySystem
         if (TryComp<CartridgeAmmoComponent>(uid, out var cartridge))
             return cartridge;
 
+        if (TryComp<HitscanAmmoComponent>(uid, out var hitscanAmmo))
+            return hitscanAmmo;
+
         return EnsureComp<AmmoComponent>(uid);
     }
 
@@ -614,6 +618,8 @@ public abstract partial class SharedGunSystem : EntitySystem
 
     protected abstract void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null);
 
+    public abstract void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound);
+
     /// <summary>
     /// Used for animated effects on the client.
     /// </summary>
index ea906810a55feb912c9ef75067c7838ef8554276..06923612de3e61b94b3a7b681c7eb546336d4d1a 100644 (file)
@@ -56,6 +56,10 @@ public sealed partial class ReflectComponent : Component
     public SoundSpecifier? SoundOnReflect = new SoundPathSpecifier("/Audio/Weapons/Guns/Hits/laser_sear_wall.ogg", AudioParams.Default.WithVariation(0.05f));
 }
 
+/// <summary>
+/// Used for both the projectiles being reflected and the entities reflecting. If there is ever overlap between the
+/// reflection types, the projectile will be reflected.
+/// </summary>
 [Flags, Serializable, NetSerializable]
 public enum ReflectType : byte
 {
index 89db3240bef649f2e9dd1d0e677523c2410db108..bc3e8f383c5556bce571f8bcff7ac6c37f937d55 100644 (file)
       - HideContextMenu
   - type: AnimationPlayer
 
-- type: hitscan
-  id: RedLaser
-  damage:
-    types:
-      Heat: 14
-  muzzleFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: muzzle_laser
-  travelFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: beam
-  impactFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: impact_laser
+- type: entity
+  id: BasicHitscan
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: HitscanAmmo
+  - type: HitscanBasicRaycast
+  - type: HitscanBasicVisuals
+    muzzleFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: muzzle_laser
+    travelFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: beam
+    impactFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: impact_laser
+  - type: HitscanReflect
+  - type: HitscanBasicEffects
 
-- type: hitscan
-  id: RedLaserPractice
-  damage:
-    types:
-      Heat: 1
-  muzzleFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: muzzle_laser
-  travelFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: beam
-  impactFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: impact_laser
+- type: entity
+  parent: BasicHitscan
+  id: RedLightLaser
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: HitscanBasicDamage
+    damage:
+      types:
+        Heat: 7
 
-- type: hitscan
+- type: entity
+  parent: BasicHitscan
+  id: RedLaser
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: HitscanBasicDamage
+    damage:
+      types:
+        Heat: 14
+
+- type: entity
+  parent: BasicHitscan
   id: RedMediumLaser
-  damage:
-    types:
-      Heat: 17
-  muzzleFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: muzzle_laser
-  travelFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: beam
-  impactFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: impact_laser
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: HitscanBasicDamage
+    damage:
+      types:
+        Heat: 17
 
-- type: hitscan
-  id: RedLightLaser
-  damage:
-    types:
-      Heat: 7
-  muzzleFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: muzzle_laser
-  travelFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: beam
-  impactFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: impact_laser
+- type: entity
+  parent: BasicHitscan
+  id: RedHeavyLaser
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: HitscanBasicDamage
+    damage:
+      types:
+        Heat: 28
+  - type: HitscanBasicVisuals
+    muzzleFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: muzzle_beam_heavy
+    travelFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: beam_heavy
+    impactFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: impact_beam_heavy
 
-- type: hitscan
-  id: XrayLaser
-  damage:
-    types:
-      Heat: 10
-      Radiation: 10
-  muzzleFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: muzzle_xray
-  travelFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: xray
-  impactFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: impact_xray
+- type: entity
+  parent: BasicHitscan
+  id: RedLaserPractice
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: HitscanBasicDamage
+    damage:
+      types:
+        Heat: 1
 
-- type: hitscan
-  id: RedHeavyLaser
-  damage:
-    types:
-      Heat: 28
-  muzzleFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: muzzle_beam_heavy
-  travelFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: beam_heavy
-  impactFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: impact_beam_heavy
+- type: entity
+  parent: BasicHitscan
+  id: XrayLaser
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: HitscanBasicDamage
+    damage:
+      types:
+        Heat: 10
+        Radiation: 10
+  - type: HitscanBasicVisuals
+    muzzleFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: muzzle_xray
+    travelFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: xray
+    impactFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: impact_xray
 
-- type: hitscan
+- type: entity
+  parent: BasicHitscan
   id: Pulse
-  damage:
-    types:
-      Heat: 35
-  muzzleFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: muzzle_blue
-  travelFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: beam_blue
-  impactFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: impact_blue
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: HitscanBasicDamage
+    damage:
+      types:
+        Heat: 35
+  - type: HitscanBasicVisuals
+    muzzleFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: muzzle_blue
+    travelFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: beam_blue
+    impactFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: impact_blue
 
-- type: hitscan
+- type: entity
+  parent: BasicHitscan
   id: RedShuttleLaser
-  maxLength: 60
-  damage:
-    types:
-      Heat: 45
-      Structural: 10
-  muzzleFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: muzzle_beam_heavy2
-  travelFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: beam_heavy2
-  impactFlash:
-    sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
-    state: impact_beam_heavy2
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: HitscanBasicRaycast
+    maxDistance: 60.0
+  - type: HitscanBasicDamage
+    damage:
+      types:
+        Heat: 45
+        Structural: 10
+  - type: HitscanBasicVisuals
+    muzzleFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: muzzle_beam_heavy2
+    travelFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: beam_heavy2
+    impactFlash:
+      sprite: Objects/Weapons/Guns/Projectiles/projectiles.rsi
+      state: impact_beam_heavy2