From: beck-thompson <107373427+beck-thompson@users.noreply.github.com> Date: Sat, 18 Oct 2025 05:42:08 +0000 (-0700) Subject: Hitscans are now entities (#38035) X-Git-Url: https://git.smokeofanarchy.ru/gitweb.cgi?a=commitdiff_plain;h=492a1aa9c3a751233be6fc21980daa85cd38bf42;p=space-station-14.git Hitscans are now entities (#38035) * 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 --- diff --git a/Content.Client/Weapons/Ranged/Systems/GunSystem.cs b/Content.Client/Weapons/Ranged/Systems/GunSystem.cs index c27e81b5c7..adef067b60 100644 --- a/Content.Client/Weapons/Ranged/Systems/GunSystem.cs +++ b/Content.Client/Weapons/Ranged/Systems/GunSystem.cs @@ -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) {} } diff --git a/Content.Server/Weapons/Ranged/Systems/GunSystem.cs b/Content.Server/Weapons/Ranged/Systems/GunSystem.cs index e04fa9b64c..dcfa3e6654 100644 --- a/Content.Server/Weapons/Ranged/Systems/GunSystem.cs +++ b/Content.Server/Weapons/Ranged/Systems/GunSystem.cs @@ -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(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() { 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 index 0000000000..3993d5ad20 --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Components/HitscanAmmoComponent.cs @@ -0,0 +1,11 @@ +using Content.Shared.Weapons.Ranged; +using Robust.Shared.GameStates; + +namespace Content.Shared.Weapons.Hitscan.Components; + +/// +/// 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. +/// +[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 index 0000000000..dcbb19265e --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Components/HitscanBasicDamageComponent.cs @@ -0,0 +1,17 @@ +using Content.Shared.Damage; +using Robust.Shared.GameStates; + +namespace Content.Shared.Weapons.Hitscan.Components; + +/// +/// Hitscan entities that have this component will do the damage specified to hit targets (Who didn't reflect it). +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class HitscanBasicDamageComponent : Component +{ + /// + /// How much damage the hitscan weapon will do when hitting a target. + /// + [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 index 0000000000..05209f3b9e --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Components/HitscanBasicEffectsComponent.cs @@ -0,0 +1,29 @@ +using Robust.Shared.Audio; +using Robust.Shared.GameStates; + +namespace Content.Shared.Weapons.Hitscan.Components; + +/// +/// System or basic "effects" like sounds and hit markers for hitscans. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class HitscanBasicEffectsComponent : Component +{ + /// + /// This will turn hit entities this color briefly. + /// + [DataField] + public Color? HitColor = Color.Red; + + /// + /// Sound that plays upon the thing being hit. + /// + [DataField] + public SoundSpecifier? Sound; + + /// + /// Force the hitscan sound to play rather than playing the entity's override sound (if it exists). + /// + [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 index 0000000000..e04ab0da4f --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Components/HitscanBasicRaycastComponent.cs @@ -0,0 +1,23 @@ +using Content.Shared.Physics; +using Robust.Shared.GameStates; + +namespace Content.Shared.Weapons.Hitscan.Components; + +/// +/// A basic raycast system that will shoot in a straight line when triggered. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class HitscanBasicRaycastComponent : Component +{ + /// + /// Maximum distance the raycast will travel before giving up. Reflections will reset the distance traveled + /// + [DataField] + public float MaxDistance = 20.0f; + + /// + /// The collision mask the hitscan ray uses to collide with other objects. See the enum for more information + /// + [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 index 0000000000..519425ece3 --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Components/HitscanBasicVisualsComponent.cs @@ -0,0 +1,29 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Utility; + +namespace Content.Shared.Weapons.Hitscan.Components; + +/// +/// Provides basic visuals for hitscan weapons - works with +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class HitscanBasicVisualsComponent : Component +{ + /// + /// The muzzle flash from the hitscan weapon. + /// + [DataField] + public SpriteSpecifier? MuzzleFlash; + + /// + /// The "travel" sprite, this gets repeated until it hits the target. + /// + [DataField] + public SpriteSpecifier? TravelFlash; + + /// + /// The sprite that gets shown on the impact of the laser. + /// + [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 index 0000000000..a63f3ab670 --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Components/HitscanReflectComponent.cs @@ -0,0 +1,29 @@ +using Content.Shared.Weapons.Reflect; +using Robust.Shared.GameStates; + +namespace Content.Shared.Weapons.Hitscan.Components; + +/// +/// Hitscan entities with this component will get reflected by certain things (E.G energy swords). +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class HitscanReflectComponent : Component +{ + /// + /// The reflective type, will only reflect from entities that have a matching reflection type. + /// + [DataField] + public ReflectType ReflectiveType = ReflectType.Energy; + + /// + /// The maximum number of reflections the laser will make. + /// + [DataField] + public int MaxReflections = 3; + + /// + /// Current number of times this hitscan entity was reflected. Will not be more than + /// + [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 index 0000000000..35eb398682 --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Components/HitscanStaminaDamageComponent.cs @@ -0,0 +1,16 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Weapons.Hitscan.Components; + +/// +/// Hitscan entities that have this component will deal stamina damage to the target. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class HitscanStaminaDamageComponent : Component +{ + /// + /// How much stamania damage the hitscan weapon will do when hitting a target. + /// + [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 index 0000000000..0ebc18079a --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Events/HitscanEvents.cs @@ -0,0 +1,110 @@ +using System.Numerics; +using Content.Shared.Damage; +using Robust.Shared.Map; + +namespace Content.Shared.Weapons.Hitscan.Events; + +/// +/// 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. +/// +[ByRefEvent] +public record struct HitscanTraceEvent +{ + /// + /// Location the hitscan was fired from. + /// + public EntityCoordinates FromCoordinates; + + /// + /// Direction that the ray was fired towards. + /// + public Vector2 ShotDirection; + + /// + /// Gun that was fired - this will always be the original weapon even if reflected. + /// + public EntityUid Gun; + + /// + /// Player who shot the gun, if null the gun was fired by itself. + /// + public EntityUid? Shooter; + + /// + /// Target that was being aimed at (Not necessarily hit). + /// + public EntityUid? Target; +} + +/// +/// All data known data for when a hitscan is actually fired. +/// +public record struct HitscanRaycastFiredData +{ + /// + /// Direction that the ray was fired towards. + /// + public Vector2 ShotDirection; + + /// + /// The entity that got hit, if null the raycast didn't hit anyone. + /// + public EntityUid? HitEntity; + + /// + /// Gun that fired the raycast. + /// + public EntityUid Gun; + + /// + /// Player who shot the gun, if null the gun was fired by itself. + /// + public EntityUid? Shooter; +} + +/// +/// 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. +/// +[ByRefEvent] +public struct AttemptHitscanRaycastFiredEvent +{ + /// + /// Data for the hitscan that was fired. + /// + public HitscanRaycastFiredData Data; + + /// + /// Set to true the hitscan is cancelled (e.g. due to reflection). + /// Cancelled hitscans should not apply damage or trigger follow-up effects. + /// + public bool Cancelled; +} + +/// +/// 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. +/// +[ByRefEvent] +public struct HitscanRaycastFiredEvent +{ + /// + /// Data for the hitscan that was fired. + /// + public HitscanRaycastFiredData Data; +} + +[ByRefEvent] +public record struct HitscanDamageDealtEvent +{ + /// + /// Target that was dealt damage. + /// + public EntityUid Target; + + /// + /// The amount of damage that the target was dealt. + /// + 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 index 0000000000..130687469a --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Systems/HitscanBasicDamageSystem.cs @@ -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(OnHitscanHit); + } + + private void OnHitscanHit(Entity 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 index 0000000000..4713734be7 --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Systems/HitscanBasicEffectsSystem.cs @@ -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(OnHitscanDamageDealt); + } + + private void OnHitscanDamageDealt(Entity 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 { 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 index 0000000000..4bcfe8a69f --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Systems/HitscanBasicRaycastSystem.cs @@ -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 _visualsQuery; + + public override void Initialize() + { + base.Initialize(); + + _visualsQuery = GetEntityQuery(); + + SubscribeLocalEvent(OnHitscanFired); + } + + private void OnHitscanFired(Entity 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(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); + } + + /// + /// Create visual effects for the fired hitscan weapon. + /// + /// Location to start the effect. + /// Distance of the hitscan shot. + /// Angle of the shot. + /// The hitscan entity itself. + 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 index 0000000000..389e944942 --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Systems/HitscanReflectSystem.cs @@ -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(OnHitscanHit); + } + + private void OnHitscanHit(Entity 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 index 0000000000..8c1d1b45fb --- /dev/null +++ b/Content.Shared/Weapons/Hitscan/Systems/HitscanStunSystem.cs @@ -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(OnHitscanHit); + } + + private void OnHitscanHit(Entity 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); + } +} diff --git a/Content.Shared/Weapons/Ranged/Components/HitscanBatteryAmmoProviderComponent.cs b/Content.Shared/Weapons/Ranged/Components/HitscanBatteryAmmoProviderComponent.cs index be9d9805a8..cdbf51456e 100644 --- a/Content.Shared/Weapons/Ranged/Components/HitscanBatteryAmmoProviderComponent.cs +++ b/Content.Shared/Weapons/Ranged/Components/HitscanBatteryAmmoProviderComponent.cs @@ -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))] - 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 index 3e0c15b879..0000000000 --- a/Content.Shared/Weapons/Ranged/HitscanPrototype.cs +++ /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; - - /// - /// What we count as for reflection. - /// - [DataField("reflective")] public ReflectType Reflective = ReflectType.Energy; - - /// - /// Sound that plays upon the thing being hit. - /// - [DataField("sound")] - public SoundSpecifier? Sound; - - /// - /// Force the hitscan sound to play rather than potentially playing the entity's sound. - /// - [ViewVariables(VVAccess.ReadWrite), DataField("forceSound")] - public bool ForceSound; - - /// - /// Try not to set this too high. - /// - [DataField("maxLength")] - public float MaxLength = 20f; -} diff --git a/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.Battery.cs b/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.Battery.cs index cb4e42f3cf..663f5f1faa 100644 --- a/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.Battery.cs +++ b/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.Battery.cs @@ -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(battery.Prototype) - .Components + if (ProtoManager.Index(battery.Prototype).Components .TryGetValue(Factory.GetComponentName(), 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(hitscan.Prototype).Damage; - return dmg == null ? dmg : dmg * Damageable.UniversalHitscanDamageModifier; + var dmg = ProtoManager.Index(hitscan.HitscanEntityProto); + if (!dmg.TryGetComponent(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(hitscan.Prototype)); + var hitscanEnt = Spawn(hitscan.HitscanEntityProto); + return (hitscanEnt, EnsureShootable(hitscanEnt)); default: throw new ArgumentOutOfRangeException(); } diff --git a/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs b/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs index 952b53acf2..bbfd4a051e 100644 --- a/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs +++ b/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs @@ -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(uid, out var cartridge)) return cartridge; + if (TryComp(uid, out var hitscanAmmo)) + return hitscanAmmo; + return EnsureComp(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); + /// /// Used for animated effects on the client. /// diff --git a/Content.Shared/Weapons/Reflect/ReflectComponent.cs b/Content.Shared/Weapons/Reflect/ReflectComponent.cs index ea906810a5..06923612de 100644 --- a/Content.Shared/Weapons/Reflect/ReflectComponent.cs +++ b/Content.Shared/Weapons/Reflect/ReflectComponent.cs @@ -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)); } +/// +/// 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. +/// [Flags, Serializable, NetSerializable] public enum ReflectType : byte { diff --git a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/hitscan.yml b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/hitscan.yml index 89db3240be..bc3e8f383c 100644 --- a/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/hitscan.yml +++ b/Resources/Prototypes/Entities/Objects/Weapons/Guns/Projectiles/hitscan.yml @@ -16,125 +16,145 @@ - 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