// 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}"; } } }