Files
wwdpublic/Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs
Spatison 5d347ebb94 Upstream 08.03-09.03 (#299)
* Grabbing Fixes / Table Slam (#1889)

# Description
Ports several fixes + Tabling from
[/Goob-Station#1922](https://github.com/Goob-Station/Goob-Station/pull/1922)
Tabling is pretty much 1:1 with how it is from SS13

## This shit is so code
Required before I can port [Martial
Arts](https://github.com/Goob-Station/Goob-Station/pull/1868)

# TODO
* [ ] Await merge
* [ ] Gaming

# Media

![CQC](https://github.com/user-attachments/assets/dc202ce1-ec97-4448-b8bc-71b9a44a608f)

# Changelog
🆑 Eagle

* add: Table slamming. Harm a table when you have someone harm choked,
see what happens.
* tweak: Grab throw damage to other entities is now based on the thrown
entities kinetic energy. No more mouse wrecking balls.
* tweak: You can now escape from a soft grab by just walking away.
* tweak: You can no longer grab someone else while your being grabbed.
* tweak: Mass now effects grab release attempts.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a table slam mechanic that brings dynamic combat
interactions. Characters and objects can now be “tabled” with associated
damage, stamina effects, and paralysis chance.
- Added new interactive states for pullable entities, enriching
environmental and combat engagements.

- **Gameplay Improvements**
- Refined pulling and throwing mechanics to enhance collision handling
and damage calculations, resulting in more impactful throw actions and
balanced kinetic responses.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

(cherry picked from commit 71147f8295c5c817b56d52c5d2a38acced2f14b9)

* Automatic Changelog Update (#1889)

(cherry picked from commit 434ce42a8a0739ff0873c4c02bfe83ed39c857e9)

* Fix UI Crap (#1888)

I have no idea if this fixes the issues, and I have not checked if it
does. But this is the only thing we're missing that wasn't related to
other unrelated stuff.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Improved the initialization process for several in-game user
interfaces, ensuring that all essential functionalities load
consistently when accessed.
- **New Features**
- Enhanced the voice mask configuration panel to automatically present
available speech verb options, streamlining the setup process for users.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Milon <milonpl.git@proton.me>
Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
(cherry picked from commit e2fbebba312a01d9fb92eaac64190df503607f6b)

* Shuttle Spawner Airlocks (#1890)

# Description

This PR adds a variety of "Shuttle Spawning Airlocks" for certain ships
in this game that mappers might wish to use. The most important of which
are airlocks that cause a Cargo Shuttle and a Pathfinder to spawn
already docked to the station. The fact that nobody did this before was
fucking astounding to me.

# Changelog

🆑
- add: Added a variety of "Shuttle Spawning Airlocks" for mappers to
use, which can make it so that shuttles like the Cargo Shuttle,
Pathfinder, etc. Spawn already docked to the station.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a refined docking and spawning system for shuttle
operations, incorporating multiple shuttle types including cargo, dart,
infiltrator, pathfinder, and SANDropship.
- Added dedicated deployment entities to manage shuttle instantiation
effectively.
- Rolled out a new tagging framework to enhance the categorization and
identification of dockable vehicles.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

(cherry picked from commit 2f9239f6b0918fbdca1f0c48a06c3b3b76a11ab8)

* Automatic Changelog Update (#1890)

(cherry picked from commit 39eb098ebe3fcf7f283f46fadfc76545c20e667c)

* Update Credits (#1854)

This is an automated Pull Request. This PR updates the GitHub
contributors in the credits section.

Co-authored-by: SimpleStation Changelogs <SimpleStation14@users.noreply.github.com>
(cherry picked from commit ad2ebc04093388d29db758fd7e632744e4d728d8)

* Remove Outdated Description From Flash, Add One to the New Disabler A… (#1887)

<!--
This is a semi-strict format, you can add/remove sections as needed but
the order/format should be kept the same
Remove these comments before submitting
-->

# Description

<!--
Explain this PR in as much detail as applicable

Some example prompts to consider:
How might this affect the game? The codebase?
What might be some alternatives to this?
How/Who does this benefit/hurt [the game/codebase]?
-->

Accidentally left an old ExtendDescription on a flash which is no longer
accurate, and added extra descriptions to the rev manifesto and civilian
disabler while I was at it.

---

# Changelog

<!--
You can add an author after the `🆑` to change the name that appears
in the changelog (ex: `🆑 Death`)
Leaving it blank will default to your GitHub display name
This includes all available types for the changelog
-->

🆑
- add: Added extra descriptions to the revolutionary manifesto and the
civilian disabler
- fix: Fixed extenddescription on flash

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced extended, context-sensitive in-game descriptions for the
revolutionary manifesto, offering nuanced details about its use across
various roles.
- Added enhanced descriptive information for the civilian disabler
weapon, clarifying its legal ownership and accessory considerations.

- **Chores**
- Streamlined the flash item display by removing redundant extended
descriptions to improve clarity.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Timfa <timfalken@hotmail.com>
(cherry picked from commit 3e3bee060cb7eee98ae3fde7c4f7b819f16bf840)

* Automatic Changelog Update (#1887)

(cherry picked from commit 4af6dc83bd2ebded1421dadea70b6a9586776fda)

* NewMats Lathe Recipe Changes (#1873)

# Description

<!--
Explain this PR in as much detail as applicable

Some example prompts to consider:
How might this affect the game? The codebase?
What might be some alternatives to this?
How/Who does this benefit/hurt [the game/codebase]?
-->

Removes wait times from Copper, Lead, and Aluminum and reduces the wait
time for Tungsten to 0.13 (4 seconds over 30 ingots, too rare for it to
have no completiontime but too much completiontime for just one ingot).
It seems pretty ridiculous to make the former 3 recipes have a wait time
of TWO SECONDS PER INDIVIDUAL INGOT (which presents a lot of problems in
practice for salvagers) in comparison to the standard ore recipes which
have 0 wait times at all.

if the wait times are intentional then I would like you to consider
reducing them to something similar to what I did with the Tungsten- with
X seconds over 30 (or any other standard amount) ingots

---

# Changelog

🆑
- tweak: the Ore Processors now process Lead, Copper, and Aluminum
instantly, and is much faster at processing Tungsten

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Chores**
- Adjusted lathe production times for several recipes, resulting in more
accurate processing durations.
- The tungsten-based recipe now completes significantly faster (0.13
seconds versus 4 seconds).
- Three metal-based recipes have been updated to finish in shorter
durations (0.01 to 0.016 seconds).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

(cherry picked from commit d295e2535f9aa21497460279d0bfe108920c445b)

* Automatic Changelog Update (#1873)

(cherry picked from commit 8deed0c3c9d16ff0cdb956fc0ee457a5bf14f5ed)

* Revert "[Add] High-Risk Loadout Item For Warden: Power Gloves (#252)"

This reverts commit 1f936feaa8.

* Grab Intent Part 2: Martial Arts (#1891)

# Description
Finally, after 9 years in development, CQC is here.
Traitors can buy a CQC manual in the uplink, giving them access to
unarmed combos, and instant hardgrabs.
Traitors can also buy a Sleeping Carp Scroll, giving them 3 different
unarmed combos, and the ability to deflect all incoming projectiles, at
the cost of no longer being able to use ranged weapons.
The Chef can use Close-Quarters-Cooking while in the kitchen. Tiders
beware.
Security officers also have access to a Corporate Judo Belt as an
alternative to the stun baton.
The Warden starts with Krav Maga gloves in his locker, with 3 different
attacks.

## This code is, not shit perhaps?
Ports martial arts from
[/Goob-Station#1868](https://github.com/Goob-Station/Goob-Station/pull/1868)
All seems pretty well written, shouldn't be hard to add new ones in the
future.

There also exists a version of the CQC manual for the BSO. Might add to
the BSO locker if requested.

# TODO
* [x] Await reviews
* [x] Pain

# Media
Judo

https://github.com/user-attachments/assets/b0aa4d24-f5cd-478e-8358-a095d46a4572
CQC
https://youtu.be/c0EJfbwqil8
Sleeping Carp

https://github.com/user-attachments/assets/a16ec334-9f9a-4820-b4f1-32a0cc598c67

https://github.com/user-attachments/assets/3e2bfc95-7c92-46f6-9b7c-b1e6596540c7

# Changelog
🆑 Eagle

* add: Added Corporate Judo, CQC, Sleeping Carp, and Krav Maga martial
arts with unique abilities.
* add: The Chef has been given Close Quarters Cooking in the Kitchen and
Bar. Tiders beware.

---------

Signed-off-by: Eagle-0 <114363363+Eagle-0@users.noreply.github.com>

(cherry picked from commit 68872f85c8b2227e871667caed2289042edd0d7b)

* Automatic Changelog Update (#1891)

(cherry picked from commit 9e3ad56873aedb7a7d0fff6037f9aaf0026897c0)

* Bug Fix: Fix Roboticist Airlock Sprite Error (#1899)

<!--
This is a semi-strict format, you can add/remove sections as needed but
the order/format should be kept the same
Remove these comments before submitting
-->

# Description

Description.

A fix for [this
issue](https://github.com/Simple-Station/Einstein-Engines/issues/1872)

Fixed bug of the painted roboticist airlock displaying the windowed
counterpart instead of the standard one.

# TODO

<!--
A list of everything you have to do before this PR is "complete"
You probably won't have to complete everything before merging but it's
good to leave future references
-->

- [ ] Task
- [x] Completed Task

---

<!--
This is default collapsed, readers click to expand it and see all your
media
The PR media section can get very large at times, so this is a good way
to keep it clean
The title is written using HTML tags
The title must be within the <summary> tags or you won't see it
-->

<details><summary><h1>Media</h1></summary>

https://github.com/user-attachments/assets/fbe4c85f-c876-4e29-9c8d-cf95314e737f

</details>

---

# Changelog

<!--
You can add an author after the `🆑` to change the name that appears
in the changelog (ex: `🆑 Death`)
Leaving it blank will default to your GitHub display name
This includes all available types for the changelog
-->

🆑
- fix: Fixed bug of the painted roboticist airlock displaying the
windowed counterpart instead of the standard one.

(cherry picked from commit a3b823b0059a67767f0adf27ec65151d2f73a1fd)

* Automatic Changelog Update (#1899)

(cherry picked from commit ca839d18fbcdb85d4d1e60f2acad68fda02e1634)

* Fixes SM Being Started on Round Start (#1901)

# Description

To stop the SM from getting activated without something being thrown
into the SM or by having emitters hit the SM.

---

# TODO

- [x] Fix the SM by starting on its own.
---

# Changelog

🆑
- fix: SM no longer starts on round start.
- fix: SM will no longer delam from spacing unless it's activated.

---------

Co-authored-by: Nathaniel Adams <60526456+Nathaniel-Adams@users.noreply.github.com>
(cherry picked from commit 234ac6119f999ff2bfaabee6b93b5fa75c61c0fa)

* Automatic Changelog Update (#1901)

(cherry picked from commit 3a0c67ba9c6aa8341e9bfd529bb58818164e20c8)

* Tc rebalance

---------

Co-authored-by: Eagle-0 <114363363+Eagle-0@users.noreply.github.com>
Co-authored-by: SimpleStation Changelogs <SimpleStation14@users.noreply.github.com>
Co-authored-by: VMSolidus <evilexecutive@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Timfa <timfalken@hotmail.com>
Co-authored-by: RadsammyT <32146976+RadsammyT@users.noreply.github.com>
Co-authored-by: Paulo Artur Pinheiro Viana Villaça <112904295+AlgumCorrupto@users.noreply.github.com>
Co-authored-by: Solaris <60526456+SolarisBirb@users.noreply.github.com>
2025-03-09 14:01:34 +02:00

934 lines
35 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
using Content.Shared._Goobstation.MartialArts.Events; // Goobstation - Martial Arts
using Content.Shared._White;
using Content.Shared.ActionBlocker;
using Content.Shared.Administration.Logs;
using Content.Shared.CombatMode;
using Content.Shared.Contests;
using Content.Shared.Coordinates;
using Content.Shared.Damage;
using Content.Shared.Damage.Components;
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.Inventory.VirtualItem;
using Content.Shared.Item.ItemToggle.Components;
using Content.Shared.Physics;
using Content.Shared.Popups;
using Content.Shared.Throwing;
using Content.Shared.Weapons.Melee.Components;
using Content.Shared.Weapons.Melee.Events;
using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Weapons.Ranged.Events;
using Content.Shared.Weapons.Ranged.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.Map;
using Robust.Shared.Physics;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using ItemToggleMeleeWeaponComponent = Content.Shared.Item.ItemToggle.Components.ItemToggleMeleeWeaponComponent;
namespace Content.Shared.Weapons.Melee;
public abstract class SharedMeleeWeaponSystem : EntitySystem
{
[Dependency] protected readonly ISharedAdminLogManager AdminLogger = default!;
[Dependency] protected readonly ActionBlockerSystem Blocker = default!;
[Dependency] protected readonly SharedCombatModeSystem CombatMode = default!;
[Dependency] protected readonly DamageableSystem Damageable = default!;
[Dependency] protected readonly SharedInteractionSystem Interaction = default!;
[Dependency] protected readonly IMapManager MapManager = default!;
[Dependency] protected readonly SharedPopupSystem PopupSystem = default!;
[Dependency] protected readonly IGameTiming Timing = default!;
[Dependency] protected readonly SharedTransformSystem TransformSystem = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly MeleeSoundSystem _meleeSound = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly IPrototypeManager _protoManager = default!;
[Dependency] private readonly StaminaSystem _stamina = default!;
[Dependency] private readonly ContestsSystem _contests = default!;
private const int AttackMask = (int) (CollisionGroup.MobMask | CollisionGroup.Opaque);
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<MeleeWeaponComponent, HandSelectedEvent>(OnMeleeSelected);
SubscribeLocalEvent<BonusMeleeDamageComponent, GetMeleeDamageEvent>(OnGetBonusMeleeDamage);
SubscribeLocalEvent<BonusMeleeDamageComponent, GetHeavyDamageModifierEvent>(OnGetBonusHeavyDamageModifier);
SubscribeLocalEvent<BonusMeleeAttackRateComponent, GetMeleeAttackRateEvent>(OnGetBonusMeleeAttackRate);
SubscribeLocalEvent<ItemToggleMeleeWeaponComponent, ItemToggledEvent>(OnItemToggle);
SubscribeAllEvent<HeavyAttackEvent>(OnHeavyAttack);
SubscribeAllEvent<LightAttackEvent>(OnLightAttack);
SubscribeAllEvent<DisarmAttackEvent>(OnDisarmAttack);
SubscribeAllEvent<StopAttackEvent>(OnStopAttack);
SubscribeLocalEvent<MeleeWeaponComponent, GunShotEvent>(OnMeleeShot); // WWDP
#if DEBUG
SubscribeLocalEvent<MeleeWeaponComponent,
MapInitEvent> (OnMapInit);
}
private void OnMapInit(EntityUid uid, MeleeWeaponComponent component, MapInitEvent args)
{
if (component.NextAttack > Timing.CurTime)
Log.Warning($"Initializing a map that contains an entity that is on cooldown. Entity: {ToPrettyString(uid)}");
#endif
}
private void OnMeleeSelected(EntityUid uid, MeleeWeaponComponent component, HandSelectedEvent args)
{
var attackRate = GetAttackRate(uid, args.User, component);
if (attackRate.Equals(0f))
return;
if (!component.ResetOnHandSelected)
return;
if (Paused(uid))
return;
// If someone swaps to this weapon then reset its cd.
var curTime = Timing.CurTime;
var minimum = curTime + TimeSpan.FromSeconds(attackRate);
if (minimum < component.NextAttack)
return;
component.NextAttack = minimum;
Dirty(uid, component);
}
private void OnMeleeShot(EntityUid uid, MeleeWeaponComponent component, ref GunShotEvent args)
{
if (!TryComp<GunComponent>(uid, out var gun))
return;
if (gun.NextFire > component.NextAttack)
{
component.NextAttack = gun.NextFire;
Dirty(uid, component);
}
}
private void OnGetBonusMeleeDamage(EntityUid uid, BonusMeleeDamageComponent component, ref GetMeleeDamageEvent args)
{
if (component.BonusDamage != null)
args.Damage += component.BonusDamage;
if (component.DamageModifierSet != null)
args.Modifiers.Add(component.DamageModifierSet);
}
private void OnGetBonusHeavyDamageModifier(EntityUid uid, BonusMeleeDamageComponent component, ref GetHeavyDamageModifierEvent args)
{
args.DamageModifier += component.HeavyDamageFlatModifier;
args.Multipliers *= component.HeavyDamageMultiplier;
}
private void OnGetBonusMeleeAttackRate(EntityUid uid, BonusMeleeAttackRateComponent component, ref GetMeleeAttackRateEvent args)
{
args.Rate += component.FlatModifier;
args.Multipliers *= component.Multiplier;
}
private void OnStopAttack(StopAttackEvent msg, EntitySessionEventArgs args)
{
var user = args.SenderSession.AttachedEntity;
if (user == null)
return;
if (!TryGetWeapon(user.Value, out var weaponUid, out var weapon) ||
weaponUid != GetEntity(msg.Weapon))
{
return;
}
if (!weapon.Attacking)
return;
weapon.Attacking = false;
Dirty(weaponUid, weapon);
}
private void OnLightAttack(LightAttackEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not {} user)
return;
if (!TryGetWeapon(user, out var weaponUid, out var weapon) ||
weaponUid != GetEntity(msg.Weapon))
{
return;
}
AttemptAttack(user, weaponUid, weapon, msg, args.SenderSession);
}
private void OnHeavyAttack(HeavyAttackEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not {} user)
return;
if (!TryGetWeapon(user, out var weaponUid, out var weapon) ||
weaponUid != GetEntity(msg.Weapon) ||
!weapon.CanWideSwing) // Goobstation Change
{
return;
}
AttemptAttack(user, weaponUid, weapon, msg, args.SenderSession);
}
private void OnDisarmAttack(DisarmAttackEvent msg, EntitySessionEventArgs args)
{
if (args.SenderSession.AttachedEntity is not {} user)
return;
if (TryGetWeapon(user, out var weaponUid, out var weapon))
AttemptAttack(user, weaponUid, weapon, msg, args.SenderSession);
}
/// <summary>
/// Gets the total damage a weapon does, including modifiers like wielding and enablind/disabling
/// </summary>
public DamageSpecifier GetDamage(EntityUid uid, EntityUid user, MeleeWeaponComponent? component = null)
{
if (!Resolve(uid, ref component, false))
return new DamageSpecifier();
var ev = new GetMeleeDamageEvent(uid, new(component.Damage), new(), user, component.ResistanceBypass);
RaiseLocalEvent(uid, ref ev);
if (component.ContestArgs is not null)
ev.Damage *= _contests.ContestConstructor(user, component.ContestArgs);
return DamageSpecifier.ApplyModifierSets(ev.Damage, ev.Modifiers);
}
public float GetAttackRate(EntityUid uid, EntityUid user, MeleeWeaponComponent? component = null)
{
if (!Resolve(uid, ref component))
return 0;
var ev = new GetMeleeAttackRateEvent(uid, component.AttackRate, 1, user);
RaiseLocalEvent(uid, ref ev);
return ev.Rate * ev.Multipliers;
}
public FixedPoint2 GetHeavyDamageModifier(EntityUid uid, EntityUid user, MeleeWeaponComponent? component = null)
{
if (!Resolve(uid, ref component))
return FixedPoint2.Zero;
var ev = new GetHeavyDamageModifierEvent(uid, component.ClickDamageModifier, 1, user);
RaiseLocalEvent(uid, ref ev);
return ev.DamageModifier * ev.Multipliers * component.HeavyDamageBaseModifier;
}
public bool GetResistanceBypass(EntityUid uid, EntityUid user, MeleeWeaponComponent? component = null)
{
if (!Resolve(uid, ref component))
return false;
var ev = new GetMeleeDamageEvent(uid, new(component.Damage), new(), user, component.ResistanceBypass);
RaiseLocalEvent(uid, ref ev);
return ev.ResistanceBypass;
}
public bool TryGetWeapon(EntityUid entity, out EntityUid weaponUid, [NotNullWhen(true)] out MeleeWeaponComponent? melee)
{
weaponUid = default;
melee = null;
var ev = new GetMeleeWeaponEvent();
RaiseLocalEvent(entity, ev);
if (ev.Handled)
{
if (TryComp(ev.Weapon, out melee))
{
weaponUid = ev.Weapon.Value;
return true;
}
return false;
}
// Use inhands entity if we got one.
if (EntityManager.TryGetComponent(entity, out HandsComponent? hands) &&
hands.ActiveHandEntity is { } held)
{
// Make sure the entity is a weapon AND it doesn't need
// to be equipped to be used (E.g boxing gloves).
if (EntityManager.TryGetComponent(held, out melee) &&
!melee.MustBeEquippedToUse)
{
weaponUid = held;
return true;
}
if (!HasComp<VirtualItemComponent>(held))
return false;
}
// Use hands clothing if applicable.
if (_inventory.TryGetSlotEntity(entity, "gloves", out var gloves) &&
TryComp<MeleeWeaponComponent>(gloves, out var glovesMelee))
{
weaponUid = gloves.Value;
melee = glovesMelee;
return true;
}
// Use our own melee
if (TryComp(entity, out melee))
{
weaponUid = entity;
return true;
}
return false;
}
public void AttemptLightAttackMiss(EntityUid user, EntityUid weaponUid, MeleeWeaponComponent weapon, EntityCoordinates coordinates)
{
AttemptAttack(user, weaponUid, weapon, new LightAttackEvent(null, GetNetEntity(weaponUid), GetNetCoordinates(coordinates)), null);
}
public bool AttemptLightAttack(EntityUid user, EntityUid weaponUid, MeleeWeaponComponent weapon, EntityUid target)
{
if (!TryComp(target, out TransformComponent? targetXform))
return false;
return AttemptAttack(user, weaponUid, weapon, new LightAttackEvent(GetNetEntity(target), GetNetEntity(weaponUid), GetNetCoordinates(targetXform.Coordinates)), null);
}
public bool AttemptDisarmAttack(EntityUid user, EntityUid weaponUid, MeleeWeaponComponent weapon, EntityUid target)
{
if (!TryComp(target, out TransformComponent? targetXform))
return false;
return AttemptAttack(user, weaponUid, weapon, new DisarmAttackEvent(GetNetEntity(target), GetNetCoordinates(targetXform.Coordinates)), null);
}
/// <summary>
/// Called when a windup is finished and an attack is tried.
/// </summary>
/// <returns>True if attack successful</returns>
private bool AttemptAttack(EntityUid user, EntityUid weaponUid, MeleeWeaponComponent weapon, AttackEvent attack, ICommonSession? session)
{
var curTime = Timing.CurTime;
if (weapon.NextAttack > curTime)
return false;
if (!CombatMode.IsInCombatMode(user))
return false;
var fireRateSwingModifier = 1f;
EntityUid? target = null;
switch (attack)
{
case LightAttackEvent light:
if (light.Target != null && !TryGetEntity(light.Target, out target))
{
// Target was lightly attacked & deleted.
return false;
}
if (!Blocker.CanAttack(user, target, (weaponUid, weapon)))
return false;
// Can't self-attack if you're the weapon
if (weaponUid == target)
return false;
break;
case HeavyAttackEvent:
fireRateSwingModifier = weapon.HeavyRateModifier;
break;
case DisarmAttackEvent disarm:
if (disarm.Target != null && !TryGetEntity(disarm.Target, out target))
{
// Target was lightly attacked & deleted.
return false;
}
if (!Blocker.CanAttack(user, target, (weaponUid, weapon), true))
return false;
break;
default:
if (!Blocker.CanAttack(user, weapon: (weaponUid, weapon)))
return false;
break;
}
// Windup time checked elsewhere.
var fireRate = TimeSpan.FromSeconds(GetAttackRate(weaponUid, user, weapon) * fireRateSwingModifier);
var swings = 0;
// TODO: If we get autoattacks then probably need a shotcounter like guns so we can do timing properly.
if (weapon.NextAttack < curTime)
weapon.NextAttack = curTime;
while (weapon.NextAttack <= curTime)
{
weapon.NextAttack += fireRate;
swings++;
}
Dirty(weaponUid, weapon);
// Do this AFTER attack so it doesn't spam every tick
var ev = new AttemptMeleeEvent
{
PlayerUid = user
};
RaiseLocalEvent(weaponUid, ref ev);
if (ev.Cancelled)
{
if (ev.Message != null)
{
PopupSystem.PopupClient(ev.Message, weaponUid, user);
}
return false;
}
// Attack confirmed
for (var i = 0; i < swings; i++)
{
string animation = weapon.Animation;
Angle spriteRotation = weapon.AnimationRotation;
switch (attack)
{
case LightAttackEvent light:
DoLightAttack(user, light, weaponUid, weapon, session);
animation = weapon.Animation;
break;
case DisarmAttackEvent disarm:
if (!DoDisarm(user, disarm, weaponUid, weapon, session))
return false;
animation = weapon.Animation;
break;
case HeavyAttackEvent heavy:
if (!DoHeavyAttack(user, heavy, weaponUid, weapon, session))
return false;
animation = weapon.WideAnimation;
spriteRotation = weapon.WideAnimationRotation;
break;
default:
throw new NotImplementedException();
}
DoLungeAnimation(user, weaponUid, weapon.Angle, TransformSystem.ToMapCoordinates(GetCoordinates(attack.Coordinates)), weapon.Range, animation, spriteRotation);
}
var attackEv = new MeleeAttackEvent(weaponUid);
RaiseLocalEvent(user, ref attackEv);
weapon.Attacking = true;
return true;
}
protected abstract bool InRange(EntityUid user, EntityUid target, float range, ICommonSession? session);
protected bool CanDoLightAttack(EntityUid user, [NotNullWhen(true)] EntityUid? target, MeleeWeaponComponent component, [NotNullWhen(true)] out TransformComponent? targetXform, ICommonSession? session = null)
{
targetXform = null;
return !Deleted(target) &&
HasComp<DamageableComponent>(target) &&
TryComp<TransformComponent>(target, out targetXform) &&
// Not in LOS.
InRange(user, target.Value, component.Range, session);
}
protected virtual void DoLightAttack(EntityUid user, LightAttackEvent ev, EntityUid meleeUid, MeleeWeaponComponent component, ICommonSession? session)
{
// If I do not come back later to fix Light Attacks being Heavy Attacks you can throw me in the spider pit -Errant
var damage = GetDamage(meleeUid, user, component) * GetHeavyDamageModifier(meleeUid, user, component);
var target = GetEntity(ev.Target);
var resistanceBypass = GetResistanceBypass(meleeUid, user, component);
// For consistency with wide attacks stuff needs damageable.
if (!CanDoLightAttack(user, target, component, out var targetXform, session))
{
// Leave IsHit set to true, because the only time it's set to false
// is when a melee weapon is examined. Misses are inferred from an
// empty HitEntities.
// TODO: This needs fixing
if (meleeUid == user)
{
AdminLogger.Add(LogType.MeleeHit,
LogImpact.Low,
$"{ToPrettyString(user):actor} melee attacked (light) using their hands and missed");
}
else
{
AdminLogger.Add(LogType.MeleeHit,
LogImpact.Low,
$"{ToPrettyString(user):actor} melee attacked (light) using {ToPrettyString(meleeUid):tool} and missed");
}
var missEvent = new MeleeHitEvent(new List<EntityUid>(), user, meleeUid, damage, null);
RaiseLocalEvent(meleeUid, missEvent);
_meleeSound.PlaySwingSound(user, meleeUid, component);
return;
}
// 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<EntityUid> { target.Value }, user, meleeUid, damage, null);
RaiseLocalEvent(meleeUid, hitEvent);
if (hitEvent.Handled)
return;
var targets = new List<EntityUid>(1)
{
target.Value
};
var weapon = GetEntity(ev.Weapon);
// We skip weapon -> target interaction, as forensics system applies DNA on hit
Interaction.DoContactInteraction(user, 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, target);
// For stuff that cares about it being attacked.
var attackedEvent = new AttackedEvent(meleeUid, user, targetXform.Coordinates);
RaiseLocalEvent(target.Value, attackedEvent);
var modifiedDamage = DamageSpecifier.ApplyModifierSets(damage + hitEvent.BonusDamage + attackedEvent.BonusDamage, hitEvent.ModifiersList);
var damageResult = Damageable.TryChangeDamage(target, modifiedDamage, origin:user, ignoreResistances: resistanceBypass, partMultiplier: component.ClickPartDamageMultiplier);
var comboEv = new ComboAttackPerformedEvent(user, target.Value, meleeUid, ComboAttackType.Harm);
RaiseLocalEvent(user, comboEv);
if (damageResult is {Empty: false})
{
// 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(target.Value, (bluntDamage * component.BluntStaminaDamageFactor).Float(), visual: false, source: user, with: meleeUid == user ? null : meleeUid);
}
if (meleeUid == user)
{
AdminLogger.Add(LogType.MeleeHit,
LogImpact.Medium,
$"{ToPrettyString(user):actor} melee attacked (light) {ToPrettyString(target.Value):subject} using their hands and dealt {damageResult.GetTotal():damage} damage");
}
else
{
AdminLogger.Add(LogType.MeleeHit,
LogImpact.Medium,
$"{ToPrettyString(user):actor} melee attacked (light) {ToPrettyString(target.Value):subject} using {ToPrettyString(meleeUid):tool} and dealt {damageResult.GetTotal():damage} damage");
}
}
_meleeSound.PlayHitSound(target.Value, user, GetHighestDamageSound(modifiedDamage, _protoManager), hitEvent.HitSoundOverride, component.SoundHit, component.SoundNoDamage);
if (damageResult?.GetTotal() > FixedPoint2.Zero)
{
DoDamageEffect(targets, user, targetXform);
}
}
protected abstract void DoDamageEffect(List<EntityUid> targets, EntityUid? user, TransformComponent targetXform);
private bool DoHeavyAttack(EntityUid user, HeavyAttackEvent ev, EntityUid meleeUid, MeleeWeaponComponent component, ICommonSession? session)
{
// TODO: This is copy-paste as fuck with DoPreciseAttack
if (!TryComp(user, out TransformComponent? userXform))
return false;
var targetMap = TransformSystem.ToMapCoordinates(GetCoordinates(ev.Coordinates));
if (targetMap.MapId != userXform.MapID)
return false;
if (TryComp<StaminaComponent>(user, out var stamina))
{
if (stamina.CritThreshold - stamina.StaminaDamage <= component.HeavyStaminaCost)
{
PopupSystem.PopupClient(Loc.GetString("melee-heavy-no-stamina"), meleeUid, user);
return false;
}
_stamina.TakeStaminaDamage(user, component.HeavyStaminaCost, stamina, visual: false);
}
var userPos = TransformSystem.GetWorldPosition(userXform);
var direction = targetMap.Position - userPos;
var distance = Math.Min(component.Range, direction.Length());
var damage = GetDamage(meleeUid, user, component);
var entities = GetEntityList(ev.Entities);
if (entities.Count == 0)
{
if (meleeUid == user)
{
AdminLogger.Add(LogType.MeleeHit,
LogImpact.Low,
$"{ToPrettyString(user):actor} melee attacked (heavy) using their hands and missed");
}
else
{
AdminLogger.Add(LogType.MeleeHit,
LogImpact.Low,
$"{ToPrettyString(user):actor} melee attacked (heavy) using {ToPrettyString(meleeUid):tool} and missed");
}
var missEvent = new MeleeHitEvent(new List<EntityUid>(), user, meleeUid, damage, direction);
RaiseLocalEvent(meleeUid, missEvent);
// immediate audio feedback
_meleeSound.PlaySwingSound(user, meleeUid, component);
return true;
}
// Naughty input
if (entities.Count > component.MaxTargets)
{
entities.RemoveRange(component.MaxTargets, entities.Count - component.MaxTargets);
}
// Validate client
for (var i = entities.Count - 1; i >= 0; i--)
{
if (ArcRaySuccessful(entities[i],
userPos,
direction.ToWorldAngle(),
component.Angle,
distance,
userXform.MapID,
user,
session))
{
continue;
}
// Bad input
entities.RemoveAt(i);
}
var targets = new List<EntityUid>();
var damageQuery = GetEntityQuery<DamageableComponent>();
foreach (var entity in entities)
{
if (entity == user ||
!damageQuery.HasComponent(entity))
continue;
targets.Add(entity);
}
// 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, meleeUid, damage, direction);
RaiseLocalEvent(meleeUid, hitEvent);
if (hitEvent.Handled)
return true;
var weapon = GetEntity(ev.Weapon);
Interaction.DoContactInteraction(user, weapon);
// For stuff that cares about it being attacked.
foreach (var target in targets)
{
// We skip weapon -> target interaction, as forensics system applies DNA on hit
// 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);
}
var appliedDamage = new DamageSpecifier();
for (var i = targets.Count - 1; i >= 0; i--)
{
var entity = targets[i];
// We raise an attack attempt here as well,
// primarily because this was an untargeted wideswing: if a subscriber to that event cared about
// the potential target (such as for pacifism), they need to be made aware of the target here.
// In that case, just continue.
if (!Blocker.CanAttack(user, entity, (weapon, component)))
{
targets.RemoveAt(i);
continue;
}
var attackedEvent = new AttackedEvent(meleeUid, user, GetCoordinates(ev.Coordinates));
RaiseLocalEvent(entity, attackedEvent);
var modifiedDamage = DamageSpecifier.ApplyModifierSets(damage + hitEvent.BonusDamage + attackedEvent.BonusDamage, hitEvent.ModifiersList);
var damageResult = Damageable.TryChangeDamage(entity, modifiedDamage, origin: user, partMultiplier: component.HeavyPartDamageMultiplier);
var comboEv = new ComboAttackPerformedEvent(user, entity, meleeUid, ComboAttackType.HarmLight);
RaiseLocalEvent(user, comboEv);
if (damageResult != null && damageResult.GetTotal() > 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(entity, (bluntDamage * component.BluntStaminaDamageFactor).Float(), visual: false, source: user, with: meleeUid == user ? null : meleeUid);
}
appliedDamage += damageResult;
if (meleeUid == user)
{
AdminLogger.Add(LogType.MeleeHit,
LogImpact.Medium,
$"{ToPrettyString(user):actor} melee attacked (heavy) {ToPrettyString(entity):subject} using their hands and dealt {damageResult.GetTotal():damage} damage");
}
else
{
AdminLogger.Add(LogType.MeleeHit,
LogImpact.Medium,
$"{ToPrettyString(user):actor} melee attacked (heavy) {ToPrettyString(entity):subject} using {ToPrettyString(meleeUid):tool} and dealt {damageResult.GetTotal():damage} damage");
}
}
}
if (entities.Count != 0)
{
var target = entities.First();
_meleeSound.PlayHitSound(target, user, GetHighestDamageSound(appliedDamage, _protoManager), hitEvent.HitSoundOverride, component.SoundHit, component.SoundNoDamage);
}
if (appliedDamage.GetTotal() > FixedPoint2.Zero)
{
DoDamageEffect(targets, user, Transform(targets[0]));
}
return true;
}
protected HashSet<EntityUid> 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<EntityUid>();
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;
}
protected virtual bool ArcRaySuccessful(EntityUid targetUid,
Vector2 position,
Angle angle,
Angle arcWidth,
float range,
MapId mapId,
EntityUid ignore,
ICommonSession? session)
{
// Only matters for server.
return true;
}
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, EntityUid meleeUid, MeleeWeaponComponent component, ICommonSession? session)
{
var target = GetEntity(ev.Target);
if (Deleted(target) ||
user == target)
{
return false;
}
var comboEv = new ComboAttackPerformedEvent(user, target.Value, meleeUid, ComboAttackType.Disarm);
RaiseLocalEvent(user, comboEv);
if (!TryComp<CombatModeComponent>(user, out var combatMode) ||
combatMode.CanDisarm == false) // WWDP
{
return false;
}
if (!InRange(user, target.Value, component.Range, session))
{
return false;
}
// Play a sound to give instant feedback; same with playing the animations
_meleeSound.PlaySwingSound(user, meleeUid, component);
return true;
}
private void DoLungeAnimation(EntityUid user, EntityUid weapon, Angle angle, MapCoordinates coordinates, float length, string? animation, Angle spriteRotation)
{
// TODO: Assert that offset eyes are still okay.
if (!TryComp(user, out TransformComponent? userXform))
return;
var invMatrix = TransformSystem.GetInvWorldMatrix(userXform);
var localPos = Vector2.Transform(coordinates.Position, invMatrix);
if (localPos.LengthSquared() <= 0f)
return;
localPos = userXform.LocalRotation.RotateVec(localPos);
// We'll play the effect just short visually so it doesn't look like we should be hitting but actually aren't.
const float bufferLength = 0.2f;
var visualLength = length - bufferLength;
if (localPos.Length() > visualLength)
localPos = localPos.Normalized() * visualLength;
DoLunge(user, weapon, angle, localPos, animation, spriteRotation);
}
public abstract void DoLunge(EntityUid user, EntityUid weapon, Angle angle, Vector2 localPos, string? animation, Angle spriteRotation, bool predicted = true);
/// <summary>
/// Used to update the MeleeWeapon component on item toggle.
/// </summary>
private void OnItemToggle(EntityUid uid, ItemToggleMeleeWeaponComponent itemToggleMelee, ItemToggledEvent args)
{
if (!TryComp(uid, out MeleeWeaponComponent? meleeWeapon))
return;
if (args.Activated)
{
if (itemToggleMelee.ActivatedDamage != null)
{
//Setting deactivated damage to the weapon's regular value before changing it.
itemToggleMelee.DeactivatedDamage ??= meleeWeapon.Damage;
meleeWeapon.Damage = itemToggleMelee.ActivatedDamage;
Dirty(uid, meleeWeapon);
}
meleeWeapon.SoundHit = itemToggleMelee.ActivatedSoundOnHit;
if (itemToggleMelee.ActivatedSoundOnHitNoDamage != null)
{
//Setting the deactivated sound on no damage hit to the weapon's regular value before changing it.
itemToggleMelee.DeactivatedSoundOnHitNoDamage ??= meleeWeapon.SoundNoDamage;
meleeWeapon.SoundNoDamage = itemToggleMelee.ActivatedSoundOnHitNoDamage;
Dirty(uid, meleeWeapon);
}
if (itemToggleMelee.ActivatedSoundOnSwing != null)
{
//Setting the deactivated sound on no damage hit to the weapon's regular value before changing it.
itemToggleMelee.DeactivatedSoundOnSwing ??= meleeWeapon.SoundSwing;
meleeWeapon.SoundSwing = itemToggleMelee.ActivatedSoundOnSwing;
Dirty(uid, meleeWeapon);
}
if (itemToggleMelee.DeactivatedSecret)
{
meleeWeapon.Hidden = false;
}
}
else
{
if (itemToggleMelee.DeactivatedDamage != null)
{
meleeWeapon.Damage = itemToggleMelee.DeactivatedDamage;
Dirty(uid, meleeWeapon);
}
meleeWeapon.SoundHit = itemToggleMelee.DeactivatedSoundOnHit;
Dirty(uid, meleeWeapon);
if (itemToggleMelee.DeactivatedSoundOnHitNoDamage != null)
{
meleeWeapon.SoundNoDamage = itemToggleMelee.DeactivatedSoundOnHitNoDamage;
Dirty(uid, meleeWeapon);
}
if (itemToggleMelee.DeactivatedSoundOnSwing != null)
{
meleeWeapon.SoundSwing = itemToggleMelee.DeactivatedSoundOnSwing;
Dirty(uid, meleeWeapon);
}
if (itemToggleMelee.DeactivatedSecret)
{
meleeWeapon.Hidden = true;
}
}
}
}