diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml b/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml new file mode 100644 index 0000000000..a5725b2013 --- /dev/null +++ b/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml.cs b/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml.cs new file mode 100644 index 0000000000..d00ebc0c91 --- /dev/null +++ b/Content.Client/GameObjects/Components/Observer/GhostRolesEntry.xaml.cs @@ -0,0 +1,21 @@ +using System; +using Content.Shared.GameObjects.Components.Observer; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.GameObjects.Components.Observer +{ + [GenerateTypedNameReferences] + public partial class GhostRolesEntry : VBoxContainer + { + public GhostRolesEntry(GhostRoleInfo info, Action requestAction) + { + RobustXamlLoader.Load(this); + + Title.SetMessage(info.Name); + Description.SetMessage(info.Description); + RequestButton.OnPressed += requestAction; + } + } +} diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesEui.cs b/Content.Client/GameObjects/Components/Observer/GhostRolesEui.cs new file mode 100644 index 0000000000..e5d5c188b2 --- /dev/null +++ b/Content.Client/GameObjects/Components/Observer/GhostRolesEui.cs @@ -0,0 +1,54 @@ +using Content.Client.Eui; +using Content.Shared.Eui; +using Content.Shared.GameObjects.Components.Observer; +using JetBrains.Annotations; + +namespace Content.Client.GameObjects.Components.Observer +{ + [UsedImplicitly] + public class GhostRolesEui : BaseEui + { + private readonly GhostRolesWindow _window; + + public GhostRolesEui() + { + _window = new GhostRolesWindow(); + + _window.RoleRequested += id => + { + SendMessage(new GhostRoleTakeoverRequestMessage(id)); + }; + + _window.OnClose += () => + { + SendMessage(new GhostRoleWindowCloseMessage()); + }; + } + + public override void Opened() + { + base.Opened(); + _window.OpenCentered(); + } + + public override void Closed() + { + base.Closed(); + _window.Close(); + } + + public override void HandleState(EuiStateBase state) + { + base.HandleState(state); + + if (state is not GhostRolesEuiState ghostState) return; + + _window.ClearEntries(); + + foreach (var info in ghostState.GhostRoles) + { + _window.AddEntry(info); + } + } + } +} diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml b/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml new file mode 100644 index 0000000000..ab26c90d8c --- /dev/null +++ b/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml.cs b/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml.cs new file mode 100644 index 0000000000..b67fa4782b --- /dev/null +++ b/Content.Client/GameObjects/Components/Observer/GhostRolesWindow.xaml.cs @@ -0,0 +1,28 @@ +using System; +using Content.Shared.GameObjects.Components.Observer; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.CustomControls; +using Robust.Shared.Maths; + +namespace Content.Client.GameObjects.Components.Observer +{ + [GenerateTypedNameReferences] + public partial class GhostRolesWindow : SS14Window + { + public event Action RoleRequested; + + protected override Vector2 CalculateMinimumSize() => (350, 275); + + public void ClearEntries() + { + EntryContainer.DisposeAllChildren(); + NoRolesMessage.Visible = true; + } + + public void AddEntry(GhostRoleInfo info) + { + NoRolesMessage.Visible = false; + EntryContainer.AddChild(new GhostRolesEntry(info, _ => RoleRequested?.Invoke(info.Identifier))); + } + } +} diff --git a/Content.Client/UserInterface/GhostGui.cs b/Content.Client/UserInterface/GhostGui.cs index 661e1c8313..7cd2fcb60d 100644 --- a/Content.Client/UserInterface/GhostGui.cs +++ b/Content.Client/UserInterface/GhostGui.cs @@ -1,4 +1,5 @@ using Content.Client.GameObjects.Components.Observer; +using Robust.Client.Console; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.CustomControls; @@ -12,6 +13,7 @@ namespace Content.Client.UserInterface { private readonly Button _returnToBody = new() {Text = Loc.GetString("Return to body")}; private readonly Button _ghostWarp = new() {Text = Loc.GetString("Ghost Warp")}; + private readonly Button _ghostRoles = new() {Text = Loc.GetString("Ghost Roles")}; private readonly GhostComponent _owner; public GhostGui(GhostComponent owner) @@ -26,13 +28,15 @@ namespace Content.Client.UserInterface _ghostWarp.OnPressed += args => targetMenu.Populate(); _returnToBody.OnPressed += args => owner.SendReturnToBodyMessage(); + _ghostRoles.OnPressed += _ => IoCManager.Resolve().RemoteExecuteCommand(null, "ghostroles"); AddChild(new HBoxContainer { Children = { _returnToBody, - _ghostWarp + _ghostWarp, + _ghostRoles, } }); diff --git a/Content.Client/UserInterface/GhostRoleWindow.cs b/Content.Client/UserInterface/GhostRoleWindow.cs new file mode 100644 index 0000000000..9157e78a7d --- /dev/null +++ b/Content.Client/UserInterface/GhostRoleWindow.cs @@ -0,0 +1,14 @@ +using Content.Client.GameObjects.EntitySystems; +using Robust.Client.UserInterface.CustomControls; +using Robust.Shared.GameObjects.Systems; + +namespace Content.Client.UserInterface +{ + public class GhostRoleWindow : SS14Window + { + protected override void Opened() + { + base.Opened(); + } + } +} diff --git a/Content.Server/Commands/MakeSentientCommand.cs b/Content.Server/Commands/MakeSentientCommand.cs index eb969ecf49..e180b17a60 100644 --- a/Content.Server/Commands/MakeSentientCommand.cs +++ b/Content.Server/Commands/MakeSentientCommand.cs @@ -1,4 +1,5 @@ #nullable enable +using System.Threading; using Content.Server.Administration; using Content.Server.GameObjects.Components.Mobs; using Content.Server.GameObjects.Components.Movement; @@ -7,6 +8,7 @@ using Content.Shared.GameObjects.Components.Mobs.Speech; using Robust.Shared.Console; using Robust.Shared.GameObjects; using Robust.Shared.IoC; +using Timer = Robust.Shared.Timers.Timer; namespace Content.Server.Commands { @@ -44,10 +46,14 @@ namespace Content.Server.Commands if(entity.HasComponent()) entity.RemoveComponent(); - entity.EnsureComponent(); - entity.EnsureComponent(); - entity.EnsureComponent(); - entity.EnsureComponent(); + // Delay spawning these components to avoid race conditions with the deferred removal of AiController. + Timer.Spawn(100, () => + { + entity.EnsureComponent(); + entity.EnsureComponent(); + entity.EnsureComponent(); + entity.EnsureComponent(); + }); } } } diff --git a/Content.Server/GameObjects/Components/Observer/GhostRoleComponent.cs b/Content.Server/GameObjects/Components/Observer/GhostRoleComponent.cs new file mode 100644 index 0000000000..a021a190d9 --- /dev/null +++ b/Content.Server/GameObjects/Components/Observer/GhostRoleComponent.cs @@ -0,0 +1,75 @@ +using Content.Server.GameObjects.EntitySystems; +using Robust.Server.Interfaces.Player; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Serialization; +using Robust.Shared.ViewVariables; + +namespace Content.Server.GameObjects.Components.Observer +{ + public abstract class GhostRoleComponent : Component + { + private string _roleName; + private string _roleDescription; + + // We do this so updating RoleName and RoleDescription in VV updates the open EUIs. + + [ViewVariables(VVAccess.ReadWrite)] + public string RoleName + { + get + { + return _roleName; + } + private set + { + _roleName = value; + EntitySystem.Get().UpdateAllEui(); + } + } + + [ViewVariables(VVAccess.ReadWrite)] + public string RoleDescription + { + get + { + return _roleDescription; + } + private set + { + _roleDescription = value; + EntitySystem.Get().UpdateAllEui(); + } + } + + [ViewVariables(VVAccess.ReadOnly)] + public bool Taken { get; protected set; } + + [ViewVariables] + public uint Identifier { get; set; } + + public override void ExposeData(ObjectSerializer serializer) + { + base.ExposeData(serializer); + + serializer.DataField(ref _roleName, "name", "Unknown"); + serializer.DataField(ref _roleDescription, "description", "Unknown"); + } + + public override void Initialize() + { + base.Initialize(); + + EntitySystem.Get().RegisterGhostRole(this); + } + + protected override void Shutdown() + { + base.Shutdown(); + + EntitySystem.Get().UnregisterGhostRole(this); + } + + public abstract bool Take(IPlayerSession session); + } +} diff --git a/Content.Server/GameObjects/Components/Observer/GhostRoleMobSpawnerComponent.cs b/Content.Server/GameObjects/Components/Observer/GhostRoleMobSpawnerComponent.cs new file mode 100644 index 0000000000..421a97144f --- /dev/null +++ b/Content.Server/GameObjects/Components/Observer/GhostRoleMobSpawnerComponent.cs @@ -0,0 +1,69 @@ +using System; +using Content.Server.GameObjects.Components.Mobs; +using Content.Server.Players; +using JetBrains.Annotations; +using Robust.Server.Interfaces.GameObjects; +using Robust.Server.Interfaces.Player; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using Robust.Shared.ViewVariables; + +namespace Content.Server.GameObjects.Components.Observer +{ + /// + /// Allows a ghost to take this role, spawning a new entity. + /// + [RegisterComponent, ComponentReference(typeof(GhostRoleComponent))] + public class GhostRoleMobSpawnerComponent : GhostRoleComponent + { + public override string Name => "GhostRoleMobSpawner"; + + + [ViewVariables] + private bool _deleteOnSpawn = true; + + [ViewVariables(VVAccess.ReadWrite)] + private int _availableTakeovers = 1; + + [ViewVariables] + private int _currentTakeovers = 0; + + [CanBeNull, ViewVariables(VVAccess.ReadWrite)] public string Prototype { get; private set; } + + public override void ExposeData(ObjectSerializer serializer) + { + base.ExposeData(serializer); + + serializer.DataField(this, x => x.Prototype, "prototype", null); + serializer.DataField(ref _deleteOnSpawn, "deleteOnSpawn", true); + serializer.DataField(ref _availableTakeovers, "availableTakeovers", 1); + } + + public override bool Take(IPlayerSession session) + { + if (Taken) + return false; + + if(string.IsNullOrEmpty(Prototype)) + throw new NullReferenceException("Prototype string cannot be null or empty!"); + + var mob = Owner.EntityManager.SpawnEntity(Prototype, Owner.Transform.Coordinates); + + mob.EnsureComponent(); + session.ContentData().Mind.TransferTo(mob); + + if (++_currentTakeovers < _availableTakeovers) return true; + + Taken = true; + + if (_deleteOnSpawn) + Owner.Delete(); + + + return true; + + } + } +} diff --git a/Content.Server/GameObjects/Components/Observer/GhostRolesEui.cs b/Content.Server/GameObjects/Components/Observer/GhostRolesEui.cs new file mode 100644 index 0000000000..a074aa2400 --- /dev/null +++ b/Content.Server/GameObjects/Components/Observer/GhostRolesEui.cs @@ -0,0 +1,39 @@ +using Content.Server.Eui; +using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Eui; +using Content.Shared.GameObjects.Components.Observer; +using Robust.Shared.GameObjects.Systems; + +namespace Content.Server.GameObjects.Components.Observer +{ + public class GhostRolesEui : BaseEui + { + public override GhostRolesEuiState GetNewState() + { + return new(EntitySystem.Get().GetGhostRolesInfo()); + } + + public override void HandleMessage(EuiMessageBase msg) + { + base.HandleMessage(msg); + + switch (msg) + { + case GhostRoleTakeoverRequestMessage req: + EntitySystem.Get().Takeover(Player, req.Identifier); + break; + + case GhostRoleWindowCloseMessage _: + Closed(); + break; + } + } + + public override void Closed() + { + base.Closed(); + + EntitySystem.Get().CloseEui(Player); + } + } +} diff --git a/Content.Server/GameObjects/Components/Observer/GhostTakeoverAvailableComponent.cs b/Content.Server/GameObjects/Components/Observer/GhostTakeoverAvailableComponent.cs new file mode 100644 index 0000000000..a0adda9907 --- /dev/null +++ b/Content.Server/GameObjects/Components/Observer/GhostTakeoverAvailableComponent.cs @@ -0,0 +1,38 @@ +using System; +using Content.Server.GameObjects.Components.Mobs; +using Content.Server.GameObjects.EntitySystems; +using Content.Server.Players; +using Robust.Server.Interfaces.Player; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; + +namespace Content.Server.GameObjects.Components.Observer +{ + /// + /// Allows a ghost to take over the Owner entity. + /// + [RegisterComponent, ComponentReference(typeof(GhostRoleComponent))] + public class GhostTakeoverAvailableComponent : GhostRoleComponent + { + public override string Name => "GhostTakeoverAvailable"; + + public override bool Take(IPlayerSession session) + { + if (Taken) + return false; + + Taken = true; + + var mind = Owner.EnsureComponent(); + + if(mind.HasMind) + throw new Exception("MindComponent already has a mind!"); + + session.ContentData().Mind.TransferTo(Owner); + + EntitySystem.Get().UnregisterGhostRole(this); + + return true; + } + } +} diff --git a/Content.Server/GameObjects/EntitySystems/GhostRoleSystem.cs b/Content.Server/GameObjects/EntitySystems/GhostRoleSystem.cs new file mode 100644 index 0000000000..f39d6d628c --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/GhostRoleSystem.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Drawing; +using Content.Server.Administration; +using Content.Server.Eui; +using Content.Server.GameObjects.Components.Observer; +using Content.Shared.GameObjects.Components.Observer; +using Content.Shared.GameObjects.EntitySystemMessages; +using Content.Shared.GameTicking; +using JetBrains.Annotations; +using Robust.Server.GameObjects; +using Robust.Server.Interfaces.Player; +using Robust.Shared.Console; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.IoC; +using Robust.Shared.Utility; +using Robust.Shared.ViewVariables; + +namespace Content.Server.GameObjects.EntitySystems +{ + [UsedImplicitly] + public class GhostRoleSystem : EntitySystem, IResettingEntitySystem + { + [Dependency] private readonly EuiManager _euiManager = default!; + + private uint _nextRoleIdentifier = 0; + private readonly Dictionary _ghostRoles = new(); + private readonly Dictionary _openUis = new(); + + [ViewVariables] + public IReadOnlyCollection GhostRoles => _ghostRoles.Values; + + public override void Initialize() + { + SubscribeLocalEvent(OnPlayerAttached); + } + + private uint GetNextRoleIdentifier() + { + return unchecked(_nextRoleIdentifier++); + } + + public void OpenEui(IPlayerSession session) + { + if (session.AttachedEntity == null || !session.AttachedEntity.HasComponent()) + return; + + if(_openUis.ContainsKey(session)) + CloseEui(session); + + var eui = _openUis[session] = new GhostRolesEui(); + _euiManager.OpenEui(eui, session); + eui.StateDirty(); + } + + public void CloseEui(IPlayerSession session) + { + if (!_openUis.ContainsKey(session)) return; + + _openUis.Remove(session, out var eui); + + eui?.Close(); + } + + public void UpdateAllEui() + { + foreach (var eui in _openUis.Values) + { + eui.StateDirty(); + } + } + + public void RegisterGhostRole(GhostRoleComponent role) + { + if (_ghostRoles.ContainsValue(role)) return; + _ghostRoles[role.Identifier = GetNextRoleIdentifier()] = role; + UpdateAllEui(); + + } + + public void UnregisterGhostRole(GhostRoleComponent role) + { + if (!_ghostRoles.ContainsKey(role.Identifier) || _ghostRoles[role.Identifier] != role) return; + _ghostRoles.Remove(role.Identifier); + UpdateAllEui(); + } + + public void Takeover(IPlayerSession player, uint identifier) + { + if (!_ghostRoles.TryGetValue(identifier, out var role)) return; + if (!role.Take(player)) return; + CloseEui(player); + } + + public GhostRoleInfo[] GetGhostRolesInfo() + { + var roles = new GhostRoleInfo[_ghostRoles.Count]; + + var i = 0; + + foreach (var (id, role) in _ghostRoles) + { + roles[i] = new GhostRoleInfo(){Identifier = id, Name = role.RoleName, Description = role.RoleDescription}; + i++; + } + + return roles; + } + + private void OnPlayerAttached(PlayerAttachSystemMessage message) + { + // Close the session of any player that has a ghost roles window open and isn't a ghost anymore. + if (!_openUis.ContainsKey(message.NewPlayer)) return; + if (message.Entity.HasComponent()) return; + CloseEui(message.NewPlayer); + } + + public void Reset() + { + foreach (var session in _openUis.Keys) + { + CloseEui(session); + } + + _openUis.Clear(); + _ghostRoles.Clear(); + _nextRoleIdentifier = 0; + } + } + + [AnyCommand] + public class GhostRoles : IConsoleCommand + { + public string Command => "ghostroles"; + public string Description => "Opens the ghost role request window."; + public string Help => $"{Command}"; + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if(shell.Player != null) + EntitySystem.Get().OpenEui((IPlayerSession)shell.Player); + else + shell.WriteLine("You can only open the ghost roles UI on a client."); + } + } +} diff --git a/Content.Shared/GameObjects/Components/Observer/GhostRolesEuiMessages.cs b/Content.Shared/GameObjects/Components/Observer/GhostRolesEuiMessages.cs new file mode 100644 index 0000000000..ce646e8d8b --- /dev/null +++ b/Content.Shared/GameObjects/Components/Observer/GhostRolesEuiMessages.cs @@ -0,0 +1,41 @@ +using System; +using Content.Shared.Eui; +using Robust.Shared.Serialization; + +namespace Content.Shared.GameObjects.Components.Observer +{ + [NetSerializable, Serializable] + public struct GhostRoleInfo + { + public uint Identifier { get; set; } + public string Name { get; set; } + public string Description { get; set; } + } + + [NetSerializable, Serializable] + public class GhostRolesEuiState : EuiStateBase + { + public GhostRoleInfo[] GhostRoles { get; } + + public GhostRolesEuiState(GhostRoleInfo[] ghostRoles) + { + GhostRoles = ghostRoles; + } + } + + [NetSerializable, Serializable] + public class GhostRoleTakeoverRequestMessage : EuiMessageBase + { + public uint Identifier { get; } + + public GhostRoleTakeoverRequestMessage(uint identifier) + { + Identifier = identifier; + } + } + + [NetSerializable, Serializable] + public class GhostRoleWindowCloseMessage : EuiMessageBase + { + } +} diff --git a/Content.Shared/GameObjects/EntitySystems/SharedGhostRoleSystem.cs b/Content.Shared/GameObjects/EntitySystems/SharedGhostRoleSystem.cs new file mode 100644 index 0000000000..1aa269c4ea --- /dev/null +++ b/Content.Shared/GameObjects/EntitySystems/SharedGhostRoleSystem.cs @@ -0,0 +1,20 @@ +using System; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Serialization; + +namespace Content.Shared.GameObjects.EntitySystems +{ + public abstract class SharedGhostRoleSystem : EntitySystem + { + + } + + [Serializable, NetSerializable] + public class GhostRole + { + public string Name { get; set; } + public string Description { get; set; } + public EntityUid Id; + } +}