Add Mortar and Handheld Juicer (#42019)

* init

* API

* testing

* review

* return

* good enough, fix later

TODO:
Proper prototype
DoAfter
Sounds

* "proper" prototype

TODO
DoAfter
Sprite

* proper protos, mortar sprite

* juicer sprites

TODO:
Juicer sounds
Makeshift crafting recipes
Add regular to vendors

* sprite tweak

* juicing sound, cleanup, construction

* vendors

* line end

* attribution newline

* small balance tweak

* Let it be known id never webedit

* meta

* item size

* review

* handhelds

* partial review

* cache solution, looping

* graph

* review

* popup

---------

Co-authored-by: Janet Blackquill <uhhadd@gmail.com>
This commit is contained in:
ScarKy0
2026-01-16 01:19:42 +01:00
committed by GitHub
parent 6df3ed9682
commit 897a2d40bc
42 changed files with 596 additions and 3 deletions

View File

@@ -5,4 +5,3 @@ namespace Content.Client.Kitchen.EntitySystems;
[UsedImplicitly]
public sealed class ReagentGrinderSystem : SharedReagentGrinderSystem;

View File

@@ -0,0 +1,151 @@
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;

View File

@@ -0,0 +1,54 @@
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;
}

View File

@@ -51,4 +51,3 @@ public sealed partial class ActiveReagentGrinderComponent : Component
[ViewVariables]
public GrinderProgram Program;
}

View File

@@ -65,4 +65,3 @@ public abstract class SharedReagentGrinderSystem : EntitySystem
return ent.Comp.JuiceSolution is not null;
}
}

View File

@@ -0,0 +1,9 @@
- 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/"

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,6 @@
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)}.

View File

@@ -27,6 +27,8 @@
DrinkMugOne: 1
DrinkMugRainbow: 2
DrinkMugRed: 2
MortarAndPestle: 1
HandheldJuicer: 1
contrabandInventory:
CandyBowl: 1
BarSpoon: 2

View File

@@ -17,6 +17,8 @@
Bucket: 3
BoxMouthSwab: 1
BoxAgrichem: 1
MortarAndPestle: 1
HandheldJuicer: 1
#TO DO:
#plant analyzer
contrabandInventory:

View File

@@ -0,0 +1,170 @@
- 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

View File

@@ -46,6 +46,20 @@
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

View File

@@ -0,0 +1,80 @@
# 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -0,0 +1,54 @@
{
"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
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

View File

@@ -0,0 +1,54 @@
{
"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
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B