Prediction + small cleanup: GatherableSystem (#44840)

* Predict

* Reuse pos

* Update Content.Shared/Gatherable/GatherableSystem.cs

Co-authored-by: Pok <113675512+Pok27@users.noreply.github.com>

* Update Content.Shared/Gatherable/GatherableSystem.cs

Co-authored-by: Connor Huffine <chuffine@gmail.com>

* Review

* refactor: GatherableSystem xml-doc

---------

Co-authored-by: Pok <113675512+Pok27@users.noreply.github.com>
Co-authored-by: Connor Huffine <chuffine@gmail.com>
Co-authored-by: Fildrance <fildrance@gmail.com>
This commit is contained in:
Winkarst-cpu 2026-07-22 18:27:24 +00:00 • committed by GitHub
parent b28ca9f741
commit 54986e0347
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 186 additions and 179 deletions

View file

@ -1,40 +0,0 @@
using Content.Shared.EntityTable.EntitySelectors;
using Content.Shared.Whitelist;
namespace Content.Server.Gatherable.Components;
[RegisterComponent]
[Access(typeof(GatherableSystem))]
public sealed partial class GatherableComponent : Component
{
/// <summary>
/// Whitelist for specifying the kind of tools can be used on a resource
/// Supports multiple tags.
/// </summary>
[DataField(required: true)]
public EntityWhitelist? ToolWhitelist;
/// <summary>
/// YAML example below
/// (Tag1, Tag2, LootTableID1, LootTableID2 are placeholders for example)
/// --------------------
/// useMappedLoot: true
/// toolWhitelist:
/// tags:
/// - Tag1
/// - Tag2
/// loot:
/// Tag1: !type:NestedSelector
/// tableId: LootTableID1
/// Tag2: !type:NestedSelector
/// tableId: LootTableID2
/// </summary>
[DataField]
public Dictionary<string, EntityTableSelector>? Loot = new();
/// <summary>
/// Random shift of the appearing entity during gathering
/// </summary>
[DataField]
public float GatherOffset = 0.3f;
}

View file

@ -1,19 +0,0 @@
using Content.Shared.Audio;
using Robust.Shared.Audio;
namespace Content.Server.Gatherable.Components;
/// <summary>
/// Plays the specified sound when this entity is gathered.
/// </summary>
[RegisterComponent, Access(typeof(GatherableSystem))]
public sealed partial class SoundOnGatherComponent : Component
{
[ViewVariables(VVAccess.ReadWrite), DataField("sound")]
public SoundSpecifier Sound = new SoundPathSpecifier("/Audio/Effects/break_stone.ogg")
{
Params = AudioParams.Default
.WithVariation(SharedContentAudioSystem.DefaultVariation)
.WithVolume(-3f),
};
}

View file

@ -1,30 +0,0 @@
using Content.Server.Gatherable.Components;
using Content.Shared.Projectiles;
using Robust.Shared.Physics.Events;
namespace Content.Server.Gatherable;
public sealed partial class GatherableSystem
{
private void InitializeProjectile()
{
SubscribeLocalEvent<GatheringProjectileComponent, StartCollideEvent>(OnProjectileCollide);
}
private void OnProjectileCollide(Entity<GatheringProjectileComponent> gathering, ref StartCollideEvent args)
{
if (!args.OtherFixture.Hard ||
args.OurFixtureId != SharedProjectileSystem.ProjectileFixture ||
gathering.Comp.Amount <= 0 ||
!TryComp<GatherableComponent>(args.OtherEntity, out var gatherable))
{
return;
}
Gather(args.OtherEntity, gathering, gatherable);
gathering.Comp.Amount--;
if (gathering.Comp.Amount <= 0)
QueueDel(gathering);
}
}

View file

@ -1,87 +0,0 @@
using Content.Server.Destructible;
using Content.Server.Gatherable.Components;
using Content.Shared.EntityTable;
using Content.Shared.Interaction;
using Content.Shared.Tag;
using Content.Shared.Weapons.Melee.Events;
using Content.Shared.Whitelist;
using Robust.Server.GameObjects;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Random;
namespace Content.Server.Gatherable;
public sealed partial class GatherableSystem : EntitySystem
{
[Dependency] private IRobustRandom _random = default!;
[Dependency] private DestructibleSystem _destructible = default!;
[Dependency] private SharedAudioSystem _audio = default!;
[Dependency] private TagSystem _tagSystem = default!;
[Dependency] private TransformSystem _transform = default!;
[Dependency] private EntityWhitelistSystem _whitelistSystem = default!;
[Dependency] private EntityTableSystem _entityTable = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<GatherableComponent, ActivateInWorldEvent>(OnActivate);
SubscribeLocalEvent<GatherableComponent, AttackedEvent>(OnAttacked);
InitializeProjectile();
}
private void OnAttacked(Entity<GatherableComponent> gatherable, ref AttackedEvent args)
{
if (_whitelistSystem.IsWhitelistFailOrNull(gatherable.Comp.ToolWhitelist, args.Used))
return;
Gather(gatherable, args.User);
}
private void OnActivate(Entity<GatherableComponent> gatherable, ref ActivateInWorldEvent args)
{
if (args.Handled || !args.Complex)
return;
if (_whitelistSystem.IsWhitelistFailOrNull(gatherable.Comp.ToolWhitelist, args.User))
return;
Gather(gatherable, args.User);
args.Handled = true;
}
public void Gather(EntityUid gatheredUid, EntityUid? gatherer = null, GatherableComponent? component = null)
{
if (!Resolve(gatheredUid, ref component))
return;
if (TryComp<SoundOnGatherComponent>(gatheredUid, out var soundComp))
{
_audio.PlayPvs(soundComp.Sound, Transform(gatheredUid).Coordinates);
}
// Complete the gathering process
_destructible.DestroyEntity(gatheredUid);
// Spawn the loot!
if (component.Loot == null)
return;
var pos = _transform.GetMapCoordinates(gatheredUid);
foreach (var (tag, table) in component.Loot)
{
if (tag != "All")
{
if (gatherer != null && !_tagSystem.HasTag(gatherer.Value, tag))
continue;
}
var spawnLoot = _entityTable.GetSpawns(table);
foreach (var loot in spawnLoot)
{
var spawnPos = pos.Offset(_random.NextVector2(component.GatherOffset));
Spawn(loot, spawnPos);
}
}
}
}

View file

@ -0,0 +1,44 @@
using Content.Shared.EntityTable.EntitySelectors;
using Content.Shared.Whitelist;
using Robust.Shared.GameStates;
namespace Content.Shared.Gatherable.Components;
/// <summary>
/// Makes the entity possible to gather.
/// </summary>
[RegisterComponent, NetworkedComponent]
[Access(typeof(GatherableSystem))]
public sealed partial class GatherableComponent : Component
{
/// <summary>
/// Whitelist for specifying the kind of tools can be used on a resource.
/// Supports multiple tags.
/// </summary>
[DataField(required: true)]
public EntityWhitelist? ToolWhitelist;
/// <summary>
/// YAML example below
/// (Tag1, Tag2, LootTableID1, LootTableID2 are placeholders for example)
/// --------------------
/// useMappedLoot: true
/// toolWhitelist:
/// tags:
/// - Tag1
/// - Tag2
/// loot:
/// Tag1: !type:NestedSelector
/// tableId: LootTableID1
/// Tag2: !type:NestedSelector
/// tableId: LootTableID2
/// </summary>
[DataField]
public Dictionary<string, EntityTableSelector>? Loot = [];
/// <summary>
/// Random shift of the appearing entity during gathering.
/// </summary>
[DataField]
public float GatherOffset = 0.3f;
}

View file

@ -1,14 +1,17 @@
namespace Content.Server.Gatherable.Components;
using Robust.Shared.GameStates;
namespace Content.Shared.Gatherable.Components;
/// <summary>
/// Destroys a gatherable entity when colliding with it.
/// </summary>
[RegisterComponent]
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
[Access(typeof(GatherableSystem))]
public sealed partial class GatheringProjectileComponent : Component
{
/// <summary>
/// How many more times we can gather.
/// </summary>
[ViewVariables(VVAccess.ReadWrite), DataField("amount")]
[DataField, AutoNetworkedField]
public int Amount = 1;
}

View file

@ -0,0 +1,21 @@
using Content.Shared.Audio;
using Robust.Shared.Audio;
using Robust.Shared.GameStates;
namespace Content.Shared.Gatherable.Components;
/// <summary>
/// Plays the specified sound when this entity is gathered.
/// </summary>
[RegisterComponent, NetworkedComponent]
[Access(typeof(GatherableSystem))]
public sealed partial class SoundOnGatherComponent : Component
{
/// <summary>
/// Sound to play when this entity is gathered.
/// </summary>
[DataField]
public SoundSpecifier Sound = new SoundPathSpecifier("/Audio/Effects/break_stone.ogg", AudioParams.Default
.WithVariation(SharedContentAudioSystem.DefaultVariation)
.WithVolume(-3f));
}

View file

@ -0,0 +1,115 @@
using Content.Shared.Destructible;
using Content.Shared.EntityTable;
using Content.Shared.Gatherable.Components;
using Content.Shared.Interaction;
using Content.Shared.Projectiles;
using Content.Shared.Random.Helpers;
using Content.Shared.Tag;
using Content.Shared.Weapons.Melee.Events;
using Content.Shared.Whitelist;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Physics.Events;
using Robust.Shared.Timing;
namespace Content.Shared.Gatherable;
/// <summary>
/// System for handling collecting items from in-the-world structures,
/// like gathering mushrooms or some rare herbs.
/// </summary>
public sealed partial class GatherableSystem : EntitySystem
{
[Dependency] private SharedAudioSystem _audio = default!;
[Dependency] private SharedDestructibleSystem _destructible = default!;
[Dependency] private EntityTableSystem _entityTable = default!;
[Dependency] private TagSystem _tagSystem = default!;
[Dependency] private IGameTiming _timing = default!;
[Dependency] private EntityWhitelistSystem _whitelistSystem = default!;
/// <summary>
/// If a gatherable entity is attacked with a tool that passes the tool entity whitelist (or if that whitelist doesn't exist), then it will be gathered.
/// </summary>
[SubscribeLocalEvent]
private void OnAttacked(Entity<GatherableComponent> gatherable, ref AttackedEvent args)
{
if (_whitelistSystem.IsWhitelistFailOrNull(gatherable.Comp.ToolWhitelist, args.Used))
return;
Gather(gatherable.AsNullable(), args.User);
}
/// <summary>
/// If a gatherable entity is activated by a user that passes the tool entity whitelist (or if that whitelist doesn't exist), then it will be gathered.
/// </summary>
[SubscribeLocalEvent]
private void OnActivate(Entity<GatherableComponent> gatherable, ref ActivateInWorldEvent args)
{
if (args.Handled || !args.Complex)
return;
if (_whitelistSystem.IsWhitelistFailOrNull(gatherable.Comp.ToolWhitelist, args.User))
return;
Gather(gatherable.AsNullable(), args.User);
args.Handled = true;
}
/// <summary>
/// If a projectile hits a gatherable entity, it will gather that entity and decrease its own gathering amount.
/// If the projectile’s gathering amount is zero or less, it will be deleted.
/// </summary>
[SubscribeLocalEvent]
private void OnProjectileCollide(Entity<GatheringProjectileComponent> gathering, ref StartCollideEvent args)
{
if (!args.OtherFixture.Hard)
return;
if (args.OurFixtureId != SharedProjectileSystem.ProjectileFixture)
return;
if (gathering.Comp.Amount <= 0)
return;
if (!TryComp<GatherableComponent>(args.OtherEntity, out var gatherable))
return;
Gather((args.OtherEntity, gatherable), gathering);
gathering.Comp.Amount--;
Dirty(gathering);
if (gathering.Comp.Amount <= 0)
PredictedQueueDel(gathering);
}
/// <summary>
/// Destroys the gathered entity, plays a sound if it has <see cref="SoundOnGatherComponent"/>, and spawns loot if possible.
/// </summary>
/// <param name="gathered">The entity that was gathered.</param>
/// <param name="gatherer">The entity that gathered it.</param>
public void Gather(Entity<GatherableComponent?> gathered, EntityUid? gatherer = null)
{
if (!Resolve(gathered, ref gathered.Comp))
return;
var pos = Transform(gathered).Coordinates;
if (TryComp<SoundOnGatherComponent>(gathered, out var soundComp))
_audio.PlayPredicted(soundComp.Sound, pos, gatherer);
_destructible.DestroyEntity(gathered);
if (gathered.Comp.Loot is null)
return;
foreach (var (tag, table) in gathered.Comp.Loot)
{
if (tag != "All" && gatherer != null && !_tagSystem.HasTag(gatherer.Value, tag))
continue;
var random = SharedRandomExtensions.PredictedRandom(_timing, GetNetEntity(gathered));
foreach (var loot in _entityTable.GetSpawns(table, random))
{
PredictedSpawnAtPosition(loot, pos.Offset(random.NextVector2(gathered.Comp.GatherOffset)));
}
}
}
}