Catchable items, playable basketball (#37702)

* catching

* fix

* improve

* fix linter

* cleanup

* fix prediction

* do the same here

* fix comment
This commit is contained in:
slarticodefast
2025-07-06 18:54:20 +02:00
committed by GitHub
parent cb21b72600
commit 22e3d533d3
16 changed files with 304 additions and 24 deletions

View File

@@ -14,6 +14,7 @@ using Content.Shared.Forensics.Components;
using Content.Shared.HealthExaminable;
using Content.Shared.Mobs.Systems;
using Content.Shared.Popups;
using Content.Shared.Random.Helpers;
using Content.Shared.Rejuvenate;
using Content.Shared.Speech.EntitySystems;
using Robust.Shared.Audio.Systems;
@@ -222,7 +223,7 @@ public abstract class SharedBloodstreamSystem : EntitySystem
// TODO: Replace with RandomPredicted once the engine PR is merged
// Use both the receiver and the damage causing entity for the seed so that we have different results for multiple attacks in the same tick
var seed = HashCode.Combine((int)_timing.CurTick.Value, GetNetEntity(ent).Id, GetNetEntity(args.Origin)?.Id ?? 0);
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id, GetNetEntity(args.Origin)?.Id ?? 0 });
var rand = new System.Random(seed);
var prob = Math.Clamp(totalFloat / 25, 0, 1);
if (totalFloat > 0 && rand.Prob(prob))

View File

@@ -5,7 +5,7 @@ using Robust.Shared.GameStates;
namespace Content.Shared.Clumsy;
/// <summary>
/// A simple clumsy tag-component.
/// Makes the entity clumsy, randomly failing some interactions and hurting themselves.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class ClumsyComponent : Component
@@ -48,11 +48,17 @@ public sealed partial class ClumsyComponent : Component
public TimeSpan GunShootFailStunTime = TimeSpan.FromSeconds(3);
/// <summary>
/// Stun time after failing to shoot a gun.
/// Damage taken after failing to shoot a gun.
/// </summary>
[DataField, AutoNetworkedField]
public DamageSpecifier? GunShootFailDamage;
/// <summary>
/// Damage taken after failing to catch an item.
/// </summary>
[DataField, AutoNetworkedField]
public DamageSpecifier? CatchingFailDamage;
/// <summary>
/// Noise to play after failing to shoot a gun. Boom!
/// </summary>
@@ -77,6 +83,12 @@ public sealed partial class ClumsyComponent : Component
[DataField, AutoNetworkedField]
public bool ClumsyGuns = true;
/// <summary>
/// Whether or not to apply Clumsy to catching items.
/// </summary>
[DataField, AutoNetworkedField]
public bool ClumsyCatching = true;
/// <summary>
/// Whether or not to apply Clumsy to vaulting.
/// </summary>
@@ -87,17 +99,23 @@ public sealed partial class ClumsyComponent : Component
/// Lets you define a new "failed" message for each event.
/// </summary>
[DataField]
public LocId HypoFailedMessage = "hypospray-component-inject-self-clumsy-message";
public LocId HypoFailedMessage = "clumsy-hypospray-fail-message";
[DataField]
public LocId GunFailedMessage = "gun-clumsy";
public LocId GunFailedMessage = "clumsy-gun-fail-message";
[DataField]
public LocId VaulingFailedMessageSelf = "bonkable-success-message-user";
public LocId CatchingFailedMessageSelf = "clumsy-catch-fail-message-user";
[DataField]
public LocId VaulingFailedMessageOthers = "bonkable-success-message-others";
public LocId CatchingFailedMessageOthers = "clumsy-catch-fail-message-others";
[DataField]
public LocId VaulingFailedMessageForced = "forced-bonkable-success-message";
public LocId VaulingFailedMessageSelf = "clumsy-vaulting-fail-message-user";
[DataField]
public LocId VaulingFailedMessageOthers = "clumsy-vaulting-fail-message-others";
[DataField]
public LocId VaulingFailedMessageForced = "clumsy-vaulting-fail-forced-message";
}

View File

@@ -6,10 +6,14 @@ using Content.Shared.Damage;
using Content.Shared.IdentityManagement;
using Content.Shared.Medical;
using Content.Shared.Popups;
using Content.Shared.Random.Helpers;
using Content.Shared.Stunnable;
using Content.Shared.Throwing;
using Content.Shared.Weapons.Ranged.Events;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Random;
using Robust.Shared.Timing;
@@ -17,19 +21,20 @@ namespace Content.Shared.Clumsy;
public sealed class ClumsySystem : EntitySystem
{
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly SharedStunSystem _stun = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly DamageableSystem _damageable = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly INetManager _net = default!;
public override void Initialize()
{
SubscribeLocalEvent<ClumsyComponent, SelfBeforeHyposprayInjectsEvent>(BeforeHyposprayEvent);
SubscribeLocalEvent<ClumsyComponent, SelfBeforeDefibrillatorZapsEvent>(BeforeDefibrillatorZapsEvent);
SubscribeLocalEvent<ClumsyComponent, SelfBeforeGunShotEvent>(BeforeGunShotEvent);
SubscribeLocalEvent<ClumsyComponent, CatchAttemptEvent>(OnCatchAttempt);
SubscribeLocalEvent<ClumsyComponent, SelfBeforeClimbEvent>(OnBeforeClimbEvent);
}
@@ -43,12 +48,15 @@ public sealed class ClumsySystem : EntitySystem
if (!ent.Comp.ClumsyHypo)
return;
if (!_random.Prob(ent.Comp.ClumsyDefaultCheck))
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id });
var rand = new System.Random(seed);
if (!rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;
args.TargetGettingInjected = args.EntityUsingHypospray;
args.InjectMessageOverride = "hypospray-component-inject-self-clumsy-message";
_audio.PlayPvs(ent.Comp.ClumsySound, ent);
args.InjectMessageOverride = Loc.GetString(ent.Comp.HypoFailedMessage);
_audio.PlayPredicted(ent.Comp.ClumsySound, ent, args.EntityUsingHypospray);
}
private void BeforeDefibrillatorZapsEvent(Entity<ClumsyComponent> ent, ref SelfBeforeDefibrillatorZapsEvent args)
@@ -59,7 +67,10 @@ public sealed class ClumsySystem : EntitySystem
if (!ent.Comp.ClumsyDefib)
return;
if (!_random.Prob(ent.Comp.ClumsyDefaultCheck))
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id });
var rand = new System.Random(seed);
if (!rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;
args.DefibTarget = args.EntityUsingDefib;
@@ -67,6 +78,37 @@ public sealed class ClumsySystem : EntitySystem
}
private void OnCatchAttempt(Entity<ClumsyComponent> ent, ref CatchAttemptEvent args)
{
// Clumsy people sometimes fail to catch items!
// checks if ClumsyCatching is false, if so, skips.
if (!ent.Comp.ClumsyCatching)
return;
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(args.Item).Id });
var rand = new System.Random(seed);
if (!rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;
args.Cancelled = true; // fail to catch
if (ent.Comp.CatchingFailDamage != null)
_damageable.TryChangeDamage(ent, ent.Comp.CatchingFailDamage, origin: args.Item);
// Collisions don't work properly with PopupPredicted or PlayPredicted.
// So we make this server only.
if (_net.IsClient)
return;
var selfMessage = Loc.GetString(ent.Comp.CatchingFailedMessageSelf, ("item", ent.Owner), ("catcher", Identity.Entity(ent.Owner, EntityManager)));
var othersMessage = Loc.GetString(ent.Comp.CatchingFailedMessageOthers, ("item", ent.Owner), ("catcher", Identity.Entity(ent.Owner, EntityManager)));
_popup.PopupEntity(selfMessage, ent.Owner, ent.Owner);
_popup.PopupEntity(othersMessage, ent.Owner, Filter.PvsExcept(ent.Owner), true);
_audio.PlayPvs(ent.Comp.ClumsySound, ent);
}
private void BeforeGunShotEvent(Entity<ClumsyComponent> ent, ref SelfBeforeGunShotEvent args)
{
// Clumsy people sometimes can't shoot :(
@@ -78,7 +120,10 @@ public sealed class ClumsySystem : EntitySystem
if (args.Gun.Comp.ClumsyProof)
return;
if (!_random.Prob(ent.Comp.ClumsyDefaultCheck))
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(args.Gun).Id });
var rand = new System.Random(seed);
if (!rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;
if (ent.Comp.GunShootFailDamage != null)
@@ -90,7 +135,7 @@ public sealed class ClumsySystem : EntitySystem
_audio.PlayPvs(ent.Comp.GunShootFailSound, ent);
_audio.PlayPvs(ent.Comp.ClumsySound, ent);
_popup.PopupEntity(Loc.GetString("gun-clumsy"), ent, ent);
_popup.PopupEntity(Loc.GetString(ent.Comp.GunFailedMessage), ent, ent);
args.Cancel();
}
@@ -100,9 +145,9 @@ public sealed class ClumsySystem : EntitySystem
if (!ent.Comp.ClumsyVaulting)
return;
// This event is called in shared, thats why it has all the extra prediction stuff.
var rand = new System.Random((int)_timing.CurTick.Value);
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id });
var rand = new System.Random(seed);
if (!_cfg.GetCVar(CCVars.GameTableBonk) && !rand.Prob(ent.Comp.ClumsyDefaultCheck))
return;

View File

@@ -184,5 +184,25 @@ namespace Content.Shared.Random.Helpers
// Shouldn't happen
throw new InvalidOperationException($"Invalid weighted pick for {prototype.ID}!");
}
/// <summary>
/// A very simple, deterministic djb2 hash function for generating a combined seed for the random number generator.
/// We can't use HashCode.Combine because that is initialized with a random value, creating different results on the server and client.
/// </summary>
/// <example>
/// Combine the current game tick with a NetEntity Id in order to not get the same random result if this is called multiple times in the same tick.
/// <code>
/// var seed = SharedRandomExtensions.HashCodeCombine(new() { (int)_timing.CurTick.Value, GetNetEntity(ent).Id });
/// </code>
/// </example>
public static int HashCodeCombine(List<int> values)
{
int hash = 5381;
foreach (var value in values)
{
hash = (hash << 5) + hash + value;
}
return hash;
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Content.Shared.Throwing;
/// <summary>
/// Raised on someone when they try to catch an item.
/// </summary>
[ByRefEvent]
public record struct CatchAttemptEvent(EntityUid Item, float CatchChance, bool Cancelled = false);

View File

@@ -0,0 +1,39 @@
using Content.Shared.Whitelist;
using Robust.Shared.Audio;
using Robust.Shared.GameStates;
namespace Content.Shared.Throwing;
/// <summary>
/// Allows this entity to be caught in your hands when someone else throws it at you.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class CatchableComponent : Component
{
/// <summary>
/// If true this item can only be caught while in combat mode.
/// </summary>
[DataField, AutoNetworkedField]
public bool RequireCombatMode;
/// <summary>
/// The chance of successfully catching.
/// </summary>
[DataField, AutoNetworkedField]
public float CatchChance = 1.0f;
/// <summary>
/// Optional whitelist for who can catch this item.
/// </summary>
/// <summary>
/// Example usecase: Only someone who knows martial arts can catch grenades.
/// </summary>
[DataField, AutoNetworkedField]
public EntityWhitelist? CatcherWhitelist;
/// <summary>
/// The sound to play when successfully catching.
/// </summary>
[DataField]
public SoundSpecifier? CatchSuccessSound;
}

View File

@@ -0,0 +1,84 @@
using Content.Shared.CombatMode;
using Content.Shared.Hands.Components;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.IdentityManagement;
using Content.Shared.Popups;
using Content.Shared.Whitelist;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Shared.Throwing;
public sealed partial class CatchableSystem : EntitySystem
{
[Dependency] private readonly INetManager _net = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly ThrownItemSystem _thrown = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
private EntityQuery<HandsComponent> _handsQuery;
private EntityQuery<CombatModeComponent> _combatModeQuery;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<CatchableComponent, ThrowDoHitEvent>(OnDoHit);
_handsQuery = GetEntityQuery<HandsComponent>();
_combatModeQuery = GetEntityQuery<CombatModeComponent>();
}
private void OnDoHit(Entity<CatchableComponent> ent, ref ThrowDoHitEvent args)
{
if (!_handsQuery.TryGetComponent(args.Target, out var handsComp))
return; // don't do anything for walls etc
// Is the catcher in combat mode if required?
if (ent.Comp.RequireCombatMode && (!_combatModeQuery.TryComp(args.Target, out var combatModeComp) || !combatModeComp.IsInCombatMode))
return;
// Is the catcher able to catch this item?
if (!_whitelist.IsWhitelistPassOrNull(ent.Comp.CatcherWhitelist, args.Target))
return;
var attemptEv = new CatchAttemptEvent(ent.Owner, ent.Comp.CatchChance);
RaiseLocalEvent(args.Target, ref attemptEv);
if (attemptEv.Cancelled)
return;
// TODO: Replace with RandomPredicted once the engine PR is merged
var seed = HashCode.Combine((int)_timing.CurTick.Value, GetNetEntity(ent).Id);
var rand = new System.Random(seed);
if (!rand.Prob(ent.Comp.CatchChance))
return;
// Try to catch!
if (!_hands.TryPickupAnyHand(args.Target, ent.Owner, handsComp: handsComp, animate: false))
return; // The hands are full!
// Success!
// We picked it up already but we still have to raise the throwing stop (but not the landing) events at the right time,
// otherwise it will raise the events for that later while still in your hand
_thrown.StopThrow(ent.Owner, args.Component);
// Collisions don't work properly with PopupPredicted or PlayPredicted.
// So we make this server only.
if (_net.IsClient)
return;
var selfMessage = Loc.GetString("catchable-component-success-self", ("item", ent.Owner), ("catcher", Identity.Entity(args.Target, EntityManager)));
var othersMessage = Loc.GetString("catchable-component-success-others", ("item", ent.Owner), ("catcher", Identity.Entity(args.Target, EntityManager)));
_popup.PopupEntity(selfMessage, args.Target, args.Target);
_popup.PopupEntity(othersMessage, args.Target, Filter.PvsExcept(args.Target), true);
_audio.PlayPvs(ent.Comp.CatchSuccessSound, args.Target);
}
}

View File

@@ -1,4 +0,0 @@
forced-bonkable-success-message = { CAPITALIZE($bonker) } bonks {$victim}s head against { THE($bonkable) }!
bonkable-success-message-user = You bonk your head against { THE($bonkable) }!
bonkable-success-message-others = {$victim} bonks their head against { THE($bonkable) }!

View File

@@ -10,7 +10,6 @@ hypospray-volume-label = Volume: [color=white]{$currentVolume}/{$totalVolume}u[/
hypospray-component-inject-other-message = You inject {$other}.
hypospray-component-inject-self-message = You inject yourself.
hypospray-component-inject-self-clumsy-message = Oops! You injected yourself.
hypospray-component-empty-message = Nothing to inject.
hypospray-component-feel-prick-message = You feel a tiny prick!
hypospray-component-transfer-already-full-message = {$owner} is already full!

View File

@@ -0,0 +1,10 @@
clumsy-vaulting-fail-forced-message = { CAPITALIZE($bonker) } bonks { $victim }s head against { THE($bonkable) }!
clumsy-vaulting-fail-message-user = You bonk your head against { THE($bonkable) }!
clumsy-vaulting-fail-message-others = { $victim } bonks their head against { THE($bonkable) }!
clumsy-gun-fail-message = The gun blows up in your face!
clumsy-hypospray-fail-message = Oops! You injected yourself.
clumsy-catch-fail-message-user = { CAPITALIZE(THE($item)) } hits your head!
clumsy-catch-fail-message-others = { CAPITALIZE(THE($item)) } hits { THE($catcher) }'s head!

View File

@@ -0,0 +1,4 @@
catchable-component-success-self = You catch {THE($item)}!
catchable-component-success-others = {CAPITALIZE(THE($catcher))} catches {THE($item)}!
catchable-component-fail-self = You fail to catch {THE($item)}!
catchable-component-fail-others = {CAPITALIZE(THE($catcher))} fails to catch {THE($item)}!

View File

@@ -4,7 +4,6 @@ gun-fire-rate-examine = Fire rate is [color={$color}]{$fireRate}[/color] per sec
gun-selector-verb = Change to {$mode}
gun-selected-mode = Selected {$mode}
gun-disabled = You can't use guns!
gun-clumsy = The gun blows up in your face!
gun-set-fire-mode = Set to {$mode}
gun-magazine-whitelist-fail = That won't fit into the gun!
gun-magazine-fired-empty = No ammo left!

View File

@@ -1464,6 +1464,9 @@
Piercing: 4
groups:
Burn: 3
catchingFailDamage:
types:
Blunt: 1
clumsySound:
path: /Audio/Animals/monkey_scream.ogg
@@ -1643,6 +1646,9 @@
Piercing: 7
groups:
Burn: 3
catchingFailDamage:
types:
Blunt: 1
clumsySound:
path: /Audio/Voice/Reptilian/reptilian_scream.ogg

View File

@@ -239,6 +239,9 @@
Piercing: 4
groups:
Burn: 3
catchingFailDamage:
types:
Blunt: 1
- type: MeleeWeapon
angle: 30
animation: WeaponArcFist

View File

@@ -396,6 +396,20 @@
- type: Sprite
sprite: Objects/Fun/Balls/basketball.rsi
state: icon
- type: Fixtures
fixtures:
fix1:
shape: !type:PhysShapeCircle
radius: 0.25
density: 20
mask:
- ItemMask
restitution: 0.8 # bouncy
friction: 0.2
- type: Catchable
catchChance: 0.8
catchSuccessSound:
path: /Audio/Effects/Footsteps/bounce.ogg
- type: EmitSoundOnCollide
sound:
path: /Audio/Effects/Footsteps/bounce.ogg
@@ -414,6 +428,23 @@
- type: Sprite
sprite: Objects/Fun/Balls/football.rsi
state: icon
- type: Fixtures
fixtures:
fix1:
shape: !type:PhysShapeCircle
radius: 0.25
density: 20
mask:
- ItemMask
restitution: 0.5 # a little bouncy
friction: 0.2
- type: Catchable
catchChance: 0.8
catchSuccessSound:
path: /Audio/Effects/Footsteps/bounce.ogg
- type: EmitSoundOnCollide
sound:
path: /Audio/Effects/Footsteps/bounce.ogg
- type: Item
size: Small
sprite: Objects/Fun/Balls/football.rsi
@@ -427,6 +458,21 @@
- type: Sprite
sprite: Objects/Fun/Balls/beach_ball.rsi
state: icon
- type: Fixtures
fixtures:
fix1:
shape: !type:PhysShapeCircle
radius: 0.3
position: "0,-0.2"
density: 20
mask:
- ItemMask
restitution: 0.1 # not bouncy
friction: 0.2
- type: Catchable
catchChance: 0.8
catchSuccessSound:
path: /Audio/Effects/Footsteps/bounce.ogg
- type: EmitSoundOnCollide
sound:
path: /Audio/Effects/Footsteps/bounce.ogg

View File

@@ -19,6 +19,9 @@
Piercing: 4
groups:
Burn: 3
catchingFailDamage:
types:
Blunt: 1
- type: SleepEmitSound
snore: /Audio/Voice/Misc/silly_snore.ogg
interval: 10