namespace Content.Client.PDA.Ringer
{
[UsedImplicitly]
- public sealed class RingerBoundUserInterface : BoundUserInterface
+ public sealed class RingerBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
{
[ViewVariables]
private RingtoneMenu? _menu;
- public RingerBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
- {
- }
-
protected override void Open()
{
base.Open();
_menu = this.CreateWindow<RingtoneMenu>();
_menu.OpenToLeft();
- _menu.TestRingerButton.OnPressed += _ =>
- {
- SendMessage(new RingerPlayRingtoneMessage());
- };
-
- _menu.SetRingerButton.OnPressed += _ =>
- {
- if (!TryGetRingtone(out var ringtone))
- return;
-
- SendMessage(new RingerSetRingtoneMessage(ringtone));
- _menu.SetRingerButton.Disabled = true;
+ _menu.TestRingtoneButtonPressed += OnTestRingtoneButtonPressed;
+ _menu.SetRingtoneButtonPressed += OnSetRingtoneButtonPressed;
- Timer.Spawn(333, () =>
- {
- if (_menu is { Disposed: false, SetRingerButton: { Disposed: false } ringer})
- ringer.Disabled = false;
- });
- };
+ Update();
}
private bool TryGetRingtone(out Note[] ringtone)
return true;
}
- protected override void UpdateState(BoundUserInterfaceState state)
+ public override void Update()
{
- base.UpdateState(state);
+ base.Update();
- if (_menu == null || state is not RingerUpdateState msg)
+ if (_menu == null)
return;
- for (int i = 0; i < _menu.RingerNoteInputs.Length; i++)
+ if (!EntMan.TryGetComponent(Owner, out RingerComponent? ringer))
+ return;
+
+ for (var i = 0; i < _menu.RingerNoteInputs.Length; i++)
{
+ var note = ringer.Ringtone[i].ToString();
- var note = msg.Ringtone[i].ToString();
- if (RingtoneMenu.IsNote(note))
- {
- _menu.PreviousNoteInputs[i] = note.Replace("sharp", "#");
- _menu.RingerNoteInputs[i].Text = _menu.PreviousNoteInputs[i];
- }
+ if (!RingtoneMenu.IsNote(note))
+ continue;
+ _menu.PreviousNoteInputs[i] = note.Replace("sharp", "#");
+ _menu.RingerNoteInputs[i].Text = _menu.PreviousNoteInputs[i];
}
- _menu.TestRingerButton.Disabled = msg.IsPlaying;
+ _menu.TestRingerButton.Disabled = ringer.Active;
}
+ private void OnTestRingtoneButtonPressed()
+ {
+ if (_menu is null)
+ return;
+
+ SendPredictedMessage(new RingerPlayRingtoneMessage());
+
+ // We disable it instantly to remove the delay before the client receives the next compstate
+ // Makes the UI feel responsive, will be re-enabled by ringer.Active once it gets an update.
+ _menu.TestRingerButton.Disabled = true;
+ }
- protected override void Dispose(bool disposing)
+ private void OnSetRingtoneButtonPressed()
{
- base.Dispose(disposing);
- if (!disposing)
+ if (_menu is null)
return;
- _menu?.Dispose();
+ if (!TryGetRingtone(out var ringtone))
+ return;
+
+ SendPredictedMessage(new RingerSetRingtoneMessage(ringtone));
+ _menu.SetRingerButton.Disabled = true;
+
+ Timer.Spawn(333,
+ () =>
+ {
+ if (_menu is { Disposed: false, SetRingerButton: { Disposed: false } ringer} )
+ ringer.Disabled = false;
+ });
}
}
}
--- /dev/null
+using Content.Shared.PDA;
+using Content.Shared.PDA.Ringer;
+using Content.Shared.Store.Components;
+
+namespace Content.Client.PDA.Ringer;
+
+/// <summary>
+/// Handles the client-side logic for <see cref="SharedRingerSystem"/>.
+/// </summary>
+public sealed class RingerSystem : SharedRingerSystem
+{
+ /// <inheritdoc/>
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent<RingerComponent, AfterAutoHandleStateEvent>(OnRingerUpdate);
+ }
+
+ /// <summary>
+ /// Updates the UI whenever we get a new component state from the server.
+ /// </summary>
+ private void OnRingerUpdate(Entity<RingerComponent> ent, ref AfterAutoHandleStateEvent args)
+ {
+ UpdateRingerUi(ent);
+ }
+
+ /// <inheritdoc/>
+ protected override void UpdateRingerUi(Entity<RingerComponent> ent)
+ {
+ if (UI.TryGetOpenUi(ent.Owner, RingerUiKey.Key, out var bui))
+ {
+ bui.Update();
+ }
+ }
+
+ /// <inheritdoc/>
+ public override bool TryToggleUplink(EntityUid uid, Note[] ringtone, EntityUid? user = null)
+ {
+ if (!TryComp<RingerUplinkComponent>(uid, out var uplink))
+ return false;
+
+ if (!HasComp<StoreComponent>(uid))
+ return false;
+
+ // Special case for client-side prediction:
+ // Since we can't expose the uplink code to clients for security reasons,
+ // we assume if an antagonist is trying to set a ringtone, it's to unlock the uplink.
+ // The server will properly verify the code and correct if needed.
+ if (IsAntagonist(user))
+ return ToggleUplinkInternal((uid, uplink));
+
+ // Non-antagonists never get to toggle the uplink on the client
+ return false;
+ }
+}
-<DefaultWindow xmlns="https://spacestation14.io"
- Title="{Loc 'comp-ringer-ui-menu-title'}"
- MinSize="320 128"
- SetSize="320 128">
+<controls:FancyWindow xmlns="https://spacestation14.io"
+ xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+ Title="{Loc 'comp-ringer-ui-menu-title'}"
+ MinSize="320 100"
+ SetSize="320 100">
<BoxContainer Orientation="Vertical"
VerticalExpand="True"
HorizontalExpand="True"
</BoxContainer>
</PanelContainer>
</BoxContainer>
-</DefaultWindow>
+</controls:FancyWindow>
using System.Numerics;
+using Content.Client.UserInterface.Controls;
using Robust.Client.AutoGenerated;
-using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Content.Shared.PDA;
using Robust.Client.UserInterface.Controls;
namespace Content.Client.PDA.Ringer
{
[GenerateTypedNameReferences]
- public sealed partial class RingtoneMenu : DefaultWindow
+ public sealed partial class RingtoneMenu : FancyWindow
{
public string[] PreviousNoteInputs = new[] { "A", "A", "A", "A", "A", "A" };
- public LineEdit[] RingerNoteInputs = default!;
+ public LineEdit[] RingerNoteInputs;
+
+ public event Action? SetRingtoneButtonPressed;
+ public event Action? TestRingtoneButtonPressed;
public RingtoneMenu()
{
RobustXamlLoader.Load(this);
+ SetRingerButton.OnPressed += _ => SetRingtoneButtonPressed?.Invoke();
+ TestRingerButton.OnPressed += _ => TestRingtoneButtonPressed?.Invoke();
+
RingerNoteInputs = new[] { RingerNoteOneInput, RingerNoteTwoInput, RingerNoteThreeInput, RingerNoteFourInput, RingerNoteFiveInput, RingerNoteSixInput };
for (var i = 0; i < RingerNoteInputs.Length; ++i)
foo();
input.CursorPosition = input.Text.Length; // Resets caret position to the end of the typed input
};
- input.OnTextChanged += _ =>
+
+ input.OnTextChanged += args =>
{
- input.Text = input.Text.ToUpper();
+ // Convert to uppercase
+ var upperText = args.Text.ToUpper();
- if (!IsNote(input.Text))
+ // Filter to only valid notes
+ var newText = upperText;
+ if (!IsNote(newText))
+ {
+ newText = PreviousNoteInputs[index];
input.AddStyleClass("Caution");
+ }
else
+ {
+ PreviousNoteInputs[index] = newText;
input.RemoveStyleClass("Caution");
+ }
+
+ // Only update if there's a change
+ if (newText != input.Text)
+ input.Text = newText;
};
}
}
{
Log.Debug($"MakeTraitor {ToPrettyString(traitor)} - Uplink is PDA");
// Codes are only generated if the uplink is a PDA
- code = EnsureComp<RingerUplinkComponent>(pda.Value).Code;
+ var ev = new GenerateUplinkCodeEvent();
+ RaiseLocalEvent(pda.Value, ref ev);
- // If giveUplink is false the uplink code part is omitted
- briefing = string.Format("{0}\n{1}",
- briefing,
- Loc.GetString("traitor-role-uplink-code-short", ("code", string.Join("-", code).Replace("sharp", "#"))));
- return (code, briefing);
+ if (ev.Code is { } generatedCode)
+ {
+ code = generatedCode;
+
+ // If giveUplink is false the uplink code part is omitted
+ briefing = string.Format("{0}\n{1}",
+ briefing,
+ Loc.GetString("traitor-role-uplink-code-short", ("code", string.Join("-", code).Replace("sharp", "#"))));
+ return (code, briefing);
+ }
}
else if (pda is null && uplinked)
{
using Content.Shared.Access.Components;
using Content.Shared.CartridgeLoader;
using Content.Shared.Chat;
+using Content.Shared.DeviceNetwork.Components;
using Content.Shared.Light;
using Content.Shared.Light.EntitySystems;
using Content.Shared.PDA;
+using Content.Shared.PDA.Ringer;
using Robust.Server.Containers;
using Robust.Server.GameObjects;
using Robust.Shared.Containers;
using Robust.Shared.Player;
using Robust.Shared.Utility;
-using Content.Shared.DeviceNetwork.Components;
namespace Content.Server.PDA
{
/// <summary>
/// Send new UI state to clients, call if you modify something like uplink.
/// </summary>
- public void UpdatePdaUi(EntityUid uid, PdaComponent? pda = null)
+ public override void UpdatePdaUi(EntityUid uid, PdaComponent? pda = null)
{
if (!Resolve(uid, ref pda, false))
return;
return;
if (HasComp<RingerComponent>(uid))
- _ringer.ToggleRingerUI(uid, msg.Actor);
+ _ringer.TryToggleRingerUi(uid, msg.Actor);
}
private void OnUiMessage(EntityUid uid, PdaComponent pda, PdaShowMusicMessage msg)
if (TryComp<RingerUplinkComponent>(uid, out var uplink))
{
- _ringer.LockUplink(uid, uplink);
+ _ringer.LockUplink((uid, uplink));
UpdatePdaUi(uid, pda);
}
}
+++ /dev/null
-using Content.Shared.PDA;
-
-namespace Content.Server.PDA.Ringer
-{
- [RegisterComponent]
- public sealed partial class RingerComponent : Component
- {
- [DataField("ringtone")]
- public Note[] Ringtone = new Note[SharedRingerSystem.RingtoneLength];
-
- [DataField("timeElapsed")]
- public float TimeElapsed = 0;
-
- /// <summary>
- /// Keeps track of how many notes have elapsed if the ringer component is playing.
- /// </summary>
- [DataField("noteCount")]
- public int NoteCount = 0;
-
- /// <summary>
- /// How far the sound projects in metres.
- /// </summary>
- [ViewVariables(VVAccess.ReadWrite)]
- [DataField("range")]
- public float Range = 3f;
-
- [ViewVariables(VVAccess.ReadWrite)]
- [DataField("volume")]
- public float Volume = -4f;
- }
-
- [RegisterComponent]
- public sealed partial class ActiveRingerComponent : Component
- {
- }
-}
using System.Linq;
-using System.Runtime.InteropServices;
-using Content.Server.Store.Components;
using Content.Server.Store.Systems;
using Content.Shared.PDA;
using Content.Shared.PDA.Ringer;
-using Content.Shared.Popups;
-using Content.Shared.Store;
using Content.Shared.Store.Components;
-using Robust.Server.GameObjects;
-using Robust.Shared.Audio;
-using Robust.Shared.Network;
-using Robust.Shared.Player;
using Robust.Shared.Random;
-using Robust.Shared.Timing;
-using Robust.Shared.Utility;
-using Robust.Server.Audio;
-namespace Content.Server.PDA.Ringer
-{
- public sealed class RingerSystem : SharedRingerSystem
- {
- [Dependency] private readonly PdaSystem _pda = default!;
- [Dependency] private readonly IGameTiming _gameTiming = default!;
- [Dependency] private readonly IRobustRandom _random = default!;
- [Dependency] private readonly UserInterfaceSystem _ui = default!;
- [Dependency] private readonly AudioSystem _audio = default!;
- [Dependency] private readonly SharedPopupSystem _popupSystem = default!;
- [Dependency] private readonly TransformSystem _transform = default!;
-
- private readonly Dictionary<NetUserId, TimeSpan> _lastSetRingtoneAt = new();
-
- public override void Initialize()
- {
- base.Initialize();
-
- // General Event Subscriptions
- SubscribeLocalEvent<RingerComponent, MapInitEvent>(RandomizeRingtone);
- SubscribeLocalEvent<RingerUplinkComponent, ComponentInit>(RandomizeUplinkCode);
- // RingerBoundUserInterface Subscriptions
- SubscribeLocalEvent<RingerComponent, RingerSetRingtoneMessage>(OnSetRingtone);
- SubscribeLocalEvent<RingerUplinkComponent, BeforeRingtoneSetEvent>(OnSetUplinkRingtone);
- SubscribeLocalEvent<RingerComponent, RingerPlayRingtoneMessage>(RingerPlayRingtone);
- SubscribeLocalEvent<RingerComponent, RingerRequestUpdateInterfaceMessage>(UpdateRingerUserInterfaceDriver);
-
- SubscribeLocalEvent<RingerComponent, CurrencyInsertAttemptEvent>(OnCurrencyInsert);
- }
-
- //Event Functions
-
- private void OnCurrencyInsert(EntityUid uid, RingerComponent ringer, CurrencyInsertAttemptEvent args)
- {
- if (!TryComp<RingerUplinkComponent>(uid, out var uplink))
- {
- args.Cancel();
- return;
- }
-
- // if the store can be locked, it must be unlocked first before inserting currency. Stops traitor checking.
- if (!uplink.Unlocked)
- args.Cancel();
- }
-
- private void RingerPlayRingtone(EntityUid uid, RingerComponent ringer, RingerPlayRingtoneMessage args)
- {
- EnsureComp<ActiveRingerComponent>(uid);
-
- _popupSystem.PopupEntity(Loc.GetString("comp-ringer-vibration-popup"), uid, Filter.Pvs(uid, 0.05f), false, PopupType.Small);
-
- UpdateRingerUserInterface(uid, ringer, true);
- }
-
- public void RingerPlayRingtone(Entity<RingerComponent?> ent)
- {
- if (!Resolve(ent, ref ent.Comp))
- return;
+namespace Content.Server.PDA.Ringer;
- EnsureComp<ActiveRingerComponent>(ent);
-
- _popupSystem.PopupEntity(Loc.GetString("comp-ringer-vibration-popup"), ent, Filter.Pvs(ent, 0.05f), false, PopupType.Medium);
-
- UpdateRingerUserInterface(ent, ent.Comp, true);
- }
-
- private void UpdateRingerUserInterfaceDriver(EntityUid uid, RingerComponent ringer, RingerRequestUpdateInterfaceMessage args)
- {
- UpdateRingerUserInterface(uid, ringer, HasComp<ActiveRingerComponent>(uid));
- }
-
- private void OnSetRingtone(EntityUid uid, RingerComponent ringer, RingerSetRingtoneMessage args)
- {
- if (!TryComp(args.Actor, out ActorComponent? actorComp))
- return;
-
- ref var lastSetAt = ref CollectionsMarshal.GetValueRefOrAddDefault(_lastSetRingtoneAt, actorComp.PlayerSession.UserId, out var exists);
-
- // Delay on the client is 0.333, 0.25 is still enough and gives some leeway in case of small time differences
- if (exists && lastSetAt > _gameTiming.CurTime - TimeSpan.FromMilliseconds(250))
- return;
-
- lastSetAt = _gameTiming.CurTime;
-
- // Client sent us an updated ringtone so set it to that.
- if (args.Ringtone.Length != RingtoneLength)
- return;
+/// <summary>
+/// Handles the server-side logic for <see cref="SharedRingerSystem"/>.
+/// </summary>
+public sealed class RingerSystem : SharedRingerSystem
+{
+ [Dependency] private readonly IRobustRandom _random = default!;
- var ev = new BeforeRingtoneSetEvent(args.Ringtone);
- RaiseLocalEvent(uid, ref ev);
- if (ev.Handled)
- return;
+ /// <inheritdoc/>
+ public override void Initialize()
+ {
+ base.Initialize();
- UpdateRingerRingtone(uid, ringer, args.Ringtone);
- }
+ SubscribeLocalEvent<RingerComponent, MapInitEvent>(OnMapInit);
+ SubscribeLocalEvent<RingerComponent, CurrencyInsertAttemptEvent>(OnCurrencyInsert);
- private void OnSetUplinkRingtone(EntityUid uid, RingerUplinkComponent uplink, ref BeforeRingtoneSetEvent args)
- {
- if (uplink.Code.SequenceEqual(args.Ringtone) && HasComp<StoreComponent>(uid))
- {
- uplink.Unlocked = !uplink.Unlocked;
- if (TryComp<PdaComponent>(uid, out var pda))
- _pda.UpdatePdaUi(uid, pda);
-
- // can't keep store open after locking it
- if (!uplink.Unlocked)
- _ui.CloseUi(uid, StoreUiKey.Key);
-
- // no saving the code to prevent meta click set on sus guys pda -> wewlad
- args.Handled = true;
- }
- }
-
- /// <summary>
- /// Locks the uplink and closes the window, if its open
- /// </summary>
- /// <remarks>
- /// Will not update the PDA ui so you must do that yourself if needed
- /// </remarks>
- public void LockUplink(EntityUid uid, RingerUplinkComponent? uplink)
- {
- if (!Resolve(uid, ref uplink, true))
- return;
+ SubscribeLocalEvent<RingerUplinkComponent, GenerateUplinkCodeEvent>(OnGenerateUplinkCode);
+ }
- uplink.Unlocked = false;
- _ui.CloseUi(uid, StoreUiKey.Key);
- }
+ /// <summary>
+ /// Randomizes a ringtone for <see cref="RingerComponent"/> on <see cref="MapInitEvent"/>.
+ /// </summary>
+ private void OnMapInit(Entity<RingerComponent> ent, ref MapInitEvent args)
+ {
+ UpdateRingerRingtone(ent, GenerateRingtone());
+ }
- public void RandomizeRingtone(EntityUid uid, RingerComponent ringer, MapInitEvent args)
+ /// <summary>
+ /// Handles the <see cref="CurrencyInsertAttemptEvent"/> for <see cref="RingerUplinkComponent"/>.
+ /// </summary>
+ private void OnCurrencyInsert(Entity<RingerComponent> ent, ref CurrencyInsertAttemptEvent args)
+ {
+ // TODO: Store isn't predicted, can't move it to shared
+ if (!TryComp<RingerUplinkComponent>(ent, out var uplink))
{
- UpdateRingerRingtone(uid, ringer, GenerateRingtone());
+ args.Cancel();
+ return;
}
- public void RandomizeUplinkCode(EntityUid uid, RingerUplinkComponent uplink, ComponentInit args)
- {
- uplink.Code = GenerateRingtone();
- }
+ // if the store can be locked, it must be unlocked first before inserting currency. Stops traitor checking.
+ if (!uplink.Unlocked)
+ args.Cancel();
+ }
- //Non Event Functions
+ /// <summary>
+ /// Handles the <see cref="GenerateUplinkCodeEvent"/> for generating an uplink code.
+ /// </summary>
+ private void OnGenerateUplinkCode(Entity<RingerUplinkComponent> ent, ref GenerateUplinkCodeEvent ev)
+ {
+ // Generate a new uplink code
+ var code = GenerateRingtone();
- private Note[] GenerateRingtone()
- {
- // Default to using C pentatonic so it at least sounds not terrible.
- return GenerateRingtone(new[]
- {
- Note.C,
- Note.D,
- Note.E,
- Note.G,
- Note.A
- });
- }
+ // Set the code on the component
+ ent.Comp.Code = code;
- private Note[] GenerateRingtone(Note[] notes)
- {
- var ringtone = new Note[RingtoneLength];
+ // Return the code via the event
+ ev.Code = code;
+ }
- for (var i = 0; i < RingtoneLength; i++)
- {
- ringtone[i] = _random.Pick(notes);
- }
+ /// <inheritdoc/>
+ public override bool TryToggleUplink(EntityUid uid, Note[] ringtone, EntityUid? user = null)
+ {
+ if (!TryComp<RingerUplinkComponent>(uid, out var uplink))
+ return false;
- return ringtone;
- }
+ if (!HasComp<StoreComponent>(uid))
+ return false;
- private bool UpdateRingerRingtone(EntityUid uid, RingerComponent ringer, Note[] ringtone)
- {
- // Assume validation has already happened.
- ringer.Ringtone = ringtone;
- UpdateRingerUserInterface(uid, ringer, HasComp<ActiveRingerComponent>(uid));
+ // On the server, we always check if the code matches
+ if (!uplink.Code.SequenceEqual(ringtone))
+ return false;
- return true;
- }
+ return ToggleUplinkInternal((uid, uplink));
+ }
- private void UpdateRingerUserInterface(EntityUid uid, RingerComponent ringer, bool isPlaying)
- {
- _ui.SetUiState(uid, RingerUiKey.Key, new RingerUpdateState(isPlaying, ringer.Ringtone));
- }
+ /// <summary>
+ /// Generates a random ringtone using the C pentatonic scale.
+ /// </summary>
+ /// <returns>An array of Notes representing the ringtone.</returns>
+ /// <remarks>The logic for this is on the Server so that we don't get a different result on the Client every time.</remarks>
+ private Note[] GenerateRingtone()
+ {
+ // Default to using C pentatonic so it at least sounds not terrible.
+ return GenerateRingtone(new[]
+ {
+ Note.C,
+ Note.D,
+ Note.E,
+ Note.G,
+ Note.A
+ });
+ }
- public bool ToggleRingerUI(EntityUid uid, EntityUid actor)
- {
- _ui.TryToggleUi(uid, RingerUiKey.Key, actor);
- return true;
- }
+ /// <summary>
+ /// Generates a random ringtone using the specified notes.
+ /// </summary>
+ /// <param name="notes">The notes to choose from when generating the ringtone.</param>
+ /// <returns>An array of Notes representing the ringtone.</returns>
+ /// <remarks>The logic for this is on the Server so that we don't get a different result on the Client every time.</remarks>
+ private Note[] GenerateRingtone(Note[] notes)
+ {
+ var ringtone = new Note[RingtoneLength];
- public override void Update(float frameTime) //Responsible for actually playing the ringtone
+ for (var i = 0; i < RingtoneLength; i++)
{
- var remove = new RemQueue<EntityUid>();
-
- var pdaQuery = EntityQueryEnumerator<RingerComponent, ActiveRingerComponent>();
- while (pdaQuery.MoveNext(out var uid, out var ringer, out var _))
- {
- ringer.TimeElapsed += frameTime;
-
- if (ringer.TimeElapsed < NoteDelay)
- continue;
-
- ringer.TimeElapsed -= NoteDelay;
- var ringerXform = Transform(uid);
-
- _audio.PlayEntity(
- GetSound(ringer.Ringtone[ringer.NoteCount]),
- Filter.Empty().AddInRange(_transform.GetMapCoordinates(uid, ringerXform), ringer.Range),
- uid,
- true,
- AudioParams.Default.WithMaxDistance(ringer.Range).WithVolume(ringer.Volume)
- );
-
- ringer.NoteCount++;
-
- if (ringer.NoteCount > RingtoneLength - 1)
- {
- remove.Add(uid);
- UpdateRingerUserInterface(uid, ringer, false);
- ringer.TimeElapsed = 0;
- ringer.NoteCount = 0;
- break;
- }
- }
-
- foreach (var ent in remove)
- {
- RemComp<ActiveRingerComponent>(ent);
- }
+ ringtone[i] = _random.Pick(notes);
}
- private static string GetSound(Note note)
- {
- return new ResPath("/Audio/Effects/RingtoneNotes/" + note.ToString().ToLower()) + ".ogg";
- }
+ return ringtone;
}
+}
- [ByRefEvent]
- public record struct BeforeRingtoneSetEvent(Note[] Ringtone, bool Handled = false);
+/// <summary>
+/// Event raised to generate a new uplink code for a PDA.
+/// </summary>
+[ByRefEvent]
+public record struct GenerateUplinkCodeEvent
+{
+ /// <summary>
+ /// The generated uplink code (filled in by the event handler).
+ /// </summary>
+ public Note[]? Code;
}
+++ /dev/null
-using Content.Shared.PDA;
-
-namespace Content.Server.PDA.Ringer;
-
-/// <summary>
-/// Opens the store ui when the ringstone is set to the secret code.
-/// Traitors are told the code when greeted.
-/// </summary>
-[RegisterComponent, Access(typeof(RingerSystem))]
-public sealed partial class RingerUplinkComponent : Component
-{
- /// <summary>
- /// Notes to set ringtone to in order to lock or unlock the uplink.
- /// Automatically initialized to random notes.
- /// </summary>
- [DataField("code")]
- public Note[] Code = new Note[RingerSystem.RingtoneLength];
-
- /// <summary>
- /// Whether to show the toggle uplink button in pda settings.
- /// </summary>
- [DataField("unlocked"), ViewVariables(VVAccess.ReadWrite)]
- public bool Unlocked;
-}
using System.Linq;
using Content.Server.Actions;
using Content.Server.Administration.Logs;
-using Content.Server.PDA.Ringer;
using Content.Server.Stack;
using Content.Server.Store.Components;
using Content.Shared.Actions;
using Content.Shared.FixedPoint;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Mind;
+using Content.Shared.PDA.Ringer;
using Content.Shared.Store;
using Content.Shared.Store.Components;
using Content.Shared.UserInterface;
--- /dev/null
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.PDA.Ringer;
+
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedRingerSystem))]
+[AutoGenerateComponentState(true, fieldDeltas: true), AutoGenerateComponentPause]
+public sealed partial class RingerComponent : Component
+{
+ /// <summary>
+ /// The ringtone, represented as an array of notes.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public Note[] Ringtone = new Note[SharedRingerSystem.RingtoneLength];
+
+ /// <summary>
+ /// The last time this ringer's ringtone was set.
+ /// </summary>
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField, AutoNetworkedField]
+ public TimeSpan NextRingtoneSetTime;
+
+ /// <summary>
+ /// The time when the next note should play.
+ /// </summary>
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField, AutoNetworkedField]
+ public TimeSpan? NextNoteTime;
+
+ /// <summary>
+ /// The cooldown before the ringtone can be changed again.
+ /// </summary>
+ [DataField]
+ public TimeSpan Cooldown = TimeSpan.FromMilliseconds(250);
+
+ /// <summary>
+ /// Keeps track of how many notes have elapsed if the ringer component is playing.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public int NoteCount;
+
+ /// <summary>
+ /// How far the sound projects in metres.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public float Range = 3f;
+
+ /// <summary>
+ /// The ringtone volume.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public float Volume = -4f;
+
+ /// <summary>
+ /// Whether the ringer is currently playing its ringtone.
+ /// </summary>
+ [DataField, AutoNetworkedField]
+ public bool Active;
+}
using Robust.Shared.Serialization;
-namespace Content.Shared.PDA.Ringer
-{
+namespace Content.Shared.PDA.Ringer;
- [Serializable, NetSerializable]
- public sealed class RingerRequestUpdateInterfaceMessage : BoundUserInterfaceMessage
- {
- }
+[Serializable, NetSerializable]
+public sealed class RingerPlayRingtoneMessage : BoundUserInterfaceMessage;
- [Serializable, NetSerializable]
- public sealed class RingerPlayRingtoneMessage : BoundUserInterfaceMessage
- {
- }
+[Serializable, NetSerializable]
+public sealed class RingerSetRingtoneMessage : BoundUserInterfaceMessage
+{
+ public Note[] Ringtone { get; }
- [Serializable, NetSerializable]
- public sealed class RingerSetRingtoneMessage : BoundUserInterfaceMessage
+ public RingerSetRingtoneMessage(Note[] ringTone)
{
- public Note[] Ringtone { get; }
-
- public RingerSetRingtoneMessage(Note[] ringTone)
- {
- Ringtone = ringTone;
- }
+ Ringtone = ringTone;
}
}
+++ /dev/null
-using Robust.Shared.Serialization;
-
-namespace Content.Shared.PDA.Ringer
-{
- [Serializable, NetSerializable]
- public sealed class RingerUpdateState : BoundUserInterfaceState
- {
- public bool IsPlaying;
- public Note[] Ringtone;
-
- public RingerUpdateState(bool isPlay, Note[] ringtone)
- {
- IsPlaying = isPlay;
- Ringtone = ringtone;
- }
- }
-
-}
--- /dev/null
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.PDA.Ringer;
+
+/// <summary>
+/// Opens the store UI when the ringstone is set to the secret code.
+/// Traitors are told the code when greeted.
+/// </summary>
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedRingerSystem))]
+public sealed partial class RingerUplinkComponent : Component
+{
+ /// <summary>
+ /// Notes to set ringtone to in order to lock or unlock the uplink.
+ /// Set via GenerateUplinkCodeEvent.
+ /// </summary>
+ [DataField]
+ public Note[] Code = new Note[SharedRingerSystem.RingtoneLength];
+
+ /// <summary>
+ /// Whether to show the toggle uplink button in PDA settings.
+ /// </summary>
+ [DataField]
+ public bool Unlocked;
+}
using Robust.Shared.Serialization;
-namespace Content.Shared.PDA.Ringer
-{
- [Serializable, NetSerializable]
- public enum RingerUiKey
- {
- Key
- }
+namespace Content.Shared.PDA.Ringer;
+[Serializable, NetSerializable]
+public enum RingerUiKey : byte
+{
+ Key,
}
{
Appearance.SetData(uid, PdaVisuals.IdCardInserted, pda.ContainedId != null);
}
+
+ public virtual void UpdatePdaUi(EntityUid uid, PdaComponent? pda = null)
+ {
+ // This does nothing yet while I finish up PDA prediction
+ // Overriden by the server
+ }
}
}
+using Content.Shared.Mind;
+using Content.Shared.PDA.Ringer;
+using Content.Shared.Popups;
+using Content.Shared.Roles;
+using Content.Shared.Store;
+using JetBrains.Annotations;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
using Robust.Shared.Serialization;
+using Robust.Shared.Timing;
namespace Content.Shared.PDA;
+/// <summary>
+/// Handles the shared functionality for PDA ringtones.
+/// </summary>
public abstract class SharedRingerSystem : EntitySystem
{
public const int RingtoneLength = 6;
public const int NoteTempo = 300;
public const float NoteDelay = 60f / NoteTempo;
+
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly INetManager _net = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly SharedMindSystem _mind = default!;
+ [Dependency] private readonly SharedPdaSystem _pda = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedRoleSystem _role = default!;
+ [Dependency] private readonly SharedTransformSystem _xform = default!;
+ [Dependency] protected readonly SharedUserInterfaceSystem UI = default!;
+
+ /// <inheritdoc/>
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ // RingerBoundUserInterface Subscriptions
+ SubscribeLocalEvent<RingerComponent, RingerSetRingtoneMessage>(OnSetRingtone);
+ SubscribeLocalEvent<RingerComponent, RingerPlayRingtoneMessage>(OnPlayRingtone);
+ }
+
+ /// <inheritdoc/>
+ public override void Update(float frameTime)
+ {
+ var ringerQuery = EntityQueryEnumerator<RingerComponent>();
+ while (ringerQuery.MoveNext(out var uid, out var ringer))
+ {
+ if (!ringer.Active || !ringer.NextNoteTime.HasValue)
+ continue;
+
+ var curTime = _timing.CurTime;
+
+ // Check if it's time to play the next note
+ if (curTime < ringer.NextNoteTime.Value)
+ continue;
+
+ // Play the note
+ // We only do this on the server because otherwise the sound either dupes or blends into a mess
+ // There's no easy way to figure out which player started it, so that we can exclude them from the list
+ // and play it separately with PlayLocal, so that it's actually predicted
+ if (_net.IsServer)
+ {
+ var ringerXform = Transform(uid);
+ _audio.PlayEntity(
+ GetSound(ringer.Ringtone[ringer.NoteCount]),
+ Filter.Empty().AddInRange(_xform.GetMapCoordinates(uid, ringerXform), ringer.Range),
+ uid,
+ true,
+ AudioParams.Default.WithMaxDistance(ringer.Range).WithVolume(ringer.Volume)
+ );
+ }
+
+ // Schedule next note
+ ringer.NextNoteTime = curTime + TimeSpan.FromSeconds(NoteDelay);
+ ringer.NoteCount++;
+
+ // Dirty the fields we just changed
+ DirtyFields(uid,
+ ringer,
+ null,
+ nameof(RingerComponent.NextNoteTime),
+ nameof(RingerComponent.NoteCount));
+
+ // Check if we've finished playing all notes
+ if (ringer.NoteCount >= RingtoneLength)
+ {
+ ringer.Active = false;
+ ringer.NextNoteTime = null;
+ ringer.NoteCount = 0;
+
+ DirtyFields(uid,
+ ringer,
+ null,
+ nameof(RingerComponent.Active),
+ nameof(RingerComponent.NextNoteTime),
+ nameof(RingerComponent.NoteCount));
+
+ UpdateRingerUi((uid, ringer));
+ }
+ }
+ }
+
+ #region Public API
+
+ /// <summary>
+ /// Plays the ringtone on the device with the given RingerComponent.
+ /// </summary>
+ public void RingerPlayRingtone(Entity<RingerComponent?> ent)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return;
+
+ StartRingtone((ent, ent.Comp));
+ }
+
+ /// <summary>
+ /// Toggles the ringer UI for the given entity.
+ /// </summary>
+ /// <param name="uid">The entity containing the ringer UI.</param>
+ /// <param name="actor">The entity that's interacting with the UI.</param>
+ /// <returns>True if the UI toggle was successful.</returns>
+ public bool TryToggleRingerUi(EntityUid uid, EntityUid actor)
+ {
+ UI.TryToggleUi(uid, RingerUiKey.Key, actor);
+ return true;
+ }
+
+ /// <summary>
+ /// Locks the uplink and closes the window, if its open.
+ /// </summary>
+ /// <remarks>
+ /// Will not update the PDA ui so you must do that yourself if needed.
+ /// </remarks>
+ public void LockUplink(Entity<RingerUplinkComponent?> ent)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return;
+
+ ent.Comp.Unlocked = false;
+ UI.CloseUi(ent.Owner, StoreUiKey.Key);
+ }
+
+ /// <summary>
+ /// Attempts to unlock or lock the uplink by checking the provided ringtone against the uplink code.
+ /// On the client side, for antagonists, the code check is skipped to support prediction.
+ /// On the server side, the code is always verified.
+ /// </summary>
+ /// <param name="uid">The entity with the RingerUplinkComponent.</param>
+ /// <param name="ringtone">The ringtone to check against the uplink code.</param>
+ /// <param name="user">The entity attempting to toggle the uplink. If the user is an antagonist,
+ /// the ringtone code check will be skipped on the client to allow prediction.</param>
+ /// <returns>True if the uplink state was toggled, false otherwise.</returns>
+ [PublicAPI]
+ public abstract bool TryToggleUplink(EntityUid uid, Note[] ringtone, EntityUid? user = null);
+
+ #endregion
+
+ // UI Message event handlers
+
+ /// <summary>
+ /// Handles the <see cref="RingerSetRingtoneMessage"/> from the client UI.
+ /// </summary>
+ private void OnSetRingtone(Entity<RingerComponent> ent, ref RingerSetRingtoneMessage args)
+ {
+ // Prevent ringtone spam by checking the last time this ringtone was set
+ var curTime = _timing.CurTime;
+ if (ent.Comp.NextRingtoneSetTime > curTime)
+ return;
+
+ ent.Comp.NextRingtoneSetTime = curTime + ent.Comp.Cooldown;
+ DirtyField(ent.AsNullable(), nameof(RingerComponent.NextRingtoneSetTime));
+
+ // Client sent us an updated ringtone so set it to that.
+ if (args.Ringtone.Length != RingtoneLength)
+ return;
+
+ // Try to toggle the uplink first
+ if (TryToggleUplink(ent, args.Ringtone))
+ return; // Don't save the uplink code as the ringtone
+
+ UpdateRingerRingtone(ent, args.Ringtone);
+ }
+
+ /// <summary>
+ /// Handles the <see cref="RingerPlayRingtoneMessage"/> from the client UI.
+ /// </summary>
+ private void OnPlayRingtone(Entity<RingerComponent> ent, ref RingerPlayRingtoneMessage args)
+ {
+ StartRingtone(ent);
+ }
+
+ // Helper methods
+
+ /// <summary>
+ /// Starts playing the ringtone on the device.
+ /// </summary>
+ private void StartRingtone(Entity<RingerComponent> ent)
+ {
+ // Already active? Don't start it again
+ if (ent.Comp.Active)
+ return;
+
+ ent.Comp.Active = true;
+ ent.Comp.NoteCount = 0;
+ ent.Comp.NextNoteTime = _timing.CurTime;
+
+ UpdateRingerUi(ent);
+
+ _popup.PopupPredicted(Loc.GetString("comp-ringer-vibration-popup"),
+ ent,
+ ent.Owner,
+ Filter.Pvs(ent, 0.05f),
+ false,
+ PopupType.Medium);
+
+ DirtyFields(ent.AsNullable(),
+ null,
+ nameof(RingerComponent.NextNoteTime),
+ nameof(RingerComponent.Active),
+ nameof(RingerComponent.NoteCount));
+ }
+
+ /// <summary>
+ /// Updates the ringer's ringtone and notifies clients.
+ /// </summary>
+ /// <param name="ent">Entity with RingerComponent to update.</param>
+ /// <param name="ringtone">The new ringtone to set.</param>
+ protected void UpdateRingerRingtone(Entity<RingerComponent> ent, Note[] ringtone)
+ {
+ // Assume validation has already happened.
+ ent.Comp.Ringtone = ringtone;
+ DirtyField(ent.AsNullable(), nameof(RingerComponent.Ringtone));
+ UpdateRingerUi(ent);
+ }
+
+ /// <summary>
+ /// Base implementation for toggle uplink processing after verification.
+ /// </summary>
+ protected bool ToggleUplinkInternal(Entity<RingerUplinkComponent> ent)
+ {
+ // Toggle the unlock state
+ ent.Comp.Unlocked = !ent.Comp.Unlocked;
+
+ // Update PDA UI if needed
+ if (TryComp<PdaComponent>(ent, out var pda))
+ _pda.UpdatePdaUi(ent, pda);
+
+ // Close store UI if we're locking
+ if (!ent.Comp.Unlocked)
+ UI.CloseUi(ent.Owner, StoreUiKey.Key);
+
+ return true;
+ }
+
+ /// <summary>
+ /// Helper method to determine if the mind is an antagonist.
+ /// </summary>
+ protected bool IsAntagonist(EntityUid? user)
+ {
+ return user != null && _mind.TryGetMind(user.Value, out var mindId, out _) && _role.MindIsAntagonist(mindId);
+ }
+
+ /// <summary>
+ /// Gets the sound path for a specific note.
+ /// </summary>
+ /// <param name="note">The note to get the sound for.</param>
+ /// <returns>A SoundPathSpecifier pointing to the sound file for the note.</returns>
+ private static SoundPathSpecifier GetSound(Note note)
+ {
+ return new SoundPathSpecifier($"/Audio/Effects/RingtoneNotes/{note.ToString().ToLower()}.ogg");
+ }
+
+ /// <summary>
+ /// Updates the RingerBoundUserInterface.
+ /// </summary>
+ protected virtual void UpdateRingerUi(Entity<RingerComponent> ent)
+ {
+ }
}
+/// <summary>
+/// Enum representing musical notes for ringtones.
+/// </summary>
[Serializable, NetSerializable]
public enum Note : byte
{
softness: 5
autoRot: true
- type: Ringer
+ - type: RingerUplink
- type: DeviceNetwork
deviceNetId: Wireless
receiveFrequencyId: PDA