Files
rolld/game/Assets/Scripts/Network/RemotePlayerController.cs
kerboul 32becc12f9 feat: free-roam mode + fix multiplayer sync + remote player polish
Backend (ArenaRoom.js):
- Strip race state machine (lobby/countdown/playing/round/qualify). Persistent
  "playing" phase, no rounds, no checkpoints. Free-roam multi.
- Spawn lowered to y=1.5 (was 5) + MIN_DIST raised to 5 (was 3) to avoid
  ejecting overlapping players at connect.
- Schema kept intact (handshake-safe); deprecated fields default-valued.
- npm run schema:gen wired (anti-drift codegen).

Unity client:
- C# schema generated by schema-codegen into RolldSchema namespace
  (Generated/GameState.cs, Generated/Player.cs). NetworkSchema.cs removed —
  handshake no longer scans global namespace.
- NetworkManager: typed Room<GameState>, callbacks rebound, seeds players
  already in room on join.
- RemotePlayerController:
  * Post-spawn 1.5s grace window (BumpReady) — local PlayerController.HandleBump
    ignores remotes during grace.
  * Solid SphereCollider disabled during grace, re-enabled afterwards — fixes
    the kinematic-vs-dynamic eject when a new client spawns inside someone.
  * NPCBall prefab material switched from invisible-in-URP Default-Material to
    BallShader.shadergraph.
  * TrailRenderer added, tinted with player's chosen color.
  * Name label distance-scales (1x-8x) so pseudos remain readable far away.
- GameHUD: OnGUI emptied — race UI (rounds, mode, timer, playersAlive) gone.
- GameCanvas.jsx: BUILD_PREFIX/VERSION bumped for cache-bust.

Frontend WebGL build (pretty_build): final build with all the above.
2026-05-20 12:25:48 +02:00

378 lines
14 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEngine;
/// <summary>
/// Controls a remote player's ball using snapshot interpolation.
/// Maintains a ring buffer of recent network snapshots and interpolates
/// between them with a fixed delay, producing smooth motion even with jitter.
/// Uses Rigidbody.MovePosition for proper physics collision detection.
/// </summary>
public class RemotePlayerController : MonoBehaviour
{
[Header("Interpolation")]
[Tooltip("Interpolation delay in seconds (higher = smoother, more latency)")]
public float interpolationDelay = 0.083f; // ~83ms = 5 frames at 60Hz
[Tooltip("Max extrapolation time when no new data arrives")]
public float maxExtrapolation = 0.08f; // 80ms — short to avoid overshoot
[Tooltip("If distance exceeds this, snap instead of interpolate")]
public float snapDistance = 8f;
[Tooltip("Final smoothing factor (higher = tighter follow, lower = smoother)")]
public float smoothingSpeed = 24f;
[Tooltip("Rotation slerp speed")]
public float rotationSpeed = 24f;
[Header("Spawn")]
[Tooltip("Seconds after spawn during which this remote ignores local bump interactions (avoids upward eject if balls overlap at spawn).")]
public float spawnBumpGrace = 1.5f;
// Public info
public string SessionId { get; private set; }
public string PlayerName { get; private set; }
public Color PlayerColor { get; private set; }
public float SpawnTime { get; private set; }
public bool BumpReady => Time.time - SpawnTime > spawnBumpGrace;
// --- Snapshot buffer ---
private struct Snapshot
{
public double serverTime; // server timestamp (ms)
public float localTime; // Time.time when received
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 float _firstLocalTime; // local time of first snapshot received
private double _firstServerTime; // server time of first snapshot received
private bool _initialized;
private Quaternion _currentRotation = Quaternion.identity;
private Rigidbody _rb; // Cached for MovePosition
// Optional: floating name label
private TextMesh _nameLabel;
/// <summary>
/// Called by NetworkManager when spawning this remote player.
/// </summary>
public void Initialize(string sessionId, string playerName, Color color)
{
SessionId = sessionId;
PlayerName = playerName;
PlayerColor = color;
SpawnTime = Time.time;
_currentRotation = transform.rotation;
_bufferCount = 0;
_initialized = true;
// Apply color tint (multiply blend to keep pattern visible)
var renderer = GetComponent<Renderer>();
if (renderer != null)
{
var mat = new Material(renderer.sharedMaterial);
Color original = Color.white;
if (mat.HasProperty("_BaseColor")) original = mat.GetColor("_BaseColor");
else if (mat.HasProperty("_Color")) original = mat.GetColor("_Color");
float strength = 0.7f;
Color tint = new Color(
Mathf.Lerp(original.r, original.r * color.r * 2f, strength),
Mathf.Lerp(original.g, original.g * color.g * 2f, strength),
Mathf.Lerp(original.b, original.b * color.b * 2f, strength),
original.a
);
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", tint);
if (mat.HasProperty("_Color")) mat.color = tint;
renderer.material = mat;
}
// Kinematic rigidbody with MovePosition for proper collision detection
_rb = GetComponent<Rigidbody>();
if (_rb != null)
{
_rb.isKinematic = true;
_rb.useGravity = false;
_rb.interpolation = RigidbodyInterpolation.Interpolate;
_rb.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative;
}
// Add a trigger collider slightly larger than the physics collider
// so the local player can detect bumps
_solidCollider = GetComponent<SphereCollider>();
float baseRadius = _solidCollider != null ? _solidCollider.radius : 0.5f;
var trigger = gameObject.AddComponent<SphereCollider>();
trigger.isTrigger = true;
trigger.radius = baseRadius * 1.15f; // 15% larger
// During the spawn grace window, disable the SOLID collider so the kinematic
// remote can't physically eject an overlapping local player at spawn.
// (The trigger stays — bump detection is gated by BumpReady in PlayerController.)
if (_solidCollider != null) _solidCollider.enabled = false;
// Disable any player input on remote balls
var playerInput = GetComponent<UnityEngine.InputSystem.PlayerInput>();
if (playerInput != null)
playerInput.enabled = false;
var playerController = GetComponent<PlayerController>();
if (playerController != null)
playerController.enabled = false;
// Create floating name label + speed trail
CreateNameLabel();
CreateTrail(color);
Debug.Log($"[RemotePlayer] Initialized: {playerName} ({sessionId[..6]}) color={color}");
}
private SphereCollider _solidCollider;
private bool _solidReenabled;
/// <summary>
/// Called by NetworkManager when a state update arrives from the server.
/// Pushes a new snapshot into the interpolation buffer.
/// </summary>
public void SetTargetState(Vector3 position, Vector3 velocity, Quaternion rotation, double serverTime, Vector3 angularVelocity = default)
{
// Bootstrap time mapping on first snapshot
if (_bufferCount == 0)
{
_firstLocalTime = Time.time;
_firstServerTime = serverTime;
}
// Advance ring buffer
_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++;
}
void Update()
{
if (!_initialized) return;
// Re-enable the solid collider once the grace window has elapsed.
if (!_solidReenabled && BumpReady && _solidCollider != null)
{
_solidCollider.enabled = true;
_solidReenabled = true;
}
if (_bufferCount == 0) return;
// Render time = current time minus interpolation delay
float renderTime = Time.time - interpolationDelay;
// Build a sorted view of the buffer (oldest → newest by localTime)
// to safely find the two bracketing snapshots
int oldestIdx = (_newestIndex - _bufferCount + 1 + BUFFER_SIZE) % BUFFER_SIZE;
Snapshot older = default;
Snapshot newer = default;
bool found = false;
for (int i = 0; i < _bufferCount - 1; i++)
{
int idxA = (oldestIdx + i) % BUFFER_SIZE;
int idxB = (oldestIdx + i + 1) % BUFFER_SIZE;
if (_buffer[idxA].localTime <= renderTime && _buffer[idxB].localTime >= renderTime)
{
older = _buffer[idxA];
newer = _buffer[idxB];
found = true;
break;
}
}
Vector3 targetPos;
Quaternion targetRot;
if (found)
{
// Interpolate between the two bounding snapshots
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
{
// No bracketing pair found
var newest = _buffer[_newestIndex];
float elapsed = renderTime - newest.localTime;
if (elapsed < 0)
{
// Render time is earlier than all snapshots — use oldest, don't extrapolate backwards
targetPos = _buffer[oldestIdx].position;
targetRot = _buffer[oldestIdx].rotation;
}
else
{
// Extrapolate forward from newest, but with velocity damping
float extTime = Mathf.Min(elapsed, maxExtrapolation);
float dampFactor = 1f - Mathf.Clamp01(elapsed / (maxExtrapolation * 2f)); // fade to 0
targetPos = newest.position + newest.velocity * extTime * dampFactor;
targetRot = newest.rotation;
}
}
// Final smoothing layer: lerp from current position toward computed target
float dist = Vector3.Distance(transform.position, targetPos);
Vector3 newPos;
if (dist > snapDistance)
{
// Teleport for large distances (spawn, reconnect)
newPos = targetPos;
_currentRotation = targetRot;
}
else
{
float lerpT = 1f - Mathf.Exp(-smoothingSpeed * Time.deltaTime);
newPos = Vector3.Lerp(transform.position, targetPos, lerpT);
}
// Smooth rotation
float rotLerpT = 1f - Mathf.Exp(-rotationSpeed * Time.deltaTime);
_currentRotation = Quaternion.Slerp(_currentRotation, targetRot, rotLerpT);
// Use MovePosition/MoveRotation for proper collision detection
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 * 1.5f;
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);
// Distance-based scale: keeps the pseudo readable when players are far apart.
// Below 8 m → base size; above → grows linearly, capped at 8× to avoid screen takeover.
float scaleFactor = Mathf.Clamp(camDist / 8f, 1f, 8f);
_nameLabelObj.transform.localScale = Vector3.one * (0.1f * scaleFactor);
}
}
}
private GameObject _nameLabelObj; // Keep reference for billboard update
private void CreateTrail(Color playerColor)
{
var trail = GetComponent<TrailRenderer>() ?? gameObject.AddComponent<TrailRenderer>();
trail.time = 0.4f;
trail.startWidth = 0.3f;
trail.endWidth = 0.02f;
trail.minVertexDistance = 0.1f;
trail.autodestruct = false;
trail.emitting = true;
trail.material = new Material(Shader.Find("Sprites/Default"));
// Use the player's chosen color so each remote has a visually distinct trail
// (lobby presets avoid orange, so it never clashes with the local player's orange trail).
trail.startColor = new Color(playerColor.r, playerColor.g, playerColor.b, 0.7f);
trail.endColor = new Color(playerColor.r, playerColor.g, playerColor.b, 0f);
}
private void CreateNameLabel()
{
GameObject labelObj = new GameObject("NameLabel");
// Do NOT parent to transform — ball rotation would spin the label
labelObj.transform.position = transform.position + Vector3.up * 1.5f;
labelObj.transform.localScale = Vector3.one * 0.1f;
_nameLabelObj = labelObj;
_nameLabel = labelObj.AddComponent<TextMesh>();
_nameLabel.text = PlayerName;
_nameLabel.fontSize = 144;
_nameLabel.characterSize = 0.15f;
_nameLabel.anchor = TextAnchor.MiddleCenter;
_nameLabel.alignment = TextAlignment.Center;
_nameLabel.color = Color.white;
var font = PlayerController.LabelFont;
if (font != null) _nameLabel.font = font;
var meshRenderer = _nameLabel.GetComponent<MeshRenderer>();
if (font != null && font.material != null)
meshRenderer.material = font.material;
else
{
var textShader = Shader.Find("GUI/Text Shader") ?? Shader.Find("Unlit/Texture");
if (textShader != null) meshRenderer.material = new Material(textShader);
}
}
/// <summary>Called by NetworkManager when the player's team or color changes (e.g. teams mode).</summary>
public void UpdateTeamColor(int team, Color serverColor)
{
// Only re-tint if the color actually changed significantly
if (PlayerColor == serverColor) return;
PlayerColor = serverColor;
var renderer = GetComponent<Renderer>();
if (renderer == null) return;
var mat = renderer.material;
if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", serverColor);
else mat.color = serverColor;
// Update name label color to match team
if (_nameLabel != null)
{
_nameLabel.color = team == 1
? new Color(1f, 0.5f, 0.5f)
: team == 2
? new Color(0.5f, 0.7f, 1f)
: Color.white;
}
}
/// <summary>Show or hide this remote player (used when eliminated).</summary>
public void SetVisible(bool visible)
{
var renderer = GetComponent<Renderer>();
if (renderer != null) renderer.enabled = visible;
if (_nameLabelObj != null) _nameLabelObj.SetActive(visible);
// Disable physics interactions when hidden
var col = GetComponent<Collider>();
if (col != null) col.enabled = visible;
}
void OnDestroy()
{
if (_nameLabelObj != null) Destroy(_nameLabelObj);
Debug.Log($"[RemotePlayer] Destroyed: {PlayerName}");
}
}