Generalize the body-renderer gate to all avatars so nameplates inherit the existing visibility matrix (hide phase, found hiders, paint reveal, conversion settle), and add the one case the body gate cannot cover: hunters see camouflaged hider bodies during seek, so suppress hider names for a local hunter viewer while LocalSubPhase == Seek. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
154 lines
6.1 KiB
C#
154 lines
6.1 KiB
C#
using System.Collections.Generic;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
|
|
/// <summary>
|
|
/// Screen-space nameplate overlay: one pooled TMP label per spawned avatar (<see cref="NetworkPlayerSetup.Active"/>),
|
|
/// 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.
|
|
/// </summary>
|
|
[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 TMP_Text labelTemplate;
|
|
|
|
[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 TMP_Text label;
|
|
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<Entry> _entries = new List<Entry>();
|
|
private int _seenVersion = -1;
|
|
|
|
private void Awake()
|
|
{
|
|
if (labelTemplate) labelTemplate.gameObject.SetActive(false);
|
|
}
|
|
|
|
private void LateUpdate()
|
|
{
|
|
if (!labelTemplate) 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.label) 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.label.gameObject.activeSelf != show)
|
|
e.label.gameObject.SetActive(show);
|
|
if (!show) continue;
|
|
|
|
e.label.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;
|
|
e.label.fontSize = size;
|
|
}
|
|
string text = e.setup.DisplayName; // same instance until renamed -> reference compare suffices
|
|
if (!ReferenceEquals(text, e.lastText))
|
|
{
|
|
e.lastText = text;
|
|
e.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].label) Destroy(_entries[i].label.gameObject);
|
|
_entries.Clear();
|
|
|
|
var avatars = NetworkPlayerSetup.Active;
|
|
for (int i = 0; i < avatars.Count; i++)
|
|
{
|
|
var setup = avatars[i];
|
|
if (!setup) continue;
|
|
|
|
var label = Instantiate(labelTemplate, labelTemplate.transform.parent);
|
|
label.raycastTarget = false;
|
|
_entries.Add(new Entry
|
|
{
|
|
setup = setup,
|
|
label = label,
|
|
capsule = setup.GetComponent<CharacterController>(),
|
|
body = setup.GetComponentInChildren<SkinnedMeshRenderer>(true),
|
|
});
|
|
}
|
|
}
|
|
}
|