// Assets/_Project/Scripts/Gameplay/ChatService.cs
using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
using TD.Core;
namespace TD.Gameplay
{
///
/// Networked chat singleton. Carries player-typed messages between peers and
/// exposes a local-only entry point for system messages (life lost, income
/// changes, etc.). UI consumers subscribe to
/// to display the feed.
///
///
/// Authority model. Player messages go client → server (via
/// + ) so the
/// server gets a chance to validate/filter, then the server broadcasts to
/// every peer via . The host receives
/// its own broadcast like any other client, so a single subscription path
/// handles every message type uniformly.
///
/// System messages. is local-only and
/// does NOT cross the network. Callers typically invoke it from inside an
/// already-replicated event (e.g. , which
/// fires on every peer via its own ClientRpc) so each peer posts the system
/// message itself. This avoids paying a second round-trip for events that
/// are inherently broadcast already.
///
/// Scene setup. Drop a ChatService GameObject (with a
/// NetworkObject) into the gameplay scene. NGO 2.x auto-discovers
/// the prefab — no manual registration needed.
///
[RequireComponent(typeof(NetworkObject))]
public class ChatService : NetworkBehaviour
{
// ----- Singleton --------------------------------------------------
public static ChatService Instance { get; private set; }
// ----- Message types ----------------------------------------------
public enum MessageKind
{
Player,
System,
}
///
/// One chat feed entry. is empty for system messages.
///
public readonly struct ChatEntry
{
public readonly MessageKind Kind;
public readonly string SenderName;
public readonly string Text;
public ChatEntry(MessageKind kind, string senderName, string text)
{
Kind = kind;
SenderName = senderName;
Text = text;
}
}
// ----- Local events -----------------------------------------------
///
/// Fires on every peer when a player message arrives (after the
/// server's ClientRpc) OR when a local system message is posted via
/// . HUD subscribes to render the feed.
///
public static event System.Action OnMessageReceived;
// ----- NGO lifecycle ----------------------------------------------
public override void OnNetworkSpawn()
{
if (Instance != null && Instance != this)
{
Debug.LogError("[ChatService] Duplicate instance detected. " +
"Only one ChatService should exist per scene.");
return;
}
Instance = this;
}
public override void OnNetworkDespawn()
{
if (Instance == this) Instance = null;
}
// ----- Player messages (network round-trip) -----------------------
///
/// Submits a chat message authored by the local player. Empty / whitespace
/// strings are dropped. Text is trimmed and truncated to fit the wire
/// payload (~120 chars).
///
public void SubmitMessage(string text)
{
if (string.IsNullOrWhiteSpace(text)) return;
text = text.Trim();
if (text.Length > 120) text = text.Substring(0, 120);
FixedString128Bytes payload = default;
payload.Append(text);
SubmitMessageRpc(payload);
}
// NGO 2.x unified RPC attribute. SendTo.Server + InvokePermission.Everyone
// is the modern replacement for [ServerRpc(RequireOwnership = false)] —
// any client (not just the NetworkObject's owner) may invoke it.
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
private void SubmitMessageRpc(FixedString128Bytes text, RpcParams rpcParams = default)
{
// Validation / filtering hook — drop spam, profanity, etc. For now we
// just broadcast unchanged. The server origin tag means whatever
// appears in clients' chat is what the server allowed through.
BroadcastMessageRpc(rpcParams.Receive.SenderClientId, text);
}
// SendTo.Everyone matches the old [ClientRpc] behavior under host mode —
// the body runs on every peer including the host's local client, so the
// host sees its own messages in the feed without a separate local call.
[Rpc(SendTo.Everyone)]
private void BroadcastMessageRpc(ulong senderClientId, FixedString128Bytes text)
{
string senderName = ResolveSenderName(senderClientId);
OnMessageReceived?.Invoke(
new ChatEntry(MessageKind.Player, senderName, text.ToString()));
}
// ----- System messages (local only) -------------------------------
///
/// Posts a system message to the local chat feed only. Does NOT cross the
/// network. Callers should invoke this from a code path that's already
/// replicated on every peer (e.g. a ClientRpc handler or an event that's
/// fired on every peer) so each peer sees the message exactly once.
///
public static void PostLocalSystem(string text)
{
if (string.IsNullOrEmpty(text)) return;
OnMessageReceived?.Invoke(
new ChatEntry(MessageKind.System, "", text));
}
// ----- Helpers ----------------------------------------------------
private static string ResolveSenderName(ulong clientId)
{
var pms = PlayerMatchState.GetForClient(clientId);
if (pms != null && pms.Slot != PlayerSlot.None)
return $"P{(int)pms.Slot}";
return $"Client {clientId}";
}
}
}