diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 54c136b0043..9ea663d791f 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,6 +1,7 @@
name: Publish
concurrency:
group: publish
+ cancel-in-progress: true
on:
@@ -59,12 +60,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 }}
diff --git a/.run/Content Server+Client.run.xml b/.run/Content Server+Client.run.xml
index 4ba50d81900..0ec9bf16a75 100644
--- a/.run/Content Server+Client.run.xml
+++ b/.run/Content Server+Client.run.xml
@@ -4,4 +4,4 @@
-
\ No newline at end of file
+
diff --git a/Content.Client/Guidebook/Controls/GuideMicrowaveEmbed.xaml.cs b/Content.Client/Guidebook/Controls/GuideMicrowaveEmbed.xaml.cs
index 74521c6c258..62d802a3230 100644
--- a/Content.Client/Guidebook/Controls/GuideMicrowaveEmbed.xaml.cs
+++ b/Content.Client/Guidebook/Controls/GuideMicrowaveEmbed.xaml.cs
@@ -84,7 +84,7 @@ private void GenerateHeader(FoodRecipePrototype recipe)
var entity = _prototype.Index(recipe.Result);
IconContainer.AddChild(new GuideEntityEmbed(recipe.Result, false, false));
- ResultName.SetMarkup(entity.Name);
+ ResultName.SetMarkup(Loc.GetString("guidebook-microwave-recipe-name-display", ("amount", recipe.ResultCount), ("name", entity.Name))); // Mono
ResultDescription.SetMarkup(entity.Description);
}
diff --git a/Content.Client/Jittering/JitteringSystem.cs b/Content.Client/Jittering/JitteringSystem.cs
index 03acb244bcf..0fb218e02ac 100644
--- a/Content.Client/Jittering/JitteringSystem.cs
+++ b/Content.Client/Jittering/JitteringSystem.cs
@@ -1,4 +1,5 @@
using System.Numerics;
+using Content.Shared._CE.ZLevels.Core.Components; // Mono
using Content.Shared.Jittering;
using Robust.Client.Animations;
using Robust.Client.GameObjects;
@@ -30,7 +31,9 @@ private void OnStartup(EntityUid uid, JitteringComponent jittering, ComponentSta
var animationPlayer = EnsureComp(uid);
- jittering.StartOffset = sprite.Offset;
+ jittering.StartOffset = TryComp(uid, out CEZPhysicsComponent? zPhys)
+ ? zPhys.SpriteOffsetDefault
+ : sprite.Offset;
_animationPlayer.Play(uid, animationPlayer, GetAnimation(jittering, sprite), _jitterAnimationKey);
}
@@ -58,6 +61,7 @@ private void OnAnimationCompleted(EntityUid uid, JitteringComponent jittering, A
private Animation GetAnimation(JitteringComponent jittering, SpriteComponent sprite)
{
+ var previousJitter = jittering.LastJitter; // Mono
var amplitude = MathF.Min(4f, jittering.Amplitude / 100f + 1f) / 10f;
var offset = new Vector2(_random.NextFloat(amplitude/4f, amplitude),
_random.NextFloat(amplitude / 4f, amplitude / 3f));
@@ -94,7 +98,7 @@ private Animation GetAnimation(JitteringComponent jittering, SpriteComponent spr
Property = nameof(SpriteComponent.Offset),
KeyFrames =
{
- new AnimationTrackProperty.KeyFrame(sprite.Offset, 0f),
+ new AnimationTrackProperty.KeyFrame(jittering.StartOffset + previousJitter, 0f),
new AnimationTrackProperty.KeyFrame(jittering.StartOffset + offset, length),
}
}
diff --git a/Content.Client/PDA/PdaMenu.xaml b/Content.Client/PDA/PdaMenu.xaml
index 9322d201ac2..64ddddeb870 100644
--- a/Content.Client/PDA/PdaMenu.xaml
+++ b/Content.Client/PDA/PdaMenu.xaml
@@ -43,9 +43,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/PDA/PdaMenu.xaml.cs b/Content.Client/PDA/PdaMenu.xaml.cs
index 6d90e6f47d4..f0aea5e9101 100644
--- a/Content.Client/PDA/PdaMenu.xaml.cs
+++ b/Content.Client/PDA/PdaMenu.xaml.cs
@@ -36,6 +36,8 @@ public sealed partial class PdaMenu : PdaWindow
private string _balance = Loc.GetString("comp-pda-ui-unknown"); // Frontier
private string _shuttleDeed = Loc.GetString("comp-pda-ui-unknown"); // Frontier
+ private string _currentDate = Loc.GetString("comp-pda-ui-unknown"); // DeltaV - PDA date
+
private int _currentView;
@@ -139,6 +141,13 @@ public PdaMenu()
_clipboard.SetText(_instructions);
};
+ // Begin DeltaV additions
+ CurrentDateButton.OnPressed += _ =>
+ {
+ _clipboard.SetText(_currentDate);
+ };
+ // End DeltaV additions
+
@@ -219,6 +228,18 @@ public void UpdateState(PdaUpdateState state)
"comp-pda-ui-station-alert-level-instructions",
("instructions", _instructions))
);
+ // Begin DeltaV additions
+ if (state.PdaOwnerInfo.CurrentDate is { } curDate)
+ _currentDate = curDate.ToString("dd/MM/yyyy"); // Wicce: dd MMMM yyyy -> dd/MM/yyyy. Looks nicer.
+ CurrentDateLabel.SetMarkup(Loc.GetString(
+ "comp-pda-ui-current-date",
+ ("date", _currentDate)
+ ));
+ // End DeltaV additions
+
+ // Mono
+ var warLevel = state.PdaOwnerInfo.WarLevel;
+ WarLevelLabel.SetMarkup(warLevel != null ? warLevel : Loc.GetString("comp-pda-ui-station-war-level-unknown"));
AddressLabel.Text = state.Address?.ToUpper() ?? " - ";
diff --git a/Content.Client/Parallax/ParallaxOverlay.cs b/Content.Client/Parallax/ParallaxOverlay.cs
index ff76864eca0..98381de3122 100644
--- a/Content.Client/Parallax/ParallaxOverlay.cs
+++ b/Content.Client/Parallax/ParallaxOverlay.cs
@@ -1,5 +1,8 @@
using System.Numerics;
using Content.Client.Parallax.Managers;
+using Content.Client.Viewport; // CrystallEdge
+using Content.Shared._CE.ZLevels.Core.Components; // CrystallEdge
+using Content.Shared._CE.ZLevels.Core.EntitySystems; // CrystallEdge
using Content.Shared.CCVar;
using Content.Shared.Parallax.Biomes;
using Robust.Client.Graphics;
@@ -20,6 +23,7 @@ public sealed partial class ParallaxOverlay : Overlay
[Dependency] private IMapManager _mapManager = default!;
[Dependency] private IParallaxManager _manager = default!;
private readonly ParallaxSystem _parallax;
+ private readonly CESharedZLevelsSystem _zLevel; //CrystallEdge
public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowWorld;
@@ -28,6 +32,7 @@ public ParallaxOverlay()
ZIndex = ParallaxSystem.ParallaxZIndex;
IoCManager.InjectDependencies(this);
_parallax = _entManager.System();
+ _zLevel = _entManager.System(); //CrystallEdge
}
protected override bool BeforeDraw(in OverlayDrawArgs args)
@@ -35,7 +40,17 @@ protected override bool BeforeDraw(in OverlayDrawArgs args)
if (args.MapId == MapId.Nullspace)
return false;
- return true;
+ //CrystallEdge draw parallax only for lowest zlevel
+ if (args.Viewport.Eye is ScalingViewport.ZEye zEye)
+ return zEye.DrawParallax;
+
+ // Transit maps are mostly-empty carriers for a moving ship; painting the
+ // skybox on them would overwrite the already-rendered world below.
+ if (_entManager.HasComponent(args.MapUid))
+ return false;
+
+ return !_zLevel.TryMapDown(args.MapUid, out _);
+ //CrystallEdge end
}
protected override void Draw(in OverlayDrawArgs args)
diff --git a/Content.Client/Remotes/UI/DoorRemoteStatusControl.cs b/Content.Client/Remotes/UI/DoorRemoteStatusControl.cs
index 94589ecdaab..5101a329fe7 100644
--- a/Content.Client/Remotes/UI/DoorRemoteStatusControl.cs
+++ b/Content.Client/Remotes/UI/DoorRemoteStatusControl.cs
@@ -38,6 +38,7 @@ protected override void FrameUpdate(FrameEventArgs args)
OperatingMode.OpenClose => "door-remote-open-close-text",
OperatingMode.ToggleBolts => "door-remote-toggle-bolt-text",
OperatingMode.ToggleEmergencyAccess => "door-remote-emergency-access-text",
+ OperatingMode.ToggleOvercharge => "door-remote-toggle-eletrify-text",
_ => "door-remote-invalid-text"
});
diff --git a/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml.cs b/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml.cs
index 17803b557c5..9d19231d5c6 100644
--- a/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml.cs
+++ b/Content.Client/Robotics/UI/RoboticsConsoleWindow.xaml.cs
@@ -27,6 +27,8 @@ public sealed partial class RoboticsConsoleWindow : FancyWindow
private Dictionary _cyborgs = new();
public EntityUid Entity;
+
+ private bool _allowBorgControl = true;
public RoboticsConsoleWindow()
{
@@ -72,6 +74,7 @@ public void SetEntity(EntityUid uid)
public void UpdateState(RoboticsConsoleState state)
{
_cyborgs = state.Cyborgs;
+ _allowBorgControl = state.AllowBorgControl;
// clear invalid selection
if (_selected is {} selected && !_cyborgs.ContainsKey(selected))
@@ -95,8 +98,8 @@ public void UpdateState(RoboticsConsoleState state)
PopulateData();
var locked = _lock.IsLocked(Entity);
- DangerZone.Visible = !locked;
- LockedMessage.Visible = locked;
+ DangerZone.Visible = !locked && _allowBorgControl;
+ LockedMessage.Visible = locked && _allowBorgControl; // Only show if locked AND control is allowed
}
private void PopulateCyborgs()
@@ -147,7 +150,8 @@ private void PopulateData()
BorgInfo.SetMessage(text);
// how the turntables
- DisableButton.Disabled = !(data.HasBrain && data.CanDisable);
+ DisableButton.Disabled = !_allowBorgControl || !(data.HasBrain && data.CanDisable);
+ DestroyButton.Disabled = !_allowBorgControl;
}
protected override void FrameUpdate(FrameEventArgs args)
diff --git a/Content.Client/Shuttles/Systems/ShuttleConsoleSystem.cs b/Content.Client/Shuttles/Systems/ShuttleConsoleSystem.cs
index 2070edb9d7f..277e4724e42 100644
--- a/Content.Client/Shuttles/Systems/ShuttleConsoleSystem.cs
+++ b/Content.Client/Shuttles/Systems/ShuttleConsoleSystem.cs
@@ -17,9 +17,9 @@ public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent(OnHandleState);
-
+
// We don't need to handle BUI events on client-side since the BoundUserInterface class does that
-
+
var shuttle = _input.Contexts.New("shuttle", "common");
shuttle.AddFunction(ContentKeyFunctions.ShuttleStrafeUp);
shuttle.AddFunction(ContentKeyFunctions.ShuttleStrafeDown);
@@ -28,6 +28,8 @@ public override void Initialize()
shuttle.AddFunction(ContentKeyFunctions.ShuttleRotateLeft);
shuttle.AddFunction(ContentKeyFunctions.ShuttleRotateRight);
shuttle.AddFunction(ContentKeyFunctions.ShuttleBrake);
+ shuttle.AddFunction(ContentKeyFunctions.ShuttleAscend); // Mono
+ shuttle.AddFunction(ContentKeyFunctions.ShuttleDescend); // Mono
}
public override void Shutdown()
diff --git a/Content.Client/Shuttles/UI/NavScreen.xaml b/Content.Client/Shuttles/UI/NavScreen.xaml
index 2bf6f903537..bd25dd4e266 100644
--- a/Content.Client/Shuttles/UI/NavScreen.xaml
+++ b/Content.Client/Shuttles/UI/NavScreen.xaml
@@ -80,6 +80,37 @@
HorizontalExpand="True"
Align="Right"
FontColorOverride="#00ff2a"/>
+
+
+
+
+
+
+
@@ -111,8 +142,8 @@
TextAlign="Center"
StyleClasses="ButtonSquare"
ToggleMode="True"/>
-
diff --git a/Content.Client/Shuttles/UI/NavScreen.xaml.cs b/Content.Client/Shuttles/UI/NavScreen.xaml.cs
index ac240d2a3f5..5ea911648a0 100644
--- a/Content.Client/Shuttles/UI/NavScreen.xaml.cs
+++ b/Content.Client/Shuttles/UI/NavScreen.xaml.cs
@@ -1,4 +1,5 @@
using System.Numerics;
+using Content.Shared._CE.ZLevels.Core.Components; // Mono
using Content.Shared._Mono.Company;
using Content.Shared.Shuttles.BUIStates;
using Robust.Client.AutoGenerated;
@@ -32,8 +33,8 @@ public NavScreen()
IFFToggle.OnToggled += OnIFFTogglePressed;
IFFToggle.Pressed = NavRadar.ShowIFF;
- IFFShuttleToggle.OnToggled += OnIFFShuttleTogglePressed;
- IFFShuttleToggle.Pressed = NavRadar.ShowIFFShuttles;
+ IFFDetailedToggle.OnToggled += OnIFFDetailedTogglePressed; // Mono
+ IFFDetailedToggle.Pressed = NavRadar.ShowIFFDetailed; // Mono
DockToggle.OnToggled += OnDockTogglePressed;
DockToggle.Pressed = NavRadar.ShowDocks;
@@ -102,10 +103,11 @@ private void OnIFFTogglePressed(BaseButton.ButtonEventArgs args)
args.Button.Pressed = NavRadar.ShowIFF;
}
- private void OnIFFShuttleTogglePressed(BaseButton.ButtonEventArgs args)
+ // Mono
+ private void OnIFFDetailedTogglePressed(BaseButton.ButtonEventArgs args)
{
- NavRadar.ShowIFFShuttles ^= true;
- args.Button.Pressed = NavRadar.ShowIFFShuttles;
+ NavRadar.ShowIFFDetailed ^= true;
+ args.Button.Pressed = NavRadar.ShowIFFDetailed;
}
private void OnDockTogglePressed(BaseButton.ButtonEventArgs args)
@@ -209,5 +211,71 @@ protected override void Draw(DrawingHandleScreen handle)
("Y", $"{gridVelocity.Y + 10f * float.Epsilon:0.0}"));
GridAngularVelocity.Text = Loc.GetString("shuttle-console-angular-velocity-value",
("angularVelocity", $"{-MathHelper.RadiansToDegrees(gridBody.AngularVelocity) + 10f * float.Epsilon:0.0}"));
+
+ UpdateAltitude(gridXform); // Mono
+ }
+
+ // Mono: z-level altimeter
+ private void UpdateAltitude(TransformComponent gridXform)
+ {
+ float? altitude = null;
+ string? state = null;
+
+ if (gridXform.MapUid is { } mapUid)
+ {
+ if (_entManager.TryGetComponent(mapUid, out CEZMapComponent? zLevel))
+ {
+ altitude = zLevel.Depth;
+
+ // Mid-spool a grounded ship shows a launch countdown instead.
+ if (_entManager.TryGetComponent(_shuttleEntity, out CEZPhysicsComponent? spoolPhys) &&
+ spoolPhys.LaunchCountdown > 0f)
+ {
+ state = Loc.GetString("shuttle-console-travel-state-launching",
+ ("countdown", $"{spoolPhys.LaunchCountdown:0.0}"));
+ }
+ else
+ {
+ state = Loc.GetString(_entManager.HasComponent(mapUid)
+ ? "shuttle-console-travel-state-grounded"
+ : "shuttle-console-travel-state-hovering");
+ }
+ }
+ else if (_entManager.TryGetComponent(mapUid, out CEZTransitMapComponent? transit) &&
+ transit.LowerMap is { } lowerMap &&
+ _entManager.TryGetComponent(lowerMap, out CEZMapComponent? lowerZ))
+ {
+ var progress = 0f;
+ if (_entManager.TryGetComponent(_shuttleEntity, out CEZPhysicsComponent? zPhys))
+ progress = Math.Clamp(zPhys.LocalPosition, 0f, 1f);
+
+ altitude = lowerZ.Depth + progress;
+ state = Loc.GetString("shuttle-console-travel-state-flying");
+ }
+ }
+
+ var onZNetwork = altitude != null;
+ GridAltitudeCaption.Visible = onZNetwork;
+ GridAltitude.Visible = onZNetwork;
+ GridVerticalVelocityCaption.Visible = onZNetwork;
+ GridVerticalVelocity.Visible = onZNetwork;
+ GridTravelStateCaption.Visible = onZNetwork;
+ GridTravelState.Visible = onZNetwork;
+
+ if (!onZNetwork)
+ return;
+
+ GridAltitude.Text = Loc.GetString("shuttle-console-altitude-value",
+ ("altitude", $"{altitude!.Value:0.00}"));
+
+ // CEZPhysics.Velocity is positive = up; the server mirrors the grid's fall
+ // speed onto it. Add a bias like the other velocity rows so -0 never shows.
+ var vertical = 0f;
+ if (_entManager.TryGetComponent(_shuttleEntity, out CEZPhysicsComponent? velPhys))
+ vertical = velPhys.Velocity;
+ GridVerticalVelocity.Text = Loc.GetString("shuttle-console-vertical-velocity-value",
+ ("velocity", $"{vertical + 10f * float.Epsilon:0.00}"));
+
+ GridTravelState.Text = state!;
}
}
diff --git a/Content.Client/Shuttles/UI/ShuttleConsoleWindow.xaml b/Content.Client/Shuttles/UI/ShuttleConsoleWindow.xaml
index 59b6fb62ae6..ae0a87f22dc 100644
--- a/Content.Client/Shuttles/UI/ShuttleConsoleWindow.xaml
+++ b/Content.Client/Shuttles/UI/ShuttleConsoleWindow.xaml
@@ -3,8 +3,8 @@
xmlns:ui="clr-namespace:Content.Client.Shuttles.UI"
xmlns:graphics="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
Title="{Loc 'shuttle-console-window-title'}"
- SetSize="1000 910"
- MinSize="1000 910">
+ SetSize="1000 1000"
+ MinSize="1000 1000">
diff --git a/Content.Client/Shuttles/UI/ShuttleNavControl.xaml.cs b/Content.Client/Shuttles/UI/ShuttleNavControl.xaml.cs
index 829c9b92a71..7d845863427 100644
--- a/Content.Client/Shuttles/UI/ShuttleNavControl.xaml.cs
+++ b/Content.Client/Shuttles/UI/ShuttleNavControl.xaml.cs
@@ -67,11 +67,10 @@ public partial class ShuttleNavControl : BaseShuttleControl // Mono
];
public bool ShowIFF { get; set; } = true;
- public bool ShowIFFShuttles { get; set; } = true;
+ public bool ShowIFFDetailed { get; set; } = true;
public bool ShowDocks { get; set; } = true;
public float MaximumIFFDistance { get; set; } = 3000f; // Frontier // Mono - 3000 by default to not gigaclutter
- public bool HideCoords { get; set; } = false; // Frontier
private static Color _dockLabelColor = Color.White; // Frontier
@@ -548,7 +547,6 @@ public void UpdateState(NavInterfaceState state)
// Frontier
if (state.MaxIffRange != null)
MaximumIFFDistance = state.MaxIffRange.Value;
- HideCoords = state.HideCoords;
// End Frontier
_docks = state.Docks;
@@ -702,12 +700,13 @@ protected override void Draw(DrawingHandleScreen handle)
: _shuttles.GetIFFLabel(grid, self: false, component: iff);
var shouldDrawIFF = ShowIFF && labelName != null;
+ var shouldDrawDetailedIFF = ShowIFFDetailed && shouldDrawIFF; // Mono
if (shouldDrawIFF)
{
if (IFFFilter != null)
shouldDrawIFF &= IFFFilter(gUid, grid.Comp, iff, hideLabel, labelName!);
- if (isPlayerShuttle)
- shouldDrawIFF &= ShowIFFShuttles;
+ //if (isPlayerShuttle) // Mono - comments this out, replaced elsewere
+ // shouldDrawIFF &= ShowIFFShuttles;
}
//var mapCenter = curGridToWorld. * gridBody.LocalCenter;
@@ -768,6 +767,7 @@ protected override void Draw(DrawingHandleScreen handle)
var labelText = Loc.GetString("shuttle-console-iff-label", ("name", labelName)!, ("distance", displayedDistance));
var coordsText = $"({gridMapPos.X:0.0}, {gridMapPos.Y:0.0})";
+ var trackIdText = iff != null ? Loc.GetString("shuttle-console-track-label") + $"{iff.Address}" : Loc.GetString("shuttle-console-track-unknown-label");
#region Mono
@@ -796,9 +796,10 @@ protected override void Draw(DrawingHandleScreen handle)
var radius = Width * 0.5f;
var squaredRadius = radius * radius;
+
// If true, flip the entire label to the right side of the blip and left-align it.
// We default to the label being on the left side of the blip because it looked better to me in testing. (arbitrary)
- var flipLabel = isOnLeftSide && labelCorners.Any(corner => corner.LengthSquared() > squaredRadius);
+ var flipLabel = true; // isOnLeftSide && labelCorners.Any(corner => corner.LengthSquared() > squaredRadius); // Mono - comment out, we dont want this teehee
// Calculate unscaled offsets.
var labelOffset = new Vector2()
@@ -831,30 +832,33 @@ protected override void Draw(DrawingHandleScreen handle)
// Draw main ship label with company color if available
handle.DrawString(Font, (uiPosition + labelOffset) * UIScale, mainLabel, UIScale * 0.9f, displayColor);
- // Draw company label if present
- if (!hideLabel && lines.Length > 1)
- {
- var companyLabel = lines[1];
- var companyLabelOffset = new Vector2(
- labelOffset.X,
- labelOffset.Y + handle.GetDimensions(Font, mainLabel, 0.9f).Y
- );
-
- handle.DrawString(Font, (uiPosition + companyLabelOffset) * UIScale, companyLabel, UIScale * 0.9f, displayColor);
- }
-
- if (isMouseOver && !HideCoords)
+ // Mono start - draw main stack of info
+ if (shouldDrawDetailedIFF)
{
- var coordDimensions = handle.GetDimensions(Font, coordsText, 0.7f);
- var coordOffset = new Vector2()
- {
- X = uiPosition.X > Width / 2f
- ? -coordDimensions.X - blipSize / 0.7f // right align the text to left of the blip (0.7 needed for scale)
- : blipSize, // left align the text to the right of the blip
- Y = labelOffset.Y + handle.GetDimensions(Font, mainLabel, 1f).Y + (lines.Length > 1 ? handle.GetDimensions(Font, lines[1], 1f).Y : 0) + 5
- };
- handle.DrawString(Font, (uiPosition + coordOffset) * UIScale, coordsText, 0.7f * UIScale, displayColor);
+ // Get company label & draw
+ var companyLabel = !hideLabel ? lines[1] : Loc.GetString("shuttle-console-company-unknown");
+ var companyLabelOffset = new Vector2(
+ labelOffset.X,
+ labelOffset.Y + handle.GetDimensions(Font, mainLabel, 0.9f).Y
+ );
+ handle.DrawString(Font, (uiPosition + companyLabelOffset) * UIScale, companyLabel, UIScale * 0.7f, displayColor);
+
+ // Draw coordinates
+ var coordDimensions = handle.GetDimensions(Font, coordsText, 0.7f);
+ var coordOffset = new Vector2(
+ labelOffset.X,
+ labelOffset.Y + handle.GetDimensions(Font, mainLabel, 0.9f).Y + handle.GetDimensions(Font, companyLabel, 0.7f).Y);
+ handle.DrawString(Font, (uiPosition + coordOffset) * UIScale, coordsText, 0.7f * UIScale, displayColor);
+
+ // Draw track ID (if it has one)
+ var trackIdDimensions = handle.GetDimensions(Font, trackIdText, 0.7f);
+ var trackIdOffset = new Vector2(
+ labelOffset.X,
+ labelOffset.Y + handle.GetDimensions(Font, mainLabel, 0.9f).Y + handle.GetDimensions(Font, companyLabel, 0.7f).Y + handle.GetDimensions(Font, coordsText, 0.7f).Y);
+ if (iff != null)
+ handle.DrawString(Font, (uiPosition + trackIdOffset) * UIScale, trackIdText, 0.7f * UIScale, displayColor);
}
+ // Mono end
}
NfAddBlipToList(_tempBlipDataList, isOutsideRadarCircle, uiPosition, uiXCentre, uiYCentre, labelColor, hideLabel ? default : gUid); // Frontier code
@@ -932,6 +936,23 @@ protected override void Draw(DrawingHandleScreen handle)
}
}
+ // Draw missile lines from the radar blips system
+ var missileLines = _blips.GetMissileLines();
+ foreach (var line in missileLines)
+ {
+ var startPos = new Vector2(line.PositionStart.X, line.PositionStart.Y);
+ var startEnd = new Vector2(line.PositionEnd.X, line.PositionEnd.Y);
+ var startPosInView = Vector2.Transform(startPos, worldToView);
+ var endPosInView = Vector2.Transform(startEnd, worldToView);
+
+ // Only draw lines if at least one endpoint is within view
+ if (monoViewBounds.Contains(startPosInView) || monoViewBounds.Contains(endPosInView))
+ {
+ // Draw the line with the specified thickness and color
+ handle.DrawLine(startPosInView, endPosInView, line.Color);
+ }
+ }
+
// Draw hitscan lines from the radar blips system
var hitscanLines = _blips.GetHitscanLines();
foreach (var line in hitscanLines)
diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs
index 9dc51526db8..4196057ca0c 100644
--- a/Content.Client/Stylesheets/StyleNano.cs
+++ b/Content.Client/Stylesheets/StyleNano.cs
@@ -94,6 +94,10 @@ public sealed class StyleNano : StyleBase
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";
public const string StyleClassSliderBlue = "Blue";
@@ -207,6 +211,9 @@ public StyleNano(IResourceCache resCache) : base(resCache)
var notoSansBold18 = resCache.NotoStack(variation: "Bold", size: 18);
var notoSansBold20 = resCache.NotoStack(variation: "Bold", size: 20);
var notoSansMono = resCache.NotoStack2ElectricBoogaloo("/EngineFonts/NotoSans/NotoSansMono-Regular.ttf", size: 12); // Goobstation - ZH text support
+ 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
{
@@ -434,6 +441,8 @@ public StyleNano(IResourceCache resCache) : base(resCache)
// 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");
@@ -968,6 +977,21 @@ public StyleNano(IResourceCache resCache) : base(resCache)
new StyleProperty(BoxContainer.StylePropertySeparation, 10),
}),
+ new StyleRule(new SelectorElement(typeof(TextureRect), new [] { MonotoneCheckBox.StyleClassCheckBox }, null, null), new[]
+ {
+ new StyleProperty(TextureRect.StylePropertyTexture, monotoneCheckBoxTextureUnchecked),
+ }),
+
+ new StyleRule(new SelectorElement(typeof(TextureRect), new [] { MonotoneCheckBox.StyleClassCheckBox, MonotoneCheckBox.StyleClassCheckBoxChecked }, null, null), new[]
+ {
+ new StyleProperty(TextureRect.StylePropertyTexture, monotoneCheckBoxTextureChecked),
+ }),
+
+ new StyleRule(new SelectorElement(typeof(BoxContainer), new [] { MonotoneCheckBox.StyleClassCheckBox }, null, null), new[]
+ {
+ new StyleProperty(BoxContainer.StylePropertySeparation, 10),
+ }),
+
// Tooltip
new StyleRule(new SelectorElement(typeof(Tooltip), null, null, null), new[]
{
@@ -1166,6 +1190,22 @@ public StyleNano(IResourceCache resCache) : base(resCache)
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),
diff --git a/Content.Client/TurretController/DeployableTurretControllerSystem.cs b/Content.Client/TurretController/DeployableTurretControllerSystem.cs
new file mode 100644
index 00000000000..b0f53ff6b8b
--- /dev/null
+++ b/Content.Client/TurretController/DeployableTurretControllerSystem.cs
@@ -0,0 +1,8 @@
+using Content.Shared.TurretController;
+
+namespace Content.Client.TurretController;
+
+public sealed class DeployableTurretControllerSystem : SharedDeployableTurretControllerSystem
+{
+
+}
diff --git a/Content.Client/TurretController/TurretControllerBoundUserInterface.cs b/Content.Client/TurretController/TurretControllerBoundUserInterface.cs
new file mode 100644
index 00000000000..38781455576
--- /dev/null
+++ b/Content.Client/TurretController/TurretControllerBoundUserInterface.cs
@@ -0,0 +1,79 @@
+using Content.Shared.Access;
+using Content.Shared.TurretController;
+using Robust.Client.UserInterface;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.TurretController;
+
+public sealed class TurretControllerBoundUserInterface : BoundUserInterface
+{
+ [ViewVariables]
+ private TurretControllerWindow? _window;
+
+ public TurretControllerBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) { }
+
+ protected override void Open()
+ {
+ if (UiKey is not DeployableTurretControllerUiKey)
+ {
+ Close();
+ return;
+ }
+
+ _window = this.CreateWindow();
+ _window.SetOwner(Owner);
+ _window.OpenCentered();
+
+ _window.OnAccessLevelsChangedEvent += OnAccessLevelChanged;
+ _window.OnArmamentSettingChangedEvent += OnArmamentSettingChanged;
+ }
+
+ ///
+ /// Update state in this context is for when users open the controller it will populate with the last interfacestate
+ ///
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (_window == null)
+ return;
+
+ if (state is not DeployableTurretControllerBoundInterfaceState { } cast)
+ return;
+
+ _window.UpdateState(cast);
+ }
+
+ ///
+ /// ReceiveMessage is for live turret data to be updated continuously to the client UI.
+ ///
+ protected override void ReceiveMessage(BoundUserInterfaceMessage message)
+ {
+ base.ReceiveMessage(message);
+
+ if (_window == null)
+ return;
+
+ if (message is not DeployableTurretControllerBoundInterfaceMessage { } cast)
+ return;
+
+ // Update the turret states
+ _window.UpdateMessage(cast);
+ }
+
+ ///
+ /// When changed access level in UI send message to shared.
+ ///
+ private void OnAccessLevelChanged(HashSet> accessLevels, bool enabled)
+ {
+ SendPredictedMessage(new DeployableTurretExemptAccessLevelChangedMessage(accessLevels, enabled));
+ }
+
+ ///
+ /// When changed armamentsettings in UI send message to shared.
+ ///
+ private void OnArmamentSettingChanged(int setting)
+ {
+ SendPredictedMessage(new DeployableTurretArmamentSettingChangedMessage(setting));
+ }
+}
diff --git a/Content.Client/TurretController/TurretControllerWindow.xaml b/Content.Client/TurretController/TurretControllerWindow.xaml
new file mode 100644
index 00000000000..20b4649c2a4
--- /dev/null
+++ b/Content.Client/TurretController/TurretControllerWindow.xaml
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/TurretController/TurretControllerWindow.xaml.cs b/Content.Client/TurretController/TurretControllerWindow.xaml.cs
new file mode 100644
index 00000000000..6a138fc2ce4
--- /dev/null
+++ b/Content.Client/TurretController/TurretControllerWindow.xaml.cs
@@ -0,0 +1,501 @@
+using Content.Client.Resources;
+using Content.Client.Stylesheets;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Access;
+using Content.Shared.Access.Systems;
+using Content.Shared.TurretController;
+using Content.Shared.Turrets;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Player;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using System.Linq;
+using System.Numerics;
+
+namespace Content.Client.TurretController;
+
+[GenerateTypedNameReferences]
+public sealed partial class TurretControllerWindow : BaseWindow
+{
+ [Dependency] private IEntityManager _entManager = default!;
+ [Dependency] private IPrototypeManager _protoManager = default!;
+ [Dependency] private IPlayerManager _playerManager = default!;
+
+ private readonly IResourceCache _cache;
+ private readonly AccessReaderSystem _accessReaderSystem;
+
+ private EntityUid? _owner;
+ private int _tabIndex = 0;
+
+ // Button groups
+ private readonly ButtonGroup _armamentButtons = new();
+ private readonly ButtonGroup _accessGroupsButtons = new();
+
+ // Temp values
+ private List _checkBoxes = new();
+ private HashSet _accessLevelsForTab = new();
+ private List _accessLevelEntries = new();
+
+ // Events
+ private event Action? OnAccessGroupChangedEvent;
+
+ public event Action>, bool>? OnAccessLevelsChangedEvent;
+ public event Action? OnArmamentSettingChangedEvent;
+
+ // Colors
+ private Color[] _themeColors = [Color.FromHex("#33e633"), Color.FromHex("#dfb827"), Color.FromHex("#da2a2a")];
+
+ public TurretControllerWindow()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ _cache = IoCManager.Resolve();
+ _accessReaderSystem = _entManager.System();
+
+ CloseButton.OnPressed += _ => Close();
+ XamlChildren = ContentsContainer.Children;
+
+ OnAccessGroupChangedEvent += OnAccessGroupChanged;
+
+ var smallFont = _cache.NotoStack(size: 8);
+ Footer.FontOverride = smallFont;
+ }
+
+ private void Initialize()
+ {
+ if (_owner == null)
+ return;
+
+ // Set up armament buttons
+ SafeButton.OnToggled += args => OnArmamentButtonPressed(SafeButton, -1);
+ StunButton.OnToggled += args => OnArmamentButtonPressed(StunButton, 0);
+ LethalButton.OnToggled += args => OnArmamentButtonPressed(LethalButton, 1);
+
+ SafeButton.Group = _armamentButtons;
+ StunButton.Group = _armamentButtons;
+ LethalButton.Group = _armamentButtons;
+
+ SafeButton.Label.AddStyleClass("ConsoleText");
+ StunButton.Label.AddStyleClass("ConsoleText");
+ LethalButton.Label.AddStyleClass("ConsoleText");
+
+ // Refresh UI
+ RefreshLinkedTurrets(new());
+
+ if (_entManager.TryGetComponent(_owner, out var turretController))
+ UpdateTheme(turretController.ArmamentState);
+
+ if (_entManager.TryGetComponent(_owner, out var turretTargetSettings))
+ RefreshAccessControls(turretTargetSettings.ExemptAccessLevels);
+ }
+
+ private void OnArmamentButtonPressed(Button pressedButton, int index)
+ {
+ UpdateTheme(index);
+ OnArmamentSettingChangedEvent?.Invoke(index);
+ }
+
+ private void UpdateTheme(int index)
+ {
+ switch (index)
+ {
+ case -1:
+ SafeButton.Pressed = true;
+ break;
+ case 0:
+ StunButton.Pressed = true;
+ break;
+ case 1:
+ LethalButton.Pressed = true;
+ break;
+ }
+
+ var canInteract = IsLocalPlayerAllowedToInteract();
+
+ SafeButton.Disabled = !SafeButton.Pressed && !canInteract;
+ StunButton.Disabled = !StunButton.Pressed && !canInteract;
+ LethalButton.Disabled = !LethalButton.Pressed && !canInteract;
+
+ var shiftedIndex = index + 1;
+
+ if (shiftedIndex >= 0 && shiftedIndex < _themeColors.Length)
+ ContentsContainer.Modulate = _themeColors[shiftedIndex];
+ }
+
+ public void SetOwner(EntityUid owner)
+ {
+ _owner = owner;
+
+ Initialize();
+ }
+
+ public void UpdateState(DeployableTurretControllerBoundInterfaceState state)
+ {
+ if (_entManager.TryGetComponent(_owner, out var turretController))
+ UpdateTheme(turretController.ArmamentState);
+
+ if (_entManager.TryGetComponent(_owner, out var turretTargetSettings))
+ RefreshAccessControls(turretTargetSettings.ExemptAccessLevels);
+
+ RefreshLinkedTurrets(state.TurretStates);
+ }
+
+ public void UpdateMessage(DeployableTurretControllerBoundInterfaceMessage message)
+ {
+ RefreshLinkedTurrets(message.TurretStates);
+ }
+
+ public void RefreshLinkedTurrets(List<(string, string)> turretStates)
+ {
+ var turretCount = turretStates.Count;
+ var hasTurrets = turretCount > 0;
+
+ NoLinkedTurretsText.Visible = !hasTurrets;
+ LinkedTurretsContainer.Visible = hasTurrets;
+
+ LinkedTurretsContainer.RemoveAllChildren();
+
+ foreach (var turretState in turretStates)
+ {
+ var box = new BoxContainer()
+ {
+ HorizontalExpand = true,
+ };
+
+ var label = new Label()
+ {
+ Text = Loc.GetString("turret-controls-window-turret-status", ("device", turretState.Item1), ("status", Loc.GetString(turretState.Item2))),
+ HorizontalAlignment = HAlignment.Left,
+ Margin = new Thickness(10f, 0f, 10f, 0f),
+ HorizontalExpand = true,
+ SetHeight = 20f,
+ };
+
+ label.AddStyleClass("ConsoleText");
+
+ box.AddChild(label);
+ LinkedTurretsContainer.AddChild(box);
+ }
+
+ TurretStatusHeader.Text = Loc.GetString("turret-controls-window-turret-status-label", ("count", turretCount));
+ }
+
+ public void RefreshAccessControls(HashSet> exemptAccessLevels)
+ {
+ if (_owner == null)
+ return;
+
+ if (!_entManager.TryGetComponent(_owner, out var turretControls))
+ return;
+
+ var canInteract = IsLocalPlayerAllowedToInteract();
+
+ // Create a list of known access groups with which to populate the UI
+ var groupedAccessLevels = new Dictionary>();
+
+ foreach (var accessGroup in turretControls.AccessGroups)
+ {
+ if (!_protoManager.TryIndex(accessGroup, out var accessGroupProto))
+ continue;
+
+ groupedAccessLevels.Add(accessGroupProto, new());
+ }
+
+ // Ensure that the 'general' access group is added to handle
+ // misc. access levels that aren't associated with any group
+ if (_protoManager.TryIndex("GeneralAccess", out var generalAccessProto)) // Mono
+ groupedAccessLevels.TryAdd(generalAccessProto, new());
+
+ // Assign known access levels with their associated groups
+ foreach (var accessLevel in turretControls.AccessLevels)
+ {
+ if (!_protoManager.TryIndex(accessLevel, out var accessLevelProto))
+ continue;
+
+ IEnumerable associatedGroups =
+ groupedAccessLevels.Keys.Where(x => x.Tags.Contains(accessLevelProto.ID) == true);
+
+ if (!associatedGroups.Any() && generalAccessProto != null)
+ groupedAccessLevels[generalAccessProto].Add(accessLevelProto);
+
+ else
+ {
+ foreach (var group in associatedGroups)
+ groupedAccessLevels[group].Add(accessLevelProto);
+ }
+ }
+
+ // Remove access groups that have no assigned access levels
+ foreach (var (group, accessLevels) in groupedAccessLevels)
+ {
+ if (accessLevels.Count == 0)
+ groupedAccessLevels.Remove(group);
+ }
+
+ // Did something go wrong...?
+ if (groupedAccessLevels.Count == 0)
+ {
+ AccessGroupList.DisposeAllChildren();
+ AccessLevelGrid.DisposeAllChildren();
+
+ return;
+ }
+
+ // Adjust the current tab index so it remains in range
+ if (_tabIndex >= groupedAccessLevels.Count)
+ _tabIndex = groupedAccessLevels.Count - 1;
+
+ // Reorder the access groups alphabetically
+ var orderedAccessGroups = groupedAccessLevels.Keys.OrderBy(x => x.GetAccessGroupName()).ToList();
+
+ // Remove excess group access buttons from the UI
+ while (AccessGroupList.ChildCount > orderedAccessGroups.Count)
+ AccessGroupList.RemoveChild(orderedAccessGroups.Count - 1);
+
+ // Add missing group access buttons to the UI
+ while (AccessGroupList.ChildCount < orderedAccessGroups.Count)
+ {
+ var monotoneButton = new MonotoneButton
+ {
+ ToggleMode = true,
+ };
+
+ AccessGroupList.AddChild(monotoneButton);
+
+ // Add button styling
+ monotoneButton.Label.AddStyleClass("ConsoleText");
+ monotoneButton.Label.HorizontalAlignment = HAlignment.Left;
+
+ monotoneButton.Group = _accessGroupsButtons;
+
+ var childIndex = AccessGroupList.ChildCount - 1;
+
+ if (orderedAccessGroups.Count > 1)
+ {
+ if (childIndex == 0)
+ monotoneButton.Shape = MonotoneButtonShape.OpenLeft;
+
+ else if (orderedAccessGroups.Count > 1 && childIndex == (orderedAccessGroups.Count - 1))
+ monotoneButton.Shape = MonotoneButtonShape.OpenRight;
+
+ else
+ monotoneButton.Shape = MonotoneButtonShape.OpenBoth;
+ }
+
+ // Add button events
+ monotoneButton.OnPressed += _ =>
+ {
+ OnAccessGroupChangedEvent?.Invoke(monotoneButton.GetPositionInParent());
+ };
+ }
+
+ // Update the group access buttons
+ for (int i = 0; i < orderedAccessGroups.Count; i++)
+ {
+ if (AccessGroupList.GetChild(i) is not Button { } accessGroupButton)
+ continue;
+
+ var accessGroup = orderedAccessGroups[i];
+ var prefix = groupedAccessLevels[accessGroup].Any(x => exemptAccessLevels.Contains(x)) ? "»" : " ";
+
+ accessGroupButton.Text = Loc.GetString("turret-controls-window-access-group-label",
+ ("prefix", prefix), ("label", accessGroup.GetAccessGroupName()));
+
+ accessGroupButton.Pressed = _tabIndex == orderedAccessGroups.IndexOf(accessGroup);
+ }
+
+ // Get the access levels associated with the current tab
+ _accessLevelsForTab = groupedAccessLevels[orderedAccessGroups[_tabIndex]];
+ _accessLevelsForTab = _accessLevelsForTab.OrderBy(x => x.GetAccessLevelName()).ToHashSet();
+
+ // Remove excess access level buttons from the UI
+ // Note: if _accessLevelsForTab is length 'n', AccessLevelGrid should have 'n + 1' children at the end
+ while (AccessLevelGrid.ChildCount > (_accessLevelsForTab.Count + 1))
+ {
+ var index = AccessLevelGrid.ChildCount - 1;
+
+ if (AccessLevelGrid.GetChild(AccessLevelGrid.ChildCount - 1) is AccessLevelEntry { } accessLevelEntry)
+ _accessLevelEntries.Remove(accessLevelEntry);
+
+ AccessLevelGrid.RemoveChild(index);
+ }
+
+ // Add an 'all' checkbox as the first child of the list if it hasn't been initalized yet
+ // Toggling this checkbox on will mark all other boxes below it on/off
+ if (AccessLevelGrid.ChildCount == 0)
+ {
+ var checkBox = new MonotoneCheckBox
+ {
+ Text = Loc.GetString("turret-controls-window-all-checkbox"),
+ Margin = new Thickness(0, 0, 0, 3),
+ ToggleMode = true,
+ ReservesSpace = false,
+ };
+
+ AccessLevelGrid.AddChild(checkBox);
+
+ // Add checkbox styling
+ checkBox.Label.AddStyleClass("ConsoleText");
+
+ // Add checkbox events
+ checkBox.OnPressed += args =>
+ {
+ SetCheckBoxPressedState(_checkBoxes, checkBox.Pressed);
+
+ var accessLevels = new HashSet>();
+
+ foreach (var accessLevel in _accessLevelsForTab)
+ accessLevels.Add(accessLevel);
+
+ OnAccessLevelsChangedEvent?.Invoke(accessLevels, checkBox.Pressed);
+ };
+ }
+
+ // Hide the 'all' checkbox if the tab has only one access level
+ var allCheckBoxVisible = _accessLevelsForTab.Count > 1;
+
+ // Did something go wrong...?
+ if (AccessLevelGrid.GetChild(0) is not CheckBox { } allCheckBox)
+ return;
+
+ allCheckBox.Visible = allCheckBoxVisible;
+ allCheckBox.Disabled = !canInteract;
+
+ // Add any remaining missing access level buttons to the UI
+ while (AccessLevelGrid.ChildCount < (_accessLevelsForTab.Count + 1))
+ {
+ var accessLevelEntry = new AccessLevelEntry();
+ AccessLevelGrid.AddChild(accessLevelEntry);
+
+ _accessLevelEntries.Add(accessLevelEntry);
+
+ // Add checkbox events
+ accessLevelEntry.CheckBox.OnPressed += args =>
+ {
+ // If the checkbox and its siblings are checked, check the 'all' checkbox too
+ allCheckBox.Pressed = AreAllCheckBoxesPressed(_accessLevelEntries.Select(x => (CheckBox)x.CheckBox));
+
+ OnAccessLevelsChangedEvent?.Invoke
+ (new HashSet>() { accessLevelEntry.AccessLevel }, accessLevelEntry.CheckBox.Pressed);
+ };
+ }
+
+ // Update the access levels buttons' appearance
+ for (int i = 0; i < _accessLevelEntries.Count; i++)
+ {
+ var accessLevel = _accessLevelsForTab.ElementAt(i);
+ var accessLevelEntry = _accessLevelEntries[i];
+
+ accessLevelEntry.AccessLevel = accessLevel;
+ accessLevelEntry.CheckBox.Text = accessLevel.GetAccessLevelName();
+ accessLevelEntry.CheckBox.Pressed = exemptAccessLevels.Contains(accessLevel);
+
+ var isEndOfList = i == (_accessLevelEntries.Count - 1);
+
+ var lines = new List<(Vector2, Vector2)>()
+ {
+ (new Vector2(0.5f, 0f), new Vector2(0.5f, isEndOfList ? 0.5f : 1f)),
+ (new Vector2(0.5f, 0.5f), new Vector2(1f, 0.5f)),
+ };
+
+ accessLevelEntry.UpdateCheckBoxLink(lines);
+ accessLevelEntry.CheckBoxLink.Visible = allCheckBoxVisible;
+ accessLevelEntry.CheckBoxLink.Modulate = !canInteract ? Color.Gray : Color.White;
+
+ accessLevelEntry.CheckBox.Disabled = !canInteract;
+ }
+
+ // Press the 'all' checkbox if all others are pressed
+ allCheckBox.Pressed = AreAllCheckBoxesPressed(_accessLevelEntries.Select(x => x.CheckBox));
+ }
+
+
+ private bool AreAllCheckBoxesPressed(IEnumerable checkBoxes)
+ {
+ foreach (var checkBox in checkBoxes)
+ {
+ if (!checkBox.Pressed)
+ return false;
+ }
+
+ return true;
+ }
+
+ private void SetCheckBoxPressedState(IEnumerable checkBoxes, bool pressed)
+ {
+ foreach (var checkBox in checkBoxes)
+ checkBox.Pressed = pressed;
+ }
+
+ protected override DragMode GetDragModeFor(Vector2 relativeMousePos)
+ {
+ return DragMode.Move;
+ }
+
+ private void OnAccessGroupChanged(int newTabIndex)
+ {
+ if (newTabIndex == _tabIndex)
+ return;
+
+ _tabIndex = newTabIndex;
+
+ if (_entManager.TryGetComponent(_owner, out var turretTargetSettings))
+ RefreshAccessControls(turretTargetSettings.ExemptAccessLevels);
+ }
+
+ private bool IsLocalPlayerAllowedToInteract()
+ {
+ if (_owner == null || _playerManager.LocalSession?.AttachedEntity == null)
+ return false;
+
+ return _accessReaderSystem.IsAllowed(_playerManager.LocalSession.AttachedEntity.Value, _owner.Value);
+ }
+
+ private sealed class AccessLevelEntry : BoxContainer
+ {
+ public ProtoId AccessLevel = default!;
+ public MonotoneCheckBox CheckBox;
+ public LineRenderer CheckBoxLink;
+
+ public AccessLevelEntry()
+ {
+ HorizontalExpand = true;
+
+ var lines = new List<(Vector2, Vector2)>()
+ {
+ (new Vector2(0,0), new Vector2(0,0)),
+ (new Vector2(0,0), new Vector2(0,0))
+ };
+
+ CheckBoxLink = new LineRenderer(lines)
+ {
+ SetWidth = 22,
+ VerticalExpand = true,
+ Margin = new Thickness(0, -1),
+ ReservesSpace = false,
+ };
+
+ AddChild(CheckBoxLink);
+
+ CheckBox = new MonotoneCheckBox
+ {
+ ToggleMode = true,
+ Margin = new Thickness(0f, 0f, 0f, 3f),
+ };
+
+ AddChild(CheckBox);
+
+ CheckBox.Label.AddStyleClass("ConsoleText");
+ }
+
+ public void UpdateCheckBoxLink(List<(Vector2, Vector2)> lines)
+ {
+ CheckBoxLink.Lines = lines;
+ }
+ }
+}
diff --git a/Content.Client/Turrets/DeployableTurretSystem.cs b/Content.Client/Turrets/DeployableTurretSystem.cs
new file mode 100644
index 00000000000..46b38822cab
--- /dev/null
+++ b/Content.Client/Turrets/DeployableTurretSystem.cs
@@ -0,0 +1,124 @@
+using Content.Client.Power;
+using Content.Shared.Turrets;
+using Robust.Client.Animations;
+using Robust.Client.GameObjects;
+
+namespace Content.Client.Turrets;
+
+public sealed partial class DeployableTurretSystem : SharedDeployableTurretSystem
+{
+ [Dependency] private readonly AppearanceSystem _appearance = default!;
+ [Dependency] private readonly AnimationPlayerSystem _animation = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnComponentInit);
+ SubscribeLocalEvent(OnAnimationCompleted);
+ SubscribeLocalEvent(OnAppearanceChange);
+ }
+
+ private void OnComponentInit(Entity ent, ref ComponentInit args)
+ {
+ ent.Comp.DeploymentAnimation = new Animation
+ {
+ Length = TimeSpan.FromSeconds(ent.Comp.DeploymentLength),
+ AnimationTracks = {
+ new AnimationTrackSpriteFlick() {
+ LayerKey = DeployableTurretVisuals.Turret,
+ KeyFrames = {new AnimationTrackSpriteFlick.KeyFrame(ent.Comp.DeployingState, 0f)}
+ },
+ }
+ };
+
+ ent.Comp.RetractionAnimation = new Animation
+ {
+ Length = TimeSpan.FromSeconds(ent.Comp.RetractionLength),
+ AnimationTracks = {
+ new AnimationTrackSpriteFlick() {
+ LayerKey = DeployableTurretVisuals.Turret,
+ KeyFrames = {new AnimationTrackSpriteFlick.KeyFrame(ent.Comp.RetractingState, 0f)}
+ },
+ }
+ };
+ }
+
+ private void OnAnimationCompleted(Entity ent, ref AnimationCompletedEvent args)
+ {
+ if (args.Key != DeployableTurretComponent.AnimationKey)
+ return;
+
+ if (!TryComp(ent, out var sprite))
+ return;
+
+ if (!TryComp(ent, out var animPlayer))
+ return;
+
+ if (!_appearance.TryGetData(ent, DeployableTurretVisuals.Turret, out var state))
+ state = ent.Comp.VisualState;
+
+ // Convert to terminal state
+ var targetState = state & DeployableTurretState.Deployed;
+
+ UpdateVisuals(ent, targetState, sprite, animPlayer);
+ }
+
+ private void OnAppearanceChange(Entity ent, ref AppearanceChangeEvent args)
+ {
+ if (args.Sprite == null)
+ return;
+
+ if (!TryComp(ent, out var animPlayer))
+ return;
+
+ if (!_appearance.TryGetData(ent, DeployableTurretVisuals.Turret, out var state, args.Component))
+ state = DeployableTurretState.Retracted;
+
+ UpdateVisuals(ent, state, args.Sprite, animPlayer);
+ }
+
+ private void UpdateVisuals(Entity ent, DeployableTurretState state, SpriteComponent sprite, AnimationPlayerComponent? animPlayer = null)
+ {
+ if (!Resolve(ent, ref animPlayer))
+ return;
+
+ if (_animation.HasRunningAnimation(ent, animPlayer, DeployableTurretComponent.AnimationKey))
+ return;
+
+ if (state == ent.Comp.VisualState)
+ return;
+
+ var targetState = state & DeployableTurretState.Deployed;
+ var destinationState = ent.Comp.VisualState & DeployableTurretState.Deployed;
+
+ if (targetState != destinationState)
+ targetState = targetState | DeployableTurretState.Retracting;
+
+ ent.Comp.VisualState = state;
+
+ // Toggle layer visibility
+ sprite.LayerSetVisible(DeployableTurretVisuals.Weapon, (targetState & DeployableTurretState.Deployed) > 0);
+ sprite.LayerSetVisible(PowerDeviceVisualLayers.Powered, HasAmmo(ent) && targetState == DeployableTurretState.Retracted);
+
+ // Change the visual state
+ switch (targetState)
+ {
+ case DeployableTurretState.Deploying:
+ _animation.Play((ent, animPlayer), (Animation)ent.Comp.DeploymentAnimation, DeployableTurretComponent.AnimationKey);
+ break;
+
+ case DeployableTurretState.Retracting:
+ _animation.Play((ent, animPlayer), (Animation)ent.Comp.RetractionAnimation, DeployableTurretComponent.AnimationKey);
+ break;
+
+ case DeployableTurretState.Deployed:
+ sprite.LayerSetState(DeployableTurretVisuals.Turret, ent.Comp.DeployedState);
+ break;
+
+ case DeployableTurretState.Retracted:
+ sprite.LayerSetState(DeployableTurretVisuals.Turret, ent.Comp.RetractedState);
+ break;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/UserInterface/Controls/LineRenderer.cs b/Content.Client/UserInterface/Controls/LineRenderer.cs
new file mode 100644
index 00000000000..336e4b6f289
--- /dev/null
+++ b/Content.Client/UserInterface/Controls/LineRenderer.cs
@@ -0,0 +1,38 @@
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using System.Numerics;
+
+namespace Content.Client.UserInterface.Controls;
+
+///
+/// A simple control that contains one or more rendered lines
+///
+public sealed class LineRenderer : Control
+{
+ ///
+ /// List of lines to render (their start and end x-y coordinates).
+ /// Position (0,0) is the top left corner of the control and
+ /// position (1,1) is the bottom right corner.
+ /// The color of the lines is inherited from the control.
+ ///
+ public List<(Vector2, Vector2)> Lines = new List<(Vector2, Vector2)>();
+
+ public LineRenderer(List<(Vector2, Vector2)> lines = default!)
+ {
+ Lines = lines;
+ }
+
+ protected override void Draw(DrawingHandleScreen handle)
+ {
+ foreach (var line in Lines)
+ {
+ var start = PixelPosition +
+ new Vector2(PixelWidth * line.Item1.X, PixelHeight * line.Item1.Y);
+
+ var end = PixelPosition +
+ new Vector2(PixelWidth * line.Item2.X, PixelHeight * line.Item2.Y);
+
+ handle.DrawLine(start, end, ActualModulateSelf);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/UserInterface/Controls/MonotoneButton.cs b/Content.Client/UserInterface/Controls/MonotoneButton.cs
new file mode 100644
index 00000000000..626c43e0c5d
--- /dev/null
+++ b/Content.Client/UserInterface/Controls/MonotoneButton.cs
@@ -0,0 +1,101 @@
+using Content.Client.Resources;
+using Robust.Client.Graphics;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface.Controls;
+
+namespace Content.Client.UserInterface.Controls;
+
+public sealed class MonotoneButton : Button
+{
+ ///
+ /// Specifies the color of the button's background element
+ ///
+ public Color BackgroundColor { set; get; } = new Color(0.2f, 0.2f, 0.2f);
+
+ ///
+ /// Describes the general shape of the button (i.e., open vs closed).
+ ///
+ public MonotoneButtonShape Shape
+ {
+ get { return _shape; }
+ set { _shape = value; UpdateAppearance(); }
+ }
+
+ private MonotoneButtonShape _shape = MonotoneButtonShape.Closed;
+
+ // Unfilled buttons
+ // Since the texture isn't uniform, we can't subsample it to make buttons
+ // of different shapes, we need to use a separate texture for each
+ private string[] _buttons =
+ ["/Textures/Interface/Nano/Monotone/monotone_button.svg.96dpi.png",
+ "/Textures/Interface/Nano/Monotone/monotone_button_open_left.svg.96dpi.png",
+ "/Textures/Interface/Nano/Monotone/monotone_button_open_right.svg.96dpi.png",
+ "/Textures/Interface/Nano/Monotone/monotone_button_open_both.svg.96dpi.png"];
+
+ // Filled buttons
+ // Let's just treat these the same as the unfilled buttons to ensure consistency
+ private string[] _buttonsFilled =
+ ["/Textures/Interface/Nano/Monotone/monotone_button_filled.svg.96dpi.png",
+ "/Textures/Interface/Nano/Monotone/monotone_button_open_left_filled.svg.96dpi.png",
+ "/Textures/Interface/Nano/Monotone/monotone_button_open_right_filled.svg.96dpi.png",
+ "/Textures/Interface/Nano/Monotone/monotone_button_open_both_filled.svg.96dpi.png"];
+
+ private readonly IResourceCache _resourceCache;
+
+ public MonotoneButton()
+ {
+ IoCManager.InjectDependencies(this);
+
+ _resourceCache = IoCManager.Resolve();
+
+ Initialize();
+ UpdateAppearance();
+ }
+
+ private void Initialize()
+ {
+ // Apply button texture
+ var buttonbase = new StyleBoxTexture();
+ buttonbase.SetPatchMargin(StyleBox.Margin.All, 11);
+ buttonbase.SetPadding(StyleBox.Margin.All, 1);
+ buttonbase.SetContentMarginOverride(StyleBox.Margin.Vertical, 2);
+ buttonbase.SetContentMarginOverride(StyleBox.Margin.Horizontal, 14);
+ buttonbase.Texture = _resourceCache.GetTexture(_buttons[(int)Shape]);
+
+ // We don't want any generic button styles being applied
+ this.StyleBoxOverride = buttonbase;
+ }
+
+ private void UpdateAppearance()
+ {
+ if (_resourceCache == null)
+ return;
+
+ // Recolor label
+ if (Label != null)
+ Label.ModulateSelfOverride = Pressed ? BackgroundColor : null;
+
+ // Get button texture
+ var buttonTexture = Pressed ? _buttonsFilled[(int)Shape] : _buttons[(int)Shape];
+
+ // Apply button texture
+ if (StyleBoxOverride is StyleBoxTexture { } styleBoxTexture)
+ styleBoxTexture.Texture = _resourceCache.GetTexture(buttonTexture);
+
+ // Appearance modulations
+ Modulate = Disabled ? Color.Gray : Color.White;
+ }
+
+ protected override void DrawModeChanged()
+ {
+ UpdateAppearance();
+ }
+}
+
+public enum MonotoneButtonShape : byte
+{
+ Closed = 0,
+ OpenLeft = 1,
+ OpenRight = 2,
+ OpenBoth = 3
+}
\ No newline at end of file
diff --git a/Content.Client/UserInterface/Controls/MonotoneCheckBox.cs b/Content.Client/UserInterface/Controls/MonotoneCheckBox.cs
new file mode 100644
index 00000000000..a2b7f9aaebb
--- /dev/null
+++ b/Content.Client/UserInterface/Controls/MonotoneCheckBox.cs
@@ -0,0 +1,32 @@
+using Robust.Client.UserInterface.Controls;
+
+namespace Content.Client.UserInterface.Controls;
+
+public sealed class MonotoneCheckBox : CheckBox
+{
+ public new const string StyleClassCheckBox = "monotoneCheckBox";
+ public new const string StyleClassCheckBoxChecked = "monotoneCheckBoxChecked";
+
+ public MonotoneCheckBox()
+ {
+ TextureRect.RemoveStyleClass(CheckBox.StyleClassCheckBox);
+ TextureRect.AddStyleClass(StyleClassCheckBox);
+ }
+
+ protected override void DrawModeChanged()
+ {
+ base.DrawModeChanged();
+
+ if (TextureRect == null)
+ return;
+
+ // Update appearance
+ if (Pressed)
+ TextureRect.AddStyleClass(StyleClassCheckBoxChecked);
+ else
+ TextureRect.RemoveStyleClass(StyleClassCheckBoxChecked);
+
+ // Appearance modulations
+ Modulate = Disabled ? Color.Gray : Color.White;
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/Viewport/ScalingViewport.cs b/Content.Client/Viewport/ScalingViewport.cs
index 69acb286eaf..b97a49d8e10 100644
--- a/Content.Client/Viewport/ScalingViewport.cs
+++ b/Content.Client/Viewport/ScalingViewport.cs
@@ -154,7 +154,9 @@ protected override void Draw(IRenderHandle handle)
DebugTools.AssertNotNull(_viewport);
- _viewport!.Render();
+ RenderZLevels(handle, _viewport!); // CrystallEdge Process multi-Z rendering
+
+ //_viewport!.Render();
if (_queuedScreenshots.Count != 0)
{
diff --git a/Content.Client/_CE/IconSmoothing/CEIconSmoothComponent.cs b/Content.Client/_CE/IconSmoothing/CEIconSmoothComponent.cs
new file mode 100644
index 00000000000..48d0860a4f0
--- /dev/null
+++ b/Content.Client/_CE/IconSmoothing/CEIconSmoothComponent.cs
@@ -0,0 +1,32 @@
+using Robust.Shared.GameObjects;
+
+namespace Content.Client._CE.IconSmoothing;
+
+///
+/// Tile-based icon smoothing: corners take their RSI from the adjacent tile's CEiconSmoothSprite.
+///
+[RegisterComponent]
+public sealed partial class CEIconSmoothComponent : Component
+{
+ [ViewVariables(VVAccess.ReadWrite), DataField("enabled")]
+ public bool Enabled = true;
+
+ public (EntityUid?, Vector2i)? LastPosition;
+
+ ///
+ /// We will smooth with other entities that share the same key.
+ ///
+ [ViewVariables(VVAccess.ReadWrite), DataField("key")]
+ public string? SmoothKey { get; private set; }
+
+ ///
+ /// Additional keys to smooth with.
+ ///
+ [DataField]
+ public List AdditionalKeys = new();
+
+ ///
+ /// Used by to reduce redundant updates.
+ ///
+ internal int UpdateGeneration { get; set; }
+}
diff --git a/Content.Client/_CE/IconSmoothing/CEIconSmoothSystem.cs b/Content.Client/_CE/IconSmoothing/CEIconSmoothSystem.cs
new file mode 100644
index 00000000000..c5e9ec31535
--- /dev/null
+++ b/Content.Client/_CE/IconSmoothing/CEIconSmoothSystem.cs
@@ -0,0 +1,589 @@
+using Content.Client.IconSmoothing;
+using Content.Shared.Maps;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Map.Enumerators;
+using Robust.Shared.Utility;
+
+namespace Content.Client._CE.IconSmoothing;
+
+///
+/// Tile-based icon smoothing system.
+/// Works like Corners mode but selects the RSI for each corner from the adjacent tile's
+/// .
+///
+public sealed partial class CEIconSmoothSystem : EntitySystem
+{
+ [Dependency] private SharedMapSystem _mapSystem = default!;
+ [Dependency] private SpriteSystem _sprite = default!;
+ [Dependency] private ITileDefinitionManager _tileDefManager = default!;
+
+ private readonly Queue _dirtyEntities = new();
+ private readonly Queue _anchorChangedEntities = new();
+ private int _generation;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnAnchorChanged);
+ SubscribeLocalEvent(OnShutdown);
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnTileChanged);
+ }
+
+ private void OnTileChanged(Entity gridEntity, ref TileChangedEvent args)
+ {
+ foreach (var change in args.Changes)
+ {
+ var pos = change.GridIndices;
+
+ // Dirty all CE smooth entities in a 3×3 area around the changed tile.
+ for (var dx = -1; dx <= 1; dx++)
+ {
+ for (var dy = -1; dy <= 1; dy++)
+ {
+ DirtyEntities(_mapSystem.GetAnchoredEntitiesEnumerator(
+ gridEntity.Owner, gridEntity.Comp, pos + new Vector2i(dx, dy)));
+ }
+ }
+ }
+ }
+
+ private void OnStartup(EntityUid uid, CEIconSmoothComponent component, ComponentStartup args)
+ {
+ var xform = Transform(uid);
+ if (xform.Anchored)
+ {
+ component.LastPosition = TryComp(xform.GridUid, out var grid)
+ ? (xform.GridUid.Value, _mapSystem.TileIndicesFor(xform.GridUid.Value, grid, xform.Coordinates))
+ : (null, new Vector2i(0, 0));
+
+ DirtyNeighbours(uid, component);
+ }
+
+ if (!TryComp(uid, out SpriteComponent? sprite))
+ return;
+
+ SetCornerLayers(new Entity(uid, sprite));
+ }
+
+ private void SetCornerLayers(Entity sprite)
+ {
+ var nullable = sprite.AsNullable();
+
+ _sprite.LayerMapRemove(nullable, CECornerLayers.SE);
+ _sprite.LayerMapRemove(nullable, CECornerLayers.NE);
+ _sprite.LayerMapRemove(nullable, CECornerLayers.NW);
+ _sprite.LayerMapRemove(nullable, CECornerLayers.SW);
+ _sprite.LayerMapRemove(nullable, CECornerLayers.SEAlt);
+ _sprite.LayerMapRemove(nullable, CECornerLayers.NEAlt);
+ _sprite.LayerMapRemove(nullable, CECornerLayers.NWAlt);
+ _sprite.LayerMapRemove(nullable, CECornerLayers.SWAlt);
+
+ AddCornerPair(nullable, CECornerLayers.SE, CECornerLayers.SEAlt,
+ SpriteComponent.DirectionOffset.None);
+ AddCornerPair(nullable, CECornerLayers.NE, CECornerLayers.NEAlt,
+ SpriteComponent.DirectionOffset.CounterClockwise);
+ AddCornerPair(nullable, CECornerLayers.NW, CECornerLayers.NWAlt,
+ SpriteComponent.DirectionOffset.Flip);
+ AddCornerPair(nullable, CECornerLayers.SW, CECornerLayers.SWAlt,
+ SpriteComponent.DirectionOffset.Clockwise);
+ }
+
+ private void AddCornerPair(
+ Entity sprite,
+ CECornerLayers primary,
+ CECornerLayers alt,
+ SpriteComponent.DirectionOffset offset)
+ {
+ _sprite.LayerMapSet(sprite, primary,
+ _sprite.AddRsiLayer(sprite, RSI.StateId.Invalid));
+ _sprite.LayerSetDirOffset(sprite, primary, offset);
+ _sprite.LayerSetVisible(sprite, primary, false);
+
+ _sprite.LayerMapSet(sprite, alt,
+ _sprite.AddRsiLayer(sprite, RSI.StateId.Invalid));
+ _sprite.LayerSetDirOffset(sprite, alt, offset);
+ _sprite.LayerSetVisible(sprite, alt, false);
+ }
+
+ private void OnShutdown(EntityUid uid, CEIconSmoothComponent component, ComponentShutdown args)
+ {
+ _dirtyEntities.Enqueue(uid);
+ DirtyNeighbours(uid, component);
+ }
+
+ private void OnAnchorChanged(EntityUid uid, CEIconSmoothComponent component, ref AnchorStateChangedEvent args)
+ {
+ if (!args.Detaching)
+ _anchorChangedEntities.Enqueue(uid);
+ }
+
+ public void DirtyNeighbours(
+ EntityUid uid,
+ CEIconSmoothComponent? comp = null,
+ TransformComponent? transform = null,
+ EntityQuery? smoothQuery = null)
+ {
+ smoothQuery ??= GetEntityQuery();
+ if (!smoothQuery.Value.Resolve(uid, ref comp) || !comp.Running)
+ return;
+
+ _dirtyEntities.Enqueue(uid);
+
+ if (!Resolve(uid, ref transform))
+ return;
+
+ Vector2i pos;
+ EntityUid entityUid;
+
+ if (transform.Anchored && TryComp(transform.GridUid, out var grid))
+ {
+ entityUid = transform.GridUid.Value;
+ pos = _mapSystem.CoordinatesToTile(transform.GridUid.Value, grid, transform.Coordinates);
+ }
+ else
+ {
+ if (comp.LastPosition is not (EntityUid gridId, Vector2i oldPos))
+ return;
+
+ if (!TryComp(gridId, out grid))
+ return;
+
+ entityUid = gridId;
+ pos = oldPos;
+ }
+
+ // Dirty all neighbours including diagonals.
+ DirtyEntities(_mapSystem.GetAnchoredEntitiesEnumerator(entityUid, grid, pos + new Vector2i(1, 0)));
+ DirtyEntities(_mapSystem.GetAnchoredEntitiesEnumerator(entityUid, grid, pos + new Vector2i(-1, 0)));
+ DirtyEntities(_mapSystem.GetAnchoredEntitiesEnumerator(entityUid, grid, pos + new Vector2i(0, 1)));
+ DirtyEntities(_mapSystem.GetAnchoredEntitiesEnumerator(entityUid, grid, pos + new Vector2i(0, -1)));
+ DirtyEntities(_mapSystem.GetAnchoredEntitiesEnumerator(entityUid, grid, pos + new Vector2i(1, 1)));
+ DirtyEntities(_mapSystem.GetAnchoredEntitiesEnumerator(entityUid, grid, pos + new Vector2i(-1, -1)));
+ DirtyEntities(_mapSystem.GetAnchoredEntitiesEnumerator(entityUid, grid, pos + new Vector2i(-1, 1)));
+ DirtyEntities(_mapSystem.GetAnchoredEntitiesEnumerator(entityUid, grid, pos + new Vector2i(1, -1)));
+ }
+
+ private void DirtyEntities(AnchoredEntitiesEnumerator entities)
+ {
+ while (entities.MoveNext(out var entity))
+ {
+ _dirtyEntities.Enqueue(entity.Value);
+ }
+ }
+
+ public override void FrameUpdate(float frameTime)
+ {
+ base.FrameUpdate(frameTime);
+
+ var xformQuery = GetEntityQuery();
+ var smoothQuery = GetEntityQuery();
+
+ while (_anchorChangedEntities.TryDequeue(out var uid))
+ {
+ if (!xformQuery.TryGetComponent(uid, out var xform))
+ continue;
+
+ if (xform.MapID == MapId.Nullspace)
+ continue;
+
+ DirtyNeighbours(uid, comp: null, xform, smoothQuery);
+ }
+
+ if (_dirtyEntities.Count == 0)
+ return;
+
+ _generation += 1;
+ var spriteQuery = GetEntityQuery();
+ var vanillaQuery = GetEntityQuery();
+
+ while (_dirtyEntities.TryDequeue(out var uid))
+ {
+ CalculateNewSprite(uid, spriteQuery, smoothQuery, xformQuery, vanillaQuery);
+ }
+ }
+
+ private void CalculateNewSprite(
+ EntityUid uid,
+ EntityQuery spriteQuery,
+ EntityQuery smoothQuery,
+ EntityQuery xformQuery,
+ EntityQuery vanillaQuery,
+ CEIconSmoothComponent? smooth = null)
+ {
+ if (!smoothQuery.Resolve(uid, ref smooth, false)
+ || smooth.UpdateGeneration == _generation
+ || !smooth.Enabled
+ || !smooth.Running)
+ {
+ return;
+ }
+
+ var xform = xformQuery.GetComponent(uid);
+ smooth.UpdateGeneration = _generation;
+
+ if (!spriteQuery.TryGetComponent(uid, out var sprite))
+ {
+ Log.Error($"CE icon-smooth entity without a sprite: {ToPrettyString(uid)}");
+ RemCompDeferred(uid, smooth);
+ return;
+ }
+
+ Entity? gridEntity = null;
+
+ if (xform.Anchored)
+ {
+ if (TryComp(xform.GridUid, out MapGridComponent? grid))
+ gridEntity = (xform.GridUid.Value, grid);
+ else
+ {
+ Log.Error(
+ $"Failed to calculate CEIconSmooth for {uid}: grid {xform.GridUid} missing.");
+ return;
+ }
+ }
+
+ CalculateNewSpriteCorners(gridEntity, smooth, (uid, sprite), xform, smoothQuery, vanillaQuery);
+ }
+
+ private void CalculateNewSpriteCorners(
+ Entity? gridEntity,
+ CEIconSmoothComponent smooth,
+ Entity spriteEnt,
+ TransformComponent xform,
+ EntityQuery smoothQuery,
+ EntityQuery vanillaQuery)
+ {
+ var nullable = spriteEnt.AsNullable();
+
+ if (gridEntity == null)
+ {
+ _sprite.LayerSetVisible(nullable, CECornerLayers.SE, false);
+ _sprite.LayerSetVisible(nullable, CECornerLayers.NE, false);
+ _sprite.LayerSetVisible(nullable, CECornerLayers.NW, false);
+ _sprite.LayerSetVisible(nullable, CECornerLayers.SW, false);
+ _sprite.LayerSetVisible(nullable, CECornerLayers.SEAlt, false);
+ _sprite.LayerSetVisible(nullable, CECornerLayers.NEAlt, false);
+ _sprite.LayerSetVisible(nullable, CECornerLayers.NWAlt, false);
+ _sprite.LayerSetVisible(nullable, CECornerLayers.SWAlt, false);
+ return;
+ }
+
+ var gridUid = gridEntity.Value.Owner;
+ var grid = gridEntity.Value.Comp;
+ var pos = _mapSystem.TileIndicesFor(gridUid, grid, xform.Coordinates);
+
+ // Calculate corner fills — identical to the original Corners mode.
+ var n = MatchingEntity(smooth,
+ _mapSystem.GetAnchoredEntitiesEnumerator(gridUid, grid, pos.Offset(Direction.North)), smoothQuery, vanillaQuery);
+ var ne = MatchingEntity(smooth,
+ _mapSystem.GetAnchoredEntitiesEnumerator(gridUid, grid, pos.Offset(Direction.NorthEast)), smoothQuery, vanillaQuery);
+ var e = MatchingEntity(smooth,
+ _mapSystem.GetAnchoredEntitiesEnumerator(gridUid, grid, pos.Offset(Direction.East)), smoothQuery, vanillaQuery);
+ var se = MatchingEntity(smooth,
+ _mapSystem.GetAnchoredEntitiesEnumerator(gridUid, grid, pos.Offset(Direction.SouthEast)), smoothQuery, vanillaQuery);
+ var s = MatchingEntity(smooth,
+ _mapSystem.GetAnchoredEntitiesEnumerator(gridUid, grid, pos.Offset(Direction.South)), smoothQuery, vanillaQuery);
+ var sw = MatchingEntity(smooth,
+ _mapSystem.GetAnchoredEntitiesEnumerator(gridUid, grid, pos.Offset(Direction.SouthWest)), smoothQuery, vanillaQuery);
+ var w = MatchingEntity(smooth,
+ _mapSystem.GetAnchoredEntitiesEnumerator(gridUid, grid, pos.Offset(Direction.West)), smoothQuery, vanillaQuery);
+ var nw = MatchingEntity(smooth,
+ _mapSystem.GetAnchoredEntitiesEnumerator(gridUid, grid, pos.Offset(Direction.NorthWest)), smoothQuery, vanillaQuery);
+
+ var cornerNE = CornerFill.None;
+ var cornerSE = CornerFill.None;
+ var cornerSW = CornerFill.None;
+ var cornerNW = CornerFill.None;
+
+ if (n)
+ {
+ cornerNE |= CornerFill.CounterClockwise;
+ cornerNW |= CornerFill.Clockwise;
+ }
+
+ if (ne)
+ cornerNE |= CornerFill.Diagonal;
+
+ if (e)
+ {
+ cornerNE |= CornerFill.Clockwise;
+ cornerSE |= CornerFill.CounterClockwise;
+ }
+
+ if (se)
+ cornerSE |= CornerFill.Diagonal;
+
+ if (s)
+ {
+ cornerSE |= CornerFill.Clockwise;
+ cornerSW |= CornerFill.CounterClockwise;
+ }
+
+ if (sw)
+ cornerSW |= CornerFill.Diagonal;
+
+ if (w)
+ {
+ cornerSW |= CornerFill.Clockwise;
+ cornerNW |= CornerFill.CounterClockwise;
+ }
+
+ if (nw)
+ cornerNW |= CornerFill.Diagonal;
+
+ // Resolve tile RSI data for each world-space corner.
+ var dataNE = GetCornerData(gridUid, grid, pos, cornerNE,
+ Direction.NorthEast, Direction.North, Direction.East);
+ var dataSE = GetCornerData(gridUid, grid, pos, cornerSE,
+ Direction.SouthEast, Direction.East, Direction.South);
+ var dataSW = GetCornerData(gridUid, grid, pos, cornerSW,
+ Direction.SouthWest, Direction.South, Direction.West);
+ var dataNW = GetCornerData(gridUid, grid, pos, cornerNW,
+ Direction.NorthWest, Direction.West, Direction.North);
+
+ // Apply rotation mapping (same as original Corners mode).
+ CornerData visNE, visNW, visSW, visSE;
+
+ switch (xform.LocalRotation.GetCardinalDir())
+ {
+ case Direction.North:
+ (visNE, visNW, visSW, visSE) = (dataSW, dataSE, dataNE, dataNW);
+ break;
+ case Direction.West:
+ (visNE, visNW, visSW, visSE) = (dataSE, dataNE, dataNW, dataSW);
+ break;
+ case Direction.South:
+ (visNE, visNW, visSW, visSE) = (dataNE, dataNW, dataSW, dataSE);
+ break;
+ default:
+ (visNE, visNW, visSW, visSE) = (dataNW, dataSW, dataSE, dataNE);
+ break;
+ }
+
+ ApplyCorner(nullable, CECornerLayers.NE, CECornerLayers.NEAlt, visNE);
+ ApplyCorner(nullable, CECornerLayers.NW, CECornerLayers.NWAlt, visNW);
+ ApplyCorner(nullable, CECornerLayers.SW, CECornerLayers.SWAlt, visSW);
+ ApplyCorner(nullable, CECornerLayers.SE, CECornerLayers.SEAlt, visSE);
+ }
+
+ private ResPath? GetTileRsi(EntityUid gridUid, MapGridComponent grid, Vector2i pos, Direction dir)
+ {
+ var tileRef = _mapSystem.GetTileRef(gridUid, grid, pos.Offset(dir));
+ if (tileRef.Tile.IsEmpty)
+ return null;
+
+ var tileDef = (ContentTileDefinition) _tileDefManager[tileRef.Tile.TypeId];
+ return tileDef.IconSmoothSprite;
+ }
+
+ ///
+ /// Build per-corner data: primary RSI/state and optional secondary overlay.
+ ///
+ private CornerData GetCornerData(
+ EntityUid gridUid,
+ MapGridComponent grid,
+ Vector2i pos,
+ CornerFill fill,
+ Direction diagonal,
+ Direction ccw,
+ Direction cw)
+ {
+ const CornerFill all = CornerFill.CounterClockwise | CornerFill.Diagonal | CornerFill.Clockwise;
+
+ // State 7: never drawn.
+ if (fill == all)
+ return default;
+
+ // State 0 (no neighbours): look at both CCW and CW cardinal tiles.
+ if (fill == CornerFill.None)
+ {
+ var rsiCcw = GetTileRsi(gridUid, grid, pos, ccw);
+ var rsiCw = GetTileRsi(gridUid, grid, pos, cw);
+
+ // Both null -> nothing to draw.
+ if (rsiCcw == null && rsiCw == null)
+ return default;
+
+ // Same RSI (or one is null) -> single tile_0 layer.
+ if (rsiCcw == rsiCw || rsiCcw == null || rsiCw == null)
+ {
+ return new CornerData
+ {
+ PrimaryRsi = rsiCcw ?? rsiCw,
+ PrimaryState = "tile_0",
+ };
+ }
+
+ // Different RSIs -> overlay: state 4 from CCW tile + state 1 from CW tile.
+ return new CornerData
+ {
+ PrimaryRsi = rsiCcw,
+ PrimaryState = "tile_4",
+ AltRsi = rsiCw,
+ AltState = "tile_1",
+ };
+ }
+
+ // State 2 (diagonal only): same logic as state 0 but with tile_2/tile_6/tile_3.
+ if (fill == CornerFill.Diagonal)
+ {
+ var rsiCcw = GetTileRsi(gridUid, grid, pos, ccw);
+ var rsiCw = GetTileRsi(gridUid, grid, pos, cw);
+
+ if (rsiCcw == null && rsiCw == null)
+ return default;
+
+ if (rsiCcw == rsiCw || rsiCcw == null || rsiCw == null)
+ {
+ return new CornerData
+ {
+ PrimaryRsi = rsiCcw ?? rsiCw,
+ PrimaryState = "tile_2",
+ };
+ }
+
+ // Different RSIs -> overlay: state 6 from CCW tile + state 3 from CW tile.
+ return new CornerData
+ {
+ PrimaryRsi = rsiCcw,
+ PrimaryState = "tile_6",
+ AltRsi = rsiCw,
+ AltState = "tile_3",
+ };
+ }
+
+ // State 5 (CCW + CW, no diagonal): look at the diagonal tile.
+ if (fill == (CornerFill.CounterClockwise | CornerFill.Clockwise))
+ {
+ var diagRsi = GetTileRsi(gridUid, grid, pos, diagonal);
+ if (diagRsi == null)
+ return default;
+
+ return new CornerData
+ {
+ PrimaryRsi = diagRsi,
+ PrimaryState = "tile_5",
+ };
+ }
+
+ // States 1, 3 (CCW filled) -> border faces CW -> CW tile.
+ // States 4, 6 (CW filled) -> border faces CCW -> CCW tile.
+ Direction? lookDir = fill switch
+ {
+ CornerFill.CounterClockwise => cw,
+ CornerFill.CounterClockwise | CornerFill.Diagonal => cw,
+ CornerFill.Clockwise => ccw,
+ CornerFill.Diagonal | CornerFill.Clockwise => ccw,
+ _ => null,
+ };
+
+ if (lookDir == null)
+ return default;
+
+ var rsi = GetTileRsi(gridUid, grid, pos, lookDir.Value);
+ if (rsi == null)
+ return default;
+
+ return new CornerData
+ {
+ PrimaryRsi = rsi,
+ PrimaryState = $"tile_{(int) fill}",
+ };
+ }
+
+ private void ApplyCorner(
+ Entity sprite,
+ CECornerLayers primary,
+ CECornerLayers alt,
+ CornerData data)
+ {
+ // Primary layer.
+ if (data.PrimaryRsi != null)
+ {
+ _sprite.LayerSetRsi(sprite, primary, data.PrimaryRsi.Value,
+ (RSI.StateId) data.PrimaryState!);
+ _sprite.LayerSetVisible(sprite, primary, true);
+ }
+ else
+ {
+ _sprite.LayerSetVisible(sprite, primary, false);
+ }
+
+ // Alt (overlay) layer.
+ if (data.AltRsi != null)
+ {
+ _sprite.LayerSetRsi(sprite, alt, data.AltRsi.Value,
+ (RSI.StateId) data.AltState!);
+ _sprite.LayerSetVisible(sprite, alt, true);
+ }
+ else
+ {
+ _sprite.LayerSetVisible(sprite, alt, false);
+ }
+ }
+
+ private struct CornerData
+ {
+ public ResPath? PrimaryRsi;
+ public string? PrimaryState;
+ public ResPath? AltRsi;
+ public string? AltState;
+ }
+
+ private bool MatchingEntity(
+ CEIconSmoothComponent smooth,
+ AnchoredEntitiesEnumerator candidates,
+ EntityQuery smoothQuery,
+ EntityQuery vanillaQuery)
+ {
+ while (candidates.MoveNext(out var entity))
+ {
+ // Check CE smooth entities.
+ if (smoothQuery.TryGetComponent(entity, out var other)
+ && other.SmoothKey != null
+ && (other.SmoothKey == smooth.SmoothKey
+ || smooth.AdditionalKeys.Contains(other.SmoothKey))
+ && other.Enabled)
+ {
+ return true;
+ }
+
+ // Check vanilla IconSmooth entities (cross-matching via AdditionalKeys).
+ if (vanillaQuery.TryGetComponent(entity, out var vanilla)
+ && vanilla.SmoothKey != null
+ && (vanilla.SmoothKey == smooth.SmoothKey
+ || smooth.AdditionalKeys.Contains(vanilla.SmoothKey))
+ && vanilla.Enabled)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ [Flags]
+ private enum CornerFill : byte
+ {
+ None = 0,
+ CounterClockwise = 1,
+ Diagonal = 2,
+ Clockwise = 4,
+ }
+
+ private enum CECornerLayers : byte
+ {
+ SE,
+ NE,
+ NW,
+ SW,
+ SEAlt,
+ NEAlt,
+ NWAlt,
+ SWAlt,
+ }
+}
diff --git a/Content.Client/_CE/ZLevels/Core/CEClientZLevelsSystem.cs b/Content.Client/_CE/ZLevels/Core/CEClientZLevelsSystem.cs
new file mode 100644
index 00000000000..e45f81c1b15
--- /dev/null
+++ b/Content.Client/_CE/ZLevels/Core/CEClientZLevelsSystem.cs
@@ -0,0 +1,146 @@
+/*
+ * This file is sublicensed under MIT License
+ * https://github.com/space-wizards/space-station-14/blob/master/LICENSE.TXT
+ */
+
+using System.Numerics;
+using Content.Client._CE.ZLevels.Core.Overlays;
+using Content.Shared._CE.ZLevels.Core.Components;
+using Content.Shared._CE.ZLevels.Core.EntitySystems;
+using Content.Shared.Camera;
+using Content.Shared.StatusEffect;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Maths;
+
+namespace Content.Client._CE.ZLevels.Core;
+
+///
+/// Only process Eye offset and drawdepth on clientside
+///
+public sealed partial class CEClientZLevelsSystem : CESharedZLevelsSystem
+{
+ [Dependency] private IOverlayManager _overlay = default!;
+ [Dependency] private IEyeManager _eye = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ _overlay.AddOverlay(new CEZLevelBlurOverlay());
+
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnEyeOffset);
+ }
+
+ private void OnEyeOffset(Entity ent, ref GetEyeOffsetEvent args)
+ {
+ Angle rotation = _eye.CurrentEye.Rotation * -1;
+ var localPosition = ent.Comp.LocalPosition;
+ var offset = rotation.RotateVec(new Vector2(0, localPosition * ZLevelOffset));
+ args.Offset += offset;
+ }
+
+ private void OnStartup(Entity ent, ref ComponentStartup args)
+ {
+ if (!TryComp(ent, out var sprite))
+ return;
+
+ if (sprite.SnapCardinals)
+ return;
+
+ ent.Comp.DrawDepthDefault = sprite.DrawDepth;
+ ent.Comp.SpriteOffsetDefault = sprite.Offset;
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ _overlay.RemoveOverlay();
+ }
+}
+
+///
+/// Pre-animation pass for Z-level visuals.
+/// Runs its BEFORE every render frame,
+/// resetting to the entity's clean base (no Z).
+/// This prevents Z from accumulating across frames when no animation writes to the offset
+/// (e.g. entities that only animate scale, like SlimeIceBig).
+///
+internal sealed partial class CEClientZLevelsPreAnimSystem : EntitySystem
+{
+ [Dependency] private SpriteSystem _sprite = default!;
+ [Dependency] private EntityQuery _mapGridQuery = default!;
+ [Dependency] private EntityQuery _zPhysQuery = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ UpdatesBefore.Add(typeof(AnimationPlayerSystem));
+ }
+
+ public override void FrameUpdate(float frameTime)
+ {
+ // Phase 1 (per render frame): strip any Z left from last frame so the animation player
+ // always starts from a Z-free base, and Phase 2 can add exactly one Z contribution.
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var zPhys, out var sprite))
+ {
+ var localPosition = zPhys.LocalPosition;
+ _sprite.SetOffset((uid, sprite), zPhys.SpriteOffsetDefault);
+ _sprite.SetDrawDepth((uid, sprite), localPosition > 0 ? (int)Shared.DrawDepth.DrawDepth.OverMobs : zPhys.DrawDepthDefault);
+ }
+
+ // Set parent-synced status effect offsets to the parent's current Z value each frame — prevents accumulation.
+ var syncQuery = EntityQueryEnumerator();
+ while (syncQuery.MoveNext(out var uid, out _, out var sprite, out var xform))
+ {
+ var parent = xform.ParentUid;
+ if (_mapGridQuery.HasComp(parent))
+ continue;
+ if (!_zPhysQuery.TryComp(parent, out var parentZPhys))
+ continue;
+ var zOffset = new Vector2(0, parentZPhys.LocalPosition * CESharedZLevelsSystem.ZLevelOffset);
+ _sprite.SetOffset((uid, sprite), zOffset);
+ }
+ }
+}
+
+///
+/// Post-animation pass for Z-level visuals.
+/// Runs its AFTER so that
+/// whatever offset the animation player wrote to this frame
+/// (loop animation, one-shot swing, idle bob, etc.) is preserved, and the Z-height contribution
+/// is simply added on top. No animation code needs to know about Z levels.
+///
+internal sealed partial class CEClientZLevelsPostAnimSystem : EntitySystem
+{
+ [Dependency] private SpriteSystem _sprite = default!;
+ [Dependency] private SharedTransformSystem _xform = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ UpdatesAfter.Add(typeof(AnimationPlayerSystem));
+ }
+
+ public override void FrameUpdate(float frameTime)
+ {
+ // Phase 2: add the Z-height contribution on top of the animation-player's output.
+ // At this point sprite.Offset == animationValue (or SpriteOffsetDefault if no anim ran).
+ // The offset is counter-rotated by the entity's world angle so it always points world-up,
+ // preventing it from orbiting the pivot when the entity has angular velocity (e.g. shurikens).
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var zPhys, out var sprite, out var xform))
+ {
+ var rawZ = new Vector2(0, zPhys.LocalPosition * CESharedZLevelsSystem.ZLevelOffset);
+ Vector2 zOffset;
+ if (sprite.NoRotation)
+ zOffset = rawZ;
+ else
+ zOffset = new Angle(-_xform.GetWorldRotation(xform)).RotateVec(rawZ);
+ _sprite.SetOffset((uid, sprite), sprite.Offset + zOffset);
+ }
+ }
+}
diff --git a/Content.Client/_CE/ZLevels/Core/Overlays/CEZLevelBlurOverlay.cs b/Content.Client/_CE/ZLevels/Core/Overlays/CEZLevelBlurOverlay.cs
new file mode 100644
index 00000000000..60417e4114c
--- /dev/null
+++ b/Content.Client/_CE/ZLevels/Core/Overlays/CEZLevelBlurOverlay.cs
@@ -0,0 +1,76 @@
+/*
+ * This file is sublicensed under MIT License
+ * https://github.com/space-wizards/space-station-14/blob/master/LICENSE.TXT
+ */
+
+using System.Numerics;
+using Content.Client.Viewport;
+using Robust.Client.Graphics;
+using Robust.Shared.Enums;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client._CE.ZLevels.Core.Overlays;
+
+public sealed partial class CEZLevelBlurOverlay : Overlay
+{
+ [Dependency] private IPrototypeManager _proto = default!;
+ [Dependency] private IEntityManager _entity = default!;
+ private readonly ShaderInstance? _blurShader;
+
+ public override bool RequestScreenTexture => true;
+ public override OverlaySpace Space => OverlaySpace.WorldSpace;
+
+ private readonly ProtoId _zBlurShader = "CEZBlur";
+
+ public CEZLevelBlurOverlay()
+ {
+ IoCManager.InjectDependencies(this);
+ _blurShader = _proto.Index(_zBlurShader).InstanceUnique();
+ }
+
+ protected override bool BeforeDraw(in OverlayDrawArgs args)
+ {
+ if (args.Viewport.Eye is not ScalingViewport.ZEye zeye)
+ return false;
+
+ if (zeye.Depth >= 0)
+ return false;
+
+ if (args.MapId == MapId.Nullspace)
+ return false;
+
+ return true;
+ }
+
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ if (ScreenTexture == null || args.Viewport.Eye == null)
+ return;
+
+ var ambientColor = new Vector3(0, 0, 1); //Default blue
+
+ if (_entity.TryGetComponent(args.MapUid, out var mapLight))
+ {
+ ambientColor = new Vector3(
+ mapLight.AmbientLightColor.R,
+ mapLight.AmbientLightColor.G,
+ mapLight.AmbientLightColor.B);
+ }
+
+ var strength = 1f;
+ if (args.Viewport.Eye is ScalingViewport.ZEye zeye)
+ strength = Math.Clamp(-zeye.Depth, 0f, 1f);
+
+ _blurShader?.SetParameter("SCREEN_TEXTURE", ScreenTexture);
+ _blurShader?.SetParameter("BLUR_COLOR", ambientColor);
+ _blurShader?.SetParameter("STRENGTH", strength);
+ _blurShader?.SetParameter("FADE", 1f);
+
+ var worldHandle = args.WorldHandle;
+ worldHandle.UseShader(_blurShader);
+ worldHandle.DrawRect(args.WorldBounds, Color.White);
+ worldHandle.UseShader(null);
+ }
+}
diff --git a/Content.Client/_CE/ZLevels/Core/Overlays/CEZLevelDebugOverlay.cs b/Content.Client/_CE/ZLevels/Core/Overlays/CEZLevelDebugOverlay.cs
new file mode 100644
index 00000000000..3828fdff60a
--- /dev/null
+++ b/Content.Client/_CE/ZLevels/Core/Overlays/CEZLevelDebugOverlay.cs
@@ -0,0 +1,107 @@
+/*
+ * This file is sublicensed under MIT License
+ * https://github.com/space-wizards/space-station-14/blob/master/LICENSE.TXT
+ */
+
+using System.Numerics;
+using Content.Shared._CE.ZLevels.Core.Components;
+using Content.Shared._CE.ZLevels.Core.EntitySystems;
+using Robust.Client.Graphics;
+using Robust.Client.ResourceManagement;
+using Robust.Shared.Console;
+using Robust.Shared.Enums;
+
+namespace Content.Client._CE.ZLevels.Core.Overlays;
+
+public sealed partial class CEZLevelDebugOverlay : Overlay
+{
+ [Dependency] private IEntityManager _entityManager = null!;
+ [Dependency] private IResourceCache _cache = null!;
+
+ private readonly CESharedZLevelsSystem _zLevels;
+ private readonly SharedTransformSystem _transform;
+
+ private readonly Font _font;
+
+ public override OverlaySpace Space => OverlaySpace.ScreenSpace;
+
+ public CEZLevelDebugOverlay()
+ {
+ IoCManager.InjectDependencies(this);
+
+ _zLevels = _entityManager.System();
+ _transform = _entityManager.System();
+
+ _font = new VectorFont(_cache.GetResource("/Fonts/NotoSans/NotoSans-Regular.ttf"), 8);
+ }
+
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ foreach (var uid in _zLevels.ActiveBodies)
+ {
+ if (!_entityManager.TryGetComponent(uid, out var zPhys) ||
+ !_entityManager.TryGetComponent(uid, out var xform))
+ continue;
+
+ if (xform.GridUid != xform.ParentUid)
+ continue;
+
+ DrawEntityDebug(args, uid, zPhys);
+ }
+
+ var gridQuery = _entityManager.EntityQueryEnumerator();
+ while (gridQuery.MoveNext(out _, out var gridNet, out var gridXform))
+ {
+ if (gridNet.NetworkId == string.Empty)
+ continue;
+
+ var gridWorldPos = _transform.GetWorldPosition(gridXform);
+ var gridScreenPos = args.ViewportControl?.WorldToScreen(gridWorldPos) ?? Vector2.Zero;
+ if (gridScreenPos == Vector2.Zero)
+ continue;
+
+ var shortId = gridNet.NetworkId.Length >= 8
+ ? gridNet.NetworkId[..8]
+ : gridNet.NetworkId;
+ args.ScreenHandle.DrawString(_font, gridScreenPos, $"Net: {shortId}", Color.Cyan);
+ }
+ }
+
+ private void DrawEntityDebug(in OverlayDrawArgs args, EntityUid uid, CEZPhysicsComponent component)
+ {
+ var worldPos = _transform.GetWorldPosition(uid);
+ var screenPos = args.ViewportControl?.WorldToScreen(worldPos) ?? Vector2.Zero;
+
+ if (screenPos == Vector2.Zero)
+ return;
+
+ var localPos = float.Round(component.LocalPosition, 2);
+ var groundDis = float.Round(component.LocalPosition - component.CachedGroundHeight, 2);
+ var velocity = float.Round(component.Velocity, 2);
+
+ var depthText = $"Z: {localPos}\n" +
+ $"G: {groundDis}\n" +
+ $"V: {velocity}\n" +
+ $"S: {component.CachedStickyGround}";
+
+ args.ScreenHandle.DrawString(_font, screenPos, depthText, Color.White);
+ }
+}
+
+public sealed partial class CEShowZLevelDebugCommand : LocalizedCommands
+{
+ [Dependency] private IOverlayManager _overlayManager = null!;
+
+ public override string Command => "showzleveldebug";
+
+ public override void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (_overlayManager.HasOverlay())
+ {
+ _overlayManager.RemoveOverlay();
+ return;
+ }
+
+ _overlayManager.AddOverlay(new CEZLevelDebugOverlay());
+ }
+}
diff --git a/Content.Client/_CE/ZLevels/Core/ScalingViewport.CEZLevels.cs b/Content.Client/_CE/ZLevels/Core/ScalingViewport.CEZLevels.cs
new file mode 100644
index 00000000000..56a7fa86767
--- /dev/null
+++ b/Content.Client/_CE/ZLevels/Core/ScalingViewport.CEZLevels.cs
@@ -0,0 +1,493 @@
+/*
+ * This file is sublicensed under MIT License
+ * https://github.com/space-wizards/space-station-14/blob/master/LICENSE.TXT
+ */
+
+using System.Numerics;
+using Content.Client._CE.ZLevels.Core;
+using Content.Shared._CE.ZLevels.Core.Components;
+using Content.Shared._CE.ZLevels.Core.EntitySystems;
+using Content.Shared.Maps;
+using Robust.Client.Graphics;
+using Robust.Client.Player;
+using Robust.Shared.Graphics;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Viewport;
+
+public sealed partial class ScalingViewport
+{
+ [Dependency] private IMapManager _mapManager = default!;
+ [Dependency] private IEyeManager _eyeManager = default!;
+ [Dependency] private IPlayerManager _player = default!;
+ [Dependency] private ITileDefinitionManager _tile = default!;
+ [Dependency] private IPrototypeManager _prototypeManager = default!;
+
+ private CEClientZLevelsSystem? _zLevels;
+ private SharedMapSystem? _mapSystem;
+
+ private EntityQuery? _xformQuery;
+ private EntityQuery? _mapQuery;
+
+ private IEye? _fallbackEye;
+
+ ///
+ /// We are looking for at least one empty tile on the screen.
+ /// This is used to ensure that it makes sense to draw the z-planes and that they are visible.
+ ///
+ public bool TryFindEmptyTiles(EntityUid mapUid)
+ {
+ if (_xformQuery is null || !_xformQuery.Value.TryComp(mapUid, out var xform))
+ return true;
+
+ var drawBox = GetDrawBox();
+ var mapId = xform.MapID;
+
+ var corners = new[]
+ {
+ _eyeManager.ScreenToMap(drawBox.BottomLeft).Position,
+ _eyeManager.ScreenToMap(drawBox.BottomRight).Position,
+ _eyeManager.ScreenToMap(drawBox.TopLeft).Position,
+ _eyeManager.ScreenToMap(drawBox.TopRight).Position
+ };
+
+ float minX = float.MaxValue, minY = float.MaxValue;
+ float maxX = float.MinValue, maxY = float.MinValue;
+
+ foreach (var c in corners)
+ {
+ if (c.X < minX)
+ minX = c.X;
+ if (c.Y < minY)
+ minY = c.Y;
+ if (c.X > maxX)
+ maxX = c.X;
+ if (c.Y > maxY)
+ maxY = c.Y;
+ }
+
+ var mapCoordsBottomLeft = new MapCoordinates(new Vector2(minX, minY), mapId);
+ var mapCoordsTopRight = new MapCoordinates(new Vector2(maxX, maxY), mapId);
+
+ if (_mapSystem is null || !_mapManager.TryFindGridAt(mapUid, mapCoordsBottomLeft.Position, out var gridUid, out var grid))
+ return true;
+
+ var tileBottomLeft = _mapSystem.TileIndicesFor(gridUid, grid, mapCoordsBottomLeft);
+ var tileTopRight = _mapSystem.TileIndicesFor(gridUid, grid, mapCoordsTopRight);
+
+ for (var x = tileBottomLeft.X - 1; x <= tileTopRight.X + 1; x++)
+ {
+ for (var y = tileBottomLeft.Y - 1; y <= tileTopRight.Y + 1; y++)
+ {
+ var tile = _mapSystem.GetTileRef(gridUid, grid, new Vector2i(x, y));
+ var tileDef = (ContentTileDefinition)_tile[tile.Tile.TypeId];
+ if (tileDef.Transparent || tile.Tile.IsEmpty)
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private readonly List<(EntityUid MapUid, float Depth, bool AllowFov, bool Transit)> _zPasses = new();
+ private IClydeViewport? _transitViewport;
+ private ShaderInstance? _transitBlitShader;
+ private ShaderInstance? _cloudShader;
+
+ private void RenderZLevels(IRenderHandle renderHandle, IClydeViewport viewport)
+ {
+ if (_eye is null)
+ return;
+
+ _fallbackEye = _eye;
+
+ // Cache frequently accessed components/systems
+ _xformQuery ??= _entityManager.GetEntityQuery();
+ _mapQuery ??= _entityManager.GetEntityQuery();
+
+ // Cache systems and components
+ _zLevels ??= _entityManager.System();
+ _mapSystem ??= _entityManager.System();
+
+ if (_player.LocalEntity is null)
+ return;
+
+ if (!_entityManager.TryGetComponent(_player.LocalEntity.Value, out var zLevelViewer))
+ return;
+
+ if (!_xformQuery.Value.TryComp(_player.LocalEntity, out var playerXform))
+ return;
+
+ if (playerXform.MapUid is null)
+ return;
+
+ var playerMap = playerXform.MapUid.Value;
+
+ _zPasses.Clear();
+
+ var frac = 0f;
+ var ownDepth = 0f;
+ EntityUid? belowChainStart = null;
+ var belowChainStartDepth = -1f;
+ EntityUid? aboveMap = null;
+ var aboveDepth = 1f;
+
+ if (_entityManager.TryGetComponent(playerMap, out CEZTransitMapComponent? riderTransit))
+ {
+ frac = GetTransitProgress(riderTransit);
+ belowChainStart = riderTransit.LowerMap;
+ belowChainStartDepth = -frac;
+ aboveMap = riderTransit.UpperMap;
+ aboveDepth = 1f - frac;
+ }
+ else
+ {
+ frac = _zLevels.GetLocalAltitude(_player.LocalEntity.Value);
+ ownDepth = -frac;
+ belowChainStartDepth = -1f - frac;
+ aboveDepth = 1f - frac;
+
+ if (_zLevels.TryMapOffset(playerMap, -1, out var mapBelow))
+ belowChainStart = mapBelow.Owner;
+ if (_zLevels.TryMapUp(playerMap, out var mapAbove))
+ aboveMap = mapAbove.Owner;
+ }
+
+ if (TryFindEmptyTiles(playerMap) &&
+ !_entityManager.HasComponent(playerMap))
+ {
+ var current = belowChainStart;
+ var depthCursor = belowChainStartDepth;
+ for (var i = 0; i < CESharedZLevelsSystem.MaxZLevelsBelowRendering && current != null; i++)
+ {
+ _zPasses.Add((current.Value, depthCursor, false, false));
+
+ if (_entityManager.HasComponent(current.Value))
+ break; // clouds are very hard to see through
+
+ if (!TryFindEmptyTiles(current.Value))
+ break;
+
+ current = _zLevels.TryMapOffset(current.Value, -1, out var next) ? next.Owner : null;
+ depthCursor -= 1f;
+ }
+ }
+
+ // always render your own map
+ _zPasses.Add((playerMap, ownDepth, true, false));
+
+ if (riderTransit != null)
+ {
+ if (aboveMap != null && aboveDepth > 0.001f && TransitFade(aboveDepth) > 0.01f)
+ _zPasses.Add((aboveMap.Value, aboveDepth, false, true));
+ }
+ else if (zLevelViewer.LookUp && aboveMap != null)
+ {
+ _zPasses.Add((aboveMap.Value, aboveDepth, true, false));
+ }
+
+ // transit maps also render
+ var altitudeAnchor = riderTransit?.LowerMap ?? playerMap;
+ if (_entityManager.TryGetComponent(altitudeAnchor, out CEZMapComponent? anchorZ))
+ {
+ var observerAltitude = anchorZ.Depth + frac;
+ var hasObserverNetwork = _zLevels.TryGetMapNetwork(altitudeAnchor, out var observerNetwork);
+
+ var transitQuery = _entityManager.EntityQueryEnumerator();
+ while (transitQuery.MoveNext(out var transitUid, out var transit))
+ {
+ if (transitUid == playerMap || transit.LowerMap is not { } lowerMap)
+ continue;
+
+ if (!_entityManager.TryGetComponent(lowerMap, out CEZMapComponent? lowerZ))
+ continue;
+
+ if (hasObserverNetwork &&
+ (!_zLevels.TryGetMapNetwork(lowerMap, out var transitNetwork) ||
+ transitNetwork.Owner != observerNetwork.Owner))
+ {
+ continue;
+ }
+
+ var transitDepth = lowerZ.Depth + GetTransitProgress(transit) - observerAltitude;
+
+ // they're gone
+ if (transitDepth > 0f && TransitFade(transitDepth) <= 0.01f)
+ continue;
+
+ _zPasses.Add((transitUid, transitDepth, false, true));
+ }
+ }
+
+ // Painter's algorithm.
+ _zPasses.Sort(static (a, b) =>
+ {
+ var aUp = a.Depth > 0f;
+ var bUp = b.Depth > 0f;
+ if (aUp != bUp)
+ return aUp ? 1 : -1;
+ return aUp ? b.Depth.CompareTo(a.Depth) : a.Depth.CompareTo(b.Depth);
+ });
+
+ CEZCloudLayerComponent? riderDeck = null;
+ if (aboveMap != null &&
+ aboveDepth <= CloudFullCoverDepth &&
+ _entityManager.TryGetComponent(aboveMap.Value, out CEZCloudLayerComponent? riderDeckComp))
+ {
+ riderDeck = riderDeckComp;
+ }
+
+ var lowestDepth = float.MaxValue;
+ var highestDepth = float.MinValue;
+ foreach (var pass in _zPasses)
+ {
+ lowestDepth = Math.Min(lowestDepth, pass.Depth);
+ highestDepth = Math.Max(highestDepth, pass.Depth);
+ }
+ var first = true;
+
+ foreach (var (mapUid, depth, allowFov, isTransit) in _zPasses)
+ {
+ // A cloud layer at or below the observer draws an opaque deck beneath
+ // its own pass: deeper passes already rendered vanish under it, grids
+ // parked on the layer draw crisp on top of it.
+ CEZCloudLayerComponent? cloudDeck = null;
+ if (depth <= 0.001f && !isTransit)
+ _entityManager.TryGetComponent(mapUid, out cloudDeck);
+
+ if (mapUid == playerMap && depth == 0f)
+ {
+ viewport.Eye = _fallbackEye;
+ }
+ else
+ {
+ if (!_mapQuery.Value.TryComp(mapUid, out var mapComp))
+ continue;
+
+ Angle rotation = _fallbackEye.Rotation * -1;
+
+ var offset = rotation.ToWorldVec() * CEClientZLevelsSystem.ZLevelOffset * (depth - ownDepth);
+ var zScale = MathF.Pow(CESharedZLevelsSystem.ZLevelViewShrink, -depth);
+
+ var zEye = new ZEye(lowestDepth, depth, highestDepth)
+ {
+ Position = new MapCoordinates(_fallbackEye.Position.Position, mapComp.MapId),
+ // Not gated on depth >= 0: an airborne viewer's own map sits at a
+ // small negative depth but their walls still block sight.
+ DrawFov = _fallbackEye.DrawFov && allowFov,
+ DrawLight = _fallbackEye.DrawLight,
+ // A pass with a cloud deck never wants the skybox: the deck IS
+ // the backdrop, and parallax would paint over it.
+ DrawParallax = !isTransit && depth == lowestDepth && cloudDeck == null,
+ Offset = _fallbackEye.Offset + offset,
+ Rotation = _fallbackEye.Rotation,
+ Scale = _fallbackEye.Scale * zScale,
+ };
+
+ if (isTransit && depth > 0f)
+ {
+ RenderTransitOverhead(renderHandle, viewport, mapUid, zEye, depth);
+ continue;
+ }
+
+ viewport.Eye = zEye;
+ }
+
+
+ Color? wispColor = null;
+
+ if (riderDeck != null && mapUid == playerMap && !isTransit && depth == ownDepth)
+ {
+ DrawCloudDeck(renderHandle, viewport, riderDeck.CloudColor, 1f);
+ DrawCloudWisps(renderHandle, viewport, riderDeck.CloudColor);
+ first = false;
+ }
+
+
+ else if (cloudDeck != null)
+ {
+ DrawCloudDeck(renderHandle, viewport, cloudDeck.CloudColor, 1f);
+
+ if (mapUid == playerMap && depth == 0f)
+ DrawCloudWisps(renderHandle, viewport, cloudDeck.CloudColor);
+
+ else if (depth > -0.5f)
+ wispColor = cloudDeck.CloudColor;
+ first = false;
+ }
+
+ viewport.ClearColor = first ? Color.Black : null;
+ first = false;
+ viewport.Render();
+
+ if (wispColor != null)
+ DrawCloudWisps(renderHandle, viewport, wispColor.Value);
+ }
+
+ if (aboveMap != null &&
+ _entityManager.TryGetComponent(aboveMap.Value, out CEZCloudLayerComponent? cloudAbove))
+ {
+ var coverage = CloudCoverage(aboveDepth);
+ if (coverage > 0.001f)
+ DrawCloudDeck(renderHandle, viewport, cloudAbove.CloudColor, coverage);
+ }
+
+ // Restore the Eye
+ Eye = _fallbackEye;
+ viewport.Eye = Eye;
+ }
+
+ private void RenderTransitOverhead(IRenderHandle renderHandle,
+ IClydeViewport viewport,
+ EntityUid transitMap,
+ ZEye zEye,
+ float depth)
+ {
+ if (_transitViewport == null || _transitViewport.Size != viewport.Size)
+ {
+ _transitViewport?.Dispose();
+ _transitViewport = _clyde.CreateViewport(viewport.Size, nameof(_transitViewport));
+ _transitViewport.RenderScale = viewport.RenderScale;
+ }
+
+ _transitBlitShader ??= _prototypeManager.Index("CEZBlurBlit").InstanceUnique();
+
+ zEye.DrawParallax = false;
+
+ _transitViewport.Eye = zEye;
+ // "Why aren't you using Color.Transparent" because it's LIES it is entirely white
+ _transitViewport.ClearColor = new Color(0f, 0f, 0f, 0f);
+ _transitViewport.Render();
+
+ var hazeColor = new Vector3(0, 0, 1);
+ if (_entityManager.TryGetComponent(transitMap, out MapLightComponent? mapLight))
+ {
+ hazeColor = new Vector3(
+ mapLight.AmbientLightColor.R,
+ mapLight.AmbientLightColor.G,
+ mapLight.AmbientLightColor.B);
+ }
+
+ var strength = Math.Clamp(depth, 0f, 1f);
+
+ var cloud = 0f;
+ var cloudColor = Vector3.One;
+ if (_entityManager.TryGetComponent(transitMap, out CEZTransitMapComponent? transit) &&
+ transit.UpperMap is { } upper &&
+ _entityManager.TryGetComponent(upper, out CEZCloudLayerComponent? cloudLayer))
+ {
+ cloud = CloudCoverage(1f - GetTransitProgress(transit));
+ cloudColor = new Vector3(cloudLayer.CloudColor.R, cloudLayer.CloudColor.G, cloudLayer.CloudColor.B);
+ }
+
+ var screenHandle = renderHandle.DrawingHandleScreen;
+ screenHandle.RenderInRenderTarget(viewport.RenderTarget, () =>
+ {
+ var texture = _transitViewport.RenderTarget.Texture;
+
+ _transitBlitShader.SetParameter("BLUR_COLOR", hazeColor);
+ _transitBlitShader.SetParameter("STRENGTH", strength);
+ _transitBlitShader.SetParameter("CLOUD_COLOR", cloudColor);
+ _transitBlitShader.SetParameter("CLOUD", cloud);
+ _transitBlitShader.SetParameter("FADE", TransitFade(depth));
+
+ screenHandle.UseShader(_transitBlitShader);
+ screenHandle.DrawTextureRect(texture, new UIBox2(Vector2.Zero, texture.Size));
+ screenHandle.UseShader(null);
+ }, null);
+ }
+
+ ///
+ /// How many z-levels of climb it takes for a transiting ship seen from below to
+ /// fully dissolve into the sky.
+ ///
+ public const float TransitFadeDepth = 0.8f;
+
+ private static float TransitFade(float depth)
+ {
+ return Math.Clamp(1f - depth / TransitFadeDepth, 0f, 1f);
+ }
+
+ public const float CloudFullCoverDepth = 0.25f;
+
+ private const float CloudBreakthroughBand = 0.1f;
+
+ ///
+ /// Cloud coverage over a grid by its depth below a cloud layer
+ ///
+ private static float CloudCoverage(float depthBelowLayer)
+ {
+ if (depthBelowLayer <= 0f)
+ return 0f;
+
+ if (depthBelowLayer >= CloudFullCoverDepth)
+ return Math.Clamp((1f - depthBelowLayer) / (1f - CloudFullCoverDepth), 0f, 1f);
+
+ return Math.Clamp(
+ (depthBelowLayer - (CloudFullCoverDepth - CloudBreakthroughBand)) / CloudBreakthroughBand,
+ 0f,
+ 1f);
+ }
+
+ ///
+ /// Draw clouds.
+ ///
+ private void DrawCloudDeck(IRenderHandle renderHandle, IClydeViewport viewport, Color color, float coverage)
+ {
+ _cloudShader ??= _prototypeManager.Index("CEZClouds").InstanceUnique();
+
+ var screenHandle = renderHandle.DrawingHandleScreen;
+ screenHandle.RenderInRenderTarget(viewport.RenderTarget, () =>
+ {
+ _cloudShader.SetParameter("CLOUD_COLOR", new Vector3(color.R, color.G, color.B));
+ _cloudShader.SetParameter("COVERAGE", coverage);
+ _cloudShader.SetParameter("WISP", 0f);
+
+ screenHandle.UseShader(_cloudShader);
+ screenHandle.DrawRect(new UIBox2(Vector2.Zero, viewport.RenderTarget.Texture.Size), Color.White);
+ screenHandle.UseShader(null);
+ }, null);
+ }
+
+ private void DrawCloudWisps(IRenderHandle renderHandle, IClydeViewport viewport, Color color)
+ {
+ _cloudShader ??= _prototypeManager.Index("CEZClouds").InstanceUnique();
+
+ var screenHandle = renderHandle.DrawingHandleScreen;
+ screenHandle.RenderInRenderTarget(viewport.RenderTarget, () =>
+ {
+ _cloudShader.SetParameter("CLOUD_COLOR", new Vector3(color.R, color.G, color.B));
+ _cloudShader.SetParameter("COVERAGE", 0f);
+ _cloudShader.SetParameter("WISP", 0.85f);
+
+ screenHandle.UseShader(_cloudShader);
+ screenHandle.DrawRect(new UIBox2(Vector2.Zero, viewport.RenderTarget.Texture.Size), Color.White);
+ screenHandle.UseShader(null);
+ }, null);
+ }
+
+ private float GetTransitProgress(CEZTransitMapComponent transit)
+ {
+ if (transit.PrimaryGrid is { } grid &&
+ _entityManager.TryGetComponent(grid, out CEZPhysicsComponent? zPhys))
+ {
+ return Math.Clamp(zPhys.LocalPosition, 0f, 1f);
+ }
+
+ return 0f;
+ }
+
+ public sealed class ZEye(float lowest, float depth, float high) : Robust.Shared.Graphics.Eye
+ {
+ public float LowestDepth = lowest;
+ public float Depth = depth;
+ public float HighestDepth = high;
+
+ ///
+ /// whether parallax draws (only used on the actual bottom layer of a z stack so transit doesnt explode time)
+ ///
+ public bool DrawParallax = true;
+ }
+}
diff --git a/Content.Client/_CE/ZLevels/Ghost/CEClientZLevelGhostMoverSystem.cs b/Content.Client/_CE/ZLevels/Ghost/CEClientZLevelGhostMoverSystem.cs
new file mode 100644
index 00000000000..0ea50e4d56c
--- /dev/null
+++ b/Content.Client/_CE/ZLevels/Ghost/CEClientZLevelGhostMoverSystem.cs
@@ -0,0 +1,10 @@
+/*
+ * This file is sublicensed under MIT License
+ * https://github.com/space-wizards/space-station-14/blob/master/LICENSE.TXT
+ */
+
+using Content.Shared._CE.ZLevels.Ghost;
+
+namespace Content.Client._CE.ZLevels.Ghost;
+
+public sealed class CEClientZLevelGhostMoverSystem : CESharedZLevelGhostMoverSystem;
diff --git a/Content.Client/_CE/ZLevels/Roof/CEClientZLevelsRoofSystem.cs b/Content.Client/_CE/ZLevels/Roof/CEClientZLevelsRoofSystem.cs
new file mode 100644
index 00000000000..487bab9b3fb
--- /dev/null
+++ b/Content.Client/_CE/ZLevels/Roof/CEClientZLevelsRoofSystem.cs
@@ -0,0 +1,10 @@
+/*
+ * This file is sublicensed under MIT License
+ * https://github.com/space-wizards/space-station-14/blob/master/LICENSE.TXT
+ */
+
+using Content.Shared._CE.ZLevels.Roof;
+
+namespace Content.Client._CE.ZLevels.Roof;
+
+public sealed class CEClientZLevelsRoofSystem : CESharedZLevelsRoofSystem;
diff --git a/Content.Client/_Goobstation/Wizard/Trail/TrailOverlay.cs b/Content.Client/_Goobstation/Wizard/Trail/TrailOverlay.cs
new file mode 100644
index 00000000000..afbd91c6b3e
--- /dev/null
+++ b/Content.Client/_Goobstation/Wizard/Trail/TrailOverlay.cs
@@ -0,0 +1,187 @@
+using System.Numerics;
+using Content.Shared._Goobstation.Wizard.Projectiles;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Shared.Enums;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using DrawDepth = Content.Shared.DrawDepth.DrawDepth;
+
+namespace Content.Client._Goobstation.Wizard.Trail;
+
+public sealed class TrailOverlay : Overlay
+{
+ public override OverlaySpace Space => OverlaySpace.WorldSpaceEntities;
+
+ private readonly IEntityManager _entManager;
+ private readonly IPrototypeManager _protoMan;
+ private readonly IGameTiming _timing;
+
+ private readonly SpriteSystem _sprite;
+ private readonly TransformSystem _transform;
+
+ public TrailOverlay(IEntityManager entManager, IPrototypeManager protoMan, IGameTiming timing)
+ {
+ ZIndex = (int) DrawDepth.Effects;
+
+ _entManager = entManager;
+ _protoMan = protoMan;
+ _timing = timing;
+ _sprite = _entManager.System();
+ _transform = _entManager.System();
+ }
+
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ var eye = args.Viewport.Eye;
+
+ if (eye == null)
+ return;
+
+ var eyeRot = eye.Rotation;
+ var handle = args.WorldHandle;
+ var bounds = args.WorldAABB;
+
+ var xformQuery = _entManager.GetEntityQuery();
+ var spriteQuery = _entManager.GetEntityQuery();
+
+ var query = _entManager.EntityQueryEnumerator();
+ while (query.MoveNext(out _, out var trail, out var xform))
+ {
+ if (trail.TrailData.Count == 0)
+ continue;
+
+ var (position, rotation) = _transform.GetWorldPositionRotation(xform, xformQuery);
+
+ if (trail.Shader != null && _protoMan.TryIndex(trail.Shader, out var shaderProto))
+ {
+ var shader = shaderProto.InstanceUnique();
+ foreach (var (key, data) in trail.ShaderData)
+ {
+ switch (data)
+ {
+ case GetShaderLocalPositionData:
+ shader.SetParameter(key, args.Viewport.WorldToLocal(position));
+ break;
+ case GetShaderFloatParam f:
+ if (float.TryParse(f.Param, out var fValue))
+ shader.SetParameter(key, fValue);
+ break;
+ }
+ }
+ handle.UseShader(shader);
+ }
+ else
+ handle.UseShader(null);
+
+ if (trail.RenderedEntity != null)
+ {
+ Direction? direction = null;
+ var rot = rotation;
+ if (trail.RenderedEntityRotationStrategy == RenderedEntityRotationStrategy.Trail)
+ {
+ var dirRot = rotation + eyeRot;
+ direction = dirRot.GetCardinalDir();
+ }
+ else if (trail.RenderedEntityRotationStrategy == RenderedEntityRotationStrategy.RenderedEntity)
+ rot = _transform.GetWorldRotation(trail.RenderedEntity.Value);
+
+ if (spriteQuery.TryComp(trail.RenderedEntity.Value, out var sprite))
+ {
+ handle.SetTransform(Matrix3x2.Identity);
+ foreach (var data in trail.TrailData)
+ {
+ if (data.Color.A <= 0.01f || data.Scale <= 0.01f || data.MapId != args.MapId)
+ continue;
+
+ var worldPosition = data.Position;
+ if (!bounds.Contains(worldPosition))
+ continue;
+
+ if (trail.RenderedEntityRotationStrategy == RenderedEntityRotationStrategy.Particle)
+ {
+ rot = data.Angle;
+ direction = (rot + eyeRot).GetCardinalDir();
+ }
+
+ var originalColor = sprite.Color;
+ var originalScale = sprite.Scale;
+ sprite.Color = data.Color;
+ sprite.Scale *= data.Scale;
+ sprite.Render(handle, eyeRot, rot, direction, worldPosition);
+ sprite.Color = originalColor;
+ sprite.Scale = originalScale;
+ }
+ }
+ continue;
+ }
+
+ if (trail.Sprite == null)
+ {
+ handle.SetTransform(Matrix3x2.Identity);
+ if (xform.MapID == args.MapId)
+ {
+ var start = trail.TrailData[^1].Position;
+ DrawTrailLine(start, position, trail.Color, trail.Scale, bounds, handle);
+ }
+
+ for (var i = 1; i < trail.TrailData.Count; i++)
+ {
+ var data = trail.TrailData[i];
+ var prevData = trail.TrailData[i - 1];
+
+ if (data.MapId == args.MapId && prevData.MapId == args.MapId)
+ DrawTrailLine(prevData.Position, data.Position, data.Color, data.Scale, bounds, handle);
+ }
+
+ continue;
+ }
+
+ var textureSize = _sprite.Frame0(trail.Sprite).Size;
+ var pos = -(Vector2) textureSize / 2f / EyeManager.PixelsPerMeter;
+ foreach (var data in trail.TrailData)
+ {
+ if (data.Color.A <= 0.01f || data.Scale <= 0.01f || data.MapId != args.MapId)
+ continue;
+
+ var worldPosition = data.Position;
+ if (!bounds.Contains(worldPosition))
+ continue;
+
+ var scaleMatrix = Matrix3x2.CreateScale(new Vector2(data.Scale, data.Scale));
+ var worldMatrix = Matrix3Helpers.CreateTranslation(worldPosition);
+
+ var time = _timing.CurTime > data.SpawnTime ? _timing.CurTime - data.SpawnTime : TimeSpan.Zero;
+ var texture = _sprite.GetFrame(trail.Sprite, time);
+
+ handle.SetTransform(Matrix3x2.Multiply(scaleMatrix, worldMatrix));
+ handle.DrawTexture(texture, pos, data.Angle, data.Color);
+ }
+ }
+
+ handle.UseShader(null);
+ handle.SetTransform(Matrix3x2.Identity);
+ }
+
+ private void DrawTrailLine(Vector2 start,
+ Vector2 end,
+ Color color,
+ float scale,
+ Box2 bounds,
+ DrawingHandleWorld handle)
+ {
+ if (color.A <= 0.01f || scale <= 0.01f)
+ return;
+
+ if (!bounds.Contains(start) || !bounds.Contains(end))
+ return;
+
+ var halfScale = scale * 0.5f;
+ var direction = end - start;
+ var angle = direction.ToAngle();
+ var box = new Box2(start - new Vector2(0f, halfScale),
+ start + new Vector2(direction.Length(), halfScale));
+ var boxRotated = new Box2Rotated(box, angle, start);
+ handle.DrawRect(boxRotated, color);
+ }
+}
diff --git a/Content.Client/_Goobstation/Wizard/Trail/TrailSystem.cs b/Content.Client/_Goobstation/Wizard/Trail/TrailSystem.cs
new file mode 100644
index 00000000000..33f04e48822
--- /dev/null
+++ b/Content.Client/_Goobstation/Wizard/Trail/TrailSystem.cs
@@ -0,0 +1,307 @@
+using System.Linq;
+using System.Numerics;
+using Content.Shared._Goobstation.Wizard.Projectiles;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Shared.Animations;
+using Robust.Shared.Map;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Spawners;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Client._Goobstation.Wizard.Trail;
+
+public sealed class TrailSystem : EntitySystem
+{
+ [Dependency] private readonly IOverlayManager _overlay = default!;
+ [Dependency] private readonly IEyeManager _eye = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IPrototypeManager _protoMan = default!;
+ [Dependency] private readonly TransformSystem _transform = default!;
+
+ private EntityQuery _xformQuery;
+ private EntityQuery _physicsQuery;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ _overlay.AddOverlay(new TrailOverlay(EntityManager, _protoMan, _timing));
+
+ SubscribeLocalEvent(OnRemove);
+ SubscribeLocalEvent(OnStartup);
+
+ _xformQuery = GetEntityQuery();
+ _physicsQuery = GetEntityQuery();
+
+ UpdatesOutsidePrediction = true;
+ }
+
+ private void OnStartup(Entity ent, ref ComponentStartup args)
+ {
+ ent.Comp.Accumulator = ent.Comp.Frequency;
+ ent.Comp.LerpAccumulator = ent.Comp.LerpTime;
+ }
+
+ private void OnRemove(Entity ent, ref ComponentRemove args)
+ {
+ var (_, comp) = ent;
+
+ if (!comp.SpawnRemainingTrail || comp.TrailData.Count == 0 || comp.Frequency <= 0f || comp.Lifetime <= 0f)
+ return;
+
+ if (comp.LastCoords.MapId != _eye.CurrentEye.Position.MapId)
+ return;
+
+ if (comp.RenderedEntity != null && TerminatingOrDeleted(comp.RenderedEntity.Value))
+ return;
+
+ var remainingTrail = Spawn(null, comp.LastCoords);
+ EnsureComp(remainingTrail).Lifetime = comp.Lifetime;
+ var trail = EnsureComp(remainingTrail);
+ trail.SpawnRemainingTrail = false;
+ trail.Frequency = 0f;
+ trail.Lifetime = comp.Lifetime;
+ trail.AlphaLerpAmount = comp.AlphaLerpAmount;
+ trail.ScaleLerpAmount = comp.ScaleLerpAmount;
+ trail.VelocityLerpAmount = comp.VelocityLerpAmount;
+ trail.PositionLerpAmount = comp.PositionLerpAmount;
+ trail.AlphaLerpTarget = comp.AlphaLerpTarget;
+ trail.ScaleLerpTarget = comp.ScaleLerpTarget;
+ trail.Sprite = comp.Sprite;
+ trail.Color = comp.Color;
+ trail.Scale = comp.Scale;
+ trail.TrailData = comp.TrailData;
+ trail.Shader = comp.Shader;
+ trail.ParticleAmount = comp.ParticleAmount;
+ trail.StartAngle = comp.StartAngle;
+ trail.EndAngle = comp.EndAngle;
+ trail.LerpTime = comp.LerpTime;
+ trail.LerpAccumulator = comp.LerpAccumulator;
+ trail.RenderedEntity = comp.RenderedEntity;
+ trail.Velocity = comp.Velocity;
+ trail.Radius = comp.Radius;
+ trail.MaxParticleAmount = comp.MaxParticleAmount;
+ trail.ParticleCount = comp.ParticleCount;
+ trail.SpawnPosition = comp.SpawnPosition;
+ trail.SpawnEntityPosition = comp.SpawnEntityPosition;
+ trail.RenderedEntityRotationStrategy = comp.RenderedEntityRotationStrategy;
+ trail.AdditionalLerpData = comp.AdditionalLerpData;
+ trail.TrailData.Sort((x, y) => x.SpawnTime.CompareTo(y.SpawnTime));
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ _overlay.RemoveOverlay();
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ if (!_timing.IsFirstTimePredicted)
+ return;
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var trail, out var xform))
+ {
+ if (trail.Lifetime <= 0f)
+ continue;
+
+ var (position, rotation) = _transform.GetWorldPositionRotation(xform, _xformQuery);
+ trail.LastCoords = new MapCoordinates(position, xform.MapID);
+
+ Lerp(trail, position, frameTime);
+
+ trail.Accumulator += frameTime;
+
+ // Assuming that lifetime and frequency don't change
+ if (trail.Accumulator > trail.Lifetime && trail.Lifetime < trail.Frequency && trail.TrailData.Count > 0)
+ trail.TrailData.Clear();
+
+ if (trail.Frequency <= 0f || trail.ParticleAmount < 1 ||
+ trail.MaxParticleAmount > 0 && trail.ParticleCount >= trail.MaxParticleAmount)
+ {
+ if (trail.Accumulator <= trail.Lifetime)
+ continue;
+
+ trail.Accumulator = 0f;
+
+ for (var i = 0; i < Math.Max(1, trail.ParticleAmount); i++)
+ {
+ if (trail.TrailData.Count == 0)
+ {
+ if (IsClientSide(uid) && trail.Frequency <= 0f)
+ QueueDel(uid);
+ break;
+ }
+
+ trail.TrailData.RemoveAt(0);
+ }
+
+ continue;
+ }
+
+ if (trail.Accumulator <= trail.Frequency)
+ continue;
+
+ trail.Accumulator = 0f;
+
+ if (trail.SpawnEntityPosition != null && !Exists(trail.SpawnEntityPosition.Value))
+ continue;
+
+ Angle angle;
+ if (_physicsQuery.TryComp(uid, out var physics) && physics.LinearVelocity.LengthSquared() > 0)
+ angle = physics.LinearVelocity.ToAngle();
+ else
+ angle = xform.LocalRotation;
+
+ var start = trail.StartAngle + angle;
+ var end = trail.EndAngle + angle;
+
+ // It would break if we try to do this with line based trails
+ if (trail.ParticleAmount == 1 || trail is { ParticleAmount: > 1, RenderedEntity: null, Sprite: null })
+ {
+ var direction = new Angle((end.Theta + start.Theta) * 0.5).ToVec();
+ SpawnParticle(trail, position, rotation, direction, xform.MapID);
+ continue;
+ }
+
+ if (trail.ParticleAmount < 1) // Impossible
+ continue;
+
+ var angles = LinearSpread(start, end, trail.ParticleAmount);
+ for (var i = 0; i < trail.ParticleAmount; i++)
+ {
+ SpawnParticle(trail, position, rotation, angles[i].ToVec(), xform.MapID);
+ if (trail.MaxParticleAmount > 0 && trail.ParticleCount >= trail.MaxParticleAmount)
+ break;
+ }
+ }
+ }
+
+ private Angle[] LinearSpread(Angle start, Angle end, int intervals)
+ {
+ DebugTools.Assert(intervals > 1);
+ var angles = new Angle[intervals];
+
+ for (var i = 0; i <= intervals - 1; i++)
+ {
+ angles[i] = new Angle(start + (end - start) * i / (intervals - 1));
+ }
+
+ return angles;
+ }
+
+ private void SpawnParticle(TrailComponent trail, Vector2 position, Angle rotation, Vector2 direction, MapId mapId)
+ {
+ DebugTools.Assert(trail is { ParticleAmount: > 0, Frequency: > 0f });
+ trail.ParticleCount++;
+
+ if (trail.SpawnEntityPosition != null && Exists(trail.SpawnEntityPosition.Value))
+ {
+ position = _transform.GetWorldPosition(trail.SpawnEntityPosition.Value, _xformQuery);
+ if (trail.SpawnPosition != null)
+ position += trail.SpawnPosition.Value;
+ }
+ else if (trail.SpawnPosition != null)
+ position = trail.SpawnPosition.Value;
+
+ var targetPos = position + direction * trail.Radius;
+ if (trail.TrailData.Count <
+ MathF.Max(trail.ParticleAmount, trail.ParticleAmount * trail.Lifetime / trail.Frequency))
+ {
+ trail.TrailData.Add(new TrailData(targetPos,
+ trail.Velocity,
+ mapId,
+ direction,
+ rotation,
+ trail.Color,
+ trail.Scale,
+ _timing.CurTime));
+ }
+ else if (trail.TrailData.Count > 0)
+ {
+ if (trail.CurIndex >= trail.TrailData.Count || trail.Sprite == null)
+ trail.CurIndex = 0;
+
+ var data = trail.TrailData[trail.CurIndex];
+
+ data.Color = trail.Color;
+ data.Position = targetPos;
+ data.Velocity = trail.Velocity;
+ data.MapId = mapId;
+ data.Direction = direction;
+ data.Angle = rotation;
+ data.Scale = trail.Scale;
+ data.SpawnTime = _timing.CurTime;
+
+ if (trail.Sprite == null)
+ {
+ if (trail is
+ {
+ AlphaLerpAmount: <= 0f, ScaleLerpAmount: <= 0f, VelocityLerpAmount: <= 0f, Velocity: 0f,
+ PositionLerpAmount: <= 0f,
+ })
+ return;
+
+ trail.TrailData.RemoveAt(0);
+ trail.TrailData.Add(data);
+ }
+ else
+ trail.CurIndex++;
+ }
+ }
+
+ private void Lerp(TrailComponent trail, Vector2 position, float frameTime)
+ {
+ if (trail is
+ {
+ AlphaLerpAmount: <= 0f, ScaleLerpAmount: <= 0f, Velocity: 0f, VelocityLerpAmount: <= 0f,
+ PositionLerpAmount: <= 0f,
+ })
+ return;
+
+ trail.LerpAccumulator += frameTime;
+
+ if (trail.LerpAccumulator <= trail.LerpTime)
+ return;
+
+ trail.LerpAccumulator = 0;
+
+ foreach (var data in trail.TrailData)
+ {
+ if (trail.LerpDelay > _timing.CurTime - data.SpawnTime)
+ return;
+
+ if (trail.AlphaLerpAmount > 0f)
+ {
+ var alphaTarget = trail.AlphaLerpTarget is >= 0f and <= 1f ? trail.AlphaLerpTarget : 0f;
+ data.Color.A = float.Lerp(data.Color.A, alphaTarget, trail.AlphaLerpAmount);
+ }
+
+ if (trail.ScaleLerpAmount > 0f)
+ {
+ var scaleTarget = trail.ScaleLerpTarget >= 0f ? trail.ScaleLerpTarget : 0f;
+ data.Scale = float.Lerp(data.Scale, scaleTarget, trail.ScaleLerpAmount);
+ }
+
+ data.Position += data.Direction * data.Velocity;
+
+ if (trail.PositionLerpAmount > 0f)
+ data.Position = Vector2.Lerp(data.Position, position, trail.PositionLerpAmount);
+
+ if (trail.VelocityLerpAmount > 0f)
+ data.Velocity = float.Lerp(data.Velocity, trail.VelocityLerpTarget, trail.VelocityLerpAmount);
+ }
+
+ foreach (var lerpData in trail.AdditionalLerpData.Where(x => x.LerpAmount > 0f))
+ {
+ lerpData.Value = float.Lerp(lerpData.Value, lerpData.LerpTarget, lerpData.LerpAmount);
+
+ AnimationHelper.SetAnimatableProperty(trail, lerpData.Property, lerpData.Value);
+ }
+ }
+}
diff --git a/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderBoundUserInterface.cs b/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderBoundUserInterface.cs
new file mode 100644
index 00000000000..e610fe276cf
--- /dev/null
+++ b/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderBoundUserInterface.cs
@@ -0,0 +1,36 @@
+// LuaCorp - This file is licensed under AGPLv3
+// Copyright (c) 2026 LuaCorp Contributors
+// See AGPLv3.txt for details.
+
+using Content.Shared._Lua.AmmoLoader;
+using JetBrains.Annotations;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._Lua.AmmoLoader.UI;
+
+[UsedImplicitly]
+public sealed class AmmoLoaderBoundUserInterface : BoundUserInterface
+{
+ private AmmoLoaderWindow? _window;
+
+ public AmmoLoaderBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow();
+ _window.OnUnloadOne += (protoId, emptyOnly) => SendMessage(new AmmoLoaderUnloadOneMessage(protoId, emptyOnly));
+ _window.OnLoadTurret += (turret, protoId) => SendMessage(new AmmoLoaderLoadTurretMessage(turret, protoId));
+ _window.OnUnloadTurret += turret => SendMessage(new AmmoLoaderUnloadTurretMessage(turret));
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (state is AmmoLoaderBoundUserInterfaceState ammoState) _window?.UpdateState(ammoState);
+ }
+}
diff --git a/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderLocale.cs b/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderLocale.cs
new file mode 100644
index 00000000000..1ad074f6544
--- /dev/null
+++ b/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderLocale.cs
@@ -0,0 +1,82 @@
+// LuaCorp - This file is licensed under AGPLv3
+// Copyright (c) 2026 LuaCorp Contributors
+// See AGPLv3.txt for details.
+
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+
+namespace Content.Client._Lua.AmmoLoader.UI;
+
+public static class AmmoLoaderLocale
+{
+ private const string TypeAttr = "ammo-loader-type";
+ private const string CaliberAttr = "ammo-loader-caliber";
+ private const string WeightAttr = "ammo-loader-weight";
+
+ private static ILocalizationManager Localization => IoCManager.Resolve();
+
+ public static string GetAmmoType(string prototypeId)
+ {
+ if (TryGetEntityAttribute(prototypeId, TypeAttr, out var value)) return value;
+ return Loc.GetString("ammo-loader-item-type-default");
+ }
+
+ public static string GetAmmoCaliber(string prototypeId, string fallbackName)
+ {
+ if (TryGetEntityAttribute(prototypeId, CaliberAttr, out var value)) return value;
+ return fallbackName;
+ }
+
+ public static string GetAmmoWeight(string prototypeId)
+ {
+ if (TryGetEntityAttribute(prototypeId, WeightAttr, out var value)) return value;
+ return Loc.GetString("ammo-loader-item-weight-unknown");
+ }
+ // Mono start
+ private static string CompactAmmoLabel(string label)//shit code, mb any idea?
+ {
+ label = RemoveToken(label, " ammo loader");
+ label = RemoveToken(label, " ammo box");
+ label = RemoveToken(label, " magazine");
+ label = RemoveToken(label, " box");
+ label = RemovePrefix(label, "M381 CHARON "); // He gets a special pass, those slug names are too long.
+ label = RemovePrefix(label, "cartridge (");
+ label = RemoveToken(label, ")");
+ label = RemoveToken(label, " shell");
+ label = RemoveToken(label, " torpedo");
+ label = RemoveToken(label, " missile");
+ label = RemoveToken(label, " cruise missile");
+ return label.Trim();
+ }
+ private static string RemovePrefix(string value, string prefix)
+ { return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? value[prefix.Length..] : value; }
+ private static string RemoveToken(string value, string token)
+ {
+ var index = value.IndexOf(token, StringComparison.OrdinalIgnoreCase);
+ return index >= 0 ? value.Remove(index, token.Length) : value;
+ }
+ // Mono end
+
+ public static string FormatItemStats(string prototypeId, string displayName)
+ {
+ var type = GetAmmoType(prototypeId);
+ var caliber = CompactAmmoLabel(GetAmmoCaliber(prototypeId, displayName)); // Mono
+ var weight = GetAmmoWeight(prototypeId);
+ var typeLine = Loc.GetString("ammo-loader-item-stats-type", ("value", type));
+ var caliberLine = Loc.GetString("ammo-loader-item-stats-caliber", ("value", caliber));
+ var weightLine = Loc.GetString("ammo-loader-item-stats-weight", ("value", weight));
+ // return typeLine + '\n' + caliberLine + '\n' + weightLine; // Mono - TODO make this not need locales to work then uncomment it
+ return caliberLine; // Mono
+ }
+
+ private static bool TryGetEntityAttribute(string prototypeId, string attribute, out string value)
+ {
+ if (Localization.GetEntityData(prototypeId).Attributes.TryGetValue(attribute, out var attrValue) && !string.IsNullOrWhiteSpace(attrValue))
+ {
+ value = attrValue;
+ return true;
+ }
+ value = string.Empty;
+ return false;
+ }
+}
diff --git a/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderWindow.xaml b/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderWindow.xaml
new file mode 100644
index 00000000000..c4fd525a420
--- /dev/null
+++ b/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderWindow.xaml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderWindow.xaml.cs b/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderWindow.xaml.cs
new file mode 100644
index 00000000000..ec95336c004
--- /dev/null
+++ b/Content.Client/_Lua/AmmoLoader/UI/AmmoLoaderWindow.xaml.cs
@@ -0,0 +1,905 @@
+// LuaCorp - This file is licensed under AGPLv3
+// Copyright (c) 2026 LuaCorp Contributors
+// See AGPLv3.txt for details.
+
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using Content.Client.Interaction;
+using Content.Client.Stylesheets;
+using Content.Client.UserInterface.Controls;
+using Content.Shared._Lua.AmmoLoader;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.Input;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Input;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using Key = Robust.Client.Input.Keyboard.Key;
+
+namespace Content.Client._Lua.AmmoLoader.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class AmmoLoaderWindow : FancyWindow
+{
+ private enum InfoMode : byte
+ {
+ None,
+ StorageAmmo,
+ Turret,
+ TurretLoadedAmmo,
+ }
+
+ private static readonly Color PanelBg = Color.FromHex("#111820");
+ private static readonly Color SlotBg = Color.FromHex("#11171e");
+ private static readonly Color SlotBorder = Color.FromHex("#2c3846");
+ private static readonly Color SlotSelectedBorder = Color.FromHex("#7ec8e3");
+ private static readonly Color Accent = Color.FromHex("#7ec8e3");
+ private static readonly Color DropHighlight = Color.FromHex("#1c3540");
+ private static readonly Vector2 SlotSize = new(108, 126);
+ private static readonly Vector2 QuickSlotSize = new(48, 48);
+ private static readonly Vector2 TurretSlotSize = new(48, 48);
+ private const int QuickUnloadSlotCount = 8;
+ private const int StorageColumns = 5;
+ private const int DefaultTurretColumns = 16;
+ [Dependency] private readonly IPrototypeManager _prototypes = default!;
+ [Dependency] private readonly IInputManager _input = default!;
+ private readonly List _groups = new();
+ private readonly List _linkedTurrets = new();
+ private readonly Dictionary _slotPanels = new();
+ private readonly Dictionary _slotTitles = new();
+ private readonly Dictionary _turretPanels = new();
+ private readonly List _quickSlots = new();
+ private readonly List _quickIcons = new();
+ private readonly string?[] _quickQueue = new string?[QuickUnloadSlotCount];
+ private readonly DragDropHelper _dragHelper;
+ private readonly EntityPrototypeView _dragShadow;
+ private readonly PanelContainer _turretLoadedBg;
+ private readonly EntityPrototypeView _turretLoadedIcon;
+ private EntProtoId? _selectedAmmo;
+ private bool _selectedEmpty;
+ private NetEntity? _selectedTurret;
+ private InfoMode _infoMode = InfoMode.None;
+ private int _maxConnections = DefaultTurretColumns;
+ private bool _dragMouseHeld;
+ private bool _dragFromQuick;
+ private bool _dragFromTurretSlot;
+ private bool _dragFromEmpty;
+ private int _dragQuickSlot = -1;
+ public event Action? OnUnloadOne;
+ public event Action? OnLoadTurret;
+ public event Action? OnUnloadTurret;
+
+ public AmmoLoaderWindow()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+ AmmoGrid.Columns = StorageColumns;
+ TurretGrid.Columns = DefaultTurretColumns;
+ _dragHelper = new DragDropHelper(OnBeginDrag, OnContinueDrag, OnEndDrag);
+ _dragShadow = new EntityPrototypeView
+ {
+ SetSize = new Vector2(48, 48),
+ Visible = false,
+ MouseFilter = MouseFilterMode.Ignore,
+ };
+ ConfigurePrototypeIcon(_dragShadow);
+ EjectAllButton.OnPressed += _ => EjectQuickUnloadQueue();
+ UnloadOneButton.OnPressed += _ => OnUnloadPressed();
+ ConfigurePrototypeIcon(SelectedIcon);
+ LayoutContainer.SetAnchorAndMarginPreset(SelectedIcon, LayoutContainer.LayoutPreset.Wide, margin: 34);
+ LayoutContainer.SetAnchorAndMarginPreset(SelectedNameMarquee, LayoutContainer.LayoutPreset.TopWide, margin: 0);
+ LayoutContainer.SetAnchorAndMarginPreset(SelectedCountLabel, LayoutContainer.LayoutPreset.BottomWide, margin: 8);
+ SelectedNameMarquee.FontColorOverride = Accent;
+ _turretLoadedBg = new PanelContainer
+ {
+ HorizontalExpand = true,
+ VerticalExpand = true,
+ MouseFilter = MouseFilterMode.Ignore,
+ PanelOverride = MakeSlotStyle(false),
+ };
+ TurretLoadedSlot.AddChild(_turretLoadedBg);
+ LayoutContainer.SetAnchorPreset(_turretLoadedBg, LayoutContainer.LayoutPreset.Wide);
+ _turretLoadedIcon = new EntityPrototypeView
+ {
+ MouseFilter = MouseFilterMode.Ignore,
+ Visible = false,
+ };
+ ConfigurePrototypeIcon(_turretLoadedIcon);
+ TurretLoadedSlot.AddChild(_turretLoadedIcon);
+ LayoutContainer.SetAnchorAndMarginPreset(_turretLoadedIcon, LayoutContainer.LayoutPreset.Wide, margin: 6);
+ TurretLoadedSlot.OnKeyBindDown += args =>
+ {
+ if (args.Function != EngineKeyFunctions.UIClick) return;
+ if (_selectedTurret is not { } turret) return;
+ var loaded = GetSelectedTurretState()?.LoadedAmmoPrototype;
+ if (loaded == null) return;
+ SelectTurretLoadedAmmo(loaded.Value);
+ _dragFromQuick = false;
+ _dragFromTurretSlot = true;
+ _dragQuickSlot = -1;
+ _dragMouseHeld = true;
+ _dragHelper.MouseDown(loaded.Value.Id);
+ args.Handle();
+ };
+ TurretLoadedSlot.OnKeyBindUp += args =>
+ { if (args.Function == EngineKeyFunctions.UIClick) args.Handle(); };
+ BuildQuickUnloadSlots();
+ RefreshQuickUnloadVisuals();
+ AddTurretPlaceholders();
+ }
+ protected override void EnteredTree()
+ {
+ base.EnteredTree();
+ if (_dragShadow.Parent == null) UserInterfaceManager.PopupRoot.AddChild(_dragShadow);
+ }
+ protected override void ExitedTree()
+ {
+ base.ExitedTree();
+ _dragMouseHeld = false;
+ _dragFromQuick = false;
+ _dragFromTurretSlot = false;
+ _dragFromEmpty = false;
+ _dragQuickSlot = -1;
+ _dragHelper.EndDrag();
+ _dragShadow.Orphan();
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+ _dragHelper.Update(args.DeltaSeconds);
+ if (!_dragMouseHeld) return;
+ if (_input.IsKeyDown(Key.MouseLeft)) return;
+ var wasDragging = _dragHelper.IsDragging;
+ var dragged = _dragHelper.Dragged;
+ var mouse = UserInterfaceManager.MousePositionScaled.Position;
+ var overQuick = QuickUnloadPanel.GlobalRect.Contains(mouse);
+ var overStorage = StoragePanel.GlobalRect.Contains(mouse);
+ var turretMode = _selectedTurret != null && _infoMode is (InfoMode.Turret or InfoMode.TurretLoadedAmmo);
+ var overTurretDrop = turretMode && TurretLoadedSection.Visible && (TurretLoadedSlot.GlobalRect.Contains(mouse) || TurretLoadedSection.GlobalRect.Contains(mouse) || InfoPanel.GlobalRect.Contains(mouse));
+ _dragMouseHeld = false;
+ if (wasDragging && dragged != null)
+ {
+ if (_dragFromQuick)
+ { HandleQuickDragEnd(overQuick); }
+ else if (_dragFromTurretSlot)
+ { if (overStorage) TryUnloadSelectedTurret(); }
+ else if (overTurretDrop)
+ { if (!_dragFromEmpty) TryLoadSelectedTurret(dragged); }
+ else if (overQuick)
+ { TryStageQuickUnload(dragged); }
+ }
+ else if (!wasDragging && !_dragFromQuick && !_dragFromTurretSlot && dragged != null && _selectedTurret != null && _infoMode is (InfoMode.Turret or InfoMode.TurretLoadedAmmo))
+ { SelectStorageAmmo(new EntProtoId(dragged), _dragFromEmpty); }
+ _dragFromQuick = false;
+ _dragFromTurretSlot = false;
+ _dragFromEmpty = false;
+ _dragQuickSlot = -1;
+ _dragHelper.EndDrag();
+ }
+
+ public void UpdateState(AmmoLoaderBoundUserInterfaceState state)
+ {
+ _groups.Clear();
+ _groups.AddRange(state.Groups);
+ _linkedTurrets.Clear();
+ if (state.LinkedTurrets != null) _linkedTurrets.AddRange(state.LinkedTurrets);
+ _maxConnections = Math.Max(1, state.MaxConnections > 0 ? state.MaxConnections : DefaultTurretColumns);
+ TurretGrid.Columns = _maxConnections;
+ _slotPanels.Clear();
+ _slotTitles.Clear();
+ _turretPanels.Clear();
+ AmmoGrid.DisposeAllChildren();
+ TurretGrid.DisposeAllChildren();
+ if (_selectedAmmo is { } selectedAmmo && !_groups.Exists(g => g.PrototypeId.Id == selectedAmmo.Id && g.IsEmpty == _selectedEmpty))
+ {
+ _selectedAmmo = null;
+ _selectedEmpty = false;
+ }
+ if (_selectedTurret is { } selectedTurret && !_linkedTurrets.Exists(t => t.Turret == selectedTurret))
+ {
+ _selectedTurret = null;
+ if (_infoMode is InfoMode.Turret or InfoMode.TurretLoadedAmmo) _infoMode = InfoMode.None;
+ }
+ if (_selectedAmmo == null && _selectedTurret == null && _infoMode == InfoMode.None && _groups.Count > 0)
+ { SelectStorageAmmo(_groups[0].PrototypeId, _groups[0].IsEmpty); }
+ foreach (var group in _groups) AmmoGrid.AddChild(CreateGroupSlot(group));
+ AddStoragePlaceholders();
+ foreach (var turret in _linkedTurrets) TurretGrid.AddChild(CreateTurretSlot(turret));
+ AddTurretPlaceholders();
+ TurretGrid.MinHeight = 56;
+ TotalLabel.Text = Loc.GetString("ammo-loader-window-total", ("current", state.CurrentCount), ("max", state.MaxCapacity));
+ LinkedTurretsPanel.Visible = true;
+ PruneQuickUnloadAgainstStorage();
+ RefreshQuickUnloadVisuals();
+ RefreshSelectionPanel();
+ UpdateEjectButtonState();
+ RefreshTurretSelectionBorders();
+ }
+
+ private void OnUnloadPressed()
+ {
+ if (_infoMode is InfoMode.Turret or InfoMode.TurretLoadedAmmo)
+ {
+ TryUnloadSelectedTurret();
+ return;
+ }
+ if (_selectedAmmo is { } selected) OnUnloadOne?.Invoke(selected, _selectedEmpty);
+ }
+
+ private void TryLoadSelectedTurret(string prototypeId)
+ {
+ if (_selectedTurret is not { } turret) return;
+ var turretState = GetSelectedTurretState();
+ if (turretState is not { CanModifyAmmo: true }) return;
+ if (GetAvailableCount(prototypeId) <= 0) return;
+ OnLoadTurret?.Invoke(turret, new EntProtoId(prototypeId));
+ }
+
+ private void TryUnloadSelectedTurret()
+ {
+ if (_selectedTurret is not { } turret) return;
+ var turretState = GetSelectedTurretState();
+ if (turretState is not { CanModifyAmmo: true } || turretState.AmmoCount <= 0) return;
+ OnUnloadTurret?.Invoke(turret);
+ }
+
+ private AmmoLoaderLinkedTurret? GetSelectedTurretState()
+ {
+ if (_selectedTurret is not { } selected) return null;
+ foreach (var turret in _linkedTurrets)
+ { if (turret.Turret == selected) return turret; }
+ return null;
+ }
+
+ private void BuildQuickUnloadSlots()
+ {
+ QuickUnloadSlots.DisposeAllChildren();
+ _quickSlots.Clear();
+ _quickIcons.Clear();
+ for (var i = 0; i < QuickUnloadSlotCount; i++)
+ {
+ var slotIndex = i;
+ var slot = new LayoutContainer
+ {
+ MinSize = QuickSlotSize,
+ MaxSize = QuickSlotSize,
+ SetSize = QuickSlotSize,
+ HorizontalExpand = false,
+ VerticalExpand = false,
+ MouseFilter = MouseFilterMode.Stop,
+ };
+ var bg = new PanelContainer
+ {
+ HorizontalExpand = true,
+ VerticalExpand = true,
+ MouseFilter = MouseFilterMode.Ignore,
+ PanelOverride = MakeSlotStyle(false),
+ };
+ slot.AddChild(bg);
+ LayoutContainer.SetAnchorPreset(bg, LayoutContainer.LayoutPreset.Wide);
+ var icon = new EntityPrototypeView
+ {
+ MouseFilter = MouseFilterMode.Ignore,
+ Visible = false,
+ };
+ ConfigurePrototypeIcon(icon);
+ slot.AddChild(icon);
+ LayoutContainer.SetAnchorAndMarginPreset(icon, LayoutContainer.LayoutPreset.Wide, margin: 6);
+ slot.OnKeyBindDown += args =>
+ {
+ if (args.Function != EngineKeyFunctions.UIClick) return;
+ if (_quickQueue[slotIndex] is not { } protoId) return;
+ _dragFromQuick = true;
+ _dragFromTurretSlot = false;
+ _dragQuickSlot = slotIndex;
+ _dragMouseHeld = true;
+ _dragHelper.MouseDown(protoId);
+ args.Handle();
+ };
+ slot.OnKeyBindUp += args =>
+ { if (args.Function == EngineKeyFunctions.UIClick) args.Handle(); };
+ QuickUnloadSlots.AddChild(slot);
+ _quickSlots.Add(bg);
+ _quickIcons.Add(icon);
+ }
+ }
+
+ private Control CreateGroupSlot(AmmoLoaderInventoryGroup group)
+ {
+ var name = group.PrototypeId.Id;
+ if (_prototypes.TryIndex(group.PrototypeId, out var proto)) name = proto.Name;
+ var slotName = GetStorageSlotName(group.PrototypeId, name);
+ var selected = _selectedAmmo?.Id == group.PrototypeId.Id && _selectedEmpty == group.IsEmpty && _infoMode == InfoMode.StorageAmmo;
+ var panel = new PanelContainer
+ {
+ MinSize = SlotSize,
+ MaxSize = SlotSize,
+ SetSize = SlotSize,
+ HorizontalExpand = false,
+ VerticalExpand = false,
+ PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = SlotBg,
+ BorderColor = selected ? SlotSelectedBorder : SlotBorder,
+ BorderThickness = new Thickness(2),
+ },
+ };
+ var content = new LayoutContainer
+ {
+ Margin = new Thickness(3),
+ HorizontalExpand = false,
+ MinSize = new Vector2(102, 120),
+ MaxSize = new Vector2(102, 120),
+ SetSize = new Vector2(102, 120),
+ };
+ var iconArea = new LayoutContainer
+ {
+ MinSize = new Vector2(102, 92),
+ MaxSize = new Vector2(102, 92),
+ SetSize = new Vector2(102, 92),
+ HorizontalExpand = false,
+ MouseFilter = MouseFilterMode.Stop,
+ RectClipContent = true,
+ };
+ var icon = new EntityPrototypeView
+ { MouseFilter = MouseFilterMode.Ignore, };
+ ConfigurePrototypeIcon(icon);
+ icon.SetPrototype(group.PrototypeId);
+ iconArea.AddChild(icon);
+ LayoutContainer.SetAnchorAndMarginPreset(icon, LayoutContainer.LayoutPreset.Wide, margin: 16);
+ content.AddChild(iconArea);
+ LayoutContainer.SetAnchorAndMarginPreset(iconArea, LayoutContainer.LayoutPreset.TopWide, margin: 0);
+ var titleBar = new PanelContainer
+ {
+ MouseFilter = MouseFilterMode.Ignore,
+ MinHeight = 18,
+ MaxHeight = 18,
+ PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = Color.FromHex("#151d26e6"),
+ BorderColor = Color.Transparent,
+ },
+ };
+ var title = new MarqueeLabel
+ {
+ Text = slotName,
+ FontColorOverride = selected ? Accent : Color.FromHex("#d0d6e0"),
+ MinHeight = 18,
+ MaxHeight = 18,
+ HorizontalExpand = true,
+ };
+ titleBar.AddChild(title);
+ content.AddChild(titleBar);
+ LayoutContainer.SetAnchorAndMarginPreset(titleBar, LayoutContainer.LayoutPreset.TopWide, margin: 0);
+ var countLabel = new Label
+ {
+ Text = FormatGroupCount(group),
+ FontColorOverride = group.IsEmpty ? Color.FromHex("#8a93a3") : Color.FromHex("#e8eef6"),
+ MouseFilter = MouseFilterMode.Ignore,
+ Margin = new Thickness(0, 0, 3, 2),
+ };
+ iconArea.AddChild(countLabel);
+ LayoutContainer.SetAnchorAndMarginPreset(countLabel, LayoutContainer.LayoutPreset.BottomRight, margin: 1);
+ var groupKey = GetGroupKey(group);
+ _slotPanels[groupKey] = panel;
+ _slotTitles[groupKey] = title;
+ var protoId = group.PrototypeId;
+ var isEmpty = group.IsEmpty;
+ iconArea.OnKeyBindDown += args =>
+ {
+ if (args.Function != EngineKeyFunctions.UIClick) return;
+ if (_selectedTurret == null || _infoMode is not (InfoMode.Turret or InfoMode.TurretLoadedAmmo)) SelectStorageAmmo(protoId, isEmpty);
+ _dragFromQuick = false;
+ _dragFromTurretSlot = false;
+ _dragFromEmpty = isEmpty;
+ _dragQuickSlot = -1;
+ _dragMouseHeld = true;
+ _dragHelper.MouseDown(protoId.Id);
+ args.Handle();
+ };
+ iconArea.OnKeyBindUp += args =>
+ {
+ if (args.Function == EngineKeyFunctions.UIClick) args.Handle();
+ };
+ var unloadButton = new Button
+ {
+ Text = Loc.GetString("ammo-loader-window-unload"),
+ HorizontalExpand = true,
+ MinSize = new Vector2(102, 24),
+ MaxSize = new Vector2(102, 24),
+ SetSize = new Vector2(102, 24),
+ StyleClasses = { StyleBase.ButtonOpenBoth },
+ };
+ unloadButton.OnPressed += _ =>
+ {
+ SelectStorageAmmo(protoId, isEmpty);
+ OnUnloadOne?.Invoke(protoId, isEmpty);
+ };
+ content.AddChild(unloadButton);
+ LayoutContainer.SetAnchorAndMarginPreset(unloadButton, LayoutContainer.LayoutPreset.BottomWide, margin: 0);
+ panel.AddChild(content);
+ return panel;
+ }
+ private Control CreateTurretSlot(AmmoLoaderLinkedTurret turret)
+ {
+ var selected = _selectedTurret == turret.Turret && _infoMode is InfoMode.Turret or InfoMode.TurretLoadedAmmo;
+ var slot = new LayoutContainer
+ {
+ MinSize = TurretSlotSize,
+ MaxSize = TurretSlotSize,
+ SetSize = TurretSlotSize,
+ HorizontalExpand = false,
+ VerticalExpand = false,
+ MouseFilter = MouseFilterMode.Stop,
+ ToolTip = $"{turret.TurretName} ({turret.AmmoCount}/{turret.AmmoCapacity})",
+ };
+ var bg = new PanelContainer
+ {
+ HorizontalExpand = true,
+ VerticalExpand = true,
+ MouseFilter = MouseFilterMode.Ignore,
+ PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = selected ? Color.FromHex("#1a2c35") : Color.FromHex("#0d1319"),
+ BorderColor = selected ? SlotSelectedBorder : SlotBorder,
+ BorderThickness = new Thickness(2),
+ },
+ };
+ slot.AddChild(bg);
+ LayoutContainer.SetAnchorPreset(bg, LayoutContainer.LayoutPreset.Wide);
+ var icon = new EntityPrototypeView
+ { MouseFilter = MouseFilterMode.Ignore, };
+ ConfigureTurretIcon(icon);
+ icon.SetPrototype(turret.TurretPrototype);
+ slot.AddChild(icon);
+ LayoutContainer.SetAnchorAndMarginPreset(icon, LayoutContainer.LayoutPreset.Wide, margin: 2);
+ _turretPanels[turret.Turret] = bg;
+ var turretNet = turret.Turret;
+ slot.OnKeyBindDown += args =>
+ {
+ if (args.Function != EngineKeyFunctions.UIClick) return;
+ SelectTurret(turretNet);
+ args.Handle();
+ };
+ slot.OnKeyBindUp += args =>
+ { if (args.Function == EngineKeyFunctions.UIClick) args.Handle(); };
+ return slot;
+ }
+
+ private static string GetStorageSlotName(EntProtoId prototypeId, string fallbackName)
+ {
+ var label = AmmoLoaderLocale.GetAmmoCaliber(prototypeId.Id, fallbackName);
+ return CompactAmmoLabel(label);
+ }
+
+ private static string CompactAmmoLabel(string label)//shit code, mb any idea?
+ {
+ label = RemovePrefix(label, "магазин ");
+ label = RemovePrefix(label, "ящик ");
+ label = RemovePrefix(label, "блок ");
+ label = RemovePrefix(label, "magazine ");
+ label = RemovePrefix(label, "box ");
+ label = RemoveToken(label, " ammo loader");
+ label = RemoveToken(label, " ammo box");
+ label = RemoveToken(label, " magazine");
+ label = RemoveToken(label, " box");
+ return label.Trim();
+ }
+
+ private static string RemovePrefix(string value, string prefix)
+ { return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? value[prefix.Length..] : value; }
+ private static string RemoveToken(string value, string token)
+ {
+ var index = value.IndexOf(token, StringComparison.OrdinalIgnoreCase);
+ return index >= 0 ? value.Remove(index, token.Length) : value;
+ }
+
+ private void SelectStorageAmmo(EntProtoId prototypeId, bool empty = false)
+ {
+ _selectedAmmo = prototypeId;
+ _selectedEmpty = empty;
+ _infoMode = InfoMode.StorageAmmo;
+ RefreshTurretSelectionBorders();
+ var selectedKey = GetGroupKey(prototypeId, empty);
+ foreach (var (id, panel) in _slotPanels)
+ {
+ var isSelected = id == selectedKey;
+ panel.PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = SlotBg,
+ BorderColor = isSelected ? SlotSelectedBorder : SlotBorder,
+ BorderThickness = new Thickness(2),
+ };
+ if (_slotTitles.TryGetValue(id, out var title)) title.FontColorOverride = isSelected ? Accent : Color.FromHex("#d0d6e0");
+ }
+ RefreshSelectionPanel();
+ }
+
+ private void SelectTurret(NetEntity turret)
+ {
+ _selectedTurret = turret;
+ _infoMode = InfoMode.Turret;
+ foreach (var (id, panel) in _slotPanels)
+ {
+ panel.PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = SlotBg,
+ BorderColor = SlotBorder,
+ BorderThickness = new Thickness(2),
+ };
+ if (_slotTitles.TryGetValue(id, out var title)) title.FontColorOverride = Color.FromHex("#d0d6e0");
+ }
+ RefreshTurretSelectionBorders();
+ RefreshSelectionPanel();
+ }
+
+ private void RefreshTurretSelectionBorders()
+ {
+ foreach (var (netEntity, panel) in _turretPanels)
+ {
+ var isSelected = netEntity == _selectedTurret && _infoMode is InfoMode.Turret or InfoMode.TurretLoadedAmmo;
+ panel.PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = isSelected ? Color.FromHex("#1a2c35") : Color.FromHex("#0d1319"),
+ BorderColor = isSelected ? SlotSelectedBorder : SlotBorder,
+ BorderThickness = new Thickness(2),
+ };
+ }
+ }
+
+ private void SelectTurretLoadedAmmo(EntProtoId prototypeId)
+ {
+ _selectedAmmo = prototypeId;
+ _infoMode = InfoMode.TurretLoadedAmmo;
+ RefreshSelectionPanel();
+ }
+
+ private void RefreshSelectionPanel()
+ {
+ if (_infoMode is InfoMode.Turret or InfoMode.TurretLoadedAmmo)
+ {
+ RefreshTurretSelectionPanel();
+ return;
+ }
+ TurretLoadedSection.Visible = false;
+ InfoHeadingLabel.Text = Loc.GetString("ammo-loader-window-item-info");
+ if (_selectedAmmo is not { } selected || !_groups.Exists(g => g.PrototypeId.Id == selected.Id && g.IsEmpty == _selectedEmpty))
+ {
+ SelectedNameMarquee.Text = Loc.GetString("ammo-loader-window-no-selection");
+ SelectedDescLabel.SetMessage(string.Empty);
+ SelectedCountLabel.Text = string.Empty;
+ SelectedIcon.SetPrototype(null);
+ UnloadOneButton.Disabled = true;
+ return;
+ }
+ var group = _groups.Find(g => g.PrototypeId.Id == selected.Id && g.IsEmpty == _selectedEmpty);
+ if (group == null)
+ {
+ SelectedNameMarquee.Text = Loc.GetString("ammo-loader-window-no-selection");
+ SelectedDescLabel.SetMessage(string.Empty);
+ SelectedCountLabel.Text = string.Empty;
+ SelectedIcon.SetPrototype(null);
+ UnloadOneButton.Disabled = true;
+ return;
+ }
+ var name = selected.Id;
+ if (_prototypes.TryIndex(selected, out var proto)) name = proto.Name;
+ SelectedNameMarquee.Text = name;
+ SelectedNameMarquee.ResetScroll();
+ SelectedDescLabel.SetMessage(FormattedMessage.FromMarkupPermissive( AmmoLoaderLocale.FormatItemStats(selected.Id, name)));
+ SelectedCountLabel.Text = FormatGroupCount(group);
+ SelectedIcon.SetPrototype(selected);
+ UnloadOneButton.Text = Loc.GetString("ammo-loader-window-unload-one");
+ UnloadOneButton.Disabled = false;
+ }
+
+ private void RefreshTurretSelectionPanel()
+ {
+ var turret = GetSelectedTurretState();
+ if (turret == null)
+ {
+ SelectedNameMarquee.Text = Loc.GetString("ammo-loader-window-no-selection");
+ SelectedDescLabel.SetMessage(string.Empty);
+ SelectedCountLabel.Text = string.Empty;
+ SelectedIcon.SetPrototype(null);
+ TurretLoadedSection.Visible = false;
+ UnloadOneButton.Disabled = true;
+ return;
+ }
+ InfoHeadingLabel.Text = Loc.GetString("ammo-loader-window-turret-info");
+ TurretLoadedSection.Visible = true;
+ SelectedNameMarquee.Text = turret.TurretName;
+ SelectedNameMarquee.ResetScroll();
+ SelectedCountLabel.Text = Loc.GetString("ammo-loader-window-turret-ammo", ("current", turret.AmmoCount), ("max", turret.AmmoCapacity));
+ SelectedIcon.SetPrototype(turret.TurretPrototype);
+ if (_infoMode == InfoMode.TurretLoadedAmmo && (_selectedAmmo ?? turret.LoadedAmmoPrototype) is { } loadedAmmo)
+ {
+ var ammoName = loadedAmmo.Id;
+ if (_prototypes.TryIndex(loadedAmmo, out var ammoProto)) ammoName = ammoProto.Name;
+ SelectedDescLabel.SetMessage(FormattedMessage.FromMarkupPermissive(AmmoLoaderLocale.FormatItemStats(loadedAmmo.Id, ammoName)));
+ _turretLoadedBg.PanelOverride = MakeSlotStyle(true);
+ }
+ else
+ {
+ SelectedDescLabel.SetMessage(string.Empty);
+ _turretLoadedBg.PanelOverride = MakeSlotStyle(false);
+ }
+ if (turret.LoadedAmmoPrototype is { } loadedProto)
+ {
+ _turretLoadedIcon.Visible = true;
+ _turretLoadedIcon.SetPrototype(loadedProto);
+ TurretLoadedSlot.MouseFilter = MouseFilterMode.Stop;
+ }
+ else
+ {
+ _turretLoadedIcon.Visible = false;
+ _turretLoadedIcon.SetPrototype(null);
+ TurretLoadedSlot.MouseFilter = MouseFilterMode.Stop;
+ }
+ var canUnload = turret.CanModifyAmmo && turret.AmmoCount > 0;
+ UnloadOneButton.Text = Loc.GetString("ammo-loader-window-unload-turret");
+ UnloadOneButton.Disabled = !canUnload;
+ }
+
+ private void HandleQuickDragEnd(bool overQuick)
+ {
+ if (_dragQuickSlot < 0 || _dragQuickSlot >= _quickQueue.Length) return;
+ if (!overQuick) _quickQueue[_dragQuickSlot] = null;
+ RefreshQuickUnloadVisuals();
+ UpdateEjectButtonState();
+ }
+
+ private bool TryStageQuickUnload(string prototypeId)
+ {
+ if (IsTypeStaged(prototypeId)) return false;
+ if (GetAvailableCount(prototypeId) <= 0) return false;
+ for (var i = 0; i < _quickQueue.Length; i++)
+ {
+ if (_quickQueue[i] != null) continue;
+ _quickQueue[i] = prototypeId;
+ RefreshQuickUnloadVisuals();
+ UpdateEjectButtonState();
+ return true;
+ }
+ return false;
+ }
+
+ private void EjectQuickUnloadQueue()
+ {
+ var stagedTypes = new List();
+ foreach (var id in _quickQueue)
+ {
+ if (id == null || stagedTypes.Contains(id)) continue;
+ stagedTypes.Add(id);
+ }
+ foreach (var type in stagedTypes)
+ {
+ var count = GetAvailableCount(type);
+ for (var i = 0; i < count; i++) OnUnloadOne?.Invoke(new EntProtoId(type), false);
+ }
+ Array.Clear(_quickQueue);
+ RefreshQuickUnloadVisuals();
+ UpdateEjectButtonState();
+ }
+
+ private void PruneQuickUnloadAgainstStorage()
+ {
+ for (var i = 0; i < _quickQueue.Length; i++)
+ {
+ var id = _quickQueue[i];
+ if (id == null) continue;
+ if (GetAvailableCount(id) <= 0) _quickQueue[i] = null;
+ }
+ }
+
+ private void RefreshQuickUnloadVisuals()
+ {
+ for (var i = 0; i < _quickIcons.Count; i++)
+ {
+ var id = _quickQueue[i];
+ var icon = _quickIcons[i];
+ if (id == null)
+ {
+ icon.Visible = false;
+ icon.SetPrototype(null);
+ }
+ else
+ {
+ icon.Visible = true;
+ icon.SetPrototype(id);
+ }
+ _quickSlots[i].PanelOverride = MakeSlotStyle(false);
+ }
+ }
+
+ private void AddStoragePlaceholders()
+ {
+ var targetSlots = Math.Max(StorageColumns * 2, ((_groups.Count + StorageColumns - 1) / StorageColumns) * StorageColumns);
+ for (var i = _groups.Count; i < targetSlots; i++)
+ {
+ AmmoGrid.AddChild(new PanelContainer
+ {
+ MinSize = SlotSize,
+ MaxSize = SlotSize,
+ SetSize = SlotSize,
+ HorizontalExpand = false,
+ VerticalExpand = false,
+ PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = Color.FromHex("#0d1319"),
+ BorderColor = Color.FromHex("#24313d"),
+ BorderThickness = new Thickness(2),
+ },
+ });
+ }
+ }
+
+ private void AddTurretPlaceholders()
+ {
+ var columns = Math.Max(1, _maxConnections);
+ var targetSlots = Math.Max(columns, ((_linkedTurrets.Count + columns - 1) / columns) * columns);
+ for (var i = _linkedTurrets.Count; i < targetSlots; i++)
+ {
+ TurretGrid.AddChild(new PanelContainer
+ {
+ MinSize = TurretSlotSize,
+ MaxSize = TurretSlotSize,
+ SetSize = TurretSlotSize,
+ HorizontalExpand = false,
+ VerticalExpand = false,
+ PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = Color.FromHex("#0d1319"),
+ BorderColor = Color.FromHex("#24313d"),
+ BorderThickness = new Thickness(2),
+ },
+ });
+ }
+ }
+
+ private void UpdateEjectButtonState()
+ {
+ var hasStaged = false;
+ foreach (var id in _quickQueue)
+ {
+ if (id == null) continue;
+ hasStaged = true;
+ break;
+ }
+ EjectAllButton.Disabled = !hasStaged;
+ }
+
+ private bool IsTypeStaged(string prototypeId)
+ {
+ foreach (var id in _quickQueue)
+ { if (id == prototypeId) return true; }
+ return false;
+ }
+
+ private int GetAvailableCount(string prototypeId)
+ {
+ foreach (var group in _groups)
+ {
+ if (group.IsEmpty) continue;
+ if (group.PrototypeId.Id == prototypeId) return group.Count;
+ }
+ return 0;
+ }
+
+ private static string FormatGroupCount(AmmoLoaderInventoryGroup group)
+ {
+ if (group.IsEmpty)
+ { return group.Count > 1 ? Loc.GetString("ammo-loader-window-empty-count", ("count", group.Count)) : Loc.GetString("ammo-loader-window-empty"); }
+ return Loc.GetString("ammo-loader-window-count", ("count", group.Count));
+ }
+
+ private static string GetGroupKey(AmmoLoaderInventoryGroup group)
+ { return GetGroupKey(group.PrototypeId, group.IsEmpty); }
+ private static string GetGroupKey(EntProtoId prototypeId, bool empty)
+ { return empty ? $"{prototypeId.Id}#empty" : prototypeId.Id; }
+ private static void ConfigurePrototypeIcon(EntityPrototypeView view)
+ {
+ view.Scale = Vector2.One;
+ view.Stretch = SpriteView.StretchMode.Fill;
+ view.SpriteOffset = false;
+ }
+
+ private static void ConfigureTurretIcon(EntityPrototypeView view)
+ {
+ view.Scale = Vector2.One;
+ view.Stretch = SpriteView.StretchMode.Fit;
+ view.SpriteOffset = false;
+ view.OverrideDirection = Direction.South;
+ }
+
+ private static StyleBoxFlat MakeSlotStyle(bool active)
+ {
+ return new StyleBoxFlat
+ {
+ BackgroundColor = active ? Color.FromHex("#1a2c35") : Color.FromHex("#0d1319"),
+ BorderColor = active ? Accent : SlotBorder,
+ BorderThickness = new Thickness(2),
+ };
+ }
+
+ private bool OnBeginDrag()
+ {
+ if (_dragHelper.Dragged is not { } dragged) return false;
+ _dragShadow.SetPrototype(dragged);
+ _dragShadow.Visible = true;
+ SetQuickUnloadHighlight(true);
+ SetStorageHighlight(false);
+ SetTurretLoadedHighlight(false);
+ return true;
+ }
+
+ private bool OnContinueDrag(float _)
+ {
+ var mouse = UserInterfaceManager.MousePositionScaled;
+ LayoutContainer.SetPosition(_dragShadow, mouse.Position - _dragShadow.Size / 2f);
+ if (_dragFromTurretSlot)
+ {
+ SetStorageHighlight(StoragePanel.GlobalRect.Contains(mouse.Position));
+ SetQuickUnloadHighlight(false);
+ SetTurretLoadedHighlight(false);
+ }
+ else
+ {
+ var overTurretDrop = TurretLoadedSection.Visible &&
+ _selectedTurret != null &&
+ _infoMode is (InfoMode.Turret or InfoMode.TurretLoadedAmmo) &&
+ (TurretLoadedSlot.GlobalRect.Contains(mouse.Position) ||
+ TurretLoadedSection.GlobalRect.Contains(mouse.Position) ||
+ InfoPanel.GlobalRect.Contains(mouse.Position));
+ SetQuickUnloadHighlight(QuickUnloadPanel.GlobalRect.Contains(mouse.Position));
+ SetStorageHighlight(false);
+ SetTurretLoadedHighlight(overTurretDrop);
+ }
+
+ return true;
+ }
+
+ private void OnEndDrag()
+ {
+ _dragShadow.Visible = false;
+ _dragShadow.SetPrototype(null);
+ SetQuickUnloadHighlight(false);
+ SetStorageHighlight(false);
+ SetTurretLoadedHighlight(false);
+ }
+
+ private void SetQuickUnloadHighlight(bool active)
+ {
+ QuickUnloadPanel.PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = active ? DropHighlight : PanelBg,
+ BorderColor = active ? Accent : Color.FromHex("#334252"),
+ BorderThickness = new Thickness(2),
+ };
+ for (var i = 0; i < _quickSlots.Count; i++) _quickSlots[i].PanelOverride = MakeSlotStyle(active && _quickQueue[i] == null);
+ }
+
+ private void SetStorageHighlight(bool active)
+ {
+ StoragePanel.PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = active ? DropHighlight : PanelBg,
+ BorderColor = active ? Accent : Color.FromHex("#334252"),
+ BorderThickness = new Thickness(2),
+ };
+ }
+
+ private void SetTurretLoadedHighlight(bool active)
+ {
+ var selected = _infoMode == InfoMode.TurretLoadedAmmo;
+ _turretLoadedBg.PanelOverride = MakeSlotStyle(active || selected);
+ }
+}
diff --git a/Content.Client/_Lua/AmmoLoader/UI/MarqueeLabel.cs b/Content.Client/_Lua/AmmoLoader/UI/MarqueeLabel.cs
new file mode 100644
index 00000000000..0c18c820f86
--- /dev/null
+++ b/Content.Client/_Lua/AmmoLoader/UI/MarqueeLabel.cs
@@ -0,0 +1,118 @@
+// LuaCorp - This file is licensed under AGPLv3
+// Copyright (c) 2026 LuaCorp Contributors
+// See AGPLv3.txt for details.
+
+using System;
+using System.Numerics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Shared.Timing;
+
+namespace Content.Client._Lua.AmmoLoader.UI;
+
+public sealed class MarqueeLabel : Control
+{
+ private const float ScrollSpeedPx = 28f;
+ private const float EndPauseSeconds = 1.25f;
+ private const float GapPx = 32f;
+ private readonly Label _label;
+ private float _offset;
+ private float _pauseRemaining;
+ private bool _scrollingForward = true;
+
+ public string? Text
+ {
+ get => _label.Text;
+ set
+ {
+ _label.Text = value;
+ ResetScroll();
+ InvalidateMeasure();
+ }
+ }
+
+ public Color? FontColorOverride
+ {
+ get => _label.FontColorOverride;
+ set => _label.FontColorOverride = value;
+ }
+
+ public MarqueeLabel()
+ {
+ RectClipContent = true;
+ MouseFilter = MouseFilterMode.Ignore;
+ _label = new Label
+ {
+ MouseFilter = MouseFilterMode.Ignore,
+ ClipText = false,
+ };
+ AddChild(_label);
+ }
+
+ public void ResetScroll()
+ {
+ _offset = 0f;
+ _pauseRemaining = EndPauseSeconds;
+ _scrollingForward = true;
+ InvalidateArrange();
+ }
+
+ protected override Vector2 MeasureOverride(Vector2 availableSize)
+ {
+ _label.Measure(new Vector2(float.PositiveInfinity, availableSize.Y));
+ var height = _label.DesiredSize.Y > 0 ? _label.DesiredSize.Y : 16f;
+ if (float.IsFinite(availableSize.X) && availableSize.X > 0f) return new Vector2(availableSize.X, height);
+ return new Vector2(_label.DesiredSize.X, height);
+ }
+
+ protected override Vector2 ArrangeOverride(Vector2 finalSize)
+ {
+ var textSize = _label.DesiredSize;
+ if (textSize.X <= finalSize.X + 0.5f)
+ { _label.Arrange(UIBox2.FromDimensions(Vector2.Zero, finalSize)); }
+ else
+ { _label.Arrange(UIBox2.FromDimensions(new Vector2(-_offset, 0), new Vector2(textSize.X, finalSize.Y))); }
+ return finalSize;
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+ var avail = Size.X;
+ var textWidth = _label.DesiredSize.X;
+ if (textWidth <= avail + 0.5f)
+ {
+ if (_offset != 0f)
+ {
+ _offset = 0f;
+ InvalidateArrange();
+ }
+ return;
+ }
+ if (_pauseRemaining > 0f)
+ { _pauseRemaining -= args.DeltaSeconds; return; }
+ var maxOffset = textWidth - avail + GapPx;
+ var delta = ScrollSpeedPx * args.DeltaSeconds;
+ if (_scrollingForward)
+ {
+ _offset += delta;
+ if (_offset >= maxOffset)
+ {
+ _offset = maxOffset;
+ _scrollingForward = false;
+ _pauseRemaining = EndPauseSeconds;
+ }
+ }
+ else
+ {
+ _offset -= delta;
+ if (_offset <= 0f)
+ {
+ _offset = 0f;
+ _scrollingForward = true;
+ _pauseRemaining = EndPauseSeconds;
+ }
+ }
+ InvalidateArrange();
+ }
+}
diff --git a/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml b/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml
index be855d847e7..6c608949f43 100644
--- a/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml
+++ b/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml
@@ -57,7 +57,35 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml.cs b/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml.cs
index 3212f9685e0..6be700a85dc 100644
--- a/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml.cs
+++ b/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml.cs
@@ -85,6 +85,16 @@ public FireControlWindow()
SelectMissileButton.OnPressed += SelectMissileWeapons;
IffSearchCriteria.OnTextChanged += args => OnIffSearchChanged(args.Text);
+ IFFToggle.OnToggled += OnIFFTogglePressed;
+
+ IFFToggle.Pressed = NavRadar.ShowIFF;
+
+ IFFDetailedToggle.OnToggled += OnIFFDetailedTogglePressed; // Mono
+ IFFDetailedToggle.Pressed = NavRadar.ShowIFFDetailed; // Mono
+
+ DockToggle.OnToggled += OnDockTogglePressed;
+ DockToggle.Pressed = NavRadar.ShowDocks;
+
InitializePresetControls();
ApplyWindowLayout(initial: true);
}
@@ -623,6 +633,24 @@ private void SelectMissileWeapons(BaseButton.ButtonEventArgs args)
UpdateAllWeaponButtonTexts();
}
+ private void OnIFFTogglePressed(BaseButton.ButtonEventArgs args)
+ {
+ NavRadar.ShowIFF ^= true;
+ args.Button.Pressed = NavRadar.ShowIFF;
+ }
+
+ private void OnIFFDetailedTogglePressed(BaseButton.ButtonEventArgs args)
+ {
+ NavRadar.ShowIFFDetailed ^= true;
+ args.Button.Pressed = NavRadar.ShowIFFDetailed;
+ }
+
+ private void OnDockTogglePressed(BaseButton.ButtonEventArgs args)
+ {
+ NavRadar.ShowDocks ^= true;
+ args.Button.Pressed = NavRadar.ShowDocks;
+ }
+
///
/// Updates the text of a weapon button based on its selection state and manual reload status.
///
diff --git a/Content.Client/_Mono/PersonalShield/PersonalShieldOverlay.cs b/Content.Client/_Mono/PersonalShield/PersonalShieldOverlay.cs
new file mode 100644
index 00000000000..a80c9413b2e
--- /dev/null
+++ b/Content.Client/_Mono/PersonalShield/PersonalShieldOverlay.cs
@@ -0,0 +1,131 @@
+using System.Numerics;
+using Content.Shared._Mono.PersonalShield;
+using Content.Shared.Inventory;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Shared.Enums;
+using Robust.Shared.GameObjects;
+using Robust.Shared.IoC;
+using Robust.Shared.Map;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Components;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client._Mono.PersonalShield;
+
+public sealed partial class PersonalShieldOverlay : Overlay
+{
+ [Dependency] private IEntityManager _entManager = null!;
+
+ private static readonly ProtoId ShaderId = "PersonalShieldSkin";
+
+ private readonly SharedTransformSystem _transform;
+ private readonly SpriteSystem _sprite;
+ private readonly InventorySystem _inventory;
+ private readonly ShaderInstance _shader;
+
+ public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowFOV;
+
+ public PersonalShieldOverlay()
+ {
+ IoCManager.InjectDependencies(this);
+ _transform = _entManager.System();
+ _sprite = _entManager.System();
+ _inventory = _entManager.System();
+ var protoMan = IoCManager.Resolve();
+ _shader = protoMan.Index(ShaderId).InstanceUnique();
+ }
+
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ if (args.MapId == MapId.Nullspace)
+ return;
+
+ var handle = args.WorldHandle;
+
+ // Cancel the eye rotation so the shield is always "upright".
+ var eyeRot = args.Viewport.Eye?.Rotation ?? Angle.Zero;
+ var counterRot = Matrix3Helpers.CreateRotation(-eyeRot);
+
+ var query = _entManager.EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var shield))
+ {
+ if (shield.Runtime.Form <= 0f && shield.Runtime.Shatter <= 0f)
+ continue;
+
+ if (!_inventory.TryGetContainingEntity(uid, out var wearer))
+ continue;
+
+ if (!_entManager.TryGetComponent(wearer, out SpriteComponent? sprite) || !sprite.Visible)
+ continue;
+
+ if (!_entManager.TryGetComponent(wearer, out TransformComponent? xform) || xform.MapID != args.MapId)
+ continue;
+
+ if (!TryGetHitboxSize(wearer.Value, sprite, out var extents))
+ continue;
+
+ var size = extents * shield.Scale;
+
+ _shader.SetParameter("progress", GetProgress(shield));
+ _shader.SetParameter("skin_color", shield.Color);
+ _shader.SetParameter("brightness", shield.Brightness);
+ _shader.SetParameter("pixel_grid", shield.PixelGrid);
+ _shader.SetParameter("hex_density", shield.HexDensity);
+ _shader.SetParameter("form_origin", shield.FormOrigin);
+ _shader.SetParameter("fill_level", shield.FillLevel);
+ _shader.SetParameter("line_level", shield.LineLevel);
+ _shader.SetParameter("rim_level", shield.RimLevel);
+ _shader.SetParameter("core_fade", shield.CoreFade);
+ _shader.SetParameter("shard_scale", shield.ShardScale);
+ _shader.SetParameter("alpha_bands", shield.AlphaBands);
+ _shader.SetParameter("breath_depth", shield.BreathDepth);
+
+ handle.UseShader(_shader);
+
+ var worldPos = _transform.GetWorldPosition(xform);
+ handle.SetTransform(Matrix3x2.Multiply(counterRot, Matrix3Helpers.CreateTranslation(worldPos)));
+ handle.DrawTextureRect(Texture.White, Box2.CenteredAround(Vector2.Zero, size));
+ }
+
+ handle.SetTransform(Matrix3x2.Identity);
+ handle.UseShader(null);
+ }
+
+ private bool TryGetHitboxSize(EntityUid uid, SpriteComponent sprite, out Vector2 extents)
+ {
+ extents = Vector2.Zero;
+
+ if (_entManager.TryGetComponent(uid, out FixturesComponent? fixtures) && fixtures.FixtureCount > 0)
+ {
+ var identity = new Transform(Vector2.Zero, 0f);
+ Box2? union = null;
+
+ foreach (var fixture in fixtures.Fixtures.Values)
+ {
+ if (!fixture.Hard)
+ continue;
+
+ var aabb = fixture.Shape.ComputeAABB(identity, 0);
+ union = union?.Union(aabb) ?? aabb;
+ }
+
+ if (union is { } box && box.Width > 0f && box.Height > 0f)
+ {
+ extents = box.Size;
+ return true;
+ }
+ }
+
+ var bounds = _sprite.GetLocalBounds((uid, sprite));
+ extents = bounds.Size;
+ return extents is { X: > 0f, Y: > 0f };
+ }
+
+ private static float GetProgress(PersonalShieldComponent shield)
+ {
+ return shield.Runtime.Shatter > 0f
+ ? 1f + MathF.Min(shield.Runtime.Shatter, 1f)
+ : shield.Runtime.Form;
+ }
+}
diff --git a/Content.Client/_Mono/PersonalShield/PersonalShieldSystem.cs b/Content.Client/_Mono/PersonalShield/PersonalShieldSystem.cs
new file mode 100644
index 00000000000..97d7756308e
--- /dev/null
+++ b/Content.Client/_Mono/PersonalShield/PersonalShieldSystem.cs
@@ -0,0 +1,20 @@
+using Robust.Client.Graphics;
+
+namespace Content.Client._Mono.PersonalShield;
+
+public sealed partial class PersonalShieldSystem : EntitySystem
+{
+ [Dependency] private IOverlayManager _overlayMan = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ _overlayMan.AddOverlay(new PersonalShieldOverlay());
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ _overlayMan.RemoveOverlay();
+ }
+}
diff --git a/Content.Client/_Mono/Radar/RadarBlipsSystem.cs b/Content.Client/_Mono/Radar/RadarBlipsSystem.cs
index c3e70837eb4..8f88c528140 100644
--- a/Content.Client/_Mono/Radar/RadarBlipsSystem.cs
+++ b/Content.Client/_Mono/Radar/RadarBlipsSystem.cs
@@ -1,4 +1,5 @@
using System.Numerics;
+using System.Linq;
using Content.Shared._Mono.Radar;
using Content.Shared.Projectiles;
using Robust.Shared.Map;
@@ -18,6 +19,7 @@ public sealed partial class RadarBlipsSystem : EntitySystem
private TimeSpan _lastUpdatedTime;
private List _blips = new();
+ private List _missiles = new();
private List _hitscans = new();
private List _configPalette = new();
@@ -26,6 +28,7 @@ public sealed partial class RadarBlipsSystem : EntitySystem
// cached results to avoid allocating on every draw/frame
private readonly List _cachedBlipData = new();
+ private readonly List _cachedMissileData = new();
public override void Initialize()
{
@@ -39,6 +42,8 @@ public override void Initialize()
private void HandleReceiveBlips(GiveBlipsEvent ev, EntitySessionEventArgs args)
{
_configPalette = ev.ConfigPalette;
+ _blips = ev.Blips;
+ _missiles = ev.Missiles;
_hitscans = ev.HitscanLines;
_lastUpdatedTime = _timing.CurTime;
@@ -164,6 +169,52 @@ public List GetCurrentBlips()
return _cachedBlipData;
}
+ ///
+ /// Gets the missile vectors to be rendered on the radar
+ ///
+ public List GetMissileLines()
+ {
+ // clear the cache and bail early if the data is stale
+ _cachedMissileData.Clear();
+ if (_timing.CurTime.TotalSeconds - _lastUpdatedTime.TotalSeconds > BlipStaleSeconds)
+ return _cachedMissileData;
+
+ // populate the cached list instead of allocating a new one each frame
+ foreach (var missile in _missiles)
+ {
+ var tiedBlip = _blips.FirstOrDefault(x => x.Uid == missile.Uid);
+ if (tiedBlip == default)
+ continue;
+
+ var coord = tiedBlip.Position;
+ var color = Color.FromHex("#00AACC");
+ var colorArcs = Color.FromHex("#FF0040");
+
+ var predictedPosStart = new NetCoordinates(missile.Uid, coord.Position + tiedBlip.Vel * (float)(_timing.CurTime - _lastUpdatedTime).TotalSeconds);
+ var posEnd = Vector2.Create(
+ predictedPosStart.X + (missile.Range / 2) * (float)Math.Cos(tiedBlip.Rotation + Math.PI * -0.5),
+ predictedPosStart.Y + (missile.Range / 2) * (float)Math.Sin(tiedBlip.Rotation + Math.PI * -0.5));
+ var predictedPosEnd = new NetCoordinates(missile.Uid, posEnd);
+
+ _cachedMissileData.Add(new(missile.Uid, predictedPosStart, predictedPosEnd, color));
+ if (missile.ScanArc > 0)
+ {
+ var posEndLeft = Vector2.Create(
+ predictedPosStart.X + (missile.Range) * (float)Math.Cos(tiedBlip.Rotation + Math.PI * -0.5 - (missile.ScanArc * 0.5)),
+ predictedPosStart.Y + (missile.Range) * (float)Math.Sin(tiedBlip.Rotation + Math.PI * -0.5 - (missile.ScanArc * 0.5)));
+ var posEndRight = Vector2.Create(
+ predictedPosStart.X + (missile.Range) * (float)Math.Cos(tiedBlip.Rotation + Math.PI * -0.5 + (missile.ScanArc * 0.5)),
+ predictedPosStart.Y + (missile.Range) * (float)Math.Sin(tiedBlip.Rotation + Math.PI * -0.5+ (missile.ScanArc * 0.5)));
+ var predictedPosLeft = new NetCoordinates(missile.Uid, posEndLeft);
+ var predictedPosRight = new NetCoordinates(missile.Uid, posEndRight);
+ _cachedMissileData.Add(new(missile.Uid, predictedPosStart, predictedPosLeft, colorArcs));
+ _cachedMissileData.Add(new(missile.Uid, predictedPosStart, predictedPosRight, colorArcs));
+ }
+ }
+
+ return _cachedMissileData;
+ }
+
///
/// Gets the hitscan lines to be rendered on the radar
///
@@ -184,3 +235,11 @@ public record struct BlipData
EntityUid? GridUid,
BlipConfig Config
);
+
+public record struct MissileVectorData
+(
+ NetEntity NetUid,
+ NetCoordinates PositionStart,
+ NetCoordinates PositionEnd,
+ Color Color
+);
diff --git a/Content.Client/_NF/Kitchen/UI/AssemblerBoundUserInterface.cs b/Content.Client/_NF/Kitchen/UI/AssemblerBoundUserInterface.cs
index 0c8d22d7dcc..ca261474948 100644
--- a/Content.Client/_NF/Kitchen/UI/AssemblerBoundUserInterface.cs
+++ b/Content.Client/_NF/Kitchen/UI/AssemblerBoundUserInterface.cs
@@ -20,26 +20,16 @@ public sealed class AssemblerBoundUserInterface : BoundUserInterface
[ViewVariables]
private readonly Dictionary _reagents = new();
- private readonly string _menuTitle;
- private readonly string _leftFlavorText;
+ private readonly string? _menuTitle;
+ private readonly string? _leftFlavorText;
public AssemblerBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
{
- if ((MicrowaveUiKey)uiKey == MicrowaveUiKey.MedicalAssemblerKey)
+ if (EntMan.TryGetComponent(owner, out MicrowaveComponent? component))
{
- _menuTitle = "assembler-menu-medical-title";
- _leftFlavorText = "assembler-menu-medical-footer-flavor-left";
+ _menuTitle = component.MenuTitle;
+ _leftFlavorText = component.FlavorText;
}
- else if ((MicrowaveUiKey)uiKey == MicrowaveUiKey.ArcFurnaceKey) // Mono - added arc furnace key
- {
- _menuTitle = "assembler-menu-arc-furnace-title";
- _leftFlavorText = "assembler-menu-arc-furnace-footer-flavor-left";
- }
- else
- {
- _menuTitle = "assembler-menu-title";
- _leftFlavorText = "assembler-menu-footer-flavor-left";
- } // End mono
}
protected override void Open()
@@ -53,8 +43,8 @@ protected override void Open()
SendPredictedMessage(new MicrowaveEjectSolidIndexedMessage(EntMan.GetNetEntity(_solids[args.ItemIndex])));
};
- _menu.Title = Loc.GetString(_menuTitle);
- _menu.LeftFooter.Text = Loc.GetString(_leftFlavorText);
+ _menu.Title = Loc.GetString(_menuTitle ?? string.Empty);
+ _menu.LeftFooter.Text = Loc.GetString(_leftFlavorText ?? string.Empty);
}
protected override void UpdateState(BoundUserInterfaceState state)
diff --git a/Content.Client/_Rat/Overwatch/OverwatchAnnouncementOverlay.cs b/Content.Client/_Rat/Overwatch/OverwatchAnnouncementOverlay.cs
new file mode 100644
index 00000000000..076690c6007
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchAnnouncementOverlay.cs
@@ -0,0 +1,212 @@
+using System.Numerics;
+using System.Text;
+using Content.Client.Resources;
+using Robust.Client.Graphics;
+using Robust.Client.ResourceManagement;
+using Robust.Shared.Enums;
+using Robust.Shared.Timing;
+
+namespace Content.Client._Rat.Overwatch;
+
+///
+/// Оверлей для отображения объявлений Overwatch.
+///
+public sealed class OverwatchAnnouncementOverlay : Overlay
+{
+ private const string FontPath = "/Fonts/Helvetica/Helvetica-Bold.ttf";
+ private const int TitleFontSize = 20;
+ private const int MessageFontSize = 25;
+ private const float TitleAnimationDuration = 1.5f;
+ private const float MessageAnimationDuration = 2.5f;
+ private const float AnnouncementDisplayDuration = 5f;
+
+ private readonly IResourceCache _cache;
+ private readonly IGameTiming _timing;
+
+ public override OverlaySpace Space => OverlaySpace.ScreenSpace;
+
+ private Font _titleFont = default!;
+ private Font _messageFont = default!;
+
+ private string? Title;
+ private int TitleIndex;
+ private bool TitleReverse;
+ private Vector2 TitlePosition;
+ private TimeSpan TitleCharInterval;
+ private Color TitleColor;
+ private TimeSpan _nextUpdTitle;
+
+ private string? Text;
+ private int Index;
+ private bool Reverse;
+ private Vector2 Position;
+ private TimeSpan CharInterval;
+ private Color TextColor;
+ private TimeSpan _nextUpd;
+
+ public OverwatchAnnouncementOverlay(IResourceCache cache, IGameTiming timing)
+ {
+ _cache = cache;
+ _timing = timing;
+ _titleFont = _cache.GetFont(FontPath, TitleFontSize);
+ _messageFont = _cache.GetFont(FontPath, MessageFontSize);
+ }
+
+ ///
+ /// Сбрасывает все параметры оверлея.
+ ///
+ public void Reset()
+ {
+ Title = null;
+ TitleIndex = 0;
+ TitleReverse = false;
+ TitlePosition = Vector2.Zero;
+ TitleColor = Color.White;
+ _nextUpdTitle = TimeSpan.Zero;
+
+ Text = null;
+ Index = 0;
+ Reverse = false;
+ Position = Vector2.Zero;
+ TextColor = Color.White;
+ _nextUpd = TimeSpan.Zero;
+ }
+
+ ///
+ /// Устанавливает текст объявления для отображения с эффектом печатной машинки.
+ ///
+ /// Заголовок объявления.
+ /// Текст сообщения.
+ /// Цвет текста.
+ public void SetText(string title, string message, Color color)
+ {
+ Title = title;
+ TitleIndex = 0;
+ TitleReverse = false;
+ TitlePosition = Vector2.Zero;
+ TitleColor = color;
+ _nextUpdTitle = TimeSpan.Zero;
+
+ TitleCharInterval = title.Length > 0
+ ? TimeSpan.FromSeconds(TitleAnimationDuration / title.Length)
+ : TimeSpan.Zero;
+
+ Text = message;
+ Index = 0;
+ Reverse = false;
+ Position = Vector2.Zero;
+ TextColor = color;
+ _nextUpd = TimeSpan.Zero;
+
+ CharInterval = message.Length > 0
+ ? TimeSpan.FromSeconds(MessageAnimationDuration / message.Length)
+ : TimeSpan.Zero;
+ }
+
+ ///
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ if (string.IsNullOrEmpty(Text))
+ return;
+
+ var viewport = new Vector2(args.ViewportBounds.Width, args.ViewportBounds.Height);
+
+ if (Position == Vector2.Zero)
+ Position = CalcPosition(_messageFont, Text, viewport, 180);
+
+ args.ScreenHandle.DrawString(_messageFont, Position, Text[..Index], TextColor);
+
+ if (TitlePosition == Vector2.Zero)
+ {
+ var titleSize = CalcTextSize(_titleFont, Title);
+ TitlePosition = new Vector2(Position.X, Position.Y - titleSize.Y - 10);
+ }
+
+ DrawTitle(args);
+
+ if (_nextUpd > _timing.CurTime)
+ return;
+
+ if (!Reverse && Index == Text.Length)
+ {
+ Reverse = true;
+ _nextUpd += TimeSpan.FromSeconds(AnnouncementDisplayDuration);
+ return;
+ }
+
+ if (Reverse && Index == 0)
+ {
+ Reset();
+ return;
+ }
+
+ Index = Reverse ? Index - 1 : Index + 1;
+
+ if (_nextUpd == TimeSpan.Zero)
+ _nextUpd = _timing.CurTime;
+ _nextUpd += CharInterval;
+ }
+
+ ///
+ /// Отрисовывает заголовок объявления с эффектом печатной машинки.
+ ///
+ private void DrawTitle(in OverlayDrawArgs args)
+ {
+ if (string.IsNullOrEmpty(Title))
+ return;
+
+ args.ScreenHandle.DrawString(_titleFont, TitlePosition, Title[..TitleIndex], TitleColor);
+
+ if (_nextUpdTitle > _timing.CurTime)
+ return;
+
+ if (!TitleReverse && TitleIndex == Title.Length)
+ {
+ TitleReverse = true;
+ _nextUpdTitle += TimeSpan.FromSeconds(AnnouncementDisplayDuration);
+ return;
+ }
+
+ if (TitleReverse && TitleIndex == 0)
+ {
+ Title = null;
+ return;
+ }
+
+ TitleIndex = TitleReverse ? TitleIndex - 1 : TitleIndex + 1;
+
+ if (_nextUpdTitle == TimeSpan.Zero)
+ _nextUpdTitle = _timing.CurTime;
+ _nextUpdTitle += TitleCharInterval;
+ }
+
+ ///
+ /// Вычисляет позицию центрирования текста с учётом размера вьюпорта.
+ ///
+ private Vector2 CalcPosition(Font font, string str, Vector2 viewport, int yOffset)
+ {
+ var strSize = CalcTextSize(font, str);
+
+ return new Vector2((viewport.X - strSize.X) / 2, strSize.Y + yOffset);
+ }
+
+ ///
+ /// Вычисляет размер текста в пикселях.
+ ///
+ private Vector2 CalcTextSize(Font font, string? str)
+ {
+ Vector2 strSize = new();
+ if (string.IsNullOrEmpty(str))
+ return strSize;
+
+ foreach (Rune r in str)
+ {
+ if (font.TryGetCharMetrics(r, 1, out var metrics))
+ {
+ strSize.X += metrics.Width;
+ strSize.Y = Math.Max(strSize.Y, metrics.Height);
+ }
+ }
+ return strSize;
+ }
+}
diff --git a/Content.Client/_Rat/Overwatch/OverwatchBoundUserInterface.cs b/Content.Client/_Rat/Overwatch/OverwatchBoundUserInterface.cs
new file mode 100644
index 00000000000..4f3cb3d002e
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchBoundUserInterface.cs
@@ -0,0 +1,140 @@
+using Content.Shared._Rat.Overwatch;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._Rat.Overwatch;
+
+///
+/// BUI для консоли Overwatch.
+///
+public sealed class OverwatchBoundUserInterface : BoundUserInterface
+{
+ private OverwatchWindow? _window;
+
+ public OverwatchBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ ///
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow();
+ _window.Initialize(this);
+
+ _window.OnClose += () =>
+ {
+ _window = null;
+ };
+ }
+
+ ///
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (state is OverwatchUpdateState updateState)
+ {
+ _window?.UpdateState(updateState);
+ }
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ _window?.Dispose();
+ _window = null;
+ base.Dispose(disposing);
+ }
+
+ ///
+ /// Запрашивает переключение вида на камеру цели.
+ ///
+ /// Сущность цели для наблюдения.
+ public void ViewCamera(NetEntity target)
+ {
+ SendMessage(new OverwatchViewCameraMessage(target));
+ }
+
+ ///
+ /// Запрашивает остановку текущего наблюдения.
+ ///
+ public void StopWatching()
+ {
+ SendMessage(new OverwatchStopWatchingMessage());
+ }
+
+ ///
+ /// Запрашивает установку фильтра по статусу участника.
+ ///
+ /// Статус для фильтрации или null для сброса.
+ public void SetStatusFilter(OverwatchMemberStatus? status)
+ {
+ SendMessage(new OverwatchSetStatusFilterMessage(status));
+ }
+
+ ///
+ /// Запрашивает установку фильтра по отряду.
+ ///
+ /// ID отряда для фильтрации или null для сброса.
+ public void SetSquadFilter(int? squadId)
+ {
+ SendMessage(new OverwatchSetSquadFilterMessage(squadId));
+ }
+
+ ///
+ /// Запрашивает установку поискового запроса.
+ ///
+ /// Строка поискового запроса.
+ public void SetSearchQuery(string query)
+ {
+ SendMessage(new OverwatchSetSearchMessage(query));
+ }
+
+ ///
+ /// Запрашивает создание нового отряда.
+ ///
+ /// Название нового отряда.
+ public void CreateSquad(string squadName)
+ {
+ SendMessage(new OverwatchCreateSquadMessage(squadName));
+ }
+
+ ///
+ /// Запрашивает удаление отряда.
+ ///
+ /// ID отряда для удаления.
+ public void DeleteSquad(int squadId)
+ {
+ SendMessage(new OverwatchDeleteSquadMessage(squadId));
+ }
+
+ ///
+ /// Запрашивает назначение игрока в отряд.
+ ///
+ /// Сущность игрока.
+ /// ID отряда для назначения.
+ public void AssignSquad(NetEntity player, int squadId)
+ {
+ SendMessage(new OverwatchAssignSquadMessage(player, squadId));
+ }
+
+ ///
+ /// Запрашивает удаление игрока из отряда.
+ ///
+ /// Сущность игрока.
+ public void RemoveSquadMember(NetEntity player)
+ {
+ SendMessage(new OverwatchRemoveSquadMemberMessage(player));
+ }
+
+ ///
+ /// Запрашивает отправку объявления.
+ ///
+ /// Текст объявления.
+ /// ID целевого отряда или null для всей фракции.
+ public void SendAnnouncement(string message, int? targetSquadId = null)
+ {
+ SendMessage(new OverwatchSendMessageAnnouncement(message, targetSquadId));
+ }
+}
diff --git a/Content.Client/_Rat/Overwatch/OverwatchConsoleSystem.cs b/Content.Client/_Rat/Overwatch/OverwatchConsoleSystem.cs
new file mode 100644
index 00000000000..ad4b143279f
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchConsoleSystem.cs
@@ -0,0 +1,259 @@
+using Content.Shared._Rat.Overwatch;
+using Robust.Client.Audio;
+using Robust.Client.Graphics;
+using Robust.Client.Player;
+using Robust.Client.ResourceManagement;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Map;
+using Robust.Shared.Timing;
+using System.Numerics;
+
+namespace Content.Client._Rat.Overwatch;
+
+///
+/// Клиентская система для ретрансляции звуков при наблюдении через камеру Overwatch.
+///
+public sealed class OverwatchConsoleSystem : EntitySystem
+{
+ ///
+ /// Максимальное расстояние для проверки звуков (оптимизация производительности).
+ ///
+ private const float MaxSoundRelayDistance = 50f;
+
+ [Dependency] private AudioSystem _audio = default!;
+ [Dependency] private IEyeManager _eye = default!;
+ [Dependency] private IPlayerManager _player = default!;
+ [Dependency] private SharedTransformSystem _transform = default!;
+ [Dependency] private IOverlayManager _overlay = default!;
+ [Dependency] private IResourceCache _cache = default!;
+ [Dependency] private IGameTiming _timing = default!;
+
+ ///
+ /// Кэш ретранслируемых звуков для отслеживания активных ретрансляций.
+ ///
+ private readonly Dictionary _relayedSounds = new();
+
+ ///
+ /// Временный список звуков для ретрансляции в текущем кадре.
+ ///
+ private readonly List<(EntityUid Uid, AudioComponent Audio, RatOverwatchRelayedSoundComponent? Relay, EntityCoordinates Position)> _toRelay = new();
+
+ private OverwatchAnnouncementOverlay _announcementOverlay = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeNetworkEvent(OnAnnouncement);
+
+ SubscribeLocalEvent(OnRelayedRemove);
+ SubscribeLocalEvent(OnRelayedRemove);
+ SubscribeLocalEvent(OnLocalWatchingInit);
+ SubscribeLocalEvent(OnLocalWatchingRemoved);
+
+ _announcementOverlay = new(_cache, _timing);
+ _overlay.AddOverlay(_announcementOverlay);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ _overlay.RemoveOverlay(_announcementOverlay);
+ }
+
+ ///
+ /// Обработчик события объявления Overwatch.
+ ///
+ private void OnAnnouncement(OverwatchAnnouncementEvent ev)
+ {
+ var title = Loc.GetString("overwatch-announcement-title",
+ ("overwatchTitle", ev.OverwatchTitle),
+ ("targetName", ev.TargetName));
+ _announcementOverlay.SetText(title, ev.Message, ev.Color);
+ }
+
+ private void OnLocalWatchingInit(Entity ent, ref ComponentInit args)
+ {
+ if (_player.LocalEntity != ent.Owner || !ent.Comp.Watching.HasValue)
+ return;
+
+ var watchingNet = GetNetEntity(ent.Comp.Watching.Value);
+ _announcementOverlay?.Reset();
+ }
+
+ private void OnLocalWatchingRemoved(Entity ent, ref ComponentRemove args)
+ {
+ if (_player.LocalEntity != ent.Owner)
+ return;
+
+ _announcementOverlay.Reset();
+ CleanupAllRelayedSounds();
+ }
+
+ ///
+ /// Обработчик удаления компонента ретрансляции звука.
+ ///
+ private void OnRelayedRemove(Entity ent, ref T args)
+ {
+ TryDeleteRelayed(ent.Comp.Relay);
+ }
+
+ ///
+ /// Удаляет ретранслируемую сущность звука если она клиентская.
+ ///
+ private void TryDeleteRelayed(EntityUid? relay)
+ {
+ if (relay == null)
+ return;
+
+ if (IsClientSide(relay.Value))
+ QueueDel(relay);
+ }
+
+ ///
+ /// Удаляет ретранслируемый звук из кэша и очищает сущность.
+ ///
+ private void RemoveRelayedSound(EntityUid soundUid)
+ {
+ if (_relayedSounds.Remove(soundUid, out var relayedUid) && relayedUid.Valid)
+ {
+ if (IsClientSide(relayedUid))
+ QueueDel(relayedUid);
+ }
+ }
+
+ ///
+ /// Очищает все ретранслируемые звуки при выходе из режима наблюдения.
+ ///
+ private void CleanupAllRelayedSounds()
+ {
+ foreach (var relayedUid in _relayedSounds.Values)
+ {
+ if (relayedUid.Valid && IsClientSide(relayedUid))
+ QueueDel(relayedUid);
+ }
+ _relayedSounds.Clear();
+
+ var relayQuery = AllEntityQuery();
+ while (relayQuery.MoveNext(out var uid, out var relay))
+ {
+ TryDeleteRelayed(relay.Relay);
+ RemCompDeferred(uid);
+ }
+ }
+
+ ///
+ /// Очищает устаревшие ретранслируемые звуки которые больше не слышны.
+ ///
+ private void CleanupStaleRelayedSounds(HashSet activeSounds)
+ {
+ var toRemove = new List();
+ foreach (var soundUid in _relayedSounds.Keys)
+ {
+ if (!activeSounds.Contains(soundUid))
+ toRemove.Add(soundUid);
+ }
+
+ foreach (var soundUid in toRemove)
+ {
+ RemoveRelayedSound(soundUid);
+ }
+ }
+
+ ///
+ /// Создаёт или обновляет ретранслируемый звук на новой позиции.
+ ///
+ private void UpdateOrCreateRelayedSound(
+ EntityUid uid,
+ AudioComponent audio,
+ MapCoordinates eyePosition,
+ Vector2 delta,
+ EntityUid player)
+ {
+ var position = eyePosition.Offset(delta);
+
+ if (_relayedSounds.TryGetValue(uid, out var relayedUid) && relayedUid.Valid)
+ {
+ _transform.SetMapCoordinates(relayedUid, position);
+ return;
+ }
+
+ var entityPosition = _transform.ToCoordinates(position);
+ _toRelay.Add((uid, audio, null, entityPosition));
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ if (_player.LocalEntity is not { } player ||
+ !TryComp(player, out RatOverwatchWatchingComponent? watching) ||
+ !watching.Watching.HasValue ||
+ !TryComp(player, out TransformComponent? playerTransform))
+ {
+ CleanupAllRelayedSounds();
+ return;
+ }
+
+ _toRelay.Clear();
+
+ var eyePosition = _eye.CurrentEye.Position;
+ var listenerCoords = _transform.ToCoordinates(eyePosition);
+ var maxDistanceSquared = MaxSoundRelayDistance * MaxSoundRelayDistance;
+
+ var activeSounds = new HashSet();
+
+ var query = AllEntityQuery();
+ while (query.MoveNext(out var uid, out var audio, out var xform))
+ {
+ if (IsClientSide(uid))
+ continue;
+
+ if (eyePosition.MapId != xform.MapID)
+ continue;
+
+ var audioCoords = xform.Coordinates;
+ if (!audioCoords.TryDelta(EntityManager, _transform, listenerCoords, out var delta))
+ continue;
+
+ var distanceSquared = delta.LengthSquared();
+
+ if (distanceSquared <= audio.MaxDistance * audio.MaxDistance)
+ {
+ RemoveRelayedSound(uid);
+ continue;
+ }
+
+ if (distanceSquared > maxDistanceSquared)
+ continue;
+
+ activeSounds.Add(uid);
+ UpdateOrCreateRelayedSound(uid, audio, eyePosition, delta, player);
+ }
+
+ foreach (var (uid, audio, _, coordinates) in _toRelay)
+ {
+ var relayedAudio = _audio.PlayStatic(
+ new SoundPathSpecifier(audio.FileName),
+ player,
+ coordinates,
+ audio.Params
+ );
+
+ if (relayedAudio is not { Entity: var relayedAudioEnt })
+ continue;
+
+ _audio.SetPlaybackPosition(relayedAudioEnt, audio.PlaybackPosition);
+
+ _relayedSounds[uid] = relayedAudioEnt;
+
+ if (TryComp(uid, out var relayedComp))
+ {
+ relayedComp.Relay = relayedAudioEnt;
+ }
+ }
+
+ CleanupStaleRelayedSounds(activeSounds);
+ }
+}
diff --git a/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml b/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml
new file mode 100644
index 00000000000..e7fbc90bf6a
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml.cs b/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml.cs
new file mode 100644
index 00000000000..0f52c127f86
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml.cs
@@ -0,0 +1,733 @@
+using System.Linq;
+using Content.Client.UserInterface.Controls;
+using Content.Shared._Rat.Overwatch;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._Rat.Overwatch;
+
+///
+/// Окно консоли Overwatch для наблюдения за членами фракции.
+///
+[GenerateTypedNameReferences]
+public sealed partial class OverwatchWindow : FancyWindow
+{
+ private const int MinMembersGridWidth = 400;
+ private const int MinAdminPanelWidth = 350;
+ private const int WindowWidth = 1200;
+ private const int WindowHeight = 750;
+ private const int HeaderMarginTop = 5;
+ private const int HeaderMarginBottom = 2;
+ private const int SquadFilterAllId = 0;
+ private const int SquadFilterUnassignedId = -1;
+ private const int AnnouncementTargetAllId = 0;
+
+ private OverwatchBoundUserInterface? _ui;
+ private readonly Dictionary _memberRows = new();
+ private List _allMembers = new();
+
+ ///
+ /// Кэш для сохранения состояния наблюдения при обновлении UI.
+ ///
+ private readonly Dictionary _watchingStateCache = new();
+
+ private bool _anyDropdownOpen;
+ private bool _justOpenedSquadSelect;
+ private OverwatchUpdateState? _pendingState;
+
+ private OverwatchMemberStatus? _statusFilter;
+ private int? _squadFilterId;
+ private string _searchQuery = "";
+
+ private Dictionary _availableSquads = new();
+ private Color _factionColor;
+
+ ///
+ /// Инициализирует окно Overwatch и настраивает обработчики событий.
+ ///
+ public OverwatchWindow()
+ {
+ RobustXamlLoader.Load(this);
+
+ StopWatchingButton.OnPressed += _ =>
+ {
+ _ui?.StopWatching();
+ StopWatchingButton.Visible = false;
+ };
+
+ OnKeyBindDown += evt =>
+ {
+ if (_justOpenedSquadSelect)
+ {
+ _justOpenedSquadSelect = false;
+ return;
+ }
+ CloseAllSquadSelects();
+ };
+
+ InitializeFilters();
+ InitializeAdminPanel();
+ }
+
+ ///
+ /// Инициализирует фильтры UI (статус, отряд, поиск).
+ ///
+ private void InitializeFilters()
+ {
+ StatusFilter.AddItem(Loc.GetString("overwatch-status-all"), SquadFilterAllId);
+ StatusFilter.AddItem(Loc.GetString("overwatch-status-alive"), (int)OverwatchMemberStatus.Alive + 1);
+ StatusFilter.AddItem(Loc.GetString("overwatch-status-ssd"), (int)OverwatchMemberStatus.SSD + 1);
+ StatusFilter.AddItem(Loc.GetString("overwatch-status-dead"), (int)OverwatchMemberStatus.Dead + 1);
+
+ StatusFilter.OnItemSelected += args =>
+ {
+ _statusFilter = args.Id == SquadFilterAllId
+ ? null
+ : (OverwatchMemberStatus)(args.Id - 1);
+ StatusFilter.SelectId(args.Id);
+ ApplyFilters();
+ };
+ StatusFilter.SelectId(SquadFilterAllId);
+
+ _squadFilterId = SquadFilterAllId;
+
+ SquadFilter.AddItem(Loc.GetString("overwatch-squad-all"), SquadFilterAllId);
+ SquadFilter.AddItem(Loc.GetString("overwatch-squad-unassigned"), SquadFilterUnassignedId);
+ SquadFilter.SelectId(SquadFilterAllId);
+
+ SquadFilter.OnItemSelected += args =>
+ {
+ _squadFilterId = args.Id;
+ SquadFilter.SelectId(args.Id);
+ ApplyFilters();
+ };
+
+ SearchInput.OnTextEntered += _ =>
+ {
+ _searchQuery = SearchInput.Text.Trim();
+ ApplyFilters();
+ };
+ SearchInput.OnTextChanged += _ =>
+ {
+ _searchQuery = SearchInput.Text.Trim();
+ ApplyFilters();
+ };
+ }
+
+ ///
+ /// Инициализирует BUI для взаимодействия с сервером.
+ ///
+ /// BUI консоли Overwatch.
+ public void Initialize(OverwatchBoundUserInterface ui)
+ {
+ _ui = ui;
+ }
+
+ ///
+ /// Обработчик начала наблюдения за участником - сбрасывает наблюдение у остальных.
+ ///
+ /// Выбранная строка участника.
+ private void OnMemberStartWatching(OverwatchMemberRow selectedRow)
+ {
+ foreach (var row in _memberRows.Values)
+ {
+ if (row != selectedRow)
+ {
+ row.SetWatching(false);
+ }
+ }
+ }
+
+ ///
+ /// Обновляет состояние UI на основе данных от сервера.
+ ///
+ /// Состояние от сервера.
+ public void UpdateState(OverwatchUpdateState state)
+ {
+ if (_ui == null)
+ return;
+
+ _allMembers = state.Members;
+ _factionColor = state.FactionColor;
+
+ if (!string.IsNullOrEmpty(state.SearchQuery) && string.IsNullOrEmpty(_searchQuery))
+ {
+ _searchQuery = state.SearchQuery;
+ SearchInput.Text = state.SearchQuery;
+ }
+
+ var squadsChanged = _availableSquads.Count != state.AvailableSquads.Count ||
+ state.AvailableSquads.Any(kvp =>
+ !_availableSquads.ContainsKey(kvp.Key) ||
+ _availableSquads[kvp.Key] != kvp.Value);
+
+ _availableSquads = state.AvailableSquads;
+
+ if (_anyDropdownOpen)
+ {
+ _pendingState = state;
+ return;
+ }
+
+ _pendingState = null;
+ ApplyStateToUi(squadsChanged);
+ }
+
+ private void ApplyStateToUi(bool squadsChanged)
+ {
+ AdminPanel.Visible = true;
+
+ if (squadsChanged)
+ UpdateSquadFilter(_availableSquads);
+
+ UpdateSquadsList(_availableSquads);
+ ApplyFilters();
+ StopWatchingButton.Visible = _memberRows.Values.Any(r => r.IsWatching);
+ }
+
+ ///
+ /// Закрывает все открытые селекторы отрядов.
+ ///
+ public void CloseAllSquadSelects()
+ {
+ var anyWasOpen = false;
+ foreach (var row in _memberRows.Values)
+ {
+ if (row.SquadSelect.Visible)
+ {
+ row.SquadSelect.Visible = false;
+ row.SquadButton.Visible = true;
+ anyWasOpen = true;
+ }
+ }
+ if (anyWasOpen)
+ {
+ _anyDropdownOpen = false;
+
+ if (_pendingState != null)
+ {
+ var pending = _pendingState;
+ _pendingState = null;
+ UpdateState(pending);
+ }
+ }
+ }
+
+ ///
+ /// Устанавливает флаг только что открытого селектора для предотвращения немедленного закрытия.
+ ///
+ public void SetJustOpenedSquadSelect()
+ {
+ _justOpenedSquadSelect = true;
+ }
+
+ ///
+ /// Обновляет фильтр отрядов на основе доступных отрядов.
+ ///
+ /// Словарь доступных отрядов (ID -> Название).
+ private void UpdateSquadFilter(Dictionary availableSquads)
+ {
+ var currentSelection = SquadFilter.SelectedId;
+ var currentAnnouncementSelection = AnnouncementTarget.SelectedId;
+
+ SquadFilter.Clear();
+ SquadFilter.AddItem(Loc.GetString("overwatch-squad-all"), SquadFilterAllId);
+ SquadFilter.AddItem(Loc.GetString("overwatch-squad-unassigned"), SquadFilterUnassignedId);
+
+ foreach (var squad in availableSquads.OrderBy(s => s.Value))
+ {
+ SquadFilter.AddItem(squad.Value, squad.Key);
+ }
+
+ _availableSquads = availableSquads;
+ SquadFilter.SelectId(currentSelection);
+ UpdateAnnouncementTargets(currentAnnouncementSelection);
+ }
+
+ ///
+ /// Обновляет список получателей объявлений.
+ ///
+ /// Текущий выбранный элемент.
+ private void UpdateAnnouncementTargets(int? currentSelection = null)
+ {
+ AnnouncementTarget.Clear();
+ AnnouncementTarget.AddItem(Loc.GetString("overwatch-admin-announcement-to-all"), AnnouncementTargetAllId);
+
+ foreach (var squad in _availableSquads.OrderBy(s => s.Value))
+ {
+ AnnouncementTarget.AddItem(squad.Value, squad.Key);
+ }
+
+ AnnouncementTarget.SelectId(AnnouncementTargetAllId);
+ }
+
+ ///
+ /// Обновляет список отрядов в админ-панели.
+ ///
+ /// Словарь отрядов (ID -> Название).
+ private void UpdateSquadsList(Dictionary squads)
+ {
+ SquadsList.DisposeAllChildren();
+
+ foreach (var squad in squads.OrderBy(s => s.Value))
+ {
+ var memberCount = _allMembers.Count(m => m.SquadId == squad.Key);
+
+ var squadBox = new BoxContainer
+ {
+ Orientation = BoxContainer.LayoutOrientation.Horizontal,
+ SeparationOverride = 5,
+ HorizontalExpand = true
+ };
+
+ var squadLabel = new Label
+ {
+ Text = $"{squad.Value} {Loc.GetString("overwatch-squad-member-count", ("count", memberCount))}",
+ HorizontalExpand = true
+ };
+
+ var deleteButton = new Button
+ {
+ Text = Loc.GetString("overwatch-squad-delete-button"),
+ Disabled = memberCount > 0
+ };
+
+ if (memberCount == 0)
+ {
+ deleteButton.OnPressed += _ => _ui?.DeleteSquad(squad.Key);
+ }
+
+ squadBox.AddChild(squadLabel);
+ squadBox.AddChild(deleteButton);
+ SquadsList.AddChild(squadBox);
+ }
+ }
+
+ ///
+ /// Инициализирует админ-панель (объявления, создание/удаление отрядов).
+ ///
+ private void InitializeAdminPanel()
+ {
+ AnnouncementTarget.AddItem(Loc.GetString("overwatch-admin-announcement-to-all"), AnnouncementTargetAllId);
+
+ AnnouncementTarget.OnItemSelected += args =>
+ {
+ AnnouncementTarget.SelectId(args.Id);
+ };
+ AnnouncementTarget.SelectId(AnnouncementTargetAllId);
+
+ SendAnnouncementButton.OnPressed += _ =>
+ {
+ var message = AnnouncementInput.Text?.Trim();
+ if (string.IsNullOrEmpty(message))
+ return;
+
+ var targetSquadId = AnnouncementTarget.SelectedId == AnnouncementTargetAllId
+ ? null
+ : GetSquadIdByIndex(AnnouncementTarget.SelectedId);
+
+ _ui?.SendAnnouncement(message, targetSquadId);
+ AnnouncementInput.Text = "";
+ };
+
+ CreateSquadButton.OnPressed += _ =>
+ {
+ var squadName = NewSquadNameInput.Text?.Trim();
+
+ if (string.IsNullOrEmpty(squadName))
+ return;
+
+ _ui?.CreateSquad(squadName);
+ NewSquadNameInput.Text = "";
+ };
+ }
+
+ ///
+ /// Получает ID отряда по индексу.
+ ///
+ /// Индекс отряда.
+ /// ID отряда или null если выбрано "Всей фракции".
+ private int? GetSquadIdByIndex(int index)
+ {
+ if (index == AnnouncementTargetAllId)
+ return null;
+
+ return index;
+ }
+
+ ///
+ /// Применяет фильтры к списку участников и обновляет сетку.
+ ///
+ private void ApplyFilters()
+ {
+ int? squadFilterId = null;
+ bool filterUnassigned = false;
+
+ if (_squadFilterId.HasValue)
+ {
+ if (_squadFilterId.Value == SquadFilterUnassignedId)
+ {
+ filterUnassigned = true;
+ }
+ else if (_squadFilterId.Value != SquadFilterAllId)
+ {
+ squadFilterId = _squadFilterId.Value;
+ }
+ }
+
+ var filteredMembers = _allMembers.Where(m =>
+ {
+ if (_statusFilter.HasValue && m.Status != _statusFilter.Value)
+ return false;
+
+ if (filterUnassigned)
+ {
+ if (m.SquadId.HasValue)
+ return false;
+ }
+ else if (squadFilterId.HasValue)
+ {
+ if (!m.SquadId.HasValue || m.SquadId.Value != squadFilterId.Value)
+ return false;
+ }
+
+ if (!string.IsNullOrEmpty(_searchQuery) &&
+ !m.Name.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return true;
+ }).ToList();
+
+ UpdateMembersGrid(filteredMembers);
+ }
+
+ ///
+ /// Обновляет сетку участников с применением группировки по отрядам.
+ ///
+ /// Отфильтрованный список участников.
+ private void UpdateMembersGrid(List members)
+ {
+ var oldWatchingStates = new Dictionary();
+ foreach (var (member, row) in _memberRows)
+ {
+ if (row.IsWatching)
+ {
+ oldWatchingStates[member] = true;
+ }
+ }
+
+ MembersGrid.DisposeAllChildren();
+ _memberRows.Clear();
+
+ foreach (var (member, isWatching) in oldWatchingStates)
+ {
+ _watchingStateCache[member] = isWatching;
+ }
+
+ var membersBySquad = new Dictionary>();
+
+ foreach (var member in members)
+ {
+ var squadId = member.SquadId ?? SquadFilterUnassignedId;
+ if (!membersBySquad.ContainsKey(squadId))
+ {
+ membersBySquad[squadId] = new List();
+ }
+ membersBySquad[squadId].Add(member);
+ }
+
+ var sortedSquads = membersBySquad.OrderBy(kvp =>
+ {
+ if (kvp.Key == SquadFilterUnassignedId)
+ return "";
+ return _availableSquads.TryGetValue(kvp.Key, out var name) ? name : kvp.Key.ToString();
+ }).ToList();
+
+ foreach (var squad in sortedSquads)
+ {
+ string squadName;
+ if (squad.Key == SquadFilterUnassignedId)
+ {
+ squadName = Loc.GetString("overwatch-squad-unassigned");
+ }
+ else if (_availableSquads.TryGetValue(squad.Key, out var name))
+ {
+ squadName = $"{name} {Loc.GetString("overwatch-squad-member-count", ("count", squad.Value.Count))}";
+ }
+ else
+ {
+ squadName = $"{Loc.GetString("overwatch-squad-label")} {squad.Key} {Loc.GetString("overwatch-squad-member-count", ("count", squad.Value.Count))}";
+ }
+
+ var headerLabel = new Label
+ {
+ Text = squadName,
+ FontColorOverride = _factionColor,
+ Margin = new Thickness(0, HeaderMarginTop, 0, HeaderMarginBottom)
+ };
+ MembersGrid.AddChild(headerLabel);
+
+ foreach (var memberData in squad.Value)
+ {
+ if (_ui == null)
+ continue;
+
+ var row = new OverwatchMemberRow(memberData, _ui, OnMemberStartWatching, () => _availableSquads, _availableSquads, (isOpen) => _anyDropdownOpen = isOpen, this);
+
+ if (_watchingStateCache.TryGetValue(memberData.Member, out var isWatching))
+ {
+ row.SetWatching(isWatching);
+ }
+
+ MembersGrid.AddChild(row);
+ _memberRows[memberData.Member] = row;
+ }
+ }
+ }
+
+ ///
+ /// Устанавливает состояние наблюдения для участника.
+ ///
+ /// Сущность участника.
+ /// Флаг состояния наблюдения.
+ public void SetWatching(NetEntity member, bool isWatching)
+ {
+ _watchingStateCache[member] = isWatching;
+
+ if (_memberRows.TryGetValue(member, out var row))
+ {
+ row.SetWatching(isWatching);
+ StopWatchingButton.Visible = _memberRows.Values.Any(r => r.IsWatching);
+ }
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ foreach (var row in _memberRows.Values)
+ {
+ row.Dispose();
+ }
+ _memberRows.Clear();
+ base.Dispose(disposing);
+ }
+}
+
+///
+/// Строка с информацией о члене фракции в окне Overwatch.
+///
+public sealed class OverwatchMemberRow : BoxContainer
+{
+ private readonly Label _nameLabel;
+ private readonly Label _jobLabel;
+ private readonly Label _statusLabel;
+ private readonly Label _coordinatesLabel;
+ private readonly Button _viewButton;
+ private readonly Button _squadButton;
+ private readonly OptionButton _squadSelect;
+ private readonly OverwatchBoundUserInterface _ui;
+ private readonly Action _onStartWatching;
+ private readonly Func> _getSquadsFunc;
+ private readonly Dictionary? _availableSquadsCache;
+ private readonly Action? _onDropdownOpenChanged;
+ private readonly OverwatchWindow? _window;
+ private NetEntity _member;
+ private int? _currentSquadId;
+ private System.Numerics.Vector2? _coordinates;
+
+ ///
+ /// Флаг текущего состояния наблюдения за участником.
+ ///
+ public bool IsWatching { get; private set; }
+
+ ///
+ /// Селектор отрядов для назначения участника.
+ ///
+ public OptionButton SquadSelect => _squadSelect;
+
+ ///
+ /// Кнопка открытия селектора отрядов.
+ ///
+ public Button SquadButton => _squadButton;
+
+ ///
+ /// Инициализирует строку участника.
+ ///
+ public OverwatchMemberRow(
+ OverwatchMemberData data,
+ OverwatchBoundUserInterface ui,
+ Action onStartWatching,
+ Func> getSquadsFunc,
+ Dictionary? availableSquadsCache = null,
+ Action? onDropdownOpenChanged = null,
+ OverwatchWindow? window = null)
+ {
+ _ui = ui;
+ _member = data.Member;
+ _onStartWatching = onStartWatching;
+ _getSquadsFunc = getSquadsFunc;
+ _currentSquadId = data.SquadId;
+ _coordinates = data.Coordinates;
+ _availableSquadsCache = availableSquadsCache;
+ _onDropdownOpenChanged = onDropdownOpenChanged;
+ _window = window;
+
+ Orientation = LayoutOrientation.Horizontal;
+ SeparationOverride = 5;
+ HorizontalExpand = true;
+
+ _nameLabel = new Label{};
+ _jobLabel = new Label();
+ _statusLabel = new Label();
+ _coordinatesLabel = new Label();
+ _viewButton = new Button { Text = Loc.GetString("overwatch-member-view-camera-button"), MinSize = new Vector2i(120, 0) };
+ _squadButton = new Button { Text = Loc.GetString("overwatch-member-squad-assign"), MinSize = new Vector2i(60, 0) };
+ _squadSelect = new OptionButton { MinSize = new Vector2i(150, 0), Visible = false };
+
+ AddChild(_nameLabel);
+ AddChild(_jobLabel);
+ AddChild(new Label { Text = "|" });
+ AddChild(_statusLabel);
+ AddChild(_coordinatesLabel);
+ AddChild(new Label { Text = "|" });
+ AddChild(_viewButton);
+ AddChild(_squadButton);
+ AddChild(_squadSelect);
+
+ _viewButton.OnPressed += _ =>
+ {
+ _onStartWatching(this);
+ _ui.ViewCamera(_member);
+ _window?.SetWatching(_member, true);
+ };
+
+ _squadButton.OnPressed += _ =>
+ {
+ RefreshSquadSelect();
+ _squadSelect.Visible = true;
+ _squadButton.Visible = false;
+ _onDropdownOpenChanged?.Invoke(true);
+ _window?.SetJustOpenedSquadSelect();
+ };
+
+ _squadSelect.OnItemSelected += args =>
+ {
+ _squadSelect.Visible = false;
+ _squadButton.Visible = true;
+ _onDropdownOpenChanged?.Invoke(false);
+
+ if (args.Id == -1)
+ {
+ _ui?.RemoveSquadMember(_member);
+ _currentSquadId = null;
+ }
+ else
+ {
+ _ui?.AssignSquad(_member, args.Id);
+ _currentSquadId = args.Id;
+ }
+
+ _squadSelect.SelectId(args.Id);
+ };
+
+ Update(data);
+ }
+
+ ///
+ /// Обновляет список отрядов в селекторе.
+ ///
+ public void RefreshSquadSelect()
+ {
+ var squads = _availableSquadsCache ?? _getSquadsFunc();
+
+ _squadSelect.Clear();
+ _squadSelect.AddItem(Loc.GetString("overwatch-member-squad-no-squad"), -1);
+
+ foreach (var squad in squads.OrderBy(s => s.Value))
+ {
+ _squadSelect.AddItem(squad.Value, squad.Key);
+ }
+
+ if (_currentSquadId.HasValue && squads.ContainsKey(_currentSquadId.Value))
+ {
+ _squadSelect.SelectId(_currentSquadId.Value);
+ }
+ else
+ {
+ _squadSelect.SelectId(-1);
+ }
+ }
+
+ ///
+ /// Обновляет отображаемые данные участника.
+ ///
+ public void Update(OverwatchMemberData data)
+ {
+ if (_squadSelect.Visible)
+ {
+ _squadSelect.Visible = false;
+ _squadButton.Visible = true;
+ _onDropdownOpenChanged?.Invoke(false);
+ }
+
+ _nameLabel.Text = data.Name;
+ _jobLabel.Text = data.JobTitle;
+ _statusLabel.Text = data.Status switch
+ {
+ OverwatchMemberStatus.Alive => Loc.GetString("overwatch-member-status-alive"),
+ OverwatchMemberStatus.SSD => Loc.GetString("overwatch-member-status-ssd"),
+ OverwatchMemberStatus.Dead => Loc.GetString("overwatch-member-status-dead"),
+ _ => Loc.GetString("overwatch-member-status-unknown")
+ };
+
+ _statusLabel.FontColorOverride = data.Status switch
+ {
+ OverwatchMemberStatus.Alive => Color.Green,
+ OverwatchMemberStatus.SSD => Color.Yellow,
+ OverwatchMemberStatus.Dead => Color.Red,
+ _ => Color.White
+ };
+
+ _coordinates = data.Coordinates;
+ _coordinatesLabel.Text = data.Coordinates.HasValue
+ ? Loc.GetString("overwatch-member-coordinates", ("x", Math.Round(data.Coordinates.Value.X, 1)), ("y", Math.Round(data.Coordinates.Value.Y, 1)))
+ : Loc.GetString("overwatch-member-coordinates-none");
+
+ _viewButton.Disabled = !data.HasCamera;
+ _currentSquadId = data.SquadId;
+ UpdateButtonState();
+ }
+
+ ///
+ /// Устанавливает состояние наблюдения за участником.
+ ///
+ public void SetWatching(bool isWatching)
+ {
+ IsWatching = isWatching;
+ UpdateButtonState();
+ }
+
+ ///
+ /// Обновляет текст и стиль кнопки просмотра в зависимости от состояния.
+ ///
+ private void UpdateButtonState()
+ {
+ if (IsWatching)
+ {
+ _viewButton.Text = Loc.GetString("overwatch-member-watching-button");
+ _viewButton.StyleClasses.Clear();
+ _viewButton.StyleClasses.Add("openBoth");
+ }
+ else if (_viewButton.Disabled)
+ {
+ _viewButton.Text = Loc.GetString("overwatch-member-no-camera-button");
+ _viewButton.StyleClasses.Clear();
+ }
+ else
+ {
+ _viewButton.Text = Loc.GetString("overwatch-member-view-camera-button");
+ _viewButton.StyleClasses.Clear();
+ }
+ }
+}
diff --git a/Content.Client/_SCP/Vignette/VignetteOverylay.cs b/Content.Client/_SCP/Vignette/VignetteOverylay.cs
index ffec8208ec7..fc01e40da6c 100644
--- a/Content.Client/_SCP/Vignette/VignetteOverylay.cs
+++ b/Content.Client/_SCP/Vignette/VignetteOverylay.cs
@@ -1,3 +1,4 @@
+using Content.Client.Viewport;
using Robust.Client.Graphics;
using Robust.Shared.Enums;
using Robust.Shared.Prototypes;
@@ -26,6 +27,11 @@ protected override void Draw(in OverlayDrawArgs args)
if (ScreenTexture is null)
return;
+ // Mono: Prevent a stupid bug with Z eyes causing multiple vignettes showing causing rendering issues.
+ // This took me too long to find for how simple it was.
+ if (args.Viewport.Eye is ScalingViewport.ZEye)
+ return;
+
_shader.SetParameter("SCREEN_TEXTURE", ScreenTexture);
_shader.SetParameter("vignette_color", Color.Black.WithAlpha(0.9f));
diff --git a/Content.Client/_White/Overlays/ThermalVisionOverlay.cs b/Content.Client/_White/Overlays/ThermalVisionOverlay.cs
index d3bc92a8030..445e1507ee2 100644
--- a/Content.Client/_White/Overlays/ThermalVisionOverlay.cs
+++ b/Content.Client/_White/Overlays/ThermalVisionOverlay.cs
@@ -3,6 +3,7 @@
using Content.Client.Stealth;
using Content.Shared._White.Overlays;
using Content.Shared.Body.Components;
+using Content.Shared.Chemistry.Components;
using Content.Shared.Stealth.Components;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
@@ -89,6 +90,10 @@ protected override void Draw(in OverlayDrawArgs args)
if (!CanSee(uid, sprite) || !body.ThermalVisibility)
continue;
+ // Mono - teargas hides you from smoke
+ if (_entity.HasComponent(uid))
+ continue;
+
var entity = uid;
if (_container.TryGetOuterContainer(uid, xform, out var container))
diff --git a/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs b/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs
index d5d9379182c..fddd6b7922c 100644
--- a/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs
+++ b/Content.IntegrationTests/Tests/Construction/Interaction/MachineConstruction.cs
@@ -2,6 +2,7 @@
namespace Content.IntegrationTests.Tests.Construction.Interaction;
+// Mono - these checks are updated to use economy parts, i'm too lazy to put comments for the changed lines
public sealed class MachineConstruction : InteractionTest
{
private const string MachineFrame = "MachineFrame";
@@ -9,6 +10,8 @@ public sealed class MachineConstruction : InteractionTest
private const string ProtolatheBoard = "ProtolatheMachineCircuitboard";
private const string Protolathe = "Protolathe";
private const string Beaker = "Beaker";
+ private const string Processor = "MicroprocessorEconomy1";
+ private const string Motor = "MotorEconomy1";
[Test]
public async Task ConstructProtolathe()
@@ -18,7 +21,7 @@ public async Task ConstructProtolathe()
ClientAssertPrototype(Unfinished, Target);
await Interact(Wrench, Cable);
AssertPrototype(MachineFrame);
- await Interact(ProtolatheBoard, Bin1, Bin1, Manipulator1, Manipulator1, Beaker, Beaker, Screw);
+ await Interact(ProtolatheBoard, Bin1, Bin1, Manipulator1, Manipulator1, Beaker, Beaker, Processor, Processor, Motor, Screw);
AssertPrototype(Protolathe);
}
@@ -36,6 +39,8 @@ await AssertEntityLookup(
(Steel, 5),
(Cable, 1),
(Beaker, 2),
+ (Motor, 1),
+ (Processor, 2),
(Manipulator1, 2),
(Bin1, 2),
(ProtolatheBoard, 1));
@@ -52,7 +57,7 @@ public async Task ChangeMachine()
// Change it into an autolathe
await InteractUsing("AutolatheMachineCircuitboard");
AssertPrototype(MachineFrame);
- await Interact(Bin1, Bin1, Bin1, Manipulator1, Glass, Screw);
+ await Interact(Bin1, Bin1, Bin1, Manipulator1, Glass, Beaker, Beaker, Motor, Screw);
AssertPrototype("Autolathe");
}
@@ -74,7 +79,7 @@ public async Task UpgradeLathe()
AssertPrototype(MachineFrame);
// Reconstruct with better parts.
- await Interact(ProtolatheBoard, Bin4, Bin4, Manipulator4, Manipulator4, Beaker, Beaker);
+ await Interact(ProtolatheBoard, Bin4, Bin4, Manipulator4, Manipulator4, Beaker, Beaker, Processor, Processor, Motor);
await Interact(Screw);
AssertPrototype(Protolathe);
diff --git a/Content.IntegrationTests/Tests/PostMapInitTest.cs b/Content.IntegrationTests/Tests/PostMapInitTest.cs
index 7be890eb95c..df5c36c8955 100644
--- a/Content.IntegrationTests/Tests/PostMapInitTest.cs
+++ b/Content.IntegrationTests/Tests/PostMapInitTest.cs
@@ -508,6 +508,7 @@ public async Task AllMapsTested()
!x.MapPath.ToString().StartsWith("/SharedMaps/_Forge/Shuttles") && // Forge: skip shuttles (not loaded as maps)
!x.MapPath.ToString().StartsWith("/Maps/_Mono/Deprecated") && // Mono: skip deprecated (not loaded as maps)
!x.MapPath.ToString().StartsWith("/Maps/_Mono/ShuttleEvent") && // Mono: skip shuttleevents (not loaded as maps)
+ !x.MapPath.ToString().StartsWith("/Maps/_Mono/Supercapitals") && // Mono: skip supercapitals (not loaded as maps)
!x.MapPath.ToString().StartsWith("/Maps/_Mono/POI")) // Mono: skip POIs (not loaded as maps)
)
// End Frontier
diff --git a/Content.Server/Access/Systems/IdCardSystem.cs b/Content.Server/Access/Systems/IdCardSystem.cs
index 647a3ca7511..23e90839dcf 100644
--- a/Content.Server/Access/Systems/IdCardSystem.cs
+++ b/Content.Server/Access/Systems/IdCardSystem.cs
@@ -1,6 +1,7 @@
using System.Linq;
using Content.Server.Administration.Logs;
using Content.Server.Kitchen.Components;
+using Content.Shared.Kitchen.Components;
using Content.Server.Popups;
using Content.Shared.Access;
using Content.Shared.Access.Components;
@@ -108,7 +109,12 @@ private void OnMicrowaved(EntityUid uid, IdCardComponent component, BeingMicrowa
}
// Give them a wonderful new access to compensate for everything
- var random = _random.Pick(_prototypeManager.EnumeratePrototypes().ToArray());
+ var ids = _prototypeManager.EnumeratePrototypes().Where(x => x.CanAddToIdCard).ToArray();
+
+ if (ids.Length == 0)
+ return;
+
+ var random = _random.Pick(ids);
access.Tags.Add(random.ID);
Dirty(uid, access);
diff --git a/Content.Server/Body/Components/EmitSoundOnInternalsActiveComponent.cs b/Content.Server/Body/Components/EmitSoundOnInternalsActiveComponent.cs
new file mode 100644
index 00000000000..0e964ea8307
--- /dev/null
+++ b/Content.Server/Body/Components/EmitSoundOnInternalsActiveComponent.cs
@@ -0,0 +1,13 @@
+
+using Content.Shared.Sound.Components;
+
+namespace Content.Shared.Sound.Components
+{
+ ///
+ /// Whenever a and internals are on, play a sound in PVS range.
+ ///
+ [RegisterComponent]
+ public sealed partial class EmitSoundOnInternalsActiveComponent : BaseEmitSoundComponent
+ {
+ }
+}
diff --git a/Content.Server/Body/Systems/InternalsNoiseSystem.cs b/Content.Server/Body/Systems/InternalsNoiseSystem.cs
new file mode 100644
index 00000000000..4d99d9b425f
--- /dev/null
+++ b/Content.Server/Body/Systems/InternalsNoiseSystem.cs
@@ -0,0 +1,32 @@
+using Content.Shared.Sound.Components;
+using Content.Server.Body.Components;
+
+
+using Robust.Shared.Audio.Systems;
+using Content.Server.Body.Systems;
+
+namespace Content.Shared.Sound.Systems;
+
+public sealed class InternalsNoiseSystem : EntitySystem
+{
+
+ [Dependency] private readonly InternalsSystem _internals = default!;
+
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnInhaleLocation);
+ }
+
+ public void OnInhaleLocation(Entity ent, ref InhaleLocationEvent args)
+ {
+ if (_internals.AreInternalsWorking(ent))
+ _audio.PlayPvs(ent.Comp.Sound, ent.Owner);
+
+ }
+
+}
\ No newline at end of file
diff --git a/Content.Server/Botany/Systems/BotanySystem.Seed.cs b/Content.Server/Botany/Systems/BotanySystem.Seed.cs
index a11db96b0b9..280abc33a6f 100644
--- a/Content.Server/Botany/Systems/BotanySystem.Seed.cs
+++ b/Content.Server/Botany/Systems/BotanySystem.Seed.cs
@@ -1,5 +1,6 @@
using Content.Server.Botany.Components;
using Content.Server.Kitchen.Components;
+using Content.Shared.Kitchen.Components;
using Content.Server.Popups;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Botany;
diff --git a/Content.Server/Botany/Systems/LogSystem.cs b/Content.Server/Botany/Systems/LogSystem.cs
index 5d524a53027..ca1feebc351 100644
--- a/Content.Server/Botany/Systems/LogSystem.cs
+++ b/Content.Server/Botany/Systems/LogSystem.cs
@@ -1,5 +1,6 @@
using Content.Server.Botany.Components;
using Content.Server.Kitchen.Components;
+using Content.Shared.Kitchen.Components;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Interaction;
using Content.Shared.Random;
diff --git a/Content.Server/Botany/Systems/PlantHolderSystem.cs b/Content.Server/Botany/Systems/PlantHolderSystem.cs
index e6041b98508..998fe6cf18f 100644
--- a/Content.Server/Botany/Systems/PlantHolderSystem.cs
+++ b/Content.Server/Botany/Systems/PlantHolderSystem.cs
@@ -1,6 +1,7 @@
using Content.Server.Atmos.EntitySystems;
using Content.Server.Botany.Components;
using Content.Server.Kitchen.Components;
+using Content.Shared.Kitchen.Components;
using Content.Server.Popups;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Atmos;
diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs
index 7eaa72642cb..4c97bd8fe69 100644
--- a/Content.Server/Chat/Systems/ChatSystem.cs
+++ b/Content.Server/Chat/Systems/ChatSystem.cs
@@ -560,6 +560,7 @@ private void SendEntitySpeak(
else
{
var nameEv = new TransformSpeakerNameEvent(source, Name(source));
+ nameEv.FromRadio = true; //Mono
RaiseLocalEvent(source, nameEv);
name = nameEv.VoiceName;
// Check for a speech verb override
diff --git a/Content.Server/Construction/Components/ElectronicsBoardComponent.cs b/Content.Server/Construction/Components/ElectronicsBoardComponent.cs
new file mode 100644
index 00000000000..734edfba236
--- /dev/null
+++ b/Content.Server/Construction/Components/ElectronicsBoardComponent.cs
@@ -0,0 +1,16 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.Construction.Components;
+
+///
+/// Used in construction graphs for building wall-mounted electronic devices.
+///
+[RegisterComponent]
+public sealed partial class ElectronicsBoardComponent : Component
+{
+ ///
+ /// The device that is produced when the construction is completed.
+ ///
+ [DataField(required: true)]
+ public EntProtoId Prototype;
+}
diff --git a/Content.Server/Construction/NodeEntities/BoardNodeEntity.cs b/Content.Server/Construction/NodeEntities/BoardNodeEntity.cs
index 1631f846049..aaa1160a88a 100644
--- a/Content.Server/Construction/NodeEntities/BoardNodeEntity.cs
+++ b/Content.Server/Construction/NodeEntities/BoardNodeEntity.cs
@@ -51,9 +51,12 @@ public sealed partial class BoardNodeEntity : IGraphNodeEntity
if (args.EntityManager.TryGetComponent(board, out MachineBoardComponent? machine))
return machine.Prototype;
- if(args.EntityManager.TryGetComponent(board, out ComputerBoardComponent? computer))
+ if (args.EntityManager.TryGetComponent(board, out ComputerBoardComponent? computer))
return computer.Prototype;
+ if (args.EntityManager.TryGetComponent(board, out ElectronicsBoardComponent? electronics))
+ return electronics.Prototype;
+
return null;
}
diff --git a/Content.Server/DeviceLinking/Systems/GunSignalControlSystem.cs b/Content.Server/DeviceLinking/Systems/GunSignalControlSystem.cs
index bc49f64b938..81b219c94ab 100644
--- a/Content.Server/DeviceLinking/Systems/GunSignalControlSystem.cs
+++ b/Content.Server/DeviceLinking/Systems/GunSignalControlSystem.cs
@@ -1,3 +1,4 @@
+using Content.Server._Mono.SpaceArtillery.Components;
using Content.Server.DeviceLinking.Components;
using Content.Shared.DeviceLinking.Events;
using Content.Shared.Weapons.Ranged.Components;
@@ -7,8 +8,8 @@ namespace Content.Server.DeviceLinking.Systems;
public sealed partial class GunSignalControlSystem : EntitySystem
{
- [Dependency] private DeviceLinkSystem _signalSystem = default!;
- [Dependency] private SharedGunSystem _gun = default!;
+ [Dependency] private readonly DeviceLinkSystem _signalSystem = default!;
+ [Dependency] private readonly SharedGunSystem _gun = default!;
public override void Initialize()
{
@@ -18,11 +19,17 @@ public override void Initialize()
private void OnInit(Entity gunControl, ref MapInitEvent args)
{
+ if (HasComp(gunControl))
+ return;
+
_signalSystem.EnsureSinkPorts(gunControl, gunControl.Comp.TriggerPort, gunControl.Comp.TogglePort, gunControl.Comp.OnPort, gunControl.Comp.OffPort);
}
private void OnSignalReceived(Entity gunControl, ref SignalReceivedEvent args)
{
+ if (HasComp(gunControl))
+ return;
+
if (!TryComp(gunControl, out var gun))
return;
@@ -32,8 +39,7 @@ private void OnSignalReceived(Entity gunControl, ref
if (!TryComp(gunControl, out var autoShoot))
return;
- if (args.Port == gunControl.Comp.TogglePort)
- _gun.SetEnabled(gunControl, autoShoot, !autoShoot.Enabled);
+ if (args.Port == gunControl.Comp.TogglePort) _gun.SetEnabled(gunControl, autoShoot, !autoShoot.Enabled);
if (args.Port == gunControl.Comp.OnPort)
_gun.SetEnabled(gunControl, autoShoot, true);
diff --git a/Content.Server/DeviceNetwork/Systems/DeviceNetworkJammerSystem.cs b/Content.Server/DeviceNetwork/Systems/DeviceNetworkJammerSystem.cs
index cdd23d9f592..5bef94ecde8 100644
--- a/Content.Server/DeviceNetwork/Systems/DeviceNetworkJammerSystem.cs
+++ b/Content.Server/DeviceNetwork/Systems/DeviceNetworkJammerSystem.cs
@@ -38,5 +38,4 @@ private void BeforePacketSent(Entity xform, ref BeforePacket
}
}
}
-
}
diff --git a/Content.Server/DeviceNetwork/Systems/DeviceNetworkSystem.cs b/Content.Server/DeviceNetwork/Systems/DeviceNetworkSystem.cs
index 59b5ed30fce..d26cd3ab180 100644
--- a/Content.Server/DeviceNetwork/Systems/DeviceNetworkSystem.cs
+++ b/Content.Server/DeviceNetwork/Systems/DeviceNetworkSystem.cs
@@ -349,7 +349,7 @@ private void SendToConnections(ReadOnlySpan connections,
if (connection.Owner == packet.Sender)
continue;
- BeforePacketSentEvent beforeEv = new(packet.Sender, xform, senderPos, connection.NetIdEnum.ToString());
+ BeforePacketSentEvent beforeEv = new(packet.Sender, xform, senderPos, connection.NetIdEnum.ToString(), packet.Frequency);
RaiseLocalEvent(connection.Owner, beforeEv, false);
if (!beforeEv.Cancelled)
diff --git a/Content.Server/Entry/IgnoredComponents.cs b/Content.Server/Entry/IgnoredComponents.cs
index 58264e14adb..978e28549df 100644
--- a/Content.Server/Entry/IgnoredComponents.cs
+++ b/Content.Server/Entry/IgnoredComponents.cs
@@ -20,7 +20,8 @@ public static class IgnoredComponents
"LightFade",
"HolidayRsiSwap",
"OptionsVisualizer",
- "MultipartMachineGhost"
+ "MultipartMachineGhost",
+ "CEIconSmooth"
};
}
}
diff --git a/Content.Server/Explosion/EntitySystems/ProjectileGrenadeSystem.cs b/Content.Server/Explosion/EntitySystems/ProjectileGrenadeSystem.cs
index 4d1be09a56e..1cb0b8bc149 100644
--- a/Content.Server/Explosion/EntitySystems/ProjectileGrenadeSystem.cs
+++ b/Content.Server/Explosion/EntitySystems/ProjectileGrenadeSystem.cs
@@ -58,7 +58,9 @@ private void FragmentIntoProjectiles(EntityUid uid, ProjectileGrenadeComponent c
var grenadeCoord = _transformSystem.GetMapCoordinates(uid);
var shootCount = 0;
var totalCount = component.Container.ContainedEntities.Count + component.UnspawnedCount;
- var segmentAngle = 360 / totalCount;
+ var segmentAngle = 5; // Mono
+ if (totalCount != 0) // Mono - Stupid fucking sanity check, because it doesn't stop whining about dividing by 0 when I put this shit on a bullet.
+ segmentAngle = 360 / totalCount;
while (TrySpawnContents(grenadeCoord, component, out var contentUid))
{
diff --git a/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs
index 5626f11e0e3..d67a72578a6 100644
--- a/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs
+++ b/Content.Server/GameTicking/Rules/Components/NukeopsRuleComponent.cs
@@ -93,7 +93,7 @@ public sealed partial class NukeopsRuleComponent : Component
public EntityUid? TargetStation;
[DataField]
- public ProtoId Faction = "Syndicate";
+ public ProtoId Faction= "PirateNF"; // Mono
///
/// Path to antagonist alert sound.
@@ -126,13 +126,27 @@ public enum WinType : byte
/// Crew major win. This means they either killed all nukies,
/// or the bomb exploded too far away from the station, or on the nukie moon.
///
- CrewMajor
+ CrewMajor,
+
+ // Mono start
+ ///
+ /// The nuke exploded on the TSF station.
+ ///
+ TSFMajor,
+
+ ///
+ /// The nuke exploded on the PDV station.
+ ///
+ PDVMajor,
+ // Mono end
}
public enum WinCondition : byte
{
NukeExplodedOnCorrectStation,
NukeExplodedOnNukieOutpost,
+ NukeExplodedOnTSFStation,
+ NukeExplodedOnPDVStation,
NukeExplodedOnIncorrectLocation,
NukeActiveInStation,
NukeActiveAtCentCom,
diff --git a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs
index bbb25a6d2ad..841e456e4c1 100644
--- a/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs
+++ b/Content.Server/GameTicking/Rules/NukeopsRuleSystem.cs
@@ -26,6 +26,7 @@
using System.Linq;
using Content.Shared.Station.Components;
using Content.Shared.Store.Components;
+using Robust.Shared.Prototypes;
namespace Content.Server.GameTicking.Rules;
@@ -39,11 +40,14 @@ public sealed partial class NukeopsRuleSystem : GameRuleSystem]
- private const string TelecrystalCurrencyPrototype = "Telecrystal";
+ private static readonly ProtoId TelecrystalCurrencyPrototype = "Telecrystal"; // Mono - cleanup
- [ValidatePrototypeId]
- private const string NukeOpsUplinkTagPrototype = "NukeOpsUplink";
+ private static readonly ProtoId NukeOpsUplinkTagPrototype = "NukeOpsUplink"; // Mono - cleanup
+
+ // Mono start
+ private static readonly ProtoId TsfStationTagPrototype = "TsfStation";
+ private static readonly ProtoId PdvStationTagPrototype = "PdvStation";
+ // Mono end
public override void Initialize()
{
@@ -103,14 +107,15 @@ protected override void AppendRoundEndText(EntityUid uid,
args.AddLine(text);
}
- args.AddLine(Loc.GetString("nukeops-list-start"));
+ /*
+ args.AddLine(Loc.GetString("nukeops-list-start")); // Mono - Unneeded. Lazy.
var antags =_antag.GetAntagIdentifiers(uid);
foreach (var (_, sessionData, name) in antags)
{
args.AddLine(Loc.GetString("nukeops-list-name-user", ("name", name), ("user", sessionData.UserName)));
- }
+ }*/ // Mono - Unneeded. Lazy.
}
private void OnNukeExploded(NukeExplodedEvent ev)
@@ -120,6 +125,24 @@ private void OnNukeExploded(NukeExplodedEvent ev)
{
if (ev.OwningStation != null)
{
+ if (TryComp(ev.OwningStation, out var tags))
+ {
+ if(_tag.HasTag(tags, PdvStationTagPrototype))
+ {
+ nukeops.WinConditions.Add(WinCondition.NukeExplodedOnTSFStation);
+ SetWinType((uid, nukeops), WinType.TSFMajor);
+ _roundEndSystem.EndRound();
+ return;
+ }
+ if(_tag.HasTag(tags, TsfStationTagPrototype))
+ {
+ nukeops.WinConditions.Add(WinCondition.NukeExplodedOnPDVStation);
+ SetWinType((uid, nukeops), WinType.PDVMajor);
+ _roundEndSystem.EndRound();
+ return;
+ }
+ }
+
if (ev.OwningStation == GetOutpost(uid))
{
nukeops.WinConditions.Add(WinCondition.NukeExplodedOnNukieOutpost);
@@ -153,7 +176,7 @@ private void OnNukeExploded(NukeExplodedEvent ev)
nukeops.WinConditions.Add(WinCondition.NukeExplodedOnIncorrectLocation);
}
- _roundEndSystem.EndRound();
+ // _roundEndSystem.EndRound(); // Mono - comment out; we have 2 nukes and a end timer at 4 hours
}
}
@@ -175,6 +198,10 @@ private void OnRoundEnd(Entity ent)
if (ent.Comp.WinType == WinType.OpsMajor || ent.Comp.WinType == WinType.CrewMajor)
return;
+ // Mono - ignore if TSF/PDV major or neutral. I don't care where my disk is.
+ if (ent.Comp.WinType == WinType.PDVMajor || ent.Comp.WinType == WinType.TSFMajor || ent.Comp.WinType == WinType.Neutral)
+ return;
+
var nukeQuery = AllEntityQuery();
var centcomms = _emergency.GetCentcommMaps();
@@ -211,13 +238,15 @@ private void OnRoundEnd(Entity ent)
if (_antag.AllAntagsAlive(ent.Owner))
{
SetWinType(ent, WinType.OpsMinor);
- ent.Comp.WinConditions.Add(WinCondition.AllNukiesAlive);
+ // ent.Comp.WinConditions.Add(WinCondition.AllNukiesAlive); // Mono - Unneeded. Lazy.
return;
}
+ /*
ent.Comp.WinConditions.Add(_antag.AnyAliveAntags(ent.Owner)
? WinCondition.SomeNukiesAlive
: WinCondition.AllNukiesDead);
+ */// Mono - Unneeded. Lazy.
var diskAtCentCom = false;
var diskQuery = AllEntityQuery();
@@ -233,6 +262,7 @@ private void OnRoundEnd(Entity ent)
// If the disk is currently at Central Command, the crew wins - just slightly.
// This also implies that some nuclear operatives have died.
+ /*
SetWinType(ent,
diskAtCentCom
? WinType.CrewMinor
@@ -240,6 +270,7 @@ private void OnRoundEnd(Entity ent)
ent.Comp.WinConditions.Add(diskAtCentCom
? WinCondition.NukeDiskOnCentCom
: WinCondition.NukeDiskNotOnCentCom);
+ */// Mono - Unneeded. Lazy.
}
private void OnNukeDisarm(NukeDisarmSuccessEvent ev)
diff --git a/Content.Server/Gravity/GravityGeneratorComponent.cs b/Content.Server/Gravity/GravityGeneratorComponent.cs
index c715a5e5f35..f1990c49f98 100644
--- a/Content.Server/Gravity/GravityGeneratorComponent.cs
+++ b/Content.Server/Gravity/GravityGeneratorComponent.cs
@@ -16,5 +16,11 @@ public sealed partial class GravityGeneratorComponent : SharedGravityGeneratorCo
///
[ViewVariables]
public bool GravityActive { get; set; } = false;
+
+ ///
+ /// pzn: maximum grid mass (= tiles / ShuttleSystem.TileDensityMultiplier because robustdevs lazy as fuck) that this gravgen can take on planetmaps
+ ///
+ [DataField]
+ public float MaxHandledMass;
}
}
diff --git a/Content.Server/Gravity/GravityGeneratorSystem.cs b/Content.Server/Gravity/GravityGeneratorSystem.cs
index 89829048d0b..6b76ed407ec 100644
--- a/Content.Server/Gravity/GravityGeneratorSystem.cs
+++ b/Content.Server/Gravity/GravityGeneratorSystem.cs
@@ -1,6 +1,8 @@
+using Content.Server._CE.ZLevels.Core; // pzn: gravgen load readout
using Content.Server.Emp; // Frontier: Upstream - #28984
using Content.Server.Power.Components;
using Content.Server.Power.EntitySystems;
+using Content.Shared.Examine; // pzn: mass limit examine
using Content.Shared.Gravity;
namespace Content.Server.Gravity;
@@ -9,6 +11,7 @@ public sealed partial class GravityGeneratorSystem : EntitySystem
{
[Dependency] private GravitySystem _gravitySystem = default!;
[Dependency] private SharedPointLightSystem _lights = default!;
+ [Dependency] private CEZLevelsSystem _zLevels = default!; // pzn: gravgen load readout
public override void Initialize()
{
@@ -18,6 +21,62 @@ public override void Initialize()
SubscribeLocalEvent(OnActivated);
SubscribeLocalEvent(OnDeactivated);
// SubscribeLocalEvent(OnEmpPulse); // Frontier: Upstream - #28984
+ SubscribeLocalEvent(OnExamined); // pzn: mass limit
+ }
+
+ // pzn: state the rated mass capacity so people know why their brick fell out of the sky
+ private void OnExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (ent.Comp.MaxHandledMass == 0f)
+ return;
+
+ if (ent.Comp.MaxHandledMass > 0f)
+ {
+ // raw physics mass ("kilograms"). Yes, a tile is 0.5 of these.
+ // Players can do the math themselves. Fuck you guys.
+ args.PushMarkup(Loc.GetString("gravity-generator-examine-max-mass",
+ ("mass", ent.Comp.MaxHandledMass)));
+ }
+
+ // will it lift
+ if (!TryGetLoad(ent, out var mass, out var capacity))
+ return;
+
+ if (float.IsPositiveInfinity(capacity))
+ {
+ args.PushMarkup(Loc.GetString("gravity-generator-examine-load-unlimited"));
+ return;
+ }
+
+ var percent = mass / capacity * 100f;
+ args.PushMarkup(Loc.GetString("gravity-generator-examine-load",
+ ("percent", MathF.Round(percent)),
+ ("color", LoadColor(percent))));
+ }
+
+ ///
+ /// pzn: pooled load for the grid this gravgen sits on. An idle generator still
+ /// counts its own rating, so you can read what it *would* carry once spun up.
+ ///
+ private bool TryGetLoad(Entity ent, out float mass, out float capacity)
+ {
+ var gridUid = Transform(ent).ParentUid;
+ if (!_zLevels.TryGetGravgenLoad(gridUid, out mass, out capacity))
+ return false;
+
+ if (!ent.Comp.GravityActive)
+ capacity += ent.Comp.MaxHandledMass < 0f ? float.PositiveInfinity : ent.Comp.MaxHandledMass;
+
+ return capacity > 0f;
+ }
+
+ private static string LoadColor(float percent)
+ {
+ var t = Math.Clamp(percent / 100f, 0f, 1f);
+ var color = t < 0.5f
+ ? Color.InterpolateBetween(Color.FromHex("#3fb54a"), Color.FromHex("#e6d227"), t * 2f)
+ : Color.InterpolateBetween(Color.FromHex("#e6d227"), Color.FromHex("#d43d3d"), (t - 0.5f) * 2f);
+ return color.ToHex();
}
public override void Update(float frameTime)
diff --git a/Content.Server/Humanoid/Components/RandomHumanoidAppearanceComponent.cs b/Content.Server/Humanoid/Components/RandomHumanoidAppearanceComponent.cs
index 9e6aa191343..757e3fdd031 100644
--- a/Content.Server/Humanoid/Components/RandomHumanoidAppearanceComponent.cs
+++ b/Content.Server/Humanoid/Components/RandomHumanoidAppearanceComponent.cs
@@ -18,6 +18,7 @@ public sealed partial class RandomHumanoidAppearanceComponent : Component
[DataField] public Color? SkinColor = null; /// Forge-Change End
+ [DataField("randomizeHair")] public bool RandomizeHair = true;
///
/// After randomizing, sets the hair style to this, if possible
///
diff --git a/Content.Server/Humanoid/Systems/RandomHumanoidAppearanceSystem.cs b/Content.Server/Humanoid/Systems/RandomHumanoidAppearanceSystem.cs
index 9ee22803eb8..d74d3549bf5 100644
--- a/Content.Server/Humanoid/Systems/RandomHumanoidAppearanceSystem.cs
+++ b/Content.Server/Humanoid/Systems/RandomHumanoidAppearanceSystem.cs
@@ -43,7 +43,7 @@ private void OnMapInit(EntityUid uid, RandomHumanoidAppearanceComponent componen
profile = profile.WithCharacterAppearance(profile.Appearance.WithSkinColor(skinColor)); /// Forge-Change End
//If we have a specified hair style, change it to this
- if (component.Hair != null)
+ if(component.Hair != null && component.RandomizeHair)
profile = profile.WithCharacterAppearance(profile.Appearance.WithHairStyleName(component.Hair));
_humanoid.LoadProfile(uid, profile, humanoid);
diff --git a/Content.Server/Kitchen/EntitySystems/KitchenSpikeSystem.cs b/Content.Server/Kitchen/EntitySystems/KitchenSpikeSystem.cs
index 691e6fbed8d..a734623bc9f 100644
--- a/Content.Server/Kitchen/EntitySystems/KitchenSpikeSystem.cs
+++ b/Content.Server/Kitchen/EntitySystems/KitchenSpikeSystem.cs
@@ -1,6 +1,7 @@
using Content.Server.Administration.Logs;
using Content.Server.Body.Systems;
using Content.Server.Kitchen.Components;
+using Content.Shared.Kitchen.Components;
using Content.Server.Popups;
using Content.Shared.Chat;
using Content.Shared.Damage;
diff --git a/Content.Server/Kitchen/EntitySystems/MicrowaveSystem.cs b/Content.Server/Kitchen/EntitySystems/MicrowaveSystem.cs
index cc92a6ed019..2d3ae2f9dab 100644
--- a/Content.Server/Kitchen/EntitySystems/MicrowaveSystem.cs
+++ b/Content.Server/Kitchen/EntitySystems/MicrowaveSystem.cs
@@ -23,6 +23,7 @@
using Content.Shared.FixedPoint;
using Content.Shared.Interaction;
using Content.Shared.Interaction.Events;
+using Content.Shared.Kitchen.Components;
using Robust.Shared.Random;
using Robust.Shared.Audio;
using Content.Server.Lightning;
diff --git a/Content.Server/Kitchen/EntitySystems/ReagentGrinderSystem.cs b/Content.Server/Kitchen/EntitySystems/ReagentGrinderSystem.cs
index a40367e50c1..a3205469350 100644
--- a/Content.Server/Kitchen/EntitySystems/ReagentGrinderSystem.cs
+++ b/Content.Server/Kitchen/EntitySystems/ReagentGrinderSystem.cs
@@ -1,6 +1,7 @@
using Content.Server.Chemistry.Containers.EntitySystems; // Frontier
using Content.Server.Construction; // Frontier
using Content.Server.Kitchen.Components;
+using Content.Shared.Kitchen.Components;
using Content.Server.Power.Components;
using Content.Server.Power.EntitySystems;
using Content.Server.Stack;
diff --git a/Content.Server/Kitchen/EntitySystems/SharpSystem.cs b/Content.Server/Kitchen/EntitySystems/SharpSystem.cs
index 2e4c6f7e110..cb2158b11d8 100644
--- a/Content.Server/Kitchen/EntitySystems/SharpSystem.cs
+++ b/Content.Server/Kitchen/EntitySystems/SharpSystem.cs
@@ -5,6 +5,7 @@
using Content.Shared.Administration.Logs;
using Content.Shared.Database;
using Content.Shared.Interaction;
+using Content.Shared.Kitchen.Components;
using Content.Shared.Nutrition.Components;
using Content.Shared.Popups;
using Content.Shared.Storage;
diff --git a/Content.Server/NPC/Components/NPCRangedCombatComponent.cs b/Content.Server/NPC/Components/NPCRangedCombatComponent.cs
index 714eaad00f0..ead604eeb02 100644
--- a/Content.Server/NPC/Components/NPCRangedCombatComponent.cs
+++ b/Content.Server/NPC/Components/NPCRangedCombatComponent.cs
@@ -41,6 +41,13 @@ public sealed partial class NPCRangedCombatComponent : Component
[ViewVariables(VVAccess.ReadWrite)]
public bool TargetInLOS = false;
+ ///
+ /// If true, only opaque objects will block line of sight.
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ // ReSharper disable once InconsistentNaming
+ public bool UseOpaqueForLOSChecks = false;
+
///
/// Delay after target is in LOS before we start shooting.
///
diff --git a/Content.Server/NPC/HTN/HTNComponent.cs b/Content.Server/NPC/HTN/HTNComponent.cs
index 8649b2cbe02..2306371e6da 100644
--- a/Content.Server/NPC/HTN/HTNComponent.cs
+++ b/Content.Server/NPC/HTN/HTNComponent.cs
@@ -11,7 +11,7 @@ public sealed partial class HTNComponent : NPCComponent
/// The base task to use for planning
///
[ViewVariables(VVAccess.ReadWrite),
- DataField("rootTask", required: true)]
+ DataField("rootTask", required: true)]
public HTNCompoundTask RootTask = default!;
///
@@ -75,4 +75,11 @@ public sealed partial class HTNComponent : NPCComponent
///
[DataField]
public float? SleepMaxGridSpeed = null;
+
+
+ ///
+ /// Determines whether plans should be made / updated for this entity
+ ///
+ [DataField]
+ public bool Enabled = true;
}
diff --git a/Content.Server/NPC/HTN/HTNSystem.cs b/Content.Server/NPC/HTN/HTNSystem.cs
index 0346142cc2d..2416a19ac89 100644
--- a/Content.Server/NPC/HTN/HTNSystem.cs
+++ b/Content.Server/NPC/HTN/HTNSystem.cs
@@ -151,6 +151,39 @@ private void OnHTNShutdown(EntityUid uid, HTNComponent component, ComponentShutd
component.PlanningJob = null;
}
+ ///
+ /// Enable / disable the hierarchical task network of an entity
+ ///
+ /// The entity and its
+ /// Set 'true' to enable, or 'false' to disable, the HTN
+ /// Specifies a time in seconds before the entity can start planning a new action (only takes effect when the HTN is enabled)
+ // ReSharper disable once InconsistentNaming
+ [PublicAPI]
+ public void SetHTNEnabled(Entity ent, bool state, float planCooldown = 0f)
+ {
+ if (ent.Comp.Enabled == state)
+ return;
+
+ ent.Comp.Enabled = state;
+ ent.Comp.PlanAccumulator = planCooldown;
+
+ ent.Comp.PlanningToken?.Cancel();
+ ent.Comp.PlanningToken = null;
+
+ if (ent.Comp.Plan != null)
+ {
+ var currentOperator = ent.Comp.Plan.CurrentOperator;
+
+ ShutdownTask(currentOperator, ent.Comp.Blackboard, HTNOperatorStatus.Failed);
+ ShutdownPlan(ent.Comp);
+
+ ent.Comp.Plan = null;
+ }
+
+ if (ent.Comp.Enabled && ent.Comp.PlanAccumulator <= 0)
+ RequestPlan(ent.Comp);
+ }
+
///
/// Forces the NPC to replan.
///
@@ -192,6 +225,9 @@ public HTNUpdateStats UpdateNPC(ref int count, int maxUpdates, float frameTime)
continue;
}
+ if (!comp.Enabled)
+ continue;
+
if (comp.PlanningJob != null)
{
if (comp.PlanningJob.Exception != null)
diff --git a/Content.Server/NPC/HTN/Preconditions/TargetInLOSPrecondition.cs b/Content.Server/NPC/HTN/Preconditions/TargetInLOSPrecondition.cs
index c56be135c70..bb27ae8868f 100644
--- a/Content.Server/NPC/HTN/Preconditions/TargetInLOSPrecondition.cs
+++ b/Content.Server/NPC/HTN/Preconditions/TargetInLOSPrecondition.cs
@@ -1,17 +1,12 @@
using Content.Server.Interaction;
-using Content.Shared.Damage.Components;
using Content.Shared.Physics;
-using Robust.Shared.Physics.Components;
namespace Content.Server.NPC.HTN.Preconditions;
public sealed partial class TargetInLOSPrecondition : HTNPrecondition
{
- [Dependency] private IEntityManager _entManager = default!;
+ [Dependency] private readonly IEntityManager _entManager = default!;
private InteractionSystem _interaction = default!;
- // Mono
- private EntityQuery _physicsQuery;
- private EntityQuery _requireTargetQuery;
[DataField("targetKey")]
public string TargetKey = "Target";
@@ -19,21 +14,13 @@ public sealed partial class TargetInLOSPrecondition : HTNPrecondition
[DataField("rangeKey")]
public string RangeKey = "RangeKey";
- // Mono
- [DataField]
- public CollisionGroup ObstructedMask = CollisionGroup.Opaque;
-
- // Mono
- [DataField]
- public CollisionGroup BulletMask = CollisionGroup.Impassable | CollisionGroup.BulletImpassable;
+ [DataField("opaqueKey")]
+ public bool UseOpaqueForLOSChecksKey = true;
public override void Initialize(IEntitySystemManager sysManager)
{
base.Initialize(sysManager);
_interaction = sysManager.GetEntitySystem();
- // Mono
- _physicsQuery = _entManager.GetEntityQuery();
- _requireTargetQuery = _entManager.GetEntityQuery();
}
public override bool IsMet(NPCBlackboard blackboard)
@@ -44,11 +31,8 @@ public override bool IsMet(NPCBlackboard blackboard)
return false;
var range = blackboard.GetValueOrDefault(RangeKey, _entManager);
- // Mono
- return _interaction.InRangeUnobstructed(owner, target, range, ObstructedMask, predicate: (EntityUid entity) =>
- {
- return _physicsQuery.TryGetComponent(entity, out var physics) && (physics.CollisionLayer & (int)BulletMask) == 0 // ignore if it can't collide with bullets
- || _requireTargetQuery.HasComponent(entity); // or if it requires targeting
- });
+ var collisionGroup = UseOpaqueForLOSChecksKey ? CollisionGroup.Opaque : (CollisionGroup.Impassable | CollisionGroup.InteractImpassable);
+
+ return _interaction.InRangeUnobstructed(owner, target, range, collisionGroup);
}
}
diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/Ranged/GunOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/Ranged/GunOperator.cs
index 0f6cad80960..81d2ccc905c 100644
--- a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/Ranged/GunOperator.cs
+++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Combat/Ranged/GunOperator.cs
@@ -34,13 +34,11 @@ public sealed partial class GunOperator : HTNOperator, IHtnConditionalShutdown
[DataField("requireLOS")]
public bool RequireLOS = false;
- // Mono
- [DataField]
- public CollisionGroup ObstructedMask = CollisionGroup.Opaque;
-
- // Mono
- [DataField]
- public CollisionGroup BulletMask = CollisionGroup.Impassable | CollisionGroup.BulletImpassable;
+ ///
+ /// If true, only opaque objects will block line of sight.
+ ///
+ [DataField("opaqueKey")]
+ public bool UseOpaqueForLOSChecks = false;
// Like movement we add a component and pass it off to the dedicated system.
@@ -65,10 +63,10 @@ public sealed partial class GunOperator : HTNOperator, IHtnConditionalShutdown
public override void Startup(NPCBlackboard blackboard)
{
base.Startup(blackboard);
+
var ranged = _entManager.EnsureComponent(blackboard.GetValue(NPCBlackboard.Owner));
ranged.Target = blackboard.GetValue(TargetKey);
- ranged.ObstructedMask = ObstructedMask; // Mono
- ranged.BulletMask = BulletMask; // Mono
+ ranged.UseOpaqueForLOSChecks = UseOpaqueForLOSChecks;
if (blackboard.TryGetValue(NPCBlackboard.RotateSpeed, out var rotSpeed, _entManager))
{
diff --git a/Content.Server/NPC/Queries/Considerations/TargetTargetingCon.cs b/Content.Server/NPC/Queries/Considerations/TargetTargetingCon.cs
new file mode 100644
index 00000000000..e39af270459
--- /dev/null
+++ b/Content.Server/NPC/Queries/Considerations/TargetTargetingCon.cs
@@ -0,0 +1,12 @@
+namespace Content.Server.NPC.Queries.Considerations;
+
+///
+/// Returns 0f if the NPC has a and the
+/// target entity is exempt from being targeted, otherwise it returns 1f.
+/// See
+/// for further details on turret target validation.
+///
+public sealed partial class TurretTargetingCon : UtilityConsideration
+{
+
+}
\ No newline at end of file
diff --git a/Content.Server/NPC/Systems/NPCCombatSystem.Ranged.cs b/Content.Server/NPC/Systems/NPCCombatSystem.Ranged.cs
index 2e730aa722c..f0abebb7522 100644
--- a/Content.Server/NPC/Systems/NPCCombatSystem.Ranged.cs
+++ b/Content.Server/NPC/Systems/NPCCombatSystem.Ranged.cs
@@ -162,8 +162,10 @@ private void UpdateRanged(float frameTime)
if (comp.LOSAccumulator < 0f)
{
comp.LOSAccumulator += UnoccludedCooldown;
- // Mono
- comp.TargetInLOS = InRangeGoodTarget((gunUid, gun), uid, comp.Target, distance, comp.ShotsThreshold, comp.ObstructedMask, comp.BulletMask);
+
+ // For consistency with NPC steering.
+ var collisionGroup = comp.UseOpaqueForLOSChecks ? CollisionGroup.Opaque : (CollisionGroup.Impassable | CollisionGroup.InteractImpassable);
+ comp.TargetInLOS = _interaction.InRangeUnobstructed(uid, comp.Target, distance + 0.1f, collisionGroup);
}
if (!comp.TargetInLOS)
diff --git a/Content.Server/NPC/Systems/NPCUtilitySystem.cs b/Content.Server/NPC/Systems/NPCUtilitySystem.cs
index e1bcc844682..8df9881b65f 100644
--- a/Content.Server/NPC/Systems/NPCUtilitySystem.cs
+++ b/Content.Server/NPC/Systems/NPCUtilitySystem.cs
@@ -28,6 +28,7 @@
using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Physics;
using Content.Shared.Tools.Systems;
+using Content.Shared.Turrets;
using Content.Shared.Weapons.Melee;
using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Weapons.Ranged.Events;
@@ -69,6 +70,7 @@ public sealed partial class NPCUtilitySystem : EntitySystem
[Dependency] private DestructibleSystem _destructible = default!; // Mono
[Dependency] private GunSystem _gun = default!; // Mono
[Dependency] private NPCCombatSystem _npcCombat = default!;
+ [Dependency] private TurretTargetSettingsSystem _turretTargetSettings = default!;
private EntityQuery _puddleQuery;
private EntityQuery _xformQuery;
@@ -402,6 +404,14 @@ private float GetScore(NPCBlackboard blackboard, EntityUid targetUid, UtilityCon
return 0f;
}
+ case TurretTargetingCon:
+ {
+ if (!TryComp(owner, out var turretTargetSettings) ||
+ _turretTargetSettings.EntityIsTargetForTurret((owner, turretTargetSettings), targetUid))
+ return 1f;
+
+ return 0f;
+ }
case TargetOnFireCon:
{
if (TryComp(targetUid, out FlammableComponent? fire) && fire.OnFire)
diff --git a/Content.Server/Nuke/NukeCodePaperSystem.cs b/Content.Server/Nuke/NukeCodePaperSystem.cs
index ae266b14e62..2ba6fec666b 100644
--- a/Content.Server/Nuke/NukeCodePaperSystem.cs
+++ b/Content.Server/Nuke/NukeCodePaperSystem.cs
@@ -116,7 +116,7 @@ private bool TryGetRelativeNukeCode(
foreach (var (nukeUid, nuke) in nukes)
{
- if (!onlyCurrentStation &&
+ if (!onlyCurrentStation || // Mono - swap to ||
(owningStation == null &&
nuke.OriginMapGrid != (transform.MapID, transform.GridUid) ||
nuke.OriginStation != owningStation))
diff --git a/Content.Server/Nuke/NukeSystem.cs b/Content.Server/Nuke/NukeSystem.cs
index 15f36f283d6..38ff0a18741 100644
--- a/Content.Server/Nuke/NukeSystem.cs
+++ b/Content.Server/Nuke/NukeSystem.cs
@@ -474,9 +474,10 @@ public void ArmBomb(EntityUid uid, NukeComponent? component = null)
// warn a crew
var announcement = Loc.GetString("nuke-component-announcement-armed",
("time", (int) component.RemainingTime),
- ("location", FormattedMessage.RemoveMarkupOrThrow(_navMap.GetNearestBeaconString((uid, nukeXform)))));
+ ("x", x),
+ ("y", y)); // Mono - change to x/y from beacon
var sender = Loc.GetString("nuke-component-announcement-sender");
- _chatSystem.DispatchStationAnnouncement(stationUid ?? uid, announcement, sender, false, null, Color.Red);
+ _chatSystem.DispatchGlobalAnnouncement(announcement, playSound:true, announcementSound:component.AlertSound,colorOverride:Color.Red, sender:sender); // Mono - change to global announcement from station
_sound.PlayGlobalOnStation(uid, _audio.ResolveSound(component.ArmSound));
_nukeSongLength = (float) _audio.GetAudioLength(_selectedNukeSong).TotalSeconds;
diff --git a/Content.Server/Nyanotrasen/Kitchen/EntitySystems/DeepFryerSystem.Storage.cs b/Content.Server/Nyanotrasen/Kitchen/EntitySystems/DeepFryerSystem.Storage.cs
index 3d0686abcfe..508c7159d49 100644
--- a/Content.Server/Nyanotrasen/Kitchen/EntitySystems/DeepFryerSystem.Storage.cs
+++ b/Content.Server/Nyanotrasen/Kitchen/EntitySystems/DeepFryerSystem.Storage.cs
@@ -5,6 +5,7 @@
using Content.Shared.Hands.Components;
using Content.Shared.Interaction;
using Content.Shared.Item;
+using Content.Shared.Kitchen.Components;
using Content.Shared.Nyanotrasen.Kitchen.UI;
using Content.Shared.Storage;
using Content.Shared.Tools.Components;
diff --git a/Content.Server/Nyanotrasen/Kitchen/EntitySystems/DeepFryerSystem.cs b/Content.Server/Nyanotrasen/Kitchen/EntitySystems/DeepFryerSystem.cs
index e74727a579a..e8845399e7e 100644
--- a/Content.Server/Nyanotrasen/Kitchen/EntitySystems/DeepFryerSystem.cs
+++ b/Content.Server/Nyanotrasen/Kitchen/EntitySystems/DeepFryerSystem.cs
@@ -33,6 +33,7 @@
using Content.Shared.IdentityManagement;
using Content.Shared.Interaction;
using Content.Shared.Item;
+using Content.Shared.Kitchen.Components;
using Content.Shared.Mobs.Components;
using Content.Shared.Mobs.Systems;
using Content.Shared.Movement.Events;
diff --git a/Content.Server/PAI/PAISystem.cs b/Content.Server/PAI/PAISystem.cs
index b9616a85b54..44be02cbbdd 100644
--- a/Content.Server/PAI/PAISystem.cs
+++ b/Content.Server/PAI/PAISystem.cs
@@ -4,6 +4,7 @@
using Content.Server.Kitchen.Components;
using Content.Shared.Instruments;
using Content.Shared.Interaction.Events;
+using Content.Shared.Kitchen.Components;
using Content.Shared.Mind.Components;
using Content.Shared.PAI;
using Content.Shared.Popups;
diff --git a/Content.Server/PDA/PdaSystem.cs b/Content.Server/PDA/PdaSystem.cs
index 018b51e5e50..1e74808a113 100644
--- a/Content.Server/PDA/PdaSystem.cs
+++ b/Content.Server/PDA/PdaSystem.cs
@@ -1,3 +1,4 @@
+using Content.Server._Mono.AlertLevel;
using Content.Server.Access.Systems;
using Content.Server.AlertLevel;
using Content.Server.CartridgeLoader;
@@ -7,6 +8,7 @@
using Content.Server.Station.Systems;
using Content.Server.Store.Systems;
using Content.Server.Traitor.Uplink;
+using Content.Shared._DV.CCVars; // DeltaV - PDA date
using Content.Shared.Access.Components;
using Content.Shared.CartridgeLoader;
using Content.Shared.Chat;
@@ -15,6 +17,7 @@
using Content.Shared.PDA;
using Robust.Server.Containers;
using Robust.Server.GameObjects;
+using Robust.Shared.Configuration; // DeltaV - PDA date
using Robust.Shared.Containers;
using Robust.Shared.Player;
using Robust.Shared.Utility;
@@ -42,6 +45,9 @@ public sealed partial class PdaSystem : SharedPdaSystem
[Dependency] private IdCardSystem _idCard = default!;
[Dependency] private SectorServiceSystem _sectorService = default!;
[Dependency] private IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly IConfigurationManager _config = default!; // DeltaV
+
+ private static DateTime ServerDate; // DeltaV - PDA
public override void Initialize()
{
@@ -63,6 +69,29 @@ public override void Initialize()
SubscribeLocalEvent(OnStationRenamed);
SubscribeLocalEvent(OnEntityRenamed, after: new[] { typeof(IdCardSystem) });
SubscribeLocalEvent(OnAlertLevelChanged);
+ SubscribeLocalEvent(OnWarLevelChanged);
+
+ // Begin DeltaV additions
+ Subs.CVar(_config,
+ DCCVars.YearOffset,
+ value => ServerDate = DateTime.Today.AddYears(value),
+ true);
+ // End DeltaV additions
+ SubscribeLocalEvent(OnPlayerAttached);
+ }
+
+ private void OnPlayerAttached(PlayerAttachedEvent args)
+ {
+ // When a player reconnects, update all PDAs that have open UIs for this player.
+ // This ensures the shift remaining timer and other dynamic data are refreshed.
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var pda))
+ {
+ if (_ui.IsUiOpen(uid, PdaUiKey.Key, args.Entity))
+ {
+ UpdatePdaUi(uid, pda, args.Entity);
+ }
+ }
}
private void OnEntityRenamed(ref EntityRenamedEvent ev)
@@ -91,6 +120,7 @@ protected override void OnComponentInit(EntityUid uid, PdaComponent pda, Compone
if (!HasComp(uid))
return;
+ UpdateWarLevel(uid, pda); // Mono
UpdateAlertLevel(uid, pda);
UpdateStationName(uid, pda);
}
@@ -140,6 +170,11 @@ private void OnAlertLevelChanged(AlertLevelChangedEvent args)
UpdateAllPdaUisOnStation();
}
+ private void OnWarLevelChanged(WarLevelChangedEvent args)
+ {
+ UpdateAllPdaUisOnStation();
+ }
+
private void UpdateAllPdaUisOnStation()
{
var query = AllEntityQuery();
@@ -186,8 +221,10 @@ public void UpdatePdaUi(EntityUid uid, PdaComponent? pda = null, EntityUid? acto
var hasInstrument = HasComp(uid);
var showUplink = HasComp(uid) && IsUnlocked(uid);
+ pda.CurrentDate = pda.DateOverride ?? ServerDate; // DeltaV - PDA date
UpdateStationName(uid, pda);
UpdateAlertLevel(uid, pda);
+ UpdateWarLevel(uid, pda); // Mono
// TODO: Update the level and name of the station with each call to UpdatePdaUi is only needed for latejoin players.
// TODO: If someone can implement changing the level and name of the station when changing the PDA grid, this can be removed.
@@ -234,8 +271,10 @@ public void UpdatePdaUi(EntityUid uid, PdaComponent? pda = null, EntityUid? acto
JobTitle = id?.LocalizedJobTitle,
CompanyName = companyName,
CompanyColor = companyColor,
+ CurrentDate = pda.CurrentDate, // DeltaV - PDA date
StationAlertLevel = pda.StationAlertLevel,
- StationAlertColor = pda.StationAlertColor
+ StationAlertColor = pda.StationAlertColor,
+ WarLevel = pda.WarLevel
},
balance, // Frontier
ownedShipName, // Frontier
@@ -336,6 +375,15 @@ private void UpdateAlertLevel(EntityUid uid, PdaComponent pda)
pda.StationAlertColor = details.Color;
}
+ // Mono
+ private void UpdateWarLevel(EntityUid uid, PdaComponent pda)
+ {
+ var station = _sectorService.GetServiceEntity();
+ if (!TryComp(station, out WarLevelComponent? warComp))
+ return;
+ pda.WarLevel = warComp.PostWar ? Loc.GetString("comp-pda-ui-station-war-level-post") : Loc.GetString("comp-pda-ui-station-war-level-pre");
+ }
+
private string? GetDeviceNetAddress(EntityUid uid)
{
string? address = null;
diff --git a/Content.Server/Parallax/BiomeSystem.ChunkLoader.cs b/Content.Server/Parallax/BiomeSystem.ChunkLoader.cs
index e9852521a22..5b0573bab49 100644
--- a/Content.Server/Parallax/BiomeSystem.ChunkLoader.cs
+++ b/Content.Server/Parallax/BiomeSystem.ChunkLoader.cs
@@ -265,13 +265,13 @@ private void UnloadEntities(BiomeComponent component, EntityUid gridUid, MapGrid
_chunkLoaderEntitiesToDelete.Add(ent);
}
+ component.LoadedEntities.Remove(chunk);
+
// Batch delete entities
foreach (var ent in _chunkLoaderEntitiesToDelete)
{
Del(ent);
}
-
- component.LoadedEntities.Remove(chunk);
}
private void UnloadTiles(BiomeComponent component, EntityUid gridUid, MapGridComponent grid, Vector2i chunk, int seed, HashSet modified, List<(Vector2i, Tile)> tiles)
diff --git a/Content.Server/Parallax/BiomeSystem.cs b/Content.Server/Parallax/BiomeSystem.cs
index 82c84eb8984..e9dde10f2a8 100644
--- a/Content.Server/Parallax/BiomeSystem.cs
+++ b/Content.Server/Parallax/BiomeSystem.cs
@@ -83,6 +83,7 @@ public override void Initialize()
SubscribeLocalEvent(OnBiomeMapInit);
SubscribeLocalEvent(OnFTLStarted);
SubscribeLocalEvent(OnShuttleFlatten);
+ SubscribeLocalEvent(OnEntityTerminating);
Subs.CVar(_configManager, CVars.NetMaxUpdateRange, SetLoadRange, true);
InitializeChunkLoader();
InitializeMarkerProcessor();
@@ -106,6 +107,37 @@ private void OnFTLStarted(ref FTLStartedEvent ev)
Preload(targetMapUid, biome, targetArea);
}
+ private void OnEntityTerminating(ref EntityTerminatingEvent ev)
+ {
+ var uid = ev.Entity.Owner;
+
+ if (!_xformQuery.TryGetComponent(uid, out var xform) ||
+ xform.GridUid is not { } gridUid ||
+ !_biomeQuery.TryGetComponent(gridUid, out var biome) ||
+ !TryComp(gridUid, out var grid))
+ {
+ return;
+ }
+
+ var tile = _mapSystem.LocalToTile(gridUid, grid, xform.Coordinates);
+ var chunk = SharedMapSystem.GetChunkIndices(tile, ChunkSize) * ChunkSize;
+
+ if (biome.LoadedEntities.TryGetValue(chunk, out var loaded) && loaded.Remove(uid))
+ {
+ biome.ModifiedTiles.GetOrNew(chunk).Add(tile);
+ return;
+ }
+
+ foreach (var (chunkOrigin, entities) in biome.LoadedEntities)
+ {
+ if (!entities.Remove(uid, out var storedTile))
+ continue;
+
+ biome.ModifiedTiles.GetOrNew(chunkOrigin).Add(storedTile);
+ return;
+ }
+ }
+
private void OnShuttleFlatten(ref ShuttleFlattenEvent ev)
{
if (!TryComp(ev.MapUid, out var biome) ||
diff --git a/Content.Server/Power/EntitySystems/RiggableSystem.cs b/Content.Server/Power/EntitySystems/RiggableSystem.cs
index 3d6ab135baf..fa6b871c9b1 100644
--- a/Content.Server/Power/EntitySystems/RiggableSystem.cs
+++ b/Content.Server/Power/EntitySystems/RiggableSystem.cs
@@ -4,6 +4,7 @@
using Content.Server.Power.Components;
using Content.Shared.Chemistry.EntitySystems;
using Content.Shared.Database;
+using Content.Shared.Kitchen.Components;
using Content.Shared.Power.Components;
using Content.Shared.Rejuvenate;
diff --git a/Content.Server/PowerCell/PowerCellSystem.cs b/Content.Server/PowerCell/PowerCellSystem.cs
index 26062568fcd..f4ee6215394 100644
--- a/Content.Server/PowerCell/PowerCellSystem.cs
+++ b/Content.Server/PowerCell/PowerCellSystem.cs
@@ -4,6 +4,7 @@
using Content.Server.Power.EntitySystems;
using Content.Shared.Containers.ItemSlots;
using Content.Shared.Examine;
+using Content.Shared.Kitchen.Components;
using Content.Shared.Popups;
using Content.Shared.Power;
using Content.Shared.Power.Components;
@@ -13,7 +14,6 @@
using Content.Shared.UserInterface;
using Robust.Shared.Containers;
using System.Diagnostics.CodeAnalysis;
-using Content.Server.Kitchen.Components;
using Content.Server.Power.EntitySystems;
using Content.Server.UserInterface;
using Content.Shared.Containers.ItemSlots;
diff --git a/Content.Server/Radio/EntitySystems/JammerSystem.cs b/Content.Server/Radio/EntitySystems/JammerSystem.cs
index f5d3561b7b5..b15625c069b 100644
--- a/Content.Server/Radio/EntitySystems/JammerSystem.cs
+++ b/Content.Server/Radio/EntitySystems/JammerSystem.cs
@@ -1,5 +1,6 @@
using Content.Server.Power.EntitySystems;
using Content.Server.PowerCell;
+using Content.Server.Radio;
using Content.Shared.DeviceNetwork.Components;
using Content.Shared.Interaction;
using Content.Shared.PowerCell.Components;
@@ -51,70 +52,103 @@ public override void Update(float frameTime)
};
ChangeChargeLevel(uid, chargeLevel);
}
-
}
-
}
}
private void OnActivate(Entity ent, ref ActivateInWorldEvent args)
{
+
if (args.Handled || !args.Complex)
return;
- var activated = !HasComp(ent) &&
- _powerCell.TryGetBatteryFromSlot(ent.Owner, out var battery) &&
- battery.CurrentCharge > GetCurrentWattage(ent);
- if (activated)
- {
- ChangeLEDState(ent.Owner, true);
- EnsureComp(ent);
- EnsureComp(ent, out var jammingComp);
- _jammer.SetRange((ent, jammingComp), GetCurrentRange(ent));
- _jammer.AddJammableNetwork((ent, jammingComp), DeviceNetworkComponent.DeviceNetIdDefaults.Wireless.ToString());
- }
- else
- {
- ChangeLEDState(ent.Owner, false);
- RemCompDeferred(ent);
- RemCompDeferred(ent);
- }
- var state = Loc.GetString(activated ? "radio-jammer-component-on-state" : "radio-jammer-component-off-state");
- var message = Loc.GetString("radio-jammer-component-on-use", ("state", state));
- Popup.PopupEntity(message, args.User, args.User);
- args.Handled = true;
- }
+ var activated = !HasComp(ent) &&
+ _powerCell.TryGetBatteryFromSlot(ent.Owner, out var battery) &&
+ battery.CurrentCharge > GetCurrentWattage(ent);
- private void OnPowerCellChanged(Entity ent, ref PowerCellChangedEvent args)
+ if (activated)
{
- if (args.Ejected)
+ ChangeLEDState(ent.Owner, true);
+
+ EnsureComp(ent);
+ EnsureComp(ent, out var jammingComp);
+
+ _jammer.SetRange((ent, jammingComp), GetCurrentRange(ent));
+
+ _jammer.AddJammableNetwork(
+ (ent, jammingComp),
+ DeviceNetworkComponent.DeviceNetIdDefaults.Wireless.ToString()
+ );
+
+ /// Sync excluded frequencies from RadioJammerComponent
+ /// into DeviceNetworkJammerComponen
+
+ _jammer.ClearExcludedFrequency((ent, jammingComp));
+
+ foreach (var freq in ent.Comp.FrequenciesExcluded)
{
- ChangeLEDState(ent.Owner, false);
- RemCompDeferred(ent);
+ _jammer.AddExcludedFrequency((ent, jammingComp), (uint) freq);
}
}
+ else
+ {
+ ChangeLEDState(ent.Owner, false);
+
+ RemCompDeferred(ent);
+ RemCompDeferred(ent);
+ }
+
+ var state = Loc.GetString(activated
+ ? "radio-jammer-component-on-state"
+ : "radio-jammer-component-off-state");
+
+ var message = Loc.GetString(
+ "radio-jammer-component-on-use",
+ ("state", state)
+ );
+
+ Popup.PopupEntity(message, args.User, args.User);
+ args.Handled = true;
+ }
private void OnRadioSendAttempt(ref RadioSendAttemptEvent args)
{
- if (ShouldCancelSend(args.RadioSource))
+
+ if (!TryComp(args.RadioSource, out var sourceTransform))
+ return;
+
+ var source = sourceTransform.Coordinates;
+
+ var query = EntityQueryEnumerator<
+ ActiveRadioJammerComponent,
+ RadioJammerComponent,
+ TransformComponent>();
+
+ while (query.MoveNext(out var uid, out _, out var jammer, out var transform))
+ {
+ // Excluded channels are allowed through.
+ if (jammer.FrequenciesExcluded.Contains(args.Frequency))
+ continue;
+
+ if (_transform.InRange(
+ source,
+ transform.Coordinates,
+ GetCurrentRange((uid, jammer))))
{
args.Cancelled = true;
+ return;
}
}
+}
- private bool ShouldCancelSend(EntityUid sourceUid)
+ private void OnPowerCellChanged(Entity ent, ref PowerCellChangedEvent args)
{
- var source = Transform(sourceUid).Coordinates;
- var query = EntityQueryEnumerator();
- while (query.MoveNext(out var uid, out _, out var jam, out var transform))
+ if (args.Ejected)
{
- if (_transform.InRange(source, transform.Coordinates, GetCurrentRange((uid, jam))))
- {
- return true;
- }
+ ChangeLEDState(ent.Owner, false);
+ RemCompDeferred(ent.Owner);
+ RemCompDeferred(ent.Owner);
}
-
- return false;
}
-}
+}
\ No newline at end of file
diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs
index c14e0cb2d5e..9b39c1ac58d 100644
--- a/Content.Server/Radio/EntitySystems/RadioSystem.cs
+++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs
@@ -216,9 +216,16 @@ public void SendRadioMessage(
var ev = new RadioReceiveEvent(messageSource, channel, msg, notUdsMsg, language, radioSource);
// Einstein Engines - Language end
- var sendAttemptEv = new RadioSendAttemptEvent(channel, radioSource);
+ var transmitFrequency = frequency ?? GetFrequency(messageSource, channel);
+
+ var sendAttemptEv = new RadioSendAttemptEvent(
+ channel,
+ radioSource,
+ transmitFrequency);
+
RaiseLocalEvent(ref sendAttemptEv);
RaiseLocalEvent(radioSource, ref sendAttemptEv);
+
var canSend = !sendAttemptEv.Cancelled;
var sourceMapId = Transform(radioSource).MapID;
diff --git a/Content.Server/Radio/RadioEvent.cs b/Content.Server/Radio/RadioEvent.cs
index c244ae23f67..0857c8cdf4e 100644
--- a/Content.Server/Radio/RadioEvent.cs
+++ b/Content.Server/Radio/RadioEvent.cs
@@ -37,9 +37,10 @@ public record struct RadioReceiveAttemptEvent(RadioChannelPrototype Channel, Ent
/// Use this event to cancel sending message to every receiver
///
[ByRefEvent]
-public record struct RadioSendAttemptEvent(RadioChannelPrototype Channel, EntityUid RadioSource)
+public record struct RadioSendAttemptEvent(RadioChannelPrototype Channel, EntityUid RadioSource, int Frequency)
{
public readonly RadioChannelPrototype Channel = Channel;
public readonly EntityUid RadioSource = RadioSource;
+ public readonly int Frequency = Frequency;
public bool Cancelled = false;
}
diff --git a/Content.Server/Remotes/DoorRemoteSystem.cs b/Content.Server/Remotes/DoorRemoteSystem.cs
index a5f4effdb72..3ed9b15fbf2 100644
--- a/Content.Server/Remotes/DoorRemoteSystem.cs
+++ b/Content.Server/Remotes/DoorRemoteSystem.cs
@@ -1,5 +1,6 @@
using Content.Server.Administration.Logs;
using Content.Server.Doors.Systems;
+using Content.Shared.Electrocution;
using Content.Server.Power.EntitySystems;
using Content.Shared.Access.Components;
using Content.Shared.Database;
@@ -8,15 +9,18 @@
using Content.Shared.Interaction;
using Content.Shared.Remotes.Components;
using Content.Shared.Remotes.EntitySystems;
+using Robust.Shared.Audio.Systems;
namespace Content.Shared.Remotes
{
- public sealed partial class DoorRemoteSystem : SharedDoorRemoteSystem
+ public sealed class DoorRemoteSystem : SharedDoorRemoteSystem
{
- [Dependency] private IAdminLogManager _adminLogger = default!;
- [Dependency] private AirlockSystem _airlock = default!;
- [Dependency] private DoorSystem _doorSystem = default!;
- [Dependency] private ExamineSystemShared _examine = default!;
+ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly AirlockSystem _airlock = default!;
+ [Dependency] private readonly DoorSystem _doorSystem = default!;
+ [Dependency] private readonly ExamineSystemShared _examine = default!;
+ [Dependency] private readonly SharedElectrocutionSystem _electrify = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
public override void Initialize()
{
@@ -90,6 +94,22 @@ private void OnBeforeInteract(Entity entity, ref BeforeRang
$"{ToPrettyString(args.User):player} used {ToPrettyString(args.Used)} on {ToPrettyString(args.Target.Value)} to set emergency access {(airlockComp.EmergencyAccess ? "on" : "off")}");
}
+ break;
+ case OperatingMode.ToggleOvercharge:
+ {
+ if (!TryComp(args.Target, out var electrifiedComp))
+ break;
+ var newState = !electrifiedComp.Enabled;
+ _electrify.SetElectrified((args.Target.Value, electrifiedComp), newState);
+ var soundToPlay = newState
+ ? electrifiedComp.AirlockElectrifyDisabled
+ : electrifiedComp.AirlockElectrifyEnabled;
+ _audio.PlayPvs(soundToPlay, args.Target.Value);
+ _adminLogger.Add(LogType.Action,
+ LogImpact.Medium,
+ $"{ToPrettyString(args.User):player} used {ToPrettyString(args.Used)} on {ToPrettyString(args.Target.Value)} to {(electrifiedComp.Enabled ? "" : "un")}electrify it");
+ }
+
break;
default:
throw new InvalidOperationException(
diff --git a/Content.Server/Robotics/Systems/RoboticsConsoleSystem.cs b/Content.Server/Robotics/Systems/RoboticsConsoleSystem.cs
index 995baaec0c5..283063aff65 100644
--- a/Content.Server/Robotics/Systems/RoboticsConsoleSystem.cs
+++ b/Content.Server/Robotics/Systems/RoboticsConsoleSystem.cs
@@ -95,7 +95,10 @@ private void OnOpened(Entity ent, ref BoundUIOpenedEve
private void OnDisable(Entity ent, ref RoboticsConsoleDisableMessage args)
{
- if (_lock.IsLocked(ent.Owner))
+ if (!ent.Comp.AllowBorgControl)
+ return;
+
+ if (_lock.IsLocked(ent.Owner))
return;
if (!ent.Comp.Cyborgs.TryGetValue(args.Address, out var data))
@@ -112,7 +115,10 @@ private void OnDisable(Entity ent, ref RoboticsConsole
private void OnDestroy(Entity ent, ref RoboticsConsoleDestroyMessage args)
{
- if (_lock.IsLocked(ent.Owner))
+ if (!ent.Comp.AllowBorgControl)
+ return;
+
+ if (_lock.IsLocked(ent.Owner))
return;
var now = _timing.CurTime;
@@ -139,7 +145,7 @@ private void OnDestroy(Entity ent, ref RoboticsConsole
private void UpdateUserInterface(Entity ent)
{
- var state = new RoboticsConsoleState(ent.Comp.Cyborgs);
+ var state = new RoboticsConsoleState(ent.Comp.Cyborgs, ent.Comp.AllowBorgControl);
_ui.SetUiState(ent.Owner, RoboticsConsoleUiKey.Key, state);
}
}
diff --git a/Content.Server/Shuttles/Systems/ShuttleSystem.FasterThanLight.cs b/Content.Server/Shuttles/Systems/ShuttleSystem.FasterThanLight.cs
index 955145f19c8..bef4649fb92 100644
--- a/Content.Server/Shuttles/Systems/ShuttleSystem.FasterThanLight.cs
+++ b/Content.Server/Shuttles/Systems/ShuttleSystem.FasterThanLight.cs
@@ -31,6 +31,7 @@
using Content.Shared._Mono.Ships;
using Content.Shared._Crescent.SpaceBiomes;
using Robust.Shared.Prototypes;
+using Content.Server.Explosion.EntitySystems;
namespace Content.Server.Shuttles.Systems;
@@ -1378,20 +1379,28 @@ public bool TryFTLProximity(Entity shuttle, EntityCoordinat
///
/// Flattens / deletes everything under the grid upon FTL.
+ /// Mono: public + funny extra code for two grids hitting eachother
///
- private void Smimsh(EntityUid uid, FixturesComponent? manager = null, MapGridComponent? grid = null, TransformComponent? xform = null)
+ public void Smimsh(EntityUid uid, FixturesComponent? manager = null, MapGridComponent? grid = null, TransformComponent? xform = null, EntityUid? crushMap = null, bool explodeGrids = false, HashSet? ignoredGrids = null)
{
- if (!Resolve(uid, ref manager, ref grid, ref xform) || xform.MapUid == null)
+ if (!Resolve(uid, ref manager, ref grid, ref xform))
return;
- if (!TryComp(xform.MapUid, out BroadphaseComponent? lookup))
+ var mapUid = crushMap ?? xform.MapUid;
+ if (mapUid == null)
+ return;
+
+ if (!TryComp(mapUid, out BroadphaseComponent? lookup))
return;
// Flatten anything not parented to a grid.
- var transform = _physics.GetRelativePhysicsTransform((uid, xform), xform.MapUid.Value);
+ var transform = _physics.GetRelativePhysicsTransform((uid, xform), mapUid.Value);
var aabbs = new List(manager.Fixtures.Count);
var tileSet = new List<(Vector2i, Tile)>();
+ // Mono: grids crush other grids
+ var crushedGrids = new HashSet();
+
foreach (var fixture in manager.Fixtures.Values)
{
if (xform.MapID == _ticker.DefaultMap)
@@ -1409,11 +1418,23 @@ private void Smimsh(EntityUid uid, FixturesComponent? manager = null, MapGridCom
// Handle clearing biome stuff as relevant.
tileSet.Clear();
- _biomes.ReserveTiles(xform.MapUid.Value, aabb, tileSet);
+ _biomes.ReserveTiles(mapUid.Value, aabb, tileSet);
_lookupEnts.Clear();
_immuneEnts.Clear();
// TODO: Ideally we'd query first BEFORE moving grid but needs adjustments above.
- _lookup.GetLocalEntitiesIntersecting(xform.MapUid.Value, fixture.Shape, transform, _lookupEnts, flags: LookupFlags.Uncontained, lookup: lookup);
+ _lookup.GetLocalEntitiesIntersecting(mapUid.Value, fixture.Shape, transform, _lookupEnts, flags: LookupFlags.Uncontained, lookup: lookup);
+
+ if (explodeGrids)
+ {
+ _mapManager.FindGridsIntersecting(mapUid.Value, fixture.Shape, transform,
+ (EntityUid gridEnt, MapGridComponent _) =>
+ {
+ if (gridEnt != uid && (ignoredGrids == null || !ignoredGrids.Contains(gridEnt)))
+ crushedGrids.Add(gridEnt);
+
+ return true;
+ }, approx: false, includeMap: false);
+ }
foreach (var ent in _lookupEnts)
{
@@ -1428,6 +1449,11 @@ private void Smimsh(EntityUid uid, FixturesComponent? manager = null, MapGridCom
continue;
}
+ if (ignoredGrids != null && childXform.GridUid != null && ignoredGrids.Contains(childXform.GridUid.Value))
+ {
+ continue;
+ }
+
// If it has the FTLSmashImmuneComponent ignore it.
if (_immuneQuery.HasComponent(ent))
{
@@ -1447,10 +1473,67 @@ private void Smimsh(EntityUid uid, FixturesComponent? manager = null, MapGridCom
}
}
- var ev = new ShuttleFlattenEvent(xform.MapUid.Value, aabbs);
+ foreach (var gridEnt in crushedGrids)
+ {
+ CrushGrid(gridEnt, uid);
+ }
+
+ var ev = new ShuttleFlattenEvent(mapUid.Value, aabbs);
RaiseLocalEvent(ref ev);
}
+ private const float CrushIntensityPerArea = 5f;
+ private const float CrushMinIntensity = 300f;
+ private const float CrushMaxIntensity = 25000f;
+
+ ///
+ /// Did you make absolutely sure there wasn't anything under you when you landed?
+ ///
+ private void CrushGrid(EntityUid crushed, EntityUid crusher)
+ {
+ if (!TryComp(crushed, out var crushedGrid) ||
+ !TryComp(crusher, out var crusherGrid))
+ return;
+
+ var crushedAabb = _transform.GetWorldMatrix(crushed).TransformBox(crushedGrid.LocalAABB);
+ var crusherAabb = _transform.GetWorldMatrix(crusher).TransformBox(crusherGrid.LocalAABB);
+ var overlap = crushedAabb.Intersect(crusherAabb);
+
+ var epicentre = overlap.Width > 0f && overlap.Height > 0f
+ ? overlap.Center
+ : (crushedAabb.Center + crusherAabb.Center) / 2f;
+
+ _logger.Add(LogType.Explosion, LogImpact.Extreme,
+ $"{ToPrettyString(crushed)} and {ToPrettyString(crusher)} crushed into each other during z-level transit");
+
+ ExplodeCrushedGrid(crushed, crushedGrid, epicentre);
+ ExplodeCrushedGrid(crusher, crusherGrid, epicentre);
+ }
+
+ ///
+ /// No, Zed, I did not.
+ ///
+ private void ExplodeCrushedGrid(EntityUid gridUid, MapGridComponent grid, Vector2 worldEpicentre)
+ {
+ var xform = Transform(gridUid);
+ if (xform.MapID == MapId.Nullspace)
+ return;
+
+ var area = grid.LocalAABB.Width * grid.LocalAABB.Height;
+ var intensity = Math.Clamp(area * CrushIntensityPerArea, CrushMinIntensity, CrushMaxIntensity);
+
+ // Boom.
+ var localEpicentre = Vector2.Transform(worldEpicentre, _transform.GetInvWorldMatrix(gridUid));
+ _explosion.QueueExplosion(
+ _transform.ToMapCoordinates(new EntityCoordinates(gridUid, localEpicentre)),
+ ExplosionSystem.DefaultExplosionPrototypeId,
+ intensity,
+ slope: 5f,
+ maxTileIntensity: 100f,
+ cause: null,
+ addLog: false);
+ }
+
///
/// Transitions shuttle to FTL map.
///
diff --git a/Content.Server/Shuttles/Systems/ShuttleSystem.cs b/Content.Server/Shuttles/Systems/ShuttleSystem.cs
index f774280345b..d8605f78059 100644
--- a/Content.Server/Shuttles/Systems/ShuttleSystem.cs
+++ b/Content.Server/Shuttles/Systems/ShuttleSystem.cs
@@ -38,6 +38,8 @@
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Timing;
+using Content.Shared.Maps;
+using Content.Shared.Shuttles.Components;
namespace Content.Server.Shuttles.Systems;
@@ -57,6 +59,7 @@ public sealed partial class ShuttleSystem : SharedShuttleSystem
[Dependency] private DockingSystem _dockSystem = default!;
[Dependency] private DungeonSystem _dungeon = default!;
[Dependency] private EntityLookupSystem _lookup = default!;
+ [Dependency] private Content.Server.Explosion.EntitySystems.ExplosionSystem _explosion = default!; // Mono: z-level transit crush
[Dependency] private IEntityManager _entityManager = default!;
[Dependency] private FixtureSystem _fixtures = default!;
[Dependency] private InventorySystem _inventorySystem = default!;
@@ -104,6 +107,7 @@ public override void Initialize()
SubscribeLocalEvent(OnShuttleStartup);
SubscribeLocalEvent(OnShuttleShutdown);
+ SubscribeLocalEvent(OnShuttleIFFStartup); // Mono
SubscribeLocalEvent(OnTileFriction);
SubscribeLocalEvent(OnFTLStarted);
SubscribeLocalEvent(OnFTLCompleted);
@@ -167,6 +171,22 @@ private void OnShuttleStartup(EntityUid uid, ShuttleComponent component, Compone
component.DampingModifier = component.BodyModifier;
}
+ // Mono - track ID
+ private void OnShuttleIFFStartup(EntityUid uid, IFFComponent component, ComponentStartup args)
+ {
+ if (!EntityManager.HasComponent(uid))
+ {
+ return;
+ }
+
+ if (!EntityManager.TryGetComponent(uid, out PhysicsComponent? physicsComponent))
+ {
+ return;
+ }
+ var num = _random.Next();
+ component.Address = $" {num >> 16:X4}-{num & 0xFFFF:X4}";
+ }
+
public void Toggle(EntityUid uid, ShuttleComponent component,
bool force = false) // Mono - add force
{
diff --git a/Content.Server/Silicons/Borgs/BorgSwitchableTypeSystem.cs b/Content.Server/Silicons/Borgs/BorgSwitchableTypeSystem.cs
index 6bd83387890..ea20817bcff 100644
--- a/Content.Server/Silicons/Borgs/BorgSwitchableTypeSystem.cs
+++ b/Content.Server/Silicons/Borgs/BorgSwitchableTypeSystem.cs
@@ -53,9 +53,7 @@ protected override void SelectBorgModule(Entity ent
(ent.Owner, transponder),
new SpriteSpecifier.Rsi(new ResPath("Mobs/Silicon/chassis.rsi"), prototype.SpriteBodyState));
- _borgSystem.SetTransponderName(
- (ent.Owner, transponder),
- Loc.GetString($"borg-type-{borgType}-transponder"));
+ _borgSystem.SetTransponderName((ent.Owner, transponder),Loc.GetString($"borg-type-{borgType}-name"));
}
// Configure modules
diff --git a/Content.Server/Spawners/Components/SpawnOnDespawnComponent.cs b/Content.Server/Spawners/Components/SpawnOnDespawnComponent.cs
index 24b57a4b1c0..6abdc93e877 100644
--- a/Content.Server/Spawners/Components/SpawnOnDespawnComponent.cs
+++ b/Content.Server/Spawners/Components/SpawnOnDespawnComponent.cs
@@ -15,4 +15,10 @@ public sealed partial class SpawnOnDespawnComponent : Component
///
[DataField(required: true)]
public EntProtoId Prototype = string.Empty;
+
+ ///
+ /// Mono: How many Entity prototypes to spawn.
+ ///
+ [DataField]
+ public int Count = 1;
}
diff --git a/Content.Server/Spawners/EntitySystems/SpawnOnDespawnSystem.cs b/Content.Server/Spawners/EntitySystems/SpawnOnDespawnSystem.cs
index 2f850faab13..9fa651e2cc9 100644
--- a/Content.Server/Spawners/EntitySystems/SpawnOnDespawnSystem.cs
+++ b/Content.Server/Spawners/EntitySystems/SpawnOnDespawnSystem.cs
@@ -18,9 +18,13 @@ private void OnDespawn(EntityUid uid, SpawnOnDespawnComponent comp, ref TimedDes
if (!TryComp(uid, out TransformComponent? xform))
return;
- Spawn(comp.Prototype, xform.Coordinates);
+ // Mono start - multiple entity spawning
+ for (int i = 0; i <= comp.Count; i++)
+ {
+ Spawn(comp.Prototype, xform.Coordinates);
+ }
+ // End mono
}
-
public void SetPrototype(Entity entity, EntProtoId prototype)
{
entity.Comp.Prototype = prototype;
diff --git a/Content.Server/Station/Commands/JobsCommand.cs b/Content.Server/Station/Commands/JobsCommand.cs
index c971a1cac31..38a62ac0f56 100644
--- a/Content.Server/Station/Commands/JobsCommand.cs
+++ b/Content.Server/Station/Commands/JobsCommand.cs
@@ -86,6 +86,18 @@ public int Amount([PipedArgument] JobSlotRef @ref)
[CommandImplementation("amount")]
public IEnumerable Amount([PipedArgument] IEnumerable @ref)
=> @ref.Select(Amount);
+
+ [CommandImplementation("unlimited")]
+ public JobSlotRef Unlimited([PipedArgument] JobSlotRef @ref)
+ {
+ _jobs ??= GetSys();
+ _jobs.MakeJobUnlimited(@ref.Station, @ref.Job);
+ return @ref;
+ }
+
+ [CommandImplementation("unlimited")]
+ public IEnumerable Unlimited([PipedArgument] IEnumerable @ref)
+ => @ref.Select(Unlimited);
}
// Used for Toolshed queries.
diff --git a/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraSystem.cs b/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraSystem.cs
index 49d02cd0aa0..cbc93e68b80 100644
--- a/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraSystem.cs
+++ b/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraSystem.cs
@@ -83,7 +83,7 @@ private void OnPacketReceived(EntityUid uid, SurveillanceCameraComponent compone
{
{ DeviceNetworkConstants.Command, string.Empty },
{ CameraAddressData, deviceNet.Address },
- { CameraNameData, component.CameraId },
+ { CameraNameData, component.UseEntityNameAsCameraId ? MetaData(uid).EntityName : component.CameraId },
{ CameraSubnetData, string.Empty }
};
@@ -215,7 +215,8 @@ private void UpdateSetupInterface(EntityUid uid, SurveillanceCameraComponent? ca
return;
}
}
-
+
+ var name = camera.UseEntityNameAsCameraId ? MetaData(uid).EntityName : camera.CameraId;
var state = new SurveillanceCameraSetupBoundUiState(camera.CameraId, deviceNet.ReceiveFrequency ?? 0,
camera.AvailableNetworks, camera.NameSet, camera.NetworkSet);
_userInterface.SetUiState(uid, SurveillanceCameraSetupUiKey.Camera, state);
diff --git a/Content.Server/TurretController/DeployableTurretControllerSystem.cs b/Content.Server/TurretController/DeployableTurretControllerSystem.cs
new file mode 100644
index 00000000000..e14f6dbfeb6
--- /dev/null
+++ b/Content.Server/TurretController/DeployableTurretControllerSystem.cs
@@ -0,0 +1,169 @@
+using Content.Server.DeviceNetwork;
+using Content.Server.DeviceNetwork.Components;
+using Content.Server.DeviceNetwork.Systems;
+using Content.Shared.Access;
+using Content.Shared.DeviceNetwork;
+using Content.Shared.DeviceNetwork.Systems;
+using Content.Shared.TurretController;
+using Content.Shared.Turrets;
+using Robust.Server.GameObjects;
+using Robust.Shared.Prototypes;
+using System.Linq;
+using Content.Shared.DeviceNetwork.Components;
+using Content.Shared.DeviceNetwork.Events;
+
+namespace Content.Server.TurretController;
+
+public sealed partial class DeployableTurretControllerSystem : SharedDeployableTurretControllerSystem
+{
+ [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!;
+ [Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!;
+
+ public const string CmdSetArmamemtState = "set_armament_state";
+ public const string CmdSetAccessExemptions = "set_access_exemption";
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnBUIOpened);
+ SubscribeLocalEvent(OnDeviceListUpdate);
+ SubscribeLocalEvent(OnPacketReceived);
+ }
+
+ private void OnBUIOpened(Entity ent, ref BoundUIOpenedEvent args)
+ {
+ UpdateUIState(ent);
+ }
+
+ ///
+ /// Each time you add devices to the controller it will update all turrets.
+ ///
+ private void OnDeviceListUpdate(Entity ent, ref DeviceListUpdateEvent args)
+ {
+ if (!TryComp(ent, out var deviceNetwork))
+ return;
+
+ // List of turrets
+ var turretsToAdd = args.Devices;
+
+ // Refresh turrets
+ ent.Comp.LinkedTurrets.Clear();
+
+ // Request data from newly added devices
+ var payload = new NetworkPayload
+ {
+ [DeviceNetworkConstants.Command] = DeviceNetworkConstants.CmdUpdatedState,
+ };
+
+ foreach (var turretUid in turretsToAdd)
+ {
+ if (!HasComp(turretUid))
+ continue;
+
+ if (!TryComp(turretUid, out var turretDeviceNetwork))
+ continue;
+
+ _deviceNetwork.QueuePacket(ent, turretDeviceNetwork.Address, payload, device: deviceNetwork);
+ }
+ }
+
+ ///
+ /// When recieving the packet back from the turret it will update the current updatedstates.
+ ///
+ private void OnPacketReceived(Entity ent, ref DeviceNetworkPacketEvent args)
+ {
+ if (!args.Data.TryGetValue(DeviceNetworkConstants.Command, out string? command))
+ return;
+
+ // If an update is received from a conencted turrt, update the UI
+ if (command == DeviceNetworkConstants.CmdUpdatedState &&
+ args.Data.TryGetValue(command, out DeployableTurretState updatedState))
+ {
+ ent.Comp.LinkedTurrets[args.SenderAddress] = updatedState;
+ UpdateUIState(ent);
+ }
+ }
+
+ ///
+ /// When you change the armament setting send a packet to the turrets to update them.
+ ///
+ protected override void ChangeArmamentSetting(Entity ent, int armamentState, EntityUid? user = null)
+ {
+ base.ChangeArmamentSetting(ent, armamentState, user);
+
+ if (!TryComp(ent, out var device))
+ return;
+
+ // Update linked turrets' armament statuses
+ var payload = new NetworkPayload
+ {
+ [DeviceNetworkConstants.Command] = CmdSetArmamemtState,
+ [CmdSetArmamemtState] = armamentState,
+ };
+
+ _deviceNetwork.QueuePacket(ent, null, payload, device: device);
+ }
+
+ ///
+ /// When changing the access levels send another packet to the turrets.
+ ///
+ protected override void ChangeExemptAccessLevels
+ (Entity ent, HashSet> exemptions, bool enabled, EntityUid? user = null)
+ {
+ base.ChangeExemptAccessLevels(ent, exemptions, enabled, user);
+
+ if (!TryComp(ent, out var device) ||
+ !TryComp(ent, out var turretTargetingSettings))
+ return;
+
+ // Update linked turrets' target selection exemptions
+ var payload = new NetworkPayload
+ {
+ [DeviceNetworkConstants.Command] = CmdSetAccessExemptions,
+ [CmdSetAccessExemptions] = turretTargetingSettings.ExemptAccessLevels,
+ };
+
+ _deviceNetwork.QueuePacket(ent, null, payload, device: device);
+ }
+
+ ///
+ /// Updates the UI state for the user that currently has it open.
+ ///
+ private void UpdateUIState(Entity ent)
+ {
+ var turretStates = new List<(string, string)>();
+
+ foreach (var (address, turret) in ent.Comp.LinkedTurrets)
+ turretStates.Add((address, GetTurretStateDescription(turret)));
+
+ // Live turret data.
+ var message = new DeployableTurretControllerBoundInterfaceMessage(turretStates);
+ _userInterfaceSystem.ServerSendUiMessage(ent.Owner, DeployableTurretControllerUiKey.Key, message);
+
+ // For when it changes states or opens the UI.
+ var state = new DeployableTurretControllerBoundInterfaceState(turretStates);
+ _userInterfaceSystem.SetUiState(ent.Owner, DeployableTurretControllerUiKey.Key, state);
+ }
+
+ private string GetTurretStateDescription(DeployableTurretState state)
+ {
+ switch (state)
+ {
+ case DeployableTurretState.Disabled:
+ return "turret-controls-window-turret-disabled";
+ case DeployableTurretState.Firing:
+ return "turret-controls-window-turret-firing";
+ case DeployableTurretState.Deploying:
+ return "turret-controls-window-turret-deploying";
+ case DeployableTurretState.Deployed:
+ return "turret-controls-window-turret-deployed";
+ case DeployableTurretState.Retracting:
+ return "turret-controls-window-turret-retracting";
+ case DeployableTurretState.Retracted:
+ return "turret-controls-window-turret-retracted";
+ }
+
+ return "turret-controls-window-turret-error";
+ }
+}
diff --git a/Content.Server/Turrets/DeployableTurretSystem.cs b/Content.Server/Turrets/DeployableTurretSystem.cs
new file mode 100644
index 00000000000..80dc468821e
--- /dev/null
+++ b/Content.Server/Turrets/DeployableTurretSystem.cs
@@ -0,0 +1,232 @@
+using Content.Server.DeviceNetwork;
+using Content.Server.DeviceNetwork.Components;
+using Content.Server.DeviceNetwork.Systems;
+using Content.Server.NPC.HTN;
+using Content.Server.NPC.HTN.PrimitiveTasks.Operators.Combat.Ranged;
+using Content.Server.Power.Components;
+using Content.Server.TurretController;
+using Content.Shared.TurretController;
+using Content.Shared.Destructible;
+using Content.Shared.DeviceNetwork;
+using Content.Shared.Power;
+using Content.Shared.Turrets;
+using Content.Shared.Weapons.Ranged.Systems;
+using Content.Shared.Weapons.Ranged.Components;
+using Content.Shared.Weapons.Ranged.Events;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Timing;
+using Robust.Shared.Prototypes;
+using Content.Shared.Access;
+using Content.Shared.DeviceNetwork.Components;
+using Content.Shared.DeviceNetwork.Events;
+using Content.Shared.Repairable;
+
+namespace Content.Server.Turrets;
+
+public sealed partial class DeployableTurretSystem : SharedDeployableTurretSystem
+{
+ [Dependency] private readonly HTNSystem _htn = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly BatteryWeaponFireModesSystem _turretfiremode = default!;
+ [Dependency] private readonly TurretTargetSettingsSystem _turretaccess = default!;
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnAmmoShot);
+ SubscribeLocalEvent(OnChargeChanged);
+ SubscribeLocalEvent(OnPowerChanged);
+ SubscribeLocalEvent(OnBroken);
+ SubscribeLocalEvent(OnBeforeBroadcast);
+ SubscribeLocalEvent(OnPacketReceived);
+ }
+
+ private void OnAmmoShot(Entity ent, ref AmmoShotEvent args)
+ {
+ if (!HasAmmo(ent))
+ SetState(ent, false);
+ }
+
+ private void OnChargeChanged(Entity ent, ref ChargeChangedEvent args)
+ {
+ if (!HasAmmo(ent))
+ SetState(ent, false);
+ }
+
+ private void OnPowerChanged(Entity ent, ref PowerChangedEvent args)
+ {
+ ent.Comp.Powered = args.Powered;
+ Dirty(ent);
+
+ if (!HasAmmo(ent))
+ SetState(ent, false);
+ }
+
+ private void OnBroken(Entity ent, ref BreakageEventArgs args)
+ {
+ ent.Comp.Broken = true;
+ Dirty(ent);
+
+ if (TryComp(ent, out var appearance))
+ _appearance.SetData(ent, DeployableTurretVisuals.Broken, true, appearance);
+
+ SetState(ent, false);
+ }
+
+ private void OnBeforeBroadcast(Entity ent, ref BeforeBroadcastAttemptEvent args)
+ {
+ if (!TryComp(ent, out var deviceNetwork))
+ return;
+
+ var recipientDeviceNetworks = new HashSet();
+
+ // Only broadcast to connected devices
+ foreach (var recipient in deviceNetwork.DeviceLists)
+ {
+ if (!TryComp(recipient, out var recipientDeviceNetwork))
+ continue;
+
+ recipientDeviceNetworks.Add(recipientDeviceNetwork);
+ }
+
+ if (recipientDeviceNetworks.Count > 0)
+ args.ModifiedRecipients = recipientDeviceNetworks;
+ }
+
+ ///
+ /// Gets packet from controller asking for an update or registering device.
+ ///
+ private void SendStateUpdateToDeviceNetwork(Entity ent)
+ {
+ if (!TryComp(ent, out var device))
+ return;
+
+ var payload = new NetworkPayload
+ {
+ [DeviceNetworkConstants.Command] = DeviceNetworkConstants.CmdUpdatedState,
+ [DeviceNetworkConstants.CmdUpdatedState] = GetTurretState(ent)
+ };
+
+ _deviceNetwork.QueuePacket(ent, null, payload, device: device);
+ }
+
+ ///
+ /// Each time the controller changes settings or registers device it will update the turret and send a packet back to the controller
+ ///
+ private void OnPacketReceived(Entity ent, ref DeviceNetworkPacketEvent args)
+ {
+ if (!args.Data.TryGetValue(DeviceNetworkConstants.Command, out string? command))
+ return;
+
+ if (!HasComp(args.Sender))
+ return;
+
+ switch (command)
+ {
+ // Just trying to get an update of the turret
+ case DeviceNetworkConstants.CmdUpdatedState:
+ SendStateUpdateToDeviceNetwork(ent);
+ return;
+ // Set a new turret mode
+ case DeployableTurretControllerSystem.CmdSetArmamemtState:
+ args.Data.TryGetValue(command, out int updatedState);
+ bool state = true;
+
+ // -1 is inactive so just set state to false
+ if (updatedState == -1)
+ state = false;
+ else
+ {
+ if (!TryComp(ent.Owner, out var firemode))
+ return;
+
+ // swap firemode to the new firemode
+ if (firemode.CurrentFireMode != updatedState)
+ _turretfiremode.TrySetFireMode(ent.Owner, firemode, updatedState);
+ }
+
+ // Set state and send updated packet to controller.
+ SetState(ent, state);
+ SendStateUpdateToDeviceNetwork(ent);
+ return;
+
+ // New access. Update the access.
+ case DeployableTurretControllerSystem.CmdSetAccessExemptions:
+ args.Data.TryGetValue(command, out HashSet>? access);
+
+ if (access == null)
+ return;
+
+ if (!TryComp(ent.Owner, out var comp))
+ return;
+
+ _turretaccess.SyncAccessLevelExemptions(comp, access);
+ return;
+ }
+ }
+
+ protected override void SetState(Entity ent, bool enabled, EntityUid? user = null)
+ {
+ if (ent.Comp.Enabled == enabled)
+ return;
+
+ base.SetState(ent, enabled, user);
+ Dirty(ent);
+
+ // Determine how much time is remaining in the current animation and the one next in queue
+ var animTimeRemaining = MathF.Max((float)(ent.Comp.AnimationCompletionTime - _timing.CurTime).TotalSeconds, 0f);
+ var animTimeNext = ent.Comp.Enabled ? ent.Comp.DeploymentLength : ent.Comp.RetractionLength;
+
+ // End/restart any tasks the NPC was doing
+ // Delay the resumption of any tasks based on the total animation length (plus a buffer)
+ var planCooldown = animTimeRemaining + animTimeNext + 0.5f;
+
+ if (TryComp(ent, out var htn))
+ _htn.SetHTNEnabled((ent, htn), ent.Comp.Enabled, planCooldown);
+
+ // Play audio
+ _audio.PlayPvs(ent.Comp.Enabled ? ent.Comp.DeploymentSound : ent.Comp.RetractionSound, ent, new AudioParams { Volume = -10f });
+ }
+
+ private DeployableTurretState GetTurretState(Entity ent)
+ {
+ if (!TryComp(ent, out var htn) ||
+ ent.Comp.Broken || !HasAmmo(ent))
+ return DeployableTurretState.Disabled;
+
+ if (htn.Plan?.CurrentTask.Operator is GunOperator)
+ return DeployableTurretState.Firing;
+
+ if (ent.Comp.AnimationCompletionTime > _timing.CurTime)
+ return ent.Comp.Enabled ? DeployableTurretState.Deploying : DeployableTurretState.Retracting;
+
+ return ent.Comp.Enabled ? DeployableTurretState.Deployed : DeployableTurretState.Retracted;
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var deployableTurret))
+ {
+ // Check if the turret state has changed since the last update,
+ // and if it has, inform the device network
+ var ent = new Entity(uid, deployableTurret);
+ var newState = GetTurretState(ent);
+
+ if (newState != deployableTurret.CurrentState)
+ {
+ deployableTurret.CurrentState = newState;
+ SendStateUpdateToDeviceNetwork(ent);
+
+ if (TryComp(ent, out var appearance))
+ _appearance.SetData(ent, DeployableTurretVisuals.Turret, newState, appearance);
+ }
+ }
+ }
+}
diff --git a/Content.Server/Worldgen/Prototypes/BiomePrototype.cs b/Content.Server/Worldgen/Prototypes/BiomePrototype.cs
index b74570927fb..bd28e4cafc1 100644
--- a/Content.Server/Worldgen/Prototypes/BiomePrototype.cs
+++ b/Content.Server/Worldgen/Prototypes/BiomePrototype.cs
@@ -58,22 +58,48 @@ private set
///
/// The valid ranges of noise values under which this biome can be picked.
///
- [DataField("noiseRanges", required: true)]
+ [DataField(required: true)] // EE/Mono
public Dictionary> NoiseRanges = default!;
///
/// Higher priority biomes get picked before lower priority ones.
///
- [DataField("priority", required: true)]
+ [DataField(required: true)] // EE/Mono
public int Priority { get; private set; }
///
/// The components that get added to the target map.
///
- [DataField("chunkComponents")]
+ [DataField] // EE/Mono
[AlwaysPushInheritance]
public ComponentRegistry ChunkComponents = new();
+ // Mono / EE start - Specific chunk generation
+ ///
+ /// Minimum X coordinate value to spawn this biome.
+ ///
+ [DataField]
+ public int? MinX;
+
+ ///
+ /// Minimum Y coordinate value to spawn this biome.
+ ///
+ [DataField]
+ public int? MinY;
+
+ ///
+ /// Maximum X coordinate value to spawn this biome.
+ ///
+ [DataField]
+ public int? MaxX;
+
+ ///
+ /// Maximum Y coordinate value to spawn this biome.
+ ///
+ [DataField]
+ public int? MaxY;
+ // Mono / EE end
+
//TODO: Get someone to make this a method on componentregistry that does it Correctly.
///
/// Applies the worldgen config to the given target (presumably a map.)
diff --git a/Content.Server/Worldgen/Systems/Biomes/BiomeSelectionSystem.cs b/Content.Server/Worldgen/Systems/Biomes/BiomeSelectionSystem.cs
index ec55045ca35..a3bbb636010 100644
--- a/Content.Server/Worldgen/Systems/Biomes/BiomeSelectionSystem.cs
+++ b/Content.Server/Worldgen/Systems/Biomes/BiomeSelectionSystem.cs
@@ -46,18 +46,14 @@ private void OnWorldChunkAdded(EntityUid uid, BiomeSelectionComponent component,
Log.Error($"Biome selection ran out of biomes to select? See biomes list: {component.Biomes}");
}
- private void OnBiomeSelectionStartup(EntityUid uid, BiomeSelectionComponent component, ComponentStartup args)
- {
- // surely this can't be THAAAAAAAAAAAAAAAT bad right????
- var sorted = component.Biomes
+
+ private void OnBiomeSelectionStartup(EntityUid uid, BiomeSelectionComponent component, ComponentStartup args) =>
+ component.Biomes = component.Biomes
.Select(x => (Id: x, _proto.Index(x).Priority))
.OrderByDescending(x => x.Priority)
.Select(x => x.Id)
.ToList();
- component.Biomes = sorted; // my hopes and dreams rely on this being pre-sorted by priority.
- }
-
// Frontier: check that a given point (passed as the square of its length) meets the range requirements of a biome
private bool CheckBiomeRange(BiomePrototype biome, float centerLengthSquared)
{
@@ -69,7 +65,12 @@ private bool CheckBiomeRange(BiomePrototype biome, float centerLengthSquared)
}
// End Frontier
- private bool CheckBiomeValidity(EntityUid chunk, BiomePrototype biome, Vector2i coords)
+ // Mono / EE - change like this entire function
+ private bool CheckBiomeValidity(EntityUid chunk, BiomePrototype biome, Vector2i coords) =>
+ (biome.MinX is null || biome.MaxX is null || biome.MinY is null || biome.MaxY is null)
+ ? CheckNoiseRanges(chunk, biome, coords) : CheckSpecificChunkRange(biome, coords);
+
+ private bool CheckNoiseRanges(EntityUid chunk, BiomePrototype biome, Vector2i coords)
{
foreach (var (noise, ranges) in biome.NoiseRanges)
{
@@ -87,8 +88,9 @@ private bool CheckBiomeValidity(EntityUid chunk, BiomePrototype biome, Vector2i
if (!anyValid)
return false;
}
-
return true;
}
-}
+ private bool CheckSpecificChunkRange(BiomePrototype biome, Vector2i coords) =>
+ coords.X >= biome.MinX && coords.X <= biome.MaxX && coords.Y >= biome.MinY && coords.Y <= biome.MaxY;
+}
diff --git a/Content.Server/Worldgen/Systems/Debris/DebrisFeaturePlacerSystem.cs b/Content.Server/Worldgen/Systems/Debris/DebrisFeaturePlacerSystem.cs
index f5ead09cfcb..20fad34f422 100644
--- a/Content.Server/Worldgen/Systems/Debris/DebrisFeaturePlacerSystem.cs
+++ b/Content.Server/Worldgen/Systems/Debris/DebrisFeaturePlacerSystem.cs
@@ -243,7 +243,7 @@ private void OnChunkLoaded(EntityUid uid, DebrisFeaturePlacerControllerComponent
}
if (failures > 0)
- _sawmill.Error($"Failed to place {failures} debris at chunk {args.Chunk}");
+ _sawmill.Error($"Failed to place {failures} debris at chunk {args.Chunk} at coords {args.Coords}");
}
///
diff --git a/Content.Server/Xenoarchaeology/XenoArtifacts/Triggers/Systems/ArtifactMicrowaveTriggerSystem.cs b/Content.Server/Xenoarchaeology/XenoArtifacts/Triggers/Systems/ArtifactMicrowaveTriggerSystem.cs
index 73d8bedf98c..b8acec4163d 100644
--- a/Content.Server/Xenoarchaeology/XenoArtifacts/Triggers/Systems/ArtifactMicrowaveTriggerSystem.cs
+++ b/Content.Server/Xenoarchaeology/XenoArtifacts/Triggers/Systems/ArtifactMicrowaveTriggerSystem.cs
@@ -1,4 +1,5 @@
using Content.Server.Kitchen.Components;
+using Content.Shared.Kitchen.Components;
using Content.Server.Xenoarchaeology.XenoArtifacts.Triggers.Components;
namespace Content.Server.Xenoarchaeology.XenoArtifacts.Triggers.Systems;
diff --git a/Content.Server/Xenoborgs/Xenoborgsystem.cs b/Content.Server/Xenoborgs/Xenoborgsystem.cs
new file mode 100644
index 00000000000..dab720fda87
--- /dev/null
+++ b/Content.Server/Xenoborgs/Xenoborgsystem.cs
@@ -0,0 +1,177 @@
+using Content.Server.Chat.Systems;
+using Content.Server.Explosion.EntitySystems;
+using Content.Shared.Destructible;
+using Content.Shared.Pinpointer;
+using Content.Shared.Xenoborgs.Components;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Player;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Xenoborgs;
+
+public sealed class XenoborgCoreSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly ExplosionSystem _explosion = default!;
+ [Dependency] private readonly ChatSystem _chat = default!;
+
+ private TimeSpan? _soundTime;
+ private TimeSpan? _wipeTime;
+ private TimeSpan? _pinpointerWarningTime;
+ private TimeSpan? _pinpointerWipeTime;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnCoreDestroyed);
+ }
+
+ private void OnCoreDestroyed(EntityUid ent, MothershipCoreComponent comp, DestructionEventArgs args)
+ {
+ /// Announcement every time a core destruction
+ _chat.DispatchGlobalAnnouncement(
+ "A Mothership Core has been destroyed. Xenoborg systems destabilizing... Please discard any Mothership Pinpointers or Pieces before rapid disassembly in 15 seconds",
+ colorOverride: Color.OrangeRed);
+
+ /// Restart pinpointer collapse EVERY core destruction
+ StartPinpointerCollapse();
+
+ /// If this was the final core, trigger full Xenoborg collapse
+ if (IsLastCore())
+ TriggerCollapse();
+ }
+
+ private void StartPinpointerCollapse()
+ {
+ var now = _timing.CurTime;
+
+ _pinpointerWarningTime = now + TimeSpan.FromSeconds(10);
+ _pinpointerWipeTime = now + TimeSpan.FromSeconds(15);
+ }
+
+ private bool IsLastCore()
+ {
+ var query = AllEntityQuery();
+ var count = 0;
+
+ while (query.MoveNext(out _, out _))
+ {
+ count++;
+
+ if (count > 1)
+ return false;
+ }
+
+ return count == 1;
+ }
+
+ private void CleanupPinpointerPieces()
+ {
+ var pieceQuery = EntityQueryEnumerator();
+
+ while (pieceQuery.MoveNext(out var uid, out _))
+ {
+ /// Small explosion?
+ _explosion.QueueExplosion(
+ uid,
+ "Default",
+ 2f,
+ 1f,
+ 2f);
+
+ QueueDel(uid);
+ }
+ }
+
+ private void TriggerCollapse()
+ {
+ var now = _timing.CurTime;
+
+ _chat.DispatchGlobalAnnouncement(
+ "All Mothership Cores have been destroyed. Xenoborg systems destabilizing...",
+ colorOverride: Color.DarkRed);
+
+ _soundTime = now + TimeSpan.FromSeconds(10);
+ _wipeTime = now + TimeSpan.FromSeconds(15);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var now = _timing.CurTime;
+
+ /// FINAL XENOBORG COLLAPSE
+
+ /// Warning buzzer
+ if (_soundTime != null && now >= _soundTime)
+ {
+ _soundTime = null;
+
+ _audio.PlayGlobal(
+ "/Audio/Machines/warning_buzzer_xenoborg.ogg",
+ Filter.Broadcast(),
+ false);
+ }
+
+ /// Full Xenoborg wipe
+ if (_wipeTime != null && now >= _wipeTime)
+ {
+ _wipeTime = null;
+
+ ExplodeAllXenoborgs();
+
+ _chat.DispatchGlobalAnnouncement(
+ "All Xenoborg and Motherships Cores have been destroyed, No further active Xenoborg presence detected in the sector.",
+ colorOverride: Color.DarkRed);
+
+ _chat.DispatchGlobalAnnouncement(
+ "Have a pleasant day.",
+ colorOverride: Color.LimeGreen);
+ }
+
+ /// PINPOINTER COLLAPSE
+
+ /// Warning buzzer
+ if (_pinpointerWarningTime != null && now >= _pinpointerWarningTime)
+ {
+ _pinpointerWarningTime = null;
+
+ _audio.PlayGlobal(
+ "/Audio/Machines/warning_buzzer_xenoborg.ogg",
+ Filter.Broadcast(),
+ false);
+ }
+
+ /// Destroy all Xenoborg pinpointer pieces
+ if (_pinpointerWipeTime != null && now >= _pinpointerWipeTime)
+ {
+ _pinpointerWipeTime = null;
+
+ CleanupPinpointerPieces();
+ }
+ }
+
+ private void ExplodeAllXenoborgs()
+ {
+ var query = AllEntityQuery();
+
+ while (query.MoveNext(out var uid, out _))
+ {
+ /// Don't explode mothership cores themselves failsafe incase new spawned
+ if (HasComp(uid))
+ continue;
+
+ _explosion.QueueExplosion(
+ uid,
+ "Default",
+ 50f,
+ 5f,
+ 20f);
+
+ QueueDel(uid);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/_CE/ZLevels/Chat/CEZLevelsSpeakingSystem.cs b/Content.Server/_CE/ZLevels/Chat/CEZLevelsSpeakingSystem.cs
new file mode 100644
index 00000000000..49c57819dd6
--- /dev/null
+++ b/Content.Server/_CE/ZLevels/Chat/CEZLevelsSpeakingSystem.cs
@@ -0,0 +1,95 @@
+/*
+ * This file is sublicensed under MIT License
+ * https://github.com/space-wizards/space-station-14/blob/master/LICENSE.TXT
+ */
+
+using System.Numerics;
+using Content.Server.Chat.Systems;
+using Content.Shared._CE.ZLevels.Core.Components;
+using Content.Shared._CE.ZLevels.Core.EntitySystems;
+using Content.Shared.Chat;
+using Content.Shared.IdentityManagement;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Spawners;
+using Robust.Shared.Timing;
+
+namespace Content.Server._CE.ZLevels.Chat;
+
+public sealed partial class CEZLevelsSpeakingSystem : EntitySystem
+{
+ [Dependency] private ChatSystem _chat = default!;
+ [Dependency] private CESharedZLevelsSystem _zLevel = default!;
+ [Dependency] private SharedTransformSystem _transform = default!;
+
+ private EntityQuery _mapQuery;
+
+ private const float TransmitterLifetime = 3f;
+ private const int MessageDelayMilliseconds = 333;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _mapQuery = GetEntityQuery();
+
+ SubscribeLocalEvent(OnSpoke);
+ }
+
+ private void OnSpoke(Entity ent, ref EntitySpokeEvent args)
+ {
+ var xform = Transform(ent);
+ var sourceMap = xform.MapUid;
+ if (sourceMap is null)
+ return;
+
+ if (args.IsWhisper == true) // curse of chatcode broken we can just do this now
+ return;
+
+ var globalPosition = _transform.GetWorldPosition(xform);
+ var message = args.Message;
+
+ //Try transmit message to 1 zlevel down
+ if (_zLevel.TryMapDown(sourceMap.Value, out var belowMapUid) &&
+ _mapQuery.TryComp(belowMapUid, out var belowMapComp))
+ {
+ TransmitMessageToZLevel(
+ belowMapComp,
+ globalPosition,
+ message,
+ Loc.GetString("ce-zlevel-voice-from-up", ("name", Identity.Name(ent, EntityManager))));
+ }
+
+ //Try transmit message to 1 zlevel up
+ if (_zLevel.TryMapUp(sourceMap.Value, out var aboveMapUid) &&
+ _mapQuery.TryComp(aboveMapUid, out var aboveMapComp))
+ {
+ TransmitMessageToZLevel(
+ aboveMapComp,
+ globalPosition,
+ message,
+ Loc.GetString("ce-zlevel-voice-from-down", ("name", Identity.Name(ent, EntityManager))));
+ }
+ }
+
+ private void TransmitMessageToZLevel(MapComponent mapComp, Vector2 position, string message, string nameOverride)
+ {
+ var targetPos = new MapCoordinates(position, mapComp.MapId);
+ var transmit = Spawn(null, targetPos);
+ EnsureComp(transmit).Lifetime = TransmitterLifetime;
+
+ //It's not the most elegant solution, but as far as I understand, the entity doesn't have time to enter
+ //the client's PVS after spawning, and we already start communicating through it. A slight delay solves the problem.
+ Timer.Spawn(MessageDelayMilliseconds,
+ () =>
+ {
+ _chat.TrySendInGameICMessage(
+ transmit,
+ message,
+ InGameICChatType.Whisper,
+ false,
+ nameOverride: nameOverride,
+ ignoreActionBlocker: true);
+ });
+ }
+}
diff --git a/Content.Server/_CE/ZLevels/Core/CEZGridConnectorSystem.cs b/Content.Server/_CE/ZLevels/Core/CEZGridConnectorSystem.cs
new file mode 100644
index 00000000000..e180a743670
--- /dev/null
+++ b/Content.Server/_CE/ZLevels/Core/CEZGridConnectorSystem.cs
@@ -0,0 +1,293 @@
+/*
+ * This file is sublicensed under MIT License
+ * https://github.com/space-wizards/space-station-14/blob/master/LICENSE.TXT
+ */
+
+using System.Linq;
+using Content.Shared._CE.ZLevels.Core.Components;
+using Content.Shared._CE.ZLevels.Core.EntitySystems;
+using Robust.Shared.Map;
+
+namespace Content.Server._CE.ZLevels.Core;
+
+//WARNING: This file is vibecoded. It WORKS, but i dunno how that works - and we need investigate that and rewrite to more propriate code human style.
+
+///
+/// Universal z-grid network recalculator driven by .
+/// Sets a dirty flag on any topology event and runs a single recalculation pass per dirty cycle.
+/// Network membership is always fully derived from the set of active connector entities.
+///
+public sealed partial class CEZGridConnectorSystem : EntitySystem
+{
+ [Dependency] private CEZLevelsSystem _zLevels = default!;
+ [Dependency] private IMapManager _mapManager = default!;
+ [Dependency] private SharedTransformSystem _transform = default!;
+ [Dependency] private SharedMapSystem _mapSystem = default!;
+
+ [Dependency] private EntityQuery _zgridQuery = default!;
+ [Dependency] private EntityQuery _zgridNetworkQuery = default!;
+ [Dependency] private EntityQuery _zMapQuery = default!;
+
+ private bool _dirty;
+
+ // Reusable scratch buffers — recalc runs at most once per tick on a single thread,
+ // so we clear and reuse rather than allocating fresh collections each pass.
+ private readonly Dictionary> _adj = new();
+ private readonly List> _components = new();
+ private readonly Stack> _setPool = new();
+ private readonly HashSet _visited = new();
+ private readonly Queue _bfsQueue = new();
+ private readonly Dictionary _gridToTargetNet = new();
+ private readonly HashSet _claimedNets = new();
+ private readonly List _removeBuffer = new();
+
+ private HashSet RentSet()
+ {
+ return _setPool.Count > 0 ? _setPool.Pop() : new HashSet();
+ }
+
+ private void ReturnSet(HashSet set)
+ {
+ set.Clear();
+ _setPool.Push(set);
+ }
+
+ /// Schedules a network recalculation on the next tick.
+ public void MarkDirty()
+ {
+ _dirty = true;
+ }
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnConnectorMapInit);
+ SubscribeLocalEvent(OnConnectorAnchorChanged);
+ SubscribeLocalEvent(OnConnectorTerminating);
+
+ SubscribeLocalEvent(OnGridTerminating);
+ SubscribeLocalEvent(OnTileChanged);
+ SubscribeLocalEvent(OnGridSplit);
+
+ SubscribeLocalEvent(OnGridNetworkShutdown);
+ }
+
+ private void OnConnectorMapInit(Entity