diff --git a/.github/workflows/rsi-diff.yml b/.github/workflows/rsi-diff.yml index 15bd68d7e10..390ddcda6e7 100644 --- a/.github/workflows/rsi-diff.yml +++ b/.github/workflows/rsi-diff.yml @@ -5,22 +5,13 @@ on: paths: - '**.rsi/**.png' -permissions: # Explicitly define permissions - contents: write - pull-requests: write - jobs: diff: name: Diff runs-on: ubuntu-latest steps: - - name: Checkout PR HEAD + - name: Checkout uses: actions/checkout@v4.2.2 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} - token: ${{ secrets.PAT_TOKEN }} - fetch-depth: 0 - name: Get changed files id: files diff --git a/Content.Client/Drunk/DrunkOverlay.cs b/Content.Client/Drunk/DrunkOverlay.cs index e01719e62cb..05ba28fcce0 100644 --- a/Content.Client/Drunk/DrunkOverlay.cs +++ b/Content.Client/Drunk/DrunkOverlay.cs @@ -1,3 +1,12 @@ +// SPDX-FileCopyrightText: 2022 Kara D +// SPDX-FileCopyrightText: 2022 Leon Friedrich +// SPDX-FileCopyrightText: 2023 Waylon Cude +// SPDX-FileCopyrightText: 2023 metalgearsloth +// SPDX-FileCopyrightText: 2024 Pieter-Jan Briers +// SPDX-FileCopyrightText: 2025 ark1368 +// +// SPDX-License-Identifier: MPL-2.0 + using Content.Shared.Drunk; using Content.Shared.StatusEffect; using Robust.Client.Graphics; @@ -64,7 +73,11 @@ protected override bool BeforeDraw(in OverlayDrawArgs args) if (args.Viewport.Eye != eyeComp.Eye) return false; - _visualScale = BoozePowerToVisual(CurrentBoozePower); + var visualPower = CurrentBoozePower; + if (_entityManager.TryGetComponent(_playerManager.LocalEntity, out Content.Shared.Traits.Assorted.AlcoholToleranceComponent? tolerance)) + visualPower *= tolerance.VisualScaleMultiplier; + + _visualScale = BoozePowerToVisual(visualPower); return _visualScale > 0; } diff --git a/Content.Client/Humanoid/HumanoidAppearanceSystem.cs b/Content.Client/Humanoid/HumanoidAppearanceSystem.cs index 005edf63f1b..3e8ac7de9b9 100644 --- a/Content.Client/Humanoid/HumanoidAppearanceSystem.cs +++ b/Content.Client/Humanoid/HumanoidAppearanceSystem.cs @@ -1,4 +1,6 @@ using System.Numerics; +using Content.Client._Common.Consent; +using Content.Shared._Common.Consent; using Content.Shared.Humanoid; using Content.Shared.Humanoid.Markings; using Content.Shared.Humanoid.Prototypes; @@ -15,6 +17,9 @@ public sealed class HumanoidAppearanceSystem : SharedHumanoidAppearanceSystem [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly MarkingManager _markingManager = default!; [Dependency] private readonly AppearanceSystem _appearance = default!; + [Dependency] private readonly IClientConsentManager _consentManager = default!; // Hardlight + + private static readonly ProtoId GenitalMarkingsConsent = "GenitalMarkings"; // Hardlight public override void Initialize() { @@ -22,6 +27,8 @@ public override void Initialize() SubscribeLocalEvent(OnHandleState); SubscribeLocalEvent(OnAppearanceChange); + + _consentManager.OnServerDataLoaded += OnConsentChanged; } private void OnHandleState(EntityUid uid, HumanoidAppearanceComponent component, ref AfterAutoHandleStateEvent args) @@ -55,6 +62,15 @@ private void OnAppearanceChange(EntityUid uid, HumanoidAppearanceComponent comp, } } + private void OnConsentChanged() + { + var humanoidQuery = EntityManager.AllEntityQueryEnumerator(); + while (humanoidQuery.MoveNext(out var _, out var humanoid, out var sprite)) + { + UpdateSprite(humanoid, sprite); + } + } + private static bool IsHidden(HumanoidAppearanceComponent humanoid, HumanoidVisualLayers layer) => humanoid.HiddenLayers.ContainsKey(layer) || humanoid.PermanentlyHidden.Contains(layer); @@ -312,6 +328,15 @@ private void ApplyMarking(MarkingPrototype markingPrototype, visible &= humanoid.BaseLayers.TryGetValue(markingPrototype.BodyPart, out var setting) && setting.AllowsMarkings; + visible &= !humanoid.HiddenMarkings.Contains(markingPrototype.ID); // FLOOF ADD + // FLOOF ADD END + + // Hardlight: genital markings consent toggle + if (!(_consentManager.GetConsentSettings().Toggles.TryGetValue(GenitalMarkingsConsent, out var val) && val == "on")) + { + visible &= markingPrototype.MarkingCategory != MarkingCategories.Genital; + } + for (var j = 0; j < markingPrototype.Sprites.Count; j++) { var markingSprite = markingPrototype.Sprites[j]; @@ -329,13 +354,13 @@ private void ApplyMarking(MarkingPrototype markingPrototype, sprite.LayerMapSet(layerId, layer); sprite.LayerSetSprite(layerId, rsi); } - // impstation edit begin - check if there's a shader defined in the markingPrototype's shader datafield, and if there is... - if (markingPrototype.Shader != null) - { - // use spriteComponent's layersetshader function to set the layer's shader to that which is specified. - sprite.LayerSetShader(layerId, markingPrototype.Shader); - } - // impstation edit end + // impstation edit begin - check if there's a shader defined in the markingPrototype's shader datafield, and if there is... + if (markingPrototype.Shader != null) + { + // use spriteComponent's layersetshader function to set the layer's shader to that which is specified. + sprite.LayerSetShader(layerId, markingPrototype.Shader); + } + // impstation edit end sprite.LayerSetVisible(layerId, visible); if (!visible || setting == null) // this is kinda implied diff --git a/Content.Client/Humanoid/MarkingPicker.xaml.cs b/Content.Client/Humanoid/MarkingPicker.xaml.cs index 2ca3662871e..e079f29ac66 100644 --- a/Content.Client/Humanoid/MarkingPicker.xaml.cs +++ b/Content.Client/Humanoid/MarkingPicker.xaml.cs @@ -129,8 +129,9 @@ public MarkingPicker() IoCManager.InjectDependencies(this); _sprite = _entityManager.System(); - - CMarkingCategoryButton.OnItemSelected += OnCategoryChange; + + // Subscribe to consent changes to refresh categories + CMarkingCategoryButton.OnItemSelected += OnCategoryChange; CMarkingsUnused.OnItemSelected += item => _selectedUnusedMarking = CMarkingsUnused[item.ItemIndex]; @@ -157,8 +158,9 @@ private void SetupCategoryButtons() { var category = _markingCategories[i]; var markings = GetMarkings(category); - if (_ignoreCategories.Contains(category) || - markings.Count == 0) + + // Check if the category should be ignored + if (_ignoreCategories.Contains(category) || markings.Count == 0) { continue; } diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs index 5aa6f963e39..cd82587f28f 100644 --- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs +++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs @@ -1,3 +1,71 @@ +// SPDX-FileCopyrightText: 2020 20kdc +// SPDX-FileCopyrightText: 2020 DamianX +// SPDX-FileCopyrightText: 2020 Exp +// SPDX-FileCopyrightText: 2020 ike709 +// SPDX-FileCopyrightText: 2021 Acruid +// SPDX-FileCopyrightText: 2021 Galactic Chimp +// SPDX-FileCopyrightText: 2021 Metal Gear Sloth +// SPDX-FileCopyrightText: 2021 Pancake +// SPDX-FileCopyrightText: 2021 RemberBL +// SPDX-FileCopyrightText: 2021 Remie Richards +// SPDX-FileCopyrightText: 2021 Swept +// SPDX-FileCopyrightText: 2021 Vera Aguilera Puerto +// SPDX-FileCopyrightText: 2021 bgare89 +// SPDX-FileCopyrightText: 2022 Alex Evgrashin +// SPDX-FileCopyrightText: 2022 CommieFlowers +// SPDX-FileCopyrightText: 2022 DrSmugleaf +// SPDX-FileCopyrightText: 2022 EmoGarbage404 +// SPDX-FileCopyrightText: 2022 Flipp Syder +// SPDX-FileCopyrightText: 2022 Jezithyr +// SPDX-FileCopyrightText: 2022 Leeroy +// SPDX-FileCopyrightText: 2022 Moony +// SPDX-FileCopyrightText: 2022 Morber +// SPDX-FileCopyrightText: 2022 Rane +// SPDX-FileCopyrightText: 2022 S1ss3l +// SPDX-FileCopyrightText: 2022 Veritius +// SPDX-FileCopyrightText: 2022 metalgearsloth +// SPDX-FileCopyrightText: 2022 mirrorcult +// SPDX-FileCopyrightText: 2022 rolfero +// SPDX-FileCopyrightText: 2022 wrexbe +// SPDX-FileCopyrightText: 2023 ElectroJr +// SPDX-FileCopyrightText: 2023 James Simonson +// SPDX-FileCopyrightText: 2023 Morb +// SPDX-FileCopyrightText: 2023 PrPleGoo +// SPDX-FileCopyrightText: 2023 Ray +// SPDX-FileCopyrightText: 2023 Visne +// SPDX-FileCopyrightText: 2023 Ygg01 +// SPDX-FileCopyrightText: 2023 csqrb +// SPDX-FileCopyrightText: 2023 deltanedas +// SPDX-FileCopyrightText: 2024 AJCM-git +// SPDX-FileCopyrightText: 2024 Checkraze +// SPDX-FileCopyrightText: 2024 Ciac32 +// SPDX-FileCopyrightText: 2024 Dvir +// SPDX-FileCopyrightText: 2024 Ed +// SPDX-FileCopyrightText: 2024 ErhardSteinhauer +// SPDX-FileCopyrightText: 2024 Errant +// SPDX-FileCopyrightText: 2024 Kot +// SPDX-FileCopyrightText: 2024 Krunklehorn +// SPDX-FileCopyrightText: 2024 Leon Friedrich +// SPDX-FileCopyrightText: 2024 Pieter-Jan Briers +// SPDX-FileCopyrightText: 2024 Tornado Tech +// SPDX-FileCopyrightText: 2024 Vasilis +// SPDX-FileCopyrightText: 2024 Whatstone +// SPDX-FileCopyrightText: 2024 Winkarst-cpu +// SPDX-FileCopyrightText: 2024 dffdff2423 +// SPDX-FileCopyrightText: 2024 eoineoineoin +// SPDX-FileCopyrightText: 2025 Archylle +// SPDX-FileCopyrightText: 2025 Ark +// SPDX-FileCopyrightText: 2025 HacksLua +// SPDX-FileCopyrightText: 2025 Ignaz "Ian" Kraft +// SPDX-FileCopyrightText: 2025 LukeZurg22 +// SPDX-FileCopyrightText: 2025 Mora +// SPDX-FileCopyrightText: 2025 Redrover1760 +// SPDX-FileCopyrightText: 2025 ark1368 +// SPDX-FileCopyrightText: 2025 inquisitor-star +// SPDX-FileCopyrightText: 2025 starch +// +// SPDX-License-Identifier: AGPL-3.0-or-later + using System.IO; using System.Linq; using System.Numerics; @@ -9,6 +77,7 @@ using Content.Client.Sprite; using Content.Client.Stylesheets; using Content.Client.UserInterface.Systems.Guidebook; +using Content.Client.UserInterface.Controls; using Content.Shared.CCVar; using Content.Shared.Clothing; using Content.Shared.GameTicking; @@ -575,6 +644,8 @@ public void RefreshTraits() { TraitsList.DisposeAllChildren(); + EnforceSpeciesTraitRestrictions(); + var traits = _prototypeManager.EnumeratePrototypes().OrderBy(t => Loc.GetString(t.Name)).ToList(); TabContainer.SetTabTitle(3, Loc.GetString("humanoid-profile-editor-traits-tab")); @@ -588,15 +659,53 @@ public void RefreshTraits() return; } + // Dictionary to store category buttons - moved up before it's used + // TraitCategoryButton class missing on this branch: define a lightweight fallback widget locally. + Dictionary categoryButtons = new(); + + var clearAllButton = new ConfirmButton + { + Text = Loc.GetString("humanoid-profile-editor-clear-all-traits-button"), + ConfirmationText = Loc.GetString("humanoid-profile-editor-clear-all-traits-confirm"), + MinSize = new Vector2(0, 30) + }; + clearAllButton.OnPressed += _ => + { + if (Profile == null) + return; + + Profile = Profile.WithoutAllTraitPreferences(); + SetDirty(); + RefreshTraits(); + }; + TraitsList.AddChild(clearAllButton); + + // Add expand/collapse all buttons + var expandCollapseButtons = new TraitExpandCollapseButtons(); + expandCollapseButtons.OnExpandCollapseAll += expanded => + { + // Set the static dictionary state + TraitCategoryButton.SetAllExpanded(expanded); + + // Update all visible category buttons + foreach (var button in categoryButtons.Values) + { + button.SetExpanded(expanded); + } + }; + TraitsList.AddChild(expandCollapseButtons); + // Setup model Dictionary> traitGroups = new(); List defaultTraits = new(); traitGroups.Add(TraitCategoryPrototype.Default, defaultTraits); + var allSelectors = new Dictionary, TraitPreferenceSelector>(); + foreach (var trait in traits) { // Begin DeltaV Additions - Species trait exclusion - if (Profile?.Species is { } selectedSpecies && trait.ExcludedSpecies.Contains(selectedSpecies)) + if (Profile?.Species is { } selectedSpecies && trait.SpeciesBlacklist.Contains(selectedSpecies)) { Profile = Profile?.WithoutTraitPreference(trait.ID, _prototypeManager); continue; @@ -619,23 +728,48 @@ public void RefreshTraits() // Create UI view from model foreach (var (categoryId, categoryTraits) in traitGroups) { + // Skip the default category if it has no traits + if (categoryId == TraitCategoryPrototype.Default && categoryTraits.Count == 0) + continue; + TraitCategoryPrototype? category = null; + string categoryName; + int? maxTraitPoints = null; if (categoryId != TraitCategoryPrototype.Default) { category = _prototypeManager.Index(categoryId); - // Label - TraitsList.AddChild(new Label - { - Text = Loc.GetString(category.Name), - Margin = new Thickness(0, 10, 0, 0), - StyleClasses = { StyleBase.StyleClassLabelHeading }, - }); + categoryName = Loc.GetString(category.Name); + maxTraitPoints = category.MaxTraitPoints; } + else + { + categoryName = Loc.GetString("humanoid-profile-editor-traits-default-category"); + } + + categoryTraits.Sort((a, b) => + { + var traitA = _prototypeManager.Index(a); + var traitB = _prototypeManager.Index(b); + + var costCompare = traitA.Cost.CompareTo(traitB.Cost); + if (costCompare != 0) + return costCompare; + + var traitNameA = Loc.GetString(traitA.Name); + var traitNameB = Loc.GetString(traitB.Name); + return string.Compare(traitNameA, traitNameB, StringComparison.CurrentCulture); + }); + + // Create category button + var categoryButton = new TraitCategoryButton(categoryName); + categoryButtons[categoryId] = categoryButton; + TraitsList.AddChild(categoryButton); List selectors = new(); var selectionCount = 0; + // First pass: calculate current points and create selectors foreach (var traitProto in categoryTraits) { var trait = _prototypeManager.Index(traitProto); @@ -645,11 +779,77 @@ public void RefreshTraits() if (selector.Preference) selectionCount += trait.Cost; + { + var tooltipParts = new List(); + if (trait.Description is { } tdesc) + tooltipParts.Add(Loc.GetString(tdesc)); + + if (trait.MutuallyExclusiveTraits.Count > 0) + { + var names = new List(); + foreach (var exId in trait.MutuallyExclusiveTraits) + { + if (_prototypeManager.TryIndex(exId, out var exProto)) + names.Add($"[color=#ADD8E6]{Loc.GetString(exProto.Name)}[/color]"); + } + if (names.Count > 0) + tooltipParts.Add($"You must not have one of these traits: {string.Join(", ", names)}"); + } + + if (trait.SpeciesBlacklist.Count > 0) + { + var names = new List(); + foreach (var speciesId in trait.SpeciesBlacklist) + { + if (_prototypeManager.TryIndex(speciesId, out var speciesProto)) + names.Add($"[color=#087209]{Loc.GetString(speciesProto.Name)}[/color]"); + } + if (names.Count > 0) + tooltipParts.Add($"You must not be: {string.Join(", ", names)}"); + } + + if (tooltipParts.Count > 0) + selector.SetTooltip(string.Join("\n", tooltipParts)); + } + + allSelectors[trait.ID] = selector; + selector.PreferenceChanged += preference => { if (preference) { + // Calculate current points for this category before adding the new trait + var currentPoints = 0; + if (category != null && category.MaxTraitPoints >= 0) + { + foreach (var existingTraitId in Profile?.TraitPreferences ?? new HashSet>()) + { + if (!_prototypeManager.TryIndex(existingTraitId, out var existingProto)) + continue; + + if (existingProto.Category == categoryId) + currentPoints += existingProto.Cost; + } + + // Check if adding this trait would exceed the maximum points + if (currentPoints + trait.Cost > category.MaxTraitPoints) + { + // Reset the selection without triggering the event + selector.Preference = false; + return; + } + } + + var oldProfile = Profile; Profile = Profile?.WithTraitPreference(trait.ID, _prototypeManager); + + // If the profile didn't change, it means the trait couldn't be added (e.g., due to point limits) + if (Profile == oldProfile) + { + // Reset the selection without triggering the event + selector.Preference = false; + return; + } } else { @@ -657,7 +857,65 @@ public void RefreshTraits() } SetDirty(); - RefreshTraits(); // If too many traits are selected, they will be reset to the real value. + + UpdateTraitIncompatibilityVisibility(allSelectors); + + // Instead of refreshing the entire UI, just update the point counter if needed + if (category is { MaxTraitPoints: >= 0 }) + { + // Recalculate points for this category + var currentPoints = 0; + foreach (var traitId in Profile?.TraitPreferences ?? new HashSet>()) + { + if (!_prototypeManager.TryIndex(traitId, out var proto)) + continue; + + if (proto.Category == category.ID) + currentPoints += proto.Cost; + } + + // Find and update the point counter label + if (categoryButton.TraitsContainer.ChildCount >= 2) + { + var maxPoints = category.MaxTraitPoints.Value; + float pointsLeft = maxPoints - currentPoints; + if (categoryButton.TraitsContainer.GetChild(0) is ProgressBar progressBar) + { + progressBar.Value = pointsLeft; + float percentRemaining = pointsLeft / maxPoints; + + Color barColor; + + if (percentRemaining > 0.5f) + { + barColor = Color.FromHex("#33FF33"); + } + else if (percentRemaining > 0.25f) + { + barColor = Color.FromHex("#FFFF33"); + } + else + { + barColor = Color.FromHex("#FF3333"); + } + + if (progressBar.ForegroundStyleBoxOverride is StyleBoxFlat styleBox) + { + styleBox.BackgroundColor = barColor; + } + } + + if (categoryButton.TraitsContainer.GetChild(1) is Label pointsLabel) + { + pointsLabel.Text = Loc.GetString("humanoid-profile-editor-trait-count-hint", + ("current", pointsLeft), + ("max", category.MaxTraitPoints)); + } + } + + // Update all trait colors based on the new point total + RefreshTraitColors(categoryButton, category, currentPoints); + } }; selectors.Add(selector); } @@ -665,26 +923,149 @@ public void RefreshTraits() // Selection counter if (category is { MaxTraitPoints: >= 0 }) { - TraitsList.AddChild(new Label + var maxPoints = category.MaxTraitPoints.Value; + var progressBar = new ProgressBar + { + MinHeight = 4, + SetHeight = 4f, + MinValue = 0, + MaxValue = maxPoints, + Value = maxPoints - selectionCount, + Margin = new Thickness(0, 0, 0, 2) + }; + + float pointsLeft = maxPoints - selectionCount; + float percentRemaining = pointsLeft / maxPoints; + + Color barColor; + if (percentRemaining > 0.5f) + { + barColor = Color.FromHex("#33FF33"); + } + else if (percentRemaining > 0.25f) + { + barColor = Color.FromHex("#FFFF33"); + } + else + { + barColor = Color.FromHex("#FF3333"); + } + + progressBar.ForegroundStyleBoxOverride = new StyleBoxFlat + { + BackgroundColor = barColor, + }; + + categoryButton.AddTrait(progressBar); + + categoryButton.AddTrait(new Label { - Text = Loc.GetString("humanoid-profile-editor-trait-count-hint", ("current", selectionCount) ,("max", category.MaxTraitPoints)), + Text = Loc.GetString("humanoid-profile-editor-trait-count-hint", ("current", pointsLeft), ("max", category.MaxTraitPoints)), FontColorOverride = Color.Gray }); } + // Second pass: add selectors to UI with appropriate colors foreach (var selector in selectors) { if (selector == null) continue; - if (category is { MaxTraitPoints: >= 0 } && - selector.Cost + selectionCount > category.MaxTraitPoints) + // Color traits red if they would exceed the point limit + if (category is { MaxTraitPoints: >= 0 }) + { + // If this trait would exceed the limit when added to current selection + if (!selector.Preference && selector.Cost + selectionCount > category.MaxTraitPoints) + { + selector.SetUnavailable(true); + } + // If this trait is already selected but would exceed the limit if added now + else if (selector.Preference && selectionCount > category.MaxTraitPoints) + { + // This shouldn't happen normally, but just in case + selector.SetUnavailable(true); + } + } + + categoryButton.AddTrait(selector); + } + + UpdateTraitIncompatibilityVisibility(allSelectors); + } + } + + // Helper method to refresh trait colors when points change + private void RefreshTraitColors(TraitCategoryButton categoryButton, TraitCategoryPrototype category, int currentPoints) + { + // Skip the first child which is the points label + for (int i = 1; i < categoryButton.TraitsContainer.ChildCount; i++) + { + if (categoryButton.TraitsContainer.GetChild(i) is TraitPreferenceSelector selector) + { + // Reset color + selector.TraitButton.ModulateSelfOverride = null; + selector.SetUnavailable(false); + + // If this trait would exceed the limit when added to current selection + if (!selector.Preference && selector.Cost + currentPoints > category.MaxTraitPoints) + { + selector.SetUnavailable(true); + } + // Enable traits that can now be selected + else if (!selector.Preference && selector.Cost + currentPoints <= category.MaxTraitPoints) + { + selector.SetUnavailable(false); + } + } + } + } + + private void UpdateTraitIncompatibilityVisibility(Dictionary, TraitPreferenceSelector> allSelectors) + { + var selected = Profile?.TraitPreferences ?? new HashSet>(); + var currentSpecies = Profile?.Species; + + foreach (var (traitId, selector) in allSelectors) + { + var hide = false; + + if (selected.Contains(traitId)) + { + selector.Visible = true; + continue; + } + + if (!_prototypeManager.TryIndex(traitId, out var thisProto)) + { + selector.Visible = false; + continue; + } + + if (currentSpecies != null) + { + ProtoId speciesId = currentSpecies.Value; + if (thisProto.SpeciesBlacklist.Contains(speciesId)) { - selector.Checkbox.Label.FontColorOverride = Color.Red; + hide = true; } + } - TraitsList.AddChild(selector); + if (!hide) + { + foreach (var sel in selected) + { + if (!_prototypeManager.TryIndex(sel, out var selProto)) + continue; + + if (selProto.MutuallyExclusiveTraits.Contains(traitId) || thisProto.MutuallyExclusiveTraits.Contains(sel)) + { + hide = true; + break; + } + } } + + selector.Visible = !hide; } } @@ -1324,6 +1705,7 @@ private void SetSpecies(string newSpecies) Profile = Profile?.WithSpecies(newSpecies); OnSkinColorOnValueChanged(); // Species may have special color prefs, make sure to update it. Markings.SetSpecies(newSpecies); // Repopulate the markings tab as well. + EnforceSpeciesTraitRestrictions(); // In case there's job restrictions for the species RefreshJobs(); // In case there's species restrictions for loadouts @@ -1335,6 +1717,32 @@ private void SetSpecies(string newSpecies) ReloadPreview(); } + private void EnforceSpeciesTraitRestrictions() + { + if (Profile == null) + return; + + var species = Profile.Species; + var toRemove = new List>(); + + foreach (var traitId in Profile.TraitPreferences) + { + if (!_prototypeManager.TryIndex(traitId, out TraitPrototype? trait)) + continue; + + if (trait.SpeciesBlacklist.Contains(species)) + toRemove.Add(traitId); + } + + foreach (var traitId in toRemove) + { + Profile = Profile.WithoutTraitPreference(traitId, _prototypeManager); + } + + if (toRemove.Count > 0) + SetDirty(); + } + private void SetName(string newName) { Profile = Profile?.WithName(newName); diff --git a/Content.Client/Lobby/UI/Roles/TraitCategoryButton.xaml b/Content.Client/Lobby/UI/Roles/TraitCategoryButton.xaml new file mode 100644 index 00000000000..ad77281eda1 --- /dev/null +++ b/Content.Client/Lobby/UI/Roles/TraitCategoryButton.xaml @@ -0,0 +1,7 @@ + + + + diff --git a/Content.Client/Lobby/UI/Roles/TraitPreferenceSelector.xaml.cs b/Content.Client/Lobby/UI/Roles/TraitPreferenceSelector.xaml.cs index a52a3fa2dbc..75ea89c10a5 100644 --- a/Content.Client/Lobby/UI/Roles/TraitPreferenceSelector.xaml.cs +++ b/Content.Client/Lobby/UI/Roles/TraitPreferenceSelector.xaml.cs @@ -1,8 +1,16 @@ +// SPDX-FileCopyrightText: 2024 Ed +// SPDX-FileCopyrightText: 2024 metalgearsloth +// SPDX-FileCopyrightText: 2025 Ark +// SPDX-FileCopyrightText: 2025 ark1368 +// +// SPDX-License-Identifier: MPL-2.0 + using Content.Shared.Traits; using Robust.Client.AutoGenerated; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.XAML; +using Robust.Shared.Utility; namespace Content.Client.Lobby.UI.Roles; @@ -10,11 +18,21 @@ namespace Content.Client.Lobby.UI.Roles; public sealed partial class TraitPreferenceSelector : Control { public int Cost; + public bool Visible + { + get => Container.Visible; + set => Container.Visible = value; + } + private bool _preference; public bool Preference { - get => Checkbox.Pressed; - set => Checkbox.Pressed = value; + get => _preference; + set + { + _preference = value; + UpdateButtonState(); + } } public event Action? PreferenceChanged; @@ -23,21 +41,112 @@ public TraitPreferenceSelector(TraitPrototype trait) { RobustXamlLoader.Load(this); - var text = trait.Cost != 0 ? $"[{trait.Cost}] " : ""; - text += Loc.GetString(trait.Name); - Cost = trait.Cost; - Checkbox.Text = text; - Checkbox.OnToggled += OnCheckBoxToggled; + + // Set the trait name + NameLabel.Text = Loc.GetString(trait.Name); + + // Set the cost text and color + string costText = ""; + if (trait.Cost > 0) + { + costText = $"-{trait.Cost}"; + CostLabel.FontColorOverride = Color.Red; + } + else if (trait.Cost < 0) + { + costText = $"+{-trait.Cost}"; + CostLabel.FontColorOverride = Color.Green; + } + else + { + costText = $"{trait.Cost}"; + CostLabel.FontColorOverride = Color.Gray; + } + + CostLabel.Text = costText; + + // Set up button event + TraitButton.OnPressed += OnTraitButtonPressed; if (trait.Description is { } desc) { - Checkbox.ToolTip = Loc.GetString(desc); + TraitButton.ToolTip = Loc.GetString(desc); } + + UpdateButtonState(); } - private void OnCheckBoxToggled(BaseButton.ButtonToggledEventArgs args) + public void SetTooltip(string tooltip) + { + TraitButton.TooltipSupplier = _ => + { + var tip = new Robust.Client.UserInterface.CustomControls.Tooltip(); + tip.SetMessage(FormattedMessage.FromMarkupPermissive(tooltip)); + return tip; + }; + } + + private void OnTraitButtonPressed(BaseButton.ButtonEventArgs args) { + // Clicking the trait button toggles selection + Preference = !Preference; PreferenceChanged?.Invoke(Preference); } + + private void UpdateButtonState() + { + if (Preference) + { + // Add a visual indicator that it's selected + TraitButton.StyleClasses.Remove("OpenBottom"); + TraitButton.AddStyleClass("OpenBottomSelected"); + + TraitButton.ModulateSelfOverride = Color.FromHex("#505050"); + } + else + { + // Remove the selected visual indicator + TraitButton.StyleClasses.Remove("OpenBottomSelected"); + TraitButton.StyleClasses.Add("OpenBottom"); + + // Reset the background color + TraitButton.ModulateSelfOverride = null; + } + } + + /// + /// Sets the trait as unavailable for selection by coloring the name text red + /// instead of coloring the entire button. + /// + /// Whether the trait is unavailable for selection + public void SetUnavailable(bool unavailable) + { + if (unavailable) + { + NameLabel.FontColorOverride = Color.Red; + TraitButton.Disabled = true; + + // Preserve the darker gray background if this trait is selected + if (Preference) + { + TraitButton.ModulateSelfOverride = Color.FromHex("#454545"); + } + } + else + { + NameLabel.FontColorOverride = null; + TraitButton.Disabled = false; + + // Restore the appropriate background color based on selection state + if (Preference) + { + TraitButton.ModulateSelfOverride = Color.FromHex("#505050"); + } + else + { + TraitButton.ModulateSelfOverride = null; + } + } + } } diff --git a/Content.Client/Machines/Components/MultipartMachineGhostComponent.cs b/Content.Client/Machines/Components/MultipartMachineGhostComponent.cs new file mode 100644 index 00000000000..8fe4f3d3860 --- /dev/null +++ b/Content.Client/Machines/Components/MultipartMachineGhostComponent.cs @@ -0,0 +1,14 @@ +namespace Content.Client.Machines.Components; + +/// +/// Component attached to all multipart machine ghosts +/// Intended for client side usage only, but used on prototypes. +/// +[RegisterComponent] +public sealed partial class MultipartMachineGhostComponent : Component +{ + /// + /// Machine this particular ghost is linked to. + /// + public EntityUid? LinkedMachine = null; +} diff --git a/Content.Client/Machines/EntitySystems/MultipartMachineSystem.cs b/Content.Client/Machines/EntitySystems/MultipartMachineSystem.cs new file mode 100644 index 00000000000..4919a5e8f2e --- /dev/null +++ b/Content.Client/Machines/EntitySystems/MultipartMachineSystem.cs @@ -0,0 +1,109 @@ +using Content.Client.Examine; +using Content.Client.Machines.Components; +using Content.Shared.Machines.Components; +using Content.Shared.Machines.EntitySystems; +using Robust.Client.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Manager; +using Robust.Shared.Spawners; + +namespace Content.Client.Machines.EntitySystems; + +/// +/// Client side handling of multipart machines. +/// Handles client side examination events to show the expected layout of the machine +/// based on the origin of the main entity. +/// +public sealed class MultipartMachineSystem : SharedMultipartMachineSystem +{ + private readonly EntProtoId _ghostPrototype = "MultipartMachineGhost"; + private readonly Color _partiallyTransparent = new Color(255, 255, 255, 180); + + [Dependency] private readonly SpriteSystem _sprite = default!; + [Dependency] private readonly IPrototypeManager _prototype = default!; + [Dependency] private readonly MetaDataSystem _metaData = default!; + [Dependency] private readonly ISerializationManager _serialization= default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnMachineExamined); + SubscribeLocalEvent(OnHandleState); + SubscribeLocalEvent(OnGhostDespawned); + } + + /// + /// Handles spawning several ghost sprites to show where the different parts of the machine + /// should go and the rotations they're expected to have. + /// Can only show one set of ghost parts at a time and their location depends on the current map/grid + /// location of the origin machine. + /// + /// Entity/Component that has been inspected. + /// Args for the event. + private void OnMachineExamined(Entity ent, ref ClientExaminedEvent args) + { + if (ent.Comp.Ghosts.Count != 0) + { + // Already showing some part ghosts + return; + } + + foreach (var part in ent.Comp.Parts.Values) + { + if (part.Entity.HasValue) + continue; + + var entityCoords = new EntityCoordinates(ent.Owner, part.Offset); + var ghostEnt = Spawn(_ghostPrototype, entityCoords); + + if (!XformQuery.TryGetComponent(ghostEnt, out var xform)) + break; + + xform.LocalRotation = part.Rotation; + + Comp(ghostEnt).LinkedMachine = ent; + + ent.Comp.Ghosts.Add(ghostEnt); + + if (part.GhostProto == null) + continue; + + var entProto = _prototype.Index(part.GhostProto.Value); + if (!entProto.Components.TryGetComponent("Sprite", out var s) || s is not SpriteComponent protoSprite) + return; + + var ghostSprite = EnsureComp(ghostEnt); + _serialization.CopyTo(protoSprite, ref ghostSprite, notNullableOverride: true); + + _sprite.SetColor((ghostEnt, ghostSprite), _partiallyTransparent); + + _metaData.SetEntityName(ghostEnt, entProto.Name); + _metaData.SetEntityDescription(ghostEnt, entProto.Description); + } + } + + private void OnHandleState(Entity ent, ref AfterAutoHandleStateEvent args) + { + foreach (var part in ent.Comp.Parts.Values) + { + part.Entity = part.NetEntity.HasValue ? EnsureEntity(part.NetEntity.Value, ent) : null; + } + } + + /// + /// Handles when a ghost part despawns after its short lifetime. + /// Will attempt to remove itself from the list of known ghost entities in the main multipart + /// machine component. + /// + /// Ghost entity that has been despawned. + /// Args for the event. + private void OnGhostDespawned(Entity ent, ref TimedDespawnEvent args) + { + if (!TryComp(ent.Comp.LinkedMachine, out var machine)) + return; + + machine.Ghosts.Remove(ent); + } +} diff --git a/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.xaml b/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.xaml index 7cef7d58b63..d05262f72de 100644 --- a/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.xaml +++ b/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.xaml @@ -127,7 +127,7 @@ - + diff --git a/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.xaml.cs b/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.xaml.cs index 8b21e7d94bd..cc5016c4a70 100644 --- a/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.xaml.cs +++ b/Content.Client/ParticleAccelerator/UI/ParticleAcceleratorControlMenu.xaml.cs @@ -268,6 +268,7 @@ public sealed class PASegmentControl : Control private RSI? _rsi; public string BaseState { get; set; } = "control_box"; + public bool DefaultVisible { get; set; } = false; public PASegmentControl() { @@ -283,12 +284,14 @@ protected override void EnteredTree() _rsi = IoCManager.Resolve().GetResource($"/Textures/Structures/Power/Generation/PA/{BaseState}.rsi").RSI; MinSize = _rsi.Size; _base.Texture = _rsi["completed"].Frame0; + + SetVisible(DefaultVisible); + _unlit.Visible = DefaultVisible; } public void SetPowerState(ParticleAcceleratorUIState state, bool exists) { - _base.ShaderOverride = exists ? null : _greyScaleShader; - _base.ModulateSelfOverride = exists ? null : new Color(127, 127, 127); + SetVisible(exists); if (!state.Enabled || !exists) { @@ -319,4 +322,23 @@ public void SetPowerState(ParticleAcceleratorUIState state, bool exists) _unlit.Texture = rState.Frame0; } + + /// + /// Adds/Removes the shading to the part in the control menu based on the + /// input state. + /// + /// True if the part exists, false otherwise + private void SetVisible(bool state) + { + if (state) + { + _base.ShaderOverride = null; + _base.ModulateSelfOverride = null; + } + else + { + _base.ShaderOverride = _greyScaleShader; + _base.ModulateSelfOverride = new Color(127, 127, 127); + } + } } diff --git a/Content.Client/Shuttles/UI/ShuttleNavControl.xaml.cs b/Content.Client/Shuttles/UI/ShuttleNavControl.xaml.cs index 63956fcd1c8..2bf883e5c9f 100644 --- a/Content.Client/Shuttles/UI/ShuttleNavControl.xaml.cs +++ b/Content.Client/Shuttles/UI/ShuttleNavControl.xaml.cs @@ -1,3 +1,25 @@ +// SPDX-FileCopyrightText: 2024 ArchRBX +// SPDX-FileCopyrightText: 2024 ErhardSteinhauer +// SPDX-FileCopyrightText: 2024 Nemanja +// SPDX-FileCopyrightText: 2024 Wiebe Geertsma +// SPDX-FileCopyrightText: 2024 eoineoineoin +// SPDX-FileCopyrightText: 2024 exincore +// SPDX-FileCopyrightText: 2024 leonarudo +// SPDX-FileCopyrightText: 2024 metalgearsloth +// SPDX-FileCopyrightText: 2024 neuPanda +// SPDX-FileCopyrightText: 2025 Alex Parrill +// SPDX-FileCopyrightText: 2025 Ark +// SPDX-FileCopyrightText: 2025 Blu +// SPDX-FileCopyrightText: 2025 BlueHNT +// SPDX-FileCopyrightText: 2025 GreaseMonk +// SPDX-FileCopyrightText: 2025 Ilya246 +// SPDX-FileCopyrightText: 2025 LukeZurg22 +// SPDX-FileCopyrightText: 2025 RikuTheKiller +// SPDX-FileCopyrightText: 2025 Whatstone +// SPDX-FileCopyrightText: 2025 ark1368 +// +// SPDX-License-Identifier: AGPL-3.0-or-later + using System.Numerics; using Content.Shared.Shuttles.BUIStates; using Content.Shared.Shuttles.Components; @@ -15,9 +37,12 @@ using Robust.Shared.Timing; using Content.Client._Mono.Radar; using Content.Shared._Mono.Radar; -using Content.Client.Station; // Frontier -using Robust.Shared.Collections; // Frontier helpers -using Content.Shared._NF.Shuttles.Events; // Frontier: InertiaDampeningMode +using Robust.Shared.Prototypes; +using System.Linq; +using Content.Shared._Crescent.ShipShields; +using Robust.Shared.Physics.Collision.Shapes; +using Content.Client.Station; // StationSystem (client) +using Content.Shared._NF.Shuttles.Events; // InertiaDampeningMode namespace Content.Client.Shuttles.UI; @@ -278,7 +303,10 @@ protected override void Draw(DrawingHandleScreen handle) Matrix3x2.Invert(shuttleToWorld, out var worldToShuttle); var shuttleToView = Matrix3x2.CreateScale(new Vector2(MinimapScale, -MinimapScale)) * Matrix3x2.CreateTranslation(MidPointVector); - // Frontier: north line drawing + // Draw shields + DrawShields(handle, xform, worldToShuttle); + + // Frontier Corvax: north line drawing var rot = ourEntRot + _rotation.Value; DrawNorthLine(handle, rot); @@ -473,37 +501,16 @@ protected override void Draw(DrawingHandleScreen handle) var origin = ScalePosition(-new Vector2(Offset.X, -Offset.Y)); handle.DrawLine(origin, origin + angle.ToVec() * ScaledMinimapRadius * 1.42f, Color.Red.WithAlpha(0.1f)); - // Get raw blips with grid information - var rawBlips = _blips.GetRawBlips(); + // Get blips + var rawBlips = _blips.GetCurrentBlips(); // Prepare view bounds for culling - var blipViewBounds = new Box2(-3f, -3f, Size.X + 3f, Size.Y + 3f); + var blipViewBounds = new Box2(-3f, -3f, Size.X + 3f, Size.Y + 3f); // Draw blips using the same grid-relative transformation approach as docks foreach (var blip in rawBlips) { - Vector2 blipPosInView; - - // Handle differently based on if there's a grid - if (blip.Grid == null) - { - // For world-space blips without a grid, use standard world transformation - blipPosInView = Vector2.Transform(blip.Position, worldToShuttle * shuttleToView); - } - else if (EntManager.TryGetEntity(blip.Grid, out var gridEntity)) - { - // For grid-relative blips, transform using the grid's transform - var gridToWorld = _transform.GetWorldMatrix(gridEntity.Value); - var gridToView = gridToWorld * worldToShuttle * shuttleToView; - - // Transform the grid-local position - blipPosInView = Vector2.Transform(blip.Position, gridToView); - } - else - { - // Skip blips with invalid grid references - continue; - } + var blipPosInView = Vector2.Transform(_transform.ToMapCoordinates(blip.Position).Position, worldToShuttle * shuttleToView); // Check if this blip is within view bounds before drawing if (blipViewBounds.Contains(blipPosInView)) @@ -511,96 +518,39 @@ protected override void Draw(DrawingHandleScreen handle) DrawBlipShape(handle, blipPosInView, blip.Scale * 3f, blip.Color.WithAlpha(0.8f), blip.Shape); } } - } - /// - /// Frontier: Checks if an IFF marker should be drawn based on distance and maximum IFF range. - /// - private bool NFCheckShouldDrawIffRangeCondition(bool shouldDrawIff, Vector2 distance) - { - if (shouldDrawIff && MaximumIFFDistance >= 0.0f) + // Draw hitscan lines from the radar blips system + var hitscanLines = _blips.GetHitscanLines(); + foreach (var line in hitscanLines) { - if (distance.Length() > MaximumIFFDistance) - shouldDrawIff = false; - } - return shouldDrawIff; - } + var startPosInView = Vector2.Transform(line.Start, worldToShuttle * shuttleToView); + var endPosInView = Vector2.Transform(line.End, worldToShuttle * shuttleToView); - /// - /// Frontier: Adds a blip to the list for later drawing. - /// - private static void NFAddBlipToList(List blipDataList, bool isOutsideRadarCircle, Vector2 uiPosition, int uiXCentre, int uiYCentre, Color color) - { - blipDataList.Add(new BlipData - { - IsOutsideRadarCircle = isOutsideRadarCircle, - UiPosition = uiPosition, - VectorToPosition = uiPosition - new Vector2(uiXCentre, uiYCentre), - Color = color - }); - } - - /// - /// Frontier: Adds blip style triangles on ship edges or towards ships. - /// - private void NFDrawBlips(DrawingHandleBase handle, List blipDataList) - { - var blipValueList = new Dictionary>(); - - foreach (var blipData in blipDataList) - { - var triangleShapeVectorPoints = new[] - { - new Vector2(0, 0), - new Vector2(RadarBlipSize, 0), - new Vector2(RadarBlipSize * 0.5f, RadarBlipSize) - }; - - if (blipData.IsOutsideRadarCircle) + // Only draw lines if at least one endpoint is within view + if (blipViewBounds.Contains(startPosInView) || blipViewBounds.Contains(endPosInView)) { - var angle = (float)Math.Atan2(blipData.VectorToPosition.Y, blipData.VectorToPosition.X) + -1.6f; - var cos = (float)Math.Cos(angle); - var sin = (float)Math.Sin(angle); - float[,] rotationMatrix = { { cos, -sin }, { sin, cos } }; + // Draw the line with the specified thickness and color + handle.DrawLine(startPosInView, endPosInView, line.Color); - for (var i = 0; i < triangleShapeVectorPoints.Length; i++) + // For thicker lines, draw multiple lines side by side + if (line.Thickness > 1.0f) { - var vertex = triangleShapeVectorPoints[i]; - var x = vertex.X * rotationMatrix[0, 0] + vertex.Y * rotationMatrix[0, 1]; - var y = vertex.X * rotationMatrix[1, 0] + vertex.Y * rotationMatrix[1, 1]; - triangleShapeVectorPoints[i] = new Vector2(x, y); - } - } - - var triangleCenterVector = - (triangleShapeVectorPoints[0] + triangleShapeVectorPoints[1] + triangleShapeVectorPoints[2]) / 3; + // Calculate perpendicular vector for thickness + var dir = (endPosInView - startPosInView).Normalized(); + var perpendicular = new Vector2(-dir.Y, dir.X) * 0.5f; - var vectorsFromCenter = new Vector2[3]; - for (int i = 0; i < 3; i++) - { - vectorsFromCenter[i] = (triangleShapeVectorPoints[i] - triangleCenterVector) * UIScale; - } - - var newVerts = new Vector2[3]; - for (var i = 0; i < 3; i++) - { - newVerts[i] = (blipData.UiPosition * UIScale) + vectorsFromCenter[i]; - } - - if (!blipValueList.TryGetValue(blipData.Color, out var valueList)) - { - valueList = new ValueList(); + // Draw additional lines for thickness + for (float i = 1; i <= line.Thickness; i += 1.0f) + { + var offset = perpendicular * i; + handle.DrawLine(startPosInView + offset, endPosInView + offset, line.Color); + handle.DrawLine(startPosInView - offset, endPosInView - offset, line.Color); + } + } } - valueList.Add(newVerts[0]); - valueList.Add(newVerts[1]); - valueList.Add(newVerts[2]); - blipValueList[blipData.Color] = valueList; } - foreach (var color in blipValueList) - { - handle.DrawPrimitives(DrawPrimitiveTopology.TriangleList, color.Value.Span, color.Key); - } + ClearShader(handle); } private void DrawBlipShape(DrawingHandleScreen handle, Vector2 position, float size, Color color, RadarBlipShape shape) @@ -703,11 +653,11 @@ private void DrawDocks(DrawingHandleScreen handle, EntityUid uid, Matrix3x2 grid if (!ShowDocks) return; - const float DockScale = 0.6f; + var dockScale = 0.6f; var nent = EntManager.GetNetEntity(uid); const float sqrt2 = 1.41421356f; - const float dockRadius = DockScale * sqrt2; + var dockRadius = dockScale * sqrt2; // Worst-case bounds used to cull a dock: Box2 viewBounds = new Box2( -dockRadius * UIScale, @@ -732,10 +682,10 @@ private void DrawDocks(DrawingHandleScreen handle, EntityUid uid, Matrix3x2 grid var verts = new[] { - Vector2.Transform(position + new Vector2(-DockScale, -DockScale), gridToView), - Vector2.Transform(position + new Vector2(DockScale, -DockScale), gridToView), - Vector2.Transform(position + new Vector2(DockScale, DockScale), gridToView), - Vector2.Transform(position + new Vector2(-DockScale, DockScale), gridToView), + Vector2.Transform(position + new Vector2(-dockScale, -dockScale), gridToView), + Vector2.Transform(position + new Vector2(dockScale, -dockScale), gridToView), + Vector2.Transform(position + new Vector2(dockScale, dockScale), gridToView), + Vector2.Transform(position + new Vector2(-dockScale, dockScale), gridToView), }; handle.DrawPrimitives(DrawPrimitiveTopology.TriangleFan, verts, color.WithAlpha(0.8f)); @@ -777,6 +727,123 @@ public class BlipData } private const int RadarBlipSize = 15; - private const int RadarFontSize = 10; + private const int RadarFontSize = 8; + + private void DrawShields(DrawingHandleScreen handle, TransformComponent consoleXform, Matrix3x2 matrix) + { + var shields = EntManager.AllEntityQueryEnumerator(); + while (shields.MoveNext(out var uid, out var visuals, out var fixtures, out var xform)) + { + if (!EntManager.TryGetComponent(xform.GridUid, out var parentXform)) + continue; + + if (xform.MapID != consoleXform.MapID) + continue; + + // Don't draw shields when in FTL + if (EntManager.HasComponent(parentXform.Owner)) + continue; + + var shieldFixture = fixtures.Fixtures.TryGetValue("shield", out var fixture) ? fixture : null; + + if (shieldFixture == null || shieldFixture.Shape is not ChainShape) + continue; + + var chain = (ChainShape)shieldFixture.Shape; + var count = chain.Count; + var verticies = chain.Vertices; + + var center = xform.LocalPosition; + + for (int i = 1; i < count; i++) + { + var v1 = Vector2.Add(center, verticies[i - 1]); + v1 = Vector2.Transform(v1, parentXform.WorldMatrix); // transform to world matrix + v1 = Vector2.Transform(v1, matrix); // get back to local matrix for drawing + v1.Y = -v1.Y; + v1 = ScalePosition(v1); + var v2 = Vector2.Add(center, verticies[i]); + v2 = Vector2.Transform(v2, parentXform.WorldMatrix); + v2 = Vector2.Transform(v2, matrix); + v2.Y = -v2.Y; + v2 = ScalePosition(v2); + handle.DrawLine(v1, v2, visuals.ShieldColor); + } + } + } + + // Frontier helpers: IFF range filter and blip rendering utilities + private bool NFCheckShouldDrawIffRangeCondition(bool shouldDrawIff, Vector2 distance) + { + if (shouldDrawIff && MaximumIFFDistance >= 0.0f) + { + if (distance.Length() > MaximumIFFDistance) + shouldDrawIff = false; + } + return shouldDrawIff; + } + + private static void NFAddBlipToList(List blipDataList, bool isOutsideRadarCircle, Vector2 uiPosition, int uiXCentre, int uiYCentre, Color color) + { + blipDataList.Add(new BlipData + { + IsOutsideRadarCircle = isOutsideRadarCircle, + UiPosition = uiPosition, + VectorToPosition = uiPosition - new Vector2(uiXCentre, uiYCentre), + Color = color + }); + } + + private void NFDrawBlips(DrawingHandleScreen handle, List blipDataList) + { + var byColor = new Dictionary>(); + + foreach (var blip in blipDataList) + { + var tri = new[] + { + new Vector2(0, 0), + new Vector2(RadarBlipSize, 0), + new Vector2(RadarBlipSize * 0.5f, RadarBlipSize) + }; + + if (blip.IsOutsideRadarCircle) + { + var angle = MathF.Atan2(blip.VectorToPosition.Y, blip.VectorToPosition.X) - 1.6f; + var cos = MathF.Cos(angle); + var sin = MathF.Sin(angle); + for (var i = 0; i < tri.Length; i++) + { + var v = tri[i]; + tri[i] = new Vector2(v.X * cos - v.Y * sin, v.X * sin + v.Y * cos); + } + } + + var center = (tri[0] + tri[1] + tri[2]) / 3f; + var verts = new Vector2[3]; + for (var i = 0; i < 3; i++) + { + var offset = (tri[i] - center) * UIScale; + verts[i] = blip.UiPosition * UIScale + offset; + } + + if (!byColor.TryGetValue(blip.Color, out var list)) + { + list = new List(); + byColor[blip.Color] = list; + } + list.AddRange(verts); + } + + foreach (var kvp in byColor) + { + handle.DrawPrimitives(DrawPrimitiveTopology.TriangleList, kvp.Value.ToArray(), kvp.Key); + } + } + + private void ClearShader(DrawingHandleScreen handle) + { + // No-op placeholder: retained for compatibility with previous shader-based effects. + } } diff --git a/Content.Client/Silicons/StationAi/StationAiFixerConsoleBoundUserInterface.cs b/Content.Client/Silicons/StationAi/StationAiFixerConsoleBoundUserInterface.cs new file mode 100644 index 00000000000..63183c2334d --- /dev/null +++ b/Content.Client/Silicons/StationAi/StationAiFixerConsoleBoundUserInterface.cs @@ -0,0 +1,42 @@ +using Content.Shared.Silicons.StationAi; +using Robust.Client.UserInterface; + +namespace Content.Client.Silicons.StationAi; + +public sealed class StationAiFixerConsoleBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey) +{ + private StationAiFixerConsoleWindow? _window; + + protected override void Open() + { + base.Open(); + + _window = this.CreateWindow(); + _window.SetOwner(Owner); + + _window.SendStationAiFixerConsoleMessageAction += SendStationAiFixerConsoleMessage; + _window.OpenConfirmationDialogAction += OpenConfirmationDialog; + } + + public override void Update() + { + base.Update(); + _window?.UpdateState(); + } + + private void OpenConfirmationDialog() + { + if (_window == null) + return; + + _window.ConfirmationDialog?.Close(); + _window.ConfirmationDialog = new StationAiFixerConsoleConfirmationDialog(); + _window.ConfirmationDialog.OpenCentered(); + _window.ConfirmationDialog.SendStationAiFixerConsoleMessageAction += SendStationAiFixerConsoleMessage; + } + + private void SendStationAiFixerConsoleMessage(StationAiFixerConsoleAction action) + { + SendPredictedMessage(new StationAiFixerConsoleMessage(action)); + } +} diff --git a/Content.Client/Silicons/StationAi/StationAiFixerConsoleConfirmationDialog.xaml b/Content.Client/Silicons/StationAi/StationAiFixerConsoleConfirmationDialog.xaml new file mode 100644 index 00000000000..fa61d614e01 --- /dev/null +++ b/Content.Client/Silicons/StationAi/StationAiFixerConsoleConfirmationDialog.xaml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Content.Client/Silicons/StationAi/StationAiFixerConsoleWindow.xaml.cs b/Content.Client/Silicons/StationAi/StationAiFixerConsoleWindow.xaml.cs new file mode 100644 index 00000000000..0c3140a13e3 --- /dev/null +++ b/Content.Client/Silicons/StationAi/StationAiFixerConsoleWindow.xaml.cs @@ -0,0 +1,198 @@ +using Content.Client.UserInterface.Controls; +using Content.Shared.Lock; +using Content.Shared.Silicons.StationAi; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using System.Numerics; + +namespace Content.Client.Silicons.StationAi; + +[GenerateTypedNameReferences] +public sealed partial class StationAiFixerConsoleWindow : FancyWindow +{ + [Dependency] private readonly IEntityManager _entManager = default!; + [Dependency] private readonly IGameTiming _timing = default!; + + private readonly StationAiFixerConsoleSystem _stationAiFixerConsole; + private readonly SharedStationAiSystem _stationAi; + + private EntityUid? _owner; + + private readonly SpriteSpecifier.Rsi _emptyPortrait = new(new("Mobs/Silicon/station_ai.rsi"), "ai_empty"); + private readonly SpriteSpecifier.Rsi _rebootingPortrait = new(new("Mobs/Silicon/station_ai.rsi"), "ai_fuzz"); + private SpriteSpecifier? _currentPortrait; + + public event Action? SendStationAiFixerConsoleMessageAction; + public event Action? OpenConfirmationDialogAction; + + public StationAiFixerConsoleConfirmationDialog? ConfirmationDialog; + + private readonly Dictionary _statusColors = new() + { + [StationAiState.Empty] = Color.FromHex("#464966"), + [StationAiState.Occupied] = Color.FromHex("#3E6C45"), + [StationAiState.Rebooting] = Color.FromHex("#A5762F"), + [StationAiState.Dead] = Color.FromHex("#BB3232"), + }; + + public StationAiFixerConsoleWindow() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + + _stationAiFixerConsole = _entManager.System(); + _stationAi = _entManager.System(); + + StationAiPortraitTexture.DisplayRect.TextureScale = new Vector2(4f, 4f); + + CancelButton.OnButtonDown += _ => OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction.Cancel); + EjectButton.OnButtonDown += _ => OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction.Eject); + RepairButton.OnButtonDown += _ => OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction.Repair); + PurgeButton.OnButtonDown += _ => OnOpenConfirmationDialog(); + + CancelButton.Label.HorizontalAlignment = HAlignment.Left; + EjectButton.Label.HorizontalAlignment = HAlignment.Left; + RepairButton.Label.HorizontalAlignment = HAlignment.Left; + PurgeButton.Label.HorizontalAlignment = HAlignment.Left; + + CancelButton.Label.Margin = new Thickness(40, 0, 0, 0); + EjectButton.Label.Margin = new Thickness(40, 0, 0, 0); + RepairButton.Label.Margin = new Thickness(40, 0, 0, 0); + PurgeButton.Label.Margin = new Thickness(40, 0, 0, 0); + } + + public void OnSendStationAiFixerConsoleMessage(StationAiFixerConsoleAction action) + { + SendStationAiFixerConsoleMessageAction?.Invoke(action); + } + + public void OnOpenConfirmationDialog() + { + OpenConfirmationDialogAction?.Invoke(); + } + + public override void Close() + { + base.Close(); + ConfirmationDialog?.Close(); + } + + public void SetOwner(EntityUid owner) + { + _owner = owner; + UpdateState(); + } + + public void UpdateState() + { + if (!_entManager.TryGetComponent(_owner, out var stationAiFixerConsole)) + return; + + var ent = (_owner.Value, stationAiFixerConsole); + var isLocked = _entManager.TryGetComponent(_owner, out var lockable) && lockable.Locked; + + var stationAiHolderInserted = _stationAiFixerConsole.IsStationAiHolderInserted((_owner.Value, stationAiFixerConsole)); + var stationAi = stationAiFixerConsole.ActionTarget; + var stationAiState = StationAiState.Empty; + + if (_entManager.TryGetComponent(stationAi, out var stationAiCustomization)) + { + stationAiState = stationAiCustomization.State; + } + + // Set subscreen visibility + LockScreen.Visible = isLocked; + MainControls.Visible = !isLocked && !_stationAiFixerConsole.IsActionInProgress(ent); + ActionProgressScreen.Visible = !isLocked && _stationAiFixerConsole.IsActionInProgress(ent); + + // Update station AI name + StationAiNameLabel.Text = GetStationAiName(stationAi); + StationAiStatusLabel.Text = Loc.GetString("station-ai-fixer-console-window-no-station-ai-status"); + + // Update station AI portrait + var portrait = _emptyPortrait; + var statusColor = _statusColors[StationAiState.Empty]; + + if (stationAiState == StationAiState.Rebooting) + { + portrait = _rebootingPortrait; + StationAiStatusLabel.Text = Loc.GetString("station-ai-fixer-console-window-station-ai-rebooting"); + _statusColors.TryGetValue(StationAiState.Rebooting, out statusColor); + } + else if (stationAi != null && + stationAiCustomization != null && + _stationAi.TryGetCustomizedAppearanceData((stationAi.Value, stationAiCustomization), out var layerData)) + { + StationAiStatusLabel.Text = stationAiState == StationAiState.Occupied ? + Loc.GetString("station-ai-fixer-console-window-station-ai-online") : + Loc.GetString("station-ai-fixer-console-window-station-ai-offline"); + + if (layerData.TryGetValue(stationAiState.ToString(), out var stateData) && stateData is { RsiPath: not null, State: not null }) + { + portrait = new SpriteSpecifier.Rsi(new ResPath(stateData.RsiPath), stateData.State); + } + + _statusColors.TryGetValue(stationAiState, out statusColor); + } + + if (_currentPortrait == null || !_currentPortrait.Equals(portrait)) + { + StationAiPortraitTexture.SetFromSpriteSpecifier(portrait); + _currentPortrait = portrait; + } + + StationAiStatus.PanelOverride = new StyleBoxFlat + { + BackgroundColor = statusColor, + }; + + // Update buttons + EjectButton.Disabled = !stationAiHolderInserted; + RepairButton.Disabled = !stationAiHolderInserted || stationAiState != StationAiState.Dead; + PurgeButton.Disabled = !stationAiHolderInserted || stationAiState == StationAiState.Empty; + + // Update progress bar + if (ActionProgressScreen.Visible) + UpdateProgressBar(ent); + } + + public void UpdateProgressBar(Entity ent) + { + ActionInProgressLabel.Text = ent.Comp.ActionType == StationAiFixerConsoleAction.Repair ? + Loc.GetString("station-ai-fixer-console-window-action-progress-repair") : + Loc.GetString("station-ai-fixer-console-window-action-progress-purge"); + + var fullTimeSpan = ent.Comp.ActionEndTime - ent.Comp.ActionStartTime; + var remainingTimeSpan = ent.Comp.ActionEndTime - _timing.CurTime; + + var time = remainingTimeSpan.TotalSeconds > 60 ? remainingTimeSpan.TotalMinutes : remainingTimeSpan.TotalSeconds; + var units = remainingTimeSpan.TotalSeconds > 60 ? Loc.GetString("generic-minutes") : Loc.GetString("generic-seconds"); + ActionProgressEtaLabel.Text = Loc.GetString("station-ai-fixer-console-window-action-progress-eta", ("time", (int)time), ("units", units)); + + ActionProgressBar.Value = 1f - (float)remainingTimeSpan.Divide(fullTimeSpan); + } + + private string GetStationAiName(EntityUid? uid) + { + if (_entManager.TryGetComponent(uid, out var metadata)) + { + return metadata.EntityName; + } + + return Loc.GetString("station-ai-fixer-console-window-no-station-ai"); + } + + protected override void FrameUpdate(FrameEventArgs args) + { + if (!ActionProgressScreen.Visible) + return; + + if (!_entManager.TryGetComponent(_owner, out var stationAiFixerConsole)) + return; + + UpdateProgressBar((_owner.Value, stationAiFixerConsole)); + } +} diff --git a/Content.Client/Silicons/StationAi/StationAiSystem.cs b/Content.Client/Silicons/StationAi/StationAiSystem.cs index 75588eda391..d4a8b9dbd81 100644 --- a/Content.Client/Silicons/StationAi/StationAiSystem.cs +++ b/Content.Client/Silicons/StationAi/StationAiSystem.cs @@ -11,6 +11,7 @@ public sealed partial class StationAiSystem : SharedStationAiSystem [Dependency] private readonly IOverlayManager _overlayMgr = default!; [Dependency] private readonly IPlayerManager _player = default!; [Dependency] private readonly SharedAppearanceSystem _appearance = default!; + [Dependency] private readonly SpriteSystem _sprite = default!; private StationAiOverlay? _overlay; @@ -80,10 +81,10 @@ private void OnAppearanceChange(Entity entity, ref Appea if (args.Sprite == null) return; - if (_appearance.TryGetData(entity.Owner, StationAiVisualState.Key, out var layerData, args.Component)) - args.Sprite.LayerSetData(StationAiVisualState.Key, layerData); + if (_appearance.TryGetData(entity.Owner, StationAiVisualLayers.Icon, out var layerData, args.Component)) + _sprite.LayerSetData((entity.Owner, args.Sprite), StationAiVisualLayers.Icon, layerData); - args.Sprite.LayerSetVisible(StationAiVisualState.Key, layerData != null); + _sprite.LayerSetVisible((entity.Owner, args.Sprite), StationAiVisualLayers.Icon, layerData != null); } public override void Shutdown() diff --git a/Content.Client/Standing/LayingDownSystem.cs b/Content.Client/Standing/LayingDownSystem.cs index d45d4811340..4fc529a097f 100644 --- a/Content.Client/Standing/LayingDownSystem.cs +++ b/Content.Client/Standing/LayingDownSystem.cs @@ -1,3 +1,4 @@ +using Content.Client._DV.Abilities; using Content.Shared.Buckle; using Content.Shared.Rotation; using Content.Shared.Standing; @@ -27,14 +28,14 @@ public override void Initialize() public override void Update(float frameTime) { // Update draw depth of laying down entities as necessary - var query = EntityQueryEnumerator(); - while (query.MoveNext(out var uid, out var layingDown, out var standing, out var sprite)) + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var layingDown, out var standing, out var sprite, out var drawDepth)) { // Do not modify the entities draw depth if it's modified externally if (sprite.DrawDepth != layingDown.NormalDrawDepth && sprite.DrawDepth != layingDown.CrawlingUnderDrawDepth) continue; - sprite.DrawDepth = standing.CurrentState is StandingState.Lying && layingDown.IsCrawlingUnder + sprite.DrawDepth = (standing.CurrentState is StandingState.Lying && layingDown.IsCrawlingUnder || drawDepth.OriginalDrawDepth != null) ? layingDown.CrawlingUnderDrawDepth : layingDown.NormalDrawDepth; } diff --git a/Content.Client/Stylesheets/StyleBase.cs b/Content.Client/Stylesheets/StyleBase.cs index 5a03a6b8ece..060e745d635 100644 --- a/Content.Client/Stylesheets/StyleBase.cs +++ b/Content.Client/Stylesheets/StyleBase.cs @@ -21,8 +21,10 @@ public abstract class StyleBase public const string ClassAngleRect = "AngleRect"; public const string ButtonOpenRight = "OpenRight"; + public const string ButtonOpenRightSelected = "OpenRightSelected"; public const string ButtonOpenLeft = "OpenLeft"; public const string ButtonOpenBoth = "OpenBoth"; + public const string ButtonOpenBothSelected = "OpenBothSelected"; public const string ButtonSquare = "ButtonSquare"; public const string ButtonCaution = "Caution"; diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs index a6ea89bbdcd..d74cc508f19 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -692,6 +692,11 @@ public StyleNano(IResourceCache resCache) : base(resCache) .Class(ButtonOpenRight) .Prop(ContainerButton.StylePropertyStyleBox, BaseButtonOpenRight), + Element().Class(ContainerButton.StyleClassButton) + .Class(ButtonOpenRightSelected) + .Prop(ContainerButton.StylePropertyStyleBox, BaseButtonOpenRight) + .Prop(Control.StylePropertyModulateSelf, ButtonColorGoodDefault), + Element().Class(ContainerButton.StyleClassButton) .Class(ButtonOpenLeft) .Prop(ContainerButton.StylePropertyStyleBox, BaseButtonOpenLeft), @@ -1880,7 +1885,21 @@ public StyleNano(IResourceCache resCache) : base(resCache) new[] { new StyleProperty(TextureButton.StylePropertyTexture, resCache.GetTexture("/Textures/Interface/Bwoink/un_pinned.png")) - }) + }), + + // Add the style rule for OpenRightSelected + Element