From 0e7ce0c410b878c4fa48362bb74698f0dbc8950a Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Wed, 8 Jul 2026 14:58:15 +0200 Subject: [PATCH] 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 --- Assets/Scripts/Helper/GameSettings.cs | 14 ++ Assets/Scripts/Network/NetworkPlayerSetup.cs | 48 ++++++ Assets/Scripts/Network/UI/PlayerNameField.cs | 44 ++++++ .../Network/UI/PlayerNameField.cs.meta | 2 + Assets/Scripts/Network/UI/PlayerNameplates.cs | 142 ++++++++++++++++++ .../Network/UI/PlayerNameplates.cs.meta | 2 + 6 files changed, 252 insertions(+) create mode 100644 Assets/Scripts/Network/UI/PlayerNameField.cs create mode 100644 Assets/Scripts/Network/UI/PlayerNameField.cs.meta create mode 100644 Assets/Scripts/Network/UI/PlayerNameplates.cs create mode 100644 Assets/Scripts/Network/UI/PlayerNameplates.cs.meta 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