mirror of
https://github.com/DeltaV-Station/Delta-v.git
synced 2026-10-04 05:17:28 +00:00
+24









Debug
deltanedas
metalgearsloth
Nemanja
PJBot
Psychpsyo
deltanedas
metalgearsloth
ravage
Pieter-Jan Briers
LEVELcat
Repo
nikthechampiongr
Doru991
DrSmugleaf
Skarletto
NULL882
lunarcomets
Miro Kavaliou
Kara
Kacper Urbańczyk
TsjipTsjip
daerSeebaer
Fluffiest Floofers
Flareguy
Leon Friedrich
Kevin Zheng
drteaspoon420
Ubaser
Nim
Kacper Urbańczyk
Tox Cruize
Ed
Michael Cu
fd2cc8570d
* Automatic changelog update * Make NPC names proper nouns & fix some genders (#20534) * Proper names & genders * Uppercase proper names * Make Smile female * fix bingus wrinkly head (#20531) * rouny meat and steak (#20526) * lost friendship flavor * add rouny steak * rouny special meat * rouny meat textures --------- Co-authored-by: deltanedas <@deltanedas:kde.org> * Automatic changelog update * Wearable Wet Floor Sign and 'Janitorial Suicide Vest' (#20311) * Explosive wet floor sign & janitorial suicide vest * fix attributions * Remove name & desc from explosive wet floor sign * Make wet floor sign chameleonable * Automatic changelog update * Minor slippery stuff (#20535) * Update submodule to 162.2.0 (#20570) * Predicted armor (#20560) * clean up some lines in smile the slime prototype (#20552) * Revert "Use full file path for temp replays (#19002)" (#20545) * Add EyesGlasses into ClothesMate (#20523) * Automatic changelog update * Fix Punpun crew monitor sensor (#20484) * Automatic changelog update * EasyPry airlocks for arrivals. Now also prying refactor I guess (#19394) Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com> * Automatic changelog update * Make arcades hackable again (#20555) * Automatic changelog update * Health alert tweaks (#20557) * Automatic changelog update * Add active and ended game rule components, generic TryRoundStartAttempt and minPlayers field (#20564) * Improve active game rule querying, add generic try round start attempt method, move minPlayers to GameRuleComponent * Nukeops todo and cleanup * Remove Active field * Add EndedGameRuleComponent * bartender suit (#20521) * give me a drink bartender * guh * aARG * Necropolis and mine walls (#20578) * Automatic changelog update * archaic accent tweaks (#20567) * Automatic changelog update * Add confirmation to kick and respawn in the admin player actions panel (#20542) * Remove windows-latest CI runs, remove unused build-test-release.yml (#20540) * Change .editorconfig to keep existing attribute arrangement (#20538) * Automatic changelog update * Catch replay start and end errors on round restarts (#20565) * Update trivial components to use auto comp states (#20539) * Fix role unbans not applying in real time (#20547) * Disable AHelp buttons when no player is selected, update button styling (#20568) * Automatic changelog update * Added a toggle fullscreen button (default F11) (#20272) * Added a toggle fullscreen button (default F11) * Removed un-needed comments * Review Requested Changes * Fixed Acidental Spacing Change * bwoink, removed extraneous code * nothing, litterally * Automatic changelog update * Fix not networking markings (#20588) * Automatic changelog update * Update RobustToolbox to v162.2.1 (#20590) * Automatic changelog update * Glorfcode (force say on damage/stun/crit) (#20562) * Automatic changelog update * Fix AHelp progressively showing more AHelp panels (#20591) * Automatic changelog update * caninsert entitystorage tweaks (#20589) * Automatic changelog update * Fix mapping actions crashing on load (#20592) * Fix loading a map with a ThirstComponent crashing the game (#20594) * Fix electrocution displaying real name of disguised players (#20603) * Organ fixes (#20488) * Automatic changelog update * Kettle medical rework (#20435) * Initial try at medical rework for kettle * Re-add decal decorations to Kettle's medical * Add more lockers to kettle's med * Actually upload kettle's map, not its proto * Automatic changelog update * Added blocked visuals to volumetric pump (#20610) * Automatic changelog update * Move TimedDespawn to engine (#20515) * Update submodule to 163.0.0 (#20616) * Remove v0.1 version number from local main menu screen (#20617) * Remove cloneData parameter from AutoNetworkedField (#20596) * Update submodule to 164.0.0 (#20618) * fix cognizine ghost role (#20632) Co-authored-by: deltanedas <@deltanedas:kde.org> * Automatic changelog update * Move ID layer one pixel to correct alignment (#20630) * Update nukie hardsuit descriptions (#20529) * Make holofans destructable (#20445) * Automatic changelog update * bowl is open (#20453) Co-authored-by: deltanedas <@deltanedas:kde.org> * Add TestPair.WaitCommand() (#20615) * Rename ThreatPrototype and mark fields as required (#20611) * Adjust hard bomb shape (#20608) * dragon refactor, objectives and use GenericAntag (#20201) Co-authored-by: deltanedas <@deltanedas:kde.org> * Automatic changelog update * Added generic empty liquids tank (#20563) * Move view variables verb to the top of the list with no category and localize it (#20546) * saltern update (#20325) Co-authored-by: deltanedas <@deltanedas:kde.org> * Fix followers leaking (#20643) * Add Winter Boots (#20622) * add * fix prototype * Add QM mantle (#20621) * add * fix prototype * Automatic changelog update * Slime mobs breathe nitrogen and resprite their organs (#20577) * Slimes breathe nitrogen and resprite their organs * ups * mmm * Automatic changelog update * Fix the new lizard horn's consistency + issue (#20620) * fix * add * Revert "add" This reverts commit a054a3204a8f185a94ceb80b1bd3bc9f30423711. * Add RandomHumanoidAppearance component to for space ninjas (#20605) * Automatic changelog update * Space cat breathes space (#20550) * Space cat breathes space Made Space Cat lungs ROBUST * Made Space Cat's lungs ROBUST 2.0 * Automatic changelog update * Wide anomaly locator (#20581) * Add files via upload * Add files via upload * Add files via upload * Add files via upload * add textures * fix encoding * fix 2 * Automatic changelog update * Fixed Telescopic Shield Lighting (#20650) * Fixed Telescopic Shield Lighting Bug fix #20199 * no need for these at all --------- Co-authored-by: Nemanja <98561806+EmoGarbage404@users.noreply.github.com> * Automatic changelog update * Add a special hardsuit for nukie medic (#20471) * Automatic changelog update * Carry over other mutations when doing species mutation (#20551) * Automatic changelog update * Add Spanish accent to poncho and sombrero (#20377) --------- Co-authored-by: PJBot <pieterjan.briers+bot@gmail.com> Co-authored-by: Psychpsyo <60073468+Psychpsyo@users.noreply.github.com> Co-authored-by: deltanedas <39013340+deltanedas@users.noreply.github.com> Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com> Co-authored-by: Nemanja <98561806+EmoGarbage404@users.noreply.github.com> Co-authored-by: ravage <142820619+ravage123321@users.noreply.github.com> Co-authored-by: Pieter-Jan Briers <pieterjan.briers+git@gmail.com> Co-authored-by: LEVELcat <68501903+LEVELcat@users.noreply.github.com> Co-authored-by: Repo <47093363+Titian3@users.noreply.github.com> Co-authored-by: nikthechampiongr <32041239+nikthechampiongr@users.noreply.github.com> Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com> Co-authored-by: Doru991 <75124791+Doru991@users.noreply.github.com> Co-authored-by: DrSmugleaf <DrSmugleaf@users.noreply.github.com> Co-authored-by: Skarletto <122584947+Skarletto@users.noreply.github.com> Co-authored-by: NULL882 <104377798+NULL882@users.noreply.github.com> Co-authored-by: lunarcomets <140772713+lunarcomets@users.noreply.github.com> Co-authored-by: Miro Kavaliou <miraslauk@gmail.com> Co-authored-by: Kara <lunarautomaton6@gmail.com> Co-authored-by: Kacper Urbańczyk <kacperjaroslawurbanczyk@gmail.com> Co-authored-by: TsjipTsjip <19798667+TsjipTsjip@users.noreply.github.com> Co-authored-by: daerSeebaer <61566539+daerSeebaer@users.noreply.github.com> Co-authored-by: Fluffiest Floofers <thebluewulf@gmail.com> Co-authored-by: Flareguy <78941145+Flareguy@users.noreply.github.com> Co-authored-by: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com> Co-authored-by: Kevin Zheng <kevinz5000@gmail.com> Co-authored-by: drteaspoon420 <87363733+drteaspoon420@users.noreply.github.com> Co-authored-by: Ubaser <134914314+UbaserB@users.noreply.github.com> Co-authored-by: Nim <128169402+Nimfar11@users.noreply.github.com> Co-authored-by: Kacper Urbańczyk <mikrel071204@gmail.com> Co-authored-by: Tox Cruize <141375638+TexCruize@users.noreply.github.com> Co-authored-by: Ed <96445749+TheShuEd@users.noreply.github.com> Co-authored-by: Michael Cu <43478115+michaelcu@users.noreply.github.com>
910 lines
31 KiB
C#
910 lines
31 KiB
C#
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Numerics;
|
|
using Content.Client.Administration.Managers;
|
|
using Content.Client.Chat;
|
|
using Content.Client.Chat.Managers;
|
|
using Content.Client.Chat.TypingIndicator;
|
|
using Content.Client.Chat.UI;
|
|
using Content.Client.Examine;
|
|
using Content.Client.Gameplay;
|
|
using Content.Client.Ghost;
|
|
using Content.Client.Lobby.UI;
|
|
using Content.Client.UserInterface.Screens;
|
|
using Content.Client.UserInterface.Systems.Chat.Widgets;
|
|
using Content.Client.UserInterface.Systems.Gameplay;
|
|
using Content.Shared.Administration;
|
|
using Content.Shared.CCVar;
|
|
using Content.Shared.Chat;
|
|
using Content.Shared.Damage.ForceSay;
|
|
using Content.Shared.Examine;
|
|
using Content.Shared.Input;
|
|
using Content.Shared.Radio;
|
|
using Robust.Client.Graphics;
|
|
using Robust.Client.Input;
|
|
using Robust.Client.Player;
|
|
using Robust.Client.State;
|
|
using Robust.Client.UserInterface;
|
|
using Robust.Client.UserInterface.Controllers;
|
|
using Robust.Client.UserInterface.Controls;
|
|
using Robust.Shared.Configuration;
|
|
using Robust.Shared.Input.Binding;
|
|
using Robust.Shared.Map;
|
|
using Robust.Shared.Network;
|
|
using Robust.Shared.Random;
|
|
using Robust.Shared.Replays;
|
|
using Robust.Shared.Timing;
|
|
using Robust.Shared.Utility;
|
|
|
|
namespace Content.Client.UserInterface.Systems.Chat;
|
|
|
|
public sealed class ChatUIController : UIController
|
|
{
|
|
[Dependency] private readonly IClientAdminManager _admin = default!;
|
|
[Dependency] private readonly IChatManager _manager = default!;
|
|
[Dependency] private readonly IConfigurationManager _config = default!;
|
|
[Dependency] private readonly IEyeManager _eye = default!;
|
|
[Dependency] private readonly IInputManager _input = default!;
|
|
[Dependency] private readonly IClientNetManager _net = default!;
|
|
[Dependency] private readonly IPlayerManager _player = default!;
|
|
[Dependency] private readonly IStateManager _state = default!;
|
|
[Dependency] private readonly IGameTiming _timing = default!;
|
|
[Dependency] private readonly IReplayRecordingManager _replayRecording = default!;
|
|
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
|
|
|
[UISystemDependency] private readonly ExamineSystem? _examine = default;
|
|
[UISystemDependency] private readonly GhostSystem? _ghost = default;
|
|
[UISystemDependency] private readonly TypingIndicatorSystem? _typingIndicator = default;
|
|
[UISystemDependency] private readonly ChatSystem? _chatSys = default;
|
|
|
|
private ISawmill _sawmill = default!;
|
|
|
|
public static readonly Dictionary<char, ChatSelectChannel> PrefixToChannel = new()
|
|
{
|
|
{SharedChatSystem.LocalPrefix, ChatSelectChannel.Local},
|
|
{SharedChatSystem.WhisperPrefix, ChatSelectChannel.Whisper},
|
|
{SharedChatSystem.ConsolePrefix, ChatSelectChannel.Console},
|
|
{SharedChatSystem.LOOCPrefix, ChatSelectChannel.LOOC},
|
|
{SharedChatSystem.OOCPrefix, ChatSelectChannel.OOC},
|
|
{SharedChatSystem.EmotesPrefix, ChatSelectChannel.Emotes},
|
|
{SharedChatSystem.EmotesAltPrefix, ChatSelectChannel.Emotes},
|
|
{SharedChatSystem.AdminPrefix, ChatSelectChannel.Admin},
|
|
{SharedChatSystem.RadioCommonPrefix, ChatSelectChannel.Radio},
|
|
{SharedChatSystem.DeadPrefix, ChatSelectChannel.Dead}
|
|
};
|
|
|
|
public static readonly Dictionary<ChatSelectChannel, char> ChannelPrefixes = new()
|
|
{
|
|
{ChatSelectChannel.Local, SharedChatSystem.LocalPrefix},
|
|
{ChatSelectChannel.Whisper, SharedChatSystem.WhisperPrefix},
|
|
{ChatSelectChannel.Console, SharedChatSystem.ConsolePrefix},
|
|
{ChatSelectChannel.LOOC, SharedChatSystem.LOOCPrefix},
|
|
{ChatSelectChannel.OOC, SharedChatSystem.OOCPrefix},
|
|
{ChatSelectChannel.Emotes, SharedChatSystem.EmotesPrefix},
|
|
{ChatSelectChannel.Admin, SharedChatSystem.AdminPrefix},
|
|
{ChatSelectChannel.Radio, SharedChatSystem.RadioCommonPrefix},
|
|
{ChatSelectChannel.Dead, SharedChatSystem.DeadPrefix}
|
|
};
|
|
|
|
/// <summary>
|
|
/// The max amount of chars allowed to fit in a single speech bubble.
|
|
/// </summary>
|
|
private const int SingleBubbleCharLimit = 100;
|
|
|
|
/// <summary>
|
|
/// Base queue delay each speech bubble has.
|
|
/// </summary>
|
|
private const float BubbleDelayBase = 0.2f;
|
|
|
|
/// <summary>
|
|
/// Factor multiplied by speech bubble char length to add to delay.
|
|
/// </summary>
|
|
private const float BubbleDelayFactor = 0.8f / SingleBubbleCharLimit;
|
|
|
|
/// <summary>
|
|
/// The max amount of speech bubbles over a single entity at once.
|
|
/// </summary>
|
|
private const int SpeechBubbleCap = 4;
|
|
|
|
private LayoutContainer _speechBubbleRoot = default!;
|
|
|
|
/// <summary>
|
|
/// Speech bubbles that are currently visible on screen.
|
|
/// We track them to push them up when new ones get added.
|
|
/// </summary>
|
|
private readonly Dictionary<EntityUid, List<SpeechBubble>> _activeSpeechBubbles =
|
|
new();
|
|
|
|
/// <summary>
|
|
/// Speech bubbles that are to-be-sent because of the "rate limit" they have.
|
|
/// </summary>
|
|
private readonly Dictionary<EntityUid, SpeechBubbleQueueData> _queuedSpeechBubbles
|
|
= new();
|
|
|
|
private readonly HashSet<ChatBox> _chats = new();
|
|
public IReadOnlySet<ChatBox> Chats => _chats;
|
|
|
|
/// <summary>
|
|
/// The max amount of characters an entity can send in one message
|
|
/// </summary>
|
|
public int MaxMessageLength => _config.GetCVar(CCVars.ChatMaxMessageLength);
|
|
|
|
/// <summary>
|
|
/// For currently disabled chat filters,
|
|
/// unread messages (messages received since the channel has been filtered out).
|
|
/// </summary>
|
|
private readonly Dictionary<ChatChannel, int> _unreadMessages = new();
|
|
|
|
public readonly List<(GameTick, ChatMessage)> History = new();
|
|
|
|
// Maintains which channels a client should be able to filter (for showing in the chatbox)
|
|
// and select (for attempting to send on).
|
|
// This may not always actually match with what the server will actually allow them to
|
|
// send / receive on, it is only what the user can select in the UI. For example,
|
|
// if a user is silenced from speaking for some reason this may still contain ChatChannel.Local, it is left up
|
|
// to the server to handle invalid attempts to use particular channels and not send messages for
|
|
// channels the user shouldn't be able to hear.
|
|
//
|
|
// Note that Command is an available selection in the chatbox channel selector,
|
|
// which is not actually a chat channel but is always available.
|
|
public ChatSelectChannel CanSendChannels { get; private set; }
|
|
public ChatChannel FilterableChannels { get; private set; }
|
|
public ChatSelectChannel SelectableChannels { get; private set; }
|
|
private ChatSelectChannel PreferredChannel { get; set; } = ChatSelectChannel.OOC;
|
|
|
|
public event Action<ChatSelectChannel>? CanSendChannelsChanged;
|
|
public event Action<ChatChannel>? FilterableChannelsChanged;
|
|
public event Action<ChatSelectChannel>? SelectableChannelsChanged;
|
|
public event Action<ChatChannel, int?>? UnreadMessageCountsUpdated;
|
|
public event Action<ChatMessage>? MessageAdded;
|
|
|
|
public override void Initialize()
|
|
{
|
|
_sawmill = Logger.GetSawmill("chat");
|
|
_sawmill.Level = LogLevel.Info;
|
|
_admin.AdminStatusUpdated += UpdateChannelPermissions;
|
|
_player.LocalPlayerChanged += OnLocalPlayerChanged;
|
|
_state.OnStateChanged += StateChanged;
|
|
_net.RegisterNetMessage<MsgChatMessage>(OnChatMessage);
|
|
SubscribeNetworkEvent<DamageForceSayEvent>(OnDamageForceSay);
|
|
|
|
_speechBubbleRoot = new LayoutContainer();
|
|
|
|
OnLocalPlayerChanged(new LocalPlayerChangedEventArgs(null, _player.LocalPlayer));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusChat,
|
|
InputCmdHandler.FromDelegate(_ => FocusChat()));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusLocalChat,
|
|
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Local)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusEmote,
|
|
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Emotes)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusWhisperChat,
|
|
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Whisper)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusLOOC,
|
|
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.LOOC)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusOOC,
|
|
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.OOC)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusAdminChat,
|
|
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Admin)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusRadio,
|
|
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Radio)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusDeadChat,
|
|
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Dead)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.FocusConsoleChat,
|
|
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Console)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.CycleChatChannelForward,
|
|
InputCmdHandler.FromDelegate(_ => CycleChatChannel(true)));
|
|
|
|
_input.SetInputCommand(ContentKeyFunctions.CycleChatChannelBackward,
|
|
InputCmdHandler.FromDelegate(_ => CycleChatChannel(false)));
|
|
|
|
var gameplayStateLoad = UIManager.GetUIController<GameplayStateLoadController>();
|
|
gameplayStateLoad.OnScreenLoad += OnScreenLoad;
|
|
gameplayStateLoad.OnScreenUnload += OnScreenUnload;
|
|
}
|
|
|
|
public void OnScreenLoad()
|
|
{
|
|
SetMainChat(true);
|
|
|
|
var viewportContainer = UIManager.ActiveScreen!.FindControl<LayoutContainer>("ViewportContainer");
|
|
SetSpeechBubbleRoot(viewportContainer);
|
|
}
|
|
|
|
public void OnScreenUnload()
|
|
{
|
|
SetMainChat(false);
|
|
}
|
|
|
|
public void SetMainChat(bool setting)
|
|
{
|
|
if (UIManager.ActiveScreen == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ChatBox chatBox;
|
|
string? chatSizeRaw;
|
|
|
|
switch (UIManager.ActiveScreen)
|
|
{
|
|
case DefaultGameScreen defaultScreen:
|
|
chatBox = defaultScreen.ChatBox;
|
|
chatSizeRaw = _config.GetCVar(CCVars.DefaultScreenChatSize);
|
|
SetChatSizing(chatSizeRaw, defaultScreen, setting);
|
|
break;
|
|
case SeparatedChatGameScreen separatedScreen:
|
|
chatBox = separatedScreen.ChatBox;
|
|
chatSizeRaw = _config.GetCVar(CCVars.SeparatedScreenChatSize);
|
|
SetChatSizing(chatSizeRaw, separatedScreen, setting);
|
|
break;
|
|
default:
|
|
// this could be better?
|
|
var maybeChat = UIManager.ActiveScreen.GetWidget<ChatBox>();
|
|
|
|
chatBox = maybeChat ?? throw new Exception("Cannot get chat box in screen!");
|
|
|
|
break;
|
|
}
|
|
|
|
chatBox.Main = setting;
|
|
}
|
|
|
|
private void SetChatSizing(string sizing, InGameScreen screen, bool setting)
|
|
{
|
|
if (!setting)
|
|
{
|
|
screen.OnChatResized -= StoreChatSize;
|
|
return;
|
|
}
|
|
|
|
screen.OnChatResized += StoreChatSize;
|
|
|
|
if (string.IsNullOrEmpty(sizing))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var split = sizing.Split(",");
|
|
|
|
var chatSize = new Vector2(
|
|
float.Parse(split[0], CultureInfo.InvariantCulture),
|
|
float.Parse(split[1], CultureInfo.InvariantCulture));
|
|
|
|
|
|
screen.SetChatSize(chatSize);
|
|
}
|
|
|
|
private void StoreChatSize(Vector2 size)
|
|
{
|
|
if (UIManager.ActiveScreen == null)
|
|
{
|
|
throw new Exception("Cannot get active screen!");
|
|
}
|
|
|
|
var stringSize = $"{size.X},{size.Y}";
|
|
switch (UIManager.ActiveScreen)
|
|
{
|
|
case DefaultGameScreen _:
|
|
_config.SetCVar(CCVars.DefaultScreenChatSize, stringSize);
|
|
break;
|
|
case SeparatedChatGameScreen _:
|
|
_config.SetCVar(CCVars.SeparatedScreenChatSize, stringSize);
|
|
break;
|
|
default:
|
|
// do nothing
|
|
return;
|
|
}
|
|
|
|
_config.SaveToFile();
|
|
}
|
|
|
|
private void FocusChat()
|
|
{
|
|
foreach (var chat in _chats)
|
|
{
|
|
if (!chat.Main)
|
|
continue;
|
|
|
|
chat.Focus();
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void FocusChannel(ChatSelectChannel channel)
|
|
{
|
|
foreach (var chat in _chats)
|
|
{
|
|
if (!chat.Main)
|
|
continue;
|
|
|
|
chat.Focus(channel);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void CycleChatChannel(bool forward)
|
|
{
|
|
foreach (var chat in _chats)
|
|
{
|
|
if (!chat.Main)
|
|
continue;
|
|
|
|
chat.CycleChatChannel(forward);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void StateChanged(StateChangedEventArgs args)
|
|
{
|
|
if (args.NewState is GameplayState)
|
|
{
|
|
PreferredChannel = ChatSelectChannel.Local;
|
|
}
|
|
|
|
UpdateChannelPermissions();
|
|
}
|
|
|
|
public void SetSpeechBubbleRoot(LayoutContainer root)
|
|
{
|
|
_speechBubbleRoot.Orphan();
|
|
root.AddChild(_speechBubbleRoot);
|
|
LayoutContainer.SetAnchorPreset(_speechBubbleRoot, LayoutContainer.LayoutPreset.Wide);
|
|
_speechBubbleRoot.SetPositionLast();
|
|
}
|
|
|
|
private void OnLocalPlayerChanged(LocalPlayerChangedEventArgs obj)
|
|
{
|
|
if (obj.OldPlayer != null)
|
|
{
|
|
obj.OldPlayer.EntityAttached -= OnLocalPlayerEntityAttached;
|
|
obj.OldPlayer.EntityDetached -= OnLocalPlayerEntityDetached;
|
|
}
|
|
|
|
if (obj.NewPlayer != null)
|
|
{
|
|
obj.NewPlayer.EntityAttached += OnLocalPlayerEntityAttached;
|
|
obj.NewPlayer.EntityDetached += OnLocalPlayerEntityDetached;
|
|
}
|
|
|
|
UpdateChannelPermissions();
|
|
}
|
|
|
|
private void OnLocalPlayerEntityAttached(EntityAttachedEventArgs obj)
|
|
{
|
|
UpdateChannelPermissions();
|
|
}
|
|
|
|
private void OnLocalPlayerEntityDetached(EntityDetachedEventArgs obj)
|
|
{
|
|
UpdateChannelPermissions();
|
|
}
|
|
|
|
private void AddSpeechBubble(ChatMessage msg, SpeechBubble.SpeechType speechType)
|
|
{
|
|
var ent = EntityManager.GetEntity(msg.SenderEntity);
|
|
|
|
if (!EntityManager.EntityExists(ent))
|
|
{
|
|
_sawmill.Debug("Got local chat message with invalid sender entity: {0}", msg.SenderEntity);
|
|
return;
|
|
}
|
|
|
|
// msg.Message should be the string that a user sent over text, without any added markup.
|
|
var messages = SplitMessage(msg.Message);
|
|
|
|
foreach (var message in messages)
|
|
{
|
|
EnqueueSpeechBubble(ent, message, speechType);
|
|
}
|
|
}
|
|
|
|
private void CreateSpeechBubble(EntityUid entity, SpeechBubbleData speechData)
|
|
{
|
|
var bubble =
|
|
SpeechBubble.CreateSpeechBubble(speechData.Type, speechData.Message, entity, _eye, _manager, EntityManager);
|
|
|
|
bubble.OnDied += SpeechBubbleDied;
|
|
|
|
if (_activeSpeechBubbles.TryGetValue(entity, out var existing))
|
|
{
|
|
// Push up existing bubbles above the mob's head.
|
|
foreach (var existingBubble in existing)
|
|
{
|
|
existingBubble.VerticalOffset += bubble.ContentSize.Y;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
existing = new List<SpeechBubble>();
|
|
_activeSpeechBubbles.Add(entity, existing);
|
|
}
|
|
|
|
existing.Add(bubble);
|
|
_speechBubbleRoot.AddChild(bubble);
|
|
|
|
if (existing.Count > SpeechBubbleCap)
|
|
{
|
|
// Get the oldest to start fading fast.
|
|
var last = existing[0];
|
|
last.FadeNow();
|
|
}
|
|
}
|
|
|
|
private void SpeechBubbleDied(EntityUid entity, SpeechBubble bubble)
|
|
{
|
|
RemoveSpeechBubble(entity, bubble);
|
|
}
|
|
|
|
private void EnqueueSpeechBubble(EntityUid entity, string contents, SpeechBubble.SpeechType speechType)
|
|
{
|
|
// Don't enqueue speech bubbles for other maps. TODO: Support multiple viewports/maps?
|
|
if (EntityManager.GetComponent<TransformComponent>(entity).MapID != _eye.CurrentMap)
|
|
return;
|
|
|
|
if (!_queuedSpeechBubbles.TryGetValue(entity, out var queueData))
|
|
{
|
|
queueData = new SpeechBubbleQueueData();
|
|
_queuedSpeechBubbles.Add(entity, queueData);
|
|
}
|
|
|
|
queueData.MessageQueue.Enqueue(new SpeechBubbleData(contents, speechType));
|
|
}
|
|
|
|
public void RemoveSpeechBubble(EntityUid entityUid, SpeechBubble bubble)
|
|
{
|
|
bubble.Dispose();
|
|
|
|
var list = _activeSpeechBubbles[entityUid];
|
|
list.Remove(bubble);
|
|
|
|
if (list.Count == 0)
|
|
{
|
|
_activeSpeechBubbles.Remove(entityUid);
|
|
}
|
|
}
|
|
|
|
private void UpdateChannelPermissions()
|
|
{
|
|
CanSendChannels = default;
|
|
FilterableChannels = default;
|
|
|
|
// Can always send console stuff.
|
|
CanSendChannels |= ChatSelectChannel.Console;
|
|
|
|
// can always send/recieve OOC
|
|
CanSendChannels |= ChatSelectChannel.OOC;
|
|
CanSendChannels |= ChatSelectChannel.LOOC;
|
|
FilterableChannels |= ChatChannel.OOC;
|
|
FilterableChannels |= ChatChannel.LOOC;
|
|
|
|
// can always hear server (nobody can actually send server messages).
|
|
FilterableChannels |= ChatChannel.Server;
|
|
|
|
if (_state.CurrentState is GameplayStateBase)
|
|
{
|
|
// can always hear local / radio / emote when in the game
|
|
FilterableChannels |= ChatChannel.Local;
|
|
FilterableChannels |= ChatChannel.Whisper;
|
|
FilterableChannels |= ChatChannel.Radio;
|
|
FilterableChannels |= ChatChannel.Emotes;
|
|
|
|
// Can only send local / radio / emote when attached to a non-ghost entity.
|
|
// TODO: this logic is iffy (checking if controlling something that's NOT a ghost), is there a better way to check this?
|
|
if (_ghost is not {IsGhost: true})
|
|
{
|
|
CanSendChannels |= ChatSelectChannel.Local;
|
|
CanSendChannels |= ChatSelectChannel.Whisper;
|
|
CanSendChannels |= ChatSelectChannel.Radio;
|
|
CanSendChannels |= ChatSelectChannel.Emotes;
|
|
}
|
|
}
|
|
|
|
// Only ghosts and admins can send / see deadchat.
|
|
if (_admin.HasFlag(AdminFlags.Admin) || _ghost is {IsGhost: true})
|
|
{
|
|
FilterableChannels |= ChatChannel.Dead;
|
|
CanSendChannels |= ChatSelectChannel.Dead;
|
|
}
|
|
|
|
// only admins can see / filter asay
|
|
if (_admin.HasFlag(AdminFlags.Admin))
|
|
{
|
|
FilterableChannels |= ChatChannel.Admin;
|
|
FilterableChannels |= ChatChannel.AdminAlert;
|
|
FilterableChannels |= ChatChannel.AdminChat;
|
|
CanSendChannels |= ChatSelectChannel.Admin;
|
|
}
|
|
|
|
SelectableChannels = CanSendChannels;
|
|
|
|
// Necessary so that we always have a channel to fall back to.
|
|
DebugTools.Assert((CanSendChannels & ChatSelectChannel.OOC) != 0, "OOC must always be available");
|
|
DebugTools.Assert((FilterableChannels & ChatChannel.OOC) != 0, "OOC must always be available");
|
|
DebugTools.Assert((SelectableChannels & ChatSelectChannel.OOC) != 0, "OOC must always be available");
|
|
|
|
// let our chatbox know all the new settings
|
|
CanSendChannelsChanged?.Invoke(CanSendChannels);
|
|
FilterableChannelsChanged?.Invoke(FilterableChannels);
|
|
SelectableChannelsChanged?.Invoke(SelectableChannels);
|
|
}
|
|
|
|
public void ClearUnfilteredUnreads(ChatChannel channels)
|
|
{
|
|
foreach (var channel in _unreadMessages.Keys.ToArray())
|
|
{
|
|
if ((channels & channel) == 0)
|
|
continue;
|
|
|
|
_unreadMessages[channel] = 0;
|
|
UnreadMessageCountsUpdated?.Invoke(channel, 0);
|
|
}
|
|
}
|
|
|
|
public override void FrameUpdate(FrameEventArgs delta)
|
|
{
|
|
UpdateQueuedSpeechBubbles(delta);
|
|
}
|
|
|
|
private void UpdateQueuedSpeechBubbles(FrameEventArgs delta)
|
|
{
|
|
// Update queued speech bubbles.
|
|
if (_queuedSpeechBubbles.Count == 0 || _examine == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var (entity, queueData) in _queuedSpeechBubbles.ShallowClone())
|
|
{
|
|
if (!EntityManager.EntityExists(entity))
|
|
{
|
|
_queuedSpeechBubbles.Remove(entity);
|
|
continue;
|
|
}
|
|
|
|
queueData.TimeLeft -= delta.DeltaSeconds;
|
|
if (queueData.TimeLeft > 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (queueData.MessageQueue.Count == 0)
|
|
{
|
|
_queuedSpeechBubbles.Remove(entity);
|
|
continue;
|
|
}
|
|
|
|
var msg = queueData.MessageQueue.Dequeue();
|
|
|
|
queueData.TimeLeft += BubbleDelayBase + msg.Message.Length * BubbleDelayFactor;
|
|
|
|
// We keep the queue around while it has 0 items. This allows us to keep the timer.
|
|
// When the timer hits 0 and there's no messages left, THEN we can clear it up.
|
|
CreateSpeechBubble(entity, msg);
|
|
}
|
|
|
|
var player = _player.LocalPlayer?.ControlledEntity;
|
|
var predicate = static (EntityUid uid, (EntityUid compOwner, EntityUid? attachedEntity) data)
|
|
=> uid == data.compOwner || uid == data.attachedEntity;
|
|
var playerPos = player != null
|
|
? EntityManager.GetComponent<TransformComponent>(player.Value).MapPosition
|
|
: MapCoordinates.Nullspace;
|
|
|
|
var occluded = player != null && _examine.IsOccluded(player.Value);
|
|
|
|
foreach (var (ent, bubs) in _activeSpeechBubbles)
|
|
{
|
|
if (EntityManager.Deleted(ent))
|
|
{
|
|
SetBubbles(bubs, false);
|
|
continue;
|
|
}
|
|
|
|
if (ent == player)
|
|
{
|
|
SetBubbles(bubs, true);
|
|
continue;
|
|
}
|
|
|
|
var otherPos = EntityManager.GetComponent<TransformComponent>(ent).MapPosition;
|
|
|
|
if (occluded && !ExamineSystemShared.InRangeUnOccluded(
|
|
playerPos,
|
|
otherPos, 0f,
|
|
(ent, player), predicate))
|
|
{
|
|
SetBubbles(bubs, false);
|
|
continue;
|
|
}
|
|
|
|
SetBubbles(bubs, true);
|
|
}
|
|
}
|
|
|
|
private void SetBubbles(List<SpeechBubble> bubbles, bool visible)
|
|
{
|
|
foreach (var bubble in bubbles)
|
|
{
|
|
bubble.Visible = visible;
|
|
}
|
|
}
|
|
|
|
private List<string> SplitMessage(string msg)
|
|
{
|
|
// Split message into words separated by spaces.
|
|
var words = msg.Split(' ');
|
|
var messages = new List<string>();
|
|
var currentBuffer = new List<string>();
|
|
|
|
// Really shoddy way to approximate word length.
|
|
// Yes, I am aware of all the crimes here.
|
|
// TODO: Improve this to use actual glyph width etc..
|
|
var currentWordLength = 0;
|
|
foreach (var word in words)
|
|
{
|
|
// +1 for the space.
|
|
currentWordLength += word.Length + 1;
|
|
|
|
if (currentWordLength > SingleBubbleCharLimit)
|
|
{
|
|
// Too long for the current speech bubble, flush it.
|
|
messages.Add(string.Join(" ", currentBuffer));
|
|
currentBuffer.Clear();
|
|
|
|
currentWordLength = word.Length;
|
|
|
|
if (currentWordLength > SingleBubbleCharLimit)
|
|
{
|
|
// Word is STILL too long.
|
|
// Truncate it with an ellipse.
|
|
messages.Add($"{word.Substring(0, SingleBubbleCharLimit - 3)}...");
|
|
currentWordLength = 0;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
currentBuffer.Add(word);
|
|
}
|
|
|
|
if (currentBuffer.Count != 0)
|
|
{
|
|
// Don't forget the last bubble.
|
|
messages.Add(string.Join(" ", currentBuffer));
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
public ChatSelectChannel MapLocalIfGhost(ChatSelectChannel channel)
|
|
{
|
|
if (channel == ChatSelectChannel.Local && _ghost is {IsGhost: true})
|
|
return ChatSelectChannel.Dead;
|
|
|
|
return channel;
|
|
}
|
|
|
|
private bool TryGetRadioChannel(string text, out RadioChannelPrototype? radioChannel)
|
|
{
|
|
radioChannel = null;
|
|
return _player.LocalPlayer?.ControlledEntity is EntityUid { Valid: true } uid
|
|
&& _chatSys != null
|
|
&& _chatSys.TryProccessRadioMessage(uid, text, out _, out radioChannel, quiet: true);
|
|
}
|
|
|
|
public void UpdateSelectedChannel(ChatBox box)
|
|
{
|
|
var (prefixChannel, _, radioChannel) = SplitInputContents(box.ChatInput.Input.Text);
|
|
|
|
if (prefixChannel == ChatSelectChannel.None)
|
|
box.ChatInput.ChannelSelector.UpdateChannelSelectButton(box.SelectedChannel, null);
|
|
else
|
|
box.ChatInput.ChannelSelector.UpdateChannelSelectButton(prefixChannel, radioChannel);
|
|
}
|
|
|
|
public (ChatSelectChannel chatChannel, string text, RadioChannelPrototype? radioChannel) SplitInputContents(string text)
|
|
{
|
|
text = text.Trim();
|
|
if (text.Length == 0)
|
|
return (ChatSelectChannel.None, text, null);
|
|
|
|
// We only cut off prefix only if it is not a radio or local channel, which both map to the same /say command
|
|
// because ????????
|
|
|
|
ChatSelectChannel chatChannel;
|
|
if (TryGetRadioChannel(text, out var radioChannel))
|
|
chatChannel = ChatSelectChannel.Radio;
|
|
else
|
|
chatChannel = PrefixToChannel.GetValueOrDefault(text[0]);
|
|
|
|
if ((CanSendChannels & chatChannel) == 0)
|
|
return (ChatSelectChannel.None, text, null);
|
|
|
|
if (chatChannel == ChatSelectChannel.Radio)
|
|
return (chatChannel, text, radioChannel);
|
|
|
|
if (chatChannel == ChatSelectChannel.Local)
|
|
{
|
|
if (_ghost?.IsGhost != true)
|
|
return (chatChannel, text, null);
|
|
else
|
|
chatChannel = ChatSelectChannel.Dead;
|
|
}
|
|
|
|
return (chatChannel, text[1..].TrimStart(), null);
|
|
}
|
|
|
|
public void SendMessage(ChatBox box, ChatSelectChannel channel)
|
|
{
|
|
_typingIndicator?.ClientSubmittedChatText();
|
|
|
|
var text = box.ChatInput.Input.Text;
|
|
box.ChatInput.Input.Clear();
|
|
box.ChatInput.Input.ReleaseKeyboardFocus();
|
|
UpdateSelectedChannel(box);
|
|
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return;
|
|
|
|
(var prefixChannel, text, var _) = SplitInputContents(text);
|
|
|
|
// Check if message is longer than the character limit
|
|
if (text.Length > MaxMessageLength)
|
|
{
|
|
var locWarning = Loc.GetString("chat-manager-max-message-length",
|
|
("maxMessageLength", MaxMessageLength));
|
|
box.AddLine(locWarning, Color.Orange);
|
|
return;
|
|
}
|
|
|
|
if (prefixChannel != ChatSelectChannel.None)
|
|
channel = prefixChannel;
|
|
else if (channel == ChatSelectChannel.Radio)
|
|
{
|
|
// radio must have prefix as it goes through the say command.
|
|
text = $";{text}";
|
|
}
|
|
|
|
_manager.SendMessage(text, prefixChannel == 0 ? channel : prefixChannel);
|
|
}
|
|
|
|
private void OnDamageForceSay(DamageForceSayEvent ev, EntitySessionEventArgs _)
|
|
{
|
|
if (UIManager.ActiveScreen?.GetWidget<ChatBox>() is not { } chatBox)
|
|
return;
|
|
|
|
// Don't send on OOC/LOOC obviously!
|
|
if (chatBox.SelectedChannel is not
|
|
(ChatSelectChannel.Local or
|
|
ChatSelectChannel.Radio or
|
|
ChatSelectChannel.Whisper))
|
|
return;
|
|
|
|
if (_player.LocalPlayer?.ControlledEntity is not { } ent
|
|
|| !EntityManager.TryGetComponent<DamageForceSayComponent>(ent, out var forceSay))
|
|
return;
|
|
|
|
var msg = chatBox.ChatInput.Input.Text.TrimEnd();
|
|
|
|
if (string.IsNullOrWhiteSpace(msg))
|
|
return;
|
|
|
|
var modifiedText = ev.Suffix != null
|
|
? Loc.GetString(forceSay.ForceSayMessageWrap,
|
|
("message", msg), ("suffix", ev.Suffix))
|
|
: Loc.GetString(forceSay.ForceSayMessageWrapNoSuffix,
|
|
("message", msg));
|
|
|
|
chatBox.ChatInput.Input.SetText(modifiedText);
|
|
chatBox.ChatInput.Input.ForceSubmitText();
|
|
}
|
|
|
|
private void OnChatMessage(MsgChatMessage message)
|
|
{
|
|
var msg = message.Message;
|
|
ProcessChatMessage(msg);
|
|
|
|
if ((msg.Channel & ChatChannel.AdminRelated) == 0 ||
|
|
_cfg.GetCVar(CCVars.ReplayRecordAdminChat))
|
|
{
|
|
_replayRecording.RecordClientMessage(msg);
|
|
}
|
|
}
|
|
|
|
public void ProcessChatMessage(ChatMessage msg, bool speechBubble = true)
|
|
{
|
|
// Log all incoming chat to repopulate when filter is un-toggled
|
|
if (!msg.HideChat)
|
|
{
|
|
History.Add((_timing.CurTick, msg));
|
|
MessageAdded?.Invoke(msg);
|
|
|
|
if (!msg.Read)
|
|
{
|
|
_sawmill.Debug($"Message filtered: {msg.Channel}: {msg.Message}");
|
|
if (!_unreadMessages.TryGetValue(msg.Channel, out var count))
|
|
count = 0;
|
|
|
|
count += 1;
|
|
_unreadMessages[msg.Channel] = count;
|
|
UnreadMessageCountsUpdated?.Invoke(msg.Channel, count);
|
|
}
|
|
}
|
|
|
|
// Local messages that have an entity attached get a speech bubble.
|
|
if (!speechBubble || msg.SenderEntity == default)
|
|
return;
|
|
|
|
switch (msg.Channel)
|
|
{
|
|
case ChatChannel.Local:
|
|
AddSpeechBubble(msg, SpeechBubble.SpeechType.Say);
|
|
break;
|
|
|
|
case ChatChannel.Whisper:
|
|
AddSpeechBubble(msg, SpeechBubble.SpeechType.Whisper);
|
|
break;
|
|
|
|
case ChatChannel.Dead:
|
|
if (_ghost is not {IsGhost: true})
|
|
break;
|
|
|
|
AddSpeechBubble(msg, SpeechBubble.SpeechType.Say);
|
|
break;
|
|
|
|
case ChatChannel.Emotes:
|
|
AddSpeechBubble(msg, SpeechBubble.SpeechType.Emote);
|
|
break;
|
|
}
|
|
}
|
|
|
|
public void RegisterChat(ChatBox chat)
|
|
{
|
|
_chats.Add(chat);
|
|
}
|
|
|
|
public void UnregisterChat(ChatBox chat)
|
|
{
|
|
_chats.Remove(chat);
|
|
}
|
|
|
|
public ChatSelectChannel GetPreferredChannel()
|
|
{
|
|
return MapLocalIfGhost(PreferredChannel);
|
|
}
|
|
|
|
public void NotifyChatTextChange()
|
|
{
|
|
_typingIndicator?.ClientChangedChatText();
|
|
}
|
|
|
|
public void Repopulate()
|
|
{
|
|
foreach (var chat in _chats)
|
|
{
|
|
chat.Repopulate();
|
|
}
|
|
}
|
|
|
|
private readonly record struct SpeechBubbleData(string Message, SpeechBubble.SpeechType Type);
|
|
|
|
private sealed class SpeechBubbleQueueData
|
|
{
|
|
/// <summary>
|
|
/// Time left until the next speech bubble can appear.
|
|
/// </summary>
|
|
public float TimeLeft { get; set; }
|
|
|
|
public Queue<SpeechBubbleData> MessageQueue { get; } = new();
|
|
}
|
|
}
|