Solar panels occlusion and tracking (#961)

This commit is contained in:
20kdc
2020-06-02 12:32:18 +01:00
committed by GitHub
parent b4c928b709
commit a09131e817
8 changed files with 564 additions and 31 deletions

View File

@@ -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);
}
}
}
}

View File

@@ -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<ServerUserInterfaceComponent>().GetBoundUserInterface(SolarControlConsoleUiKey.Key);
_userInterface.OnReceiveMessage += UserInterfaceOnReceiveMessage;
_powerDevice = Owner.GetComponent<PowerDeviceComponent>();
_powerSolarSystem = _entitySystemManager.GetEntitySystem<PowerSolarSystem>();
}
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);
}
}
}

View File

@@ -60,6 +60,15 @@ namespace Content.Server.GameObjects.Components.Power
} }
} }
/// <summary>
/// The game time (<see cref='IGameTiming'/>) 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.
/// </summary>
[ViewVariables]
public TimeSpan TimeOfNextCoverageUpdate = TimeSpan.MinValue;
private void UpdateSupply() private void UpdateSupply()
{ {
if (_powerGenerator != null) if (_powerGenerator != null)

View File

@@ -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
{
/// <summary>
/// Responsible for updating solar control consoles.
/// </summary>
[UsedImplicitly]
public class PowerSolarControlConsoleSystem : EntitySystem
{
/// <summary>
/// Timer used to avoid updating the UI state every frame (which would be overkill)
/// </summary>
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<SolarControlConsoleComponent>().UpdateUIState();
}
}
}
}
}

View File

@@ -1,9 +1,17 @@
using Content.Server.GameObjects.Components.Power; using Content.Server.GameObjects.Components.Power;
using JetBrains.Annotations; using JetBrains.Annotations;
using Content.Shared.Physics;
using Robust.Shared.GameObjects; using Robust.Shared.GameObjects;
using Robust.Shared.GameObjects.Systems; 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 Robust.Shared.Maths;
using System; using System;
using System.Linq;
namespace Content.Server.GameObjects.EntitySystems namespace Content.Server.GameObjects.EntitySystems
{ {
@@ -11,55 +19,137 @@ namespace Content.Server.GameObjects.EntitySystems
/// Responsible for maintaining the solar-panel sun angle and updating <see cref='SolarPanelComponent'/> coverage. /// Responsible for maintaining the solar-panel sun angle and updating <see cref='SolarPanelComponent'/> coverage.
/// </summary> /// </summary>
[UsedImplicitly] [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
/// <summary> /// <summary>
/// The current sun angle. /// The current sun angle.
/// </summary> /// </summary>
public Angle TowardsSun = Angle.South; public Angle TowardsSun = Angle.South;
/// <summary>
/// The current sun angular velocity. (This is changed in Initialize)
/// </summary>
public Angle SunAngularVelocity = Angle.Zero;
/// <summary>
/// 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.
/// </summary>
public float SunOcclusionCheckDistance = 20;
/// <summary>
/// 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.
/// </summary>
public TimeSpan SolarCoverageUpdateInterval = TimeSpan.FromSeconds(0.5);
/// <summary>
/// A random interval used to stagger solar coverage updates reliably.
/// </summary>
public TimeSpan SolarCoverageUpdateRandomInterval = TimeSpan.FromSeconds(0.5);
/// <summary>
/// TODO: *Should be moved into the solar tracker when powernet allows for it.*
/// The current target panel rotation.
/// </summary>
public Angle TargetPanelRotation = Angle.Zero;
/// <summary>
/// TODO: *Should be moved into the solar tracker when powernet allows for it.*
/// The current target panel velocity.
/// </summary>
public Angle TargetPanelVelocity = Angle.Zero;
/// <summary>
/// TODO: *Should be moved into the solar tracker when powernet allows for it.*
/// Last update of total panel power.
/// </summary>
public float TotalPanelPower = 0;
public override void Initialize() public override void Initialize()
{ {
EntityQuery = new TypeEntityQuery(typeof(SolarPanelComponent)); 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) public override void Update(float frameTime)
{ {
TowardsSun += Angle.FromDegrees(frameTime / 10); TowardsSun += SunAngularVelocity * frameTime;
TowardsSun = TowardsSun.Reduced(); TowardsSun = TowardsSun.Reduced();
TargetPanelRotation += TargetPanelVelocity * frameTime;
TargetPanelRotation = TargetPanelRotation.Reduced();
TotalPanelPower = 0;
foreach (var entity in RelevantEntities) foreach (var entity in RelevantEntities)
{ {
// In the 'sunRelative' coordinate system: // There's supposed to be rotational logic here, but that implies putting it somewhere.
// the sun is considered to be an infinite distance directly up. entity.Transform.WorldRotation = TargetPanelRotation;
// 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));
// 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<SolarPanelComponent>(); var panel = entity.GetComponent<SolarPanelComponent>();
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<IPhysicsManager>().IntersectRay(entity.Transform.MapID, ray, SunOcclusionCheckDistance, entity);
if (rayCastResults.Any())
coverage = 0;
}
// Total coverage calculated; apply it to the panel.
panel.Coverage = coverage;
}
} }
} }

View File

@@ -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
{
/// <summary>
/// The target rotation of the panels in radians.
/// </summary>
public Angle Rotation;
/// <summary>
/// The target velocity of the panels in radians/minute.
/// </summary>
public Angle AngularVelocity;
/// <summary>
/// The total amount of power the panels are supplying.
/// </summary>
public float OutputPower;
/// <summary>
/// The current sun angle.
/// </summary>
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
{
/// <summary>
/// New target rotation of the panels in radians.
/// </summary>
public Angle Rotation;
/// <summary>
/// New target velocity of the panels in radians/second.
/// </summary>
public Angle AngularVelocity;
}
[Serializable, NetSerializable]
public enum SolarControlConsoleUiKey
{
Key
}
}

View File

@@ -201,3 +201,19 @@
interfaces: interfaces:
- key: enum.CommunicationsConsoleUiKey.Key - key: enum.CommunicationsConsoleUiKey.Key
type: CommunicationsConsoleBoundUserInterface 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

View File

@@ -84,7 +84,6 @@
- type: PowerGenerator - type: PowerGenerator
- type: SolarPanel - type: SolarPanel
supply: 1500 supply: 1500
- type: Rotatable
- type: SnapGrid - type: SnapGrid
offset: Center offset: Center
- type: Damageable - type: Damageable