using UnityEngine;
using NWH.VehiclePhysics2;
///
/// Remote vehicle controller. Attached to remote players spawned from the network.
/// - Disables NWH local simulation (VehicleController + WheelControllers + AudioSources).
/// - Sets the Rigidbody kinematic so we can drive it with snapshot interpolation
/// via / .
/// - Local dynamic vehicles bounce naturally off the kinematic remote's colliders.
/// - Adds a floating name label (distance-scaled) and a colored capsule marker.
///
public class RemoteVehicleSync : MonoBehaviour
{
[Header("Interpolation")]
public float interpolationDelay = 0.083f;
public float maxExtrapolation = 0.08f;
public float snapDistance = 12f;
public float smoothingSpeed = 24f;
public float rotationSpeed = 24f;
[Header("Spawn")]
[Tooltip("Seconds after spawn during which colliders are disabled to avoid ejecting overlapping locals at connect.")]
public float spawnGrace = 1.5f;
public string SessionId { get; private set; }
public string PlayerName { get; private set; }
public Color PlayerColor { get; private set; }
public float SpawnTime { get; private set; }
private struct Snapshot
{
public double serverTime;
public float localTime;
public Vector3 position;
public Vector3 velocity;
public Quaternion rotation;
public Vector3 angularVelocity;
}
private const int BUFFER_SIZE = 16;
private readonly Snapshot[] _buffer = new Snapshot[BUFFER_SIZE];
private int _bufferCount;
private int _newestIndex;
private bool _initialized;
private Rigidbody _rb;
private VehicleController _vehicle;
private Collider[] _allColliders;
private bool _collidersReenabled;
private Quaternion _currentRotation = Quaternion.identity;
private GameObject _nameLabelObj;
private TextMesh _nameLabel;
private GameObject _markerObj;
public void Initialize(string sessionId, string playerName, Color color)
{
SessionId = sessionId;
PlayerName = playerName;
PlayerColor = color;
SpawnTime = Time.time;
_bufferCount = 0;
_currentRotation = transform.rotation;
// Disable NWH driving simulation: this remote is purely networked, no local AI/input.
_vehicle = GetComponent();
if (_vehicle != null) _vehicle.enabled = false;
// Disable all wheel controllers so they don't apply suspension forces.
foreach (var mb in GetComponentsInChildren(true))
{
var t = mb.GetType();
if (t.FullName == "NWH.WheelController3D.WheelController" || t.FullName.Contains(".WheelController"))
mb.enabled = false;
}
// Mute audio sources (engine, skid, etc.) on remotes.
foreach (var src in GetComponentsInChildren(true))
src.enabled = false;
// Make the rigidbody kinematic so we drive it from network snapshots.
_rb = _vehicle != null ? _vehicle.vehicleRigidbody : GetComponent();
if (_rb != null)
{
_rb.isKinematic = true;
_rb.useGravity = false;
_rb.interpolation = RigidbodyInterpolation.Interpolate;
_rb.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative;
}
// Cache colliders and disable them during the grace window to avoid spawn-time ejection.
_allColliders = GetComponentsInChildren(true);
foreach (var c in _allColliders)
if (c != null && !c.isTrigger) c.enabled = false;
BuildNameLabel(playerName, color);
BuildColorMarker(color);
_initialized = true;
Debug.Log($"[RemoteVehicle] Initialized: {playerName} ({sessionId[..6]}) color={color}");
}
public void SetTargetState(Vector3 position, Vector3 velocity, Quaternion rotation, double serverTime, Vector3 angularVelocity = default)
{
_newestIndex = (_newestIndex + 1) % BUFFER_SIZE;
_buffer[_newestIndex] = new Snapshot
{
serverTime = serverTime,
localTime = Time.time,
position = position,
velocity = velocity,
rotation = rotation,
angularVelocity = angularVelocity,
};
if (_bufferCount < BUFFER_SIZE) _bufferCount++;
}
public void SetVisible(bool visible)
{
foreach (var r in GetComponentsInChildren(true))
if (r != null) r.enabled = visible;
if (_nameLabelObj != null) _nameLabelObj.SetActive(visible);
if (_markerObj != null) _markerObj.SetActive(visible);
}
void Update()
{
if (!_initialized) return;
// Re-enable colliders once grace window has elapsed.
if (!_collidersReenabled && Time.time - SpawnTime > spawnGrace)
{
if (_allColliders != null)
foreach (var c in _allColliders)
if (c != null && !c.isTrigger) c.enabled = true;
_collidersReenabled = true;
}
if (_bufferCount == 0) return;
float renderTime = Time.time - interpolationDelay;
int oldestIdx = (_newestIndex - _bufferCount + 1 + BUFFER_SIZE) % BUFFER_SIZE;
Snapshot older = default, newer = default;
bool found = false;
for (int i = 0; i < _bufferCount - 1; i++)
{
int a = (oldestIdx + i) % BUFFER_SIZE;
int b = (oldestIdx + i + 1) % BUFFER_SIZE;
if (_buffer[a].localTime <= renderTime && _buffer[b].localTime >= renderTime)
{
older = _buffer[a];
newer = _buffer[b];
found = true;
break;
}
}
Vector3 targetPos;
Quaternion targetRot;
if (found)
{
float span = newer.localTime - older.localTime;
float t = span > 0.001f ? (renderTime - older.localTime) / span : 1f;
t = Mathf.Clamp01(t);
targetPos = Vector3.Lerp(older.position, newer.position, t);
targetRot = Quaternion.Slerp(older.rotation, newer.rotation, t);
}
else
{
var newest = _buffer[_newestIndex];
float elapsed = renderTime - newest.localTime;
if (elapsed < 0)
{
targetPos = _buffer[oldestIdx].position;
targetRot = _buffer[oldestIdx].rotation;
}
else
{
float extTime = Mathf.Min(elapsed, maxExtrapolation);
float damp = 1f - Mathf.Clamp01(elapsed / (maxExtrapolation * 2f));
targetPos = newest.position + newest.velocity * extTime * damp;
targetRot = newest.rotation;
}
}
float dist = Vector3.Distance(transform.position, targetPos);
Vector3 newPos;
if (dist > snapDistance)
{
newPos = targetPos;
_currentRotation = targetRot;
}
else
{
float lerpT = 1f - Mathf.Exp(-smoothingSpeed * Time.deltaTime);
newPos = Vector3.Lerp(transform.position, targetPos, lerpT);
}
float rotLerpT = 1f - Mathf.Exp(-rotationSpeed * Time.deltaTime);
_currentRotation = Quaternion.Slerp(_currentRotation, targetRot, rotLerpT);
if (_rb != null)
{
_rb.MovePosition(newPos);
_rb.MoveRotation(_currentRotation);
}
else
{
transform.position = newPos;
transform.rotation = _currentRotation;
}
if (_nameLabelObj != null)
{
_nameLabelObj.transform.position = transform.position + Vector3.up * 2.8f;
var cam = Camera.main;
if (cam != null)
{
Vector3 lookDir = cam.transform.position - _nameLabelObj.transform.position;
float camDist = lookDir.magnitude;
lookDir.y = 0f;
if (lookDir.sqrMagnitude > 0.001f)
_nameLabelObj.transform.rotation = Quaternion.LookRotation(lookDir);
float scale = Mathf.Clamp(camDist / 8f, 1f, 8f);
_nameLabelObj.transform.localScale = Vector3.one * (0.1f * scale);
}
}
}
private void BuildNameLabel(string playerName, Color color)
{
_nameLabelObj = new GameObject("NameLabel");
_nameLabelObj.transform.position = transform.position + Vector3.up * 2.8f;
_nameLabelObj.transform.localScale = Vector3.one * 0.1f;
_nameLabel = _nameLabelObj.AddComponent();
_nameLabel.text = playerName;
_nameLabel.fontSize = 144;
_nameLabel.characterSize = 0.15f;
_nameLabel.anchor = TextAnchor.MiddleCenter;
_nameLabel.alignment = TextAlignment.Center;
_nameLabel.color = color;
if (PlayerController.LabelFont != null) _nameLabel.font = PlayerController.LabelFont;
var mr = _nameLabel.GetComponent();
if (PlayerController.LabelFont != null && PlayerController.LabelFont.material != null)
mr.material = PlayerController.LabelFont.material;
else
{
var s = Shader.Find("GUI/Text Shader") ?? Shader.Find("Unlit/Texture");
if (s != null) mr.material = new Material(s);
}
}
private void BuildColorMarker(Color color)
{
_markerObj = GameObject.CreatePrimitive(PrimitiveType.Capsule);
_markerObj.name = "RemoteMarker";
DestroyImmediate(_markerObj.GetComponent());
_markerObj.transform.SetParent(transform, false);
_markerObj.transform.localPosition = new Vector3(0f, 2.4f, 0f);
_markerObj.transform.localScale = new Vector3(0.25f, 0.4f, 0.25f);
var r = _markerObj.GetComponent();
if (r != null)
{
var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard");
var mat = new Material(shader);
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);
else mat.color = color;
if (mat.HasProperty("_EmissionColor")) mat.SetColor("_EmissionColor", color * 0.6f);
mat.EnableKeyword("_EMISSION");
r.material = mat;
}
}
void OnDestroy()
{
if (_nameLabelObj != null) Destroy(_nameLabelObj);
if (_markerObj != null) Destroy(_markerObj);
Debug.Log($"[RemoteVehicle] Destroyed: {PlayerName}");
}
}