diff --git a/Content.Client/GameObjects/Components/Power/SolarControlConsoleBoundUserInterface.cs b/Content.Client/GameObjects/Components/Power/SolarControlConsoleBoundUserInterface.cs new file mode 100644 index 0000000000..1149d9a2b5 --- /dev/null +++ b/Content.Client/GameObjects/Components/Power/SolarControlConsoleBoundUserInterface.cs @@ -0,0 +1,231 @@ +using System; +using Content.Client.UserInterface; +using Content.Client.UserInterface.Stylesheets; +using Content.Shared.GameObjects.Components.Power; +using Robust.Client.GameObjects.Components.UserInterface; +using Robust.Client.Graphics.Drawing; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.CustomControls; +using Robust.Shared.GameObjects.Components.UserInterface; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Maths; + +namespace Content.Client.GameObjects.Components.Power +{ + public class SolarControlConsoleBoundUserInterface : BoundUserInterface + { + [Dependency] + private IGameTiming _gameTiming; + + private SolarControlWindow _window; + private SolarControlConsoleBoundInterfaceState _lastState = new SolarControlConsoleBoundInterfaceState(0, 0, 0, 0); + + protected override void Open() + { + base.Open(); + + _window = new SolarControlWindow(_gameTiming); + _window.OnClose += Close; + _window.PanelRotation.OnTextEntered += (text) => { + double value; + if (double.TryParse(text.Text, out value)) + { + SolarControlConsoleAdjustMessage msg = new SolarControlConsoleAdjustMessage(); + msg.Rotation = Angle.FromDegrees(value); + msg.AngularVelocity = _lastState.AngularVelocity; + SendMessage(msg); + } + }; + _window.PanelVelocity.OnTextEntered += (text) => { + double value; + if (double.TryParse(text.Text, out value)) + { + SolarControlConsoleAdjustMessage msg = new SolarControlConsoleAdjustMessage(); + msg.Rotation = _lastState.Rotation; + msg.AngularVelocity = Angle.FromDegrees(value / 60); + SendMessage(msg); + } + }; + _window.OpenCenteredMinSize(); + } + + public SolarControlConsoleBoundUserInterface(ClientUserInterfaceComponent owner, object uiKey) : base(owner, uiKey) + { + } + + private string FormatAngle(Angle d) + { + return d.Degrees.ToString("F1"); + } + + // The idea behind this is to prevent every update from the server + // breaking the textfield. + private void UpdateField(LineEdit field, string newValue) + { + if (!field.HasKeyboardFocus()) + { + field.Text = newValue; + } + } + + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + + SolarControlConsoleBoundInterfaceState scc = (SolarControlConsoleBoundInterfaceState) state; + _lastState = scc; + _window.NotARadar.UpdateState(scc); + _window.OutputPower.Text = ((int) Math.Floor(scc.OutputPower)).ToString(); + _window.SunAngle.Text = FormatAngle(scc.TowardsSun); + UpdateField(_window.PanelRotation, FormatAngle(scc.Rotation)); + UpdateField(_window.PanelVelocity, FormatAngle(scc.AngularVelocity * 60)); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + _window.Dispose(); + } + } + + private sealed class SolarControlWindow : SS14Window + { + public Label OutputPower; + public Label SunAngle; + + public SolarControlNotARadar NotARadar; + + public LineEdit PanelRotation; + public LineEdit PanelVelocity; + + public SolarControlWindow(IGameTiming igt) + { + Title = "Solar Control Window"; + + var rows = new GridContainer(); + rows.Columns = 2; + + // little secret: the reason I put the values + // in the first column is because otherwise the UI + // layouter autoresizes the window to be too small + rows.AddChild(new Label {Text = "Output Power:"}); + rows.AddChild(new Label {Text = ""}); + + rows.AddChild(OutputPower = new Label {Text = "?"}); + rows.AddChild(new Label {Text = "W"}); + + rows.AddChild(new Label {Text = "Sun Angle:"}); + rows.AddChild(new Label {Text = ""}); + + rows.AddChild(SunAngle = new Label {Text = "?"}); + rows.AddChild(new Label {Text = "°"}); + + rows.AddChild(new Label {Text = "Panel Angle:"}); + rows.AddChild(new Label {Text = ""}); + + rows.AddChild(PanelRotation = new LineEdit()); + rows.AddChild(new Label {Text = "°"}); + + rows.AddChild(new Label {Text = "Panel Angular Velocity:"}); + rows.AddChild(new Label {Text = ""}); + + rows.AddChild(PanelVelocity = new LineEdit()); + rows.AddChild(new Label {Text = "°/min."}); + + rows.AddChild(new Label {Text = "Press Enter to confirm."}); + rows.AddChild(new Label {Text = ""}); + + PanelRotation.SizeFlagsHorizontal = SizeFlags.FillExpand; + PanelVelocity.SizeFlagsHorizontal = SizeFlags.FillExpand; + rows.SizeFlagsHorizontal = SizeFlags.Fill; + rows.SizeFlagsVertical = SizeFlags.Fill; + + NotARadar = new SolarControlNotARadar(igt); + + var outerColumns = new HBoxContainer(); + outerColumns.AddChild(rows); + outerColumns.AddChild(NotARadar); + outerColumns.SizeFlagsHorizontal = SizeFlags.Fill; + outerColumns.SizeFlagsVertical = SizeFlags.Fill; + Contents.AddChild(outerColumns); + Resizable = false; + } + } + + private sealed class SolarControlNotARadar : Control + { + // IoC doesn't apply here, so it's propagated from the parent class. + // This is used for client-side prediction of the panel rotation. + // This makes the display feel a lot smoother. + private IGameTiming _gameTiming; + + private SolarControlConsoleBoundInterfaceState _lastState = new SolarControlConsoleBoundInterfaceState(0, 0, 0, 0); + + private TimeSpan _lastStateTime = TimeSpan.Zero; + + public const int SizeFull = 290; + public const int RadiusCircle = 140; + + public SolarControlNotARadar(IGameTiming igt) + { + _gameTiming = igt; + } + + public void UpdateState(SolarControlConsoleBoundInterfaceState ls) + { + _lastState = ls; + _lastStateTime = _gameTiming.CurTime; + } + + protected override Vector2 CalculateMinimumSize() + { + return (SizeFull, SizeFull); + } + + protected override void Draw(DrawingHandleScreen handle) + { + int point = SizeFull / 2; + Color fakeAA = new Color(0.08f, 0.08f, 0.08f); + Color gridLines = new Color(0.08f, 0.08f, 0.08f); + int panelExtentCutback = 4; + int gridLinesRadial = 8; + int gridLinesEquatorial = 8; + + // Draw base + handle.DrawCircle((point, point), RadiusCircle + 1, fakeAA); + handle.DrawCircle((point, point), RadiusCircle, Color.Black); + + // Draw grid lines + for (int i = 0; i < gridLinesEquatorial; i++) + { + handle.DrawCircle((point, point), (RadiusCircle / gridLinesEquatorial) * i, gridLines, false); + } + + for (int i = 0; i < gridLinesRadial; i++) + { + Angle angle = (Math.PI / gridLinesRadial) * i; + Vector2 aExtent = angle.ToVec() * RadiusCircle; + handle.DrawLine((point, point) - aExtent, (point, point) + aExtent, gridLines); + } + + // The rotations need to be adjusted because Y is inverted in Robust (like BYOND) + Vector2 rotMul = (1, -1); + + Angle predictedPanelRotation = _lastState.Rotation + (_lastState.AngularVelocity * ((_gameTiming.CurTime - _lastStateTime).TotalSeconds)); + + Vector2 extent = predictedPanelRotation.ToVec() * rotMul * RadiusCircle; + Vector2 extentOrtho = (extent.Y, -extent.X); + handle.DrawLine((point, point) - extentOrtho, (point, point) + extentOrtho, Color.White); + handle.DrawLine((point, point) + (extent / panelExtentCutback), (point, point) + extent - (extent / panelExtentCutback), Color.DarkGray); + + Vector2 sunExtent = _lastState.TowardsSun.ToVec() * rotMul * RadiusCircle; + handle.DrawLine((point, point) + sunExtent, (point, point), Color.Yellow); + } + } + } +} diff --git a/Content.Server/GameObjects/Components/Power/SolarControlConsoleComponent.cs b/Content.Server/GameObjects/Components/Power/SolarControlConsoleComponent.cs new file mode 100644 index 0000000000..ab9e77003e --- /dev/null +++ b/Content.Server/GameObjects/Components/Power/SolarControlConsoleComponent.cs @@ -0,0 +1,76 @@ +using Content.Server.GameObjects.Components.Power; +using Content.Server.GameObjects.EntitySystems; +using Content.Server.Interfaces.GameTicking; +using Content.Shared.GameObjects.Components.Power; +using Robust.Server.GameObjects.Components.UserInterface; +using Robust.Server.Interfaces.GameObjects; +using Robust.Server.Interfaces.Player; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.IoC; + +namespace Content.Server.GameObjects.Components.Power +{ + [RegisterComponent] + [ComponentReference(typeof(IActivate))] + public class SolarControlConsoleComponent : SharedSolarControlConsoleComponent, IActivate + { +#pragma warning disable 649 + [Dependency] private IEntitySystemManager _entitySystemManager; +#pragma warning restore 649 + + private BoundUserInterface _userInterface; + private PowerDeviceComponent _powerDevice; + private PowerSolarSystem _powerSolarSystem; + private bool Powered => _powerDevice.Powered; + + public override void Initialize() + { + base.Initialize(); + + _userInterface = Owner.GetComponent().GetBoundUserInterface(SolarControlConsoleUiKey.Key); + _userInterface.OnReceiveMessage += UserInterfaceOnReceiveMessage; + _powerDevice = Owner.GetComponent(); + _powerSolarSystem = _entitySystemManager.GetEntitySystem(); + } + + public void UpdateUIState() + { + _userInterface.SetState(new SolarControlConsoleBoundInterfaceState(_powerSolarSystem.TargetPanelRotation, _powerSolarSystem.TargetPanelVelocity, _powerSolarSystem.TotalPanelPower, _powerSolarSystem.TowardsSun)); + } + + private void UserInterfaceOnReceiveMessage(ServerBoundUserInterfaceMessage obj) + { + switch (obj.Message) + { + case SolarControlConsoleAdjustMessage msg: + if (double.IsFinite(msg.Rotation)) + { + _powerSolarSystem.TargetPanelRotation = msg.Rotation.Reduced(); + } + if (double.IsFinite(msg.AngularVelocity)) + { + _powerSolarSystem.TargetPanelVelocity = msg.AngularVelocity.Reduced(); + } + break; + } + } + + void IActivate.Activate(ActivateEventArgs eventArgs) + { + if (!eventArgs.User.TryGetComponent(out IActorComponent actor)) + { + return; + } + + if (!Powered) + { + return; + } + + // always update the UI immediately before opening, just in case + UpdateUIState(); + _userInterface.Open(actor.playerSession); + } + } +} diff --git a/Content.Server/GameObjects/Components/Power/SolarPanelComponent.cs b/Content.Server/GameObjects/Components/Power/SolarPanelComponent.cs index 4707180c59..e814a7b0d7 100644 --- a/Content.Server/GameObjects/Components/Power/SolarPanelComponent.cs +++ b/Content.Server/GameObjects/Components/Power/SolarPanelComponent.cs @@ -60,6 +60,15 @@ namespace Content.Server.GameObjects.Components.Power } } + /// + /// The game time () of the next coverage update. + /// This may have a random offset applied. + /// This is used to reduce solar panel updates and stagger them to prevent lagspikes. + /// This should only be updated by the PowerSolarSystem but is viewable for debugging. + /// + [ViewVariables] + public TimeSpan TimeOfNextCoverageUpdate = TimeSpan.MinValue; + private void UpdateSupply() { if (_powerGenerator != null) diff --git a/Content.Server/GameObjects/EntitySystems/PowerSolarControlConsoleSystem.cs b/Content.Server/GameObjects/EntitySystems/PowerSolarControlConsoleSystem.cs new file mode 100644 index 0000000000..c823080a65 --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/PowerSolarControlConsoleSystem.cs @@ -0,0 +1,46 @@ +using Content.Server.GameObjects.Components.Power; +using JetBrains.Annotations; +using Content.Shared.Physics; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Physics; +using Robust.Shared.Interfaces.Random; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.Physics; +using Robust.Shared.IoC; +using Robust.Shared.Maths; +using System; + +namespace Content.Server.GameObjects.EntitySystems +{ + /// + /// Responsible for updating solar control consoles. + /// + [UsedImplicitly] + public class PowerSolarControlConsoleSystem : EntitySystem + { + /// + /// Timer used to avoid updating the UI state every frame (which would be overkill) + /// + private float UpdateTimer = 0f; + + public override void Initialize() + { + EntityQuery = new TypeEntityQuery(typeof(SolarControlConsoleComponent)); + } + + public override void Update(float frameTime) + { + UpdateTimer += frameTime; + if (UpdateTimer >= 1) + { + UpdateTimer = 0; + foreach (var entity in RelevantEntities) + { + entity.GetComponent().UpdateUIState(); + } + } + } + } +} diff --git a/Content.Server/GameObjects/EntitySystems/PowerSolarSystem.cs b/Content.Server/GameObjects/EntitySystems/PowerSolarSystem.cs index 4108748e48..562b463958 100644 --- a/Content.Server/GameObjects/EntitySystems/PowerSolarSystem.cs +++ b/Content.Server/GameObjects/EntitySystems/PowerSolarSystem.cs @@ -1,9 +1,17 @@ using Content.Server.GameObjects.Components.Power; using JetBrains.Annotations; +using Content.Shared.Physics; using Robust.Shared.GameObjects; using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Physics; +using Robust.Shared.Interfaces.Random; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.Physics; +using Robust.Shared.IoC; using Robust.Shared.Maths; using System; +using System.Linq; namespace Content.Server.GameObjects.EntitySystems { @@ -11,55 +19,137 @@ namespace Content.Server.GameObjects.EntitySystems /// Responsible for maintaining the solar-panel sun angle and updating coverage. /// [UsedImplicitly] - public class PowerSolarSystem: EntitySystem + public class PowerSolarSystem : EntitySystem { +#pragma warning disable 649 + [Dependency] private IGameTiming _gameTiming; + [Dependency] private IRobustRandom _robustRandom; +#pragma warning restore 649 + /// /// The current sun angle. /// public Angle TowardsSun = Angle.South; + /// + /// The current sun angular velocity. (This is changed in Initialize) + /// + public Angle SunAngularVelocity = Angle.Zero; + + /// + /// The distance before the sun is considered to have been 'visible anyway'. + /// This value, like the occlusion semantics, is borrowed from all the other SS13 stations with solars. + /// + public float SunOcclusionCheckDistance = 20; + + /// + /// This is the per-second value used to reduce solar panel coverage updates + /// (and the resulting occlusion raycasts) + /// to within sane boundaries. + /// Keep in mind, this is not exact, as the random interval is also applied. + /// + public TimeSpan SolarCoverageUpdateInterval = TimeSpan.FromSeconds(0.5); + + /// + /// A random interval used to stagger solar coverage updates reliably. + /// + public TimeSpan SolarCoverageUpdateRandomInterval = TimeSpan.FromSeconds(0.5); + + /// + /// TODO: *Should be moved into the solar tracker when powernet allows for it.* + /// The current target panel rotation. + /// + public Angle TargetPanelRotation = Angle.Zero; + + /// + /// TODO: *Should be moved into the solar tracker when powernet allows for it.* + /// The current target panel velocity. + /// + public Angle TargetPanelVelocity = Angle.Zero; + + /// + /// TODO: *Should be moved into the solar tracker when powernet allows for it.* + /// Last update of total panel power. + /// + public float TotalPanelPower = 0; + public override void Initialize() { EntityQuery = new TypeEntityQuery(typeof(SolarPanelComponent)); + // Initialize the sun to something random + TowardsSun = Math.PI * 2 * _robustRandom.NextDouble(); + SunAngularVelocity = Angle.FromDegrees(0.1 + ((_robustRandom.NextDouble() - 0.5) * 0.05)); } public override void Update(float frameTime) { - TowardsSun += Angle.FromDegrees(frameTime / 10); + TowardsSun += SunAngularVelocity * frameTime; TowardsSun = TowardsSun.Reduced(); + + TargetPanelRotation += TargetPanelVelocity * frameTime; + TargetPanelRotation = TargetPanelRotation.Reduced(); + + TotalPanelPower = 0; + foreach (var entity in RelevantEntities) { - // In the 'sunRelative' coordinate system: - // the sun is considered to be an infinite distance directly up. - // this is the rotation of the panel relative to that. - // directly upwards (theta = 0) = coverage 1 - // left/right 90 degrees (abs(theta) = (pi / 2)) = coverage 0 - // directly downwards (abs(theta) = pi) = coverage -1 - // as TowardsSun + = CCW, - // panelRelativeToSun should - = CW - var panelRelativeToSun = entity.Transform.WorldRotation - TowardsSun; - // essentially, given cos = X & sin = Y & Y is 'downwards', - // then for the first 90 degrees of rotation in either direction, - // this plots the lower-right quadrant of a circle. - // now basically assume a line going from the negated X/Y to there, - // and that's the hypothetical solar panel. - // - // since, again, the sun is considered to be an infinite distance upwards, - // this essentially means Cos(panelRelativeToSun) is half of the cross-section, - // and since the full cross-section has a max of 2, effectively-halving it is fine. - // - // as for when it goes negative, it only does that when (abs(theta) > pi) - // and that's expected behavior. - float coverage = (float) Math.Max(0, Math.Cos(panelRelativeToSun)); + // There's supposed to be rotational logic here, but that implies putting it somewhere. + entity.Transform.WorldRotation = TargetPanelRotation; - // Would determine occlusion, but that requires raytraces. - // And I'm not sure where those are in the codebase. - // Luckily, auto-rotation isn't in yet, so it won't matter anyway. - - // Total coverage calculated; apply it to the panel. var panel = entity.GetComponent(); - panel.Coverage = coverage; + if (panel.TimeOfNextCoverageUpdate < _gameTiming.CurTime) + { + // Setup the next coverage check. + TimeSpan future = SolarCoverageUpdateInterval + (SolarCoverageUpdateRandomInterval * _robustRandom.NextDouble()); + panel.TimeOfNextCoverageUpdate = _gameTiming.CurTime + future; + UpdatePanelCoverage(panel); + } + TotalPanelPower += panel.Coverage * panel.MaxSupply; } } + + private void UpdatePanelCoverage(SolarPanelComponent panel) { + IEntity entity = panel.Owner; + + // So apparently, and yes, I *did* only find this out later, + // this is just a really fancy way of saying "Lambert's law of cosines". + // ...I still think this explaination makes more sense. + + // In the 'sunRelative' coordinate system: + // the sun is considered to be an infinite distance directly up. + // this is the rotation of the panel relative to that. + // directly upwards (theta = 0) = coverage 1 + // left/right 90 degrees (abs(theta) = (pi / 2)) = coverage 0 + // directly downwards (abs(theta) = pi) = coverage -1 + // as TowardsSun + = CCW, + // panelRelativeToSun should - = CW + var panelRelativeToSun = entity.Transform.WorldRotation - TowardsSun; + // essentially, given cos = X & sin = Y & Y is 'downwards', + // then for the first 90 degrees of rotation in either direction, + // this plots the lower-right quadrant of a circle. + // now basically assume a line going from the negated X/Y to there, + // and that's the hypothetical solar panel. + // + // since, again, the sun is considered to be an infinite distance upwards, + // this essentially means Cos(panelRelativeToSun) is half of the cross-section, + // and since the full cross-section has a max of 2, effectively-halving it is fine. + // + // as for when it goes negative, it only does that when (abs(theta) > pi) + // and that's expected behavior. + float coverage = (float)Math.Max(0, Math.Cos(panelRelativeToSun)); + + if (coverage > 0) + { + // Determine if the solar panel is occluded, and zero out coverage if so. + // FIXME: The "Opaque" collision group doesn't seem to work right now. + var ray = new CollisionRay(entity.Transform.WorldPosition, TowardsSun.ToVec(), (int) CollisionGroup.Opaque); + var rayCastResults = IoCManager.Resolve().IntersectRay(entity.Transform.MapID, ray, SunOcclusionCheckDistance, entity); + if (rayCastResults.Any()) + coverage = 0; + } + + // Total coverage calculated; apply it to the panel. + panel.Coverage = coverage; + } } } diff --git a/Content.Shared/GameObjects/Components/Power/SharedSolarControlConsoleComponent.cs b/Content.Shared/GameObjects/Components/Power/SharedSolarControlConsoleComponent.cs new file mode 100644 index 0000000000..a42e68287a --- /dev/null +++ b/Content.Shared/GameObjects/Components/Power/SharedSolarControlConsoleComponent.cs @@ -0,0 +1,66 @@ +using System; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Components.UserInterface; +using Robust.Shared.Serialization; +using Robust.Shared.Maths; + +namespace Content.Shared.GameObjects.Components.Power +{ + public class SharedSolarControlConsoleComponent : Component + { + public override string Name => "SolarControlConsole"; + + } + + [Serializable, NetSerializable] + public class SolarControlConsoleBoundInterfaceState : BoundUserInterfaceState + { + /// + /// The target rotation of the panels in radians. + /// + public Angle Rotation; + + /// + /// The target velocity of the panels in radians/minute. + /// + public Angle AngularVelocity; + + /// + /// The total amount of power the panels are supplying. + /// + public float OutputPower; + + /// + /// The current sun angle. + /// + public Angle TowardsSun; + + public SolarControlConsoleBoundInterfaceState(Angle r, Angle vm, float p, Angle tw) + { + Rotation = r; + AngularVelocity = vm; + OutputPower = p; + TowardsSun = tw; + } + } + + [Serializable, NetSerializable] + public sealed class SolarControlConsoleAdjustMessage : BoundUserInterfaceMessage + { + /// + /// New target rotation of the panels in radians. + /// + public Angle Rotation; + + /// + /// New target velocity of the panels in radians/second. + /// + public Angle AngularVelocity; + } + + [Serializable, NetSerializable] + public enum SolarControlConsoleUiKey + { + Key + } +} diff --git a/Resources/Prototypes/Entities/Buildings/computers.yml b/Resources/Prototypes/Entities/Buildings/computers.yml index f94fb89d21..31b58a932b 100644 --- a/Resources/Prototypes/Entities/Buildings/computers.yml +++ b/Resources/Prototypes/Entities/Buildings/computers.yml @@ -201,3 +201,19 @@ interfaces: - key: enum.CommunicationsConsoleUiKey.Key type: CommunicationsConsoleBoundUserInterface + +- type: entity + id: ComputerSolarControl + parent: ComputerBase + name: Solar Control Computer + components: + - type: Appearance + visuals: + - type: ComputerVisualizer2D + key: generic_key + screen: solar_screen + - type: SolarControlConsole + - type: UserInterface + interfaces: + - key: enum.SolarControlConsoleUiKey.Key + type: SolarControlConsoleBoundUserInterface diff --git a/Resources/Prototypes/Entities/Buildings/power.yml b/Resources/Prototypes/Entities/Buildings/power.yml index 8b3d57224e..a48ec9b0c6 100644 --- a/Resources/Prototypes/Entities/Buildings/power.yml +++ b/Resources/Prototypes/Entities/Buildings/power.yml @@ -84,7 +84,6 @@ - type: PowerGenerator - type: SolarPanel supply: 1500 - - type: Rotatable - type: SnapGrid offset: Center - type: Damageable