Adds Parcel Wrap (#34471)

* Parcel Wrap

* fix TG sprite licenses
update attribution on modified `unwrapped` sprite to better conform to CC's guidance

* ContainerContainer test failure fix

* Just easy changes for now.

* Imagine building your code before pushing it for review

* The rest of the PR comments

* PR comments

* more comments + cargo orderability

* whitespace: deduplicated.

* use limitedcharges
replace mostly-duped client/server with if(onserver)

* cabinet perspective sprites

* web edit detected

fite me

* @ps3moira 's new sprites for me :)

* add a touch of attribution

* EmoGarbage Review

* Merge with master

* Merge with master

---------

Co-authored-by: EmoGarbage404 <retron404@gmail.com>
This commit is contained in:
Centronias
2025-04-26 16:24:25 -07:00
committed by GitHub
parent fd24e4bc5b
commit 90582f27ee
26 changed files with 675 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
using Content.Shared.Item;
using Content.Shared.Whitelist;
using Robust.Shared.Audio;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Shared.ParcelWrap.Components;
/// <summary>
/// This component gives its owning entity the ability to wrap items into parcels.
/// </summary>
/// <seealso cref="Components.WrappedParcelComponent"/>
[RegisterComponent, NetworkedComponent]
[Access] // Readonly, except for VV editing
public sealed partial class ParcelWrapComponent : Component
{
/// <summary>
/// The <see cref="EntityPrototype"/> of the parcel created by using this component.
/// </summary>
[DataField(required: true)]
public EntProtoId ParcelPrototype;
/// <summary>
/// If true, parcels created by this will have the same <see cref="ItemSizePrototype">size</see> as the item they
/// contain. If false, parcels created by this will always have the size specified by <see cref="FallbackItemSize"/>.
/// </summary>
[DataField]
public bool WrappedItemsMaintainSize = true;
/// <summary>
/// The <see cref="ItemSizePrototype">size</see> of parcels created by this component's entity. This is used if
/// <see cref="WrappedItemsMaintainSize"/> is false, or if the item being wrapped somehow doesn't have a size.
/// </summary>
[DataField]
public ProtoId<ItemSizePrototype> FallbackItemSize = "Ginormous";
/// <summary>
/// If true, parcels created by this will have the same shape as the item they contain. If false, parcels created by
/// this will have the default shape for their size.
/// </summary>
[DataField]
public bool WrappedItemsMaintainShape;
/// <summary>
/// How long it takes to use this to wrap something.
/// </summary>
[DataField(required: true)]
public TimeSpan WrapDelay = TimeSpan.FromSeconds(1);
/// <summary>
/// Sound played when this is used to wrap something.
/// </summary>
[DataField]
public SoundSpecifier? WrapSound;
/// <summary>
/// Defines the set of things which can be wrapped (unless it fails the <see cref="Blacklist"/>).
/// </summary>
[DataField]
public EntityWhitelist? Whitelist;
/// <summary>
/// Defines the set of things which cannot be wrapped (even if it passes the <see cref="Whitelist"/>).
/// </summary>
[DataField]
public EntityWhitelist? Blacklist;
}

View File

@@ -0,0 +1,46 @@
using Content.Shared.ParcelWrap.Systems;
using Robust.Shared.Audio;
using Robust.Shared.Containers;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Shared.ParcelWrap.Components;
/// <summary>
/// This component marks its owner as being a parcel created by wrapping another item up. It can be unwrapped,
/// destroying this entity and releasing <see cref="Contents"/>.
/// </summary>
/// <seealso cref="ParcelWrapComponent"/>
[RegisterComponent, NetworkedComponent, Access(typeof(ParcelWrappingSystem))]
public sealed partial class WrappedParcelComponent : Component
{
/// <summary>
/// The contents of this parcel.
/// </summary>
[ViewVariables(VVAccess.ReadOnly)]
public ContainerSlot Contents = default!;
/// <summary>
/// Specifies the entity to spawn when this parcel is unwrapped.
/// </summary>
[DataField]
public EntProtoId? UnwrapTrash;
/// <summary>
/// How long it takes to unwrap this parcel.
/// </summary>
[DataField(required: true)]
public TimeSpan UnwrapDelay = TimeSpan.FromSeconds(1);
/// <summary>
/// Sound played when unwrapping this parcel.
/// </summary>
[DataField]
public SoundSpecifier? UnwrapSound;
/// <summary>
/// The ID of <see cref="Contents"/>.
/// </summary>
[DataField, ViewVariables(VVAccess.ReadOnly)]
public string ContainerId = "contents";
}

View File

@@ -0,0 +1,13 @@
using Robust.Shared.Serialization;
namespace Content.Shared.ParcelWrap.Components;
/// <summary>
/// This enum is used to change the sprite used by WrappedParcels based on the parcel's size.
/// </summary>
[Serializable, NetSerializable]
public enum WrappedParcelVisuals : byte
{
Size,
Layer,
}

View File

@@ -0,0 +1,10 @@
using Content.Shared.DoAfter;
using Robust.Shared.Serialization;
namespace Content.Shared.ParcelWrap.Systems;
[Serializable, NetSerializable]
public sealed partial class ParcelWrapItemDoAfterEvent : SimpleDoAfterEvent;
[Serializable, NetSerializable]
public sealed partial class UnwrapWrappedParcelDoAfterEvent : SimpleDoAfterEvent;

View File

@@ -0,0 +1,135 @@
using Content.Shared.DoAfter;
using Content.Shared.Interaction;
using Content.Shared.Item;
using Content.Shared.ParcelWrap.Components;
using Content.Shared.Verbs;
using Robust.Shared.Utility;
namespace Content.Shared.ParcelWrap.Systems;
// This part handles Parcel Wrap.
public sealed partial class ParcelWrappingSystem
{
private void InitializeParcelWrap()
{
SubscribeLocalEvent<ParcelWrapComponent, AfterInteractEvent>(OnAfterInteract);
SubscribeLocalEvent<ParcelWrapComponent, GetVerbsEvent<UtilityVerb>>(OnGetVerbsForParcelWrap);
SubscribeLocalEvent<ParcelWrapComponent, ParcelWrapItemDoAfterEvent>(OnWrapItemDoAfter);
}
private void OnAfterInteract(Entity<ParcelWrapComponent> entity, ref AfterInteractEvent args)
{
if (args.Handled ||
args.Target is not { } target ||
!args.CanReach ||
!IsWrappable(entity, target))
return;
args.Handled = TryStartWrapDoAfter(args.User, entity, target);
}
private void OnGetVerbsForParcelWrap(Entity<ParcelWrapComponent> entity, ref GetVerbsEvent<UtilityVerb> args)
{
if (!args.CanAccess || !IsWrappable(entity, args.Target))
return;
// "Capture" the values from `args` because C# doesn't like doing the capturing for `ref` values.
var user = args.User;
var target = args.Target;
// "Wrap" verb for when just left-clicking doesn't work.
args.Verbs.Add(new UtilityVerb
{
Text = Loc.GetString("parcel-wrap-verb-wrap"),
Act = () => TryStartWrapDoAfter(user, entity, target),
});
}
private void OnWrapItemDoAfter(Entity<ParcelWrapComponent> wrapper, ref ParcelWrapItemDoAfterEvent args)
{
if (args.Handled || args.Cancelled)
return;
if (args.Target is { } target)
{
WrapInternal(args.User, wrapper, target);
args.Handled = true;
}
}
private bool TryStartWrapDoAfter(EntityUid user, Entity<ParcelWrapComponent> wrapper, EntityUid target)
{
return _doAfter.TryStartDoAfter(new DoAfterArgs(EntityManager,
user,
wrapper.Comp.WrapDelay,
new ParcelWrapItemDoAfterEvent(),
wrapper, // Raise the event on the wrapper because that's what the event handler expects.
target,
wrapper)
{
NeedHand = true,
BreakOnMove = true,
BreakOnDamage = true,
});
}
/// <summary>
/// Spawns a WrappedParcel containing <paramref name="target"/>.
/// </summary>
/// <param name="user">The entity using <paramref name="wrapper"/> to wrap <paramref name="target"/>.</param>
/// <param name="wrapper">The wrapping being used. Determines appearance of the spawned parcel.</param>
/// <param name="target">The entity being wrapped.</param>
private void WrapInternal(EntityUid user, Entity<ParcelWrapComponent> wrapper, EntityUid target)
{
if (_net.IsServer)
{
var spawned = Spawn(wrapper.Comp.ParcelPrototype, Transform(target).Coordinates);
// If this wrap maintains the size when wrapping, set the parcel's size to the target's size. Otherwise use the
// wrap's fallback size.
TryComp(target, out ItemComponent? targetItemComp);
var size = wrapper.Comp.FallbackItemSize;
if (wrapper.Comp.WrappedItemsMaintainSize && targetItemComp is not null)
{
size = targetItemComp.Size;
}
// ParcelWrap's spawned entity should always have an `ItemComp`. As of writing, the only use has it hardcoded on
// its prototype.
var item = Comp<ItemComponent>(spawned);
_item.SetSize(spawned, size, item);
_appearance.SetData(spawned, WrappedParcelVisuals.Size, size.Id);
// If this wrap maintains the shape when wrapping and the item has a shape override, copy the shape override to
// the parcel.
if (wrapper.Comp.WrappedItemsMaintainShape && targetItemComp is { Shape: { } shape })
{
_item.SetShape(spawned, shape, item);
}
// If the target's in a container, try to put the parcel in its place in the container.
if (_container.TryGetContainingContainer((target, null, null), out var containerOfTarget))
{
_container.Remove(target, containerOfTarget);
_container.InsertOrDrop((spawned, null, null), containerOfTarget);
}
// Insert the target into the parcel.
var parcel = EnsureComp<WrappedParcelComponent>(spawned);
if (!_container.Insert(target, parcel.Contents))
{
DebugTools.Assert(
$"Failed to insert target entity into newly spawned parcel. target={PrettyPrint.PrintUserFacing(target)}");
QueueDel(spawned);
}
}
// Consume a `use` on the wrapper, and delete the wrapper if it's empty.
_charges.TryUseCharges(wrapper.Owner, 1);
if (_net.IsServer && _charges.IsEmpty(wrapper.Owner))
QueueDel(wrapper);
// Play a wrapping sound.
_audio.PlayPredicted(wrapper.Comp.WrapSound, target, user);
}
}

View File

@@ -0,0 +1,139 @@
using Content.Shared.Destructible;
using Content.Shared.DoAfter;
using Content.Shared.Interaction.Events;
using Content.Shared.Materials;
using Content.Shared.ParcelWrap.Components;
using Content.Shared.Popups;
using Content.Shared.Verbs;
using Robust.Shared.Containers;
namespace Content.Shared.ParcelWrap.Systems;
// This part handles Wrapped Parcels
public sealed partial class ParcelWrappingSystem
{
private void InitializeWrappedParcel()
{
SubscribeLocalEvent<WrappedParcelComponent, ComponentInit>(OnComponentInit);
SubscribeLocalEvent<WrappedParcelComponent, UseInHandEvent>(OnUseInHand);
SubscribeLocalEvent<WrappedParcelComponent, GetVerbsEvent<InteractionVerb>>(OnGetVerbsForWrappedParcel);
SubscribeLocalEvent<WrappedParcelComponent, UnwrapWrappedParcelDoAfterEvent>(OnUnwrapParcelDoAfter);
SubscribeLocalEvent<WrappedParcelComponent, DestructionEventArgs>(OnDestroyed);
SubscribeLocalEvent<WrappedParcelComponent, GotReclaimedEvent>(OnDestroyed);
}
private void OnComponentInit(Entity<WrappedParcelComponent> entity, ref ComponentInit args)
{
entity.Comp.Contents = _container.EnsureContainer<ContainerSlot>(entity, entity.Comp.ContainerId);
}
private void OnUseInHand(Entity<WrappedParcelComponent> entity, ref UseInHandEvent args)
{
if (args.Handled)
return;
args.Handled = TryStartUnwrapDoAfter(args.User, entity);
}
private void OnGetVerbsForWrappedParcel(Entity<WrappedParcelComponent> entity,
ref GetVerbsEvent<InteractionVerb> args)
{
if (!args.CanAccess)
return;
// "Capture" the values from `args` because C# doesn't like doing the capturing for `ref` values.
var user = args.User;
args.Verbs.Add(new InteractionVerb
{
Text = Loc.GetString("parcel-wrap-verb-unwrap"),
Act = () => TryStartUnwrapDoAfter(user, entity),
});
}
private void OnUnwrapParcelDoAfter(Entity<WrappedParcelComponent> entity, ref UnwrapWrappedParcelDoAfterEvent args)
{
if (args.Handled || args.Cancelled)
return;
if (args.Target is { } target && TryComp<WrappedParcelComponent>(target, out var parcel))
{
UnwrapInternal(args.User, (target, parcel));
args.Handled = true;
}
}
private void OnDestroyed<T>(Entity<WrappedParcelComponent> parcel, ref T args)
{
// Unwrap the package and if something was in it, show a popup describing "wow something came out!"
if (UnwrapInternal(user: null, parcel) is { } contents)
{
_popup.PopupPredicted(Loc.GetString("parcel-wrap-popup-parcel-destroyed", ("contents", contents)),
contents,
null,
PopupType.MediumCaution);
}
}
private bool TryStartUnwrapDoAfter(EntityUid user, Entity<WrappedParcelComponent> parcel)
{
return _doAfter.TryStartDoAfter(new DoAfterArgs(EntityManager,
user,
parcel.Comp.UnwrapDelay,
new UnwrapWrappedParcelDoAfterEvent(),
parcel,
parcel)
{
NeedHand = true,
});
}
/// <summary>
/// Despawns <paramref name="parcel"/>, leaving the contained entity where the parcel was.
/// </summary>
/// <param name="user">The entity doing the unwrapping.</param>
/// <param name="parcel">The entity being unwrapped.</param>
/// <returns>
/// The newly unwrapped, contained entity. Returns null only in the exceptional case that the parcel contained
/// nothing, which should be prevented by not creating such parcels.
/// </returns>
private EntityUid? UnwrapInternal(EntityUid? user, Entity<WrappedParcelComponent> parcel)
{
var containedEntity = parcel.Comp.Contents.ContainedEntity;
_audio.PlayPredicted(parcel.Comp.UnwrapSound, parcel, user);
// If we're on the client, just return the contained entity and don't try to despawn the parcel.
if (!_net.IsServer)
return containedEntity;
var parcelTransform = Transform(parcel);
if (containedEntity is { } parcelContents)
{
_container.Remove(parcelContents,
parcel.Comp.Contents,
true,
true,
parcelTransform.Coordinates);
// If the parcel is in a container, try to put the unwrapped contents in that container.
if (_container.TryGetContainingContainer((parcel, null, null), out var outerContainer))
{
// Make space in the container for the parcel contents.
_container.Remove((parcel, null, null), outerContainer, force: true);
_container.InsertOrDrop((parcelContents, null, null), outerContainer);
}
}
// Spawn unwrap trash.
if (parcel.Comp.UnwrapTrash is { } trashProto)
{
var trash = Spawn(trashProto, parcelTransform.Coordinates);
_transform.DropNextTo((trash, null), (parcel, parcelTransform));
}
QueueDel(parcel);
return containedEntity;
}
}

View File

@@ -0,0 +1,57 @@
using Content.Shared.Charges.Systems;
using Content.Shared.DoAfter;
using Content.Shared.Item;
using Content.Shared.ParcelWrap.Components;
using Content.Shared.Popups;
using Content.Shared.Whitelist;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Containers;
using Robust.Shared.Network;
namespace Content.Shared.ParcelWrap.Systems;
/// <summary>
/// This system handles things related to package wrap, both wrapping items to create parcels, and unwrapping existing
/// parcels.
/// </summary>
/// <seealso cref="ParcelWrapComponent"/>
/// <seealso cref="WrappedParcelComponent"/>
public sealed partial class ParcelWrappingSystem : EntitySystem
{
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedChargesSystem _charges = default!;
[Dependency] private readonly SharedContainerSystem _container = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly SharedItemSystem _item = default!;
[Dependency] private readonly INetManager _net = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
/// <inheritdoc/>
public override void Initialize()
{
base.Initialize();
InitializeParcelWrap();
InitializeWrappedParcel();
}
/// <summary>
/// Returns whether or not <paramref name="wrapper"/> can be used to wrap <paramref name="target"/>.
/// </summary>
/// <param name="wrapper">The entity doing the wrapping.</param>
/// <param name="target">The entity to be wrapped.</param>
/// <returns>True if <paramref name="wrapper"/> can be used to wrap <paramref name="target"/>, false otherwise.</returns>
public bool IsWrappable(Entity<ParcelWrapComponent> wrapper, EntityUid target)
{
return
// Wrapping cannot wrap itself
wrapper.Owner != target &&
// Wrapper should never be empty, but may as well make sure.
!_charges.IsEmpty(wrapper.Owner) &&
_whitelist.IsWhitelistPass(wrapper.Comp.Whitelist, target) &&
_whitelist.IsBlacklistFail(wrapper.Comp.Blacklist, target);
}
}

View File

@@ -0,0 +1,10 @@
parcel-wrap-verb-wrap = Wrap
parcel-wrap-verb-unwrap = Unwrap
parcel-wrap-popup-parcel-destroyed = The wrapping containing { THE($contents) } is destroyed!
# Shown when parcel wrap is examined in details range
parcel-wrap-examine-detail-uses = { $uses ->
[one] There is [color={$markupUsesColor}]{$uses}[/color] use left
*[other] There are [color={$markupUsesColor}]{$uses}[/color] uses left
}.

View File

@@ -17,3 +17,13 @@
cost: 15000
category: cargoproduct-category-name-cargo
group: market
- type: cargoProduct
id: CargoParcelWrap
icon:
sprite: Objects/Misc/ParcelWrap/parcel_wrap.rsi
state: brown
product: CrateCargoParcelWrap
cost: 750
category: cargoproduct-category-name-cargo
group: market

View File

@@ -8,6 +8,17 @@
contents:
- id: ClothingOuterHardsuitLuxury
- type: entity
id: CrateCargoParcelWrap
parent: CrateGenericSteel
name: parcel wrap crate
description: All your parcel wrapping needs in one crate, containing three rolls of parcel wrap.
components:
- type: StorageFill
contents:
- id: ParcelWrap
amount: 3
- type: entity
id: CrateCargoGambling
name: the grand lottery $$$

View File

@@ -92,6 +92,7 @@
whitelist:
components:
- SecretStash
- WrappedParcel
- type: entity
parent: [ PersonalAI, BaseSyndicateContraband]

View File

@@ -0,0 +1,100 @@
- type: entity
parent: BaseItem
id: ParcelWrap
name: parcel wrap
description: Paper used contain items for transport.
components:
- type: Sprite
sprite: Objects/Misc/ParcelWrap/parcel_wrap.rsi
state: brown
- type: ParcelWrap
parcelPrototype: WrappedParcel
wrapDelay: 1.0
wrapSound:
path: /Audio/Items/Handcuffs/rope_start.ogg
params:
volume: -5
variation: 0.05
whitelist:
components:
- Item
blacklist:
components:
- NukeDisk # Don't try to hide the disk.
- WrappedParcel # No wrapping wrapped things.
tags:
- ParcelWrapBlacklist
- FakeNukeDisk # So you can't tell if the nuke disk is real or fake depending on if it can be wrapped or not.
- type: LimitedCharges
maxCharges: 30
- type: entity
parent: BaseItem
id: WrappedParcel
categories: [ HideSpawnMenu ]
name: wrapped parcel
description: Something wrapped up in paper. I wonder what's inside...
components:
- type: ContainerContainer
containers:
contents: !type:ContainerSlot
- type: Appearance
- type: GenericVisualizer
visuals:
enum.WrappedParcelVisuals.Size:
enum.WrappedParcelVisuals.Layer:
"Tiny": { state: "parcel-tiny" }
"Small": { state: "parcel-small" }
"Medium": { state: "parcel-medium" }
"Large": { state: "parcel-medium" }
"Huge": { state: "parcel-large" }
"Ginormous": { state: "parcel-large" }
- type: Sprite
sprite: Objects/Misc/ParcelWrap/wrapped_parcel.rsi
layers:
- state: parcel-medium
map: [ "enum.WrappedParcelVisuals.Layer" ]
- type: WrappedParcel
unwrapDelay: 0.5
unwrapSound:
path: /Audio/Effects/poster_broken.ogg
params:
volume: -4
unwrapTrash: ParcelWrapTrash
- type: Damageable
damageContainer: Inorganic
- type: Destructible
thresholds:
- trigger:
!type:DamageTypeTrigger
damageType: Slash
damage: 5
behaviors:
- !type:PlaySoundBehavior
sound:
path: /Audio/Effects/poster_broken.ogg
params:
volume: -4
- !type:DoActsBehavior
acts: [ "Destruction" ]
- type: Tag
tags:
- Recyclable # Parcel entity is recyclable, and when it's destroyed, it'll drop its contents.
- type: entity
parent: BaseItem
id: ParcelWrapTrash
categories: [ HideSpawnMenu ]
name: parcel wrap
description: The disappointing remnants of an unwrapped parcel.
components:
- type: Sprite
sprite: Objects/Misc/ParcelWrap/parcel_wrap_trash.rsi
layers:
- state: brown
- type: Tag
tags:
- Trash
- ParcelWrapBlacklist # No exponential wrapper trash-splosions.
- Recyclable
- type: SpaceGarbage

View File

@@ -957,6 +957,9 @@
- type: Tag
id: Pancake
- type: Tag
id: ParcelWrapBlacklist
- type: Tag
id: Payload # for grenade/bomb crafting

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

View File

@@ -0,0 +1,19 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "Taken from tgstation https://github.com/tgstation/tgstation/blob/bd704770f7146d820e1e93b04ae1dcf3723b299a/icons/obj/stack_objects.dmi",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "brown",
"directions": 1
},
{
"name": "empty-roll",
"directions": 1
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 B

View File

@@ -0,0 +1,15 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "Hue shifted from the original by Shaeone: https://github.com/space-wizards/space-station-14/blob/43eb542a60772dc49e38993a54404a5799dfe344/Resources/Textures/Objects/Decoration/present.rsi/unwrapped.png",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "brown",
"directions": 1
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

View File

@@ -0,0 +1,39 @@
{
"version": 1,
"license": "CC-BY-SA-4.0",
"copyright": "created for ss14 by ps3moira (github) based on package sprites from vgstation at https://github.com/vgstation-coders/vgstation13/pull/36993",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "parcel-large",
"directions": 1
},
{
"name": "parcel-medium",
"directions": 1
},
{
"name": "parcel-small",
"directions": 1
},
{
"name": "parcel-tiny",
"directions": 1
},
{
"name": "locker",
"directions": 1
},
{
"name": "crate",
"directions": 1
},
{
"name": "tall-crate",
"directions": 1
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B