diff --git a/Content.Client/_Goobstation/Factory/ConstructorSystem.cs b/Content.Client/_Goobstation/Factory/ConstructorSystem.cs new file mode 100644 index 0000000000..3ec5a6136c --- /dev/null +++ b/Content.Client/_Goobstation/Factory/ConstructorSystem.cs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2025 GoobBot +// SPDX-FileCopyrightText: 2025 deltanedas <@deltanedas:kde.org> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Shared._Goobstation.Factory; + +namespace Content.Client._Goobstation.Factory; + +public sealed class ConstructorSystem : SharedConstructorSystem; diff --git a/Content.Client/_Goobstation/Factory/InteractorSystem.cs b/Content.Client/_Goobstation/Factory/InteractorSystem.cs new file mode 100644 index 0000000000..416328db89 --- /dev/null +++ b/Content.Client/_Goobstation/Factory/InteractorSystem.cs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2025 GoobBot +// SPDX-FileCopyrightText: 2025 deltanedas <@deltanedas:kde.org> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Shared._Goobstation.Factory; + +namespace Content.Client._Goobstation.Factory; + +public sealed class InteractorSystem : SharedInteractorSystem; diff --git a/Content.Client/_Goobstation/Factory/RoboticArmAnimationSystem.cs b/Content.Client/_Goobstation/Factory/RoboticArmAnimationSystem.cs new file mode 100644 index 0000000000..26b0f25a14 --- /dev/null +++ b/Content.Client/_Goobstation/Factory/RoboticArmAnimationSystem.cs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2025 GoobBot +// SPDX-FileCopyrightText: 2025 deltanedas <@deltanedas:kde.org> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Shared._Goobstation.Factory; +using Robust.Client.GameObjects; +using Robust.Shared.Timing; + +namespace Content.Client._Goobstation.Factory; + +/// +/// Animations robotic arm's arm layer swinging. +/// Can't be done with engine AnimationPlayer as it can't animate individual layers. +/// +public sealed class RoboticArmAnimationSystem : EntitySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + + public override void FrameUpdate(float frameTime) + { + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var comp)) + { + if (comp.ItemSlot == null) + continue; + + if (comp.NextMove is {} nextMove) + Animate((uid, comp), nextMove); + else + Reset((uid, comp)); + } + } + + private void Animate(Entity ent, TimeSpan nextMove) + { + if (!TryComp(ent, out var sprite)) + return; + + var started = nextMove - ent.Comp.MoveDelay; + // 0-1 unless something weird happens + var progress = (_timing.CurTime - started) / ent.Comp.MoveDelay; + if (!ent.Comp.HasItem) // returning to the resting position when emptied + progress = 1f - progress; + var angle = Angle.FromDegrees(progress * 180f); + sprite.LayerSetRotation(RoboticArmLayers.Arm, angle); + } + + private void Reset(Entity ent) + { + if (!TryComp(ent, out var sprite)) + return; + + var angle = ent.Comp.HasItem ? new Angle(Math.PI) : Angle.Zero; + sprite.LayerSetRotation(RoboticArmLayers.Arm, angle); + } +} diff --git a/Content.Client/_Goobstation/Factory/UI/ConstructorBUI.cs b/Content.Client/_Goobstation/Factory/UI/ConstructorBUI.cs new file mode 100644 index 0000000000..ec96ff4e1a --- /dev/null +++ b/Content.Client/_Goobstation/Factory/UI/ConstructorBUI.cs @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: 2025 GoobBot +// SPDX-FileCopyrightText: 2025 deltanedas <39013340+deltanedas@users.noreply.github.com> +// SPDX-FileCopyrightText: 2025 deltanedas <@deltanedas:kde.org> +// SPDX-FileCopyrightText: 2025 gluesniffler <159397573+gluesniffler@users.noreply.github.com> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Client.Construction; +using Content.Client.Construction.UI; +using Content.Shared._Goobstation.Factory; +using Content.Shared.Construction.Prototypes; +using Content.Shared.Whitelist; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Prototypes; +using System.Linq; + +namespace Content.Client._Goobstation.Factory.UI; + +public sealed class ConstructorBUI : BoundUserInterface +{ + [Dependency] private readonly IPrototypeManager _proto = default!; + private readonly ConstructionSystem _construction; + private readonly EntityWhitelistSystem _whitelist; + private readonly SpriteSystem _sprite; + + private ConstructionMenu? _menu; + private string? _id; + private List _recipes = new(); + private readonly LocId _favoriteCatName = "construction-category-favorites"; + private readonly LocId _forAllCategoryName = "construction-category-all"; + + public ConstructorBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + _construction = EntMan.System(); + _whitelist = EntMan.System(); + _sprite = EntMan.System(); + + _id = EntMan.GetComponentOrNull(owner)?.Construction; + } + + protected override void Open() + { + base.Open(); + + // god BLESS whoever made construction ui for having it so decoupled <3 + _menu = this.CreateWindow(); + PopulateCategories(); + PopulateRecipes(string.Empty, string.Empty); + _menu.PopulateRecipes += (_, args) => PopulateRecipes(args.Item1, args.Item2); + _menu.RecipeSelected += (_, item) => + { + _menu.ClearRecipeInfo(); + if (item != null && item.Prototype != null) + { + _id = item.Prototype.ID; + _menu.SetRecipeInfo(item.Prototype.Name ?? "", item.Prototype.Description ?? "", item?.TargetPrototype, + item!.Prototype.Type != ConstructionType.Item, true); // TODO: favourites + + GenerateStepList(item.Prototype); + } + else + { + _id = null; + } + }; + _menu.BuildButtonToggled += (_, _) => + { + SendPredictedMessage(new ConstructorSetProtoMessage(_id)); + _menu.Close(); + }; + } + + private void PopulateCategories(string? selected = null) + { + if (_menu is not {} menu) + return; + + var categories = new HashSet(); + + foreach (var prototype in _proto.EnumeratePrototypes()) + { + var category = prototype.Category; + + if (!string.IsNullOrEmpty(category)) + categories.Add(category); + } + + var categoriesArray = new string[categories.Count + 1]; + + // hard-coded to show all recipes + var idx = 0; + categoriesArray[idx++] = _forAllCategoryName; + + foreach (var cat in categories.OrderBy(Loc.GetString)) + { + categoriesArray[idx++] = cat; + } + + menu.OptionCategories.Clear(); + + for (var i = 0; i < categoriesArray.Length; i++) + { + menu.OptionCategories.AddItem(Loc.GetString(categoriesArray[i]), i); + + if (!string.IsNullOrEmpty(selected) && selected == categoriesArray[i]) + menu.OptionCategories.SelectId(i); + } + + menu.Categories = categoriesArray; + } + + // copypasted and optimised from ConstructionMenuPresenter + private void PopulateRecipes(string search, string category) + { + if (PlayerManager.LocalEntity is not { } user + || _menu is not { } menu) + return; + + search = search.Trim().ToLowerInvariant(); + var searching = !string.IsNullOrEmpty(search); + var isEmptyCategory = string.IsNullOrEmpty(category) || category == _forAllCategoryName; + + _recipes.Clear(); + foreach (var recipe in _proto.EnumeratePrototypes()) + { + if (recipe.Hide) + continue; + + if (_whitelist.IsWhitelistFail(recipe.EntityWhitelist, user)) + continue; + + if (searching + && recipe.Name != null + && !recipe.Name.ToLowerInvariant().Contains(search)) + continue; + + if (!isEmptyCategory) + { + // TODO: when favourites get sent from server do this + // currently its specific to the G menu + //if (!_favoritedRecipes.Contains(recipe)) + if (category == _favoriteCatName) + continue; + else if (recipe.Category != category) + continue; + } + + if (!_construction!.TryGetRecipePrototype(recipe.ID, out var targetProtoId)) + continue; + + if (!_proto.TryIndex(targetProtoId, out EntityPrototype? proto)) + continue; + + _recipes.Add(new(recipe, proto)); + } + + _recipes.Sort((a, b) => string.Compare(a.Prototype.Name, b.Prototype.Name, StringComparison.InvariantCulture)); + + var recipesList = menu.Recipes; + recipesList.PopulateList(_recipes); + + menu.RecipesGridScrollContainer.Visible = false; + menu.Recipes.Visible = true; + } + + private void GenerateStepList(ConstructionPrototype proto) + { + if (_construction.GetGuide(proto) is not { } guide + || _menu is not { } menu) + return; + + var list = menu.RecipeStepList; + foreach (var entry in guide.Entries) + { + var text = entry.Arguments != null + ? Loc.GetString(entry.Localization, entry.Arguments) + : Loc.GetString(entry.Localization); + + if (entry.EntryNumber is { } number) + text = Loc.GetString("construction-presenter-step-wrapper", + ("step-number", number), ("text", text)); + + // The padding needs to be applied regardless of text length... (See PadLeft documentation) + text = text.PadLeft(text.Length + entry.Padding); + + var icon = entry.Icon != null ? _sprite.Frame0(entry.Icon) : Texture.Transparent; + list.AddItem(text, icon, false); + } + } +} diff --git a/Content.Client/_Goobstation/Factory/UI/LabelFilterBUI.cs b/Content.Client/_Goobstation/Factory/UI/LabelFilterBUI.cs new file mode 100644 index 0000000000..3575a71cfe --- /dev/null +++ b/Content.Client/_Goobstation/Factory/UI/LabelFilterBUI.cs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2025 GoobBot +// SPDX-FileCopyrightText: 2025 deltanedas <@deltanedas:kde.org> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Shared._Goobstation.Factory.Filters; +using Robust.Client.UserInterface; + +namespace Content.Client._Goobstation.Factory.UI; + +public sealed class LabelFilterBUI : BoundUserInterface +{ + private LabelFilterWindow? _window; + + public LabelFilterBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + } + + protected override void Open() + { + base.Open(); + + _window = this.CreateWindow(); + _window.SetEntity(Owner); + _window.OnSetLabel += label => SendPredictedMessage(new LabelFilterSetLabelMessage(label)); + } +} diff --git a/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml b/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml new file mode 100644 index 0000000000..fcd5c77795 --- /dev/null +++ b/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml @@ -0,0 +1,6 @@ + + + diff --git a/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml.cs b/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml.cs new file mode 100644 index 0000000000..9f590e6b3a --- /dev/null +++ b/Content.Client/_Goobstation/Factory/UI/LabelFilterWindow.xaml.cs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2025 GoobBot +// SPDX-FileCopyrightText: 2025 deltanedas <@deltanedas:kde.org> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Client.UserInterface.Controls; +using Content.Shared._Goobstation.Factory.Filters; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client._Goobstation.Factory.UI; + +[GenerateTypedNameReferences] +public sealed partial class LabelFilterWindow : FancyWindow +{ + [Dependency] private readonly EntityManager _entMan = default!; + + public event Action? OnSetLabel; + + public LabelFilterWindow() + { + IoCManager.InjectDependencies(this); + RobustXamlLoader.Load(this); + + LabelEdit.OnTextChanged += _ => OnSetLabel?.Invoke(LabelEdit.Text); + } + + public void SetEntity(EntityUid uid) + { + if (!_entMan.TryGetComponent(uid, out var comp)) + return; + + var max = comp.MaxLength; + LabelEdit.IsValid = label => label.Length < max; + LabelEdit.Text = comp.Label; + } +} diff --git a/Content.Client/_Goobstation/Factory/UI/NameFilterBUI.cs b/Content.Client/_Goobstation/Factory/UI/NameFilterBUI.cs new file mode 100644 index 0000000000..5e902beebb --- /dev/null +++ b/Content.Client/_Goobstation/Factory/UI/NameFilterBUI.cs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2025 GoobBot +// SPDX-FileCopyrightText: 2025 deltanedas <@deltanedas:kde.org> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Shared._Goobstation.Factory.Filters; +using Robust.Client.UserInterface; + +namespace Content.Client._Goobstation.Factory.UI; + +public sealed class NameFilterBUI : BoundUserInterface +{ + private NameFilterWindow? _window; + + public NameFilterBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + } + + protected override void Open() + { + base.Open(); + + _window = this.CreateWindow(); + _window.SetEntity(Owner); + _window.OnSetName += name => SendPredictedMessage(new NameFilterSetNameMessage(name)); + _window.OnSetMode += mode => SendPredictedMessage(new NameFilterSetModeMessage(mode)); + } +} diff --git a/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml b/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml new file mode 100644 index 0000000000..8b17039fc4 --- /dev/null +++ b/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml.cs b/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml.cs new file mode 100644 index 0000000000..b0efa656a4 --- /dev/null +++ b/Content.Client/_Goobstation/Factory/UI/NameFilterWindow.xaml.cs @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2025 GoobBot +// SPDX-FileCopyrightText: 2025 deltanedas <@deltanedas:kde.org> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Client.UserInterface.Controls; +using Content.Shared._Goobstation.Factory.Filters; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client._Goobstation.Factory.UI; + +[GenerateTypedNameReferences] +public sealed partial class NameFilterWindow : FancyWindow +{ + [Dependency] private readonly EntityManager _entMan = default!; + + public event Action? OnSetName; + public event Action? OnSetMode; + + public NameFilterWindow() + { + IoCManager.InjectDependencies(this); + RobustXamlLoader.Load(this); + + foreach (var mode in Enum.GetValues()) + { + ModeButton.AddItem(Loc.GetString($"name-filter-mode-{mode}"), (int) mode); + } + + ModeButton.OnItemSelected += args => + { + ModeButton.SelectId(args.Id); + OnSetMode?.Invoke((NameFilterMode) args.Id); + }; + + NameEdit.OnTextChanged += _ => OnSetName?.Invoke(NameEdit.Text); + } + + public void SetEntity(EntityUid uid) + { + if (!_entMan.TryGetComponent(uid, out var comp)) + return; + + ModeButton.SelectId((int) comp.Mode); + var max = comp.MaxLength; + NameEdit.IsValid = name => name.Length < max; + NameEdit.Text = comp.Name; + } +} diff --git a/Content.Client/_Goobstation/Factory/UI/PressureFilterBUI.cs b/Content.Client/_Goobstation/Factory/UI/PressureFilterBUI.cs new file mode 100644 index 0000000000..7d8e172ba8 --- /dev/null +++ b/Content.Client/_Goobstation/Factory/UI/PressureFilterBUI.cs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2025 deltanedas <@deltanedas:kde.org> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Shared._Goobstation.Factory.Filters; +using Robust.Client.UserInterface; + +namespace Content.Client._Goobstation.Factory.UI; + +public sealed class PressureFilterBUI : BoundUserInterface +{ + private PressureFilterWindow? _window; + + public PressureFilterBUI(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + } + + protected override void Open() + { + base.Open(); + + _window = this.CreateWindow(); + _window.SetEntity(Owner); + _window.OnSetMin += min => SendPredictedMessage(new PressureFilterSetMinMessage(min)); + _window.OnSetMax += max => SendPredictedMessage(new PressureFilterSetMaxMessage(max)); + } +} diff --git a/Content.Client/_Goobstation/Factory/UI/PressureFilterWindow.xaml b/Content.Client/_Goobstation/Factory/UI/PressureFilterWindow.xaml new file mode 100644 index 0000000000..206508a937 --- /dev/null +++ b/Content.Client/_Goobstation/Factory/UI/PressureFilterWindow.xaml @@ -0,0 +1,19 @@ + + + +