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 @@ + + + + + + + + + + + + + + + + +