using Content.Shared.Containers.ItemSlots; using Content.Shared.Damage; using Content.Shared.DeltaV.TapeRecorder.Components; using Content.Shared.Destructible; using Content.Shared.DoAfter; using Content.Shared.Examine; using Content.Shared.Interaction; using Content.Shared.Labels.Components; using Content.Shared.Popups; using Content.Shared.Tag; using Content.Shared.Toggleable; using Content.Shared.UserInterface; using Content.Shared.Whitelist; using Robust.Shared.Audio.Systems; using Robust.Shared.Containers; using Robust.Shared.Random; using Robust.Shared.Serialization; using Robust.Shared.Timing; using System.Diagnostics.CodeAnalysis; using System.Text; namespace Content.Shared.DeltaV.TapeRecorder.Systems; public abstract class SharedTapeRecorderSystem : EntitySystem { [Dependency] private readonly EntityWhitelistSystem _whitelist = default!; [Dependency] protected readonly IGameTiming Timing = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly SharedAppearanceSystem _appearance = default!; [Dependency] protected readonly SharedAudioSystem Audio = default!; [Dependency] private readonly SharedDoAfterSystem _doAfter = default!; [Dependency] private readonly ItemSlotsSystem _slots = default!; [Dependency] private readonly SharedPopupSystem _popup = default!; [Dependency] private readonly SharedUserInterfaceSystem _ui = default!; protected const string SlotName = "cassette_tape"; public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnCassetteRemoveAttempt); SubscribeLocalEvent(OnCassetteRemoved); SubscribeLocalEvent(OnCassetteInserted); SubscribeLocalEvent(OnRecorderExamined); SubscribeLocalEvent(OnChangeModeMessage); SubscribeLocalEvent(OnUIOpened); SubscribeLocalEvent(OnTapeExamined); SubscribeLocalEvent(OnDamagedChanged); SubscribeLocalEvent(OnInteractingWithCassette); SubscribeLocalEvent(OnTapeCassetteRepair); } /// /// Process active tape recorder modes /// public override void Update(float frameTime) { base.Update(frameTime); var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out _, out var comp)) { var ent = (uid, comp); if (!TryGetTapeCassette(uid, out var tape)) { SetMode(ent, TapeRecorderMode.Stopped); continue; } var continuing = comp.Mode switch { TapeRecorderMode.Recording => ProcessRecordingTapeRecorder(ent, frameTime), TapeRecorderMode.Playing => ProcessPlayingTapeRecorder(ent, frameTime), TapeRecorderMode.Rewinding => ProcessRewindingTapeRecorder(ent, frameTime), _ => false }; if (continuing) continue; SetMode(ent, TapeRecorderMode.Stopped); Dirty(tape); // make sure clients have the right value once it's stopped } } private void OnUIOpened(Entity ent, ref AfterActivatableUIOpenEvent args) { UpdateUI(ent); } /// /// UI message when choosing between recorder modes /// private void OnChangeModeMessage(Entity ent, ref ChangeModeTapeRecorderMessage args) { SetMode(ent, args.Mode); } /// /// Update the tape position and overwrite any messages between the previous and new position /// /// The tape recorder to process /// Number of seconds that have passed since the last call /// True if the tape recorder should continue in the current mode, False if it should switch to the Stopped mode private bool ProcessRecordingTapeRecorder(Entity ent, float frameTime) { if (!TryGetTapeCassette(ent, out var tape)) return false; var currentTime = tape.Comp.CurrentPosition + frameTime; //'Flushed' in this context is a mark indicating the message was not added between the last update and this update //Remove any flushed messages in the segment we just recorded over (ie old messages) tape.Comp.RecordedData.RemoveAll(x => x.Timestamp > tape.Comp.CurrentPosition && x.Timestamp <= currentTime); tape.Comp.RecordedData.AddRange(tape.Comp.Buffer); tape.Comp.Buffer.Clear(); //Update the tape's current time tape.Comp.CurrentPosition = (float) Math.Min(currentTime, tape.Comp.MaxCapacity.TotalSeconds); //If we have reached the end of the tape - stop return tape.Comp.CurrentPosition < tape.Comp.MaxCapacity.TotalSeconds; } /// /// Update the tape position and play any messages with timestamps between the previous and new position /// /// The tape recorder to process /// Number of seconds that have passed since the last call /// True if the tape recorder should continue in the current mode, False if it should switch to the Stopped mode private bool ProcessPlayingTapeRecorder(Entity ent, float frameTime) { if (!TryGetTapeCassette(ent, out var tape)) return false; //Get the segment of the tape to be played //And any messages within that time period var currentTime = tape.Comp.CurrentPosition + frameTime; ReplayMessagesInSegment(ent, tape.Comp, tape.Comp.CurrentPosition, currentTime); //Update the tape's position tape.Comp.CurrentPosition = (float) Math.Min(currentTime, tape.Comp.MaxCapacity.TotalSeconds); //Stop when we reach the end of the tape return tape.Comp.CurrentPosition < tape.Comp.MaxCapacity.TotalSeconds; } /// /// Update the tape position in reverse /// /// The tape recorder to process /// Number of seconds that have passed since the last call /// True if the tape recorder should continue in the current mode, False if it should switch to the Stopped mode private bool ProcessRewindingTapeRecorder(Entity ent, float frameTime) { if (!TryGetTapeCassette(ent, out var tape)) return false; //Calculate how far we have rewound var rewindTime = frameTime * ent.Comp.RewindSpeed; //Update the current time, clamp to 0 tape.Comp.CurrentPosition = Math.Max(0, tape.Comp.CurrentPosition - rewindTime); //If we have reached the beginning of the tape, stop return tape.Comp.CurrentPosition >= float.Epsilon; } /// /// Plays messages back on the server. /// Does nothing on the client. /// protected virtual void ReplayMessagesInSegment(Entity ent, TapeCassetteComponent tape, float segmentStart, float segmentEnd) { } /// /// Start repairing a damaged tape when using a screwdriver or pen on it /// protected void OnInteractingWithCassette(Entity ent, ref InteractUsingEvent args) { //Is the tape damaged? if (HasComp(ent)) return; //Are we using a valid repair tool? if (_whitelist.IsWhitelistFail(ent.Comp.RepairWhitelist, args.Used)) return; _doAfter.TryStartDoAfter(new DoAfterArgs(EntityManager, args.User, ent.Comp.RepairDelay, new TapeCassetteRepairDoAfterEvent(), ent, target: ent, used: args.Used) { BreakOnMove = true, NeedHand = true }); } /// /// Repair a damaged tape /// protected void OnTapeCassetteRepair(Entity ent, ref TapeCassetteRepairDoAfterEvent args) { if (args.Handled || args.Cancelled || args.Args.Target == null) return; //Cant repair if not damaged if (HasComp(ent)) return; _appearance.SetData(ent, ToggleVisuals.Toggled, false); AddComp(ent); args.Handled = true; } /// /// When the cassette has been damaged, corrupt and entry and unspool it /// protected void OnDamagedChanged(Entity ent, ref DamageChangedEvent args) { if (args.DamageDelta == null || args.DamageDelta.GetTotal() < 5) return; _appearance.SetData(ent, ToggleVisuals.Toggled, true); RemComp(ent); CorruptRandomEntry(ent); } protected void OnTapeExamined(Entity ent, ref ExaminedEvent args) { if (!args.IsInDetailsRange) return; if (!HasComp(ent)) { args.PushMarkup(Loc.GetString("tape-cassette-damaged")); return; } var positionPercentage = Math.Floor(ent.Comp.CurrentPosition / ent.Comp.MaxCapacity.TotalSeconds * 100); var tapePosMsg = Loc.GetString("tape-cassette-position", ("position", positionPercentage)); args.PushMarkup(tapePosMsg); } protected void OnRecorderExamined(Entity ent, ref ExaminedEvent args) { if (!args.IsInDetailsRange) return; //Check if we have a tape cassette inserted if (!TryGetTapeCassette(ent, out var tape)) { args.PushMarkup(Loc.GetString("tape-recorder-empty")); return; } var state = ent.Comp.Mode.ToString().ToLower(); args.PushMarkup(Loc.GetString("tape-recorder-" + state)); OnTapeExamined(tape, ref args); } /// /// Prevent removing the tape cassette while the recorder is active /// protected void OnCassetteRemoveAttempt(Entity ent, ref ItemSlotEjectAttemptEvent args) { if (!HasComp(ent)) return; args.Cancelled = true; } protected void OnCassetteRemoved(Entity ent, ref EntRemovedFromContainerMessage args) { SetMode(ent, TapeRecorderMode.Stopped); UpdateAppearance(ent); UpdateUI(ent); } protected void OnCassetteInserted(Entity ent, ref EntInsertedIntoContainerMessage args) { UpdateAppearance(ent); UpdateUI(ent); } /// /// Update the appearance of the tape recorder. /// /// The tape recorder to update protected void UpdateAppearance(Entity ent) { var hasCassette = TryGetTapeCassette(ent, out _); _appearance.SetData(ent, TapeRecorderVisuals.Mode, ent.Comp.Mode); _appearance.SetData(ent, TapeRecorderVisuals.TapeInserted, hasCassette); } /// /// Choose a random recorded entry on the cassette and replace some of the text with hashes /// /// protected void CorruptRandomEntry(TapeCassetteComponent tape) { if (tape.RecordedData.Count == 0) return; var entry = _random.Pick(tape.RecordedData); var corruption = Loc.GetString("tape-recorder-message-corruption"); var corruptedMessage = new StringBuilder(); foreach (var character in entry.Message) { if (_random.Prob(tape.CorruptionChance)) corruptedMessage.Append(corruption); else corruptedMessage.Append(character); } entry.Name = Loc.GetString("tape-recorder-voice-unintelligible"); entry.Message = corruptedMessage.ToString(); } /// /// Set the tape recorder mode and dirty if it is different from the previous mode /// /// The tape recorder to update /// The new mode private void SetMode(Entity ent, TapeRecorderMode mode) { if (mode == ent.Comp.Mode) return; if (mode == TapeRecorderMode.Stopped) { RemComp(ent); } else { // can't play without a tape in it... if (!TryGetTapeCassette(ent, out _)) return; EnsureComp(ent); } var sound = ent.Comp.Mode switch { TapeRecorderMode.Stopped => ent.Comp.StopSound, TapeRecorderMode.Rewinding => ent.Comp.RewindSound, _ => ent.Comp.PlaySound }; Audio.PlayPvs(sound, ent); ent.Comp.Mode = mode; Dirty(ent); UpdateUI(ent); } protected bool TryGetTapeCassette(EntityUid ent, [NotNullWhen(true)] out Entity tape) { if (_slots.GetItemOrNull(ent, SlotName) is not {} cassette) { tape = default!; return false; } if (!TryComp(cassette, out var comp)) { tape = default!; return false; } tape = new(cassette, comp); return true; } private void UpdateUI(Entity ent) { var (uid, comp) = ent; if (!_ui.IsUiOpen(uid, TapeRecorderUIKey.Key)) return; var hasCassette = TryGetTapeCassette(ent, out var tape); var hasData = false; var currentTime = 0f; var maxTime = 0f; var cassetteName = "Unnamed"; var cooldown = comp.PrintCooldown; if (hasCassette) { hasData = tape.Comp.RecordedData.Count > 0; currentTime = tape.Comp.CurrentPosition; maxTime = (float) tape.Comp.MaxCapacity.TotalSeconds; if (TryComp(tape, out var labelComp)) if (labelComp.CurrentLabel != null) cassetteName = labelComp.CurrentLabel; } var state = new TapeRecorderState( hasCassette, hasData, currentTime, maxTime, cassetteName, cooldown); _ui.SetUiState(uid, TapeRecorderUIKey.Key, state); } } [Serializable, NetSerializable] public sealed partial class TapeCassetteRepairDoAfterEvent : SimpleDoAfterEvent;