diff --git a/.gitignore b/.gitignore index 2dd5912047c..78866a96f64 100644 --- a/.gitignore +++ b/.gitignore @@ -306,3 +306,4 @@ Resources/MapImages # Direnv stuff .direnv/ +.idea/ \ No newline at end of file diff --git a/Content.Client/Vampire/VampireSystem.cs b/Content.Client/Vampire/VampireSystem.cs index 863fa9f3435..933b4783fd7 100644 --- a/Content.Client/Vampire/VampireSystem.cs +++ b/Content.Client/Vampire/VampireSystem.cs @@ -1,25 +1,42 @@ +using System.Linq; +using Content.Client.Alerts; +using Content.Client.UserInterface.Systems.Alerts.Controls; +using Content.Shared.StatusIcon; +using Content.Shared.StatusIcon.Components; using Content.Shared.Vampire; using Content.Shared.Vampire.Components; -using Content.Shared.StatusIcon.Components; +using Robust.Client.GameObjects; using Robust.Shared.Prototypes; namespace Content.Client.Vampire; -public sealed partial class VampireSystem : EntitySystem +public sealed class VampireSystem : EntitySystem { - [Dependency] private readonly IPrototypeManager _prototype = default!; - + public override void Initialize() { base.Initialize(); - SubscribeLocalEvent(GetVampireIcon); + SubscribeLocalEvent(GetVampireIcon); + SubscribeLocalEvent(OnUpdateAlert); } - private void GetVampireIcon(Entity ent, ref GetStatusIconsEvent args) - { - var iconPrototype = _prototype.Index(ent.Comp.StatusIcon); + private void GetVampireIcon(EntityUid uid, VampireIconComponent component, ref GetStatusIconsEvent args) + { + var iconPrototype = _prototype.Index(component.StatusIcon); args.StatusIcons.Add(iconPrototype); } + + private void OnUpdateAlert(EntityUid uid, VampireAlertComponent component, ref UpdateAlertSpriteEvent args) + { + if (args.Alert.ID != component.BloodAlert) + return; + + var sprite = args.SpriteViewEnt.Comp; + var blood = Math.Clamp(component.BloodAmount, 0, 999); + sprite.LayerSetState(VampireVisualLayers.Digit1, $"{(blood / 100) % 10}"); + sprite.LayerSetState(VampireVisualLayers.Digit2, $"{(blood / 10) % 10}"); + sprite.LayerSetState(VampireVisualLayers.Digit3, $"{blood % 10}"); + } } \ No newline at end of file diff --git a/Content.Client/_Sunrise/ChoiceControl.xaml b/Content.Client/_Sunrise/ChoiceControl.xaml new file mode 100644 index 00000000000..a444070d107 --- /dev/null +++ b/Content.Client/_Sunrise/ChoiceControl.xaml @@ -0,0 +1,17 @@ + + + + + diff --git a/Content.Client/_Sunrise/ChoiceControl.xaml.cs b/Content.Client/_Sunrise/ChoiceControl.xaml.cs new file mode 100644 index 00000000000..841c9d6a2a0 --- /dev/null +++ b/Content.Client/_Sunrise/ChoiceControl.xaml.cs @@ -0,0 +1,27 @@ +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Utility; +// Taken from RMC14 build. +// https://github.com/RMC-14/RMC-14 +namespace Content.Client._Sunrise; + +[GenerateTypedNameReferences] +[Virtual] +public partial class ChoiceControl : Control +{ + public ChoiceControl() => RobustXamlLoader.Load(this); + + public void Set(string name, Texture? texture) + { + NameLabel.SetMessage(name); + Texture.Texture = texture; + } + + public void Set(FormattedMessage msg, Texture? texture) + { + NameLabel.SetMessage(msg); + Texture.Texture = texture; + } +} diff --git a/Content.Client/_Sunrise/Footprints/FootPrintsVisualizerSystem.cs b/Content.Client/_Sunrise/Footprints/FootPrintsVisualizerSystem.cs new file mode 100644 index 00000000000..d17617e6ba1 --- /dev/null +++ b/Content.Client/_Sunrise/Footprints/FootPrintsVisualizerSystem.cs @@ -0,0 +1,148 @@ +using Content.Shared._Sunrise.Footprints; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Shared.Random; + +namespace Content.Client._Sunrise.Footprints; + +/// +/// Handles the visual appearance and updates of footprint entities on the client +/// +public sealed class FootprintVisualizerSystem : VisualizerSystem +{ + [Dependency] private readonly SharedAppearanceSystem _appearanceSystem = default!; + [Dependency] private readonly IRobustRandom _random = default!; + + /// + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnFootprintInitialized); + SubscribeLocalEvent(OnFootprintShutdown); + } + + /// + /// Initializes the visual appearance of a new footprint + /// + private void OnFootprintInitialized(EntityUid uid, FootprintComponent component, ComponentInit args) + { + if (!TryComp(uid, out var sprite)) + return; + + InitializeSpriteLayers(sprite); + UpdateFootprintVisuals(uid, component, sprite); + } + + /// + /// Cleans up the visual elements when a footprint is removed + /// + private void OnFootprintShutdown(EntityUid uid, FootprintComponent component, ComponentShutdown args) + { + if (!TryComp(uid, out var sprite)) + return; + + RemoveSpriteLayers(sprite); + } + + /// + /// Sets up the initial sprite layers for the footprint + /// + private void InitializeSpriteLayers(SpriteComponent sprite) + { + sprite.LayerMapReserveBlank(FootprintSpriteLayer.MainLayer); + } + + /// + /// Removes sprite layers when cleaning up footprint + /// + private void RemoveSpriteLayers(SpriteComponent sprite) + { + if (sprite.LayerMapTryGet(FootprintSpriteLayer.MainLayer, out var layer)) + { + sprite.RemoveLayer(layer); + } + } + + /// + /// Updates the visual appearance of a footprint based on its current state + /// + private void UpdateFootprintVisuals(EntityUid uid, FootprintComponent footprint, SpriteComponent sprite) + { + if (!sprite.LayerMapTryGet(FootprintSpriteLayer.MainLayer, out var layer) + || !TryComp(footprint.CreatorEntity, out var emitterComponent) + || !TryComp(uid, out var appearance)) + return; + + if (!_appearanceSystem.TryGetData( + uid, + FootprintVisualParameter.VisualState, + out var visualType, + appearance)) + return; + + UpdateSpriteState(sprite, layer, visualType, emitterComponent); + UpdateSpriteColor(sprite, layer, uid, appearance); + } + + /// + /// Updates the sprite state based on the footprint type + /// + private void UpdateSpriteState( + SpriteComponent sprite, + int layer, + FootprintVisualType visualType, + FootprintEmitterComponent emitter) + { + var stateId = new RSI.StateId(GetStateId(visualType, emitter)); + sprite.LayerSetState(layer, stateId, emitter.SpritePath); + } + + /// + /// Determines the appropriate state ID for the footprint based on its type + /// + private string GetStateId(FootprintVisualType visualType, FootprintEmitterComponent emitter) + { + return visualType switch + { + FootprintVisualType.BareFootprint => emitter.IsRightStep + ? emitter.RightBareFootState + : emitter.LeftBareFootState, + FootprintVisualType.ShoeFootprint => emitter.ShoeFootState, + FootprintVisualType.SuitFootprint => emitter.PressureSuitFootState, + FootprintVisualType.DragMark => _random.Pick(emitter.DraggingStates), + _ => throw new ArgumentOutOfRangeException( + $"Unknown footprint visual type: {visualType}") + }; + } + + /// + /// Updates the sprite color based on appearance data + /// + private void UpdateSpriteColor( + SpriteComponent sprite, + int layer, + EntityUid uid, + AppearanceComponent appearance) + { + if (_appearanceSystem.TryGetData(uid, + FootprintVisualParameter.TrackColor, + out var color, + appearance)) + { + sprite.LayerSetColor(layer, color); + } + } + + /// + protected override void OnAppearanceChange( + EntityUid uid, + FootprintComponent component, + ref AppearanceChangeEvent args) + { + if (args.Sprite is not { } sprite) + return; + + UpdateFootprintVisuals(uid, component, sprite); + } +} diff --git a/Content.Client/_Sunrise/Medical/Surgery/SurgeryBui.cs b/Content.Client/_Sunrise/Medical/Surgery/SurgeryBui.cs new file mode 100644 index 00000000000..df19251fbe1 --- /dev/null +++ b/Content.Client/_Sunrise/Medical/Surgery/SurgeryBui.cs @@ -0,0 +1,443 @@ +using Content.Client.Administration.UI.CustomControls; +using Content.Client.Hands.Systems; +using Content.Shared._Sunrise.Medical.Surgery; +using Content.Shared.Body.Part; +using JetBrains.Annotations; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Client.Player; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; +using static Robust.Client.UserInterface.Control; + +namespace Content.Client._Sunrise.Medical.Surgery; +// Based on the RMC14 build. +// https://github.com/RMC-14/RMC-14 + +[UsedImplicitly] +public sealed class SurgeryBui : BoundUserInterface +{ + [Dependency] private readonly IEntityManager _entities = default!; + [Dependency] private readonly IPlayerManager _player = default!; + + private readonly SurgerySystem _system; + private readonly HandsSystem _hands; + + [ViewVariables] + private SurgeryWindow? _window; + + private EntityUid? _part; + private (EntityUid Ent, EntProtoId Proto)? _surgery; + private readonly List _previousSurgeries = new(); + + public SurgeryBui(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + _system = _entities.System(); + _hands = _entities.System(); + + _system.OnRefresh += UpdateDisabledPanel; + _hands.OnPlayerItemAdded += OnPlayerItemAdded; + } + private DateTime _lastRefresh = DateTime.UtcNow; + private (string k1, EntityUid k2) _throttling = ("", new EntityUid()); + private void OnPlayerItemAdded(string k1, EntityUid k2) + { + if (_throttling.k1.Equals(k1) && _throttling.k2.Equals(k2) && DateTime.UtcNow - _lastRefresh < TimeSpan.FromSeconds(1)) return; + _throttling = (k1, k2); + _lastRefresh = DateTime.UtcNow; + RefreshUI(); + } + protected override void Open() => UpdateState(State); + protected override void UpdateState(BoundUserInterfaceState? state) + { + if (state is SurgeryBuiState s) + Update(s); + } + + private void Update(SurgeryBuiState state) + { + TryInitWindow(); + + _window!.Surgeries.DisposeAllChildren(); + _window.Steps.DisposeAllChildren(); + _window.Parts.DisposeAllChildren(); + + View(ViewType.Parts); + + var oldSurgery = _surgery; + var oldPart = _part; + _part = null; + _surgery = null; + + var parts = new List>(state.Choices.Keys.Count); + foreach (var choice in state.Choices.Keys) + { + if (_entities.TryGetEntity(choice, out var ent) && + _entities.TryGetComponent(ent, out BodyPartComponent? part)) + { + parts.Add((ent.Value, part)); + } + } + + parts.Sort((a, b) => + { + static int GetScore(Entity part) + => part.Comp.PartType switch + { + BodyPartType.Head => 1, + BodyPartType.Torso => 2, + BodyPartType.Arm => 3, + BodyPartType.Hand => 4, + BodyPartType.Leg => 5, + BodyPartType.Foot => 6, + BodyPartType.Tail => 7, + BodyPartType.Other => 8, + _ => 0 + }; + + return GetScore(a) - GetScore(b); + }); + + foreach (var part in parts) + { + var netPart = _entities.GetNetEntity(part.Owner); + var surgeries = state.Choices[netPart]; + var partName = _entities.GetComponent(part).EntityName; + var partButton = new ChoiceControl(); + + partButton.Set(partName, null); + partButton.Button.OnPressed += _ => OnPartPressed(netPart, surgeries); + + _window.Parts.AddChild(partButton); + + foreach (var (surgeryId, suffix, isCompleted) in surgeries) + { + if (_system.GetSingleton(surgeryId) is not { } surgery || + !_entities.TryGetComponent(surgery, out SurgeryComponent? surgeryComp)) + { + continue; + } + + if (oldPart == part && oldSurgery?.Proto == surgeryId) + OnSurgeryPressed((surgery, surgeryComp), netPart, surgeryId); + } + + if (oldPart == part && oldSurgery == null) + OnPartPressed(netPart, surgeries); + } + + RefreshUI(); + + if (!_window.IsOpen) + _window.OpenCentered(); + } + + private void TryInitWindow() + { + if (_window != null) return; + _window = new SurgeryWindow(); + _window.OnClose += Close; + _window.Title = Loc.GetString("surgery-window-name"); + + _window.PartsButton.OnPressed += _ => + { + _part = null; + _surgery = null; + _previousSurgeries.Clear(); + View(ViewType.Parts); + }; + + _window.SurgeriesButton.OnPressed += _ => + { + _surgery = null; + _previousSurgeries.Clear(); + + if (!_entities.TryGetNetEntity(_part, out var netPart) || + State is not SurgeryBuiState s || + !s.Choices.TryGetValue(netPart.Value, out var surgeries)) + { + return; + } + + OnPartPressed(netPart.Value, surgeries); + }; + + _window.StepsButton.OnPressed += _ => + { + if (!_entities.TryGetNetEntity(_part, out var netPart) || + _previousSurgeries.Count == 0) + { + return; + } + + var last = _previousSurgeries[^1]; + _previousSurgeries.RemoveAt(_previousSurgeries.Count - 1); + + if (_system.GetSingleton(last) is not { } previousId || + !_entities.TryGetComponent(previousId, out SurgeryComponent? previous)) + { + return; + } + + OnSurgeryPressed((previousId, previous), netPart.Value, last); + }; + } + + private void AddStep(EntProtoId stepId, NetEntity netPart, EntProtoId surgeryId) + { + if (_window == null || + _system.GetSingleton(stepId) is not { } step) + { + return; + } + + var stepName = new FormattedMessage(); + stepName.AddText(_entities.GetComponent(step).EntityName); + + var stepButton = new SurgeryStepButton { Step = step }; + stepButton.Button.OnPressed += _ => SendMessage(new SurgeryStepChosenBuiMsg() + { + Step = stepId, + Part = netPart, + Surgery = surgeryId, + }); + + _window.Steps.AddChild(stepButton); + } + + private void OnSurgeryPressed(Entity surgery, NetEntity netPart, EntProtoId surgeryId) + { + if (_window == null) + return; + + _part = _entities.GetEntity(netPart); + _surgery = (surgery, surgeryId); + + _window.Steps.DisposeAllChildren(); + + if (surgery.Comp.Requirement is { } requirementId && _system.GetSingleton(requirementId) is { } requirement) + { + var label = new ChoiceControl(); + label.Button.OnPressed += _ => + { + _previousSurgeries.Add(surgeryId); + + if (_entities.TryGetComponent(requirement, out SurgeryComponent? requirementComp)) + OnSurgeryPressed((requirement, requirementComp), netPart, requirementId); + }; + + var msg = new FormattedMessage(); + var surgeryName = _entities.GetComponent(requirement).EntityName; + msg.AddMarkupOrThrow(Loc.GetString("surgery-window-reguires", ("surgeryname", surgeryName))); + label.Set(msg, null); + + _window.Steps.AddChild(label); + _window.Steps.AddChild(new HSeparator(Color.FromHex("#4972A1")) { Margin = new Thickness(0, 0, 0, 1) }); + } + + foreach (var stepId in surgery.Comp.Steps) + { + AddStep(stepId, netPart, surgeryId); + } + + View(ViewType.Steps); + RefreshUI(); + } + + private void OnPartPressed(NetEntity netPart, List<(EntProtoId, string, bool)> surgeryIds) + { + if (_window == null) + return; + + _part = _entities.GetEntity(netPart); + + _window.Surgeries.DisposeAllChildren(); + + var surgeries = new List<(Entity Ent, EntProtoId Id, string Name, bool IsCompleted, Texture?)>(); + foreach (var (surgeryId, suffix, isCompleted) in surgeryIds) + { + if (_system.GetSingleton(surgeryId) is not { } surgery || + !_entities.TryGetComponent(surgery, out SurgeryComponent? surgeryComp)) + { + continue; + } + + var texture = _entities.GetComponentOrNull(surgery)?.Icon?.Default; + var name = $"{_entities.GetComponent(surgery).EntityName} {suffix}"; + surgeries.Add(((surgery, surgeryComp), surgeryId, name, isCompleted, texture)); + } + + surgeries.Sort((a, b) => + { + var priority = a.Ent.Comp.Priority.CompareTo(b.Ent.Comp.Priority); + if (priority != 0) + return priority; + + return string.Compare(a.Name, b.Name, StringComparison.Ordinal); + }); + + foreach (var (Ent, Id, Name, IsCompleted, texture) in surgeries) + { + var surgeryButton = new ChoiceControl(); + + surgeryButton.Set(Name, texture); + if(IsCompleted) + surgeryButton.Button.Modulate = Color.Green; + surgeryButton.Button.OnPressed += _ => OnSurgeryPressed(Ent, netPart, Id); + _window.Surgeries.AddChild(surgeryButton); + } + + RefreshUI(); + View(ViewType.Surgeries); + } + + private void RefreshUI() + { + if (_window == null || + !_entities.HasComponent(_surgery?.Ent) || + !_entities.TryGetComponent(_part, out BodyPartComponent? part)) + { + return; + } + + var next = _system.GetNextStep(Owner, _part.Value, _surgery.Value.Ent); + var i = 0; + foreach (var child in _window.Steps.Children) + { + if (child is not SurgeryStepButton stepButton) + continue; + + var status = StepStatus.Incomplete; + if (next == null) + { + status = StepStatus.Complete; + } + else if (next.Value.Surgery.Owner != _surgery.Value.Ent) + { + status = StepStatus.Incomplete; + } + else if (next.Value.Step == i) + { + status = StepStatus.Next; + } + else if (i < next.Value.Step) + { + status = StepStatus.Complete; + } + + stepButton.Button.Disabled = status != StepStatus.Next; + + var stepName = new FormattedMessage(); + stepName.AddText(_entities.GetComponent(stepButton.Step).EntityName); + + if (status == StepStatus.Complete) + { + stepButton.Button.Modulate = Color.Green; + } + else if (status == StepStatus.Next) + { + stepButton.Button.Modulate = Color.White; + if (_player.LocalEntity is { } player && + !_system.CanPerformStep(player, Owner, part.PartType, stepButton.Step, false, out var popup, out var reason, out _)) + { + stepButton.ToolTip = popup; + stepButton.Button.Disabled = true; + + switch (reason) + { + case StepInvalidReason.NeedsOperatingTable: + stepName.AddMarkupOrThrow(Loc.GetString("surgery-window-reguires-table")); + break; + case StepInvalidReason.Armor: + stepName.AddMarkupOrThrow(Loc.GetString("surgery-window-reguires-undress")); + break; + case StepInvalidReason.MissingTool: + stepName.AddMarkupOrThrow(Loc.GetString("surgery-window-reguires-tool")); + break; + } + } + } + + var texture = _entities.GetComponentOrNull(stepButton.Step)?.Icon?.Default; + stepButton.Set(stepName, texture); + i++; + } + } + + private void UpdateDisabledPanel() + { + if (_window == null) + return; + + if (_system.IsLyingDown(Owner)) + { + _window.DisabledPanel.Visible = false; + _window.DisabledPanel.MouseFilter = MouseFilterMode.Ignore; + return; + } + + _window.DisabledPanel.Visible = true; + if (_window.DisabledLabel.GetMessage() is null) + { + var text = new FormattedMessage(); + text.AddMarkupOrThrow(Loc.GetString("surgery-window-reguires-laydown")); + _window.DisabledLabel.SetMessage(text); + } + + _window.DisabledPanel.MouseFilter = MouseFilterMode.Stop; + } + + private void View(ViewType type) + { + if (_window == null) + return; + + _window.PartsButton.Parent!.Margin = new Thickness(0, 0, 0, 10); + + _window.Parts.Visible = type == ViewType.Parts; + _window.PartsButton.Disabled = type == ViewType.Parts; + + _window.Surgeries.Visible = type == ViewType.Surgeries; + _window.SurgeriesButton.Disabled = type != ViewType.Steps; + + _window.Steps.Visible = type == ViewType.Steps; + _window.StepsButton.Disabled = type != ViewType.Steps || _previousSurgeries.Count == 0; + + if (_entities.TryGetComponent(_part, out MetaDataComponent? partMeta) && + _entities.TryGetComponent(_surgery?.Ent, out MetaDataComponent? surgeryMeta)) + { + _window.Title = $"{Loc.GetString("surgery-window-name")} - {partMeta.EntityName}, {surgeryMeta.EntityName}"; + } + else if (partMeta != null) + { + _window.Title = $"{Loc.GetString("surgery-window-name")} - {partMeta.EntityName}"; + } + else + { + _window.Title = Loc.GetString("surgery-window-name"); + } + } + + private enum ViewType + { + Parts, + Surgeries, + Steps + } + + private enum StepStatus + { + Next, + Complete, + Incomplete + } + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + _window?.Dispose(); + _system.OnRefresh -= UpdateDisabledPanel; + _hands.OnPlayerItemAdded -= OnPlayerItemAdded; + } +} diff --git a/Content.Client/_Sunrise/Medical/Surgery/SurgeryStepButton.xaml b/Content.Client/_Sunrise/Medical/Surgery/SurgeryStepButton.xaml new file mode 100644 index 00000000000..6b08f040e6e --- /dev/null +++ b/Content.Client/_Sunrise/Medical/Surgery/SurgeryStepButton.xaml @@ -0,0 +1,4 @@ + + diff --git a/Content.Client/_Sunrise/Medical/Surgery/SurgeryStepButton.xaml.cs b/Content.Client/_Sunrise/Medical/Surgery/SurgeryStepButton.xaml.cs new file mode 100644 index 00000000000..90f3e3fdf45 --- /dev/null +++ b/Content.Client/_Sunrise/Medical/Surgery/SurgeryStepButton.xaml.cs @@ -0,0 +1,13 @@ +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client._Sunrise.Medical.Surgery; +// Based on the RMC14. +// https://github.com/RMC-14/RMC-14 +[GenerateTypedNameReferences] +public sealed partial class SurgeryStepButton : ChoiceControl +{ + public EntityUid Step { get; set; } + + public SurgeryStepButton() => RobustXamlLoader.Load(this); +} diff --git a/Content.Client/_Sunrise/Medical/Surgery/SurgerySystem.cs b/Content.Client/_Sunrise/Medical/Surgery/SurgerySystem.cs new file mode 100644 index 00000000000..7668fed97c3 --- /dev/null +++ b/Content.Client/_Sunrise/Medical/Surgery/SurgerySystem.cs @@ -0,0 +1,17 @@ +using Content.Shared._Sunrise.Medical.Surgery; + +namespace Content.Client._Sunrise.Medical.Surgery; +// Based on the RMC14. +// https://github.com/RMC-14/RMC-14 +public sealed class SurgerySystem : SharedSurgerySystem +{ + public event Action? OnRefresh; + public override void Update(float frameTime) + { + _delayAccumulator += frameTime; + if (_delayAccumulator > 1) { + _delayAccumulator = 0; + OnRefresh?.Invoke(); + } + } +} diff --git a/Content.Client/_Sunrise/Medical/Surgery/SurgeryWindow.xaml b/Content.Client/_Sunrise/Medical/Surgery/SurgeryWindow.xaml new file mode 100644 index 00000000000..42b770950ac --- /dev/null +++ b/Content.Client/_Sunrise/Medical/Surgery/SurgeryWindow.xaml @@ -0,0 +1,30 @@ + + + +