ui/Chat.razor

A Razor UI component for in-game chat. Renders chat entries, accepts input, fades old messages, handles sending chat via RPC broadcast, shows system messages, plays UI sounds, and listens for network connect/disconnect to add join/leave messages.

Networking
@namespace Sandbox
@using Sandbox;
@using Sandbox.UI;
@using System;
@inherits PanelComponent
@implements Component.INetworkListener

<root>
	<div class="output">
		@foreach (var entry in Entries)
		{
			<div class="chat_entry" style="opacity:@(Utils.Map(entry.realTimeSinceAdded, 0f, CHAT_ENTRY_LIFETIME, 1f, 0f, EasingType.QuartIn) * (entry.isSpectator ? 0.5f : 1f));">

				@if(entry.isSpectator)
				{
					<i>videocam</i>
				}

				@if( entry.steamid != 0 ) 
				{
					<div class="avatar" style="background-image: url( avatar:@entry.steamid )"></div>
				}

				<div class="author @(entry.systemText ? "system_author" : "")">@entry.author</div>
				<RichText class="message @(entry.systemText ? "system_message" : "")" [email protected] />
			</div>
		}
	</div>

	<div class="input">
		<TextEntry @ref="InputBox" onsubmit="@ChatFinished"></TextEntry>
	</div>

</root>

@code
{
	// todo: chat messages fade out?

	TextEntry InputBox;

	public record Entry ( ulong steamid, string author, string message, RealTimeSince realTimeSinceAdded, bool systemText, bool isSpectator = false, bool clearOnRestart = false );
	List<Entry> Entries = new();

	public const float CHAT_ENTRY_LIFETIME = 15f;

	protected override void OnUpdate()
	{
		if (InputBox is null)
			return;

		Panel.AcceptsFocus = false;

		if ( Input.Pressed( "chat" ) )
		{
			InputBox.Focus();
		}

		if ( Entries.RemoveAll( x => x.realTimeSinceAdded > CHAT_ENTRY_LIFETIME ) > 0 )
		{
			StateHasChanged();
		}

		SetClass( "open", InputBox.HasFocus );
		Panel.Style.Bottom = Manager.Instance.GameState == GameState.Lobby ? Length.Percent( 75 ) : Length.Pixels( 200 );

		if(InputBox.Text.Length > 0)
			InputBox.Text = CheckChatMessage(InputBox.Text);
	}

	void ChatFinished()
	{
		var text = InputBox.Text;
		InputBox.Text = "";

		if (string.IsNullOrWhiteSpace(text))
			return;

		// Only broadcast chat to other players if chat is not disabled
		if ( !GameSettingsSystem.Current.DisableChat )
			AddText( GetDisplayText( text ) );

		var player = Manager.Instance.LocalPlayer;
		if ( player is not null )
		{
			player.ForEachPerk( perk => perk.OnSayChat( text ) );
			player.ForEachLoadoutItem( item => item.OnSayChat( text ) );
		}
	}

	string GetDisplayText( string text )
	{
		var perkType = Perk.FindTypeByLooseDisplayName( text );
		if ( perkType is null )
			return text;

		return $"{Perk.GetRichTextNameToken( perkType.TargetType )} {Perk.GetRichTextToken( perkType )}";
	}

	string CheckChatMessage(string message)
	{
		var player = Manager.Instance.LocalPlayer;
		if( player is not null && player.Stats[PlayerStat.KeyBrokenE] > 0f)
		{
			var oldLength = message.Length;

			message = message.Replace("e", "");
			message = message.Replace("E", "");
			message = message.Replace("е", "");
			message = message.Replace("ẹ", "");
			message = message.Replace("ė", "");
			message = message.Replace("é", "");
			message = message.Replace("è", "");

			if( oldLength != message.Length )
				player.HighlightPerk( TypeLibrary.GetType( typeof( PerkBrokenKey ) ) );
		}

		return message;
	}

	[Rpc.Broadcast(NetFlags.Reliable)]
	public void AddText( string message, bool systemText = false, bool clearOnRestart = false )
	{
		if ( GameSettingsSystem.Current.DisableChat && !systemText )
			return;

		message = message.Truncate( 300 );

		if (string.IsNullOrWhiteSpace(message))
			return;

		var author = Rpc.Caller.DisplayName;
		var steamid = Rpc.Caller.SteamId;
		var guid = Rpc.Caller.Id;
		var isSpectator = !( Manager.Instance?.Players?.Any( x => x.Network.Owner.Id == guid ) ?? false );

		Log.Info($"{author}: {message}");
		
		Manager.Instance.PlaySfxUI( "chat.message", pitch: Game.Random.Float(0.95f, 1.05f), volume: 0.75f );

		Entries.Add( new Entry( steamid, author, message, 0.0f, systemText, isSpectator, clearOnRestart ) );
		StateHasChanged();
	}

	[Rpc.Broadcast(NetFlags.Reliable)] // todo: only from host/owner
	public void AddSystemText(ulong steamid, string author, string message)
	{
		message = message.Truncate(300);

		if (string.IsNullOrWhiteSpace(message))
			return;

		Entries.Add(new Entry(steamid, author, message, 0.0f, systemText: true));
		StateHasChanged();
	}

	public void AddLocalChatMessage( string message, string from )
	{
		if (string.IsNullOrWhiteSpace(message))
			return;

		Entries.Add(new Entry(0, from, message, 0.0f, systemText: true, clearOnRestart: true));
		StateHasChanged();

		Manager.Instance.PlaySfxUI( "chat.message", pitch: Game.Random.Float(0.8f, 0.85f), volume: 0.75f );
	}

	public void Restart()
	{
		for(int i = Entries.Count - 1; i >= 0; i--) {
			if ( Entries[i].clearOnRestart )
				Entries.RemoveAt(i);
		}

		StateHasChanged();
	}

	void Component.INetworkListener.OnConnected( Connection channel )
	{
		Manager.Instance.PlaySfxUI( "player.join", pitch: Game.Random.Float(0.95f, 1.05f), volume: 1f );

		if ( IsProxy ) return;

		AddSystemText( channel.SteamId, channel.DisplayName, "has joined the game" );
	}

	void Component.INetworkListener.OnDisconnected( Connection channel )
	{
		Manager.Instance.PlaySfxUI( "player.left", pitch: Game.Random.Float(0.95f, 1.05f), volume: 1.1f );

		if ( IsProxy ) return;

		AddSystemText( channel.SteamId, channel.DisplayName, "has left the game" );
	}

	protected override int BuildHash()
	{
		return HashCode.Combine(Entries.Count() > 0 ? RealTime.Now : 0f);
	}
}