Predict and cleanup RingerComponent (#35907)

* clean up most stuff

* move to shared

* works

* shuffle shit around

* oops! access

* fixes

* todo: everything

* SUFFERING

* curse you
This commit is contained in:
Milon
2025-04-19 15:55:05 +02:00
committed by GitHub
parent e214eb7a28
commit 6138fcdce9
18 changed files with 645 additions and 405 deletions

View File

@@ -7,40 +7,21 @@ using Robust.Shared.Timing;
namespace Content.Client.PDA.Ringer namespace Content.Client.PDA.Ringer
{ {
[UsedImplicitly] [UsedImplicitly]
public sealed class RingerBoundUserInterface : BoundUserInterface public sealed class RingerBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
{ {
[ViewVariables] [ViewVariables]
private RingtoneMenu? _menu; private RingtoneMenu? _menu;
public RingerBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
{
}
protected override void Open() protected override void Open()
{ {
base.Open(); base.Open();
_menu = this.CreateWindow<RingtoneMenu>(); _menu = this.CreateWindow<RingtoneMenu>();
_menu.OpenToLeft(); _menu.OpenToLeft();
_menu.TestRingerButton.OnPressed += _ => _menu.TestRingtoneButtonPressed += OnTestRingtoneButtonPressed;
{ _menu.SetRingtoneButtonPressed += OnSetRingtoneButtonPressed;
SendMessage(new RingerPlayRingtoneMessage());
};
_menu.SetRingerButton.OnPressed += _ => Update();
{
if (!TryGetRingtone(out var ringtone))
return;
SendMessage(new RingerSetRingtoneMessage(ringtone));
_menu.SetRingerButton.Disabled = true;
Timer.Spawn(333, () =>
{
if (_menu is { Disposed: false, SetRingerButton: { Disposed: false } ringer})
ringer.Disabled = false;
});
};
} }
private bool TryGetRingtone(out Note[] ringtone) private bool TryGetRingtone(out Note[] ringtone)
@@ -63,36 +44,59 @@ namespace Content.Client.PDA.Ringer
return true; 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; 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))
if (RingtoneMenu.IsNote(note)) continue;
{
_menu.PreviousNoteInputs[i] = note.Replace("sharp", "#");
_menu.RingerNoteInputs[i].Text = _menu.PreviousNoteInputs[i];
}
_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()
protected override void Dispose(bool disposing)
{ {
base.Dispose(disposing); if (_menu is null)
if (!disposing)
return; return;
_menu?.Dispose(); 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;
}
private void OnSetRingtoneButtonPressed()
{
if (_menu is null)
return;
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;
});
} }
} }
} }

View File

@@ -0,0 +1,56 @@
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;
}
}

View File

@@ -1,7 +1,8 @@
<DefaultWindow xmlns="https://spacestation14.io" <controls:FancyWindow xmlns="https://spacestation14.io"
Title="{Loc 'comp-ringer-ui-menu-title'}" xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
MinSize="320 128" Title="{Loc 'comp-ringer-ui-menu-title'}"
SetSize="320 128"> MinSize="320 100"
SetSize="320 100">
<BoxContainer Orientation="Vertical" <BoxContainer Orientation="Vertical"
VerticalExpand="True" VerticalExpand="True"
HorizontalExpand="True" HorizontalExpand="True"
@@ -90,4 +91,4 @@
</BoxContainer> </BoxContainer>
</PanelContainer> </PanelContainer>
</BoxContainer> </BoxContainer>
</DefaultWindow> </controls:FancyWindow>

View File

@@ -1,6 +1,6 @@
using System.Numerics; using System.Numerics;
using Content.Client.UserInterface.Controls;
using Robust.Client.AutoGenerated; using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML; using Robust.Client.UserInterface.XAML;
using Content.Shared.PDA; using Content.Shared.PDA;
using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.Controls;
@@ -8,15 +8,21 @@ using Robust.Client.UserInterface.Controls;
namespace Content.Client.PDA.Ringer namespace Content.Client.PDA.Ringer
{ {
[GenerateTypedNameReferences] [GenerateTypedNameReferences]
public sealed partial class RingtoneMenu : DefaultWindow public sealed partial class RingtoneMenu : FancyWindow
{ {
public string[] PreviousNoteInputs = new[] { "A", "A", "A", "A", "A", "A" }; 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() public RingtoneMenu()
{ {
RobustXamlLoader.Load(this); RobustXamlLoader.Load(this);
SetRingerButton.OnPressed += _ => SetRingtoneButtonPressed?.Invoke();
TestRingerButton.OnPressed += _ => TestRingtoneButtonPressed?.Invoke();
RingerNoteInputs = new[] { RingerNoteOneInput, RingerNoteTwoInput, RingerNoteThreeInput, RingerNoteFourInput, RingerNoteFiveInput, RingerNoteSixInput }; RingerNoteInputs = new[] { RingerNoteOneInput, RingerNoteTwoInput, RingerNoteThreeInput, RingerNoteFourInput, RingerNoteFiveInput, RingerNoteSixInput };
for (var i = 0; i < RingerNoteInputs.Length; ++i) for (var i = 0; i < RingerNoteInputs.Length; ++i)
@@ -43,14 +49,28 @@ namespace Content.Client.PDA.Ringer
foo(); foo();
input.CursorPosition = input.Text.Length; // Resets caret position to the end of the typed input input.CursorPosition = input.Text.Length; // Resets caret position to the end of the typed input
}; };
input.OnTextChanged += _ =>
{
input.Text = input.Text.ToUpper();
if (!IsNote(input.Text)) input.OnTextChanged += args =>
{
// Convert to uppercase
var upperText = args.Text.ToUpper();
// Filter to only valid notes
var newText = upperText;
if (!IsNote(newText))
{
newText = PreviousNoteInputs[index];
input.AddStyleClass("Caution"); input.AddStyleClass("Caution");
}
else else
{
PreviousNoteInputs[index] = newText;
input.RemoveStyleClass("Caution"); input.RemoveStyleClass("Caution");
}
// Only update if there's a change
if (newText != input.Text)
input.Text = newText;
}; };
} }
} }

View File

@@ -184,13 +184,19 @@ public sealed class TraitorRuleSystem : GameRuleSystem<TraitorRuleComponent>
{ {
Log.Debug($"MakeTraitor {ToPrettyString(traitor)} - Uplink is PDA"); Log.Debug($"MakeTraitor {ToPrettyString(traitor)} - Uplink is PDA");
// Codes are only generated if the uplink is a 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 if (ev.Code is { } generatedCode)
briefing = string.Format("{0}\n{1}", {
briefing, code = generatedCode;
Loc.GetString("traitor-role-uplink-code-short", ("code", string.Join("-", code).Replace("sharp", "#"))));
return (code, briefing); // 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) else if (pda is null && uplinked)
{ {

View File

@@ -10,15 +10,16 @@ using Content.Server.Traitor.Uplink;
using Content.Shared.Access.Components; using Content.Shared.Access.Components;
using Content.Shared.CartridgeLoader; using Content.Shared.CartridgeLoader;
using Content.Shared.Chat; using Content.Shared.Chat;
using Content.Shared.DeviceNetwork.Components;
using Content.Shared.Light; using Content.Shared.Light;
using Content.Shared.Light.EntitySystems; using Content.Shared.Light.EntitySystems;
using Content.Shared.PDA; using Content.Shared.PDA;
using Content.Shared.PDA.Ringer;
using Robust.Server.Containers; using Robust.Server.Containers;
using Robust.Server.GameObjects; using Robust.Server.GameObjects;
using Robust.Shared.Containers; using Robust.Shared.Containers;
using Robust.Shared.Player; using Robust.Shared.Player;
using Robust.Shared.Utility; using Robust.Shared.Utility;
using Content.Shared.DeviceNetwork.Components;
namespace Content.Server.PDA namespace Content.Server.PDA
{ {
@@ -166,7 +167,7 @@ namespace Content.Server.PDA
/// <summary> /// <summary>
/// Send new UI state to clients, call if you modify something like uplink. /// Send new UI state to clients, call if you modify something like uplink.
/// </summary> /// </summary>
public void UpdatePdaUi(EntityUid uid, PdaComponent? pda = null) public override void UpdatePdaUi(EntityUid uid, PdaComponent? pda = null)
{ {
if (!Resolve(uid, ref pda, false)) if (!Resolve(uid, ref pda, false))
return; return;
@@ -243,7 +244,7 @@ namespace Content.Server.PDA
return; return;
if (HasComp<RingerComponent>(uid)) if (HasComp<RingerComponent>(uid))
_ringer.ToggleRingerUI(uid, msg.Actor); _ringer.TryToggleRingerUi(uid, msg.Actor);
} }
private void OnUiMessage(EntityUid uid, PdaComponent pda, PdaShowMusicMessage msg) private void OnUiMessage(EntityUid uid, PdaComponent pda, PdaShowMusicMessage msg)
@@ -272,7 +273,7 @@ namespace Content.Server.PDA
if (TryComp<RingerUplinkComponent>(uid, out var uplink)) if (TryComp<RingerUplinkComponent>(uid, out var uplink))
{ {
_ringer.LockUplink(uid, uplink); _ringer.LockUplink((uid, uplink));
UpdatePdaUi(uid, pda); UpdatePdaUi(uid, pda);
} }
} }

View File

@@ -1,36 +0,0 @@
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
{
}
}

View File

@@ -1,253 +1,131 @@
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using Content.Server.Store.Components;
using Content.Server.Store.Systems; using Content.Server.Store.Systems;
using Content.Shared.PDA; using Content.Shared.PDA;
using Content.Shared.PDA.Ringer; using Content.Shared.PDA.Ringer;
using Content.Shared.Popups;
using Content.Shared.Store;
using Content.Shared.Store.Components; 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.Random;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using Robust.Server.Audio;
namespace Content.Server.PDA.Ringer namespace Content.Server.PDA.Ringer;
/// <summary>
/// Handles the server-side logic for <see cref="SharedRingerSystem"/>.
/// </summary>
public sealed class RingerSystem : SharedRingerSystem
{ {
public sealed class RingerSystem : SharedRingerSystem [Dependency] private readonly IRobustRandom _random = default!;
/// <inheritdoc/>
public override void Initialize()
{ {
[Dependency] private readonly PdaSystem _pda = default!; base.Initialize();
[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(); SubscribeLocalEvent<RingerComponent, MapInitEvent>(OnMapInit);
SubscribeLocalEvent<RingerComponent, CurrencyInsertAttemptEvent>(OnCurrencyInsert);
public override void Initialize() SubscribeLocalEvent<RingerUplinkComponent, GenerateUplinkCodeEvent>(OnGenerateUplinkCode);
{
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;
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;
var ev = new BeforeRingtoneSetEvent(args.Ringtone);
RaiseLocalEvent(uid, ref ev);
if (ev.Handled)
return;
UpdateRingerRingtone(uid, ringer, args.Ringtone);
}
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;
uplink.Unlocked = false;
_ui.CloseUi(uid, StoreUiKey.Key);
}
public void RandomizeRingtone(EntityUid uid, RingerComponent ringer, MapInitEvent args)
{
UpdateRingerRingtone(uid, ringer, GenerateRingtone());
}
public void RandomizeUplinkCode(EntityUid uid, RingerUplinkComponent uplink, ComponentInit args)
{
uplink.Code = GenerateRingtone();
}
//Non Event Functions
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
});
}
private Note[] GenerateRingtone(Note[] notes)
{
var ringtone = new Note[RingtoneLength];
for (var i = 0; i < RingtoneLength; i++)
{
ringtone[i] = _random.Pick(notes);
}
return ringtone;
}
private bool UpdateRingerRingtone(EntityUid uid, RingerComponent ringer, Note[] ringtone)
{
// Assume validation has already happened.
ringer.Ringtone = ringtone;
UpdateRingerUserInterface(uid, ringer, HasComp<ActiveRingerComponent>(uid));
return true;
}
private void UpdateRingerUserInterface(EntityUid uid, RingerComponent ringer, bool isPlaying)
{
_ui.SetUiState(uid, RingerUiKey.Key, new RingerUpdateState(isPlaying, ringer.Ringtone));
}
public bool ToggleRingerUI(EntityUid uid, EntityUid actor)
{
_ui.TryToggleUi(uid, RingerUiKey.Key, actor);
return true;
}
public override void Update(float frameTime) //Responsible for actually playing the ringtone
{
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);
}
}
private static string GetSound(Note note)
{
return new ResPath("/Audio/Effects/RingtoneNotes/" + note.ToString().ToLower()) + ".ogg";
}
} }
[ByRefEvent] /// <summary>
public record struct BeforeRingtoneSetEvent(Note[] Ringtone, bool Handled = false); /// Randomizes a ringtone for <see cref="RingerComponent"/> on <see cref="MapInitEvent"/>.
/// </summary>
private void OnMapInit(Entity<RingerComponent> ent, ref MapInitEvent args)
{
UpdateRingerRingtone(ent, GenerateRingtone());
}
/// <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))
{
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();
}
/// <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();
// Set the code on the component
ent.Comp.Code = code;
// Return the code via the event
ev.Code = code;
}
/// <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;
// On the server, we always check if the code matches
if (!uplink.Code.SequenceEqual(ringtone))
return false;
return ToggleUplinkInternal((uid, uplink));
}
/// <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
});
}
/// <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];
for (var i = 0; i < RingtoneLength; i++)
{
ringtone[i] = _random.Pick(notes);
}
return ringtone;
}
}
/// <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;
} }

View File

@@ -1,24 +0,0 @@
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;
}

View File

@@ -1,7 +1,6 @@
using System.Linq; using System.Linq;
using Content.Server.Actions; using Content.Server.Actions;
using Content.Server.Administration.Logs; using Content.Server.Administration.Logs;
using Content.Server.PDA.Ringer;
using Content.Server.Stack; using Content.Server.Stack;
using Content.Server.Store.Components; using Content.Server.Store.Components;
using Content.Shared.Actions; using Content.Shared.Actions;
@@ -9,6 +8,7 @@ using Content.Shared.Database;
using Content.Shared.FixedPoint; using Content.Shared.FixedPoint;
using Content.Shared.Hands.EntitySystems; using Content.Shared.Hands.EntitySystems;
using Content.Shared.Mind; using Content.Shared.Mind;
using Content.Shared.PDA.Ringer;
using Content.Shared.Store; using Content.Shared.Store;
using Content.Shared.Store.Components; using Content.Shared.Store.Components;
using Content.Shared.UserInterface; using Content.Shared.UserInterface;

View File

@@ -0,0 +1,57 @@
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;
}

View File

@@ -1,26 +1,17 @@
using Robust.Shared.Serialization; using Robust.Shared.Serialization;
namespace Content.Shared.PDA.Ringer namespace Content.Shared.PDA.Ringer;
[Serializable, NetSerializable]
public sealed class RingerPlayRingtoneMessage : BoundUserInterfaceMessage;
[Serializable, NetSerializable]
public sealed class RingerSetRingtoneMessage : BoundUserInterfaceMessage
{ {
public Note[] Ringtone { get; }
[Serializable, NetSerializable] public RingerSetRingtoneMessage(Note[] ringTone)
public sealed class RingerRequestUpdateInterfaceMessage : BoundUserInterfaceMessage
{ {
} Ringtone = ringTone;
[Serializable, NetSerializable]
public sealed class RingerPlayRingtoneMessage : BoundUserInterfaceMessage
{
}
[Serializable, NetSerializable]
public sealed class RingerSetRingtoneMessage : BoundUserInterfaceMessage
{
public Note[] Ringtone { get; }
public RingerSetRingtoneMessage(Note[] ringTone)
{
Ringtone = ringTone;
}
} }
} }

View File

@@ -1,18 +0,0 @@
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;
}
}
}

View File

@@ -0,0 +1,24 @@
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;
}

View File

@@ -1,11 +1,9 @@
using Robust.Shared.Serialization; using Robust.Shared.Serialization;
namespace Content.Shared.PDA.Ringer namespace Content.Shared.PDA.Ringer;
{
[Serializable, NetSerializable]
public enum RingerUiKey
{
Key
}
[Serializable, NetSerializable]
public enum RingerUiKey : byte
{
Key,
} }

View File

@@ -66,5 +66,11 @@ namespace Content.Shared.PDA
{ {
Appearance.SetData(uid, PdaVisuals.IdCardInserted, pda.ContainedId != null); 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
}
} }
} }

View File

@@ -1,14 +1,289 @@
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.Serialization;
using Robust.Shared.Timing;
namespace Content.Shared.PDA; namespace Content.Shared.PDA;
/// <summary>
/// Handles the shared functionality for PDA ringtones.
/// </summary>
public abstract class SharedRingerSystem : EntitySystem public abstract class SharedRingerSystem : EntitySystem
{ {
public const int RingtoneLength = 6; public const int RingtoneLength = 6;
public const int NoteTempo = 300; public const int NoteTempo = 300;
public const float NoteDelay = 60f / NoteTempo; 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] [Serializable, NetSerializable]
public enum Note : byte public enum Note : byte
{ {

View File

@@ -68,6 +68,7 @@
softness: 5 softness: 5
autoRot: true autoRot: true
- type: Ringer - type: Ringer
- type: RingerUplink
- type: DeviceNetwork - type: DeviceNetwork
deviceNetId: Wireless deviceNetId: Wireless
receiveFrequencyId: PDA receiveFrequencyId: PDA