diff --git a/Content.Client/Weapons/Melee/MeleeWeaponSystem.cs b/Content.Client/Weapons/Melee/MeleeWeaponSystem.cs index ea3e3bcf73..71c98b6257 100644 --- a/Content.Client/Weapons/Melee/MeleeWeaponSystem.cs +++ b/Content.Client/Weapons/Melee/MeleeWeaponSystem.cs @@ -1,19 +1,16 @@ using Content.Client.CombatMode; using Content.Client.Gameplay; using Content.Client.Hands; -using Content.Client.Weapons.Melee.Components; using Content.Shared.MobState.Components; using Content.Shared.Weapons.Melee; using Content.Shared.Weapons.Melee.Events; using Content.Shared.StatusEffect; -using Robust.Client.Animations; using Robust.Client.GameObjects; using Robust.Client.Graphics; using Robust.Client.Input; using Robust.Client.Player; using Robust.Client.ResourceManagement; using Robust.Client.State; -using Robust.Shared.Animations; using Robust.Shared.Input; using Robust.Shared.Map; using Robust.Shared.Player; @@ -43,7 +40,7 @@ public sealed partial class MeleeWeaponSystem : SharedMeleeWeaponSystem base.Initialize(); InitializeEffect(); _overlayManager.AddOverlay(new MeleeWindupOverlay(EntityManager, _timing, _player, _protoManager, _cache)); - SubscribeNetworkEvent(OnDamageEffect); + SubscribeAllEvent(OnDamageEffect); SubscribeNetworkEvent(OnMeleeLunge); } @@ -208,6 +205,22 @@ public sealed partial class MeleeWeaponSystem : SharedMeleeWeaponSystem } } + protected override bool InRange(EntityUid user, EntityUid target, float range, ICommonSession? session) + { + var xform = Transform(target); + var targetCoordinates = xform.Coordinates; + var targetLocalAngle = xform.LocalRotation; + + return Interaction.InRangeUnobstructed(user, target, targetCoordinates, targetLocalAngle, range); + } + + protected override void DoDamageEffect(List targets, EntityUid? user, TransformComponent targetXform) + { + // Server never sends the event to us for predictiveeevent. + if (_timing.IsFirstTimePredicted) + RaiseLocalEvent(new DamageEffectEvent(Color.Red, targets)); + } + protected override bool DoDisarm(EntityUid user, DisarmAttackEvent ev, MeleeWeaponComponent component, ICommonSession? session) { if (!base.DoDisarm(user, ev, component, session)) diff --git a/Content.Server/Weapons/Melee/MeleeWeaponSystem.cs b/Content.Server/Weapons/Melee/MeleeWeaponSystem.cs index 0ee6f36560..e51da7c123 100644 --- a/Content.Server/Weapons/Melee/MeleeWeaponSystem.cs +++ b/Content.Server/Weapons/Melee/MeleeWeaponSystem.cs @@ -1,7 +1,6 @@ using System.Linq; using Content.Server.Actions.Events; using Content.Server.Administration.Components; -using Content.Server.Administration.Logs; using Content.Server.Body.Components; using Content.Server.Body.Systems; using Content.Server.Chemistry.Components; @@ -9,19 +8,14 @@ using Content.Server.Chemistry.EntitySystems; using Content.Server.CombatMode; using Content.Server.CombatMode.Disarm; using Content.Server.Contests; -using Content.Server.Damage.Systems; using Content.Server.Examine; using Content.Server.Hands.Components; using Content.Server.Movement.Systems; -using Content.Server.Weapons.Melee.Components; using Content.Shared.CombatMode; using Content.Shared.Damage; -using Content.Shared.Damage.Systems; using Content.Shared.Database; using Content.Shared.FixedPoint; using Content.Shared.IdentityManagement; -using Content.Shared.Interaction; -using Content.Shared.Physics; using Content.Shared.Verbs; using Content.Shared.Weapons.Melee; using Content.Shared.Weapons.Melee.Events; @@ -29,33 +23,20 @@ using Content.Shared.StatusEffect; using Robust.Server.Player; using Robust.Shared.Audio; using Robust.Shared.Map; -using Robust.Shared.Physics; -using Robust.Shared.Physics.Systems; using Robust.Shared.Player; using Robust.Shared.Players; -using Robust.Shared.Prototypes; using Robust.Shared.Random; namespace Content.Server.Weapons.Melee; public sealed class MeleeWeaponSystem : SharedMeleeWeaponSystem { - [Dependency] private readonly IAdminLogManager _adminLogger = default!; - [Dependency] private readonly IPrototypeManager _protoManager = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly BloodstreamSystem _bloodstream = default!; [Dependency] private readonly ContestsSystem _contests = default!; - [Dependency] private readonly DamageableSystem _damageable = default!; [Dependency] private readonly ExamineSystem _examine = default!; [Dependency] private readonly LagCompensationSystem _lag = default!; - [Dependency] private readonly SharedInteractionSystem _interaction = default!; - [Dependency] private readonly SharedPhysicsSystem _physics = default!; [Dependency] private readonly SolutionContainerSystem _solutions = default!; - [Dependency] private readonly StaminaSystem _stamina = default!; - - public const float DamagePitchVariation = 0.05f; - - private const int AttackMask = (int) (CollisionGroup.MobMask | CollisionGroup.Opaque); public override void Initialize() { @@ -87,7 +68,7 @@ public sealed class MeleeWeaponSystem : SharedMeleeWeaponSystem { Act = () => { - var markup = _damageable.GetDamageExamine(damageSpec, Loc.GetString("damage-melee")); + var markup = Damageable.GetDamageExamine(damageSpec, Loc.GetString("damage-melee")); _examine.SendExamineTooltip(args.User, uid, markup, false, false); }, Text = Loc.GetString("damage-examinable-verb-text"), @@ -112,208 +93,6 @@ public sealed class MeleeWeaponSystem : SharedMeleeWeaponSystem PopupSystem.PopupEntity(message, uid.Value, Filter.Pvs(uid.Value, entityManager: EntityManager).RemoveWhereAttachedEntity(e => e == user)); } - protected override void DoLightAttack(EntityUid user, LightAttackEvent ev, MeleeWeaponComponent component, ICommonSession? session) - { - base.DoLightAttack(user, ev, component, session); - - // Can't attack yourself - // Not in LOS. - if (user == ev.Target || - ev.Target == null || - Deleted(ev.Target) || - // For consistency with wide attacks stuff needs damageable. - !HasComp(ev.Target) || - !TryComp(ev.Target, out var targetXform)) - { - return; - } - - if (!InRange(user, ev.Target.Value, component.Range, session)) - return; - - var damage = component.Damage * GetModifier(component, true); - - // Sawmill.Debug($"Melee damage is {damage.Total} out of {component.Damage.Total}"); - - // Raise event before doing damage so we can cancel damage if the event is handled - var hitEvent = new MeleeHitEvent(new List { ev.Target.Value }, user, damage); - RaiseLocalEvent(component.Owner, hitEvent); - - if (hitEvent.Handled) - return; - - var targets = new List(1) - { - ev.Target.Value - }; - - _interaction.DoContactInteraction(ev.Weapon, ev.Target); - _interaction.DoContactInteraction(user, ev.Weapon); - - // If the user is using a long-range weapon, this probably shouldn't be happening? But I'll interpret melee as a - // somewhat messy scuffle. See also, heavy attacks. - _interaction.DoContactInteraction(user, ev.Target); - - // For stuff that cares about it being attacked. - RaiseLocalEvent(ev.Target.Value, new AttackedEvent(component.Owner, user, targetXform.Coordinates)); - - var modifiedDamage = DamageSpecifier.ApplyModifierSets(damage + hitEvent.BonusDamage, hitEvent.ModifiersList); - var damageResult = _damageable.TryChangeDamage(ev.Target, modifiedDamage, origin:user); - - if (damageResult != null && damageResult.Total > FixedPoint2.Zero) - { - // If the target has stamina and is taking blunt damage, they should also take stamina damage based on their blunt to stamina factor - if (damageResult.DamageDict.TryGetValue("Blunt", out var bluntDamage)) - { - _stamina.TakeStaminaDamage(ev.Target.Value, (bluntDamage * component.BluntStaminaDamageFactor).Float()); - } - - if (component.Owner == user) - { - _adminLogger.Add(LogType.MeleeHit, - $"{ToPrettyString(user):user} melee attacked {ToPrettyString(ev.Target.Value):target} using their hands and dealt {damageResult.Total:damage} damage"); - } - else - { - _adminLogger.Add(LogType.MeleeHit, - $"{ToPrettyString(user):user} melee attacked {ToPrettyString(ev.Target.Value):target} using {ToPrettyString(component.Owner):used} and dealt {damageResult.Total:damage} damage"); - } - - PlayHitSound(ev.Target.Value, GetHighestDamageSound(modifiedDamage, _protoManager), hitEvent.HitSoundOverride, component.HitSound); - } - else - { - if (hitEvent.HitSoundOverride != null) - { - Audio.PlayPvs(hitEvent.HitSoundOverride, component.Owner); - } - else - { - Audio.PlayPvs(component.NoDamageSound, component.Owner); - } - } - - if (damageResult?.Total > FixedPoint2.Zero) - { - RaiseNetworkEvent(new DamageEffectEvent(Color.Red, targets), Filter.Pvs(targetXform.Coordinates, entityMan: EntityManager)); - } - } - - protected override void DoHeavyAttack(EntityUid user, HeavyAttackEvent ev, MeleeWeaponComponent component, ICommonSession? session) - { - base.DoHeavyAttack(user, ev, component, session); - - // TODO: This is copy-paste as fuck with DoPreciseAttack - if (!TryComp(user, out var userXform)) - { - return; - } - - var targetMap = ev.Coordinates.ToMap(EntityManager); - - if (targetMap.MapId != userXform.MapID) - { - return; - } - - var userPos = userXform.WorldPosition; - var direction = targetMap.Position - userPos; - var distance = Math.Min(component.Range, direction.Length); - - // This should really be improved. GetEntitiesInArc uses pos instead of bounding boxes. - var entities = ArcRayCast(userPos, direction.ToWorldAngle(), component.Angle, distance, userXform.MapID, user); - - if (entities.Count == 0) - return; - - var targets = new List(); - var damageQuery = GetEntityQuery(); - - foreach (var entity in entities) - { - if (entity == user || - !damageQuery.HasComponent(entity)) - continue; - - targets.Add(entity); - } - - var damage = component.Damage * GetModifier(component, false); - // Sawmill.Debug($"Melee damage is {damage.Total} out of {component.Damage.Total}"); - - // Raise event before doing damage so we can cancel damage if the event is handled - var hitEvent = new MeleeHitEvent(targets, user, damage); - RaiseLocalEvent(component.Owner, hitEvent); - - if (hitEvent.Handled) - return; - - _interaction.DoContactInteraction(user, ev.Weapon); - - // For stuff that cares about it being attacked. - foreach (var target in targets) - { - _interaction.DoContactInteraction(ev.Weapon, target); - - // If the user is using a long-range weapon, this probably shouldn't be happening? But I'll interpret melee as a - // somewhat messy scuffle. See also, light attacks. - _interaction.DoContactInteraction(user, target); - - RaiseLocalEvent(target, new AttackedEvent(component.Owner, user, Transform(target).Coordinates)); - } - - var modifiedDamage = DamageSpecifier.ApplyModifierSets(damage + hitEvent.BonusDamage, hitEvent.ModifiersList); - var appliedDamage = new DamageSpecifier(); - - foreach (var entity in targets) - { - RaiseLocalEvent(entity, new AttackedEvent(component.Owner, user, ev.Coordinates)); - - var damageResult = _damageable.TryChangeDamage(entity, modifiedDamage, origin:user); - - if (damageResult != null && damageResult.Total > FixedPoint2.Zero) - { - appliedDamage += damageResult; - - if (component.Owner == user) - { - _adminLogger.Add(LogType.MeleeHit, - $"{ToPrettyString(user):user} melee attacked {ToPrettyString(entity):target} using their hands and dealt {damageResult.Total:damage} damage"); - } - else - { - _adminLogger.Add(LogType.MeleeHit, - $"{ToPrettyString(user):user} melee attacked {ToPrettyString(entity):target} using {ToPrettyString(component.Owner):used} and dealt {damageResult.Total:damage} damage"); - } - } - } - - if (entities.Count != 0) - { - if (appliedDamage.Total > FixedPoint2.Zero) - { - var target = entities.First(); - PlayHitSound(target, GetHighestDamageSound(modifiedDamage, _protoManager), hitEvent.HitSoundOverride, component.HitSound); - } - else - { - if (hitEvent.HitSoundOverride != null) - { - Audio.PlayPvs(hitEvent.HitSoundOverride, component.Owner); - } - else - { - Audio.PlayPvs(component.NoDamageSound, component.Owner); - } - } - } - - if (appliedDamage.Total > FixedPoint2.Zero) - { - RaiseNetworkEvent(new DamageEffectEvent(Color.Red, targets), Filter.Pvs(Transform(targets[0]).Coordinates, entityMan: EntityManager)); - } - } - protected override bool DoDisarm(EntityUid user, DisarmAttackEvent ev, MeleeWeaponComponent component, ICommonSession? session) { if (!base.DoDisarm(user, ev, component, session)) @@ -345,7 +124,7 @@ public sealed class MeleeWeaponSystem : SharedMeleeWeaponSystem inTargetHand = targetHandsComponent.ActiveHand.HeldEntity!.Value; } - _interaction.DoContactInteraction(user, ev.Target); + Interaction.DoContactInteraction(user, ev.Target); var attemptEvent = new DisarmAttemptEvent(target, user, inTargetHand); @@ -385,7 +164,7 @@ public sealed class MeleeWeaponSystem : SharedMeleeWeaponSystem PopupSystem.PopupEntity(msgUser, target, Filter.Entities(user)); Audio.PlayPvs(combatMode.DisarmSuccessSound, user, AudioParams.Default.WithVariation(0.025f).WithVolume(5f)); - _adminLogger.Add(LogType.DisarmedAction, $"{ToPrettyString(user):user} used disarm on {ToPrettyString(target):target}"); + AdminLogger.Add(LogType.DisarmedAction, $"{ToPrettyString(user):user} used disarm on {ToPrettyString(target):target}"); var eventArgs = new DisarmedEvent { Target = target, Source = user, PushProbability = 1 - chance }; RaiseLocalEvent(target, eventArgs); @@ -394,7 +173,7 @@ public sealed class MeleeWeaponSystem : SharedMeleeWeaponSystem return true; } - private bool InRange(EntityUid user, EntityUid target, float range, ICommonSession? session) + protected override bool InRange(EntityUid user, EntityUid target, float range, ICommonSession? session) { EntityCoordinates targetCoordinates; Angle targetLocalAngle; @@ -410,7 +189,13 @@ public sealed class MeleeWeaponSystem : SharedMeleeWeaponSystem targetLocalAngle = xform.LocalRotation; } - return _interaction.InRangeUnobstructed(user, target, targetCoordinates, targetLocalAngle, range); + return Interaction.InRangeUnobstructed(user, target, targetCoordinates, targetLocalAngle, range); + } + + protected override void DoDamageEffect(List targets, EntityUid? user, TransformComponent targetXform) + { + var filter = Filter.Pvs(targetXform.Coordinates, entityMan: EntityManager).RemoveWhereAttachedEntity(o => o == user); + RaiseNetworkEvent(new DamageEffectEvent(Color.Red, targets), filter); } private float CalculateDisarmChance(EntityUid disarmer, EntityUid disarmed, EntityUid? inTargetHand, SharedCombatModeComponent disarmerComp) @@ -433,122 +218,11 @@ public sealed class MeleeWeaponSystem : SharedMeleeWeaponSystem return Math.Clamp(chance, 0f, 1f); } - private HashSet ArcRayCast(Vector2 position, Angle angle, Angle arcWidth, float range, MapId mapId, EntityUid ignore) - { - // TODO: This is pretty sucky. - var widthRad = arcWidth; - var increments = 1 + 35 * (int) Math.Ceiling(widthRad / (2 * Math.PI)); - var increment = widthRad / increments; - var baseAngle = angle - widthRad / 2; - - var resSet = new HashSet(); - - for (var i = 0; i < increments; i++) - { - var castAngle = new Angle(baseAngle + increment * i); - var res = _physics.IntersectRay(mapId, - new CollisionRay(position, castAngle.ToWorldVec(), - AttackMask), range, ignore, false).ToList(); - - if (res.Count != 0) - { - resSet.Add(res[0].HitEntity); - } - } - - return resSet; - } - public override void DoLunge(EntityUid user, Angle angle, Vector2 localPos, string? animation) { RaiseNetworkEvent(new MeleeLungeEvent(user, angle, localPos, animation), Filter.Pvs(user, entityManager: EntityManager).RemoveWhereAttachedEntity(e => e == user)); } - private void PlayHitSound(EntityUid target, string? type, SoundSpecifier? hitSoundOverride, SoundSpecifier? hitSound) - { - var playedSound = false; - - // Play sound based off of highest damage type. - if (TryComp(target, out var damageSoundComp)) - { - if (type == null && damageSoundComp.NoDamageSound != null) - { - Audio.PlayPvs(damageSoundComp.NoDamageSound, target, AudioParams.Default.WithVariation(DamagePitchVariation)); - playedSound = true; - } - else if (type != null && damageSoundComp.SoundTypes?.TryGetValue(type, out var damageSoundType) == true) - { - Audio.PlayPvs(damageSoundType, target, AudioParams.Default.WithVariation(DamagePitchVariation)); - playedSound = true; - } - else if (type != null && damageSoundComp.SoundGroups?.TryGetValue(type, out var damageSoundGroup) == true) - { - Audio.PlayPvs(damageSoundGroup, target, AudioParams.Default.WithVariation(DamagePitchVariation)); - playedSound = true; - } - } - - // Use weapon sounds if the thing being hit doesn't specify its own sounds. - if (!playedSound) - { - if (hitSoundOverride != null) - { - Audio.PlayPvs(hitSoundOverride, target, AudioParams.Default.WithVariation(DamagePitchVariation)); - playedSound = true; - } - else if (hitSound != null) - { - Audio.PlayPvs(hitSound, target, AudioParams.Default.WithVariation(DamagePitchVariation)); - playedSound = true; - } - } - - // Fallback to generic sounds. - if (!playedSound) - { - switch (type) - { - // Unfortunately heat returns caustic group so can't just use the damagegroup in that instance. - case "Burn": - case "Heat": - case "Cold": - Audio.PlayPvs(new SoundPathSpecifier("/Audio/Items/welder.ogg"), target, AudioParams.Default.WithVariation(DamagePitchVariation)); - break; - // No damage, fallback to tappies - case null: - Audio.PlayPvs(new SoundPathSpecifier("/Audio/Weapons/tap.ogg"), target, AudioParams.Default.WithVariation(DamagePitchVariation)); - break; - case "Brute": - Audio.PlayPvs(new SoundPathSpecifier("/Audio/Weapons/smash.ogg"), target, AudioParams.Default.WithVariation(DamagePitchVariation)); - break; - } - } - } - - public static string? GetHighestDamageSound(DamageSpecifier modifiedDamage, IPrototypeManager protoManager) - { - var groups = modifiedDamage.GetDamagePerGroup(protoManager); - - // Use group if it's exclusive, otherwise fall back to type. - if (groups.Count == 1) - { - return groups.Keys.First(); - } - - var highestDamage = FixedPoint2.Zero; - string? highestDamageType = null; - - foreach (var (type, damage) in modifiedDamage.DamageDict) - { - if (damage <= highestDamage) - continue; - - highestDamageType = type; - } - - return highestDamageType; - } - private void OnChemicalInjectorHit(EntityUid owner, MeleeChemicalInjectorComponent comp, MeleeHitEvent args) { if (!args.IsHit) diff --git a/Content.Server/Weapons/Melee/Components/MeleeSoundComponent.cs b/Content.Shared/Weapons/Melee/Components/MeleeSoundComponent.cs similarity index 96% rename from Content.Server/Weapons/Melee/Components/MeleeSoundComponent.cs rename to Content.Shared/Weapons/Melee/Components/MeleeSoundComponent.cs index 23b2c2eb4d..3ec1175df8 100644 --- a/Content.Server/Weapons/Melee/Components/MeleeSoundComponent.cs +++ b/Content.Shared/Weapons/Melee/Components/MeleeSoundComponent.cs @@ -2,7 +2,7 @@ using Content.Shared.Damage.Prototypes; using Robust.Shared.Audio; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Dictionary; -namespace Content.Server.Weapons.Melee.Components; +namespace Content.Shared.Weapons.Melee.Components; /// /// Plays the specified sound upon receiving damage of the specified type. diff --git a/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs b/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs index 6d61fefd32..73742fdaa6 100644 --- a/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs +++ b/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs @@ -1,13 +1,27 @@ +using System.Linq; using Content.Shared.ActionBlocker; +using Content.Shared.Administration.Logs; using Content.Shared.CombatMode; +using Content.Shared.Damage; +using Content.Shared.Damage.Systems; +using Content.Shared.Database; +using Content.Shared.FixedPoint; using Content.Shared.Hands; using Content.Shared.Hands.Components; +using Content.Shared.Interaction; using Content.Shared.Inventory; +using Content.Shared.Physics; using Content.Shared.Popups; +using Content.Shared.Weapons.Melee.Components; using Content.Shared.Weapons.Melee.Events; +using Robust.Shared.Audio; using Robust.Shared.GameStates; using Robust.Shared.Map; +using Robust.Shared.Physics; +using Robust.Shared.Physics.Systems; +using Robust.Shared.Player; using Robust.Shared.Players; +using Robust.Shared.Prototypes; using Robust.Shared.Timing; using Robust.Shared.Utility; @@ -17,14 +31,23 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem { [Dependency] protected readonly IGameTiming Timing = default!; [Dependency] protected readonly IMapManager MapManager = default!; + [Dependency] private readonly IPrototypeManager _protoManager = default!; + [Dependency] protected readonly ISharedAdminLogManager AdminLogger = default!; [Dependency] protected readonly ActionBlockerSystem Blocker = default!; + [Dependency] protected readonly DamageableSystem Damageable = default!; + [Dependency] protected readonly InventorySystem Inventory = default!; [Dependency] protected readonly SharedAudioSystem Audio = default!; [Dependency] protected readonly SharedCombatModeSystem CombatMode = default!; - [Dependency] protected readonly InventorySystem Inventory = default!; + [Dependency] protected readonly SharedInteractionSystem Interaction = default!; + [Dependency] private readonly SharedPhysicsSystem _physics = default!; [Dependency] protected readonly SharedPopupSystem PopupSystem = default!; + [Dependency] private readonly StaminaSystem _stamina = default!; protected ISawmill Sawmill = default!; + public const float DamagePitchVariation = 0.05f; + private const int AttackMask = (int) (CollisionGroup.MobMask | CollisionGroup.Opaque); + /// /// If an attack is released within this buffer it's assumed to be full damage. /// @@ -290,9 +313,6 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem throw new NotImplementedException(); } - // Play a sound to give instant feedback; same with playing the animations - Audio.PlayPredicted(weapon.SwingSound, weapon.Owner, user); - DoLungeAnimation(user, weapon.Angle, attack.Coordinates.ToMap(EntityManager), weapon.Range, animation); weapon.Attacking = true; Dirty(weapon); @@ -332,14 +352,324 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem return (float) fraction * component.HeavyDamageModifier.Float(); } + protected abstract bool InRange(EntityUid user, EntityUid target, float range, ICommonSession? session); + protected virtual void DoLightAttack(EntityUid user, LightAttackEvent ev, MeleeWeaponComponent component, ICommonSession? session) { + // Can't attack yourself + // Not in LOS. + if (user == ev.Target || + ev.Target == null || + Deleted(ev.Target) || + // For consistency with wide attacks stuff needs damageable. + !HasComp(ev.Target) || + !TryComp(ev.Target, out var targetXform)) + { + Audio.PlayPredicted(component.SwingSound, component.Owner, user); + return; + } + if (!InRange(user, ev.Target.Value, component.Range, session)) + { + Audio.PlayPredicted(component.SwingSound, component.Owner, user); + return; + } + + var damage = component.Damage * GetModifier(component, true); + + // Sawmill.Debug($"Melee damage is {damage.Total} out of {component.Damage.Total}"); + + // Raise event before doing damage so we can cancel damage if the event is handled + var hitEvent = new MeleeHitEvent(new List { ev.Target.Value }, user, damage); + RaiseLocalEvent(component.Owner, hitEvent); + + if (hitEvent.Handled) + return; + + var targets = new List(1) + { + ev.Target.Value + }; + + Interaction.DoContactInteraction(ev.Weapon, ev.Target); + Interaction.DoContactInteraction(user, ev.Weapon); + + // If the user is using a long-range weapon, this probably shouldn't be happening? But I'll interpret melee as a + // somewhat messy scuffle. See also, heavy attacks. + Interaction.DoContactInteraction(user, ev.Target); + + // For stuff that cares about it being attacked. + RaiseLocalEvent(ev.Target.Value, new AttackedEvent(component.Owner, user, targetXform.Coordinates)); + + var modifiedDamage = DamageSpecifier.ApplyModifierSets(damage + hitEvent.BonusDamage, hitEvent.ModifiersList); + var damageResult = Damageable.TryChangeDamage(ev.Target, modifiedDamage, origin:user); + + if (damageResult != null && damageResult.Total > FixedPoint2.Zero) + { + // If the target has stamina and is taking blunt damage, they should also take stamina damage based on their blunt to stamina factor + if (damageResult.DamageDict.TryGetValue("Blunt", out var bluntDamage)) + { + _stamina.TakeStaminaDamage(ev.Target.Value, (bluntDamage * component.BluntStaminaDamageFactor).Float()); + } + + if (component.Owner == user) + { + AdminLogger.Add(LogType.MeleeHit, + $"{ToPrettyString(user):user} melee attacked {ToPrettyString(ev.Target.Value):target} using their hands and dealt {damageResult.Total:damage} damage"); + } + else + { + AdminLogger.Add(LogType.MeleeHit, + $"{ToPrettyString(user):user} melee attacked {ToPrettyString(ev.Target.Value):target} using {ToPrettyString(component.Owner):used} and dealt {damageResult.Total:damage} damage"); + } + + PlayHitSound(ev.Target.Value, user, GetHighestDamageSound(modifiedDamage, _protoManager), hitEvent.HitSoundOverride, component.HitSound); + } + else + { + if (hitEvent.HitSoundOverride != null) + { + Audio.PlayPredicted(hitEvent.HitSoundOverride, component.Owner, user); + } + else + { + Audio.PlayPredicted(component.NoDamageSound, component.Owner, user); + } + } + + if (damageResult?.Total > FixedPoint2.Zero) + { + DoDamageEffect(targets, user, targetXform); + } } + protected abstract void DoDamageEffect(List targets, EntityUid? user, TransformComponent targetXform); + protected virtual void DoHeavyAttack(EntityUid user, HeavyAttackEvent ev, MeleeWeaponComponent component, ICommonSession? session) { + // TODO: This is copy-paste as fuck with DoPreciseAttack + if (!TryComp(user, out var userXform)) + { + return; + } + var targetMap = ev.Coordinates.ToMap(EntityManager); + + if (targetMap.MapId != userXform.MapID) + { + return; + } + + var userPos = userXform.WorldPosition; + var direction = targetMap.Position - userPos; + var distance = Math.Min(component.Range, direction.Length); + + // This should really be improved. GetEntitiesInArc uses pos instead of bounding boxes. + var entities = ArcRayCast(userPos, direction.ToWorldAngle(), component.Angle, distance, userXform.MapID, user); + + if (entities.Count == 0) + { + Audio.PlayPredicted(component.SwingSound, component.Owner, user); + return; + } + + var targets = new List(); + var damageQuery = GetEntityQuery(); + + foreach (var entity in entities) + { + if (entity == user || + !damageQuery.HasComponent(entity)) + continue; + + targets.Add(entity); + } + + var damage = component.Damage * GetModifier(component, false); + // Sawmill.Debug($"Melee damage is {damage.Total} out of {component.Damage.Total}"); + + // Raise event before doing damage so we can cancel damage if the event is handled + var hitEvent = new MeleeHitEvent(targets, user, damage); + RaiseLocalEvent(component.Owner, hitEvent); + + if (hitEvent.Handled) + return; + + Interaction.DoContactInteraction(user, ev.Weapon); + + // For stuff that cares about it being attacked. + foreach (var target in targets) + { + Interaction.DoContactInteraction(ev.Weapon, target); + + // If the user is using a long-range weapon, this probably shouldn't be happening? But I'll interpret melee as a + // somewhat messy scuffle. See also, light attacks. + Interaction.DoContactInteraction(user, target); + + RaiseLocalEvent(target, new AttackedEvent(component.Owner, user, Transform(target).Coordinates)); + } + + var modifiedDamage = DamageSpecifier.ApplyModifierSets(damage + hitEvent.BonusDamage, hitEvent.ModifiersList); + var appliedDamage = new DamageSpecifier(); + + foreach (var entity in targets) + { + RaiseLocalEvent(entity, new AttackedEvent(component.Owner, user, ev.Coordinates)); + + var damageResult = Damageable.TryChangeDamage(entity, modifiedDamage, origin:user); + + if (damageResult != null && damageResult.Total > FixedPoint2.Zero) + { + appliedDamage += damageResult; + + if (component.Owner == user) + { + AdminLogger.Add(LogType.MeleeHit, + $"{ToPrettyString(user):user} melee attacked {ToPrettyString(entity):target} using their hands and dealt {damageResult.Total:damage} damage"); + } + else + { + AdminLogger.Add(LogType.MeleeHit, + $"{ToPrettyString(user):user} melee attacked {ToPrettyString(entity):target} using {ToPrettyString(component.Owner):used} and dealt {damageResult.Total:damage} damage"); + } + } + } + + if (entities.Count != 0) + { + if (appliedDamage.Total > FixedPoint2.Zero) + { + var target = entities.First(); + PlayHitSound(target, user, GetHighestDamageSound(modifiedDamage, _protoManager), hitEvent.HitSoundOverride, component.HitSound); + } + else + { + if (hitEvent.HitSoundOverride != null) + { + Audio.PlayPredicted(hitEvent.HitSoundOverride, component.Owner, user); + } + else + { + Audio.PlayPredicted(component.NoDamageSound, component.Owner, user); + } + } + } + + if (appliedDamage.Total > FixedPoint2.Zero) + { + RaiseNetworkEvent(new DamageEffectEvent(Color.Red, targets), Filter.Pvs(Transform(targets[0]).Coordinates, entityMan: EntityManager)); + } + } + + private HashSet ArcRayCast(Vector2 position, Angle angle, Angle arcWidth, float range, MapId mapId, EntityUid ignore) + { + // TODO: This is pretty sucky. + var widthRad = arcWidth; + var increments = 1 + 35 * (int) Math.Ceiling(widthRad / (2 * Math.PI)); + var increment = widthRad / increments; + var baseAngle = angle - widthRad / 2; + + var resSet = new HashSet(); + + for (var i = 0; i < increments; i++) + { + var castAngle = new Angle(baseAngle + increment * i); + var res = _physics.IntersectRay(mapId, + new CollisionRay(position, castAngle.ToWorldVec(), + AttackMask), range, ignore, false).ToList(); + + if (res.Count != 0) + { + resSet.Add(res[0].HitEntity); + } + } + + return resSet; + } + + private void PlayHitSound(EntityUid target, EntityUid? user, string? type, SoundSpecifier? hitSoundOverride, SoundSpecifier? hitSound) + { + var playedSound = false; + + // Play sound based off of highest damage type. + if (TryComp(target, out var damageSoundComp)) + { + if (type == null && damageSoundComp.NoDamageSound != null) + { + Audio.PlayPredicted(damageSoundComp.NoDamageSound, target, user, AudioParams.Default.WithVariation(DamagePitchVariation)); + playedSound = true; + } + else if (type != null && damageSoundComp.SoundTypes?.TryGetValue(type, out var damageSoundType) == true) + { + Audio.PlayPredicted(damageSoundType, target, user, AudioParams.Default.WithVariation(DamagePitchVariation)); + playedSound = true; + } + else if (type != null && damageSoundComp.SoundGroups?.TryGetValue(type, out var damageSoundGroup) == true) + { + Audio.PlayPredicted(damageSoundGroup, target, user, AudioParams.Default.WithVariation(DamagePitchVariation)); + playedSound = true; + } + } + + // Use weapon sounds if the thing being hit doesn't specify its own sounds. + if (!playedSound) + { + if (hitSoundOverride != null) + { + Audio.PlayPredicted(hitSoundOverride, target, user, AudioParams.Default.WithVariation(DamagePitchVariation)); + playedSound = true; + } + else if (hitSound != null) + { + Audio.PlayPredicted(hitSound, target, user, AudioParams.Default.WithVariation(DamagePitchVariation)); + playedSound = true; + } + } + + // Fallback to generic sounds. + if (!playedSound) + { + switch (type) + { + // Unfortunately heat returns caustic group so can't just use the damagegroup in that instance. + case "Burn": + case "Heat": + case "Cold": + Audio.PlayPvs(new SoundPathSpecifier("/Audio/Items/welder.ogg"), target, AudioParams.Default.WithVariation(DamagePitchVariation)); + break; + // No damage, fallback to tappies + case null: + Audio.PlayPvs(new SoundPathSpecifier("/Audio/Weapons/tap.ogg"), target, AudioParams.Default.WithVariation(DamagePitchVariation)); + break; + case "Brute": + Audio.PlayPvs(new SoundPathSpecifier("/Audio/Weapons/smash.ogg"), target, AudioParams.Default.WithVariation(DamagePitchVariation)); + break; + } + } + } + + public static string? GetHighestDamageSound(DamageSpecifier modifiedDamage, IPrototypeManager protoManager) + { + var groups = modifiedDamage.GetDamagePerGroup(protoManager); + + // Use group if it's exclusive, otherwise fall back to type. + if (groups.Count == 1) + { + return groups.Keys.First(); + } + + var highestDamage = FixedPoint2.Zero; + string? highestDamageType = null; + + foreach (var (type, damage) in modifiedDamage.DamageDict) + { + if (damage <= highestDamage) + continue; + + highestDamageType = type; + } + + return highestDamageType; } protected virtual bool DoDisarm(EntityUid user, DisarmAttackEvent ev, MeleeWeaponComponent component, ICommonSession? session) @@ -348,6 +678,8 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem user == ev.Target) return false; + // Play a sound to give instant feedback; same with playing the animations + Audio.PlayPredicted(component.SwingSound, component.Owner, user); return true; }