More Gun Reworks: Manual Action Edition (#2165)

# Description

This PR is yet another step on an ongoing set of refactors for guns.
This restores the functionality for weapons to operate as "Manually
Cycled", IE: Bolt Action or Pump Action. I've also fixed some older bugs
related to firearms, such as manually loading cartridges not updating
the ammo display, and manually cycling the gun not ejecting the
cartridge. And even further, fixing instances where guns would fail to
correctly eject cartridges using ejection force variables set by the gun
itself.

There's also some other small balancing changes to other guns that make
use of the DamageModifier datafield added in my previous rework. Namely
the Cobra and Mosin both use this datafield now. The .25 cartridge has
had its damage reduced to 15 per shot(from 19), so that it is properly
smaller than the .35 cartridge. This is largely to address issues of the
FPA-90 and R25 rifles being kinda overpowered in their damage output. To
keep this nerf from affecting the Cobra however, the Cobra has picked up
an innate damage modifier that restores it to the original damage
output.

The Mosin's been rebalanced around its hunting rifle powerhouse
aesthetic, it has a beefy damage modifier that brings it up to 42 damage
per shot. This is acting as a fun tradeoff for it having only a 5 round
internal magazine, and being changed to a bolt action weapon. It's still
insanely cheap, to a point a traitor or a head revolutionary can afford
40 of them. This 42 damage calculation is specifically set such that you
can consistently drop "wound into crit" unarmored crew in 2 shots (84+
HEAVY bleed will drop them consistently), while shooting security
requires the full 5 rounds.

The general gist of what these bolt action guns is that they'll
typically have better characteristics for "Single source damage" than
their non-bolt action counterparts. While for Shotguns, we can now make
shotguns follow various common shotgun tropes that players expect from
video games, such as the "Super shotgun" that obliterates anything at
pointblank, or the "Street sweeper" that trades some of its accuracy for
extra room coverage. Or the "hunting shotgun" that beats all of them at
midrange.

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

https://github.com/user-attachments/assets/00ae2b34-f3f2-4fd9-adad-7147507a6c31

</p>
</details>

# Changelog

🆑
- add: Added "Bolt Action" or "Pump Action" gun options.
- add: Added new sound fx for shotshells when landing on the ground or
colliding with objects after being thrown.
- tweak: Kammerer and Enforcer are now pump action shotguns, featuring a
choke that gives them 50% tighter spreads in return for needing to be
pumped (press Z) after each shot.
- tweak: Mosin Nagant is now a proper bolt action rifle. The bolt must
be worked manually (press Z) after each shot. The fun tradeoff is that
it now hits like a truck with 42 damage a shot.
- tweak: Bulldog shotgun now has a wide choke, it fires 33% more
projectiles per shotshell, while also having a 50% wider spread. Cover
the station hallways in lead. Pairs nicely with birdshot if you really
want to sweep rooms.
- tweak: R25 and FPA-90 both now deal 15 damage per shot, instead of 19.
- fix: Fixed guns not correctly ejecting cartridges(they were instead
dropping them at your feet).
- fix: Fixed guns not updating the ammo counter UI when loaded manually.

(cherry picked from commit a86362b8ad712678f1316e2cd55f5e888736f718)
This commit is contained in:
VMSolidus
2025-04-10 18:00:21 +03:00
committed by Spatison
parent 548e8c836b
commit a040f93ced
18 changed files with 138 additions and 82 deletions

View File

@@ -20,7 +20,7 @@ public sealed partial class GunSystem
}
}
protected override void Cycle(EntityUid uid, BallisticAmmoProviderComponent component, MapCoordinates coordinates)
protected override void Cycle(EntityUid uid, BallisticAmmoProviderComponent component, MapCoordinates coordinates, GunComponent? gunComponent)
{
if (!Timing.IsFirstTimePredicted)
return;

View File

@@ -1,5 +1,5 @@
using Content.Server.Stack;
using Content.Shared.Hands.EntitySystems; // WWDP
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Stacks;
using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Weapons.Ranged.Events;
@@ -12,9 +12,11 @@ public sealed partial class GunSystem
[Dependency] private readonly StackSystem _stack = default!; // WD EDIT
[Dependency] private readonly SharedHandsSystem _handsSystem = default!; // WWDP
protected override void Cycle(EntityUid uid, BallisticAmmoProviderComponent component, MapCoordinates coordinates)
protected override void Cycle(EntityUid uid, BallisticAmmoProviderComponent component, MapCoordinates coordinates, GunComponent? gunComponent)
{
EntityUid? ent = null;
if (!Resolve(uid, ref gunComponent, false))
return;
// TODO: Combine with TakeAmmo
if (component.Entities.Count > 0)
@@ -24,6 +26,7 @@ public sealed partial class GunSystem
Containers.Remove(existing, component.Container);
EnsureShootable(existing);
EjectCartridge(existing, gunComp: gunComponent);
}
else if (component.UnspawnedCount > 0)
{
@@ -33,7 +36,7 @@ public sealed partial class GunSystem
}
if (ent != null)
EjectCartridge(ent.Value);
EjectCartridge(ent.Value, gunComp: gunComponent);
var cycledEvent = new GunCycledEvent();
RaiseLocalEvent(uid, ref cycledEvent);

View File

@@ -66,4 +66,16 @@ public sealed partial class BallisticAmmoProviderComponent : Component
/// </summary>
[DataField]
public TimeSpan FillDelay = TimeSpan.FromSeconds(0.5);
/// <summary>
/// Is ammo ejected after each shot, or not.
/// </summary>
[DataField]
public bool AutoCycle = true;
/// <summary>
/// Is the gun ready to shoot; if AutoCycle is true then this will always stay true and not need to be manually done.
/// </summary>
[DataField, AutoNetworkedField]
public bool Cycled = true;
}

View File

@@ -351,7 +351,10 @@ public sealed partial class GunComponent : Component
public float EjectionForce = 0.04f;
[DataField]
public float EjectionSpeed = 5f;
public float EjectionSpeed = 20f;
[DataField]
public float EjectAngleOffset = 3.7f;
// WD EDIT START
[DataField]

View File

@@ -31,6 +31,7 @@ public abstract partial class SharedGunSystem
SubscribeLocalEvent<BallisticAmmoProviderComponent, GetAmmoCountEvent>(OnBallisticAmmoCount);
SubscribeLocalEvent<BallisticAmmoProviderComponent, ExaminedEvent>(OnBallisticExamine);
SubscribeLocalEvent<BallisticAmmoProviderComponent, GetVerbsEvent<Verb>>(OnBallisticVerb);
SubscribeLocalEvent<BallisticAmmoProviderComponent, GetVerbsEvent<InteractionVerb>>(AddInteractionVerb); // WWDP
SubscribeLocalEvent<BallisticAmmoProviderComponent, GetVerbsEvent<AlternativeVerb>>(AddAlternativeVerb); // WWDP
SubscribeLocalEvent<BallisticAmmoProviderComponent, InteractUsingEvent>(OnBallisticInteractUsing);
@@ -50,7 +51,9 @@ public abstract partial class SharedGunSystem
private void OnBallisticInteractUsing(EntityUid uid, BallisticAmmoProviderComponent component, InteractUsingEvent args)
{
if (args.Handled)
if (args.Handled
|| _whitelistSystem.IsWhitelistFailOrNull(component.Whitelist, args.Used)
|| GetBallisticShots(component) >= component.Capacity)
return;
if (_whitelistSystem.IsWhitelistFailOrNull(component.Whitelist, args.Used))
@@ -82,23 +85,20 @@ public abstract partial class SharedGunSystem
// Not predicted so
Audio.PlayPredicted(component.SoundInsert, uid, args.User);
args.Handled = true;
component.Cycled = true;
UpdateAmmoCount(uid);
UpdateBallisticAppearance(uid, component);
Dirty(uid, component);
}
private void OnBallisticAfterInteract(EntityUid uid, BallisticAmmoProviderComponent component, AfterInteractEvent args)
{
if (args.Handled ||
!component.MayTransfer ||
!Timing.IsFirstTimePredicted ||
args.Target == null ||
args.Used == args.Target ||
Deleted(args.Target) ||
!TryComp<BallisticAmmoProviderComponent>(args.Target, out var targetComponent) ||
targetComponent.Whitelist == null)
{
if (args.Handled || !component.MayTransfer || !Timing.IsFirstTimePredicted
|| args.Target is null || args.Used == args.Target
|| Deleted(args.Target)
|| !TryComp(args.Target, out BallisticAmmoProviderComponent? targetComponent)
|| targetComponent.Whitelist is null)
return;
}
args.Handled = true;
@@ -115,9 +115,9 @@ public abstract partial class SharedGunSystem
if (args.Handled || args.Cancelled) // WWDP
return;
if (Deleted(args.Target) ||
!TryComp<BallisticAmmoProviderComponent>(args.Target, out var target) ||
target.Whitelist == null)
if (Deleted(args.Target)
|| !TryComp(args.Target, out BallisticAmmoProviderComponent? target)
|| target.Whitelist is null)
return;
if (target.Entities.Count + target.UnspawnedCount == target.Capacity)
@@ -171,6 +171,7 @@ public abstract partial class SharedGunSystem
// play sound to be cool
Audio.PlayPredicted(component.SoundInsert, uid, args.User);
SimulateInsertAmmo(ent.Value, args.Target.Value, Transform(args.Target.Value).Coordinates);
component.Cycled = true; // Make sure when loading shells in shotguns, that the first round is chambered.
}
if (IsClientSide(ent.Value))
@@ -215,6 +216,19 @@ public abstract partial class SharedGunSystem
}
// WWDP edit end
private void OnBallisticVerb(EntityUid uid, BallisticAmmoProviderComponent component, GetVerbsEvent<Verb> args)
{
if (!args.CanAccess || !args.CanInteract || args.Hands == null || !component.Cycleable)
return;
args.Verbs.Add(new Verb()
{
Text = Loc.GetString("gun-ballistic-cycle"),
Disabled = GetBallisticShots(component) == 0,
Act = () => ManualCycle(uid, component, TransformSystem.GetMapCoordinates(uid), args.User),
});
}
private void OnBallisticExamine(EntityUid uid, BallisticAmmoProviderComponent component, ExaminedEvent args)
{
if (!args.IsInDetailsRange)
@@ -297,18 +311,18 @@ public abstract partial class SharedGunSystem
return;
// Reset shotting for cycling
if (Resolve(uid, ref gunComp, false) &&
gunComp is { FireRateModified: > 0f } &&
!Paused(uid))
{
if (Resolve(uid, ref gunComp, false)
&& gunComp is { FireRateModified: > 0f }
&& !Paused(uid))
gunComp.NextFire = Timing.CurTime + TimeSpan.FromSeconds(1 / gunComp.FireRateModified);
}
Dirty(uid, component);
Audio.PlayPredicted(component.SoundRack, uid, user);
var shots = GetBallisticShots(component);
Cycle(uid, component, coordinates);
component.Cycled = true;
Cycle(uid, component, coordinates, gunComp);
var text = Loc.GetString(shots == 0 ? "gun-ballistic-cycled-empty" : "gun-ballistic-cycled");
@@ -319,7 +333,7 @@ public abstract partial class SharedGunSystem
UpdateAmmoCount(uid);
}
protected abstract void Cycle(EntityUid uid, BallisticAmmoProviderComponent component, MapCoordinates coordinates);
protected abstract void Cycle(EntityUid uid, BallisticAmmoProviderComponent component, MapCoordinates coordinates, GunComponent? gunComponent = null);
private void OnBallisticInit(EntityUid uid, BallisticAmmoProviderComponent component, ComponentInit args)
{
@@ -341,15 +355,15 @@ public abstract partial class SharedGunSystem
}
}
protected int GetBallisticShots(BallisticAmmoProviderComponent component)
{
return component.Entities.Count + component.UnspawnedCount;
}
protected int GetBallisticShots(BallisticAmmoProviderComponent component) => component.Entities.Count + component.UnspawnedCount;
private void OnBallisticTakeAmmo(EntityUid uid, BallisticAmmoProviderComponent component, TakeAmmoEvent args)
{
for (var i = 0; i < args.Shots; i++)
{
if (!component.Cycled)
break;
EntityUid entity;
if (component.Entities.Count > 0)
@@ -358,24 +372,24 @@ public abstract partial class SharedGunSystem
args.Ammo.Add((entity, EnsureShootable(entity)));
if (component.AutoCycle) // WD EDIT
{
component.Entities.RemoveAt(component.Entities.Count - 1);
Containers.Remove(entity, component.Container);
}
// WWDP edit; support internal caseless ammo in hand-cycled guns
else if (TryComp<CartridgeAmmoComponent>(entity, out var cartridge) && cartridge.DeleteOnSpawn)
if (TryComp<CartridgeAmmoComponent>(entity, out var cartridge) && cartridge.DeleteOnSpawn)
{
component.Entities.RemoveAt(component.Entities.Count - 1);
Containers.Remove(entity, component.Container);
component.Racked = false;
break;
} // WWDP edit end
else
}
// WWDP edit end
// if entity in container it can't be ejected, so shell will remain in gun and block next shoot
if (!component.AutoCycle)
{
component.Racked = false; // WWDP
break;
}
component.Entities.RemoveAt(component.Entities.Count - 1);
Containers.Remove(entity, component.Container);
}
else if (component.UnspawnedCount > 0)
{
@@ -383,19 +397,22 @@ public abstract partial class SharedGunSystem
entity = Spawn(component.Proto, args.Coordinates);
args.Ammo.Add((entity, EnsureShootable(entity)));
// WD EDIT START
if (!component.AutoCycle && TryComp<CartridgeAmmoComponent>(entity, out var cartridge))
// Put it back in if it doesn't auto-cycle
if (Timing.IsFirstTimePredicted && TryComp<CartridgeAmmoComponent>(entity, out var cartridge) && !component.AutoCycle) // WD EDIT
{
// WD EDIT START
component.Racked = false;
if (!cartridge.DeleteOnSpawn)
{
component.Entities.Add(entity);
Containers.Insert(entity, component.Container);
}
break;
if (cartridge.DeleteOnSpawn)
break;
// WD EDIT END
component.Entities.Add(entity);
Containers.Insert(entity, component.Container);
}
// WD EDIT END
}
if (!component.AutoCycle)
component.Cycled = false;
}
UpdateBallisticAppearance(uid, component);
@@ -445,6 +462,4 @@ public abstract partial class SharedGunSystem
/// DoAfter event for filling one ballistic ammo provider from another.
/// </summary>
[Serializable, NetSerializable]
public sealed partial class AmmoFillDoAfterEvent : SimpleDoAfterEvent
{
}
public sealed partial class AmmoFillDoAfterEvent : SimpleDoAfterEvent { }

View File

@@ -540,10 +540,12 @@ public abstract partial class SharedGunSystem : EntitySystem
{
var throwingForce = 0.01f;
var throwingSpeed = 5f;
var ejectAngleOffset = 3.7f;
if (gunComp is not null)
{
throwingForce = gunComp.EjectionForce;
throwingSpeed = gunComp.EjectionSpeed;
ejectAngleOffset = gunComp.EjectAngleOffset;
}
// TODO: Sound limit version.
@@ -555,15 +557,14 @@ public abstract partial class SharedGunSystem : EntitySystem
TransformSystem.SetLocalRotation(entity, Random.NextAngle(), xform);
TransformSystem.SetCoordinates(entity, xform, coordinates);
if (angle is null)
angle = Random.NextAngle();
// decides direction the casing ejects and only when not cycling
if (angle != null)
{
Angle ejectAngle = angle.Value;
ejectAngle += 3.7f; // 212 degrees; casings should eject slightly to the right and behind of a gun
ThrowingSystem.TryThrow(entity, ejectAngle.ToVec().Normalized() * throwingForce, throwingSpeed);
}
if (playSound && TryComp<CartridgeAmmoComponent>(entity, out var cartridge))
Angle ejectAngle = angle.Value;
ejectAngle += ejectAngleOffset; // 212 degrees; casings should eject slightly to the right and behind of a gun
ThrowingSystem.TryThrow(entity, ejectAngle.ToVec().Normalized() * throwingForce, throwingSpeed);
if (playSound && TryComp(entity, out CartridgeAmmoComponent? cartridge))
{
Audio.PlayPvs(cartridge.EjectSound, entity, AudioParams.Default.WithVariation(SharedContentAudioSystem.DefaultVariation).WithVolume(-1f));
}
@@ -662,25 +663,14 @@ public abstract partial class SharedGunSystem : EntitySystem
Dirty(projectile, targeted);
}
public void SetFireRate(GunComponent component, float fireRate) // Goobstation
{
component.FireRate = fireRate;
}
public void SetFireRate(GunComponent component, float fireRate) => component.FireRate = fireRate;
public void SetUseKey(GunComponent component, bool useKey) // Goobstation
{
component.UseKey = useKey;
}
public void SetUseKey(GunComponent component, bool useKey) => component.UseKey = useKey;
public void SetSoundGunshot(GunComponent component, SoundSpecifier? sound) // Goobstation
{
component.SoundGunshot = sound;
}
public void SetSoundGunshot(GunComponent component, SoundSpecifier? sound) => component.SoundGunshot = sound;
public void SetClumsyProof(GunComponent component, bool clumsyProof) => component.ClumsyProof = clumsyProof;
public void SetClumsyProof(GunComponent component, bool clumsyProof) // Goobstation
{
component.ClumsyProof = clumsyProof;
}
protected abstract void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null);
/// <summary>

View File

@@ -0,0 +1,7 @@
- files:
- "shotgun_shell1"
- "shotgun_shell2"
- "shotgun_shell3"
license: "Custom"
copyright: "Valve Software, Non-Commercial Steam Subscriber Agreement"
source: "https://store.steampowered.com/app/220/HalfLife_2/"

View File

@@ -3,6 +3,7 @@ gun-legality-salvage = This weapon is licensed for use in planetary expeditions.
# Weapon Modifiers
gun-suppressed = This weapon comes with a built-in suppressor. It will be impossible to hear at a distance.
gun-modifier-choke = This shotgun comes with a hunting choke. It has a 50% tighter spread when firing shotshells.
# Clothing Modifiers
helmet-radio = This item includes a built-in radio, activate it to configure its settings.

View File

@@ -438,7 +438,7 @@
parent: ClothingBackpackDuffelSyndicateBundle
id: ClothingBackpackDuffelSyndicateFilledFPA90
name: FPA-90 bundle
description: "A cheap integrally suppressed SMG. Comes bundled with three magazines."
description: "A cheap integrally suppressed SMG. Magazines are sold separately."
components:
- type: StorageFill
contents:

View File

@@ -18,6 +18,16 @@
map: [ "enum.AmmoVisualLayers.Base" ]
- type: Appearance
- type: SpentAmmoVisuals
- type: EmitSoundOnLand
sound:
collection: ShellLand
params:
volume: -5
- type: EmitSoundOnCollide
sound:
collection: ShellLand
params:
volume: -5
- type: entity
id: ShellShotgunBeanbag

View File

@@ -7,7 +7,7 @@
- type: Projectile
damage:
types:
Piercing: 19
Piercing: 15
- type: entity
id: BulletCaselessRiflePractice
@@ -30,7 +30,7 @@
damage:
types:
Blunt: 3
Heat: 16
Heat: 12
- type: entity
id: BulletCaselessRifleUranium
@@ -41,8 +41,8 @@
- type: Projectile
damage:
types:
Radiation: 9
Piercing: 10
Radiation: 7
Piercing: 8
- type: entity
id: BulletCaselessRifleShrapnel
@@ -53,7 +53,7 @@
- type: Projectile
damage:
types:
Piercing: 4.37
Piercing: 3.75
- type: Sprite
scale: 0.5, 0.5

View File

@@ -10,8 +10,15 @@
files:
- "/Audio/Weapons/Guns/Casings/shotgun_fall.ogg"
- type: soundCollection
id: ShellLand
files:
- "/Audio/_EE/Weapons/Guns/Casings/shotgun_shell1.ogg"
- "/Audio/_EE/Weapons/Guns/Casings/shotgun_shell2.ogg"
- "/Audio/_EE/Weapons/Guns/Casings/shotgun_shell3.ogg"
- type: soundCollection
id: ToyFall
files:
- "/Audio/Items/Toys/ToyFall1.ogg"
- "/Audio/Items/Toys/ToyFall2.ogg"
- "/Audio/Items/Toys/ToyFall2.ogg"

View File

@@ -69,6 +69,7 @@
tags:
- Grenade
capacity: 3
autoCycle: false
proto: GrenadeFrag
soundInsert:
path: /Audio/Weapons/Guns/MagIn/batrifle_magin.ogg

View File

@@ -194,6 +194,7 @@
- type: ChamberMagazineAmmoProvider
boltClosed: null
- type: Gun
damageModifier: 1.25 # "Extra Robust" despite having an underpowered cartridge.
fireRate: 4
soundGunshot:
path: /Audio/Weapons/Guns/Gunshots/silenced.ogg

View File

@@ -284,7 +284,7 @@
- 0,0,7,0
sprite: Objects/Weapons/Guns/Shotguns/enforcer_inhands_64x.rsi
- type: BallisticAmmoProvider
autoCycle: true # WWDP semi-auto
capacity: 7
- type: Wieldable
- type: MeleeWeapon
attackRate: 1.4
@@ -327,6 +327,12 @@
- type: Wieldable
- type: Gun
shotgunSpreadMultiplier: 0.5
- type: ExtendDescription
descriptionList:
- description: "gun-modifier-choke"
fontSize: 12
color: "#ff4f00"
requireDetailRange: false
- type: entity
name: sawn-off shotgun