diff --git a/Content.Client/Atmos/UI/SpaceHeaterBoundUserInterface.cs b/Content.Client/Atmos/UI/SpaceHeaterBoundUserInterface.cs
new file mode 100644
index 00000000000000..4d8d1191e912c1
--- /dev/null
+++ b/Content.Client/Atmos/UI/SpaceHeaterBoundUserInterface.cs
@@ -0,0 +1,90 @@
+using Content.Shared.Atmos.Piping.Portable.Components;
+using JetBrains.Annotations;
+using Robust.Client.UserInterface.Controls;
+
+namespace Content.Client.Atmos.UI;
+
+///
+/// Initializes a and updates it when new server messages are received.
+///
+[UsedImplicitly]
+public sealed class SpaceHeaterBoundUserInterface : BoundUserInterface
+{
+ [ViewVariables]
+ private SpaceHeaterWindow? _window;
+
+ public SpaceHeaterBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = new SpaceHeaterWindow();
+
+ if (State != null)
+ UpdateState(State);
+
+ _window.OpenCentered();
+
+ _window.OnClose += Close;
+
+ _window.ToggleStatusButton.OnPressed += _ => OnToggleStatusButtonPressed();
+ _window.IncreaseTempRange.OnPressed += _ => OnTemperatureRangeChanged(_window.TemperatureChangeDelta);
+ _window.DecreaseTempRange.OnPressed += _ => OnTemperatureRangeChanged(-_window.TemperatureChangeDelta);
+ _window.ModeSelector.OnItemSelected += OnModeChanged;
+
+ _window.PowerLevelSelector.OnItemSelected += OnPowerLevelChange;
+ }
+
+ private void OnToggleStatusButtonPressed()
+ {
+ _window?.SetActive(!_window.Active);
+ SendMessage(new SpaceHeaterToggleMessage());
+ }
+
+ private void OnTemperatureRangeChanged(float changeAmount)
+ {
+ SendMessage(new SpaceHeaterChangeTemperatureMessage(changeAmount));
+ }
+
+ private void OnModeChanged(OptionButton.ItemSelectedEventArgs args)
+ {
+ _window?.ModeSelector.SelectId(args.Id);
+ SendMessage(new SpaceHeaterChangeModeMessage((SpaceHeaterMode)args.Id));
+ }
+
+ private void OnPowerLevelChange(RadioOptionItemSelectedEventArgs args)
+ {
+ _window?.PowerLevelSelector.Select(args.Id);
+ SendMessage(new SpaceHeaterChangePowerLevelMessage((SpaceHeaterPowerLevel)args.Id));
+ }
+
+ ///
+ /// Update the UI state based on server-sent info
+ ///
+ ///
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+ if (_window == null || state is not SpaceHeaterBoundUserInterfaceState cast)
+ return;
+
+ _window.SetActive(cast.Enabled);
+ _window.ModeSelector.SelectId((int)cast.Mode);
+ _window.PowerLevelSelector.Select((int)cast.PowerLevel);
+
+ _window.MinTemp = cast.MinTemperature;
+ _window.MaxTemp = cast.MaxTemperature;
+ _window.SetTemperature(cast.TargetTemperature);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (!disposing)
+ return;
+ _window?.Dispose();
+ }
+}
diff --git a/Content.Client/Atmos/UI/SpaceHeaterWindow.xaml b/Content.Client/Atmos/UI/SpaceHeaterWindow.xaml
new file mode 100644
index 00000000000000..1b7bd490b85dfb
--- /dev/null
+++ b/Content.Client/Atmos/UI/SpaceHeaterWindow.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Atmos/UI/SpaceHeaterWindow.xaml.cs b/Content.Client/Atmos/UI/SpaceHeaterWindow.xaml.cs
new file mode 100644
index 00000000000000..097601a011e0d4
--- /dev/null
+++ b/Content.Client/Atmos/UI/SpaceHeaterWindow.xaml.cs
@@ -0,0 +1,73 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Content.Shared.Atmos;
+using Content.Shared.Atmos.Piping.Portable.Components;
+
+namespace Content.Client.Atmos.UI;
+
+///
+/// Client-side UI used to control a space heater.
+///
+[GenerateTypedNameReferences]
+public sealed partial class SpaceHeaterWindow : DefaultWindow
+{
+ // To account for a minimum delta temperature for atmos equalization to trigger we use a fixed step for target temperature increment/decrement
+ public int TemperatureChangeDelta = 5;
+ public bool Active;
+
+ // Temperatures range bounds in Kelvin (K)
+ public float MinTemp;
+ public float MaxTemp;
+
+ public RadioOptions PowerLevelSelector;
+
+ public SpaceHeaterWindow()
+ {
+ RobustXamlLoader.Load(this);
+
+ // Add the Mode selector list
+ foreach (var value in Enum.GetValues())
+ {
+ ModeSelector.AddItem(Loc.GetString($"comp-space-heater-mode-{value}"), (int)value);
+ }
+
+ // Add the Power level radio buttons
+ PowerLevelSelectorHBox.AddChild(PowerLevelSelector = new RadioOptions(RadioOptionsLayout.Horizontal));
+ PowerLevelSelector.FirstButtonStyle = "OpenRight";
+ PowerLevelSelector.LastButtonStyle = "OpenLeft";
+ PowerLevelSelector.ButtonStyle = "OpenBoth";
+ foreach (var value in Enum.GetValues())
+ {
+ PowerLevelSelector.AddItem(Loc.GetString($"comp-space-heater-ui-{value}-power-consumption"), (int)value);
+ }
+
+ // Only allow temperature increment/decrement of TemperatureChangeDelta
+ Thermostat.Editable = false;
+ }
+
+ public void SetActive(bool active)
+ {
+ Active = active;
+ ToggleStatusButton.Pressed = active;
+
+ if (active)
+ {
+ ToggleStatusButton.Text = Loc.GetString("comp-space-heater-ui-status-enabled");
+ }
+ else
+ {
+ ToggleStatusButton.Text = Loc.GetString("comp-space-heater-ui-status-disabled");
+ }
+ }
+
+ public void SetTemperature(float targetTemperature)
+ {
+ Thermostat.SetText($"{targetTemperature - Atmospherics.T0C} °C");
+
+ IncreaseTempRange.Disabled = targetTemperature + TemperatureChangeDelta > MaxTemp;
+ DecreaseTempRange.Disabled = targetTemperature - TemperatureChangeDelta < MinTemp;
+ }
+}
+
diff --git a/Content.Server/Atmos/Piping/Unary/EntitySystems/GasThermoMachineSystem.cs b/Content.Server/Atmos/Piping/Unary/EntitySystems/GasThermoMachineSystem.cs
index 29000826232213..d376c6d9d6ef05 100644
--- a/Content.Server/Atmos/Piping/Unary/EntitySystems/GasThermoMachineSystem.cs
+++ b/Content.Server/Atmos/Piping/Unary/EntitySystems/GasThermoMachineSystem.cs
@@ -55,6 +55,7 @@ private void OnBeforeOpened(Entity ent, ref BeforeAct
private void OnThermoMachineUpdated(EntityUid uid, GasThermoMachineComponent thermoMachine, ref AtmosDeviceUpdateEvent args)
{
+ thermoMachine.LastEnergyDelta = 0f;
if (!(_power.IsPowered(uid) && TryComp(uid, out var receiver)))
return;
@@ -100,12 +101,14 @@ private void OnThermoMachineUpdated(EntityUid uid, GasThermoMachineComponent the
if (thermoMachine.Atmospheric)
{
_atmosphereSystem.AddHeat(heatExchangeGasMixture, dQActual);
+ thermoMachine.LastEnergyDelta = dQActual;
}
else
{
float dQLeak = dQActual * thermoMachine.EnergyLeakPercentage;
float dQPipe = dQActual - dQLeak;
_atmosphereSystem.AddHeat(heatExchangeGasMixture, dQPipe);
+ thermoMachine.LastEnergyDelta = dQPipe;
if (dQLeak != 0f && _atmosphereSystem.GetContainingMixture(uid) is { } containingMixture)
_atmosphereSystem.AddHeat(containingMixture, dQLeak);
diff --git a/Content.Server/Atmos/Portable/SpaceHeaterComponent.cs b/Content.Server/Atmos/Portable/SpaceHeaterComponent.cs
new file mode 100644
index 00000000000000..e490ab3ea01543
--- /dev/null
+++ b/Content.Server/Atmos/Portable/SpaceHeaterComponent.cs
@@ -0,0 +1,58 @@
+using Content.Shared.Atmos;
+using Content.Shared.Atmos.Piping.Portable.Components;
+using Content.Shared.Atmos.Visuals;
+
+namespace Content.Server.Atmos.Portable;
+
+[RegisterComponent]
+public sealed partial class SpaceHeaterComponent : Component
+{
+ ///
+ /// Current mode the space heater is in. Possible values : Auto, Heat and Cool
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public SpaceHeaterMode Mode = SpaceHeaterMode.Auto;
+
+ ///
+ /// The power level the space heater is currently set to. Possible values : Low, Medium, High
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public SpaceHeaterPowerLevel PowerLevel = SpaceHeaterPowerLevel.Medium;
+
+ ///
+ /// Maximum target temperature the device can be set to
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public float MaxTemperature = Atmospherics.T20C + 20;
+
+ ///
+ /// Minimal target temperature the device can be set to
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public float MinTemperature = Atmospherics.T0C - 10;
+
+ ///
+ /// Coefficient of performance. Output power / input power.
+ /// Positive for heaters, negative for freezers.
+ ///
+ [DataField("heatingCoefficientOfPerformance")]
+ [ViewVariables(VVAccess.ReadWrite)]
+ public float HeatingCp = 1f;
+
+ [DataField("coolingCoefficientOfPerformance")]
+ [ViewVariables(VVAccess.ReadWrite)]
+ public float CoolingCp = -0.9f;
+
+ ///
+ /// The delta from the target temperature after which the space heater switch mode while in Auto. Value should account for the thermomachine temperature tolerance.
+ ///
+ [DataField]
+ [ViewVariables(VVAccess.ReadWrite)]
+ public float AutoModeSwitchThreshold = 0.8f;
+
+ ///
+ /// Current electrical power consumption, in watts, of the space heater at medium power level. Passed to the thermomachine component.
+ ///
+ [DataField, ViewVariables(VVAccess.ReadWrite)]
+ public float PowerConsumption = 3500f;
+}
diff --git a/Content.Server/Atmos/Portable/SpaceHeaterSystem.cs b/Content.Server/Atmos/Portable/SpaceHeaterSystem.cs
new file mode 100644
index 00000000000000..b7336a74717ab5
--- /dev/null
+++ b/Content.Server/Atmos/Portable/SpaceHeaterSystem.cs
@@ -0,0 +1,191 @@
+using Content.Server.Atmos.EntitySystems;
+using Content.Server.Atmos.Piping.Components;
+using Content.Server.Atmos.Piping.Unary.Components;
+using Content.Server.Popups;
+using Content.Server.Power.Components;
+using Content.Server.Power.EntitySystems;
+using Content.Shared.Atmos.Piping.Portable.Components;
+using Content.Shared.Atmos.Visuals;
+using Content.Shared.UserInterface;
+using Robust.Server.GameObjects;
+
+namespace Content.Server.Atmos.Portable;
+
+public sealed class SpaceHeaterSystem : EntitySystem
+{
+ [Dependency] private readonly AtmosphereSystem _atmosphereSystem = default!;
+ [Dependency] private readonly PopupSystem _popup = default!;
+ [Dependency] private readonly PowerReceiverSystem _power = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnUIActivationAttempt);
+ SubscribeLocalEvent(OnBeforeOpened);
+
+ SubscribeLocalEvent(OnDeviceUpdated);
+ SubscribeLocalEvent(OnInit);
+ SubscribeLocalEvent(OnPowerChanged);
+
+ SubscribeLocalEvent(OnModeChanged);
+ SubscribeLocalEvent(OnPowerLevelChanged);
+ SubscribeLocalEvent(OnTemperatureChanged);
+ SubscribeLocalEvent(OnToggle);
+ }
+
+ private void OnInit(EntityUid uid, SpaceHeaterComponent spaceHeater, MapInitEvent args)
+ {
+ if (!TryComp(uid, out var thermoMachine))
+ return;
+ thermoMachine.Cp = spaceHeater.HeatingCp;
+ thermoMachine.HeatCapacity = spaceHeater.PowerConsumption;
+ }
+
+ private void OnBeforeOpened(EntityUid uid, SpaceHeaterComponent spaceHeater, BeforeActivatableUIOpenEvent args)
+ {
+ DirtyUI(uid, spaceHeater);
+ }
+
+ private void OnUIActivationAttempt(EntityUid uid, SpaceHeaterComponent spaceHeater, ActivatableUIOpenAttemptEvent args)
+ {
+ if (!Comp(uid).Anchored)
+ {
+ _popup.PopupEntity(Loc.GetString("comp-space-heater-unanchored", ("device", Loc.GetString("comp-space-heater-device-name"))), uid, args.User);
+ args.Cancel();
+ }
+ }
+
+ private void OnDeviceUpdated(EntityUid uid, SpaceHeaterComponent spaceHeater, ref AtmosDeviceUpdateEvent args)
+ {
+ if (!_power.IsPowered(uid)
+ || !TryComp(uid, out var thermoMachine))
+ {
+ return;
+ }
+
+ UpdateAppearance(uid);
+
+ // If in automatic temperature mode, check if we need to adjust the heat exchange direction
+ if (spaceHeater.Mode == SpaceHeaterMode.Auto)
+ {
+ var environment = _atmosphereSystem.GetContainingMixture(uid);
+ if (environment == null)
+ return;
+
+ if (environment.Temperature <= thermoMachine.TargetTemperature - (thermoMachine.TemperatureTolerance + spaceHeater.AutoModeSwitchThreshold))
+ {
+ thermoMachine.Cp = spaceHeater.HeatingCp;
+ }
+ else if (environment.Temperature >= thermoMachine.TargetTemperature + (thermoMachine.TemperatureTolerance + spaceHeater.AutoModeSwitchThreshold))
+ {
+ thermoMachine.Cp = spaceHeater.CoolingCp;
+ }
+ }
+ }
+
+ private void OnPowerChanged(EntityUid uid, SpaceHeaterComponent spaceHeater, ref PowerChangedEvent args)
+ {
+ UpdateAppearance(uid);
+ DirtyUI(uid, spaceHeater);
+ }
+
+ private void OnToggle(EntityUid uid, SpaceHeaterComponent spaceHeater, SpaceHeaterToggleMessage args)
+ {
+ ApcPowerReceiverComponent? powerReceiver = null;
+ if (!Resolve(uid, ref powerReceiver))
+ return;
+
+ _power.TogglePower(uid);
+
+ UpdateAppearance(uid);
+ DirtyUI(uid, spaceHeater);
+ }
+
+ private void OnTemperatureChanged(EntityUid uid, SpaceHeaterComponent spaceHeater, SpaceHeaterChangeTemperatureMessage args)
+ {
+ if (!TryComp(uid, out var thermoMachine))
+ return;
+
+ thermoMachine.TargetTemperature += args.Temperature;
+
+ UpdateAppearance(uid);
+ DirtyUI(uid, spaceHeater);
+ }
+
+ private void OnModeChanged(EntityUid uid, SpaceHeaterComponent spaceHeater, SpaceHeaterChangeModeMessage args)
+ {
+ if (!TryComp(uid, out var thermoMachine))
+ return;
+
+ spaceHeater.Mode = args.Mode;
+
+ if (spaceHeater.Mode == SpaceHeaterMode.Heat)
+ thermoMachine.Cp = spaceHeater.HeatingCp;
+ else if (spaceHeater.Mode == SpaceHeaterMode.Cool)
+ thermoMachine.Cp = spaceHeater.CoolingCp;
+
+ DirtyUI(uid, spaceHeater);
+ }
+
+ private void OnPowerLevelChanged(EntityUid uid, SpaceHeaterComponent spaceHeater, SpaceHeaterChangePowerLevelMessage args)
+ {
+ if (!TryComp(uid, out var thermoMachine))
+ return;
+
+ spaceHeater.PowerLevel = args.PowerLevel;
+
+ switch (spaceHeater.PowerLevel)
+ {
+ case SpaceHeaterPowerLevel.Low:
+ thermoMachine.HeatCapacity = spaceHeater.PowerConsumption / 2;
+ break;
+
+ case SpaceHeaterPowerLevel.Medium:
+ thermoMachine.HeatCapacity = spaceHeater.PowerConsumption;
+ break;
+
+ case SpaceHeaterPowerLevel.High:
+ thermoMachine.HeatCapacity = spaceHeater.PowerConsumption * 2;
+ break;
+ }
+
+ DirtyUI(uid, spaceHeater);
+ }
+
+ private void DirtyUI(EntityUid uid, SpaceHeaterComponent? spaceHeater)
+ {
+ if (!Resolve(uid, ref spaceHeater)
+ || !TryComp(uid, out var thermoMachine)
+ || !TryComp(uid, out var powerReceiver))
+ {
+ return;
+ }
+ _userInterfaceSystem.TrySetUiState(uid, SpaceHeaterUiKey.Key,
+ new SpaceHeaterBoundUserInterfaceState(spaceHeater.MinTemperature, spaceHeater.MaxTemperature, thermoMachine.TargetTemperature, !powerReceiver.PowerDisabled, spaceHeater.Mode, spaceHeater.PowerLevel));
+ }
+
+ private void UpdateAppearance(EntityUid uid)
+ {
+ if (!_power.IsPowered(uid) || !TryComp(uid, out var thermoMachine))
+ {
+ _appearance.SetData(uid, SpaceHeaterVisuals.State, SpaceHeaterState.Off);
+ return;
+ }
+
+ if (thermoMachine.LastEnergyDelta > 0)
+ {
+ _appearance.SetData(uid, SpaceHeaterVisuals.State, SpaceHeaterState.Heating);
+ }
+ else if (thermoMachine.LastEnergyDelta < 0)
+ {
+ _appearance.SetData(uid, SpaceHeaterVisuals.State, SpaceHeaterState.Cooling);
+ }
+ else
+ {
+ _appearance.SetData(uid, SpaceHeaterVisuals.State, SpaceHeaterState.StandBy);
+ }
+ }
+}
diff --git a/Content.Shared/Atmos/Portable/Components/SharedSpaceHeaterComponent.cs b/Content.Shared/Atmos/Portable/Components/SharedSpaceHeaterComponent.cs
new file mode 100644
index 00000000000000..731631b2212630
--- /dev/null
+++ b/Content.Shared/Atmos/Portable/Components/SharedSpaceHeaterComponent.cs
@@ -0,0 +1,90 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Atmos.Piping.Portable.Components;
+
+[Serializable]
+[NetSerializable]
+public enum SpaceHeaterUiKey
+{
+ Key
+}
+
+[Serializable]
+[NetSerializable]
+public sealed class SpaceHeaterToggleMessage : BoundUserInterfaceMessage
+{
+}
+
+[Serializable]
+[NetSerializable]
+public sealed class SpaceHeaterChangeTemperatureMessage : BoundUserInterfaceMessage
+{
+ public float Temperature { get; }
+
+ public SpaceHeaterChangeTemperatureMessage(float temperature)
+ {
+ Temperature = temperature;
+ }
+}
+
+[Serializable]
+[NetSerializable]
+public sealed class SpaceHeaterChangePowerLevelMessage : BoundUserInterfaceMessage
+{
+ public SpaceHeaterPowerLevel PowerLevel { get; }
+
+ public SpaceHeaterChangePowerLevelMessage(SpaceHeaterPowerLevel powerLevel)
+ {
+ PowerLevel = powerLevel;
+ }
+}
+
+[Serializable]
+[NetSerializable]
+public sealed class SpaceHeaterChangeModeMessage : BoundUserInterfaceMessage
+{
+ public SpaceHeaterMode Mode { get; }
+
+ public SpaceHeaterChangeModeMessage(SpaceHeaterMode mode)
+ {
+ Mode = mode;
+ }
+}
+
+[Serializable]
+[NetSerializable]
+public sealed class SpaceHeaterBoundUserInterfaceState : BoundUserInterfaceState
+{
+ public float MinTemperature { get; }
+ public float MaxTemperature { get; }
+ public float TargetTemperature { get; }
+ public bool Enabled { get; }
+ public SpaceHeaterMode Mode { get; }
+ public SpaceHeaterPowerLevel PowerLevel { get; }
+
+ public SpaceHeaterBoundUserInterfaceState(float minTemperature, float maxTemperature, float temperature, bool enabled, SpaceHeaterMode mode, SpaceHeaterPowerLevel powerLevel)
+ {
+ MinTemperature = minTemperature;
+ MaxTemperature = maxTemperature;
+ TargetTemperature = temperature;
+ Enabled = enabled;
+ Mode = mode;
+ PowerLevel = powerLevel;
+ }
+}
+
+[Serializable, NetSerializable]
+public enum SpaceHeaterMode : byte
+{
+ Auto,
+ Heat,
+ Cool
+}
+
+[Serializable, NetSerializable]
+public enum SpaceHeaterPowerLevel : byte
+{
+ Low,
+ Medium,
+ High
+}
diff --git a/Content.Shared/Atmos/Visuals/SpaceHeaterVisuals.cs b/Content.Shared/Atmos/Visuals/SpaceHeaterVisuals.cs
new file mode 100644
index 00000000000000..0cbdb919a0d7be
--- /dev/null
+++ b/Content.Shared/Atmos/Visuals/SpaceHeaterVisuals.cs
@@ -0,0 +1,27 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Atmos.Visuals;
+
+///
+/// Used for the visualizer
+///
+[Serializable, NetSerializable]
+public enum SpaceHeaterVisualLayers : byte
+{
+ Main
+}
+
+[Serializable, NetSerializable]
+public enum SpaceHeaterVisuals : byte
+{
+ State,
+}
+
+[Serializable, NetSerializable]
+public enum SpaceHeaterState : byte
+{
+ Off,
+ StandBy,
+ Heating,
+ Cooling,
+}
diff --git a/Resources/Locale/en-US/components/space-heater-component.ftl b/Resources/Locale/en-US/components/space-heater-component.ftl
new file mode 100644
index 00000000000000..48cd17cbf1dce4
--- /dev/null
+++ b/Resources/Locale/en-US/components/space-heater-component.ftl
@@ -0,0 +1,18 @@
+comp-space-heater-ui-thermostat = Thermostat:
+comp-space-heater-ui-mode = Mode
+comp-space-heater-ui-status-disabled = Off
+comp-space-heater-ui-status-enabled = On
+comp-space-heater-ui-increase-temperature-range = +
+comp-space-heater-ui-decrease-temperature-range = -
+
+comp-space-heater-mode-Auto = Auto
+comp-space-heater-mode-Heat = Heat
+comp-space-heater-mode-Cool = Cool
+
+comp-space-heater-ui-power-consumption = Power level:
+comp-space-heater-ui-Low-power-consumption = Low
+comp-space-heater-ui-Medium-power-consumption = Medium
+comp-space-heater-ui-High-power-consumption = High
+
+comp-space-heater-device-name = space heater
+comp-space-heater-unanchored = The {$device} is not anchored.
diff --git a/Resources/Locale/en-US/wires/wire-names.ftl b/Resources/Locale/en-US/wires/wire-names.ftl
index 8b760ca60f04e9..851241f85c1b6c 100644
--- a/Resources/Locale/en-US/wires/wire-names.ftl
+++ b/Resources/Locale/en-US/wires/wire-names.ftl
@@ -38,6 +38,7 @@ wires-board-name-windoor = Windoor Control
wires-board-name-mech = Mech
wires-board-name-fatextractor = FatExtractor
wires-board-name-flatpacker = Flatpacker
+wires-board-name-spaceheater = Space Heater
# names that get displayed in the wire hacking hud & admin logs.
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
index 6581fecbac8e5d..0d223a8bc92502 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
@@ -451,7 +451,7 @@
id: PortableScrubberMachineCircuitBoard
parent: BaseMachineCircuitboard
name: portable scrubber machine board
- description: A PCB for a portable scrubber.
+ description: A machine printed circuit board for a portable scrubber.
components:
- type: Sprite
state: engineering
@@ -464,6 +464,22 @@
Cable: 5
Glass: 2
+- type: entity
+ id: SpaceHeaterMachineCircuitBoard
+ parent: BaseMachineCircuitboard
+ name: space heater machine board
+ description: A machine printed circuit board for a space heater.
+ components:
+ - type: Sprite
+ state: engineering
+ - type: MachineBoard
+ prototype: SpaceHeater
+ requirements:
+ MatterBin: 1
+ Capacitor: 2
+ materialRequirements:
+ Cable: 5
+
- type: entity
id: CloningPodMachineCircuitboard
parent: BaseMachineCircuitboard
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index a1f00f6ffaa226..b42d9df5bb5713 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -390,6 +390,7 @@
- ThermomachineFreezerMachineCircuitBoard
- HellfireFreezerMachineCircuitBoard
- PortableScrubberMachineCircuitBoard
+ - SpaceHeaterMachineCircuitBoard
- CloningPodMachineCircuitboard
- MedicalScannerMachineCircuitboard
- CryoPodMachineCircuitboard
diff --git a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/portable.yml b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/portable.yml
index e3ae06fa509788..0e2a5f6fe5d4a5 100644
--- a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/portable.yml
+++ b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/portable.yml
@@ -1,15 +1,15 @@
- type: entity
id: PortableScrubber
- parent: BaseStructureDynamic
+ parent: [BaseMachinePowered, ConstructibleMachine]
name: portable scrubber
description: It scrubs, portably!
components:
- type: Transform
- noRot: true
- - type: InteractionOutline
+ anchored: false
- type: Physics
bodyType: Dynamic
- canCollide: false
+ - type: AtmosDevice
+ joinSystem: true
- type: Fixtures
fixtures:
fix1:
@@ -34,9 +34,6 @@
shader: unshaded
visible: false
map: ["enum.PortableScrubberVisualLayers.IsDraining"]
- - type: Pullable
- - type: AtmosDevice
- joinSystem: true
- type: PortableScrubber
gasMixture:
volume: 1250
@@ -49,7 +46,6 @@
volume: 1
- type: ApcPowerReceiver
powerLoad: 2000
- - type: ExtensionCableReceiver
- type: Appearance
- type: GenericVisualizer
visuals:
@@ -94,13 +90,109 @@
min: 1
max: 3
SheetGlass1:
+ min: 1
+ max: 2
+ - !type:DoActsBehavior
+ acts: [ "Destruction" ]
+
+- type: entity
+ id: SpaceHeater
+ parent: [BaseMachinePowered, ConstructibleMachine]
+ name: space heater
+ description: A bluespace technology device that alters local temperature. Commonly referred to as a "Space Heater".
+ suffix: Unanchored
+ components:
+ - type: Transform
+ anchored: false
+ - type: Physics
+ bodyType: Dynamic
+ - type: AtmosDevice
+ joinSystem: true
+ - type: Fixtures
+ fixtures:
+ fix1:
+ shape:
+ !type:PhysShapeAabb
+ bounds: "-0.15,-0.35,0.15,0.5"
+ density: 100
+ mask:
+ - MachineMask
+ layer:
+ - MachineLayer
+ - type: ApcPowerReceiver
+ powerDisabled: true #starts off
+ - type: Sprite
+ sprite: Structures/Piping/Atmospherics/Portable/portable_sheater.rsi
+ noRot: true
+ layers:
+ - state: sheaterOff
+ map: ["enum.SpaceHeaterVisualLayers.Main"]
+ - state: sheaterPanelOpen
+ map: [ "enum.WiresVisualLayers.MaintenancePanel" ]
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.SpaceHeaterVisuals.State:
+ SpaceHeaterVisualLayers.Main:
+ Off: { state: sheaterOff }
+ StandBy: { state: sheaterStandby }
+ Heating: { state: sheaterHeat }
+ Cooling: { state: sheaterCool }
+ - type: Machine
+ board: SpaceHeaterMachineCircuitBoard
+ - type: WiresPanel
+ - type: WiresVisuals
+ - type: UserInterface
+ interfaces:
+ - key: enum.SpaceHeaterUiKey.Key
+ type: SpaceHeaterBoundUserInterface
+ - type: ActivatableUI
+ inHandsOnly: false
+ key: enum.SpaceHeaterUiKey.Key
+ - type: SpaceHeater
+ - type: GasThermoMachine
+ temperatureTolerance: 0.2
+ atmospheric: true
+ - type: Damageable
+ damageContainer: Inorganic
+ damageModifierSet: Metallic
+ - type: Destructible
+ thresholds:
+ - trigger:
+ !type:DamageTrigger
+ damage: 600
+ behaviors:
+ - !type:DoActsBehavior
+ acts: [ "Destruction" ]
+ - trigger:
+ !type:DamageTrigger
+ damage: 300
+ behaviors:
+ - !type:PlaySoundBehavior
+ sound:
+ collection: MetalBreak
+ - !type:SpawnEntitiesBehavior
+ spawn:
+ SheetSteel1:
min: 1
max: 3
- !type:DoActsBehavior
acts: [ "Destruction" ]
- - type: CollideOnAnchor
- enable: true
- - type: ContainerContainer
- containers:
- machine_board: !type:Container
- machine_parts: !type:Container
+
+- type: entity
+ parent: SpaceHeater
+ id: SpaceHeaterAnchored
+ suffix: Anchored
+ components:
+ - type: Transform
+ anchored: true
+ - type: Physics
+ bodyType: Static
+
+- type: entity
+ parent: SpaceHeaterAnchored
+ id: SpaceHeaterEnabled
+ suffix: Anchored, Enabled
+ components:
+ - type: ApcPowerReceiver
+ powerDisabled: false
\ No newline at end of file
diff --git a/Resources/Prototypes/Recipes/Lathes/electronics.yml b/Resources/Prototypes/Recipes/Lathes/electronics.yml
index 3af8eb4e5295a9..f0c59a0bdf9151 100644
--- a/Resources/Prototypes/Recipes/Lathes/electronics.yml
+++ b/Resources/Prototypes/Recipes/Lathes/electronics.yml
@@ -156,6 +156,16 @@
Glass: 900
Gold: 50
+- type: latheRecipe
+ id: SpaceHeaterMachineCircuitBoard
+ result: SpaceHeaterMachineCircuitBoard
+ category: Circuitry
+ completetime: 4
+ materials:
+ Steel: 150
+ Glass: 900
+ Gold: 50
+
- type: latheRecipe
id: MedicalScannerMachineCircuitboard
result: MedicalScannerMachineCircuitboard
diff --git a/Resources/Prototypes/Research/industrial.yml b/Resources/Prototypes/Research/industrial.yml
index fb38c8af945dbc..52772c51e0d3de 100644
--- a/Resources/Prototypes/Research/industrial.yml
+++ b/Resources/Prototypes/Research/industrial.yml
@@ -160,6 +160,7 @@
recipeUnlocks:
- HellfireFreezerMachineCircuitBoard
- PortableScrubberMachineCircuitBoard
+ - SpaceHeaterMachineCircuitBoard
- HolofanProjector
- type: technology
diff --git a/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/meta.json b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/meta.json
new file mode 100644
index 00000000000000..3b684cec11810d
--- /dev/null
+++ b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/meta.json
@@ -0,0 +1,50 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/ead7e05f990de05b7f5f93d39f9671498cb0aa01",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "sheaterOff"
+ },
+ {
+ "name": "sheaterCool",
+ "delays": [
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ },
+ {
+ "name": "sheaterHeat",
+ "delays": [
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ },
+ {
+ "name": "sheaterPanelOpen"
+ },
+ {
+ "name": "sheaterStandby"
+ }
+ ]
+}
diff --git a/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterCool.png b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterCool.png
new file mode 100644
index 00000000000000..94682caed363a1
Binary files /dev/null and b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterCool.png differ
diff --git a/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterHeat.png b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterHeat.png
new file mode 100644
index 00000000000000..e33011d66484a2
Binary files /dev/null and b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterHeat.png differ
diff --git a/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterOff.png b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterOff.png
new file mode 100644
index 00000000000000..39569086c5ae08
Binary files /dev/null and b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterOff.png differ
diff --git a/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterPanelOpen.png b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterPanelOpen.png
new file mode 100644
index 00000000000000..2ce6410e55fe23
Binary files /dev/null and b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterPanelOpen.png differ
diff --git a/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterStandby.png b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterStandby.png
new file mode 100644
index 00000000000000..e436eb13508073
Binary files /dev/null and b/Resources/Textures/Structures/Piping/Atmospherics/Portable/portable_sheater.rsi/sheaterStandby.png differ