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>
This commit is contained in:
1 parent
6db78397da
commit
0e7ce0c410
6 files changed
+252
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Display name shown above the avatar. Empty = per-spawn fallback ("Player <clientId>").</summary>
|
||||
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();
|
||||
|
||||
@@ -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.</summary>
|
||||
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 <clientId>".
|
||||
private readonly NetworkVariable<FixedString64Bytes> _displayName =
|
||||
new NetworkVariable<FixedString64Bytes>(default, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
||||
|
||||
private string _displayNameCache; // resolved lazily, invalidated on replication (no per-frame ToString allocs)
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public string DisplayName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_displayNameCache == null)
|
||||
{
|
||||
FixedString64Bytes raw = _displayName.Value;
|
||||
_displayNameCache = raw.IsEmpty ? $"Player {OwnerClientId}" : raw.ToString();
|
||||
}
|
||||
return _displayNameCache;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<PlayerPaintSync>(true);
|
||||
_concealment = GetComponentInChildren<ConcealmentMeter>(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;
|
||||
}
|
||||
|
||||
/// <summary>Server-only: flag this hider as found by a hunter. Replicates to every client (the body is
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="NetworkPlayerSetup.OnNetworkSpawn"/>.
|
||||
/// </summary>
|
||||
[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<TMP_InputField>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90aefd049f8091b4ea1eb7033f4ee838
|
||||
@@ -0,0 +1,142 @@
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ff15c6bbf3e72d4d9f9fcfc86c0ebc0
|
||||
Reference in new issue
Block a user