using System.Collections.Generic; using TMPro; using UnityEngine; /// /// Screen-space nameplate overlay: one pooled TMP label per spawned avatar (), /// projected above the head via Camera.main each LateUpdate - NOT world-space, so names always draw over the /// scene and (via this canvas's sortingOrder) over the normal HUD. No GraphicRaycaster and every label is /// raycastTarget=false, so it never eats clicks. Font size scales inversely with camera distance, clamped to /// [minFontSize, maxFontSize]. Visibility: a label shows only while its avatar's body is rendered locally /// (own first-person self-hide, the per-client hide-phase matrix, found hiders, paint-reveal gate - a name /// floating over an invisible body would leak the position), and hider names are additionally suppressed for /// a local HUNTER viewer during Seek, where bodies ARE rendered but a label would defeat the camouflage hunt. /// [DisallowMultipleComponent] public class PlayerNameplates : MonoBehaviour { [Tooltip("Disabled label prototype under this canvas; cloned once per avatar. Author pivot (0.5, 0) + " + "bottom-center alignment so the text sits on top of the head anchor.")] [SerializeField] private GameObject labelContainerTemplate; [Tooltip("Metres above the avatar's capsule top where the name anchors.")] [SerializeField] private float heightOffset = 0.1f; [Tooltip("Font size at Reference Distance; scales inversely with camera distance from there.")] [SerializeField] private float referenceFontSize = 28f; [Tooltip("Distance (m) at which the font renders at Reference Font Size.")] [Min(0.01f)] [SerializeField] private float referenceDistance = 3f; [Tooltip("Lower font-size clamp (far players).")] [SerializeField] private float minFontSize = 12f; [Tooltip("Upper font-size clamp (near players).")] [SerializeField] private float maxFontSize = 40f; private class Entry { public NetworkPlayerSetup setup; public GameObject labelContainer; public TMP_Text[] labels; public CharacterController capsule; // head-anchor height (present but disabled on remotes) public Renderer body; // own-avatar gate: first-person self-hides the mesh public string lastText; public float lastSize = -1f; } private readonly List _entries = new List(); private int _seenVersion = -1; private void Awake() { if (labelContainerTemplate) labelContainerTemplate.SetActive(false); } private void LateUpdate() { if (!labelContainerTemplate) return; if (_seenVersion != NetworkPlayerSetup.ActiveVersion) { _seenVersion = NetworkPlayerSetup.ActiveVersion; Rebuild(); } var cam = Camera.main; // Seek is the one window where a hider's body is rendered for a hunter but its name must not be // (spotting the camouflage IS the game); everywhere else the body-rendered gate below suffices. var match = MatchManager.Instance; bool hunterSeek = match && match.LocalSubPhase == MatchManager.MatchSubPhase.Seek && NetworkPlayerSetup.LocalViewerIsHunter(); for (int i = 0; i < _entries.Count; i++) { Entry e = _entries[i]; if (!e.setup || !e.labelContainer || e.labels is null || e.labels.Length == 0) continue; bool show = cam; // Label only while the body is rendered locally: covers the own first-person self-hide AND every // per-client hide (hide-phase matrix, found hiders, paint-reveal/conversion gates) in one check. if (show && (!e.body || !e.body.enabled)) show = false; if (show && hunterSeek && e.setup.Role == PlayerRole.Hider) show = false; Vector3 screen = default; float distance = 0f; if (show) { Vector3 anchor = HeadAnchor(e); screen = cam.WorldToScreenPoint(anchor); if (screen.z <= 0f) show = false; // behind the camera else distance = Vector3.Distance(cam.transform.position, anchor); } if (e.labelContainer.activeSelf != show) e.labelContainer.SetActive(show); if (!show) continue; e.labelContainer.transform.position = new Vector3(screen.x, screen.y, 0f); float size = Mathf.Clamp(referenceFontSize * referenceDistance / Mathf.Max(distance, 0.01f), minFontSize, maxFontSize); // TMP re-layouts on every fontSize/text write, so only touch them on real changes. if (Mathf.Abs(size - e.lastSize) > 0.05f) { e.lastSize = size; foreach (var label in e.labels) label.fontSize = size; } string text = e.setup.DisplayName; // same instance until renamed -> reference compare suffices if (!ReferenceEquals(text, e.lastText)) { e.lastText = text; foreach (var label in e.labels) label.text = text; } } } private Vector3 HeadAnchor(Entry e) { Transform tf = e.setup.transform; Vector3 top = e.capsule ? tf.TransformPoint(e.capsule.center + Vector3.up * (e.capsule.height * 0.5f)) : tf.position; return top + Vector3.up * heightOffset; } private void Rebuild() { for (int i = 0; i < _entries.Count; i++) if (_entries[i].labelContainer) Destroy(_entries[i].labelContainer); _entries.Clear(); var avatars = NetworkPlayerSetup.Active; for (int i = 0; i < avatars.Count; i++) { var setup = avatars[i]; if (!setup) continue; var labelContainer = Instantiate(labelContainerTemplate, labelContainerTemplate.transform.parent); _entries.Add(new Entry { setup = setup, labelContainer = labelContainer, labels = labelContainer.GetComponentsInChildren(), capsule = setup.GetComponent(), body = setup.GetComponentInChildren(true), }); } } }