Merge remote-tracking branch 'upstream/stable' into ed-12-05-2025-upstream

# Conflicts:
#	.github/CODEOWNERS
#	Content.Client/Construction/UI/ConstructionMenuPresenter.cs
#	Content.Shared/Construction/Prototypes/ConstructionPrototype.cs
#	Content.Shared/Damage/Systems/SharedStaminaSystem.cs
#	Content.Shared/Lock/LockSystem.cs
#	Resources/Prototypes/Entities/Mobs/Customization/Markings/human_hair.yml
#	Resources/Prototypes/Entities/Objects/Specific/chemistry.yml
#	Resources/Prototypes/Procedural/vgroid.yml
This commit is contained in:
Ed
2025-05-12 14:25:42 +03:00
1224 changed files with 155650 additions and 65383 deletions

View File

@@ -2,6 +2,7 @@
concurrency:
group: publish-testing
cancel-in-progress: true
on:
workflow_dispatch:

View File

@@ -2,6 +2,7 @@ name: Publish
concurrency:
group: publish
cancel-in-progress: true
on:
workflow_dispatch:
@@ -48,12 +49,14 @@ jobs:
GITHUB_REPOSITORY: ${{ vars.GITHUB_REPOSITORY }}
- name: Publish changelog (Discord)
continue-on-error: true
run: Tools/actions_changelogs_since_last_run.py
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.CHANGELOG_DISCORD_WEBHOOK }}
- name: Publish changelog (RSS)
continue-on-error: true
run: Tools/actions_changelog_rss.py
env:
CHANGELOG_RSS_KEY: ${{ secrets.CHANGELOG_RSS_KEY }}

View File

@@ -5,6 +5,7 @@ import subprocess
import sys
import os
import shutil
import time
from pathlib import Path
from typing import List
@@ -104,7 +105,21 @@ def reset_solution():
with SOLUTION_PATH.open("w") as f:
f.write(content)
def check_for_zip_download():
# Check if .git exists,
cur_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
if not os.path.isdir(os.path.join(cur_dir, ".git")):
print("It appears that you downloaded this repository directly from GitHub. (Using the .zip download option) \n"
"When downloading straight from GitHub, it leaves out important information that git needs to function. "
"Such as information to download the engine or even the ability to even be able to create contributions. \n"
"Please read and follow https://docs.spacestation14.com/en/general-development/setup/setting-up-a-development-environment.html \n"
"If you just want a Sandbox Server, you are following the wrong guide! You can download a premade server following the instructions here:"
"https://docs.spacestation14.com/en/general-development/setup/server-hosting-tutorial.html \n"
"Closing automatically in 30 seconds.")
time.sleep(30)
exit(1)
if __name__ == '__main__':
check_for_zip_download()
install_hooks()
update_submodules()

View File

@@ -202,6 +202,7 @@ namespace Content.Client.Actions
return;
OnActionAdded?.Invoke(actionId);
ActionsUpdated?.Invoke();
}
protected override void ActionRemoved(EntityUid performer, EntityUid actionId, ActionsComponent comp, BaseActionComponent action)
@@ -210,6 +211,7 @@ namespace Content.Client.Actions
return;
OnActionRemoved?.Invoke(actionId);
ActionsUpdated?.Invoke();
}
public IEnumerable<(EntityUid Id, BaseActionComponent Comp)> GetClientActions()

View File

@@ -2,6 +2,7 @@ using System.Linq;
using Content.Shared.Alert;
using JetBrains.Annotations;
using Robust.Client.Player;
using Robust.Client.UserInterface;
using Robust.Shared.GameStates;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
@@ -15,6 +16,7 @@ public sealed class ClientAlertsSystem : AlertsSystem
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IUserInterfaceManager _ui = default!;
public event EventHandler? ClearAlerts;
public event EventHandler<IReadOnlyDictionary<AlertKey, AlertState>>? SyncAlerts;
@@ -27,6 +29,12 @@ public sealed class ClientAlertsSystem : AlertsSystem
SubscribeLocalEvent<AlertsComponent, LocalPlayerDetachedEvent>(OnPlayerDetached);
SubscribeLocalEvent<AlertsComponent, ComponentHandleState>(OnHandleState);
}
protected override void HandledAlert()
{
_ui.ClickSound();
}
protected override void LoadPrototypes()
{
base.LoadPrototypes();
@@ -52,8 +60,24 @@ public sealed class ClientAlertsSystem : AlertsSystem
if (args.Current is not AlertComponentState cast)
return;
// Save all client-sided alerts to later put back in
var clientAlerts = new Dictionary<AlertKey, AlertState>();
foreach (var alert in alerts.Comp.Alerts)
{
if (alert.Key.AlertType != null && TryGet(alert.Key.AlertType.Value, out var alertProto))
{
if (alertProto.ClientHandled)
clientAlerts[alert.Key] = alert.Value;
}
}
alerts.Comp.Alerts = new(cast.Alerts);
foreach (var alert in clientAlerts)
{
alerts.Comp.Alerts[alert.Key] = alert.Value;
}
UpdateHud(alerts);
}

View File

@@ -0,0 +1,29 @@
using Content.Shared.Atmos.Components;
using Content.Shared.Atmos.EntitySystems;
namespace Content.Client.Atmos.EntitySystems;
public sealed class GasTankSystem : SharedGasTankSystem
{
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<GasTankComponent, AfterAutoHandleStateEvent>(OnGasTankState);
}
private void OnGasTankState(Entity<GasTankComponent> ent, ref AfterAutoHandleStateEvent args)
{
if (UI.TryGetOpenUi(ent.Owner, SharedGasTankUiKey.Key, out var bui))
{
bui.Update<GasTankBoundUserInterfaceState>();
}
}
public override void UpdateUserInterface(Entity<GasTankComponent> ent)
{
if (UI.TryGetOpenUi(ent.Owner, SharedGasTankUiKey.Key, out var bui))
{
bui.Update<GasTankBoundUserInterfaceState>();
}
}
}

View File

@@ -0,0 +1,32 @@
using Content.Client.Atmos.UI;
using Content.Shared.Atmos.Piping.Binary.Components;
using Content.Shared.Atmos.Piping.Unary.Components;
using Content.Shared.Atmos.Piping.Unary.Systems;
using Content.Shared.NodeContainer;
namespace Content.Client.Atmos.Piping.Unary.Systems;
public sealed class GasCanisterSystem : SharedGasCanisterSystem
{
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<GasCanisterComponent, AfterAutoHandleStateEvent>(OnGasState);
}
private void OnGasState(Entity<GasCanisterComponent> ent, ref AfterAutoHandleStateEvent args)
{
if (UI.TryGetOpenUi<GasCanisterBoundUserInterface>(ent.Owner, GasCanisterUiKey.Key, out var bui))
{
bui.Update<GasCanisterBoundUserInterfaceState>();
}
}
protected override void DirtyUI(EntityUid uid, GasCanisterComponent? component = null, NodeContainerComponent? nodes = null)
{
if (UI.TryGetOpenUi<GasCanisterBoundUserInterface>(uid, GasCanisterUiKey.Key, out var bui))
{
bui.Update<GasCanisterBoundUserInterfaceState>();
}
}
}

View File

@@ -1,4 +1,7 @@
using Content.Shared.Atmos.Piping.Binary.Components;
using Content.Shared.Atmos.Components;
using Content.Shared.Atmos.Piping.Binary.Components;
using Content.Shared.Atmos.Piping.Unary.Components;
using Content.Shared.IdentityManagement;
using JetBrains.Annotations;
using Robust.Client.GameObjects;
using Robust.Client.UserInterface;
@@ -32,22 +35,22 @@ namespace Content.Client.Atmos.UI
private void OnTankEjectPressed()
{
SendMessage(new GasCanisterHoldingTankEjectMessage());
SendPredictedMessage(new GasCanisterHoldingTankEjectMessage());
}
private void OnReleasePressureSet(float value)
{
SendMessage(new GasCanisterChangeReleasePressureMessage(value));
SendPredictedMessage(new GasCanisterChangeReleasePressureMessage(value));
}
private void OnReleaseValveOpenPressed()
{
SendMessage(new GasCanisterChangeReleaseValveMessage(true));
SendPredictedMessage(new GasCanisterChangeReleaseValveMessage(true));
}
private void OnReleaseValveClosePressed()
{
SendMessage(new GasCanisterChangeReleaseValveMessage(false));
SendPredictedMessage(new GasCanisterChangeReleaseValveMessage(false));
}
/// <summary>
@@ -57,17 +60,21 @@ namespace Content.Client.Atmos.UI
protected override void UpdateState(BoundUserInterfaceState state)
{
base.UpdateState(state);
if (_window == null || state is not GasCanisterBoundUserInterfaceState cast)
if (_window == null || state is not GasCanisterBoundUserInterfaceState cast || !EntMan.TryGetComponent(Owner, out GasCanisterComponent? component))
return;
_window.SetCanisterLabel(cast.CanisterLabel);
var canisterLabel = Identity.Name(Owner, EntMan);
var tankLabel = component.GasTankSlot.Item != null ? Identity.Name(component.GasTankSlot.Item.Value, EntMan) : null;
_window.SetCanisterLabel(canisterLabel);
_window.SetCanisterPressure(cast.CanisterPressure);
_window.SetPortStatus(cast.PortStatus);
_window.SetTankLabel(cast.TankLabel);
_window.SetTankLabel(tankLabel);
_window.SetTankPressure(cast.TankPressure);
_window.SetReleasePressureRange(cast.ReleasePressureMin, cast.ReleasePressureMax);
_window.SetReleasePressure(cast.ReleasePressure);
_window.SetReleaseValve(cast.ReleaseValve);
_window.SetReleasePressureRange(component.MinReleasePressure, component.MaxReleasePressure);
_window.SetReleasePressure(component.ReleasePressure);
_window.SetReleaseValve(component.ReleaseValve);
}
protected override void Dispose(bool disposing)

View File

@@ -13,9 +13,6 @@ namespace Content.Client.Atmos.UI;
[UsedImplicitly]
public sealed class GasPressurePumpBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
{
[ViewVariables]
private const float MaxPressure = Atmospherics.MaxOutputPressure;
[ViewVariables]
private GasPressurePumpWindow? _window;

View File

@@ -0,0 +1,24 @@
using Content.Shared.Atmos.Components;
using Content.Shared.Body.Components;
using Content.Shared.Body.Systems;
namespace Content.Client.Body.Systems;
public sealed class InternalsSystem : SharedInternalsSystem
{
[Dependency] private readonly SharedUserInterfaceSystem _ui = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<InternalsComponent, AfterAutoHandleStateEvent>(OnInternalsAfterState);
}
private void OnInternalsAfterState(Entity<InternalsComponent> ent, ref AfterAutoHandleStateEvent args)
{
if (ent.Comp.GasTankEntity != null && _ui.TryGetOpenUi(ent.Comp.GasTankEntity.Value, SharedGasTankUiKey.Key, out var bui))
{
bui.Update();
}
}
}

View File

@@ -38,9 +38,10 @@
<!-- Products get added here by code -->
</BoxContainer>
</ScrollContainer>
<Control MinHeight="5"/>
<Control MinHeight="5" Name="OrdersSpacer"/>
<PanelContainer VerticalExpand="True"
SizeFlagsStretchRatio="1">
SizeFlagsStretchRatio="1"
Name="Orders">
<PanelContainer.PanelOverride>
<gfx:StyleBoxFlat BackgroundColor="#000000" />
</PanelContainer.PanelOverride>

View File

@@ -208,6 +208,7 @@ namespace Content.Client.Cargo.UI
var product = _protoManager.Index<EntityPrototype>(order.ProductId);
var productName = product.Name;
var account = _protoManager.Index(order.Account);
var row = new CargoOrderRow
{
@@ -219,7 +220,9 @@ namespace Content.Client.Cargo.UI
"cargo-console-menu-populate-orders-cargo-order-row-product-name-text",
("productName", productName),
("orderAmount", order.OrderQuantity),
("orderRequester", order.Requester))
("orderRequester", order.Requester),
("accountColor", account.Color),
("account", Loc.GetString(account.Code)))
},
Description =
{
@@ -282,6 +285,9 @@ namespace Content.Client.Cargo.UI
AccountActionButton.Disabled = TransferSpinBox.Value <= 0 ||
TransferSpinBox.Value > bankAccount.Accounts[orderConsole.Account] * orderConsole.TransferLimit ||
_timing.CurTime < orderConsole.NextAccountActionTime;
OrdersSpacer.Visible = !orderConsole.SlipPrinter;
Orders.Visible = !orderConsole.SlipPrinter;
}
}
}

View File

@@ -11,11 +11,10 @@
<BoxContainer Orientation="Vertical"
HorizontalExpand="True"
VerticalExpand="True">
<Label Name="ProductName"
<RichTextLabel Name="ProductName"
Access="Public"
HorizontalExpand="True"
StyleClasses="LabelSubText"
ClipText="True" />
StyleClasses="LabelSubText" />
<Label Name="Description"
Access="Public"
HorizontalExpand="True"

View File

@@ -36,6 +36,7 @@ namespace Content.Client.Cargo.UI
{
var product = protoManager.Index<EntityPrototype>(order.ProductId);
var productName = product.Name;
var account = protoManager.Index(order.Account);
var row = new CargoOrderRow
{
@@ -47,7 +48,9 @@ namespace Content.Client.Cargo.UI
"cargo-console-menu-populate-orders-cargo-order-row-product-name-text",
("productName", productName),
("orderAmount", order.OrderQuantity - order.NumDispatched),
("orderRequester", order.Requester))
("orderRequester", order.Requester),
("accountColor", account.Color),
("account", Loc.GetString(account.Code)))
},
Description = {Text = Loc.GetString("cargo-console-menu-order-reason-description",
("reason", order.Reason))}

View File

@@ -16,6 +16,7 @@ public sealed class TypingIndicatorSystem : SharedTypingIndicatorSystem
private readonly TimeSpan _typingTimeout = TimeSpan.FromSeconds(2);
private TimeSpan _lastTextChange;
private bool _isClientTyping;
private bool _isClientChatFocused;
public override void Initialize()
{
@@ -31,7 +32,8 @@ public sealed class TypingIndicatorSystem : SharedTypingIndicatorSystem
return;
// client typed something - show typing indicator
ClientUpdateTyping(true);
_isClientTyping = true;
ClientUpdateTyping();
_lastTextChange = _time.CurTime;
}
@@ -42,7 +44,19 @@ public sealed class TypingIndicatorSystem : SharedTypingIndicatorSystem
return;
// client submitted text - hide typing indicator
ClientUpdateTyping(false);
_isClientTyping = false;
ClientUpdateTyping();
}
public void ClientChangedChatFocus(bool isFocused)
{
// don't update it if player don't want to show typing
if (!_cfg.GetCVar(CCVars.ChatShowTypingIndicator))
return;
// client submitted text - hide typing indicator
_isClientChatFocused = isFocused;
ClientUpdateTyping();
}
public override void Update(float frameTime)
@@ -55,23 +69,25 @@ public sealed class TypingIndicatorSystem : SharedTypingIndicatorSystem
var dif = _time.CurTime - _lastTextChange;
if (dif > _typingTimeout)
{
// client didn't typed anything for a long time - hide indicator
ClientUpdateTyping(false);
// client didn't typed anything for a long time - change indicator
_isClientTyping = false;
ClientUpdateTyping();
}
}
}
private void ClientUpdateTyping(bool isClientTyping)
private void ClientUpdateTyping()
{
if (_isClientTyping == isClientTyping)
return;
// check if player controls any entity.
// check if player controls any pawn
if (_playerManager.LocalEntity == null)
return;
_isClientTyping = isClientTyping;
RaisePredictiveEvent(new TypingChangedEvent(isClientTyping));
var state = TypingIndicatorState.None;
if (_isClientChatFocused)
state = _isClientTyping ? TypingIndicatorState.Typing : TypingIndicatorState.Idle;
// send a networked event to server
RaisePredictiveEvent(new TypingChangedEvent(state));
}
private void OnShowTypingChanged(bool showTyping)
@@ -79,7 +95,8 @@ public sealed class TypingIndicatorSystem : SharedTypingIndicatorSystem
// hide typing indicator immediately if player don't want to show it anymore
if (!showTyping)
{
ClientUpdateTyping(false);
_isClientTyping = false;
ClientUpdateTyping();
}
}
}

View File

@@ -35,7 +35,6 @@ public sealed class TypingIndicatorVisualizerSystem : VisualizerSystem<TypingInd
return;
}
AppearanceSystem.TryGetData<bool>(uid, TypingIndicatorVisuals.IsTyping, out var isTyping, args.Component);
var layerExists = args.Sprite.LayerMapTryGet(TypingIndicatorLayers.Base, out var layer);
if (!layerExists)
layer = args.Sprite.LayerMapReserveBlank(TypingIndicatorLayers.Base);
@@ -44,6 +43,17 @@ public sealed class TypingIndicatorVisualizerSystem : VisualizerSystem<TypingInd
args.Sprite.LayerSetState(layer, proto.TypingState);
args.Sprite.LayerSetShader(layer, proto.Shader);
args.Sprite.LayerSetOffset(layer, proto.Offset);
args.Sprite.LayerSetVisible(layer, isTyping);
AppearanceSystem.TryGetData<TypingIndicatorState>(uid, TypingIndicatorVisuals.State, out var state);
args.Sprite.LayerSetVisible(layer, state != TypingIndicatorState.None);
switch (state)
{
case TypingIndicatorState.Idle:
args.Sprite.LayerSetState(layer, proto.IdleState);
break;
case TypingIndicatorState.Typing:
args.Sprite.LayerSetState(layer, proto.TypingState);
break;
}
}
}

View File

@@ -116,7 +116,10 @@ namespace Content.Client.Chemistry.UI
("1", ChemMasterReagentAmount.U1, StyleBase.ButtonOpenBoth),
("5", ChemMasterReagentAmount.U5, StyleBase.ButtonOpenBoth),
("10", ChemMasterReagentAmount.U10, StyleBase.ButtonOpenBoth),
("15", ChemMasterReagentAmount.U15, StyleBase.ButtonOpenBoth),
("20", ChemMasterReagentAmount.U20, StyleBase.ButtonOpenBoth),
("25", ChemMasterReagentAmount.U25, StyleBase.ButtonOpenBoth),
("30", ChemMasterReagentAmount.U30, StyleBase.ButtonOpenBoth),
("50", ChemMasterReagentAmount.U50, StyleBase.ButtonOpenBoth),
("100", ChemMasterReagentAmount.U100, StyleBase.ButtonOpenBoth),
(Loc.GetString("chem-master-window-buffer-all-amount"), ChemMasterReagentAmount.All, StyleBase.ButtonOpenLeft),

View File

@@ -44,7 +44,7 @@ public sealed class HyposprayStatusControl : Control
PrevMaxVolume = solution.MaxVolume;
PrevOnlyAffectsMobs = _parent.Comp.OnlyAffectsMobs;
var modeStringLocalized = Loc.GetString(_parent.Comp.OnlyAffectsMobs switch
var modeStringLocalized = Loc.GetString((_parent.Comp.OnlyAffectsMobs && _parent.Comp.CanContainerDraw) switch
{
false => "hypospray-all-mode-text",
true => "hypospray-mobs-only-mode-text",

View File

@@ -1,4 +1,5 @@
using Content.Client.Guidebook.Components;
using Content.Client.UserInterface.Controls;
using Content.Shared.Chemistry;
using Content.Shared.Containers.ItemSlots;
using JetBrains.Annotations;
@@ -31,8 +32,7 @@ namespace Content.Client.Chemistry.UI
// Setup window layout/elements
_window = this.CreateWindow<ReagentDispenserWindow>();
_window.Title = EntMan.GetComponent<MetaDataComponent>(Owner).EntityName;
_window.HelpGuidebookIds = EntMan.GetComponent<GuideHelpComponent>(Owner).Guides;
_window.SetInfoFromEntity(EntMan, Owner);
// Setup static button actions.
_window.EjectButton.OnPressed += _ => SendMessage(new ItemSlotButtonPressedEvent(SharedReagentDispenser.OutputSlotName));

View File

@@ -1,8 +1,10 @@
using System.Linq;
using Content.Shared.Construction.Prototypes;
using Robust.Client.GameObjects;
using Robust.Client.Placement;
using Robust.Client.Utility;
using Robust.Client.ResourceManagement;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
namespace Content.Client.Construction
{
@@ -45,7 +47,14 @@ namespace Content.Client.Construction
public override void StartHijack(PlacementManager manager)
{
base.StartHijack(manager);
manager.CurrentTextures = _prototype?.Layers.Select(sprite => sprite.DirFrame0()).ToList();
if (_prototype is null || !_constructionSystem.TryGetRecipePrototype(_prototype.ID, out var targetProtoId))
return;
if (!IoCManager.Resolve<IPrototypeManager>().TryIndex(targetProtoId, out EntityPrototype? proto))
return;
manager.CurrentTextures = SpriteComponent.GetPrototypeTextures(proto, IoCManager.Resolve<IResourceCache>()).ToList();
}
}
}

View File

@@ -1,11 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Client.Popups;
using Content.Shared.Construction;
using Content.Shared.Construction.Prototypes;
using Content.Shared.Construction.Steps;
using Content.Shared.Examine;
using Content.Shared.Input;
using Content.Shared.Interaction;
using Content.Shared.Wall;
using JetBrains.Annotations;
using Robust.Client.GameObjects;
@@ -15,6 +14,7 @@ using Robust.Shared.Input.Binding;
using Robust.Shared.Map;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
namespace Content.Client.Construction
{
@@ -25,7 +25,6 @@ namespace Content.Client.Construction
public sealed class ConstructionSystem : SharedConstructionSystem
{
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly ExamineSystemShared _examineSystem = default!;
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
[Dependency] private readonly PopupSystem _popupSystem = default!;
@@ -33,6 +32,8 @@ namespace Content.Client.Construction
private readonly Dictionary<int, EntityUid> _ghosts = new();
private readonly Dictionary<string, ConstructionGuide> _guideCache = new();
private readonly Dictionary<string, string> _recipesMetadataCache = [];
public bool CraftingEnabled { get; private set; }
/// <inheritdoc />
@@ -40,6 +41,8 @@ namespace Content.Client.Construction
{
base.Initialize();
WarmupRecipesCache();
UpdatesOutsidePrediction = true;
SubscribeLocalEvent<LocalPlayerAttachedEvent>(HandlePlayerAttached);
SubscribeNetworkEvent<AckStructureConstructionMessage>(HandleAckStructure);
@@ -63,6 +66,77 @@ namespace Content.Client.Construction
ClearGhost(component.GhostId);
}
public bool TryGetRecipePrototype(string constructionProtoId, [NotNullWhen(true)] out string? targetProtoId)
{
if (_recipesMetadataCache.TryGetValue(constructionProtoId, out targetProtoId))
return true;
targetProtoId = null;
return false;
}
private void WarmupRecipesCache()
{
foreach (var constructionProto in PrototypeManager.EnumeratePrototypes<ConstructionPrototype>())
{
if (!PrototypeManager.TryIndex(constructionProto.Graph, out var graphProto))
continue;
if (constructionProto.TargetNode is not { } targetNodeId)
continue;
if (!graphProto.Nodes.TryGetValue(targetNodeId, out var targetNode))
continue;
// Recursion is for wimps.
var stack = new Stack<ConstructionGraphNode>();
stack.Push(targetNode);
do
{
var node = stack.Pop();
// I never realized if this uid affects anything...
// EntityUid? userUid = args.SenderSession.State.ControlledEntity.HasValue
// ? GetEntity(args.SenderSession.State.ControlledEntity.Value)
// : null;
// We try to get the id of the target prototype, if it fails, we try going through the edges.
if (node.Entity.GetId(null, null, new(EntityManager)) is not { } entityId)
{
// If the stack is not empty, there is a high probability that the loop will go to infinity.
if (stack.Count == 0)
{
foreach (var edge in node.Edges)
{
if (graphProto.Nodes.TryGetValue(edge.Target, out var graphNode))
stack.Push(graphNode);
}
}
continue;
}
// If we got the id of the prototype, we exit the “recursion” by clearing the stack.
stack.Clear();
if (!PrototypeManager.TryIndex(constructionProto.ID, out ConstructionPrototype? recipe))
continue;
if (!PrototypeManager.TryIndex(entityId, out var proto))
continue;
var name = recipe.SetName.HasValue ? Loc.GetString(recipe.SetName) : proto.Name;
var desc = recipe.SetDescription.HasValue ? Loc.GetString(recipe.SetDescription) : proto.Description;
recipe.Name = name;
recipe.Description = desc;
_recipesMetadataCache.Add(constructionProto.ID, entityId);
} while (stack.Count > 0);
}
}
private void OnConstructionGuideReceived(ResponseConstructionGuide ev)
{
_guideCache[ev.ConstructionId] = ev.Guide;
@@ -88,7 +162,7 @@ namespace Content.Client.Construction
private void HandleConstructionGhostExamined(EntityUid uid, ConstructionGhostComponent component, ExaminedEvent args)
{
if (component.Prototype == null)
if (component.Prototype?.Name is null)
return;
using (args.PushGroup(nameof(ConstructionGhostComponent)))
@@ -97,7 +171,7 @@ namespace Content.Client.Construction
"construction-ghost-examine-message",
("name", component.Prototype.Name)));
if (!_prototypeManager.TryIndex(component.Prototype.Graph, out ConstructionGraphPrototype? graph))
if (!PrototypeManager.TryIndex(component.Prototype.Graph, out var graph))
return;
var startNode = graph.Nodes[component.Prototype.StartNode];
@@ -198,6 +272,9 @@ namespace Content.Client.Construction
return false;
}
if (!TryGetRecipePrototype(prototype.ID, out var targetProtoId) || !PrototypeManager.TryIndex(targetProtoId, out EntityPrototype? targetProto))
return false;
if (GhostPresent(loc))
return false;
@@ -214,17 +291,44 @@ namespace Content.Client.Construction
comp.GhostId = ghost.GetHashCode();
EntityManager.GetComponent<TransformComponent>(ghost.Value).LocalRotation = dir.ToAngle();
_ghosts.Add(comp.GhostId, ghost.Value);
var sprite = EntityManager.GetComponent<SpriteComponent>(ghost.Value);
sprite.Color = new Color(48, 255, 48, 128);
for (int i = 0; i < prototype.Layers.Count; i++)
if (targetProto.TryGetComponent(out IconComponent? icon, EntityManager.ComponentFactory))
{
sprite.AddBlankLayer(i); // There is no way to actually check if this already exists, so we blindly insert a new one
sprite.LayerSetSprite(i, prototype.Layers[i]);
sprite.AddBlankLayer(0);
sprite.LayerSetSprite(0, icon.Icon);
sprite.LayerSetShader(0, "unshaded");
sprite.LayerSetVisible(0, true);
}
else if (targetProto.Components.TryGetValue("Sprite", out _))
{
var dummy = EntityManager.SpawnEntity(targetProtoId, MapCoordinates.Nullspace);
var targetSprite = EntityManager.EnsureComponent<SpriteComponent>(dummy);
EntityManager.System<AppearanceSystem>().OnChangeData(dummy, targetSprite);
for (var i = 0; i < targetSprite.AllLayers.Count(); i++)
{
if (!targetSprite[i].Visible || !targetSprite[i].RsiState.IsValid)
continue;
var rsi = targetSprite[i].Rsi ?? targetSprite.BaseRSI;
if (rsi is null || !rsi.TryGetState(targetSprite[i].RsiState, out var state) ||
state.StateId.Name is null)
continue;
sprite.AddBlankLayer(i);
sprite.LayerSetSprite(i, new SpriteSpecifier.Rsi(rsi.Path, state.StateId.Name));
sprite.LayerSetShader(i, "unshaded");
sprite.LayerSetVisible(i, true);
}
EntityManager.DeleteEntity(dummy);
}
else
return false;
if (prototype.CanBuildInImpassable)
EnsureComp<WallMountComponent>(ghost.Value).Arc = new(Math.Tau);

View File

@@ -1,11 +1,12 @@
<DefaultWindow xmlns="https://spacestation14.io">
<DefaultWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True">
<BoxContainer Orientation="Vertical" MinWidth="243" Margin="0 0 5 0">
<BoxContainer Orientation="Horizontal" HorizontalExpand="True" Margin="0 0 0 5">
<LineEdit Name="SearchBar" PlaceHolder="Search" HorizontalExpand="True"/>
<LineEdit Name="SearchBar" PlaceHolder="{Loc 'construction-menu-search'}" HorizontalExpand="True"/>
<OptionButton Name="OptionCategories" Access="Public" MinSize="130 0"/>
</BoxContainer>
<ItemList Name="Recipes" Access="Public" SelectMode="Single" VerticalExpand="True"/>
<controls:ListContainer Name="Recipes" Access="Public" Group="True" Toggle="True" VerticalExpand="True" />
<ScrollContainer Name="RecipesGridScrollContainer" VerticalExpand="True" Access="Public" Visible="False">
<GridContainer Name="RecipesGrid" Columns="5" Access="Public"/>
</ScrollContainer>
@@ -18,7 +19,7 @@
<Control>
<BoxContainer Orientation="Vertical" HorizontalExpand="True" Margin="0 0 0 5">
<BoxContainer Orientation="Horizontal" Align="Center">
<TextureRect Name="TargetTexture" HorizontalAlignment="Right" Stretch="Keep" Margin="0 0 10 0"/>
<EntityPrototypeView Name="TargetTexture" HorizontalAlignment="Right" Stretch="Fill" Margin="0 0 10 0"/>
<BoxContainer Orientation="Vertical">
<RichTextLabel Name="TargetName"/>
<RichTextLabel Name="TargetDesc"/>

View File

@@ -1,14 +1,11 @@
using System;
using System.Numerics;
using Content.Client.UserInterface.Controls;
using Content.Shared.Construction.Prototypes;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Graphics;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Prototypes;
namespace Content.Client.Construction.UI
{
@@ -28,7 +25,7 @@ namespace Content.Client.Construction.UI
bool GridViewButtonPressed { get; set; }
bool BuildButtonPressed { get; set; }
ItemList Recipes { get; }
ListContainer Recipes { get; }
ItemList RecipeStepList { get; }
@@ -36,14 +33,14 @@ namespace Content.Client.Construction.UI
GridContainer RecipesGrid { get; }
event EventHandler<(string search, string catagory)> PopulateRecipes;
event EventHandler<ItemList.Item?> RecipeSelected;
event EventHandler<ConstructionMenu.ConstructionMenuListData?> RecipeSelected;
event EventHandler RecipeFavorited;
event EventHandler<bool> BuildButtonToggled;
event EventHandler<bool> EraseButtonToggled;
event EventHandler ClearAllGhosts;
void ClearRecipeInfo();
void SetRecipeInfo(string name, string description, Texture iconTexture, bool isItem, bool isFavorite);
void SetRecipeInfo(string name, string description, EntityPrototype? targetPrototype, bool isItem, bool isFavorite);
void ResetPlacement();
#region Window Control
@@ -94,8 +91,36 @@ namespace Content.Client.Construction.UI
Title = Loc.GetString("construction-menu-title");
BuildButton.Text = Loc.GetString("construction-menu-place-ghost");
Recipes.OnItemSelected += obj => RecipeSelected?.Invoke(this, obj.ItemList[obj.ItemIndex]);
Recipes.OnItemDeselected += _ => RecipeSelected?.Invoke(this, null);
Recipes.ItemPressed += (_, data) => RecipeSelected?.Invoke(this, data as ConstructionMenuListData);
Recipes.NoItemSelected += () => RecipeSelected?.Invoke(this, null);
Recipes.GenerateItem += (data, button) =>
{
if (data is not ConstructionMenuListData (var prototype, var targetPrototype))
return;
var entProtoView = new EntityPrototypeView()
{
SetSize = new(32f),
Stretch = SpriteView.StretchMode.Fill,
Scale = new(2),
Margin = new(0, 2),
};
entProtoView.SetPrototype(targetPrototype);
var label = new Label()
{
Text = prototype.Name,
Margin = new(5, 0),
};
var box = new BoxContainer();
box.AddChild(entProtoView);
box.AddChild(label);
button.AddChild(box);
button.ToolTip = prototype.Description;
button.AddStyleClass(ListContainer.StyleClassListContainerButton);
};
SearchBar.OnTextChanged += _ =>
PopulateRecipes?.Invoke(this, (SearchBar.Text, Categories[OptionCategories.SelectedId]));
@@ -121,7 +146,7 @@ namespace Content.Client.Construction.UI
public event EventHandler? ClearAllGhosts;
public event EventHandler<(string search, string catagory)>? PopulateRecipes;
public event EventHandler<ItemList.Item?>? RecipeSelected;
public event EventHandler<ConstructionMenuListData?>? RecipeSelected;
public event EventHandler? RecipeFavorited;
public event EventHandler<bool>? BuildButtonToggled;
public event EventHandler<bool>? EraseButtonToggled;
@@ -133,13 +158,17 @@ namespace Content.Client.Construction.UI
}
public void SetRecipeInfo(
string name, string description, Texture iconTexture, bool isItem, bool isFavorite)
string name,
string description,
EntityPrototype? targetPrototype,
bool isItem,
bool isFavorite)
{
BuildButton.Disabled = false;
BuildButton.Text = Loc.GetString(isItem ? "construction-menu-place-ghost" : "construction-menu-craft");
TargetName.SetMessage(name);
TargetDesc.SetMessage(description);
TargetTexture.Texture = iconTexture;
TargetTexture.SetPrototype(targetPrototype?.ID);
FavoriteButton.Visible = true;
FavoriteButton.Text = Loc.GetString(
isFavorite ? "construction-add-favorite-button" : "construction-remove-from-favorite-button");
@@ -150,9 +179,11 @@ namespace Content.Client.Construction.UI
BuildButton.Disabled = true;
TargetName.SetMessage(string.Empty);
TargetDesc.SetMessage(string.Empty);
TargetTexture.Texture = null;
TargetTexture.SetPrototype(null);
FavoriteButton.Visible = false;
RecipeStepList.Clear();
}
public sealed record ConstructionMenuListData(ConstructionPrototype Prototype, EntityPrototype TargetPrototype) : ListData;
}
}

View File

@@ -10,10 +10,8 @@ using Robust.Client.Placement;
using Robust.Client.Player;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.Utility;
using Robust.Shared.Enums;
using Robust.Shared.Prototypes;
using static Robust.Client.UserInterface.Controls.BaseButton;
namespace Content.Client.Construction.UI
{
@@ -30,18 +28,20 @@ namespace Content.Client.Construction.UI
[Dependency] private readonly IPlacementManager _placementManager = default!;
[Dependency] private readonly IUserInterfaceManager _uiManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
private readonly SpriteSystem _spriteSystem;
private readonly IConstructionMenuView _constructionView;
private readonly EntityWhitelistSystem _whitelistSystem;
private readonly SpriteSystem _spriteSystem;
private ConstructionSystem? _constructionSystem;
private ConstructionPrototype? _selected;
private List<ConstructionPrototype> _favoritedRecipes = [];
private Dictionary<string, TextureButton> _recipeButtons = new();
private Dictionary<string, ContainerButton> _recipeButtons = new();
private string _selectedCategory = string.Empty;
private string _favoriteCatName = "construction-category-favorites";
private string _forAllCategoryName = "construction-category-all";
private const string FavoriteCatName = "construction-category-favorites";
private const string ForAllCategoryName = "construction-category-all";
private bool CraftingAvailable
{
get => _uiManager.GetActiveUIWidget<GameTopMenuBar>().CraftingButton.Visible;
@@ -98,15 +98,18 @@ namespace Content.Client.Construction.UI
_placementManager.PlacementChanged += OnPlacementChanged;
_constructionView.OnClose += () => _uiManager.GetActiveUIWidget<GameTopMenuBar>().CraftingButton.Pressed = false;
_constructionView.OnClose +=
() => _uiManager.GetActiveUIWidget<GameTopMenuBar>().CraftingButton.Pressed = false;
_constructionView.ClearAllGhosts += (_, _) => _constructionSystem?.ClearAllGhosts();
_constructionView.PopulateRecipes += OnViewPopulateRecipes;
_constructionView.RecipeSelected += OnViewRecipeSelected;
_constructionView.BuildButtonToggled += (_, b) => BuildButtonToggled(b);
_constructionView.EraseButtonToggled += (_, b) =>
{
if (_constructionSystem is null) return;
if (b) _placementManager.Clear();
if (_constructionSystem is null)
return;
if (b)
_placementManager.Clear();
_placementManager.ToggleEraserHijacked(new ConstructionPlacementHijack(_constructionSystem, null));
_constructionView.EraseButtonPressed = b;
};
@@ -117,7 +120,7 @@ namespace Content.Client.Construction.UI
OnViewPopulateRecipes(_constructionView, (string.Empty, string.Empty));
}
public void OnHudCraftingButtonToggled(ButtonToggledEventArgs args)
public void OnHudCraftingButtonToggled(BaseButton.ButtonToggledEventArgs args)
{
WindowOpen = args.Pressed;
}
@@ -139,7 +142,7 @@ namespace Content.Client.Construction.UI
_constructionView.ResetPlacement();
}
private void OnViewRecipeSelected(object? sender, ItemList.Item? item)
private void OnViewRecipeSelected(object? sender, ConstructionMenu.ConstructionMenuListData? item)
{
if (item is null)
{
@@ -148,12 +151,15 @@ namespace Content.Client.Construction.UI
return;
}
_selected = (ConstructionPrototype) item.Metadata!;
if (_placementManager.IsActive && !_placementManager.Eraser) UpdateGhostPlacement();
_selected = item.Prototype;
if (_placementManager is { IsActive: true, Eraser: false })
UpdateGhostPlacement();
PopulateInfo(_selected);
}
private void OnGridViewRecipeSelected(object? sender, ConstructionPrototype? recipe)
private void OnGridViewRecipeSelected(object? _, ConstructionPrototype? recipe)
{
if (recipe is null)
{
@@ -163,65 +169,21 @@ namespace Content.Client.Construction.UI
}
_selected = recipe;
if (_placementManager.IsActive && !_placementManager.Eraser) UpdateGhostPlacement();
if (_placementManager is { IsActive: true, Eraser: false })
UpdateGhostPlacement();
PopulateInfo(_selected);
}
private void OnViewPopulateRecipes(object? sender, (string search, string catagory) args)
{
var (search, category) = args;
if (_constructionSystem is null)
return;
var recipes = new List<ConstructionPrototype>();
var isEmptyCategory = string.IsNullOrEmpty(category) || category == _forAllCategoryName;
if (isEmptyCategory)
_selectedCategory = string.Empty;
else
_selectedCategory = category;
foreach (var recipe in _prototypeManager.EnumeratePrototypes<ConstructionPrototype>())
{
if (recipe.Hide)
continue;
if (!recipe.CrystallPunkAllowed) //CrystallEdge clearing recipes
continue;
if (_playerManager.LocalSession == null
|| _playerManager.LocalEntity == null
|| _whitelistSystem.IsWhitelistFail(recipe.EntityWhitelist, _playerManager.LocalEntity.Value))
continue;
if (!string.IsNullOrEmpty(search))
{
if (!recipe.Name.ToLowerInvariant().Contains(search.Trim().ToLowerInvariant()))
continue;
}
if (!isEmptyCategory)
{
if (category == _favoriteCatName)
{
if (!_favoritedRecipes.Contains(recipe))
{
continue;
}
}
else if (recipe.Category != category)
{
continue;
}
}
recipes.Add(recipe);
}
recipes.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.InvariantCulture));
var actualRecipes = GetAndSortRecipes(args);
var recipesList = _constructionView.Recipes;
recipesList.Clear();
var recipesGrid = _constructionView.RecipesGrid;
recipesGrid.RemoveAllChildren();
@@ -230,17 +192,38 @@ namespace Content.Client.Construction.UI
if (_constructionView.GridViewButtonPressed)
{
foreach (var recipe in recipes)
recipesList.PopulateList([]);
PopulateGrid(recipesGrid, actualRecipes);
}
else
{
var itemButton = new TextureButton
recipesList.PopulateList(actualRecipes);
}
}
private void PopulateGrid(GridContainer recipesGrid,
IEnumerable<ConstructionMenu.ConstructionMenuListData> actualRecipes)
{
TextureNormal = _spriteSystem.Frame0(recipe.Icon),
VerticalAlignment = Control.VAlignment.Center,
Name = recipe.Name,
ToolTip = recipe.Name,
Scale = new Vector2(1.35f),
ToggleMode = true,
foreach (var recipe in actualRecipes)
{
if (!recipe.Prototype.CrystallPunkAllowed) //CrystallEdge clearing recipes
continue;
var protoView = new EntityPrototypeView()
{
Scale = new Vector2(1.2f),
};
protoView.SetPrototype(recipe.TargetPrototype);
var itemButton = new ContainerButton()
{
VerticalAlignment = Control.VAlignment.Center,
Name = recipe.TargetPrototype.Name,
ToolTip = recipe.TargetPrototype.Name,
ToggleMode = true,
Children = { protoView },
};
var itemButtonPanelContainer = new PanelContainer
{
PanelOverride = new StyleBoxFlat { BackgroundColor = StyleNano.ButtonColorDefault },
@@ -253,37 +236,79 @@ namespace Content.Client.Construction.UI
if (buttonToggledEventArgs.Pressed &&
_selected != null &&
_recipeButtons.TryGetValue(_selected.Name, out var oldButton))
_recipeButtons.TryGetValue(_selected.Name!, out var oldButton))
{
oldButton.Pressed = false;
SelectGridButton(oldButton, false);
}
OnGridViewRecipeSelected(this, buttonToggledEventArgs.Pressed ? recipe : null);
OnGridViewRecipeSelected(this, buttonToggledEventArgs.Pressed ? recipe.Prototype : null);
};
recipesGrid.AddChild(itemButtonPanelContainer);
_recipeButtons[recipe.Name] = itemButton;
var isCurrentButtonSelected = _selected == recipe;
_recipeButtons[recipe.Prototype.Name!] = itemButton;
var isCurrentButtonSelected = _selected == recipe.Prototype;
itemButton.Pressed = isCurrentButtonSelected;
SelectGridButton(itemButton, isCurrentButtonSelected);
}
}
else
private List<ConstructionMenu.ConstructionMenuListData> GetAndSortRecipes((string, string) args)
{
foreach (var recipe in recipes)
var recipes = new List<ConstructionMenu.ConstructionMenuListData>();
var (search, category) = args;
var isEmptyCategory = string.IsNullOrEmpty(category) || category == ForAllCategoryName;
_selectedCategory = isEmptyCategory ? string.Empty : category;
foreach (var recipe in _prototypeManager.EnumeratePrototypes<ConstructionPrototype>())
{
recipesList.Add(GetItem(recipe, recipesList));
}
}
if (recipe.Hide)
continue;
if (_playerManager.LocalSession == null
|| _playerManager.LocalEntity == null
|| _whitelistSystem.IsWhitelistFail(recipe.EntityWhitelist, _playerManager.LocalEntity.Value))
continue;
if (!string.IsNullOrEmpty(search) && (recipe.Name is { } name &&
!name.Contains(search.Trim(),
StringComparison.InvariantCultureIgnoreCase)))
continue;
if (!isEmptyCategory)
{
if ((category != FavoriteCatName || !_favoritedRecipes.Contains(recipe)) &&
recipe.Category != category)
continue;
}
private void SelectGridButton(TextureButton button, bool select)
if (!_constructionSystem!.TryGetRecipePrototype(recipe.ID, out var targetProtoId))
{
Logger.Error("Cannot find the target prototype in the recipe cache with the id \"{0}\" of {1}.",
recipe.ID,
nameof(ConstructionPrototype));
continue;
}
if (!_prototypeManager.TryIndex(targetProtoId, out EntityPrototype? proto))
continue;
recipes.Add(new(recipe, proto));
}
recipes.Sort(
(a, b) => string.Compare(a.Prototype.Name, b.Prototype.Name, StringComparison.InvariantCulture));
return recipes;
}
private void SelectGridButton(BaseButton button, bool select)
{
if (button.Parent is not PanelContainer buttonPanel)
return;
button.Modulate = select ? Color.Green : Color.White;
button.Modulate = select ? Color.Green : Color.Transparent;
var buttonColor = select ? StyleNano.ButtonColorDefault : Color.Transparent;
buttonPanel.PanelOverride = new StyleBoxFlat { BackgroundColor = buttonColor };
}
@@ -305,12 +330,12 @@ namespace Content.Client.Construction.UI
// hard-coded to show all recipes
var idx = 0;
categoriesArray[idx++] = _forAllCategoryName;
categoriesArray[idx++] = ForAllCategoryName;
// hard-coded to show favorites if it need
if (isFavorites)
{
categoriesArray[idx++] = _favoriteCatName;
categoriesArray[idx++] = FavoriteCatName;
}
var sortedProtoCategories = uniqueCategories.OrderBy(Loc.GetString);
@@ -328,18 +353,31 @@ namespace Content.Client.Construction.UI
if (!string.IsNullOrEmpty(selectCategory) && selectCategory == categoriesArray[i])
_constructionView.OptionCategories.SelectId(i);
}
_constructionView.Categories = categoriesArray;
}
private void PopulateInfo(ConstructionPrototype prototype)
private void PopulateInfo(ConstructionPrototype? prototype)
{
if (_constructionSystem is null)
return;
_constructionView.ClearRecipeInfo();
if (prototype is null)
return;
if (!_constructionSystem.TryGetRecipePrototype(prototype.ID, out var targetProtoId))
return;
if (!_prototypeManager.TryIndex(targetProtoId, out EntityPrototype? proto))
return;
_constructionView.SetRecipeInfo(
prototype.Name, prototype.Description, _spriteSystem.Frame0(prototype.Icon),
prototype.Name!,
prototype.Description!,
proto,
prototype.Type != ConstructionType.Item,
!_favoritedRecipes.Contains(prototype));
@@ -352,16 +390,17 @@ namespace Content.Client.Construction.UI
if (_constructionSystem?.GetGuide(prototype) is not { } guide)
return;
foreach (var entry in guide.Entries)
{
var text = entry.Arguments != null
? Loc.GetString(entry.Localization, entry.Arguments) : Loc.GetString(entry.Localization);
? Loc.GetString(entry.Localization, entry.Arguments)
: Loc.GetString(entry.Localization);
if (entry.EntryNumber is { } number)
{
text = Loc.GetString("construction-presenter-step-wrapper",
("step-number", number), ("text", text));
("step-number", number),
("text", text));
}
// The padding needs to be applied regardless of text length... (See PadLeft documentation)
@@ -372,23 +411,12 @@ namespace Content.Client.Construction.UI
}
}
private ItemList.Item GetItem(ConstructionPrototype recipe, ItemList itemList)
{
return new(itemList)
{
Metadata = recipe,
Text = recipe.Name,
Icon = _spriteSystem.Frame0(recipe.Icon),
TooltipEnabled = true,
TooltipText = recipe.Description,
};
}
private void BuildButtonToggled(bool pressed)
{
if (pressed)
{
if (_selected == null) return;
if (_selected == null)
return;
// not bound to a construction system
if (_constructionSystem is null)
@@ -408,7 +436,8 @@ namespace Content.Client.Construction.UI
{
IsTile = false,
PlacementOption = _selected.PlacementMode
}, new ConstructionPlacementHijack(_constructionSystem, _selected));
},
new ConstructionPlacementHijack(_constructionSystem, _selected));
UpdateGhostPlacement();
}
@@ -435,35 +464,36 @@ namespace Content.Client.Construction.UI
{
IsTile = false,
PlacementOption = _selected.PlacementMode,
}, new ConstructionPlacementHijack(constructSystem, _selected));
},
new ConstructionPlacementHijack(constructSystem, _selected));
_constructionView.BuildButtonPressed = true;
}
private void OnSystemLoaded(object? sender, SystemChangedArgs args)
{
if (args.System is ConstructionSystem system) SystemBindingChanged(system);
if (args.System is ConstructionSystem system)
SystemBindingChanged(system);
}
private void OnSystemUnloaded(object? sender, SystemChangedArgs args)
{
if (args.System is ConstructionSystem) SystemBindingChanged(null);
if (args.System is ConstructionSystem)
SystemBindingChanged(null);
}
private void OnViewFavoriteRecipe()
{
if (_selected is not ConstructionPrototype recipe)
if (_selected is null)
return;
if (!_favoritedRecipes.Remove(_selected))
_favoritedRecipes.Add(_selected);
if (_selectedCategory == _favoriteCatName)
if (_selectedCategory == FavoriteCatName)
{
if (_favoritedRecipes.Count > 0)
OnViewPopulateRecipes(_constructionView, (string.Empty, _favoriteCatName));
else
OnViewPopulateRecipes(_constructionView, (string.Empty, string.Empty));
OnViewPopulateRecipes(_constructionView,
_favoritedRecipes.Count > 0 ? (string.Empty, FavoriteCatName) : (string.Empty, string.Empty));
}
PopulateInfo(_selected);
@@ -495,6 +525,9 @@ namespace Content.Client.Construction.UI
private void BindToSystem(ConstructionSystem system)
{
_constructionSystem = system;
OnViewPopulateRecipes(_constructionView, (string.Empty, string.Empty));
system.ToggleCraftingWindow += SystemOnToggleMenu;
system.FlipConstructionPrototype += SystemFlipConstructionPrototype;
system.CraftingAvailabilityChanged += SystemCraftingAvailabilityChanged;
@@ -536,7 +569,8 @@ namespace Content.Client.Construction.UI
if (IsAtFront)
{
WindowOpen = false;
_uiManager.GetActiveUIWidget<GameTopMenuBar>().CraftingButton.SetClickPressed(false); // This does not call CraftingButtonToggled
_uiManager.GetActiveUIWidget<GameTopMenuBar>()
.CraftingButton.SetClickPressed(false); // This does not call CraftingButtonToggled
}
else
_constructionView.MoveToFront();
@@ -544,7 +578,8 @@ namespace Content.Client.Construction.UI
else
{
WindowOpen = true;
_uiManager.GetActiveUIWidget<GameTopMenuBar>().CraftingButton.SetClickPressed(true); // This does not call CraftingButtonToggled
_uiManager.GetActiveUIWidget<GameTopMenuBar>()
.CraftingButton.SetClickPressed(true); // This does not call CraftingButtonToggled
}
}

View File

@@ -0,0 +1,7 @@
using Content.Shared.Damage.Systems;
namespace Content.Client.Damage.Systems;
public sealed partial class StaminaSystem : SharedStaminaSystem
{
}

View File

@@ -4,6 +4,7 @@ using Content.Shared.Disposal;
using Content.Shared.Disposal.Components;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using System.Linq;
namespace Content.Client.Disposal.Mailing;
@@ -70,10 +71,10 @@ public sealed class MailingUnitBoundUserInterface : BoundUserInterface
//UnitTag.Text = state.Tag;
MailingUnitWindow.Target.Text = entity.Comp.Target;
MailingUnitWindow.TargetListContainer.Clear();
foreach (var target in entity.Comp.TargetList)
{
MailingUnitWindow.TargetListContainer.AddItem(target);
}
var entries = entity.Comp.TargetList.Select(target => new ItemList.Item(MailingUnitWindow.TargetListContainer) {
Text = target,
Selected = target == entity.Comp.Target
}).ToList();
MailingUnitWindow.TargetListContainer.SetItems(entries);
}
}

View File

@@ -22,6 +22,7 @@ using Content.Client.Replay;
using Content.Client.Screenshot;
using Content.Client.Singularity;
using Content.Client.Stylesheets;
using Content.Client.UserInterface;
using Content.Client.Viewport;
using Content.Client.Voting;
using Content.Shared._CP14.Sponsor;
@@ -82,6 +83,7 @@ namespace Content.Client.Entry
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly DebugMonitorManager _debugMonitorManager = default!;
[Dependency] private readonly TitleWindowManager _titleWindowManager = default!;
[Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
public override void Init()
{
@@ -240,6 +242,15 @@ namespace Content.Client.Entry
{
_debugMonitorManager.FrameUpdate();
}
if (level == ModUpdateLevel.PreEngine)
{
if (_baseClient.RunLevel is ClientRunLevel.InGame or ClientRunLevel.SinglePlayerGame)
{
var updateSystem = _entitySystemManager.GetEntitySystem<BuiPreTickUpdateSystem>();
updateSystem.RunUpdates();
}
}
}
}
}

View File

@@ -37,7 +37,6 @@ namespace Content.Client.Examine
public const string StyleClassEntityTooltip = "entity-tooltip";
private EntityUid _examinedEntity;
private EntityUid _lastExaminedEntity;
private Popup? _examineTooltipOpen;
private ScreenCoordinates _popupPos;
private CancellationTokenSource? _requestCancelTokenSource;
@@ -416,15 +415,14 @@ namespace Content.Client.Examine
if (!IsClientSide(entity))
{
// Ask server for extra examine info.
if (entity != _lastExaminedEntity)
unchecked
{
_idCounter += 1;
if (_idCounter == int.MaxValue)
_idCounter = 0;
}
RaiseNetworkEvent(new ExamineSystemMessages.RequestExamineInfoMessage(GetNetEntity(entity), _idCounter, true));
}
RaiseLocalEvent(entity, new ClientExaminedEvent(entity, playerEnt.Value));
_lastExaminedEntity = entity;
}
private void CloseTooltip()

View File

@@ -93,7 +93,7 @@ public sealed partial class TriggerSystem
break;
case ProximityTriggerVisuals.Active:
if (_player.HasRunningAnimation(uid, player, AnimKey)) return;
_player.Play(uid, player, _flasherAnimation, AnimKey);
_player.Play((uid, player), _flasherAnimation, AnimKey);
break;
case ProximityTriggerVisuals.Off:
default:

View File

@@ -34,7 +34,7 @@ public sealed class EyeLerpingSystem : EntitySystem
SubscribeLocalEvent<LerpingEyeComponent, LocalPlayerDetachedEvent>(OnDetached);
UpdatesAfter.Add(typeof(TransformSystem));
UpdatesAfter.Add(typeof(PhysicsSystem));
UpdatesAfter.Add(typeof(Robust.Client.Physics.PhysicsSystem));
UpdatesBefore.Add(typeof(SharedEyeSystem));
UpdatesOutsidePrediction = true;
}

View File

@@ -46,7 +46,7 @@ public sealed class HolopadSystem : SharedHolopadSystem
if (!HasComp<HolopadUserComponent>(uid))
return;
var netEv = new HolopadUserTypingChangedEvent(GetNetEntity(uid.Value), ev.IsTyping);
var netEv = new HolopadUserTypingChangedEvent(GetNetEntity(uid.Value), ev.State);
RaiseNetworkEvent(netEv);
}

View File

@@ -167,11 +167,14 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
private void UpdateRendererMaster(InstrumentComponent instrument)
{
if (instrument.Renderer == null || instrument.Master == null)
if (instrument.Renderer == null)
return;
if (!TryComp(instrument.Master, out InstrumentComponent? masterInstrument) || masterInstrument.Renderer == null)
if (instrument.Master == null || !TryComp(instrument.Master, out InstrumentComponent? masterInstrument) || masterInstrument.Renderer == null)
{
instrument.Renderer.Master = null;
return;
}
instrument.Renderer.Master = masterInstrument.Renderer;
}
@@ -196,15 +199,16 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
return;
}
instrument.Renderer?.SystemReset();
instrument.Renderer?.ClearAllEvents();
var renderer = instrument.Renderer;
if (instrument.Renderer is { } renderer)
{
renderer.Master = null;
renderer.SystemReset();
renderer.ClearAllEvents();
// We dispose of the synth two seconds from now to allow the last notes to stop from playing.
// Don't use timers bound to the entity in case it is getting deleted.
if (renderer != null)
Timer.Spawn(2000, () => { renderer.Dispose(); });
}
instrument.Renderer = null;
instrument.MidiEventBuffer.Clear();

View File

@@ -100,7 +100,27 @@
<controls:HSpacer Spacing="10" />
<widgets:ChatBox Name="Chat" Access="Public" VerticalExpand="True" Margin="3 3 3 3" MinHeight="50" />
</BoxContainer>
<TextureButton Name="CollapseButton"
TexturePath="filled_right_arrow.svg.192dpi"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0 2 2 0"
ModulateSelfOverride="#DEDEDE"
Scale="0.5 0.5"/>
</PanelContainer>
</SplitContainer>
<PanelContainer Name="ExpandPanel"
StyleClasses="AngleRect"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0 2 2 0"
Visible="False">
<TextureButton Name="ExpandButton"
TexturePath="filled_left_arrow.svg.192dpi"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ModulateSelfOverride="#DEDEDE"
Scale="0.5 0.5"/>
</PanelContainer>
</BoxContainer>
</lobbyUi:LobbyGui>

View File

@@ -23,6 +23,9 @@ namespace Content.Client.Lobby.UI
LeaveButton.OnPressed += _ => _consoleHost.ExecuteCommand("disconnect");
OptionsButton.OnPressed += _ => UserInterfaceManager.GetUIController<OptionsUIController>().ToggleWindow();
CollapseButton.OnPressed += _ => TogglePanel(false);
ExpandButton.OnPressed += _ => TogglePanel(true);
}
public void SwitchState(LobbyGuiState state)
@@ -53,6 +56,12 @@ namespace Content.Client.Lobby.UI
}
}
private void TogglePanel(bool value)
{
RightSide.Visible = value;
ExpandPanel.Visible = !value;
}
public enum LobbyGuiState : byte
{
/// <summary>

View File

@@ -3,7 +3,6 @@ using Content.Shared.Emag.Systems;
using Content.Shared.Medical.Cryogenics;
using Content.Shared.Verbs;
using Robust.Client.GameObjects;
using DrawDepth = Content.Shared.DrawDepth.DrawDepth;
namespace Content.Client.Medical.Cryogenics;
@@ -63,11 +62,9 @@ public sealed class CryoPodSystem: SharedCryoPodSystem
{
args.Sprite.LayerSetState(CryoPodVisualLayers.Base, "pod-open");
args.Sprite.LayerSetVisible(CryoPodVisualLayers.Cover, false);
args.Sprite.DrawDepth = (int) DrawDepth.Objects;
}
else
{
args.Sprite.DrawDepth = (int) DrawDepth.Mobs;
args.Sprite.LayerSetState(CryoPodVisualLayers.Base, isOn ? "pod-on" : "pod-off");
args.Sprite.LayerSetState(CryoPodVisualLayers.Cover, isOn ? "cover-on" : "cover-off");
args.Sprite.LayerSetVisible(CryoPodVisualLayers.Cover, true);

View File

@@ -1,5 +1,6 @@
using System.Numerics;
using Content.Client.Physics.Controllers;
using Content.Client.PhysicsSystem.Controllers;
using Content.Shared.Movement.Components;
using Content.Shared.NPC;
using Content.Shared.NPC.Events;

View File

@@ -0,0 +1,47 @@
using Robust.Client.Graphics;
using Robust.Client.Player;
using Robust.Shared.Enums;
using Robust.Shared.Prototypes;
namespace Content.Client.Overlays;
public sealed partial class BlackAndWhiteOverlay : Overlay
{
[Dependency] private readonly IEntityManager _entityManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
public override OverlaySpace Space => OverlaySpace.WorldSpace;
public override bool RequestScreenTexture => true;
private readonly ShaderInstance _greyscaleShader;
public BlackAndWhiteOverlay()
{
IoCManager.InjectDependencies(this);
_greyscaleShader = _prototypeManager.Index<ShaderPrototype>("GreyscaleFullscreen").InstanceUnique();
ZIndex = 10; // draw this over the DamageOverlay, RainbowOverlay etc.
}
protected override bool BeforeDraw(in OverlayDrawArgs args)
{
if (!_entityManager.TryGetComponent(_playerManager.LocalEntity, out EyeComponent? eyeComp))
return false;
if (args.Viewport.Eye != eyeComp.Eye)
return false;
return true;
}
protected override void Draw(in OverlayDrawArgs args)
{
if (ScreenTexture == null)
return;
var handle = args.WorldHandle;
_greyscaleShader.SetParameter("SCREEN_TEXTURE", ScreenTexture);
handle.UseShader(_greyscaleShader);
handle.DrawRect(args.WorldBounds, Color.White);
handle.UseShader(null);
}
}

View File

@@ -0,0 +1,34 @@
using Content.Shared.Inventory.Events;
using Content.Shared.Overlays;
using Robust.Client.Graphics;
using Robust.Client.Player;
namespace Content.Client.Overlays;
public sealed partial class BlackAndWhiteOverlaySystem : EquipmentHudSystem<BlackAndWhiteOverlayComponent>
{
[Dependency] private readonly IOverlayManager _overlayMan = default!;
private BlackAndWhiteOverlay _overlay = default!;
public override void Initialize()
{
base.Initialize();
_overlay = new();
}
protected override void UpdateInternal(RefreshEquipmentHudEvent<BlackAndWhiteOverlayComponent> component)
{
base.UpdateInternal(component);
_overlayMan.AddOverlay(_overlay);
}
protected override void DeactivateInternal()
{
base.DeactivateInternal();
_overlayMan.RemoveOverlay(_overlay);
}
}

View File

@@ -1,4 +1,5 @@
using System.Numerics;
using Robust.Shared.Utility;
namespace Content.Client.Paper.UI;
@@ -55,6 +56,24 @@ public sealed partial class PaperVisualsComponent : Component
[DataField("headerMargin")]
public Box2 HeaderMargin = default;
/// <summary>
/// A path to an image which will be used as a footer on the paper
/// </summary>
[DataField]
public ResPath? FooterImagePath;
/// <summary>
/// Modulate the footer image by this color
/// </summary>
[DataField]
public Color FooterImageModulate = Color.White;
/// <summary>
/// Any additional margin to add around the footer
/// </summary>
[DataField]
public Box2 FooterMargin = default;
/// <summary>
/// Path to an image to use as the background to the "content" of the paper
/// The header and actual written text will use this as a background. The

View File

@@ -22,6 +22,7 @@
</PanelContainer>
<Label Name="FillStatus" StyleClasses="LabelSecondaryColor"/>
</BoxContainer>
<TextureButton Name="FooterImage" HorizontalAlignment="Center" VerticalAlignment="Top" MouseFilter="Ignore"/>
</BoxContainer>
<paper:StampCollection Name="StampDisplay" VerticalAlignment="Bottom" Margin="6"/>

View File

@@ -149,6 +149,16 @@ namespace Content.Client.Paper.UI
HeaderImage.Margin = new Thickness(visuals.HeaderMargin.Left, visuals.HeaderMargin.Top,
visuals.HeaderMargin.Right, visuals.HeaderMargin.Bottom);
// Then the footer
if (visuals.FooterImagePath is {} path)
{
FooterImage.TexturePath = path.ToString();
FooterImage.MinSize = FooterImage.TextureNormal?.Size ?? Vector2.Zero;
}
FooterImage.ModulateSelfOverride = visuals.FooterImageModulate;
FooterImage.Margin = new Thickness(visuals.FooterMargin.Left, visuals.FooterMargin.Top,
visuals.FooterMargin.Right, visuals.FooterMargin.Bottom);
PaperContent.ModulateSelfOverride = visuals.ContentImageModulate;
WrittenTextLabel.ModulateSelfOverride = visuals.FontAccentColor;

View File

@@ -3,15 +3,13 @@ using Content.Shared.CCVar;
using Content.Shared.Movement.Components;
using Content.Shared.Movement.Pulling.Components;
using Content.Shared.Movement.Systems;
using Robust.Client.GameObjects;
using Robust.Client.Physics;
using Robust.Client.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Physics.Components;
using Robust.Shared.Player;
using Robust.Shared.Timing;
namespace Content.Client.Physics.Controllers;
namespace Content.Client.PhysicsSystem.Controllers;
public sealed class MoverController : SharedMoverController
{
@@ -101,39 +99,13 @@ public sealed class MoverController : SharedMoverController
private void HandleClientsideMovement(EntityUid player, float frameTime)
{
if (!MoverQuery.TryGetComponent(player, out var mover) ||
!XformQuery.TryGetComponent(player, out var xform))
{
return;
}
var physicsUid = player;
PhysicsComponent? body;
var xformMover = xform;
if (mover.ToParent && RelayQuery.HasComponent(xform.ParentUid))
{
if (!PhysicsQuery.TryGetComponent(xform.ParentUid, out body) ||
!XformQuery.TryGetComponent(xform.ParentUid, out xformMover))
{
return;
}
physicsUid = xform.ParentUid;
}
else if (!PhysicsQuery.TryGetComponent(player, out body))
if (!MoverQuery.TryGetComponent(player, out var mover))
{
return;
}
// Server-side should just be handled on its own so we'll just do this shizznit
HandleMobMovement(
player,
mover,
physicsUid,
body,
xformMover,
frameTime);
HandleMobMovement((player, mover), frameTime);
}
protected override bool CanSound()

View File

@@ -0,0 +1,85 @@
using Content.Client.UserInterface;
using Content.Shared.Power;
using JetBrains.Annotations;
using Robust.Client.Timing;
using Robust.Client.UserInterface;
namespace Content.Client.Power.Battery;
/// <summary>
/// BUI for <see cref="BatteryUiKey.Key"/>.
/// </summary>
/// <seealso cref="BoundUserInterfaceState"/>
/// <seealso cref="BatteryMenu"/>
[UsedImplicitly]
public sealed class BatteryBoundUserInterface : BoundUserInterface, IBuiPreTickUpdate
{
[Dependency] private readonly IClientGameTiming _gameTiming = null!;
[ViewVariables]
private BatteryMenu? _menu;
private BuiPredictionState? _pred;
private InputCoalescer<float> _chargeRateCoalescer;
private InputCoalescer<float> _dischargeRateCoalescer;
public BatteryBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
{
IoCManager.InjectDependencies(this);
}
protected override void Open()
{
base.Open();
_pred = new BuiPredictionState(this, _gameTiming);
_menu = this.CreateWindow<BatteryMenu>();
_menu.SetEntity(Owner);
_menu.OnInBreaker += val => _pred!.SendMessage(new BatterySetInputBreakerMessage(val));
_menu.OnOutBreaker += val => _pred!.SendMessage(new BatterySetOutputBreakerMessage(val));
_menu.OnChargeRate += val => _chargeRateCoalescer.Set(val);
_menu.OnDischargeRate += val => _dischargeRateCoalescer.Set(val);
}
void IBuiPreTickUpdate.PreTickUpdate()
{
if (_chargeRateCoalescer.CheckIsModified(out var chargeRateValue))
_pred!.SendMessage(new BatterySetChargeRateMessage(chargeRateValue));
if (_dischargeRateCoalescer.CheckIsModified(out var dischargeRateValue))
_pred!.SendMessage(new BatterySetDischargeRateMessage(dischargeRateValue));
}
protected override void UpdateState(BoundUserInterfaceState state)
{
if (state is not BatteryBuiState batteryState)
return;
foreach (var replayMsg in _pred!.MessagesToReplay())
{
switch (replayMsg)
{
case BatterySetInputBreakerMessage setInputBreaker:
batteryState.CanCharge = setInputBreaker.On;
break;
case BatterySetOutputBreakerMessage setOutputBreaker:
batteryState.CanDischarge = setOutputBreaker.On;
break;
case BatterySetChargeRateMessage setChargeRate:
batteryState.MaxChargeRate = setChargeRate.Rate;
break;
case BatterySetDischargeRateMessage setDischargeRate:
batteryState.MaxSupply = setDischargeRate.Rate;
break;
}
}
_menu?.Update(batteryState);
}
}

View File

@@ -0,0 +1,146 @@
<controls:FancyWindow xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
SetSize="650 330"
Resizable="False">
<BoxContainer Orientation="Vertical">
<!-- Top row: main content -->
<BoxContainer Name="MainContent" Orientation="Horizontal" VerticalExpand="True" Margin="4">
<!-- Left pane: I/O, passthrough, sprite view -->
<BoxContainer Name="IOPane" Orientation="Vertical" HorizontalExpand="True">
<!-- Top row: input -->
<BoxContainer Name="InputRow" Orientation="Horizontal">
<!-- Input power line -->
<PanelContainer Name="InPowerLine" SetHeight="2" VerticalAlignment="Top" SetWidth="32"
Margin="2 16" />
<!-- Box with breaker, label, values -->
<PanelContainer HorizontalExpand="True" StyleClasses="Inset">
<BoxContainer Orientation="Vertical">
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'battery-menu-in'}" HorizontalExpand="True" VerticalAlignment="Top"
StyleClasses="LabelKeyText" />
<controls:OnOffButton Name="InBreaker" />
</BoxContainer>
<Label Name="InValue" />
</BoxContainer>
</PanelContainer>
</BoxContainer>
<!-- Middle row: Entity view & passthrough -->
<BoxContainer Name="MiddleRow" Orientation="Horizontal" VerticalExpand="True">
<SpriteView Name="EntityView" SetSize="64 64" Scale="2 2" OverrideDirection="South" Margin="15" />
<BoxContainer Orientation="Vertical" VerticalAlignment="Center" HorizontalExpand="True"
HorizontalAlignment="Right">
<Label HorizontalAlignment="Right" Text="{Loc 'battery-menu-passthrough'}" StyleClasses="StatusFieldTitle" />
<Label HorizontalAlignment="Right" Name="PassthroughValue" />
</BoxContainer>
</BoxContainer>
<!-- Bottom row: output -->
<BoxContainer Name="OutputRow" Orientation="Horizontal">
<!-- Output power line -->
<PanelContainer Name="OutPowerLine" SetHeight="2" VerticalAlignment="Bottom" SetWidth="32"
Margin="2 16" />
<!-- Box with breaker, label, values -->
<PanelContainer HorizontalExpand="True" StyleClasses="Inset">
<BoxContainer Orientation="Vertical">
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'battery-menu-out'}" HorizontalExpand="True" VerticalAlignment="Top"
StyleClasses="LabelKeyText" />
<controls:OnOffButton Name="OutBreaker" />
</BoxContainer>
<Label Name="OutValue" />
</BoxContainer>
</PanelContainer>
</BoxContainer>
</BoxContainer>
<!-- Separator connecting panes with some wires -->
<BoxContainer Orientation="Vertical" SetWidth="22" Margin="2 16">
<PanelContainer Name="InSecondPowerLine" SetHeight="2" />
<PanelContainer Name="PassthroughPowerLine" SetWidth="2" HorizontalAlignment="Center" VerticalExpand="True" />
<PanelContainer Name="OutSecondPowerLine" SetHeight="2" />
</BoxContainer>
<!-- Middle pane: charge/discharge -->
<BoxContainer Name="ChargeDischarge" Orientation="Vertical" HorizontalExpand="True">
<!-- Charge -->
<PanelContainer VerticalExpand="True" StyleClasses="Inset" Margin="0 0 0 8">
<BoxContainer Orientation="Vertical">
<Label Text="{Loc 'battery-menu-charge-header'}" StyleClasses="LabelKeyText" />
<BoxContainer Orientation="Vertical" VerticalExpand="True" VerticalAlignment="Center">
<Slider Name="ChargeRateSlider" />
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'battery-menu-max'}" StyleClasses="StatusFieldTitle" HorizontalExpand="True" />
<Label Name="ChargeMaxValue" />
</BoxContainer>
</BoxContainer>
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'battery-menu-current'}" StyleClasses="StatusFieldTitle" HorizontalExpand="True" />
<Label Name="ChargeCurrentValue" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
<!-- Discharge -->
<PanelContainer VerticalExpand="True" StyleClasses="Inset">
<BoxContainer Orientation="Vertical">
<Label Text="{Loc 'battery-menu-discharge-header'}" StyleClasses="LabelKeyText" />
<BoxContainer Orientation="Vertical" VerticalExpand="True" VerticalAlignment="Center">
<Slider Name="DischargeRateSlider" />
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'battery-menu-max'}" StyleClasses="StatusFieldTitle" HorizontalExpand="True" />
<Label Name="DischargeMaxValue" />
</BoxContainer>
</BoxContainer>
<BoxContainer Orientation="Horizontal">
<Label Text="{Loc 'battery-menu-current'}" StyleClasses="StatusFieldTitle" HorizontalExpand="True" />
<Label Name="DischargeCurrentValue" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
</BoxContainer>
<!-- Separator connecting panes with some wires -->
<BoxContainer Orientation="Vertical" SetWidth="22" Margin="2 16">
<PanelContainer Name="ChargePowerLine" SetHeight="2" VerticalAlignment="Top" VerticalExpand="True" />
<PanelContainer Name="DischargePowerLine" SetHeight="2" VerticalAlignment="Bottom" VerticalExpand="True" />
</BoxContainer>
<!-- Right pane: storage -->
<PanelContainer Name="Storage" StyleClasses="Inset" HorizontalExpand="True">
<BoxContainer Orientation="Vertical">
<Label Text="{Loc 'battery-menu-storage-header'}" StyleClasses="LabelKeyText" />
<GridContainer Columns="2">
<Label Text="{Loc 'battery-menu-stored'}" StyleClasses="StatusFieldTitle" />
<Label Name="StoredPercentageValue" HorizontalAlignment="Right" HorizontalExpand="True" />
<Label Text="{Loc 'battery-menu-energy'}" StyleClasses="StatusFieldTitle" />
<Label Name="StoredEnergyValue" HorizontalAlignment="Right" />
<Label Name="EtaLabel" StyleClasses="StatusFieldTitle" />
<Label Name="EtaValue" HorizontalAlignment="Right" />
</GridContainer>
<!-- Charge meter -->
<GridContainer Name="ChargeMeter" Columns="3" VerticalExpand="True" Margin="0 24 0 0">
</GridContainer>
</BoxContainer>
</PanelContainer>
</BoxContainer>
<!-- Footer -->
<BoxContainer Name="Footer" Orientation="Vertical">
<PanelContainer StyleClasses="LowDivider" />
<BoxContainer Orientation="Horizontal" Margin="10 2 5 0" VerticalAlignment="Bottom">
<Label Text="{Loc 'battery-menu-footer-left'}" StyleClasses="WindowFooterText" />
<Label Text="{Loc 'battery-menu-footer-right'}" StyleClasses="WindowFooterText"
HorizontalAlignment="Right" HorizontalExpand="True" Margin="0 0 5 0" />
<TextureRect StyleClasses="NTLogoDark" Stretch="KeepAspectCentered"
VerticalAlignment="Center" HorizontalAlignment="Right" SetSize="19 19" />
</BoxContainer>
</BoxContainer>
</BoxContainer>
</controls:FancyWindow>

View File

@@ -0,0 +1,280 @@
using System.Diagnostics.CodeAnalysis;
using Content.Client.Stylesheets;
using Content.Client.UserInterface.Controls;
using Content.Shared.Power;
using Content.Shared.Rounding;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing;
namespace Content.Client.Power.Battery;
/// <summary>
/// Interface control for batteries.
/// </summary>
/// <seealso cref="BatteryBoundUserInterface"/>
[GenerateTypedNameReferences]
public sealed partial class BatteryMenu : FancyWindow
{
// Cutoff for the ETA time to switch from "~" to ">" and cap out.
private const float MaxEtaValueMinutes = 60;
// Cutoff where ETA times likely don't make sense and it's better to just say "N/A".
private const float NotApplicableEtaHighCutoffMinutes = 1000;
private const float NotApplicableEtaLowCutoffMinutes = 0.01f;
// Fudge factor to ignore small charge/discharge values, that are likely caused by floating point rounding errors.
private const float PrecisionRoundFactor = 100_000;
// Colors used for the storage cell bar graphic.
private static readonly Color[] StorageColors =
[
StyleNano.DangerousRedFore,
Color.FromHex("#C49438"),
Color.FromHex("#B3BF28"),
StyleNano.GoodGreenFore,
];
// StorageColors but dimmed for "off" bars.
private static readonly Color[] DimStorageColors =
[
DimStorageColor(StorageColors[0]),
DimStorageColor(StorageColors[1]),
DimStorageColor(StorageColors[2]),
DimStorageColor(StorageColors[3]),
];
// Parameters for the sine wave pulsing animations for active power lines in the UI.
private static readonly Color ActivePowerLineHighColor = Color.FromHex("#CCC");
private static readonly Color ActivePowerLineLowColor = Color.FromHex("#888");
private const float PowerPulseFactor = 4;
// Dependencies
[Dependency] private readonly IEntityManager _entityManager = null!;
[Dependency] private readonly ILocalizationManager _loc = null!;
// Active and inactive style boxes for power lines.
// We modify _activePowerLineStyleBox's properties programmatically to implement the pulsing animation.
private readonly StyleBoxFlat _activePowerLineStyleBox = new();
private readonly StyleBoxFlat _inactivePowerLineStyleBox = new() { BackgroundColor = Color.FromHex("#555") };
// Style boxes for the storage cell bar graphic.
// We modify the properties of these to change the bars' colors.
private StyleBoxFlat[] _chargeMeterBoxes;
// State for the powerline pulsing animation.
private float _powerPulseValue;
// State for the storage cell bar graphic and its blinking effect.
private float _blinkPulseValue;
private bool _blinkPulse;
private int _storageLevel;
private bool _hasStorageDelta;
// The entity that this UI is for.
private EntityUid _entity;
// Used to avoid sending input events when updating slider values.
private bool _suppressSliderEvents;
// Events for the BUI to subscribe to.
public event Action<bool>? OnInBreaker;
public event Action<bool>? OnOutBreaker;
public event Action<float>? OnChargeRate;
public event Action<float>? OnDischargeRate;
public BatteryMenu()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
InitChargeMeter();
InBreaker.StateChanged += val => OnInBreaker?.Invoke(val);
OutBreaker.StateChanged += val => OnOutBreaker?.Invoke(val);
ChargeRateSlider.OnValueChanged += _ =>
{
if (!_suppressSliderEvents)
OnChargeRate?.Invoke(ChargeRateSlider.Value);
};
DischargeRateSlider.OnValueChanged += _ =>
{
if (!_suppressSliderEvents)
OnDischargeRate?.Invoke(DischargeRateSlider.Value);
};
}
public void SetEntity(EntityUid entity)
{
_entity = entity;
this.SetInfoFromEntity(_entityManager, _entity);
EntityView.SetEntity(entity);
}
[MemberNotNull(nameof(_chargeMeterBoxes))]
public void InitChargeMeter()
{
_chargeMeterBoxes = new StyleBoxFlat[StorageColors.Length];
for (var i = StorageColors.Length - 1; i >= 0; i--)
{
var styleBox = new StyleBoxFlat();
_chargeMeterBoxes[i] = styleBox;
for (var j = 0; j < ChargeMeter.Columns; j++)
{
var control = new PanelContainer
{
Margin = new Thickness(2),
PanelOverride = styleBox,
HorizontalExpand = true,
VerticalExpand = true,
};
ChargeMeter.AddChild(control);
}
}
}
public void Update(BatteryBuiState msg)
{
var inValue = msg.CurrentReceiving;
var outValue = msg.CurrentSupply;
var storageDelta = inValue - outValue;
// Mask rounding errors in power code.
if (Math.Abs(storageDelta) < msg.Capacity / PrecisionRoundFactor)
storageDelta = 0;
// Update power lines based on a ton of parameters.
SetPowerLineState(InPowerLine, msg.SupplyingNetworkHasPower);
SetPowerLineState(OutPowerLine, msg.LoadingNetworkHasPower);
SetPowerLineState(InSecondPowerLine, msg.SupplyingNetworkHasPower && msg.CanCharge);
SetPowerLineState(ChargePowerLine, msg.SupplyingNetworkHasPower && msg.CanCharge && storageDelta > 0);
SetPowerLineState(PassthroughPowerLine, msg.SupplyingNetworkHasPower && msg.CanCharge && msg.CanDischarge);
SetPowerLineState(OutSecondPowerLine,
msg.CanDischarge && (msg.Charge > 0 || msg.SupplyingNetworkHasPower && msg.CanCharge));
SetPowerLineState(DischargePowerLine, storageDelta < 0);
// Update breakers.
InBreaker.IsOn = msg.CanCharge;
OutBreaker.IsOn = msg.CanDischarge;
// Update various power values.
InValue.Text = FormatPower(inValue);
OutValue.Text = FormatPower(outValue);
PassthroughValue.Text = FormatPower(Math.Min(msg.CurrentReceiving, msg.CurrentSupply));
ChargeMaxValue.Text = FormatPower(msg.MaxChargeRate);
DischargeMaxValue.Text = FormatPower(msg.MaxSupply);
ChargeCurrentValue.Text = FormatPower(Math.Max(0, storageDelta));
DischargeCurrentValue.Text = FormatPower(Math.Max(0, -storageDelta));
// Update charge/discharge rate sliders.
_suppressSliderEvents = true;
ChargeRateSlider.MaxValue = msg.MaxMaxChargeRate;
ChargeRateSlider.MinValue = msg.MinMaxChargeRate;
ChargeRateSlider.Value = msg.MaxChargeRate;
DischargeRateSlider.MaxValue = msg.MaxMaxSupply;
DischargeRateSlider.MinValue = msg.MinMaxSupply;
DischargeRateSlider.Value = msg.MaxSupply;
_suppressSliderEvents = false;
// Update ETA display.
var storageEtaDiff = storageDelta > 0 ? (msg.Capacity - msg.Charge) * (1 / msg.Efficiency) : -msg.Charge;
var etaTimeSeconds = storageEtaDiff / storageDelta;
var etaTimeMinutes = etaTimeSeconds / 60.0;
EtaLabel.Text = _loc.GetString(
storageDelta > 0 ? "battery-menu-eta-full" : "battery-menu-eta-empty");
if (!double.IsFinite(etaTimeMinutes)
|| Math.Abs(etaTimeMinutes) > NotApplicableEtaHighCutoffMinutes
|| Math.Abs(etaTimeMinutes) < NotApplicableEtaLowCutoffMinutes)
{
EtaValue.Text = _loc.GetString("battery-menu-eta-value-na");
}
else
{
EtaValue.Text = _loc.GetString(
etaTimeMinutes > MaxEtaValueMinutes ? "battery-menu-eta-value-max" : "battery-menu-eta-value",
("minutes", Math.Min(Math.Ceiling(etaTimeMinutes), MaxEtaValueMinutes)));
}
// Update storage display.
StoredPercentageValue.Text = _loc.GetString(
"battery-menu-stored-percent-value",
("value", msg.Charge / msg.Capacity));
StoredEnergyValue.Text = _loc.GetString(
"battery-menu-stored-energy-value",
("value", msg.Charge));
// Update charge meter.
_storageLevel = ContentHelpers.RoundToNearestLevels(msg.Charge, msg.Capacity, _chargeMeterBoxes.Length);
_hasStorageDelta = Math.Abs(storageDelta) > 0;
}
private static Color DimStorageColor(Color color)
{
var hsv = Color.ToHsv(color);
hsv.Z /= 5;
return Color.FromHsv(hsv);
}
private void SetPowerLineState(PanelContainer control, bool value)
{
control.PanelOverride = value ? _activePowerLineStyleBox : _inactivePowerLineStyleBox;
}
private string FormatPower(float value)
{
return _loc.GetString("battery-menu-power-value", ("value", value));
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
// Pulse power lines.
_powerPulseValue += args.DeltaSeconds * PowerPulseFactor;
var color = Color.InterpolateBetween(
ActivePowerLineLowColor,
ActivePowerLineHighColor,
MathF.Sin(_powerPulseValue) / 2 + 1);
_activePowerLineStyleBox.BackgroundColor = color;
// Update storage indicator and blink it.
for (var i = 0; i < _chargeMeterBoxes.Length; i++)
{
var box = _chargeMeterBoxes[i];
if (_storageLevel > i)
{
// On
box.BackgroundColor = StorageColors[i];
}
else
{
box.BackgroundColor = DimStorageColors[i];
}
}
_blinkPulseValue += args.DeltaSeconds;
if (_blinkPulseValue > 1)
{
_blinkPulseValue -= 1;
_blinkPulse ^= true;
}
// If there is a storage delta (charging or discharging), we want to blink the highest bar.
if (_hasStorageDelta)
{
// If there is no highest bar (UI completely at 0), then blink bar 0.
var toBlink = Math.Max(0, _storageLevel - 1);
_chargeMeterBoxes[toBlink].BackgroundColor =
_blinkPulse ? StorageColors[toBlink] : DimStorageColors[toBlink];
}
}
}

View File

@@ -66,10 +66,60 @@ namespace Content.Client.Stack
if (!_appearanceSystem.TryGetData<bool>(uid, StackVisuals.Hide, out var hidden, args.Component))
hidden = false;
if (comp.LayerFunction != StackLayerFunction.None)
ApplyLayerFunction((uid, comp), ref actual, ref maxCount);
if (comp.IsComposite)
_counterSystem.ProcessCompositeSprite(uid, actual, maxCount, comp.LayerStates, hidden, sprite: args.Sprite);
else
_counterSystem.ProcessOpaqueSprite(uid, comp.BaseLayer, actual, maxCount, comp.LayerStates, hidden, sprite: args.Sprite);
}
/// <summary>
/// Adjusts the actual and maxCount to change how stack amounts are displayed.
/// </summary>
/// <param name="ent">The entity considered.</param>
/// <param name="actual">The actual number of items in the stack. Altered depending on the function to run.</param>
/// <param name="maxCount">The maximum number of items in the stack. Altered depending on the function to run.</param>
/// <returns>Whether or not a function was applied.</returns>
private bool ApplyLayerFunction(Entity<StackComponent> ent, ref int actual, ref int maxCount)
{
switch (ent.Comp.LayerFunction)
{
case StackLayerFunction.Threshold:
if (TryComp<StackLayerThresholdComponent>(ent, out var threshold))
{
ApplyThreshold(threshold, ref actual, ref maxCount);
return true;
}
break;
}
// No function applied.
return false;
}
/// <summary>
/// Selects which layer a stack applies based on a list of thresholds.
/// Each threshold passed results in the next layer being selected.
/// </summary>
/// <param name="comp">The threshold parameters to apply.</param>
/// <param name="actual">The number of items in the stack. Will be set to the index of the layer to use.</param>
/// <param name="maxCount">The maximum possible number of items in the stack. Will be set to the number of selectable layers.</param>
private static void ApplyThreshold(StackLayerThresholdComponent comp, ref int actual, ref int maxCount)
{
// We must stop before we run out of thresholds or layers, whichever's smaller.
maxCount = Math.Min(comp.Thresholds.Count + 1, maxCount);
var newActual = 0;
foreach (var threshold in comp.Thresholds)
{
//If our value exceeds threshold, the next layer should be displayed.
//Note: we must ensure actual <= MaxCount.
if (actual >= threshold && newActual < maxCount)
newActual++;
else
break;
}
actual = newActual;
}
}
}

View File

@@ -5,7 +5,7 @@ namespace Content.Client.Station;
/// <summary>
/// This handles letting the client know stations are a thing. Only really used by an admin menu.
/// </summary>
public sealed class StationSystem : EntitySystem
public sealed partial class StationSystem : SharedStationSystem
{
private readonly List<(string Name, NetEntity Entity)> _stations = new();
@@ -15,11 +15,14 @@ public sealed class StationSystem : EntitySystem
/// <remarks>
/// I'd have this just invoke an entity query, but we're on the client and the client barely knows about stations.
/// </remarks>
// TODO: Stations have a global PVS override now, this can probably be changed into a query.
public IReadOnlyList<(string Name, NetEntity Entity)> Stations => _stations;
/// <inheritdoc/>
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<StationsUpdatedEvent>(StationsUpdated);
}

View File

@@ -66,6 +66,11 @@ namespace Content.Client.Stylesheets
public const string StyleClassChatChannelSelectorButton = "chatSelectorOptionButton";
public const string StyleClassChatFilterOptionButton = "chatFilterOptionButton";
public const string StyleClassStorageButton = "storageButton";
public const string StyleClassInset = "Inset";
public const string StyleClassConsoleHeading = "ConsoleHeading";
public const string StyleClassConsoleSubHeading = "ConsoleSubHeading";
public const string StyleClassConsoleText = "ConsoleText";
public const string StyleClassSliderRed = "Red";
public const string StyleClassSliderGreen = "Green";
@@ -179,6 +184,10 @@ namespace Content.Client.Stylesheets
var notoSansBold18 = resCache.NotoStack(variation: "Bold", size: 18);
var notoSansBold20 = resCache.NotoStack(variation: "Bold", size: 20);
var notoSansMono = resCache.GetFont("/EngineFonts/NotoSans/NotoSansMono-Regular.ttf", size: 12);
var robotoMonoBold11 = resCache.GetFont("/Fonts/RobotoMono/RobotoMono-Bold.ttf", size: 11);
var robotoMonoBold12 = resCache.GetFont("/Fonts/RobotoMono/RobotoMono-Bold.ttf", size: 12);
var robotoMonoBold14 = resCache.GetFont("/Fonts/RobotoMono/RobotoMono-Bold.ttf", size: 14);
var windowHeaderTex = resCache.GetTexture("/Textures/Interface/Nano/window_header.png");
var windowHeader = new StyleBoxTexture
{
@@ -413,9 +422,60 @@ namespace Content.Client.Stylesheets
};
progressBarForeground.SetContentMarginOverride(StyleBox.Margin.Vertical, 14.5f);
// Monotone (unfilled)
var monotoneButton = new StyleBoxTexture
{
Texture = resCache.GetTexture("/Textures/Interface/Nano/Monotone/monotone_button.svg.96dpi.png"),
};
monotoneButton.SetPatchMargin(StyleBox.Margin.All, 11);
monotoneButton.SetPadding(StyleBox.Margin.All, 1);
monotoneButton.SetContentMarginOverride(StyleBox.Margin.Vertical, 2);
monotoneButton.SetContentMarginOverride(StyleBox.Margin.Horizontal, 14);
var monotoneButtonOpenLeft = new StyleBoxTexture(monotoneButton)
{
Texture = resCache.GetTexture("/Textures/Interface/Nano/Monotone/monotone_button_open_left.svg.96dpi.png"),
};
var monotoneButtonOpenRight = new StyleBoxTexture(monotoneButton)
{
Texture = resCache.GetTexture("/Textures/Interface/Nano/Monotone/monotone_button_open_right.svg.96dpi.png"),
};
var monotoneButtonOpenBoth = new StyleBoxTexture(monotoneButton)
{
Texture = resCache.GetTexture("/Textures/Interface/Nano/Monotone/monotone_button_open_both.svg.96dpi.png"),
};
// Monotone (filled)
var monotoneFilledButton = new StyleBoxTexture(monotoneButton)
{
Texture = buttonTex,
};
var monotoneFilledButtonOpenLeft = new StyleBoxTexture(monotoneButton)
{
Texture = new AtlasTexture(buttonTex, UIBox2.FromDimensions(new Vector2(10, 0), new Vector2(14, 24))),
};
monotoneFilledButtonOpenLeft.SetPatchMargin(StyleBox.Margin.Left, 0);
var monotoneFilledButtonOpenRight = new StyleBoxTexture(monotoneButton)
{
Texture = new AtlasTexture(buttonTex, UIBox2.FromDimensions(new Vector2(0, 0), new Vector2(14, 24))),
};
monotoneFilledButtonOpenRight.SetPatchMargin(StyleBox.Margin.Right, 0);
var monotoneFilledButtonOpenBoth = new StyleBoxTexture(monotoneButton)
{
Texture = new AtlasTexture(buttonTex, UIBox2.FromDimensions(new Vector2(10, 0), new Vector2(3, 24))),
};
monotoneFilledButtonOpenBoth.SetPatchMargin(StyleBox.Margin.Horizontal, 0);
// CheckBox
var checkBoxTextureChecked = resCache.GetTexture("/Textures/Interface/Nano/checkbox_checked.svg.96dpi.png");
var checkBoxTextureUnchecked = resCache.GetTexture("/Textures/Interface/Nano/checkbox_unchecked.svg.96dpi.png");
var monotoneCheckBoxTextureChecked = resCache.GetTexture("/Textures/Interface/Nano/Monotone/monotone_checkbox_checked.svg.96dpi.png");
var monotoneCheckBoxTextureUnchecked = resCache.GetTexture("/Textures/Interface/Nano/Monotone/monotone_checkbox_unchecked.svg.96dpi.png");
// Tooltip box
var tooltipTexture = resCache.GetTexture("/Textures/Interface/Nano/tooltip.png");
@@ -945,6 +1005,17 @@ namespace Content.Client.Stylesheets
new StyleProperty(BoxContainer.StylePropertySeparation, 10),
}),
// MonotoneCheckBox
new StyleRule(new SelectorElement(typeof(TextureRect), new [] { MonotoneCheckBox.StyleClassMonotoneCheckBox }, null, null), new[]
{
new StyleProperty(TextureRect.StylePropertyTexture, monotoneCheckBoxTextureUnchecked),
}),
new StyleRule(new SelectorElement(typeof(TextureRect), new [] { MonotoneCheckBox.StyleClassMonotoneCheckBox, CheckBox.StyleClassCheckBoxChecked }, null, null), new[]
{
new StyleProperty(TextureRect.StylePropertyTexture, monotoneCheckBoxTextureChecked),
}),
// Tooltip
new StyleRule(new SelectorElement(typeof(Tooltip), null, null, null), new[]
{
@@ -1143,6 +1214,22 @@ namespace Content.Client.Stylesheets
new StyleProperty(Label.StylePropertyFontColor, Color.DarkGray),
}),
// Console text
new StyleRule(new SelectorElement(typeof(Label), new[] {StyleClassConsoleText}, null, null), new[]
{
new StyleProperty(Label.StylePropertyFont, robotoMonoBold11)
}),
new StyleRule(new SelectorElement(typeof(Label), new[] {StyleClassConsoleSubHeading}, null, null), new[]
{
new StyleProperty(Label.StylePropertyFont, robotoMonoBold12)
}),
new StyleRule(new SelectorElement(typeof(Label), new[] {StyleClassConsoleHeading}, null, null), new[]
{
new StyleProperty(Label.StylePropertyFont, robotoMonoBold14)
}),
// Big Button
new StyleRule(new SelectorChild(
new SelectorElement(typeof(Button), new[] {StyleClassButtonBig}, null, null),
@@ -1242,6 +1329,64 @@ namespace Content.Client.Stylesheets
new StyleProperty(Label.StylePropertyFont, notoSansDisplayBold14),
}),
// MonotoneButton (unfilled)
new StyleRule(
new SelectorElement(typeof(MonotoneButton), null, null, null),
new[]
{
new StyleProperty(Button.StylePropertyStyleBox, monotoneButton),
}),
new StyleRule(
new SelectorElement(typeof(MonotoneButton), new[] { ButtonOpenLeft }, null, null),
new[]
{
new StyleProperty(Button.StylePropertyStyleBox, monotoneButtonOpenLeft),
}),
new StyleRule(
new SelectorElement(typeof(MonotoneButton), new[] { ButtonOpenRight }, null, null),
new[]
{
new StyleProperty(Button.StylePropertyStyleBox, monotoneButtonOpenRight),
}),
new StyleRule(
new SelectorElement(typeof(MonotoneButton), new[] { ButtonOpenBoth }, null, null),
new[]
{
new StyleProperty(Button.StylePropertyStyleBox, monotoneButtonOpenBoth),
}),
// MonotoneButton (filled)
new StyleRule(
new SelectorElement(typeof(MonotoneButton), null, null, new[] { Button.StylePseudoClassPressed }),
new[]
{
new StyleProperty(Button.StylePropertyStyleBox, monotoneFilledButton),
}),
new StyleRule(
new SelectorElement(typeof(MonotoneButton), new[] { ButtonOpenLeft }, null, new[] { Button.StylePseudoClassPressed }),
new[]
{
new StyleProperty(Button.StylePropertyStyleBox, monotoneFilledButtonOpenLeft),
}),
new StyleRule(
new SelectorElement(typeof(MonotoneButton), new[] { ButtonOpenRight }, null, new[] { Button.StylePseudoClassPressed }),
new[]
{
new StyleProperty(Button.StylePropertyStyleBox, monotoneFilledButtonOpenRight),
}),
new StyleRule(
new SelectorElement(typeof(MonotoneButton), new[] { ButtonOpenBoth }, null, new[] { Button.StylePseudoClassPressed }),
new[]
{
new StyleProperty(Button.StylePropertyStyleBox, monotoneFilledButtonOpenBoth),
}),
// NanoHeading
new StyleRule(
@@ -1675,7 +1820,11 @@ namespace Content.Client.Stylesheets
new[]
{
new StyleProperty(TextureButton.StylePropertyTexture, resCache.GetTexture("/Textures/Interface/Bwoink/un_pinned.png"))
})
}),
Element<PanelContainer>()
.Class(StyleClassInset)
.Prop(PanelContainer.StylePropertyPanel, insetBack),
}).ToList());
}
}

View File

@@ -0,0 +1,75 @@
using Robust.Client.GameObjects;
using Robust.Client.Player;
using Robust.Shared.ContentPack;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client.UserInterface;
/// <summary>
/// Interface for <see cref="BoundUserInterface"/>s that need some updating logic
/// ran in the <see cref="ModUpdateLevel.PreEngine"/> stage.
/// </summary>
/// <remarks>
/// <para>
/// This is called on all open <see cref="BoundUserInterface"/>s that implement this interface.
/// </para>
/// <para>
/// One intended use case is coalescing input events (e.g. via <see cref="InputCoalescer{T}"/>) to send them to the
/// server only once per tick.
/// </para>
/// </remarks>
/// <seealso cref="BuiPreTickUpdateSystem"/>
public interface IBuiPreTickUpdate
{
void PreTickUpdate();
}
/// <summary>
/// Implements <see cref="BuiPreTickUpdateSystem"/>.
/// </summary>
public sealed class BuiPreTickUpdateSystem : EntitySystem
{
[Dependency] private readonly IPlayerManager _playerManager = null!;
[Dependency] private readonly UserInterfaceSystem _uiSystem = null!;
[Dependency] private readonly IGameTiming _gameTiming = null!;
private EntityQuery<UserInterfaceUserComponent> _userQuery;
public override void Initialize()
{
base.Initialize();
_userQuery = GetEntityQuery<UserInterfaceUserComponent>();
}
public void RunUpdates()
{
if (!_gameTiming.IsFirstTimePredicted)
return;
var localSession = _playerManager.LocalSession;
if (localSession?.AttachedEntity is not { } localEntity)
return;
if (!_userQuery.TryGetComponent(localEntity, out var userUIComp))
return;
foreach (var (entity, uis) in userUIComp.OpenInterfaces)
{
foreach (var key in uis)
{
if (!_uiSystem.TryGetOpenUi(entity, key, out var ui))
{
DebugTools.Assert("Unable to find UI that was in the open UIs list??");
continue;
}
if (ui is IBuiPreTickUpdate tickUpdate)
{
tickUpdate.PreTickUpdate();
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
using System.Linq;
using Robust.Client.Timing;
using Robust.Shared.Timing;
namespace Content.Client.UserInterface;
/// <summary>
/// A local buffer for <see cref="BoundUserInterface"/>s to manually implement prediction.
/// </summary>
/// <remarks>
/// <para>
/// In many current (and future) cases, it is not practically possible to implement prediction for UIs
/// by implementing the logic in shared. At the same time, we want to implement prediction for the best user experience
/// (and it is sometimes the easiest way to make even a middling user experience).
/// </para>
/// <para>
/// You can queue predicted messages into this class with <see cref="SendMessage"/>,
/// and then call <see cref="MessagesToReplay"/> later from <see cref="BoundUserInterface.UpdateState"/>
/// to get all messages that are still "ahead" of the latest server state.
/// These messages can then manually be "applied" to the latest state received from the server.
/// </para>
/// <para>
/// Note that this system only works if the server is guaranteed to send some kind of update in response to UI messages,
/// or at a regular schedule. If it does not, there is no opportunity to error correct the prediction.
/// </para>
/// </remarks>
public sealed class BuiPredictionState
{
private readonly BoundUserInterface _parent;
private readonly IClientGameTiming _gameTiming;
private readonly Queue<MessageData> _queuedMessages = new();
public BuiPredictionState(BoundUserInterface parent, IClientGameTiming gameTiming)
{
_parent = parent;
_gameTiming = gameTiming;
}
public void SendMessage(BoundUserInterfaceMessage message)
{
if (_gameTiming.IsFirstTimePredicted)
{
var messageData = new MessageData
{
TickSent = _gameTiming.CurTick,
Message = message,
};
_queuedMessages.Enqueue(messageData);
}
_parent.SendPredictedMessage(message);
}
public IEnumerable<BoundUserInterfaceMessage> MessagesToReplay()
{
var curTick = _gameTiming.LastRealTick;
while (_queuedMessages.TryPeek(out var data) && data.TickSent <= curTick)
{
_queuedMessages.Dequeue();
}
if (_queuedMessages.Count == 0)
return [];
return _queuedMessages.Select(c => c.Message);
}
private struct MessageData
{
public GameTick TickSent;
public required BoundUserInterfaceMessage Message;
public override string ToString()
{
return $"{Message} @ {TickSent}";
}
}
}

View File

@@ -81,4 +81,54 @@ namespace Content.Client.UserInterface.Controls
return mode;
}
}
/// <summary>
/// Helper functions for working with <see cref="FancyWindow"/>.
/// </summary>
public static class FancyWindowExt
{
/// <summary>
/// Sets information for a window (title and guidebooks) based on an entity.
/// </summary>
/// <param name="window">The window to modify.</param>
/// <param name="entityManager">Entity manager used to retrieve the information.</param>
/// <param name="entity">The entity that this window represents.</param>
/// <seealso cref="SetTitleFromEntity"/>
/// <seealso cref="SetGuidebookFromEntity"/>
public static void SetInfoFromEntity(this FancyWindow window, IEntityManager entityManager, EntityUid entity)
{
window.SetTitleFromEntity(entityManager, entity);
window.SetGuidebookFromEntity(entityManager, entity);
}
/// <summary>
/// Set a window's title to the name of an entity.
/// </summary>
/// <param name="window">The window to modify.</param>
/// <param name="entityManager">Entity manager used to retrieve the information.</param>
/// <param name="entity">The entity that this window represents.</param>
/// <seealso cref="SetInfoFromEntity"/>
public static void SetTitleFromEntity(
this FancyWindow window,
IEntityManager entityManager,
EntityUid entity)
{
window.Title = entityManager.GetComponent<MetaDataComponent>(entity).EntityName;
}
/// <summary>
/// Set a window's guidebook IDs to those of an entity.
/// </summary>
/// <param name="window">The window to modify.</param>
/// <param name="entityManager">Entity manager used to retrieve the information.</param>
/// <param name="entity">The entity that this window represents.</param>
/// <seealso cref="SetInfoFromEntity"/>
public static void SetGuidebookFromEntity(
this FancyWindow window,
IEntityManager entityManager,
EntityUid entity)
{
window.HelpGuidebookIds = entityManager.GetComponentOrNull<GuideHelpComponent>(entity)?.Guides;
}
}
}

View File

@@ -264,12 +264,6 @@ public class ListContainer : Control
_updateChildren = false;
var toRemove = new Dictionary<ListData, ListContainerButton>(_buttons);
foreach (var child in Children.ToArray())
{
if (child == _vScrollBar)
continue;
RemoveChild(child);
}
if (_data.Count > 0)
{
@@ -292,8 +286,9 @@ public class ListContainer : Control
if (Toggle && data == _selected)
button.Pressed = true;
}
AddChild(button);
}
button.SetPositionInParent(i - _topIndex);
button.Measure(finalSize);
}
}

View File

@@ -0,0 +1,79 @@
using JetBrains.Annotations;
using Robust.Client.UserInterface.Controls;
using static Robust.Client.UserInterface.Controls.Label;
namespace Content.Client.UserInterface.Controls;
/// <summary>
/// A button intended for use with a monotone color palette
/// </summary>
public sealed class MonotoneButton : ContainerButton
{
/// <summary>
/// Specifies the color of the label text when the button is pressed.
/// </summary>
[ViewVariables]
public Color AltTextColor { set; get; } = new Color(0.2f, 0.2f, 0.2f);
/// <summary>
/// The label that holds the button text.
/// </summary>
public Label Label { get; }
/// <summary>
/// The text displayed by the button.
/// </summary>
[PublicAPI, ViewVariables]
public string? Text { get => Label.Text; set => Label.Text = value; }
/// <summary>
/// How to align the text inside the button.
/// </summary>
[PublicAPI, ViewVariables]
public AlignMode TextAlign { get => Label.Align; set => Label.Align = value; }
/// <summary>
/// If true, the button will allow shrinking and clip text
/// to prevent the text from going outside the bounds of the button.
/// If false, the minimum size will always fit the contained text.
/// </summary>
[PublicAPI, ViewVariables]
public bool ClipText
{
get => Label.ClipText;
set => Label.ClipText = value;
}
public MonotoneButton()
{
Label = new Label
{
StyleClasses = { StyleClassButton }
};
AddChild(Label);
UpdateAppearance();
}
private void UpdateAppearance()
{
// Recolor the label
if (Label != null)
Label.ModulateSelfOverride = DrawMode == DrawModeEnum.Pressed ? AltTextColor : null;
// Modulate the button if disabled
Modulate = Disabled ? Color.Gray : Color.White;
}
protected override void StylePropertiesChanged()
{
base.StylePropertiesChanged();
UpdateAppearance();
}
protected override void DrawModeChanged()
{
base.DrawModeChanged();
UpdateAppearance();
}
}

View File

@@ -0,0 +1,24 @@
using Robust.Client.UserInterface.Controls;
namespace Content.Client.UserInterface.Controls;
/// <summary>
/// A check box intended for use with a monotone color palette
/// </summary>
public sealed class MonotoneCheckBox : CheckBox
{
public const string StyleClassMonotoneCheckBox = "monotoneCheckBox";
public MonotoneCheckBox()
{
TextureRect.AddStyleClass(StyleClassMonotoneCheckBox);
}
protected override void DrawModeChanged()
{
base.DrawModeChanged();
// Appearance modulations
Modulate = Disabled ? Color.Gray : Color.White;
}
}

View File

@@ -0,0 +1,6 @@
<Control xmlns="https://spacestation14.io">
<BoxContainer Orientation="Horizontal">
<Button Name="OffButton" StyleClasses="OpenRight" Text="{Loc 'ui-button-off'}" />
<Button Name="OnButton" StyleClasses="OpenLeft" Text="{Loc 'ui-button-on'}" />
</BoxContainer>
</Control>

View File

@@ -0,0 +1,48 @@
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
namespace Content.Client.UserInterface.Controls;
/// <summary>
/// A simple control that displays a toggleable on/off button.
/// </summary>
[GenerateTypedNameReferences]
public sealed partial class OnOffButton : Control
{
/// <summary>
/// Whether the control is currently in the "on" state.
/// </summary>
public bool IsOn
{
get => OnButton.Pressed;
set
{
if (value)
OnButton.Pressed = true;
else
OffButton.Pressed = true;
}
}
/// <summary>
/// Raised when the user changes the state of the control.
/// </summary>
/// <remarks>
/// This does not get raised if state is changed with <see cref="set_IsOn"/>.
/// </remarks>
public event Action<bool>? StateChanged;
public OnOffButton()
{
RobustXamlLoader.Load(this);
var group = new ButtonGroup(isNoneSetAllowed: false);
OffButton.Group = group;
OnButton.Group = group;
OffButton.OnPressed += _ => StateChanged?.Invoke(false);
OnButton.OnPressed += _ => StateChanged?.Invoke(true);
}
}

View File

@@ -0,0 +1,40 @@
using System.Diagnostics.CodeAnalysis;
namespace Content.Client.UserInterface;
/// <summary>
/// A simple utility class to "coalesce" multiple input events into a single one, fired later.
/// </summary>
/// <typeparam name="T"></typeparam>
public struct InputCoalescer<T>
{
public bool IsModified;
public T LastValue;
/// <summary>
/// Replace the value in the <see cref="InputCoalescer{T}"/>. This sets <see cref="IsModified"/> to true.
/// </summary>
public void Set(T value)
{
LastValue = value;
IsModified = true;
}
/// <summary>
/// Check if the <see cref="InputCoalescer{T}"/> has been modified.
/// If it was, return the value and clear <see cref="IsModified"/>.
/// </summary>
/// <returns>True if the value was modified since the last check.</returns>
public bool CheckIsModified([MaybeNullWhen(false)] out T value)
{
if (IsModified)
{
value = LastValue;
IsModified = false;
return true;
}
value = default;
return IsModified;
}
}

View File

@@ -12,6 +12,8 @@ namespace Content.Client.UserInterface.Systems.Alerts.Controls
{
public sealed class AlertControl : BaseButton
{
[Dependency] private readonly IEntityManager _entityManager = default!;
public AlertPrototype Alert { get; }
/// <summary>
@@ -33,8 +35,7 @@ namespace Content.Client.UserInterface.Systems.Alerts.Controls
private (TimeSpan Start, TimeSpan End)? _cooldown;
private short? _severity;
private readonly IGameTiming _gameTiming;
private readonly IEntityManager _entityManager;
private readonly SpriteView _icon;
private readonly CooldownGraphic _cooldownGraphic;
@@ -47,8 +48,10 @@ namespace Content.Client.UserInterface.Systems.Alerts.Controls
/// <param name="severity">severity of alert, null if alert doesn't have severity levels</param>
public AlertControl(AlertPrototype alert, short? severity)
{
_gameTiming = IoCManager.Resolve<IGameTiming>();
_entityManager = IoCManager.Resolve<IEntityManager>();
// Alerts will handle this.
MuteSounds = true;
IoCManager.InjectDependencies(this);
TooltipSupplier = SupplyTooltip;
Alert = alert;
_severity = severity;

View File

@@ -1,4 +1,5 @@
using Content.Shared.Atmos.Components;
using Content.Shared.Atmos.EntitySystems;
using JetBrains.Annotations;
using Robust.Client.GameObjects;
using Robust.Client.UserInterface;
@@ -17,7 +18,7 @@ namespace Content.Client.UserInterface.Systems.Atmos.GasTank
public void SetOutputPressure(float value)
{
SendMessage(new GasTankSetPressureMessage
SendPredictedMessage(new GasTankSetPressureMessage
{
Pressure = value
});
@@ -25,13 +26,14 @@ namespace Content.Client.UserInterface.Systems.Atmos.GasTank
public void ToggleInternals()
{
SendMessage(new GasTankToggleInternalsMessage());
SendPredictedMessage(new GasTankToggleInternalsMessage());
}
protected override void Open()
{
base.Open();
_window = this.CreateWindow<GasTankWindow>();
_window.Entity = Owner;
_window.SetTitle(EntMan.GetComponent<MetaDataComponent>(Owner).EntityName);
_window.OnOutputPressure += SetOutputPressure;
_window.OnToggleInternals += ToggleInternals;
@@ -41,6 +43,12 @@ namespace Content.Client.UserInterface.Systems.Atmos.GasTank
{
base.UpdateState(state);
if (EntMan.TryGetComponent(Owner, out GasTankComponent? component))
{
var canConnect = EntMan.System<SharedGasTankSystem>().CanConnectToInternals((Owner, component));
_window?.Update(canConnect, component.IsConnected, component.OutputPressure);
}
if (state is GasTankBoundUserInterfaceState cast)
_window?.UpdateState(cast);
}

View File

@@ -3,11 +3,14 @@ using Content.Client.Message;
using Content.Client.Resources;
using Content.Client.Stylesheets;
using Content.Shared.Atmos.Components;
using Content.Shared.Atmos.EntitySystems;
using Content.Shared.Timing;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Shared.Timing;
using static Robust.Client.UserInterface.Controls.BoxContainer;
namespace Content.Client.UserInterface.Systems.Atmos.GasTank;
@@ -15,6 +18,7 @@ namespace Content.Client.UserInterface.Systems.Atmos.GasTank;
public sealed class GasTankWindow
: BaseWindow
{
[Dependency] private readonly IEntityManager _entManager = default!;
[Dependency] private readonly IResourceCache _cache = default!;
private readonly RichTextLabel _lblPressure;
@@ -23,6 +27,8 @@ public sealed class GasTankWindow
private readonly Button _btnInternals;
private readonly Label _topLabel;
public EntityUid Entity;
public event Action<float>? OnOutputPressure;
public event Action? OnToggleInternals;
@@ -194,12 +200,30 @@ public sealed class GasTankWindow
public void UpdateState(GasTankBoundUserInterfaceState state)
{
_lblPressure.SetMarkup(Loc.GetString("gas-tank-window-tank-pressure-text", ("tankPressure", $"{state.TankPressure:0.##}")));
_btnInternals.Disabled = !state.CanConnectInternals;
_lblInternals.SetMarkup(Loc.GetString("gas-tank-window-internal-text",
("status", Loc.GetString(state.InternalsConnected ? "gas-tank-window-internal-connected" : "gas-tank-window-internal-disconnected"))));
if (state.OutputPressure.HasValue)
}
public void Update(bool canConnectInternals, bool internalsConnected, float outputPressure)
{
_spbPressure.Value = state.OutputPressure.Value;
_btnInternals.Disabled = !canConnectInternals;
_lblInternals.SetMarkup(Loc.GetString("gas-tank-window-internal-text",
("status", Loc.GetString(internalsConnected ? "gas-tank-window-internal-connected" : "gas-tank-window-internal-disconnected"))));
_spbPressure.Value = outputPressure;
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
// Easier than managing state on any ent changes. Previously this was just ticked on server's GasTankSystem.
if (_entManager.TryGetComponent(Entity, out GasTankComponent? tank))
{
var canConnectInternals = _entManager.System<SharedGasTankSystem>().CanConnectToInternals((Entity, tank));
_btnInternals.Disabled = !canConnectInternals;
}
if (!_btnInternals.Disabled)
{
_btnInternals.Disabled = _entManager.System<UseDelaySystem>().IsDelayed(Entity, id: SharedGasTankSystem.GasTankDelay);
}
}

View File

@@ -918,6 +918,11 @@ public sealed class ChatUIController : UIController
_typingIndicator?.ClientChangedChatText();
}
public void NotifyChatFocus(bool isFocused)
{
_typingIndicator?.ClientChangedChatFocus(isFocused);
}
public void Repopulate()
{
foreach (var chat in _chats)

View File

@@ -34,6 +34,8 @@ public partial class ChatBox : UIWidget
ChatInput.Input.OnTextEntered += OnTextEntered;
ChatInput.Input.OnKeyBindDown += OnInputKeyBindDown;
ChatInput.Input.OnTextChanged += OnTextChanged;
ChatInput.Input.OnFocusEnter += OnFocusEnter;
ChatInput.Input.OnFocusExit += OnFocusExit;
ChatInput.ChannelSelector.OnChannelSelect += OnChannelSelect;
ChatInput.FilterButton.Popup.OnChannelFilter += OnChannelFilter;
@@ -174,6 +176,18 @@ public partial class ChatBox : UIWidget
_controller.NotifyChatTextChange();
}
private void OnFocusEnter(LineEditEventArgs args)
{
// Warn typing indicator about focus
_controller.NotifyChatFocus(true);
}
private void OnFocusExit(LineEditEventArgs args)
{
// Warn typing indicator about focus
_controller.NotifyChatFocus(false);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);

View File

@@ -10,6 +10,7 @@ using Robust.Client.UserInterface;
using Content.Client.UserInterface.Controls;
using Content.Shared.IdentityManagement;
using Robust.Client.Graphics;
using Robust.Shared.Utility;
namespace Content.Client.VendingMachines.UI
{
@@ -162,7 +163,9 @@ namespace Content.Client.VendingMachines.UI
continue;
var dummy = _dummies[proto];
var amount = cachedInventory.First(o => o.ID == proto).Amount;
if (!cachedInventory.TryFirstOrDefault(o => o.ID == proto, out var entry))
continue;
var amount = entry.Amount;
// Could be better? Problem is all inventory entries get squashed.
var text = GetItemText(dummy, amount);

View File

@@ -1,31 +0,0 @@
using Content.Client.Xenoarchaeology.Ui;
using Content.Shared.Xenoarchaeology.Equipment;
using Content.Shared.Xenoarchaeology.Equipment.Components;
using Robust.Client.GameObjects;
namespace Content.Client.Xenoarchaeology.Equipment;
/// <inheritdoc cref="SharedNodeScannerSystem"/>
public sealed class NodeScannerSystem : SharedNodeScannerSystem
{
[Dependency] private readonly UserInterfaceSystem _ui = default!;
/// <inheritdoc />
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<NodeScannerComponent, AfterAutoHandleStateEvent>(OnAnalysisConsoleAfterAutoHandleState);
}
protected override void TryOpenUi(Entity<NodeScannerComponent> device, EntityUid actor)
{
_ui.TryOpenUi(device.Owner, NodeScannerUiKey.Key, actor, true);
}
private void OnAnalysisConsoleAfterAutoHandleState(Entity<NodeScannerComponent> ent, ref AfterAutoHandleStateEvent args)
{
if (_ui.TryGetOpenUi<NodeScannerBoundUserInterface>(ent.Owner, NodeScannerUiKey.Key, out var bui))
bui.Update(ent);
}
}

View File

@@ -1,4 +1,3 @@
using Content.Shared.Xenoarchaeology.Equipment.Components;
using Robust.Client.UserInterface;
namespace Content.Client.Xenoarchaeology.Ui;
@@ -18,15 +17,6 @@ public sealed class NodeScannerBoundUserInterface(EntityUid owner, Enum uiKey) :
_scannerDisplay = this.CreateWindow<NodeScannerDisplay>();
_scannerDisplay.SetOwner(Owner);
_scannerDisplay.OnClose += Close;
}
/// <summary>
/// Update UI state based on corresponding component.
/// </summary>
public void Update(Entity<NodeScannerComponent> ent)
{
_scannerDisplay?.Update(ent);
}
/// <inheritdoc />

View File

@@ -1,8 +1,12 @@
using Content.Client.UserInterface.Controls;
using Content.Shared.NameIdentifier;
using Content.Shared.Xenoarchaeology.Artifact;
using Content.Shared.Xenoarchaeology.Artifact.Components;
using Content.Shared.Xenoarchaeology.Equipment.Components;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing;
namespace Content.Client.Xenoarchaeology.Ui;
@@ -10,12 +14,21 @@ namespace Content.Client.Xenoarchaeology.Ui;
public sealed partial class NodeScannerDisplay : FancyWindow
{
[Dependency] private readonly IEntityManager _ent = default!;
[Dependency] private readonly IGameTiming _timing= default!;
private readonly SharedXenoArtifactSystem _artifact;
private TimeSpan? _nextUpdate;
private EntityUid _owner;
private TimeSpan _updateFromAttachedFrequency;
private readonly HashSet<string> _triggeredNodeNames = new();
public NodeScannerDisplay()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
_artifact = _ent.System<SharedXenoArtifactSystem>();
}
/// <summary>
@@ -30,30 +43,80 @@ public sealed partial class NodeScannerDisplay : FancyWindow
return;
}
Update((scannerEntityUid, scannerComponent));
_updateFromAttachedFrequency = scannerComponent.DisplayDataUpdateInterval;
_owner = scannerEntityUid;
}
/// <inheritdoc />
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
if(_nextUpdate != null && _timing.CurTime < _nextUpdate)
return;
_nextUpdate = _timing.CurTime + _updateFromAttachedFrequency;
if (!_ent.TryGetComponent(_owner, out NodeScannerConnectedComponent? connectedScanner))
{
Update(false, ArtifactState.None);
return;
}
var attachedArtifactEnt = connectedScanner.AttachedTo;
if (!_ent.TryGetComponent(attachedArtifactEnt, out XenoArtifactComponent? artifactComponent))
return;
_ent.TryGetComponent(attachedArtifactEnt, out XenoArtifactUnlockingComponent? unlockingComponent);
_triggeredNodeNames.Clear();
ArtifactState artifactState;
if (unlockingComponent == null)
{
var timeToUnlockAvailable = artifactComponent.NextUnlockTime - _timing.CurTime;
artifactState = timeToUnlockAvailable > TimeSpan.Zero
? ArtifactState.Cooldown
: ArtifactState.Ready;
}
else
{
var triggeredIndexes = unlockingComponent.TriggeredNodeIndexes;
foreach (var triggeredIndex in triggeredIndexes)
{
var node = _artifact.GetNode((attachedArtifactEnt, artifactComponent), triggeredIndex);
var triggeredNodeName = (_ent.GetComponentOrNull<NameIdentifierComponent>(node)?.Identifier ?? 0).ToString("D3");
_triggeredNodeNames.Add(triggeredNodeName);
}
artifactState = ArtifactState.Unlocking;
}
Update(true, artifactState, _triggeredNodeNames);
}
/// <summary>
/// Updates labels with scanned artifact data and list of triggered nodes from component.
/// </summary>
public void Update(Entity<NodeScannerComponent> ent)
private void Update(bool isConnected, ArtifactState artifactState, HashSet<string>? triggeredNodeNames = null)
{
ArtifactStateLabel.Text = GetState(ent);
var scannedAt = ent.Comp.ScannedAt;
NodeScannerState.Text = scannedAt > TimeSpan.Zero
? Loc.GetString("node-scanner-artifact-scanned-time", ("time", scannedAt.Value.ToString(@"hh\:mm\:ss")))
: Loc.GetString("node-scanner-artifact-scanned-time-none");
ArtifactStateLabel.Text = GetStateText(artifactState);
NodeScannerState.Text = isConnected
? Loc.GetString("node-scanner-artifact-connected")
: Loc.GetString("node-scanner-artifact-non-connected");
ActiveNodesList.Children.Clear();
var triggeredNodesSnapshot = ent.Comp.TriggeredNodesSnapshot;
if (triggeredNodesSnapshot.Count > 0)
if (triggeredNodeNames == null)
return;
if (triggeredNodeNames.Count > 0)
{
// show list of triggered nodes instead of 'no data' placeholder
NoActiveNodeDataLabel.Visible = false;
ActiveNodesList.Visible = true;
foreach (var nodeId in triggeredNodesSnapshot)
foreach (var nodeId in triggeredNodeNames)
{
var nodeLabel = new Button
{
@@ -73,9 +136,9 @@ public sealed partial class NodeScannerDisplay : FancyWindow
}
}
private string GetState(Entity<NodeScannerComponent> ent)
private string GetStateText(ArtifactState state)
{
return ent.Comp.ArtifactState switch
return state switch
{
ArtifactState.None => "\u2800", // placeholder for line to not be squeezed
ArtifactState.Ready => Loc.GetString("node-scanner-artifact-state-ready"),

View File

@@ -219,6 +219,7 @@ public sealed class CargoTest
- type: stack
id: StackProto
name: stack-steel
spawn: A
- type: entity

View File

@@ -181,7 +181,7 @@ public sealed class NukeOpsTest
}
Assert.That(!entMan.EntityExists(nukieStationEnt)); // its not supposed to be a station!
Assert.That(server.MapMan.MapExists(gridsRule.Map));
Assert.That(mapSys.MapExists(gridsRule.Map));
var nukieMap = mapSys.GetMap(gridsRule.Map!.Value);
var targetStation = entMan.GetComponent<StationDataComponent>(ruleComp.TargetStation!.Value);

View File

@@ -6,6 +6,7 @@ using Content.Server.Power.Components;
using Content.Server.Power.EntitySystems;
using Content.Server.Power.Nodes;
using Content.Shared.Coordinates;
using Content.Shared.NodeContainer;
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Maths;

View File

@@ -182,9 +182,9 @@ namespace Content.IntegrationTests.Tests
var server = pair.Server;
await server.WaitIdleAsync();
var mapManager = server.ResolveDependency<IMapManager>();
var entityManager = server.ResolveDependency<IEntityManager>();
var entitySystemManager = server.ResolveDependency<IEntitySystemManager>();
var mapSystem = server.System<SharedMapSystem>();
EntityUid packageRight;
EntityUid packageWrong;
@@ -255,7 +255,7 @@ namespace Content.IntegrationTests.Tests
Assert.That(systemMachine.GetAvailableInventory(machine, machineComponent), Has.Count.GreaterThan(0),
"Machine available inventory count is not greater than zero after restock.");
mapManager.DeleteMap(testMap.MapId);
mapSystem.DeleteMap(testMap.MapId);
});
await pair.CleanReturnAsync();
@@ -269,9 +269,9 @@ namespace Content.IntegrationTests.Tests
await server.WaitIdleAsync();
var prototypeManager = server.ResolveDependency<IPrototypeManager>();
var mapManager = server.ResolveDependency<IMapManager>();
var entityManager = server.ResolveDependency<IEntityManager>();
var entitySystemManager = server.ResolveDependency<IEntitySystemManager>();
var mapSystem = server.System<SharedMapSystem>();
var damageableSystem = entitySystemManager.GetEntitySystem<DamageableSystem>();
@@ -319,7 +319,7 @@ namespace Content.IntegrationTests.Tests
Assert.That(totalRamen, Is.EqualTo(2),
"Did not find enough ramen after destroying restock box.");
mapManager.DeleteMap(testMap.MapId);
mapSystem.DeleteMap(testMap.MapId);
});
await pair.CleanReturnAsync();

View File

@@ -197,7 +197,7 @@ public sealed class AccessOverriderSystem : SharedAccessOverriderSystem
if (!PrivilegedIdIsAuthorized(uid, component))
return;
if (!_interactionSystem.InRangeUnobstructed(uid, component.TargetAccessReaderId))
if (!_interactionSystem.InRangeUnobstructed(player, component.TargetAccessReaderId))
{
_popupSystem.PopupEntity(Loc.GetString("access-overrider-out-of-range"), player, player);

View File

@@ -18,6 +18,7 @@ using Content.Shared.Access.Components;
using Content.Shared.Access.Systems;
using Content.Shared.Administration;
using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
using Content.Shared.Construction.Components;
using Content.Shared.Damage;
using Content.Shared.Damage.Components;

View File

@@ -5,6 +5,8 @@ using Content.Server.Chat.Managers;
using Content.Server.Explosion.EntitySystems;
using Content.Server.NodeContainer.NodeGroups;
using Content.Server.NodeContainer.Nodes;
using Content.Shared.NodeContainer;
using Content.Shared.NodeContainer.NodeGroups;
using Robust.Server.GameObjects;
using Robust.Shared.Map.Components;
using Robust.Shared.Random;

View File

@@ -10,6 +10,7 @@ using Content.Shared.Ame.Components;
using Content.Shared.Containers.ItemSlots;
using Content.Shared.Database;
using Content.Shared.Mind.Components;
using Content.Shared.NodeContainer;
using Content.Shared.Power;
using Robust.Server.GameObjects;
using Robust.Shared.Audio;

View File

@@ -84,10 +84,6 @@ public sealed class TileAnomalySystem : SharedTileAnomalySystem
private void SpawnTiles(Entity<TileSpawnAnomalyComponent> anomaly, TileSpawnSettingsEntry entry, float stability, float severity, float powerMod)
{
var xform = Transform(anomaly);
if (!TryComp<MapGridComponent>(xform.GridUid, out var grid))
return;
var tiles = _anomaly.GetSpawningPoints(anomaly, stability, severity, entry.Settings, powerMod);
if (tiles == null)
return;

View File

@@ -1,21 +0,0 @@
using Content.Shared.Inventory;
namespace Content.Server.Atmos.Components
{
/// <summary>
/// Used in internals as breath tool.
/// </summary>
[RegisterComponent]
[ComponentProtoName("BreathMask")]
public sealed partial class BreathToolComponent : Component
{
/// <summary>
/// Tool is functional only in allowed slots
/// </summary>
[DataField]
public SlotFlags AllowedSlots = SlotFlags.MASK | SlotFlags.HEAD;
public bool IsFunctional;
public EntityUid? ConnectedInternalsEntity;
}
}

View File

@@ -1,121 +0,0 @@
using Content.Shared.Atmos;
using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
namespace Content.Server.Atmos.Components
{
[RegisterComponent]
public sealed partial class GasTankComponent : Component, IGasMixtureHolder
{
public const float MaxExplosionRange = 26f;
private const float DefaultLowPressure = 0f;
private const float DefaultOutputPressure = Atmospherics.OneAtmosphere;
public int Integrity = 3;
public bool IsLowPressure => (Air?.Pressure ?? 0F) <= TankLowPressure;
[ViewVariables(VVAccess.ReadWrite), DataField("ruptureSound")]
public SoundSpecifier RuptureSound = new SoundPathSpecifier("/Audio/Effects/spray.ogg");
[ViewVariables(VVAccess.ReadWrite), DataField("connectSound")]
public SoundSpecifier? ConnectSound =
new SoundPathSpecifier("/Audio/Effects/internals.ogg")
{
Params = AudioParams.Default.WithVolume(5f),
};
[ViewVariables(VVAccess.ReadWrite), DataField("disconnectSound")]
public SoundSpecifier? DisconnectSound;
// Cancel toggles sounds if we re-toggle again.
public EntityUid? ConnectStream;
public EntityUid? DisconnectStream;
[DataField("air"), ViewVariables(VVAccess.ReadWrite)]
public GasMixture Air { get; set; } = new();
/// <summary>
/// Pressure at which tank should be considered 'low' such as for internals.
/// </summary>
[DataField("tankLowPressure"), ViewVariables(VVAccess.ReadWrite)]
public float TankLowPressure = DefaultLowPressure;
/// <summary>
/// Distributed pressure.
/// </summary>
[DataField("outputPressure"), ViewVariables(VVAccess.ReadWrite)]
public float OutputPressure = DefaultOutputPressure;
/// <summary>
/// The maximum allowed output pressure.
/// </summary>
[DataField("maxOutputPressure"), ViewVariables(VVAccess.ReadWrite)]
public float MaxOutputPressure = 3 * DefaultOutputPressure;
/// <summary>
/// Tank is connected to internals.
/// </summary>
[ViewVariables]
public bool IsConnected => User != null;
[ViewVariables]
public EntityUid? User;
/// <summary>
/// True if this entity was recently moved out of a container. This might have been a hand -> inventory
/// transfer, or it might have been the user dropping the tank. This indicates the tank needs to be checked.
/// </summary>
[ViewVariables]
public bool CheckUser;
/// <summary>
/// Pressure at which tanks start leaking.
/// </summary>
[DataField("tankLeakPressure"), ViewVariables(VVAccess.ReadWrite)]
public float TankLeakPressure = 30 * Atmospherics.OneAtmosphere;
/// <summary>
/// Pressure at which tank spills all contents into atmosphere.
/// </summary>
[DataField("tankRupturePressure"), ViewVariables(VVAccess.ReadWrite)]
public float TankRupturePressure = 40 * Atmospherics.OneAtmosphere;
/// <summary>
/// Base 3x3 explosion.
/// </summary>
[DataField("tankFragmentPressure"), ViewVariables(VVAccess.ReadWrite)]
public float TankFragmentPressure = 50 * Atmospherics.OneAtmosphere;
/// <summary>
/// Increases explosion for each scale kPa above threshold.
/// </summary>
[DataField("tankFragmentScale"), ViewVariables(VVAccess.ReadWrite)]
public float TankFragmentScale = 2 * Atmospherics.OneAtmosphere;
[DataField("toggleAction", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string ToggleAction = "ActionToggleInternals";
[DataField("toggleActionEntity")] public EntityUid? ToggleActionEntity;
/// <summary>
/// Valve to release gas from tank
/// </summary>
[DataField("isValveOpen"), ViewVariables(VVAccess.ReadWrite)]
public bool IsValveOpen = false;
/// <summary>
/// Gas release rate in L/s
/// </summary>
[DataField("valveOutputRate"), ViewVariables(VVAccess.ReadWrite)]
public float ValveOutputRate = 100f;
[DataField("valveSound"), ViewVariables(VVAccess.ReadWrite)]
public SoundSpecifier ValveSound =
new SoundCollectionSpecifier("valveSqueak")
{
Params = AudioParams.Default.WithVolume(-5f),
};
}
}

View File

@@ -18,6 +18,7 @@ using Robust.Shared.Timing;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Shared.DeviceNetwork.Components;
using Content.Shared.NodeContainer;
namespace Content.Server.Atmos.Consoles;

View File

@@ -1,30 +0,0 @@
using Content.Server.Atmos.Components;
using Content.Server.Body.Components;
namespace Content.Server.Atmos.EntitySystems;
public sealed partial class AtmosphereSystem
{
private void InitializeBreathTool()
{
SubscribeLocalEvent<BreathToolComponent, ComponentShutdown>(OnBreathToolShutdown);
}
private void OnBreathToolShutdown(Entity<BreathToolComponent> entity, ref ComponentShutdown args)
{
DisconnectInternals(entity);
}
public void DisconnectInternals(Entity<BreathToolComponent> entity)
{
var old = entity.Comp.ConnectedInternalsEntity;
entity.Comp.ConnectedInternalsEntity = null;
if (TryComp<InternalsComponent>(old, out var internalsComponent))
{
_internals.DisconnectBreathTool((old.Value, internalsComponent), entity.Owner);
}
entity.Comp.IsFunctional = false;
}
}

View File

@@ -36,7 +36,7 @@ public sealed partial class AtmosphereSystem
return;
}
var mixtures = new GasMixture[8];
var mixtures = new GasMixture[9];
for (var i = 0; i < mixtures.Length; i++)
mixtures[i] = new GasMixture(Atmospherics.CellVolume) { Temperature = Atmospherics.T20C };
@@ -68,6 +68,10 @@ public sealed partial class AtmosphereSystem
// 7: Nitrogen (101kpa) for vox rooms
mixtures[7].AdjustMoles(Gas.Nitrogen, Atmospherics.MolesCellStandard);
// 8: Air (GM)
mixtures[8].AdjustMoles(Gas.Oxygen, Atmospherics.OxygenMolesGasMiner);
mixtures[8].AdjustMoles(Gas.Nitrogen, Atmospherics.NitrogenMolesGasMiner);
foreach (var arg in args)
{
if (!NetEntity.TryParse(arg, out var netEntity) || !TryGetEntity(netEntity, out var euid))

View File

@@ -44,8 +44,6 @@ public sealed partial class AtmosphereSystem
private void OnGridAtmosphereInit(EntityUid uid, GridAtmosphereComponent component, ComponentInit args)
{
base.Initialize();
EnsureComp<GasTileOverlayComponent>(uid);
foreach (var tile in component.Tiles.Values)
{

View File

@@ -28,7 +28,6 @@ public sealed partial class AtmosphereSystem : SharedAtmosphereSystem
[Dependency] private readonly ITileDefinitionManager _tileDefinitionManager = default!;
[Dependency] private readonly IAdminLogManager _adminLog = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
[Dependency] private readonly InternalsSystem _internals = default!;
[Dependency] private readonly SharedContainerSystem _containers = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly GasTileOverlaySystem _gasTileOverlaySystem = default!;
@@ -56,7 +55,6 @@ public sealed partial class AtmosphereSystem : SharedAtmosphereSystem
UpdatesAfter.Add(typeof(NodeGroupSystem));
InitializeBreathTool();
InitializeGases();
InitializeCommands();
InitializeCVars();

View File

@@ -7,6 +7,7 @@ using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
using Content.Shared.Interaction;
using Content.Shared.Interaction.Events;
using Content.Shared.NodeContainer;
using JetBrains.Annotations;
using Robust.Server.GameObjects;
using static Content.Shared.Atmos.Components.GasAnalyzerComponent;

View File

@@ -1,21 +1,13 @@
using Content.Server.Atmos.Components;
using Content.Server.Body.Components;
using Content.Server.Body.Systems;
using Content.Server.Cargo.Systems;
using Content.Server.Explosion.EntitySystems;
using Content.Shared.UserInterface;
using Content.Shared.Actions;
using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
using Content.Shared.Examine;
using Content.Shared.Atmos.EntitySystems;
using Content.Shared.Throwing;
using Content.Shared.Toggleable;
using Content.Shared.Verbs;
using JetBrains.Annotations;
using Robust.Server.GameObjects;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Containers;
using Robust.Shared.Random;
using Robust.Shared.Configuration;
using Content.Shared.CCVar;
@@ -23,14 +15,11 @@ using Content.Shared.CCVar;
namespace Content.Server.Atmos.EntitySystems
{
[UsedImplicitly]
public sealed class GasTankSystem : EntitySystem
public sealed class GasTankSystem : SharedGasTankSystem
{
[Dependency] private readonly AtmosphereSystem _atmosphereSystem = default!;
[Dependency] private readonly ExplosionSystem _explosions = default!;
[Dependency] private readonly InternalsSystem _internals = default!;
[Dependency] private readonly SharedAudioSystem _audioSys = default!;
[Dependency] private readonly SharedContainerSystem _containers = default!;
[Dependency] private readonly SharedActionsSystem _actions = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly ThrowingSystem _throwing = default!;
@@ -44,17 +33,9 @@ namespace Content.Server.Atmos.EntitySystems
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<GasTankComponent, ComponentShutdown>(OnGasShutdown);
SubscribeLocalEvent<GasTankComponent, BeforeActivatableUIOpenEvent>(BeforeUiOpen);
SubscribeLocalEvent<GasTankComponent, GetItemActionsEvent>(OnGetActions);
SubscribeLocalEvent<GasTankComponent, ExaminedEvent>(OnExamined);
SubscribeLocalEvent<GasTankComponent, ToggleActionEvent>(OnActionToggle);
SubscribeLocalEvent<GasTankComponent, EntParentChangedMessage>(OnParentChange);
SubscribeLocalEvent<GasTankComponent, GasTankSetPressureMessage>(OnGasTankSetPressure);
SubscribeLocalEvent<GasTankComponent, GasTankToggleInternalsMessage>(OnGasTankToggleInternals);
SubscribeLocalEvent<GasTankComponent, GasAnalyzerScanEvent>(OnAnalyzed);
SubscribeLocalEvent<GasTankComponent, PriceCalculationEvent>(OnGasTankPrice);
SubscribeLocalEvent<GasTankComponent, GetVerbsEvent<AlternativeVerb>>(OnGetAlternativeVerb);
Subs.CVar(_cfg, CCVars.AtmosTankFragment, UpdateMaxRange, true);
}
@@ -63,44 +44,16 @@ namespace Content.Server.Atmos.EntitySystems
_maxExplosionRange = value;
}
private void OnGasShutdown(Entity<GasTankComponent> gasTank, ref ComponentShutdown args)
{
DisconnectFromInternals(gasTank);
}
private void OnGasTankToggleInternals(Entity<GasTankComponent> ent, ref GasTankToggleInternalsMessage args)
{
ToggleInternals(ent);
}
private void OnGasTankSetPressure(Entity<GasTankComponent> ent, ref GasTankSetPressureMessage args)
{
var pressure = Math.Clamp(args.Pressure, 0f, ent.Comp.MaxOutputPressure);
ent.Comp.OutputPressure = pressure;
UpdateUserInterface(ent, true);
}
public void UpdateUserInterface(Entity<GasTankComponent> ent, bool initialUpdate = false)
public override void UpdateUserInterface(Entity<GasTankComponent> ent)
{
var (owner, component) = ent;
_ui.SetUiState(owner, SharedGasTankUiKey.Key,
new GasTankBoundUserInterfaceState
{
TankPressure = component.Air?.Pressure ?? 0,
OutputPressure = initialUpdate ? component.OutputPressure : null,
InternalsConnected = component.IsConnected,
CanConnectInternals = CanConnectToInternals(ent)
});
}
private void BeforeUiOpen(Entity<GasTankComponent> ent, ref BeforeActivatableUIOpenEvent args)
{
// Only initial update includes output pressure information, to avoid overwriting client-input as the updates come in.
UpdateUserInterface(ent, true);
}
private void OnParentChange(EntityUid uid, GasTankComponent component, ref EntParentChangedMessage args)
{
// When an item is moved from hands -> pockets, the container removal briefly dumps the item on the floor.
@@ -109,30 +62,6 @@ namespace Content.Server.Atmos.EntitySystems
component.CheckUser = true;
}
private void OnGetActions(EntityUid uid, GasTankComponent component, GetItemActionsEvent args)
{
args.AddAction(ref component.ToggleActionEntity, component.ToggleAction);
}
private void OnExamined(EntityUid uid, GasTankComponent component, ExaminedEvent args)
{
using var _ = args.PushGroup(nameof(GasTankComponent));
if (args.IsInDetailsRange)
args.PushMarkup(Loc.GetString("comp-gas-tank-examine", ("pressure", Math.Round(component.Air?.Pressure ?? 0))));
if (component.IsConnected)
args.PushMarkup(Loc.GetString("comp-gas-tank-connected"));
args.PushMarkup(Loc.GetString(component.IsValveOpen ? "comp-gas-tank-examine-open-valve" : "comp-gas-tank-examine-closed-valve"));
}
private void OnActionToggle(Entity<GasTankComponent> gasTank, ref ToggleActionEvent args)
{
if (args.Handled)
return;
ToggleInternals(gasTank);
args.Handled = true;
}
public override void Update(float frameTime)
{
base.Update(frameTime);
@@ -167,8 +96,10 @@ namespace Content.Server.Atmos.EntitySystems
{
_atmosphereSystem.React(comp.Air, comp);
}
CheckStatus(gasTank);
if (_ui.IsUiOpen(uid, SharedGasTankUiKey.Key))
if ((comp.IsConnected || comp.IsValveOpen) && _ui.IsUiOpen(uid, SharedGasTankUiKey.Key))
{
UpdateUserInterface(gasTank);
}
@@ -190,18 +121,6 @@ namespace Content.Server.Atmos.EntitySystems
_audioSys.PlayPvs(gasTank.Comp.RuptureSound, gasTank);
}
private void ToggleInternals(Entity<GasTankComponent> ent)
{
if (ent.Comp.IsConnected)
{
DisconnectFromInternals(ent);
}
else
{
ConnectToInternals(ent);
}
}
public GasMixture? RemoveAir(Entity<GasTankComponent> gasTank, float amount)
{
var gas = gasTank.Comp.Air?.Remove(amount);
@@ -227,95 +146,6 @@ namespace Content.Server.Atmos.EntitySystems
return air;
}
public bool CanConnectToInternals(Entity<GasTankComponent> ent)
{
TryGetInternalsComp(ent, out _, out var internalsComp, ent.Comp.User);
return internalsComp != null && internalsComp.BreathTools.Count != 0 && !ent.Comp.IsValveOpen;
}
public void ConnectToInternals(Entity<GasTankComponent> ent)
{
var (owner, component) = ent;
if (component.IsConnected || !CanConnectToInternals(ent))
return;
TryGetInternalsComp(ent, out var internalsUid, out var internalsComp, ent.Comp.User);
if (internalsUid == null || internalsComp == null)
return;
if (_internals.TryConnectTank((internalsUid.Value, internalsComp), owner))
component.User = internalsUid.Value;
_actions.SetToggled(component.ToggleActionEntity, component.IsConnected);
// Couldn't toggle!
if (!component.IsConnected)
return;
component.ConnectStream = _audioSys.Stop(component.ConnectStream);
component.ConnectStream = _audioSys.PlayPvs(component.ConnectSound, owner)?.Entity;
UpdateUserInterface(ent);
}
public void DisconnectFromInternals(Entity<GasTankComponent> ent)
{
var (owner, component) = ent;
if (component.User == null)
return;
TryGetInternalsComp(ent, out var internalsUid, out var internalsComp, component.User);
component.User = null;
_actions.SetToggled(component.ToggleActionEntity, false);
if (internalsUid != null && internalsComp != null)
_internals.DisconnectTank((internalsUid.Value, internalsComp));
component.DisconnectStream = _audioSys.Stop(component.DisconnectStream);
component.DisconnectStream = _audioSys.PlayPvs(component.DisconnectSound, owner)?.Entity;
UpdateUserInterface(ent);
}
/// <summary>
/// Tries to retrieve the internals component of either the gas tank's user,
/// or the gas tank's... containing container
/// </summary>
/// <param name="user">The user of the gas tank</param>
/// <returns>True if internals comp isn't null, false if it is null</returns>
private bool TryGetInternalsComp(Entity<GasTankComponent> ent, out EntityUid? internalsUid, out InternalsComponent? internalsComp, EntityUid? user = null)
{
internalsUid = default;
internalsComp = default;
// If the gas tank doesn't exist for whatever reason, don't even bother
if (TerminatingOrDeleted(ent.Owner))
return false;
user ??= ent.Comp.User;
// Check if the gas tank's user actually has the component that allows them to use a gas tank and mask
if (TryComp<InternalsComponent>(user, out var userInternalsComp) && userInternalsComp != null)
{
internalsUid = user;
internalsComp = userInternalsComp;
return true;
}
// Yeah I have no clue what this actually does, I appreciate the lack of comments on the original function
if (_containers.TryGetContainingContainer((ent.Owner, Transform(ent.Owner)), out var container) && container != null)
{
if (TryComp<InternalsComponent>(container.Owner, out var containerInternalsComp) && containerInternalsComp != null)
{
internalsUid = container.Owner;
internalsComp = containerInternalsComp;
return true;
}
}
return false;
}
public void AssumeAir(Entity<GasTankComponent> ent, GasMixture giver)
{
_atmosphereSystem.Merge(ent.Comp.Air, giver);
@@ -404,21 +234,5 @@ namespace Content.Server.Atmos.EntitySystems
{
args.Price += _atmosphereSystem.GetPrice(component.Air);
}
private void OnGetAlternativeVerb(EntityUid uid, GasTankComponent component, GetVerbsEvent<AlternativeVerb> args)
{
if (!args.CanAccess || !args.CanInteract || args.Hands == null)
return;
args.Verbs.Add(new AlternativeVerb()
{
Text = component.IsValveOpen ? Loc.GetString("comp-gas-tank-close-valve") : Loc.GetString("comp-gas-tank-open-valve"),
Act = () =>
{
component.IsValveOpen = !component.IsValveOpen;
_audioSys.PlayPvs(component.ValveSound, uid);
},
Disabled = component.IsConnected,
});
}
}
}

View File

@@ -5,6 +5,7 @@ using Content.Server.NodeContainer.Nodes;
using Content.Server.Popups;
using Content.Shared.Atmos;
using Content.Shared.Construction.Components;
using Content.Shared.NodeContainer;
using JetBrains.Annotations;
using Robust.Server.GameObjects;
using Robust.Shared.Map.Components;

View File

@@ -1,9 +0,0 @@
using Content.Shared.Atmos;
namespace Content.Server.Atmos
{
public interface IGasMixtureHolder
{
public GasMixture Air { get; set; }
}
}

View File

@@ -3,6 +3,7 @@ using Content.Server.NodeContainer.EntitySystems;
using Content.Server.NodeContainer.Nodes;
using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
using Content.Shared.NodeContainer;
using Robust.Shared.Map.Components;
namespace Content.Server.Atmos.Piping.EntitySystems;

View File

@@ -7,6 +7,7 @@ using Content.Server.Popups;
using Content.Shared.Atmos;
using Content.Shared.Construction.Components;
using Content.Shared.Destructible;
using Content.Shared.NodeContainer;
using Content.Shared.Popups;
using JetBrains.Annotations;

View File

@@ -1,73 +0,0 @@
using Content.Shared.Atmos;
using Content.Shared.Containers.ItemSlots;
using Content.Shared.Guidebook;
using Robust.Shared.Audio;
namespace Content.Server.Atmos.Piping.Unary.Components
{
[RegisterComponent]
public sealed partial class GasCanisterComponent : Component, IGasMixtureHolder
{
[ViewVariables(VVAccess.ReadWrite)]
[DataField("port")]
public string PortName { get; set; } = "port";
/// <summary>
/// Container name for the gas tank holder.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("container")]
public string ContainerName { get; set; } = "tank_slot";
[ViewVariables(VVAccess.ReadWrite)]
[DataField]
public ItemSlot GasTankSlot = new();
[ViewVariables(VVAccess.ReadWrite)]
[DataField("gasMixture")]
public GasMixture Air { get; set; } = new();
/// <summary>
/// Last recorded pressure, for appearance-updating purposes.
/// </summary>
public float LastPressure { get; set; } = 0f;
/// <summary>
/// Minimum release pressure possible for the release valve.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("minReleasePressure")]
public float MinReleasePressure { get; set; } = Atmospherics.OneAtmosphere / 10;
/// <summary>
/// Maximum release pressure possible for the release valve.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("maxReleasePressure")]
public float MaxReleasePressure { get; set; } = Atmospherics.OneAtmosphere * 10;
/// <summary>
/// Valve release pressure.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("releasePressure")]
public float ReleasePressure { get; set; } = Atmospherics.OneAtmosphere;
/// <summary>
/// Whether the release valve is open on the canister.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField("releaseValve")]
public bool ReleaseValve { get; set; } = false;
[DataField("accessDeniedSound")]
public SoundSpecifier AccessDeniedSound = new SoundPathSpecifier("/Audio/Machines/custom_deny.ogg");
#region GuidebookData
[GuidebookData]
public float Volume => Air.Volume;
#endregion
}
}

View File

@@ -1,55 +1,32 @@
using Content.Server.Administration.Logs;
using Content.Server.Atmos.Components;
using Content.Server.Atmos.EntitySystems;
using Content.Server.Atmos.Piping.Components;
using Content.Server.Atmos.Piping.Unary.Components;
using Content.Server.Cargo.Systems;
using Content.Server.NodeContainer;
using Content.Server.NodeContainer.EntitySystems;
using Content.Server.NodeContainer.NodeGroups;
using Content.Server.NodeContainer.Nodes;
using Content.Server.Popups;
using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
using Content.Shared.Atmos.Piping.Binary.Components;
using Content.Shared.Containers.ItemSlots;
using Content.Shared.Atmos.Piping.Unary.Systems;
using Content.Shared.Database;
using Content.Shared.Interaction;
using Content.Shared.Lock;
using Robust.Server.GameObjects;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Containers;
using Robust.Shared.Player;
using Content.Shared.NodeContainer;
using GasCanisterComponent = Content.Shared.Atmos.Piping.Unary.Components.GasCanisterComponent;
namespace Content.Server.Atmos.Piping.Unary.EntitySystems;
public sealed class GasCanisterSystem : EntitySystem
public sealed class GasCanisterSystem : SharedGasCanisterSystem
{
[Dependency] private readonly AtmosphereSystem _atmos = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly PopupSystem _popup = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!;
[Dependency] private readonly NodeContainerSystem _nodeContainer = default!;
[Dependency] private readonly ItemSlotsSystem _slots = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<GasCanisterComponent, ComponentStartup>(OnCanisterStartup);
SubscribeLocalEvent<GasCanisterComponent, AtmosDeviceUpdateEvent>(OnCanisterUpdated);
SubscribeLocalEvent<GasCanisterComponent, ActivateInWorldEvent>(OnCanisterActivate, after: new[] { typeof(LockSystem) });
SubscribeLocalEvent<GasCanisterComponent, InteractHandEvent>(OnCanisterInteractHand);
SubscribeLocalEvent<GasCanisterComponent, ItemSlotInsertAttemptEvent>(OnCanisterInsertAttempt);
SubscribeLocalEvent<GasCanisterComponent, EntInsertedIntoContainerMessage>(OnCanisterContainerInserted);
SubscribeLocalEvent<GasCanisterComponent, EntRemovedFromContainerMessage>(OnCanisterContainerRemoved);
SubscribeLocalEvent<GasCanisterComponent, PriceCalculationEvent>(CalculateCanisterPrice);
SubscribeLocalEvent<GasCanisterComponent, GasAnalyzerScanEvent>(OnAnalyzed);
// Bound UI subscriptions
SubscribeLocalEvent<GasCanisterComponent, GasCanisterHoldingTankEjectMessage>(OnHoldingTankEjectMessage);
SubscribeLocalEvent<GasCanisterComponent, GasCanisterChangeReleasePressureMessage>(OnCanisterChangeReleasePressure);
SubscribeLocalEvent<GasCanisterComponent, GasCanisterChangeReleaseValveMessage>(OnCanisterChangeReleaseValve);
}
/// <summary>
@@ -65,24 +42,16 @@ public sealed class GasCanisterSystem : EntitySystem
if (environment is not null)
_atmos.Merge(environment, canister.Air);
_adminLogger.Add(LogType.CanisterPurged, LogImpact.Medium, $"Canister {ToPrettyString(uid):canister} purged its contents of {canister.Air:gas} into the environment.");
AdminLogger.Add(LogType.CanisterPurged, LogImpact.Medium, $"Canister {ToPrettyString(uid):canister} purged its contents of {canister.Air:gas} into the environment.");
canister.Air.Clear();
}
private void OnCanisterStartup(EntityUid uid, GasCanisterComponent comp, ComponentStartup args)
{
// Ensure container
_slots.AddItemSlot(uid, comp.ContainerName, comp.GasTankSlot);
}
private void DirtyUI(EntityUid uid,
GasCanisterComponent? canister = null, NodeContainerComponent? nodeContainer = null)
protected override void DirtyUI(EntityUid uid, GasCanisterComponent? canister = null, NodeContainerComponent? nodeContainer = null)
{
if (!Resolve(uid, ref canister, ref nodeContainer))
return;
var portStatus = false;
string? tankLabel = null;
var tankPressure = 0f;
if (_nodeContainer.TryGetNode(nodeContainer, canister.PortName, out PipeNode? portNode) && portNode.NodeGroup?.Nodes.Count > 1)
@@ -92,62 +61,11 @@ public sealed class GasCanisterSystem : EntitySystem
{
var tank = canister.GasTankSlot.Item.Value;
var tankComponent = Comp<GasTankComponent>(tank);
tankLabel = Name(tank);
tankPressure = tankComponent.Air.Pressure;
}
_ui.SetUiState(uid, GasCanisterUiKey.Key,
new GasCanisterBoundUserInterfaceState(Name(uid),
canister.Air.Pressure, portStatus, tankLabel, tankPressure, canister.ReleasePressure,
canister.ReleaseValve, canister.MinReleasePressure, canister.MaxReleasePressure));
}
private void OnHoldingTankEjectMessage(EntityUid uid, GasCanisterComponent canister, GasCanisterHoldingTankEjectMessage args)
{
if (canister.GasTankSlot.Item == null)
return;
var item = canister.GasTankSlot.Item;
_slots.TryEjectToHands(uid, canister.GasTankSlot, args.Actor);
if (canister.ReleaseValve)
{
_adminLogger.Add(LogType.CanisterTankEjected, LogImpact.High, $"Player {ToPrettyString(args.Actor):player} ejected tank {ToPrettyString(item):tank} from {ToPrettyString(uid):canister} while the valve was open, releasing [{GetContainedGasesString((uid, canister))}] to atmosphere");
}
else
{
_adminLogger.Add(LogType.CanisterTankEjected, LogImpact.Medium, $"Player {ToPrettyString(args.Actor):player} ejected tank {ToPrettyString(item):tank} from {ToPrettyString(uid):canister}");
}
}
private void OnCanisterChangeReleasePressure(EntityUid uid, GasCanisterComponent canister, GasCanisterChangeReleasePressureMessage args)
{
var pressure = Math.Clamp(args.Pressure, canister.MinReleasePressure, canister.MaxReleasePressure);
_adminLogger.Add(LogType.CanisterPressure, LogImpact.Medium, $"{ToPrettyString(args.Actor):player} set the release pressure on {ToPrettyString(uid):canister} to {args.Pressure}");
canister.ReleasePressure = pressure;
DirtyUI(uid, canister);
}
private void OnCanisterChangeReleaseValve(EntityUid uid, GasCanisterComponent canister, GasCanisterChangeReleaseValveMessage args)
{
// filling a jetpack with plasma is less important than filling a room with it
var hasItem = canister.GasTankSlot.HasItem;
var impact = hasItem ? LogImpact.Medium : LogImpact.High;
_adminLogger.Add(
LogType.CanisterValve,
impact,
$"{ToPrettyString(args.Actor):player} {(args.Valve ? "opened" : "closed")} the valve on {ToPrettyString(uid):canister} to {(hasItem ? "inserted tank" : "environment")} while it contained [{GetContainedGasesString((uid, canister))}]");
canister.ReleaseValve = args.Valve;
DirtyUI(uid, canister);
}
private static string GetContainedGasesString(Entity<GasCanisterComponent> canister)
{
return string.Join(", ", canister.Comp.Air);
UI.SetUiState(uid, GasCanisterUiKey.Key,
new GasCanisterBoundUserInterfaceState(canister.Air.Pressure, portStatus, tankPressure));
}
private void OnCanisterUpdated(EntityUid uid, GasCanisterComponent canister, ref AtmosDeviceUpdateEvent args)
@@ -207,76 +125,6 @@ public sealed class GasCanisterSystem : EntitySystem
}
}
private void OnCanisterActivate(EntityUid uid, GasCanisterComponent component, ActivateInWorldEvent args)
{
if (!args.Complex)
return;
if (!TryComp<ActorComponent>(args.User, out var actor))
return;
if (CheckLocked(uid, component, args.User))
return;
// Needs to be here so the locked check still happens if the canister
// is locked and you don't have permissions
if (args.Handled)
return;
_ui.OpenUi(uid, GasCanisterUiKey.Key, actor.PlayerSession);
args.Handled = true;
}
private void OnCanisterInteractHand(EntityUid uid, GasCanisterComponent component, InteractHandEvent args)
{
if (!TryComp<ActorComponent>(args.User, out var actor))
return;
if (CheckLocked(uid, component, args.User))
return;
_ui.OpenUi(uid, GasCanisterUiKey.Key, actor.PlayerSession);
args.Handled = true;
}
private void OnCanisterInsertAttempt(EntityUid uid, GasCanisterComponent component, ref ItemSlotInsertAttemptEvent args)
{
if (args.Slot.ID != component.ContainerName || args.User == null)
return;
if (!TryComp<GasTankComponent>(args.Item, out var gasTank) || gasTank.IsValveOpen)
{
args.Cancelled = true;
return;
}
// Preventing inserting a tank since if its locked you cant remove it.
if (!CheckLocked(uid, component, args.User.Value))
return;
args.Cancelled = true;
}
private void OnCanisterContainerInserted(EntityUid uid, GasCanisterComponent component, EntInsertedIntoContainerMessage args)
{
if (args.Container.ID != component.ContainerName)
return;
DirtyUI(uid, component);
_appearance.SetData(uid, GasCanisterVisuals.TankInserted, true);
}
private void OnCanisterContainerRemoved(EntityUid uid, GasCanisterComponent component, EntRemovedFromContainerMessage args)
{
if (args.Container.ID != component.ContainerName)
return;
DirtyUI(uid, component);
_appearance.SetData(uid, GasCanisterVisuals.TankInserted, false);
}
/// <summary>
/// Mix air from a gas container into a pipe net.
/// Useful for anything that uses connector ports.
@@ -317,23 +165,4 @@ public sealed class GasCanisterSystem : EntitySystem
args.GasMixtures.Add((Name(tank), tankComponent.Air));
}
}
/// <summary>
/// Check if the canister is locked, playing its sound and popup if so.
/// </summary>
/// <returns>
/// True if locked, false otherwise.
/// </returns>
private bool CheckLocked(EntityUid uid, GasCanisterComponent comp, EntityUid user)
{
if (TryComp<LockComponent>(uid, out var lockComp) && lockComp.Locked)
{
_popup.PopupEntity(Loc.GetString("gas-canister-popup-denied"), uid, user);
_audio.PlayPvs(comp.AccessDeniedSound, uid);
return true;
}
return false;
}
}

View File

@@ -22,11 +22,12 @@ using Content.Shared.DeviceNetwork.Components;
using Content.Shared.DoAfter;
using Content.Shared.DeviceNetwork.Events;
using Content.Shared.Examine;
using Content.Shared.Interaction;
using Content.Shared.Power;
using Content.Shared.Tools.Systems;
using Content.Shared.Verbs;
using JetBrains.Annotations;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.Atmos.Piping.Unary.EntitySystems
{
@@ -41,7 +42,6 @@ namespace Content.Server.Atmos.Piping.Unary.EntitySystems
[Dependency] private readonly SharedAmbientSoundSystem _ambientSoundSystem = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly WeldableSystem _weldable = default!;
[Dependency] private readonly SharedToolSystem _toolSystem = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly PowerReceiverSystem _powerReceiverSystem = default!;
@@ -60,7 +60,7 @@ namespace Content.Server.Atmos.Piping.Unary.EntitySystems
SubscribeLocalEvent<GasVentPumpComponent, SignalReceivedEvent>(OnSignalReceived);
SubscribeLocalEvent<GasVentPumpComponent, GasAnalyzerScanEvent>(OnAnalyzed);
SubscribeLocalEvent<GasVentPumpComponent, WeldableChangedEvent>(OnWeldChanged);
SubscribeLocalEvent<GasVentPumpComponent, InteractUsingEvent>(OnInteractUsing);
SubscribeLocalEvent<GasVentPumpComponent, GetVerbsEvent<Verb>>(OnGetVerbs);
SubscribeLocalEvent<GasVentPumpComponent, VentScrewedDoAfterEvent>(OnVentScrewed);
}
@@ -379,23 +379,43 @@ namespace Content.Server.Atmos.Piping.Unary.EntitySystems
{
UpdateState(uid, component);
}
private void OnInteractUsing(EntityUid uid, GasVentPumpComponent component, InteractUsingEvent args)
{
if (args.Handled
|| component.UnderPressureLockout == false
|| !_toolSystem.HasQuality(args.Used, "Screwing")
|| !Transform(uid).Anchored
)
private void OnGetVerbs(Entity<GasVentPumpComponent> ent, ref GetVerbsEvent<Verb> args)
{
if (ent.Comp.UnderPressureLockout == false || !Transform(ent).Anchored)
return;
var user = args.User;
var v = new Verb
{
Priority = 1,
Icon = new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/VerbIcons/unlock.svg.192dpi.png")),
Text = Loc.GetString("gas-vent-pump-release-lockout"),
Impact = LogImpact.Low,
DoContactInteraction = true,
Act = () =>
{
var doAfter = new DoAfterArgs(EntityManager, user, ent.Comp.ManualLockoutDisableDoAfter, new VentScrewedDoAfterEvent(), ent, ent)
{
BreakOnDamage = true,
NeedHand = true,
BreakOnMove = true,
BreakOnWeightlessMove = true,
};
_doAfterSystem.TryStartDoAfter(doAfter);
},
};
args.Verbs.Add(v);
}
args.Handled = true;
_doAfterSystem.TryStartDoAfter(new DoAfterArgs(EntityManager, args.User, component.ManualLockoutDisableDoAfter, new VentScrewedDoAfterEvent(), uid, uid, args.Used));
}
private void OnVentScrewed(EntityUid uid, GasVentPumpComponent component, VentScrewedDoAfterEvent args)
{
if (args.Cancelled || args.Handled)
return;
component.ManualLockoutReenabledAt = _timing.CurTime + component.ManualLockoutDisabledDuration;
component.IsPressureLockoutManuallyDisabled = true;
}

View File

@@ -1,29 +0,0 @@
using Content.Shared.Alert;
using Robust.Shared.Prototypes;
namespace Content.Server.Body.Components
{
/// <summary>
/// Handles hooking up a mask (breathing tool) / gas tank together and allowing the Owner to breathe through it.
/// </summary>
[RegisterComponent]
public sealed partial class InternalsComponent : Component
{
[ViewVariables]
public EntityUid? GasTankEntity;
[ViewVariables]
public HashSet<EntityUid> BreathTools { get; set; } = new();
/// <summary>
/// Toggle Internals delay when the target is not you.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField]
public TimeSpan Delay = TimeSpan.FromSeconds(3);
[DataField]
public ProtoId<AlertPrototype> InternalsAlert = "Internals";
}
}

View File

@@ -1,28 +1,21 @@
using Content.Server.Atmos.Components;
using Content.Server.Atmos.EntitySystems;
using Content.Server.Body.Components;
using Content.Server.Popups;
using Content.Shared.Alert;
using Content.Shared.Atmos;
using Content.Shared.Atmos.Components;
using Content.Shared.Body.Components;
using Content.Shared.Body.Systems;
using Content.Shared.DoAfter;
using Content.Shared.Hands.Components;
using Content.Shared.Internals;
using Content.Shared.Inventory;
using Content.Shared.Roles;
using Content.Shared.Verbs;
using Robust.Shared.Containers;
using Robust.Shared.Utility;
namespace Content.Server.Body.Systems;
public sealed class InternalsSystem : EntitySystem
public sealed class InternalsSystem : SharedInternalsSystem
{
[Dependency] private readonly AlertsSystem _alerts = default!;
[Dependency] private readonly AtmosphereSystem _atmos = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly GasTankSystem _gasTank = default!;
[Dependency] private readonly InventorySystem _inventory = default!;
[Dependency] private readonly PopupSystem _popupSystem = default!;
[Dependency] private readonly RespiratorSystem _respirator = default!;
private EntityQuery<InternalsComponent> _internalsQuery;
@@ -34,12 +27,6 @@ public sealed class InternalsSystem : EntitySystem
_internalsQuery = GetEntityQuery<InternalsComponent>();
SubscribeLocalEvent<InternalsComponent, InhaleLocationEvent>(OnInhaleLocation);
SubscribeLocalEvent<InternalsComponent, ComponentStartup>(OnInternalsStartup);
SubscribeLocalEvent<InternalsComponent, ComponentShutdown>(OnInternalsShutdown);
SubscribeLocalEvent<InternalsComponent, GetVerbsEvent<InteractionVerb>>(OnGetInteractionVerbs);
SubscribeLocalEvent<InternalsComponent, InternalsDoAfterEvent>(OnDoAfter);
SubscribeLocalEvent<InternalsComponent, ToggleInternalsAlertEvent>(OnToggleInternalsAlert);
SubscribeLocalEvent<InternalsComponent, StartingGearEquippedEvent>(OnStartingGear);
}
@@ -66,120 +53,6 @@ public sealed class InternalsSystem : EntitySystem
ToggleInternals(uid, uid, force: false, component);
}
private void OnGetInteractionVerbs(
Entity<InternalsComponent> ent,
ref GetVerbsEvent<InteractionVerb> args)
{
if (!args.CanAccess || !args.CanInteract || args.Hands is null)
return;
if (!AreInternalsWorking(ent) && ent.Comp.BreathTools.Count == 0)
return;
var user = args.User;
InteractionVerb verb = new()
{
Act = () =>
{
ToggleInternals(ent, user, force: false, ent);
},
Message = Loc.GetString("action-description-internals-toggle"),
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/dot.svg.192dpi.png")),
Text = Loc.GetString("action-name-internals-toggle"),
};
args.Verbs.Add(verb);
}
public void ToggleInternals(
EntityUid uid,
EntityUid user,
bool force,
InternalsComponent? internals = null)
{
if (!Resolve(uid, ref internals, logMissing: false))
return;
// Toggle off if they're on
if (AreInternalsWorking(internals))
{
if (force)
{
DisconnectTank((uid, internals));
return;
}
StartToggleInternalsDoAfter(user, (uid, internals));
return;
}
// If they're not on then check if we have a mask to use
if (internals.BreathTools.Count == 0)
{
_popupSystem.PopupEntity(Loc.GetString("internals-no-breath-tool"), uid, user);
return;
}
var tank = FindBestGasTank(uid);
if (tank is null)
{
_popupSystem.PopupEntity(Loc.GetString("internals-no-tank"), uid, user);
return;
}
if (!force)
{
StartToggleInternalsDoAfter(user, (uid, internals));
return;
}
_gasTank.ConnectToInternals(tank.Value);
}
private void StartToggleInternalsDoAfter(EntityUid user, Entity<InternalsComponent> targetEnt)
{
// Is the target not you? If yes, use a do-after to give them time to respond.
var isUser = user == targetEnt.Owner;
var delay = !isUser ? targetEnt.Comp.Delay : TimeSpan.Zero;
_doAfter.TryStartDoAfter(new DoAfterArgs(EntityManager, user, delay, new InternalsDoAfterEvent(), targetEnt, target: targetEnt)
{
BreakOnDamage = true,
BreakOnMove = true,
MovementThreshold = 0.1f,
});
}
private void OnDoAfter(Entity<InternalsComponent> ent, ref InternalsDoAfterEvent args)
{
if (args.Cancelled || args.Handled)
return;
ToggleInternals(ent, args.User, force: true, ent);
args.Handled = true;
}
private void OnToggleInternalsAlert(Entity<InternalsComponent> ent, ref ToggleInternalsAlertEvent args)
{
if (args.Handled)
return;
ToggleInternals(ent, ent, false, internals: ent.Comp);
args.Handled = true;
}
private void OnInternalsStartup(Entity<InternalsComponent> ent, ref ComponentStartup args)
{
_alerts.ShowAlert(ent, ent.Comp.InternalsAlert, GetSeverity(ent));
}
private void OnInternalsShutdown(Entity<InternalsComponent> ent, ref ComponentShutdown args)
{
_alerts.ClearAlert(ent, ent.Comp.InternalsAlert);
}
private void OnInhaleLocation(Entity<InternalsComponent> ent, ref InhaleLocationEvent args)
{
if (AreInternalsWorking(ent))
@@ -190,110 +63,4 @@ public sealed class InternalsSystem : EntitySystem
_alerts.ShowAlert(ent, ent.Comp.InternalsAlert, GetSeverity(ent));
}
}
public void DisconnectBreathTool(Entity<InternalsComponent> ent, EntityUid toolEntity)
{
ent.Comp.BreathTools.Remove(toolEntity);
if (TryComp(toolEntity, out BreathToolComponent? breathTool))
_atmos.DisconnectInternals((toolEntity, breathTool));
if (ent.Comp.BreathTools.Count == 0)
DisconnectTank(ent);
_alerts.ShowAlert(ent, ent.Comp.InternalsAlert, GetSeverity(ent));
}
public void ConnectBreathTool(Entity<InternalsComponent> ent, EntityUid toolEntity)
{
if (!ent.Comp.BreathTools.Add(toolEntity))
return;
_alerts.ShowAlert(ent, ent.Comp.InternalsAlert, GetSeverity(ent));
}
public void DisconnectTank(Entity<InternalsComponent> ent)
{
if (TryComp(ent.Comp.GasTankEntity, out GasTankComponent? tank))
_gasTank.DisconnectFromInternals((ent.Comp.GasTankEntity.Value, tank));
ent.Comp.GasTankEntity = null;
_alerts.ShowAlert(ent.Owner, ent.Comp.InternalsAlert, GetSeverity(ent.Comp));
}
public bool TryConnectTank(Entity<InternalsComponent> ent, EntityUid tankEntity)
{
if (ent.Comp.BreathTools.Count == 0)
return false;
if (TryComp(ent.Comp.GasTankEntity, out GasTankComponent? tank))
_gasTank.DisconnectFromInternals((ent.Comp.GasTankEntity.Value, tank));
ent.Comp.GasTankEntity = tankEntity;
_alerts.ShowAlert(ent, ent.Comp.InternalsAlert, GetSeverity(ent));
return true;
}
public bool AreInternalsWorking(EntityUid uid, InternalsComponent? component = null)
{
return Resolve(uid, ref component, logMissing: false)
&& AreInternalsWorking(component);
}
public bool AreInternalsWorking(InternalsComponent component)
{
return TryComp(component.BreathTools.FirstOrNull(), out BreathToolComponent? breathTool)
&& breathTool.IsFunctional
&& HasComp<GasTankComponent>(component.GasTankEntity);
}
private short GetSeverity(InternalsComponent component)
{
if (component.BreathTools.Count == 0 || !AreInternalsWorking(component))
return 2;
// If pressure in the tank is below low pressure threshold, flash warning on internals UI
if (TryComp<GasTankComponent>(component.GasTankEntity, out var gasTank)
&& gasTank.IsLowPressure)
{
return 0;
}
return 1;
}
public Entity<GasTankComponent>? FindBestGasTank(
Entity<HandsComponent?, InventoryComponent?, ContainerManagerComponent?> user)
{
// TODO use _respirator.CanMetabolizeGas() to prioritize metabolizable gasses
// Prioritise
// 1. back equipped tanks
// 2. exo-slot tanks
// 3. in-hand tanks
// 4. pocket/belt tanks
if (!Resolve(user, ref user.Comp2, ref user.Comp3))
return null;
if (_inventory.TryGetSlotEntity(user, "back", out var backEntity, user.Comp2, user.Comp3) &&
TryComp<GasTankComponent>(backEntity, out var backGasTank) &&
_gasTank.CanConnectToInternals((backEntity.Value, backGasTank)))
{
return (backEntity.Value, backGasTank);
}
if (_inventory.TryGetSlotEntity(user, "suitstorage", out var entity, user.Comp2, user.Comp3) &&
TryComp<GasTankComponent>(entity, out var gasTank) &&
_gasTank.CanConnectToInternals((entity.Value, gasTank)))
{
return (entity.Value, gasTank);
}
foreach (var item in _inventory.GetHandOrInventoryEntities((user.Owner, user.Comp1, user.Comp2)))
{
if (TryComp(item, out gasTank) && _gasTank.CanConnectToInternals((item, gasTank)))
return (item, gasTank);
}
return null;
}
}

View File

@@ -1,4 +1,3 @@
using Content.Server.Atmos.Components;
using Content.Server.Atmos.EntitySystems;
using Content.Server.Body.Components;
using Content.Shared.Chemistry.EntitySystems;
@@ -6,6 +5,8 @@ using Content.Shared.Atmos;
using Content.Shared.Chemistry.Components;
using Content.Shared.Clothing;
using Content.Shared.Inventory.Events;
using BreathToolComponent = Content.Shared.Atmos.Components.BreathToolComponent;
using InternalsComponent = Content.Shared.Body.Components.InternalsComponent;
namespace Content.Server.Body.Systems;
@@ -23,7 +24,6 @@ public sealed class LungSystem : EntitySystem
SubscribeLocalEvent<LungComponent, ComponentInit>(OnComponentInit);
SubscribeLocalEvent<BreathToolComponent, GotEquippedEvent>(OnGotEquipped);
SubscribeLocalEvent<BreathToolComponent, GotUnequippedEvent>(OnGotUnequipped);
SubscribeLocalEvent<BreathToolComponent, ItemMaskToggledEvent>(OnMaskToggled);
}
private void OnGotUnequipped(Entity<BreathToolComponent> ent, ref GotUnequippedEvent args)
@@ -38,8 +38,6 @@ public sealed class LungSystem : EntitySystem
return;
}
ent.Comp.IsFunctional = true;
if (TryComp(args.Equipee, out InternalsComponent? internals))
{
ent.Comp.ConnectedInternalsEntity = args.Equipee;
@@ -56,24 +54,6 @@ public sealed class LungSystem : EntitySystem
}
}
private void OnMaskToggled(Entity<BreathToolComponent> ent, ref ItemMaskToggledEvent args)
{
if (args.Mask.Comp.IsToggled)
{
_atmos.DisconnectInternals(ent);
}
else
{
ent.Comp.IsFunctional = true;
if (TryComp(args.Wearer, out InternalsComponent? internals))
{
ent.Comp.ConnectedInternalsEntity = args.Wearer;
_internals.ConnectBreathTool((args.Wearer.Value, internals), ent);
}
}
}
public void GasToReagent(EntityUid uid, LungComponent lung)
{
if (!_solutionContainerSystem.ResolveSolution(uid, lung.SolutionName, ref lung.Solution, out var solution))

View File

@@ -1,5 +1,6 @@
using Content.Server.Botany.Components;
using Content.Shared.EntityEffects;
using Content.Shared.Examine;
using Content.Shared.FixedPoint;
namespace Content.Server.Botany.Systems;
@@ -37,4 +38,23 @@ public sealed partial class BotanySystem
solutionContainer.AddReagent(chem, amount);
}
}
public void OnProduceExamined(EntityUid uid, ProduceComponent comp, ExaminedEvent args)
{
if (comp.Seed == null)
return;
using (args.PushGroup(nameof(ProduceComponent)))
{
foreach (var m in comp.Seed.Mutations)
{
// Don't show mutations that have no effect on produce (sentience)
if (!m.AppliesToProduce)
continue;
if (m.Description != null)
args.PushMarkup(Loc.GetString(m.Description));
}
}
}
}

View File

@@ -36,6 +36,7 @@ public sealed partial class BotanySystem : EntitySystem
base.Initialize();
SubscribeLocalEvent<SeedComponent, ExaminedEvent>(OnExamined);
SubscribeLocalEvent<ProduceComponent, ExaminedEvent>(OnProduceExamined);
}
public bool TryGetSeed(SeedComponent comp, [NotNullWhen(true)] out SeedData? seed)

View File

@@ -116,6 +116,20 @@ public sealed class PlantHolderSystem : EntitySystem
? "plant-holder-component-plant-old-adjective"
: "plant-holder-component-plant-unhealthy-adjective"))));
}
// For future reference, mutations should only appear on examine if they apply to a plant, not to produce.
if (component.Seed.Ligneous)
args.PushMarkup(Loc.GetString("mutation-plant-ligneous"));
if (component.Seed.TurnIntoKudzu)
args.PushMarkup(Loc.GetString("mutation-plant-kudzu"));
if (component.Seed.CanScream)
args.PushMarkup(Loc.GetString("mutation-plant-scream"));
if (component.Seed.Viable == false)
args.PushMarkup(Loc.GetString("mutation-plant-unviable"));
}
else
{

View File

@@ -1,10 +0,0 @@
using Robust.Shared.Audio;
namespace Content.Server.Cargo.Components;
[RegisterComponent]
public sealed partial class CargoShuttleConsoleComponent : Component
{
[ViewVariables(VVAccess.ReadWrite), DataField("soundDeny")]
public SoundSpecifier DenySound = new SoundPathSpecifier("/Audio/Effects/Cargo/buzz_two.ogg");
}

View File

@@ -93,7 +93,11 @@ public sealed partial class CargoSystem
if (TryComp<AccessReaderComponent>(uid, out var accessReaderComponent) &&
!_accessReaderSystem.IsAllowed(mob, uid, accessReaderComponent))
{
if (Timing.CurTime >= component.NextDenySoundTime)
{
component.NextDenySoundTime = Timing.CurTime + component.DenySoundDelay;
_audio.PlayPvs(component.DenySound, uid);
}
return;
}

View File

@@ -13,8 +13,10 @@ using Content.Shared.Interaction;
using Content.Shared.Labels.Components;
using Content.Shared.Paper;
using JetBrains.Annotations;
using Robust.Shared.Audio;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.Cargo.Systems
@@ -23,6 +25,7 @@ namespace Content.Server.Cargo.Systems
{
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
[Dependency] private readonly EmagSystem _emag = default!;
[Dependency] private readonly IGameTiming _timing = default!;
private void InitializeConsole()
{
@@ -35,11 +38,8 @@ namespace Content.Server.Cargo.Systems
SubscribeLocalEvent<CargoOrderConsoleComponent, GotEmaggedEvent>(OnEmagged);
}
private void OnInteractUsing(EntityUid uid, CargoOrderConsoleComponent component, ref InteractUsingEvent args)
private void OnInteractUsingCash(EntityUid uid, CargoOrderConsoleComponent component, ref InteractUsingEvent args)
{
if (!HasComp<CashComponent>(args.Used))
return;
var price = _pricing.GetPrice(args.Used);
if (price == 0)
@@ -56,6 +56,55 @@ namespace Content.Server.Cargo.Systems
args.Handled = true;
}
private void OnInteractUsingSlip(Entity<CargoOrderConsoleComponent> ent, ref InteractUsingEvent args, CargoSlipComponent slip)
{
if (slip.OrderQuantity <= 0)
return;
var stationUid = _station.GetOwningStation(ent);
if (!TryGetOrderDatabase(stationUid, out var orderDatabase))
return;
if (!_protoMan.TryIndex(slip.Product, out var product))
{
Log.Error($"Tried to add invalid cargo product {slip.Product} as order!");
return;
}
if (!ent.Comp.AllowedGroups.Contains(product.Group))
return;
var orderId = GenerateOrderId(orderDatabase);
var data = new CargoOrderData(orderId, product.Product, product.Name, product.Cost, slip.OrderQuantity, slip.Requester, slip.Reason, slip.Account);
if (!TryAddOrder(stationUid.Value, ent.Comp.Account, data, orderDatabase))
{
PlayDenySound(ent, ent.Comp);
return;
}
// Log order addition
_audio.PlayPvs(ent.Comp.ScanSound, ent);
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(args.User):user} inserted order slip [orderId:{data.OrderId}, quantity:{data.OrderQuantity}, product:{data.ProductId}, requester:{data.Requester}, reason:{data.Reason}]");
QueueDel(args.Used);
args.Handled = true;
}
private void OnInteractUsing(EntityUid uid, CargoOrderConsoleComponent component, ref InteractUsingEvent args)
{
if (HasComp<CashComponent>(args.Used))
{
OnInteractUsingCash(uid, component, ref args);
}
else if (TryComp<CargoSlipComponent>(args.Used, out var slip) && !component.SlipPrinter)
{
OnInteractUsingSlip((uid, component), ref args, slip);
}
}
private void OnInit(EntityUid uid, CargoOrderConsoleComponent orderConsole, ComponentInit args)
{
var station = _station.GetOwningStation(uid);
@@ -94,6 +143,9 @@ namespace Content.Server.Cargo.Systems
if (args.Actor is not { Valid: true } player)
return;
if (component.SlipPrinter)
return;
if (!_accessReaderSystem.IsAllowed(player, uid))
{
ConsolePopup(args.Actor, Loc.GetString("cargo-console-order-not-allowed"));
@@ -115,7 +167,7 @@ namespace Content.Server.Cargo.Systems
// Find our order again. It might have been dispatched or approved already
var order = orderDatabase.Orders[component.Account].Find(order => args.OrderId == order.OrderId && !order.Approved);
if (order == null)
if (order == null || !_protoMan.TryIndex(order.Account, out var account))
{
return;
}
@@ -128,7 +180,7 @@ namespace Content.Server.Cargo.Systems
return;
}
var amount = GetOutstandingOrderCount(orderDatabase, component.Account);
var amount = GetOutstandingOrderCount(orderDatabase, order.Account);
var capacity = orderDatabase.Capacity;
// Too many orders, avoid them getting spammed in the UI.
@@ -150,7 +202,7 @@ namespace Content.Server.Cargo.Systems
}
var cost = order.Price * order.OrderQuantity;
var accountBalance = GetBalanceFromAccount((station.Value, bank), component.Account);
var accountBalance = GetBalanceFromAccount((station.Value, bank), order.Account);
// Not enough balance
if (cost > accountBalance)
@@ -166,7 +218,7 @@ namespace Content.Server.Cargo.Systems
if (!ev.Handled)
{
ev.FulfillmentEntity = TryFulfillOrder((station.Value, stationData), component.Account, order, orderDatabase);
ev.FulfillmentEntity = TryFulfillOrder((station.Value, stationData), order.Account, order, orderDatabase);
if (ev.FulfillmentEntity == null)
{
@@ -190,8 +242,8 @@ namespace Content.Server.Cargo.Systems
("orderAmount", order.OrderQuantity),
("approver", order.Approver ?? string.Empty),
("cost", cost));
_radio.SendRadioMessage(uid, message, component.AnnouncementChannel, uid, escapeMarkup: false);
if (CargoOrderConsoleComponent.BaseAnnouncementChannel != component.AnnouncementChannel)
_radio.SendRadioMessage(uid, message, account.RadioChannel, uid, escapeMarkup: false);
if (CargoOrderConsoleComponent.BaseAnnouncementChannel != account.RadioChannel)
_radio.SendRadioMessage(uid, message, CargoOrderConsoleComponent.BaseAnnouncementChannel, uid, escapeMarkup: false);
}
@@ -200,10 +252,10 @@ namespace Content.Server.Cargo.Systems
// Log order approval
_adminLogger.Add(LogType.Action,
LogImpact.Low,
$"{ToPrettyString(player):user} approved order [orderId:{order.OrderId}, quantity:{order.OrderQuantity}, product:{order.ProductId}, requester:{order.Requester}, reason:{order.Reason}] on account {component.Account} with balance at {accountBalance}");
$"{ToPrettyString(player):user} approved order [orderId:{order.OrderId}, quantity:{order.OrderQuantity}, product:{order.ProductId}, requester:{order.Requester}, reason:{order.Reason}] on account {order.Account} with balance at {accountBalance}");
orderDatabase.Orders[component.Account].Remove(order);
UpdateBankAccount((station.Value, bank), -cost, component.Account);
UpdateBankAccount((station.Value, bank), -cost, order.Account);
UpdateOrders(station.Value);
}
@@ -259,12 +311,48 @@ namespace Content.Server.Cargo.Systems
{
var station = _station.GetOwningStation(uid);
if (component.SlipPrinter)
return;
if (!TryGetOrderDatabase(station, out var orderDatabase))
return;
RemoveOrder(station.Value, component.Account, args.OrderId, orderDatabase);
}
private void OnAddOrderMessageSlipPrinter(EntityUid uid, CargoOrderConsoleComponent component, CargoConsoleAddOrderMessage args, CargoProductPrototype product)
{
if (!_protoMan.TryIndex(component.Account, out var account))
return;
if (Timing.CurTime < component.NextPrintTime)
return;
var label = Spawn(account.AcquisitionSlip, Transform(uid).Coordinates);
component.NextPrintTime = Timing.CurTime + component.PrintDelay;
_audio.PlayPvs(component.PrintSound, uid);
var paper = EnsureComp<PaperComponent>(label);
var msg = new FormattedMessage();
msg.AddMarkupPermissive(Loc.GetString("cargo-acquisition-slip-body",
("product", product.Name),
("description", product.Description),
("unit", product.Cost),
("amount", args.Amount),
("cost", product.Cost * args.Amount),
("orderer", args.Requester),
("reason", args.Reason)));
_paperSystem.SetContent((label, paper), msg.ToMarkup());
var slip = EnsureComp<CargoSlipComponent>(label);
slip.Product = product.ID;
slip.Requester = args.Requester;
slip.Reason = args.Reason;
slip.OrderQuantity = args.Amount;
slip.Account = component.Account;
}
private void OnAddOrderMessage(EntityUid uid, CargoOrderConsoleComponent component, CargoConsoleAddOrderMessage args)
{
if (args.Actor is not { Valid: true } player)
@@ -287,7 +375,13 @@ namespace Content.Server.Cargo.Systems
if (!component.AllowedGroups.Contains(product.Group))
return;
var data = GetOrderData(args, product, GenerateOrderId(orderDatabase));
if (component.SlipPrinter)
{
OnAddOrderMessageSlipPrinter(uid, component, args, product);
return;
}
var data = GetOrderData(args, product, GenerateOrderId(orderDatabase), component.Account);
if (!TryAddOrder(stationUid.Value, component.Account, data, orderDatabase))
{
@@ -339,12 +433,16 @@ namespace Content.Server.Cargo.Systems
private void PlayDenySound(EntityUid uid, CargoOrderConsoleComponent component)
{
if (_timing.CurTime >= component.NextDenySoundTime)
{
component.NextDenySoundTime = _timing.CurTime + component.DenySoundDelay;
_audio.PlayPvs(_audio.ResolveSound(component.ErrorSound), uid);
}
}
private static CargoOrderData GetOrderData(CargoConsoleAddOrderMessage args, CargoProductPrototype cargoProduct, int id)
private static CargoOrderData GetOrderData(CargoConsoleAddOrderMessage args, CargoProductPrototype cargoProduct, int id, ProtoId<CargoAccountPrototype> account)
{
return new CargoOrderData(id, cargoProduct.Product, cargoProduct.Name, cargoProduct.Cost, args.Amount, args.Requester, args.Reason);
return new CargoOrderData(id, cargoProduct.Product, cargoProduct.Name, cargoProduct.Cost, args.Amount, args.Requester, args.Reason, account);
}
public static int GetOutstandingOrderCount(StationCargoOrderDatabaseComponent component, ProtoId<CargoAccountPrototype> account)
@@ -378,16 +476,6 @@ namespace Content.Server.Cargo.Systems
UpdateOrderState(uid, station);
}
var consoleQuery = AllEntityQuery<CargoShuttleConsoleComponent>();
while (consoleQuery.MoveNext(out var uid, out var _))
{
var station = _station.GetOwningStation(uid);
if (station != dbUid)
continue;
UpdateShuttleState(uid, station);
}
}
public bool AddAndApproveOrder(
@@ -407,7 +495,7 @@ namespace Content.Server.Cargo.Systems
DebugTools.Assert(_protoMan.HasIndex<EntityPrototype>(spawnId));
// Make an order
var id = GenerateOrderId(component);
var order = new CargoOrderData(id, spawnId, name, cost, qty, sender, description);
var order = new CargoOrderData(id, spawnId, name, cost, qty, sender, description, account);
// Approve it now
order.SetApproverData(dest, sender);

Some files were not shown because too many files have changed in this diff Show More