diff --git a/Assets/Scripts/Helper/GameSettings.cs b/Assets/Scripts/Helper/GameSettings.cs
index e9110b5..a4f0e6c 100644
--- a/Assets/Scripts/Helper/GameSettings.cs
+++ b/Assets/Scripts/Helper/GameSettings.cs
@@ -17,6 +17,7 @@ public static class GameSettings
public int version = FileVersion;
public float mouseSensitivity = global::MouseSensitivity.Default;
public float masterVolume = global::MasterVolume.Default;
+ public string playerName = "";
}
private static Data _data;
@@ -46,6 +47,19 @@ public static class GameSettings
}
}
+ /// Display name shown above the avatar. Empty = per-spawn fallback ("Player <clientId>").
+ public static string PlayerName
+ {
+ get => Store.playerName ?? "";
+ set
+ {
+ string v = value == null ? "" : value.Trim();
+ if (Store.playerName == v) return;
+ Store.playerName = v;
+ Save();
+ }
+ }
+
private static Data Load()
{
string path = SavePath();
diff --git a/Assets/Scripts/Network/NetworkPlayerSetup.cs b/Assets/Scripts/Network/NetworkPlayerSetup.cs
index 7d41d84..2109b62 100644
--- a/Assets/Scripts/Network/NetworkPlayerSetup.cs
+++ b/Assets/Scripts/Network/NetworkPlayerSetup.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using Unity.Collections;
using Unity.Netcode;
using UnityEngine;
using Chameleon.Components;
@@ -80,6 +81,49 @@ public class NetworkPlayerSetup : NetworkBehaviour
/// target that is already out of play.
public bool IsFound => _isFound.Value;
+ // Owner-published display name (seeded from GameSettings on spawn), consumed by the nameplate overlay
+ // on every client. Empty = unset; DisplayName then falls back to "Player ".
+ private readonly NetworkVariable _displayName =
+ new NetworkVariable(default, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
+
+ private string _displayNameCache; // resolved lazily, invalidated on replication (no per-frame ToString allocs)
+
+ /// This avatar's nameplate text: the owner-published name, or "Player <clientId>" when unset.
+ /// Returns the SAME string instance until the name changes, so consumers can change-detect by reference.
+ public string DisplayName
+ {
+ get
+ {
+ if (_displayNameCache == null)
+ {
+ FixedString64Bytes raw = _displayName.Value;
+ _displayNameCache = raw.IsEmpty ? $"Player {OwnerClientId}" : raw.ToString();
+ }
+ return _displayNameCache;
+ }
+ }
+
+ /// Owner-only: publishes a new display name (trimmed, length-capped) to every client. Called on
+ /// spawn with the persisted settings value and live from the session panel's name field on a rename.
+ public void PublishDisplayName(string name)
+ {
+ if (!IsOwner || !IsSpawned) return;
+ _displayName.Value = SanitizeName(name);
+ }
+
+ private static FixedString64Bytes SanitizeName(string name)
+ {
+ name = name == null ? "" : name.Trim();
+ const int maxChars = 24;
+ if (name.Length > maxChars) name = name.Substring(0, maxChars);
+ var fs = new FixedString64Bytes();
+ fs.CopyFromTruncated(name); // exotic chars can exceed the 61-byte UTF-8 capacity; truncate, never throw
+ return fs;
+ }
+
+ private void OnDisplayNameChanged(FixedString64Bytes previous, FixedString64Bytes current) =>
+ _displayNameCache = null;
+
// Owner-authoritative "this hider is buried too deep" flag: only the OWNER runs the ConcealmentMeter,
// so it compares EmbeddedPercent against the threshold and writes; every client reads it to switch
// the through-walls X-ray glow.
@@ -130,6 +174,7 @@ public class NetworkPlayerSetup : NetworkBehaviour
_paintSync = GetComponentInChildren(true);
_concealment = GetComponentInChildren(true);
_isFound.OnValueChanged += OnFoundChanged;
+ _displayName.OnValueChanged += OnDisplayNameChanged;
if (_isFound.Value) ApplyFound(); // defensive: an avatar is never normally spawned already-found
// Push the Conversion position into the replicated override before the spawn snapshot goes out.
@@ -140,6 +185,8 @@ public class NetworkPlayerSetup : NetworkBehaviour
if (IsOwner)
{
+ PublishDisplayName(GameSettings.PlayerName);
+
// Conversion respawn: restore the server-tagged position (the owner-authoritative transform
// ignores the server's Instantiate pos); NaN override = normal spawn placement.
Vector3 overridePos = _spawnOverride.Value;
@@ -195,6 +242,7 @@ public class NetworkPlayerSetup : NetworkBehaviour
_active.Remove(this);
ActiveVersion++;
_isFound.OnValueChanged -= OnFoundChanged;
+ _displayName.OnValueChanged -= OnDisplayNameChanged;
}
/// Server-only: flag this hider as found by a hunter. Replicates to every client (the body is
diff --git a/Assets/Scripts/Network/UI/PlayerNameField.cs b/Assets/Scripts/Network/UI/PlayerNameField.cs
new file mode 100644
index 0000000..eec7104
--- /dev/null
+++ b/Assets/Scripts/Network/UI/PlayerNameField.cs
@@ -0,0 +1,44 @@
+using TMPro;
+using UnityEngine;
+
+///
+/// Binds the session panel's player-name input to the persisted name (GameSettings/Settings.json).
+/// Seeds the field on enable, saves on end-edit, and pushes a live rename to the already-spawned local
+/// avatar (the Esc overlay keeps the panel reachable while connected); fresh spawns read GameSettings
+/// themselves in .
+///
+[DisallowMultipleComponent]
+public class PlayerNameField : MonoBehaviour
+{
+ [Tooltip("Name input. Empty = the first TMP_InputField below this object.")]
+ [SerializeField] private TMP_InputField input;
+
+ private void Awake()
+ {
+ if (!input) input = GetComponentInChildren(true);
+ }
+
+ private void OnEnable()
+ {
+ if (!input) return;
+ input.SetTextWithoutNotify(GameSettings.PlayerName);
+ input.onEndEdit.AddListener(OnEndEdit);
+ }
+
+ private void OnDisable()
+ {
+ if (input) input.onEndEdit.RemoveListener(OnEndEdit);
+ }
+
+ private void OnEndEdit(string value)
+ {
+ GameSettings.PlayerName = value;
+
+ var avatars = NetworkPlayerSetup.Active;
+ for (int i = 0; i < avatars.Count; i++)
+ {
+ var avatar = avatars[i];
+ if (avatar && avatar.IsOwner) avatar.PublishDisplayName(value);
+ }
+ }
+}
diff --git a/Assets/Scripts/Network/UI/PlayerNameField.cs.meta b/Assets/Scripts/Network/UI/PlayerNameField.cs.meta
new file mode 100644
index 0000000..487d3c4
--- /dev/null
+++ b/Assets/Scripts/Network/UI/PlayerNameField.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 90aefd049f8091b4ea1eb7033f4ee838
\ No newline at end of file
diff --git a/Assets/Scripts/Network/UI/PlayerNameplates.cs b/Assets/Scripts/Network/UI/PlayerNameplates.cs
new file mode 100644
index 0000000..7883cf2
--- /dev/null
+++ b/Assets/Scripts/Network/UI/PlayerNameplates.cs
@@ -0,0 +1,142 @@
+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]. 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.
+///
+[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 _entries = new List();
+ 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(),
+ body = setup.GetComponentInChildren(true),
+ });
+ }
+ }
+}
diff --git a/Assets/Scripts/Network/UI/PlayerNameplates.cs.meta b/Assets/Scripts/Network/UI/PlayerNameplates.cs.meta
new file mode 100644
index 0000000..1cfacb9
--- /dev/null
+++ b/Assets/Scripts/Network/UI/PlayerNameplates.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3ff15c6bbf3e72d4d9f9fcfc86c0ebc0
\ No newline at end of file