[UsedImplicitly]
public sealed class ReagentGrinderSystem : SharedReagentGrinderSystem;
-
--- /dev/null
+using System.Diagnostics.CodeAnalysis;
+using Content.Shared.Chemistry.EntitySystems;
+using Content.Shared.Destructible;
+using Content.Shared.DoAfter;
+using Content.Shared.Fluids;
+using Content.Shared.Interaction;
+using Content.Shared.Kitchen.Components;
+using Content.Shared.Popups;
+using Content.Shared.Stacks;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Containers;
+using Robust.Shared.Network;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Kitchen.EntitySystems;
+
+internal sealed class HandheldGrinderSystem : EntitySystem
+{
+ [Dependency] private readonly SharedReagentGrinderSystem _reagentGrinder = default!;
+ [Dependency] private readonly SharedSolutionContainerSystem _solution = default!;
+ [Dependency] private readonly SharedStackSystem _stackSystem = default!;
+ [Dependency] private readonly SharedDestructibleSystem _destructibleSystem = default!;
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly INetManager _net = default!;
+ [Dependency] private readonly SharedPuddleSystem _puddle = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent<HandheldGrinderComponent, EntRemovedFromContainerMessage>(OnGrinderRemoved);
+ SubscribeLocalEvent<HandheldGrinderComponent, InteractUsingEvent>(OnInteractUsing);
+ SubscribeLocalEvent<HandheldGrinderComponent, HandheldGrinderDoAfterEvent>(OnHandheldDoAfter);
+ }
+
+ // prevent the infamous UdderSystem debug assert, see https://github.com/space-wizards/space-station-14/pull/35314
+ // TODO: find a better solution than copy pasting this into every shared system that caches solution entities
+ private void OnGrinderRemoved(Entity<HandheldGrinderComponent> entity, ref EntRemovedFromContainerMessage args)
+ {
+ // Make sure the removed entity was our contained solution and set it to null
+ if (args.Entity != entity.Comp.GrinderSolution?.Owner)
+ return;
+
+ entity.Comp.GrinderSolution = null;
+ }
+
+ private void OnInteractUsing(Entity<HandheldGrinderComponent> ent, ref InteractUsingEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ args.Handled = true;
+
+ var item = args.Used;
+
+ if (!CanGrinderBeUsed(ent, item, out var reason))
+ {
+ _popup.PopupClient(reason, ent, args.User);
+ return;
+ }
+
+ if (_reagentGrinder.GetGrinderSolution(item, ent.Comp.Program) is null)
+ return;
+
+ if (!_solution.ResolveSolution(ent.Owner, ent.Comp.SolutionName, ref ent.Comp.GrinderSolution))
+ return;
+
+ if (_net.IsServer) // Cannot cancel predicted audio.
+ ent.Comp.AudioStream = _audio.PlayPvs(ent.Comp.Sound, ent)?.Entity;
+
+ var doAfter = new DoAfterArgs(EntityManager, args.User, ent.Comp.DoAfterDuration, new HandheldGrinderDoAfterEvent(), ent, ent, item)
+ {
+ NeedHand = true,
+ BreakOnDamage = true,
+ BreakOnDropItem = true,
+ BreakOnHandChange = true,
+ BreakOnMove = true
+ };
+
+ _doAfter.TryStartDoAfter(doAfter);
+ }
+
+ private void OnHandheldDoAfter(Entity<HandheldGrinderComponent> ent, ref HandheldGrinderDoAfterEvent args)
+ {
+ ent.Comp.AudioStream = _audio.Stop(ent.Comp.AudioStream);
+
+ if (args.Cancelled)
+ return;
+
+ if (args.Used is not { } item)
+ return;
+
+ if (!CanGrinderBeUsed(ent, item, out var reason))
+ {
+ _popup.PopupClient(reason, ent, args.User);
+ return;
+ }
+
+ if (_reagentGrinder.GetGrinderSolution(item, ent.Comp.Program) is not { } obtainedSolution)
+ return;
+
+ if (!_solution.ResolveSolution(ent.Owner, ent.Comp.SolutionName, ref ent.Comp.GrinderSolution, out var solution))
+ return;
+
+ _solution.TryMixAndOverflow(ent.Comp.GrinderSolution.Value, obtainedSolution, solution.MaxVolume, out var overflow);
+
+ if (overflow != null)
+ _puddle.TrySpillAt(ent, overflow, out _);
+
+ if (TryComp<StackComponent>(item, out var stack))
+ _stackSystem.ReduceCount((item, stack), 1);
+ else
+ _destructibleSystem.DestroyEntity(item);
+
+ _popup.PopupClient(Loc.GetString(ent.Comp.FinishedPopup, ("item", item)), ent, args.User);
+ }
+
+ /// <summary>
+ /// Checks whether the respective handheld grinder can currently be used.
+ /// </summary>
+ /// <param name="ent">The grinder entity.</param>
+ /// <param name="item">The item it is being used on.</param>
+ /// <param name="reason">Reason the grinder cannot be used. Null if the function returns true.</param>
+ /// <returns>True if the grinder can be used, otherwise false.</returns>
+ public bool CanGrinderBeUsed(Entity<HandheldGrinderComponent> ent, EntityUid item, [NotNullWhen(false)] out string? reason)
+ {
+ reason = null;
+ if (ent.Comp.Program == GrinderProgram.Grind && !_reagentGrinder.CanGrind(item))
+ {
+ reason = Loc.GetString("handheld-grinder-cannot-grind", ("item", item));
+ return false;
+ }
+
+ if (ent.Comp.Program == GrinderProgram.Juice && !_reagentGrinder.CanJuice(item))
+ {
+ reason = Loc.GetString("handheld-grinder-cannot-juice", ("item", item));
+ return false;
+ }
+
+ return true;
+ }
+}
+
+/// <summary>
+/// DoAfter used to indicate the handheld grinder is in use.
+/// After it ends, the GrinderProgram from <see cref="HandheldGrinderComponent"/> is used on the contents.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed partial class HandheldGrinderDoAfterEvent : SimpleDoAfterEvent;
--- /dev/null
+using Content.Shared.Chemistry.Components;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Kitchen.Components;
+
+/// <summary>
+/// Indicates this entity is a handheld grinder.
+/// Entities with <see cref="ExtractableComponent"/> can be used on handheld grinders to extract their solutions.
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class HandheldGrinderComponent : Component
+{
+ /// <summary>
+ /// The length of the doAfter.
+ /// After it ends, the respective GrinderProgram is used on the contents.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public TimeSpan DoAfterDuration = TimeSpan.FromSeconds(4f);
+
+ /// <summary>
+ /// Popup to use after the current item is done processing.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public LocId FinishedPopup = "handheld-grinder-default";
+
+ /// <summary>
+ /// The sound to play when the doAfter starts.
+ /// </summary>
+ [DataField]
+ public SoundSpecifier Sound = new SoundPathSpecifier("/Audio/Items/Culinary/mortar_grinding.ogg", AudioParams.Default.WithLoop(true));
+
+ /// <summary>
+ /// The grinder program to use.
+ /// Decides whether this one will Juice or Grind the objects.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public GrinderProgram Program = GrinderProgram.Grind;
+
+ /// <summary>
+ /// The solution into which the output reagents will go.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public string SolutionName = "grinderOutput";
+
+ /// <summary>
+ /// Cached solution from the grinder.
+ /// </summary>
+ [ViewVariables]
+ public Entity<SolutionComponent>? GrinderSolution;
+
+ // Used to cancel the sound.
+ public EntityUid? AudioStream;
+}
[ViewVariables]
public GrinderProgram Program;
}
-
return ent.Comp.JuiceSolution is not null;
}
}
-
--- /dev/null
+- files: ["mortar_grinding.ogg"]
+ license: "CC0-1.0"
+ copyright: "Taken from freesound, cleaned up and sped up. Coverted to .ogg"
+ source: "https://freesound.org/people/OlyveBone/sounds/486995/"
+
+- files: ["juicer_juicing.ogg"]
+ license: "CC-BY-3.0"
+ copyright: "Taken from freesound. Converted to .ogg"
+ source: "https://freesound.org/people/Edo333/sounds/272208/"
--- /dev/null
+handheld-grinder-cannot-juice = You cannot juice {THE($item)}!
+handheld-grinder-cannot-grind = You cannot grind {THE($item)}!
+
+handheld-grinder-default = You finished processing {THE($item)}.
+handheld-grinder-juiced = You finished juicing {THE($item)}.
+handheld-grinder-grinded = You finished grinding {THE($item)}.
DrinkMugOne: 1
DrinkMugRainbow: 2
DrinkMugRed: 2
+ MortarAndPestle: 1
+ HandheldJuicer: 1
contrabandInventory:
CandyBowl: 1
BarSpoon: 2
Bucket: 3
BoxMouthSwab: 1
BoxAgrichem: 1
+ MortarAndPestle: 1
+ HandheldJuicer: 1
#TO DO:
#plant analyzer
contrabandInventory:
--- /dev/null
+- type: entity
+ abstract: true
+ parent: BaseItem
+ id: BaseHandheldGrinder
+ components:
+ - type: SolutionContainerManager
+ solutions:
+ grinderOutput:
+ maxVol: 20
+ - type: SolutionTransfer
+ - type: DrawableSolution
+ solution: grinderOutput
+ - type: RefillableSolution
+ solution: grinderOutput
+ - type: DrainableSolution
+ solution: grinderOutput
+ - type: Edible
+ edible: Drink
+ solution: grinderOutput
+ destroyOnEmpty: false
+ utensil: Spoon
+ - type: MixableSolution
+ solution: grinderOutput
+ - type: ExaminableSolution
+ solution: grinderOutput
+ exactVolume: true
+ - type: SolutionItemStatus
+ solution: grinderOutput
+ - type: SolutionContainerVisuals
+ maxFillLevels: 4
+ fillBaseName: fill-
+ - type: DnaSubstanceTrace
+ - type: Damageable
+ damageContainer: Inorganic
+ - type: Spillable
+ solution: grinderOutput
+ - type: Appearance
+ - type: HandheldGrinder
+
+# Mortars
+- type: entity
+ parent: BaseHandheldGrinder
+ id: MortarAndPestle
+ name: mortar and pestle
+ description: Used for grinding small amounts of objects.
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Kitchen/mortar_and_pestle.rsi
+ layers:
+ - state: icon
+ - map: ["enum.SolutionContainerLayers.Fill"]
+ state: fill-1
+ visible: false
+ - type: HandheldGrinder
+ finishedPopup: handheld-grinder-grinded
+
+- type: entity
+ parent: MortarAndPestle
+ id: MortarAndPestleMakeshift
+ name: makeshift mortar and pestle
+ description: Used for grinding small amounts of objects. Inferior version made out of wood.
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Kitchen/mortar_and_pestle.rsi
+ layers:
+ - state: makeshift_icon
+ - map: ["enum.SolutionContainerLayers.Fill"]
+ state: fill-1
+ visible: false
+ - type: Item
+ inhandVisuals:
+ left:
+ - state: makeshift-inhand-left
+ right:
+ - state: makeshift-inhand-right
+ - type: HandheldGrinder
+ doAfterDuration: 6
+ - type: Construction
+ graph: MakeshiftMortarAndPestle
+ node: mortarAndPestle
+
+# Juicers
+- type: entity
+ parent: BaseHandheldGrinder
+ id: HandheldJuicer
+ name: handheld juicer
+ description: Used for juicing small amounts of objects.
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Kitchen/handheld_juicer.rsi
+ layers:
+ - state: juicer_base
+ - map: ["enum.SolutionContainerLayers.Fill"]
+ state: fill-1
+ visible: false
+ - state: cap
+ - type: HandheldGrinder
+ finishedPopup: handheld-grinder-juiced
+ sound: !type:SoundPathSpecifier
+ path: /Audio/Items/Culinary/juicer_juicing.ogg # Pasta mixing sound. Close enough.
+ params:
+ loop: true
+ program: Juice
+
+- type: entity
+ parent: HandheldJuicer
+ id: HandheldJuicerMakeshift
+ name: makeshift juicer
+ description: Used for juicing small amounts of objects. Inferior version made out of wood.
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Kitchen/handheld_juicer.rsi
+ layers:
+ - state: makeshift_base
+ - map: ["enum.SolutionContainerLayers.Fill"]
+ state: fill-1
+ visible: false
+ - state: cap
+ - type: Item
+ inhandVisuals:
+ left:
+ - state: makeshift-inhand-left
+ right:
+ - state: makeshift-inhand-right
+ - type: HandheldGrinder
+ doAfterDuration: 6
+ - type: Construction
+ graph: MakeshiftJuicer
+ node: juicer
+
+# Construction
+- type: entity
+ name: incomplete mortar and pestle
+ parent: BaseItem
+ id: IncompleteMortarAndPestle
+ description: A few planks of wood stuck together.
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Kitchen/mortar_and_pestle.rsi
+ state: makeshift_base
+ - type: Item
+ size: Normal
+ inhandVisuals:
+ left:
+ - state: makeshift-inhand-left
+ right:
+ - state: makeshift-inhand-right
+ - type: Construction
+ graph: MakeshiftMortarAndPestle
+ node: incompleteMortarAndPestle
+
+- type: entity
+ name: incomplete handheld juicer
+ parent: BaseItem
+ id: IncompleteHandheldJuicer
+ description: A some wood and plastic stuck together.
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Kitchen/handheld_juicer.rsi
+ state: makeshift_base
+ - type: Item
+ size: Normal
+ inhandVisuals:
+ left:
+ - state: makeshift-inhand-left
+ right:
+ - state: makeshift-inhand-right
+ - type: Construction
+ graph: MakeshiftJuicer
+ node: incompleteJuicer
category: construction-category-tools
objectType: Item
+- type: construction
+ id: MakeshiftMortarAndPestle
+ graph: MakeshiftMortarAndPestle
+ startNode: start
+ targetNode: mortarAndPestle
+ category: construction-category-tools
+ objectType: Item
+
+- type: construction
+ id: MakeshiftJuicer
+ graph: MakeshiftJuicer
+ startNode: start
+ targetNode: juicer
+
- type: construction
id: MakeshiftCentrifuge
graph: MakeshiftCentrifuge
--- /dev/null
+
+# Mortar
+- type: constructionGraph
+ id: MakeshiftMortarAndPestle
+ start: start
+ graph:
+ - node: start
+ edges:
+ - to: incompleteMortarAndPestle
+ steps:
+ - material: WoodPlank
+ amount: 5
+ doAfter: 4
+
+ - node: incompleteMortarAndPestle
+ entity: IncompleteMortarAndPestle
+ edges:
+ - to: start
+ completed:
+ - !type:SpawnPrototype
+ prototype: MaterialWoodPlank1
+ amount: 5
+ - !type:DeleteEntity {}
+ steps:
+ - tool: Prying
+ doAfter: 1
+ - to: mortarAndPestle
+ completed:
+ - !type:AdminLog
+ message: "Construction"
+ impact: High
+ steps:
+ - tool: Slicing
+ doAfter: 4
+
+ - node: mortarAndPestle
+ entity: MortarAndPestleMakeshift
+
+# Juicer
+- type: constructionGraph
+ id: MakeshiftJuicer
+ start: start
+ graph:
+ - node: start
+ edges:
+ - to: incompleteJuicer
+ steps:
+ - material: WoodPlank
+ amount: 4
+ doAfter: 2
+ - material: Plastic
+ amount: 2
+ doAfter: 2
+
+ - node: incompleteJuicer
+ entity: IncompleteHandheldJuicer
+ edges:
+ - to: start
+ completed:
+ - !type:SpawnPrototype
+ prototype: MaterialWoodPlank1
+ amount: 4
+ - !type:SpawnPrototype
+ prototype: SheetPlastic1
+ amount: 2
+ - !type:DeleteEntity {}
+ steps:
+ - tool: Prying
+ doAfter: 1
+ - to: juicer
+ completed:
+ - !type:AdminLog
+ message: "Construction"
+ impact: High
+ steps:
+ - tool: Slicing
+ doAfter: 4
+
+ - node: juicer
+ entity: HandheldJuicerMakeshift
--- /dev/null
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-4.0",
+ "copyright": "Created by TheShuEd (Github), edited into a juicer and inhands by ScarKy0(GitHub)",
+ "states": [
+ {
+ "name": "icon"
+ },
+ {
+ "name": "makeshift_icon"
+ },
+ {
+ "name": "fill-1"
+ },
+ {
+ "name": "fill-2"
+ },
+ {
+ "name": "fill-3"
+ },
+ {
+ "name": "fill-4"
+ },
+ {
+ "name": "juicer_base"
+ },
+ {
+ "name": "cap"
+ },
+ {
+ "name": "makeshift_base"
+ },
+ {
+ "name": "inhand-right",
+ "directions": 4
+ },
+ {
+ "name": "inhand-left",
+ "directions": 4
+ },
+ {
+ "name": "makeshift-inhand-right",
+ "directions": 4
+ },
+ {
+ "name": "makeshift-inhand-left",
+ "directions": 4
+ }
+ ]
+}
--- /dev/null
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-4.0",
+ "copyright": "Created by TheShuEd (Github) | Small tweaks to fill states, makeshift and inhands by ScarKy0 (Github)",
+ "states": [
+ {
+ "name": "icon"
+ },
+ {
+ "name": "makeshift_icon"
+ },
+ {
+ "name": "fill-1"
+ },
+ {
+ "name": "fill-2"
+ },
+ {
+ "name": "fill-3"
+ },
+ {
+ "name": "fill-4"
+ },
+ {
+ "name": "mortar_base"
+ },
+ {
+ "name": "makeshift_base"
+ },
+ {
+ "name": "pestle"
+ },
+ {
+ "name": "inhand-right",
+ "directions": 4
+ },
+ {
+ "name": "inhand-left",
+ "directions": 4
+ },
+ {
+ "name": "makeshift-inhand-right",
+ "directions": 4
+ },
+ {
+ "name": "makeshift-inhand-left",
+ "directions": 4
+ }
+ ]
+}