Files
chameleon/Assets/Scripts/Network/UI/PlayerNameplates.cs
T
adminandClaude Fable 5 0e7ce0c410 Add player names: settings persistence, replication, screen-space nameplates
- GameSettings: playerName field persisted in Settings.json
- NetworkPlayerSetup: owner-written DisplayName NetworkVariable
  (seeded from settings on spawn, sanitized/truncated, live rename push)
- PlayerNameField: binds the session panel name input to settings
- PlayerNameplates: screen-space overlay labels above players with
  distance-based font scaling (clamped min/max), pooling, FP self-hide

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:58:15 +02:00

143 lines
5.3 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]. The OWN avatar's label is gated on its body being locally rendered - the exact
/// proxy for "not first-person" (the FP controller self-hides the mesh, including while Esc-paused).
/// Per-role/phase visibility (hide phase etc.) is deliberately not applied yet.
/// </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;
for (int i = 0; i < _entries.Count; i++)
{
Entry e = _entries[i];
if (!e.setup || !e.label) continue;
bool show = cam;
// Own avatar: label only while the body is rendered locally (hidden = first person / load freeze).
if (show && e.setup.IsOwner && (!e.body || !e.body.enabled))
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),
});
}
}
}