Portable scrubbers (#9417)

This commit is contained in:
Rane
2022-07-15 08:46:30 -04:00
committed by GitHub
parent f16de2186e
commit 188934a748
23 changed files with 481 additions and 24 deletions

View File

@@ -0,0 +1,23 @@
namespace Content.Client.Atmos.Visualizers;
/// <summary>
/// Holds 2 pairs of states. The idle/running pair controls animation, while
/// the ready / full pair controls the color of the light.
/// </summary>
[RegisterComponent]
public sealed class PortableScrubberVisualsComponent : Component
{
[DataField("idleState", required: true)]
public string IdleState = default!;
[DataField("runningState", required: true)]
public string RunningState = default!;
/// Powered and not full
[DataField("readyState", required: true)]
public string ReadyState = default!;
/// Powered and full
[DataField("fullState", required: true)]
public string FullState = default!;
}

View File

@@ -0,0 +1,39 @@
using Robust.Client.GameObjects;
using Content.Shared.Atmos.Visuals;
using Content.Client.Power;
namespace Content.Client.Atmos.Visualizers
{
/// <summary>
/// Controls client-side visuals for portable scrubbers.
/// </summary>
public sealed class PortableScrubberSystem : VisualizerSystem<PortableScrubberVisualsComponent>
{
protected override void OnAppearanceChange(EntityUid uid, PortableScrubberVisualsComponent component, ref AppearanceChangeEvent args)
{
if (args.Sprite == null)
return;
if (args.Component.TryGetData(PortableScrubberVisuals.IsFull, out bool isFull)
&& args.Component.TryGetData(PortableScrubberVisuals.IsRunning, out bool isRunning))
{
var runningState = isRunning ? component.RunningState : component.IdleState;
args.Sprite.LayerSetState(PortableScrubberVisualLayers.IsRunning, runningState);
var fullState = isFull ? component.FullState : component.ReadyState;
args.Sprite.LayerSetState(PowerDeviceVisualLayers.Powered, fullState);
}
if (args.Component.TryGetData(PortableScrubberVisuals.IsDraining, out bool isDraining))
{
args.Sprite.LayerSetVisible(PortableScrubberVisualLayers.IsDraining, isDraining);
}
}
}
}
public enum PortableScrubberVisualLayers : byte
{
IsRunning,
IsDraining
}

View File

@@ -150,18 +150,7 @@ namespace Content.Server.Atmos.Piping.Unary.EntitySystems
if (portNode.NodeGroup is PipeNet {NodeCount: > 1} net) if (portNode.NodeGroup is PipeNet {NodeCount: > 1} net)
{ {
var buffer = new GasMixture(net.Air.Volume + canister.Air.Volume); MixContainerWithPipeNet(canister.Air, net.Air);
_atmosphereSystem.Merge(buffer, net.Air);
_atmosphereSystem.Merge(buffer, canister.Air);
net.Air.Clear();
_atmosphereSystem.Merge(net.Air, buffer);
net.Air.Multiply(net.Air.Volume / buffer.Volume);
canister.Air.Clear();
_atmosphereSystem.Merge(canister.Air, buffer);
canister.Air.Multiply(canister.Air.Volume / buffer.Volume);
} }
ContainerManagerComponent? containerManager = null; ContainerManagerComponent? containerManager = null;
@@ -275,5 +264,25 @@ namespace Content.Server.Atmos.Piping.Unary.EntitySystems
appearance.SetData(GasCanisterVisuals.TankInserted, false); appearance.SetData(GasCanisterVisuals.TankInserted, false);
} }
/// <summary>
/// Mix air from a gas container into a pipe net.
/// Useful for anything that uses connector ports.
/// </summary>
public void MixContainerWithPipeNet(GasMixture containerAir, GasMixture pipeNetAir)
{
var buffer = new GasMixture(pipeNetAir.Volume + containerAir.Volume);
_atmosphereSystem.Merge(buffer, pipeNetAir);
_atmosphereSystem.Merge(buffer, containerAir);
pipeNetAir.Clear();
_atmosphereSystem.Merge(pipeNetAir, buffer);
pipeNetAir.Multiply(pipeNetAir.Volume / buffer.Volume);
containerAir.Clear();
_atmosphereSystem.Merge(containerAir, buffer);
containerAir.Multiply(containerAir.Volume / buffer.Volume);
}
} }
} }

View File

@@ -50,7 +50,7 @@ namespace Content.Server.Atmos.Piping.Unary.EntitySystems
} }
} }
private bool FindGasPortIn(EntityUid? gridId, EntityCoordinates coordinates, [NotNullWhen(true)] out GasPortComponent? port) public bool FindGasPortIn(EntityUid? gridId, EntityCoordinates coordinates, [NotNullWhen(true)] out GasPortComponent? port)
{ {
port = null; port = null;

View File

@@ -86,33 +86,42 @@ namespace Content.Server.Atmos.Piping.Unary.EntitySystems
AtmosDeviceEnabledEvent args) => UpdateState(uid, component); AtmosDeviceEnabledEvent args) => UpdateState(uid, component);
private void Scrub(float timeDelta, GasVentScrubberComponent scrubber, GasMixture? tile, PipeNode outlet) private void Scrub(float timeDelta, GasVentScrubberComponent scrubber, GasMixture? tile, PipeNode outlet)
{
Scrub(timeDelta, scrubber.TransferRate, scrubber.PumpDirection, scrubber.FilterGases, tile, outlet.Air);
}
/// <summary>
/// True if we were able to scrub, false if we were not.
/// </summary>
public bool Scrub(float timeDelta, float transferRate, ScrubberPumpDirection mode, HashSet<Gas> filterGases, GasMixture? tile, GasMixture destination)
{ {
// Cannot scrub if tile is null or air-blocked. // Cannot scrub if tile is null or air-blocked.
if (tile == null if (tile == null
|| outlet.Air.Pressure >= 50 * Atmospherics.OneAtmosphere) // Cannot scrub if pressure too high. || destination.Pressure >= 50 * Atmospherics.OneAtmosphere) // Cannot scrub if pressure too high.
{ {
return; return false;
} }
// Take a gas sample. // Take a gas sample.
var ratio = MathF.Min(1f, timeDelta * scrubber.TransferRate / tile.Volume); var ratio = MathF.Min(1f, timeDelta * transferRate / tile.Volume);
var removed = tile.RemoveRatio(ratio); var removed = tile.RemoveRatio(ratio);
// Nothing left to remove from the tile. // Nothing left to remove from the tile.
if (MathHelper.CloseToPercent(removed.TotalMoles, 0f)) if (MathHelper.CloseToPercent(removed.TotalMoles, 0f))
return; return false;
if (scrubber.PumpDirection == ScrubberPumpDirection.Scrubbing) if (mode == ScrubberPumpDirection.Scrubbing)
{ {
_atmosphereSystem.ScrubInto(removed, outlet.Air, scrubber.FilterGases); _atmosphereSystem.ScrubInto(removed, destination, filterGases);
// Remix the gases. // Remix the gases.
_atmosphereSystem.Merge(tile, removed); _atmosphereSystem.Merge(tile, removed);
} }
else if (scrubber.PumpDirection == ScrubberPumpDirection.Siphoning) else if (mode == ScrubberPumpDirection.Siphoning)
{ {
_atmosphereSystem.Merge(outlet.Air, removed); _atmosphereSystem.Merge(destination, removed);
} }
return true;
} }
private void OnAtmosAlarm(EntityUid uid, GasVentScrubberComponent component, AtmosMonitorAlarmEvent args) private void OnAtmosAlarm(EntityUid uid, GasVentScrubberComponent component, AtmosMonitorAlarmEvent args)

View File

@@ -0,0 +1,48 @@
using Content.Shared.Atmos;
namespace Content.Server.Atmos.Portable
{
[RegisterComponent]
public sealed class PortableScrubberComponent : Component
{
/// <summary>
/// The air inside this machine.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("gasMixture")]
public GasMixture Air { get; } = new();
[ViewVariables(VVAccess.ReadWrite)]
[DataField("port")]
public string PortName { get; set; } = "port";
/// <summary>
/// Which gases this machine will scrub out.
/// Unlike fixed scrubbers controlled by an air alarm,
/// this can't be changed in game.
/// </summary>
[DataField("filterGases")]
public HashSet<Gas> FilterGases = new()
{
Gas.CarbonDioxide,
Gas.Plasma,
Gas.Tritium,
Gas.WaterVapor,
Gas.Miasma
};
/// <summary>
/// Can this scrubber hold more gas?
/// </summary>
public bool Full => Air.Pressure >= MaxPressure;
/// <summary>
/// Maximum internal pressure before it refuses to take more.
/// </summary>
[DataField("maxPressure")]
public float MaxPressure = 3000f;
[DataField("transferRate")]
public float TransferRate = 1000f;
public bool Enabled = true;
}
}

View File

@@ -0,0 +1,162 @@
using Content.Server.Atmos.Piping.Unary.EntitySystems;
using Content.Shared.Atmos.Piping.Unary.Components;
using Content.Shared.Atmos.Visuals;
using Content.Shared.Examine;
using Content.Shared.Destructible;
using Content.Server.Atmos.Piping.Components;
using Content.Server.Atmos.EntitySystems;
using Content.Server.Power.Components;
using Content.Server.NodeContainer;
using Robust.Shared.Timing;
using Robust.Server.GameObjects;
using Content.Server.NodeContainer.Nodes;
using Content.Server.NodeContainer.NodeGroups;
using Content.Server.Audio;
using Content.Server.Administration.Logs;
using Content.Shared.Database;
namespace Content.Server.Atmos.Portable
{
public sealed class PortableScrubberSystem : EntitySystem
{
[Dependency] private readonly GasVentScrubberSystem _scrubberSystem = default!;
[Dependency] private readonly GasCanisterSystem _canisterSystem = default!;
[Dependency] private readonly GasPortableSystem _gasPortableSystem = default!;
[Dependency] private readonly AtmosphereSystem _atmosphereSystem = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly TransformSystem _transformSystem = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly AmbientSoundSystem _ambientSound = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<PortableScrubberComponent, AtmosDeviceUpdateEvent>(OnDeviceUpdated);
SubscribeLocalEvent<PortableScrubberComponent, AnchorStateChangedEvent>(OnAnchorChanged);
SubscribeLocalEvent<PortableScrubberComponent, PowerChangedEvent>(OnPowerChanged);
SubscribeLocalEvent<PortableScrubberComponent, ExaminedEvent>(OnExamined);
SubscribeLocalEvent<PortableScrubberComponent, DestructionEventArgs>(OnDestroyed);
}
private void OnDeviceUpdated(EntityUid uid, PortableScrubberComponent component, AtmosDeviceUpdateEvent args)
{
if (!TryComp(uid, out AtmosDeviceComponent? device))
return;
var timeDelta = (float) (_gameTiming.CurTime - device.LastProcess).TotalSeconds;
if (!component.Enabled)
return;
/// If we are on top of a connector port, empty into it.
if (TryComp<NodeContainerComponent>(uid, out var nodeContainer)
&& nodeContainer.TryGetNode(component.PortName, out PortablePipeNode? portableNode)
&& portableNode.ConnectionsEnabled)
{
_atmosphereSystem.React(component.Air, portableNode);
if (portableNode.NodeGroup is PipeNet {NodeCount: > 1} net)
_canisterSystem.MixContainerWithPipeNet(component.Air, net.Air);
}
if (component.Full)
{
UpdateAppearance(uid, true, false);
return;
}
var xform = Transform(uid);
if (xform.GridUid == null)
return;
var position = _transformSystem.GetGridOrMapTilePosition(uid, xform);
var environment = _atmosphereSystem.GetTileMixture(xform.GridUid, xform.MapUid, position, true);
var running = Scrub(timeDelta, component, environment);
UpdateAppearance(uid, false, running);
/// We scrub once to see if we can and set the animation
if (!running)
return;
/// widenet
foreach (var adjacent in _atmosphereSystem.GetAdjacentTileMixtures(xform.GridUid.Value, position, false, true))
{
Scrub(timeDelta, component, environment);
}
}
/// <summary>
/// If there is a port under us, let us connect with adjacent atmos pipes.
/// </summary>
private void OnAnchorChanged(EntityUid uid, PortableScrubberComponent component, ref AnchorStateChangedEvent args)
{
if (!TryComp(uid, out NodeContainerComponent? nodeContainer))
return;
if (!nodeContainer.TryGetNode(component.PortName, out PipeNode? portableNode))
return;
portableNode.ConnectionsEnabled = (args.Anchored && _gasPortableSystem.FindGasPortIn(Transform(uid).GridUid, Transform(uid).Coordinates, out _));
UpdateDrainingAppearance(uid, portableNode.ConnectionsEnabled);
}
private void OnPowerChanged(EntityUid uid, PortableScrubberComponent component, PowerChangedEvent args)
{
UpdateAppearance(uid, component.Full, args.Powered);
component.Enabled = args.Powered;
}
/// <summary>
/// Examining tells you how full it is as a %.
/// </summary>
private void OnExamined(EntityUid uid, PortableScrubberComponent component, ExaminedEvent args)
{
if (args.IsInDetailsRange)
{
var percentage = Math.Round(((component.Air.Pressure) / component.MaxPressure) * 100);
args.PushMarkup(Loc.GetString("portable-scrubber-fill-level", ("percent", percentage)));
}
}
/// <summary>
/// When this is destroyed, we dump out all the gas inside.
/// </summary>
private void OnDestroyed(EntityUid uid, PortableScrubberComponent component, DestructionEventArgs args)
{
var environment = _atmosphereSystem.GetContainingMixture(uid, false, true);
if (environment != null)
_atmosphereSystem.Merge(environment, component.Air);
_adminLogger.Add(LogType.CanisterPurged, LogImpact.Medium, $"Portable scrubber {ToPrettyString(uid):canister} purged its contents of {component.Air:gas} into the environment.");
component.Air.Clear();
}
private bool Scrub(float timeDelta, PortableScrubberComponent scrubber, GasMixture? tile)
{
return _scrubberSystem.Scrub(timeDelta, scrubber.TransferRate, ScrubberPumpDirection.Scrubbing, scrubber.FilterGases, tile, scrubber.Air);
}
private void UpdateAppearance(EntityUid uid, bool isFull, bool isRunning)
{
if (!TryComp<AppearanceComponent>(uid, out var appearance))
return;
_ambientSound.SetAmbience(uid, isRunning);
appearance.SetData(PortableScrubberVisuals.IsFull, isFull);
appearance.SetData(PortableScrubberVisuals.IsRunning, isRunning);
}
private void UpdateDrainingAppearance(EntityUid uid, bool isDraining)
{
if (!TryComp<AppearanceComponent>(uid, out var appearance))
return;
appearance.SetData(PortableScrubberVisuals.IsDraining, isDraining);
}
}
}

View File

@@ -11,6 +11,7 @@ namespace Content.Server.Entry
"MeleeWeaponArcAnimation", "MeleeWeaponArcAnimation",
"EffectVisuals", "EffectVisuals",
"DamageStateVisuals", "DamageStateVisuals",
"PortableScrubberVisuals",
"AnimationsTest", "AnimationsTest",
"ItemStatus", "ItemStatus",
"VehicleVisuals", "VehicleVisuals",

View File

@@ -0,0 +1,15 @@
using Robust.Shared.Serialization;
namespace Content.Shared.Atmos.Visuals
{
[Serializable, NetSerializable]
/// <summary>
/// Used for the visualizer
/// </summary>
public enum PortableScrubberVisuals : byte
{
IsFull,
IsRunning,
IsDraining
}
}

View File

@@ -5,4 +5,5 @@ gas_vent - https://freesound.org/people/kyles/sounds/453642/ - CC0-1.0
flowing_water_open - https://freesound.org/people/sterferny/sounds/382322/ - CC0-1.0 flowing_water_open - https://freesound.org/people/sterferny/sounds/382322/ - CC0-1.0
server_fans - https://freesound.org/people/DeVern/sounds/610761/ - CC-BY-3.0 server_fans - https://freesound.org/people/DeVern/sounds/610761/ - CC-BY-3.0
drain.ogg - https://freesound.org/people/PhreaKsAccount/sounds/46266/ - CC-BY-3.0 (by PhreaKsAccount) drain.ogg - https://freesound.org/people/PhreaKsAccount/sounds/46266/ - CC-BY-3.0 (by PhreaKsAccount)
alarm.ogg - https://github.com/Baystation12/Baystation12/commit/41b11ef289bccfdfa2940480beb9c1e3f50c3b93, fire_alarm.ogg CC-BY-SA-3.0 portable_scrubber.ogg - https://freesound.org/people/Beethovenboy/sounds/384335/ - CC0 (by Beethovenboy)
alarm.ogg - https://github.com/Baystation12/Baystation12/commit/41b11ef289bccfdfa2940480beb9c1e3f50c3b93, fire_alarm.ogg CC-BY-SA-3.0

Binary file not shown.

View File

@@ -0,0 +1 @@
portable-scrubber-fill-level = It's at about [color=yellow]{$percent}%[/color] of its maximum internal pressure.

View File

@@ -265,6 +265,7 @@
- IndustrialEngineering - IndustrialEngineering
unlockedRecipes: unlockedRecipes:
- ThermomachineFreezerMachineCircuitBoard - ThermomachineFreezerMachineCircuitBoard
- PortableScrubberMachineCircuitBoard
# Avionics Circuitry Technology Tree # Avionics Circuitry Technology Tree

View File

@@ -156,6 +156,21 @@
graph: ThermomachineBoard graph: ThermomachineBoard
node: heater node: heater
- type: entity
id: PortableScrubberMachineCircuitBoard
parent: BaseMachineCircuitboard
name: portable scrubber machine board
description: A PCB for a portable scrubber.
components:
- type: MachineBoard
prototype: PortableScrubber
requirements:
MatterBin: 3
Laser: 2
ScanningModule: 1
materialRequirements:
Cable: 5
- type: entity - type: entity
id: CloningPodMachineCircuitboard id: CloningPodMachineCircuitboard
parent: BaseMachineCircuitboard parent: BaseMachineCircuitboard
@@ -396,7 +411,7 @@
materialRequirements: materialRequirements:
Glass: 2 Glass: 2
Cable: 2 Cable: 2
- type: entity - type: entity
id: EmitterCircuitboard id: EmitterCircuitboard
parent: BaseMachineCircuitboard parent: BaseMachineCircuitboard

View File

@@ -240,6 +240,7 @@
- SMESMachineCircuitboard - SMESMachineCircuitboard
- SubstationMachineCircuitboard - SubstationMachineCircuitboard
- ThermomachineFreezerMachineCircuitBoard - ThermomachineFreezerMachineCircuitBoard
- PortableScrubberMachineCircuitBoard
- CloningPodMachineCircuitboard - CloningPodMachineCircuitboard
- MedicalScannerMachineCircuitboard - MedicalScannerMachineCircuitboard
- CrewMonitoringComputerCircuitboard - CrewMonitoringComputerCircuitboard

View File

@@ -0,0 +1,90 @@
- type: entity
id: PortableScrubber
parent: BaseStructureDynamic
name: portable scrubber
description: It scrubs, portably!
components:
- type: Transform
noRot: true
- type: InteractionOutline
- type: Physics
bodyType: Dynamic
- type: Fixtures
fixtures:
- shape:
!type:PhysShapeCircle
radius: 0.4
mass: 50
mask:
- MachineMask
layer:
- MachineLayer
- type: Sprite
netsync: false
sprite: Structures/Piping/Atmospherics/Portable/portable_scrubber.rsi
layers:
- state: icon
map: ["enum.PortableScrubberVisualLayers.IsRunning"]
- state: unlit
shader: unshaded
map: ["enum.PowerDeviceVisualLayers.Powered"]
- state: draining
shader: unshaded
visible: false
map: ["enum.PortableScrubberVisualLayers.IsDraining"]
- type: Pullable
- type: AtmosDevice
joinSystem: true
- type: PortableScrubber
gasMixture:
volume: 1250
- type: NodeContainer
nodes:
port:
!type:PortablePipeNode
nodeGroupID: Pipe
rotationsEnabled: false
volume: 1
- type: ApcPowerReceiver
powerLoad: 2000
- type: ExtensionCableReceiver
- type: Appearance
visuals:
- type: PowerDeviceVisualizer
- type: PortableScrubberVisuals
idleState: icon
runningState: icon-running
readyState: unlit
fullState: unlit-full
- type: AmbientSound
enabled: false
volume: -5
range: 5
sound:
path: /Audio/Ambience/Objects/portable_scrubber.ogg
- type: Machine
board: PortableScrubberMachineCircuitBoard
- type: Damageable
damageContainer: Inorganic
damageModifierSet: Metallic
- type: Destructible
thresholds:
- trigger:
!type:DamageTrigger
damage: 300
behaviors:
- !type:PlaySoundBehavior
sound:
path: /Audio/Effects/metalbreak.ogg
- !type:SpawnEntitiesBehavior
spawn:
SheetSteel1:
min: 1
max: 3
SheetGlass1:
min: 1
max: 3
- !type:DoActsBehavior
acts: [ "Destruction" ]
- type: CollideOnAnchor
enable: true

View File

@@ -54,6 +54,16 @@
Glass: 900 Glass: 900
Gold: 50 Gold: 50
- type: latheRecipe
id: PortableScrubberMachineCircuitBoard
icon: Objects/Misc/module.rsi/id_mod.png
result: PortableScrubberMachineCircuitBoard
completetime: 4
materials:
Steel: 150
Glass: 900
Gold: 50
- type: latheRecipe - type: latheRecipe
id: MedicalScannerMachineCircuitboard id: MedicalScannerMachineCircuitboard
icon: Objects/Misc/module.rsi/id_mod.png icon: Objects/Misc/module.rsi/id_mod.png
@@ -357,7 +367,7 @@
materials: materials:
Steel: 100 Steel: 100
Glass: 900 Glass: 900
- type: latheRecipe - type: latheRecipe
id: EmitterCircuitboard id: EmitterCircuitboard
icon: Objects/Misc/module.rsi/id_mod.png icon: Objects/Misc/module.rsi/id_mod.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,32 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/40d89d11ea4a5cb81d61dc1018b46f4e7d32c62a, and modified a bit by Rane",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "icon"
},
{
"name": "icon-running",
"delays": [
[
0.2,
0.2
]
]
},
{
"name": "unlit"
},
{
"name": "unlit-full"
},
{
"name": "draining"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B