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.
This commit is contained in:
2026-05-20 12:25:48 +02:00
parent ec05fb8ddd
commit 32becc12f9
22 changed files with 288 additions and 453 deletions

View File

@@ -0,0 +1,43 @@
//
// THIS FILE HAS BEEN GENERATED AUTOMATICALLY
// DO NOT CHANGE IT MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING
//
// GENERATED USING @colyseus/schema 4.0.15
//
using Colyseus.Schema;
#if UNITY_5_3_OR_NEWER
using UnityEngine.Scripting;
#endif
namespace RolldSchema {
public partial class GameState : Schema {
#if UNITY_5_3_OR_NEWER
[Preserve]
#endif
public GameState() { }
[Type(0, "map", typeof(MapSchema<Player>))]
public MapSchema<Player> players = null;
[Type(1, "string")]
public string phase = default(string);
[Type(2, "float32")]
public float countdown = default(float);
[Type(3, "int8")]
public sbyte roundNumber = default(sbyte);
[Type(4, "int8")]
public sbyte totalRounds = default(sbyte);
[Type(5, "int8")]
public sbyte playersAlive = default(sbyte);
[Type(6, "string")]
public string gameMode = default(string);
[Type(7, "string")]
public string winnerName = default(string);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c7c8bd319747bfa4a82569b7dc0458be

View File

@@ -0,0 +1,85 @@
//
// THIS FILE HAS BEEN GENERATED AUTOMATICALLY
// DO NOT CHANGE IT MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING
//
// GENERATED USING @colyseus/schema 4.0.15
//
using Colyseus.Schema;
#if UNITY_5_3_OR_NEWER
using UnityEngine.Scripting;
#endif
namespace RolldSchema {
public partial class Player : Schema {
#if UNITY_5_3_OR_NEWER
[Preserve]
#endif
public Player() { }
[Type(0, "float32")]
public float x = default(float);
[Type(1, "float32")]
public float y = default(float);
[Type(2, "float32")]
public float z = default(float);
[Type(3, "float32")]
public float vx = default(float);
[Type(4, "float32")]
public float vy = default(float);
[Type(5, "float32")]
public float vz = default(float);
[Type(6, "float32")]
public float rx = default(float);
[Type(7, "float32")]
public float ry = default(float);
[Type(8, "float32")]
public float rz = default(float);
[Type(9, "float32")]
public float rw = default(float);
[Type(10, "float64")]
public double t = default(double);
[Type(11, "string")]
public string name = default(string);
[Type(12, "float32")]
public float colorR = default(float);
[Type(13, "float32")]
public float colorG = default(float);
[Type(14, "float32")]
public float colorB = default(float);
[Type(15, "float32")]
public float avx = default(float);
[Type(16, "float32")]
public float avy = default(float);
[Type(17, "float32")]
public float avz = default(float);
[Type(18, "boolean")]
public bool isEliminated = default(bool);
[Type(19, "boolean")]
public bool isQualified = default(bool);
[Type(20, "boolean")]
public bool isReady = default(bool);
[Type(21, "int8")]
public sbyte checkpointIndex = default(sbyte);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1d8173f164ec47946a28c13d9638d8cf

View File

@@ -5,6 +5,7 @@ using UnityEngine;
using UnityEngine.Networking;
using Colyseus;
using Colyseus.Schema;
using RolldSchema;
/// <summary>
/// Singleton managing the Colyseus connection, room lifecycle, remote player spawning,
@@ -70,8 +71,8 @@ public class NetworkManager : MonoBehaviour
// --- Internals ---
private Client _client;
private Room<NetworkState> _room;
private StateCallbackStrategy<NetworkState> _callbacks;
private Room<GameState> _room;
private StateCallbackStrategy<GameState> _callbacks;
private readonly Dictionary<string, RemotePlayerController> _remotePlayers = new();
private float _broadcastTimer;
private const float BROADCAST_INTERVAL = 0.01667f; // ~60/sec
@@ -111,7 +112,7 @@ public class NetworkManager : MonoBehaviour
}
}
public NetworkPlayer GetLocalPlayerState()
public Player GetLocalPlayerState()
{
if (_room == null || _room.State.players == null || string.IsNullOrEmpty(LocalSessionId)) return null;
_room.State.players.TryGetValue(LocalSessionId, out var player);
@@ -168,7 +169,7 @@ public class NetworkManager : MonoBehaviour
if (_room.State.players != null)
{
foreach (var key in _room.State.players.Keys)
OnPlayerAdd((string)key, (NetworkPlayer)_room.State.players[key]);
OnPlayerAdd((string)key, (Player)_room.State.players[key]);
}
OnConnected?.Invoke();
@@ -190,7 +191,7 @@ public class NetworkManager : MonoBehaviour
PrepareJoin(playerName, color);
try
{
_room = await _client.JoinOrCreate<NetworkState>("arena", BuildJoinOptions(playerName, color));
_room = await _client.JoinOrCreate<GameState>("arena", BuildJoinOptions(playerName, color));
FinishJoin();
}
catch (Exception e) { HandleJoinError(e); }
@@ -203,7 +204,7 @@ public class NetworkManager : MonoBehaviour
PrepareJoin(playerName, color);
try
{
_room = await _client.JoinById<NetworkState>(roomId, BuildJoinOptions(playerName, color));
_room = await _client.JoinById<GameState>(roomId, BuildJoinOptions(playerName, color));
FinishJoin();
}
catch (Exception e) { HandleJoinError(e); }
@@ -218,7 +219,7 @@ public class NetworkManager : MonoBehaviour
{
var opts = BuildJoinOptions(playerName, color);
if (roomName != null) opts["roomName"] = roomName;
_room = await _client.Create<NetworkState>("arena", opts);
_room = await _client.Create<GameState>("arena", opts);
FinishJoin();
}
catch (Exception e) { HandleJoinError(e); }
@@ -259,7 +260,7 @@ public class NetworkManager : MonoBehaviour
OnPhaseChanged?.Invoke(phase);
}
private void OnPlayerAdd(string sessionId, NetworkPlayer player)
private void OnPlayerAdd(string sessionId, Player player)
{
Debug.Log($"[Network] Player joined: {sessionId} ({player.name})");
PlayerCount = _room.State.players?.Count ?? 0;
@@ -288,7 +289,7 @@ public class NetworkManager : MonoBehaviour
OnPlayerJoined?.Invoke(sessionId);
}
private void OnPlayerRemove(string sessionId, NetworkPlayer player)
private void OnPlayerRemove(string sessionId, Player player)
{
Debug.Log($"[Network] Player left: {sessionId}");
PlayerCount = _room.State.players?.Count ?? 0;
@@ -303,7 +304,7 @@ public class NetworkManager : MonoBehaviour
OnPlayerLeft?.Invoke(sessionId);
}
private void OnPlayerChange(string sessionId, NetworkPlayer player)
private void OnPlayerChange(string sessionId, Player player)
{
if (sessionId == LocalSessionId) return;

View File

@@ -1,57 +0,0 @@
// Generated from @colyseus/schema 4.0.15 — DO NOT EDIT MANUALLY
// Class names kept as NetworkPlayer/NetworkState to match existing codebase references.
using Colyseus.Schema;
#if UNITY_5_3_OR_NEWER
using UnityEngine.Scripting;
#endif
public partial class NetworkPlayer : Schema
{
#if UNITY_5_3_OR_NEWER
[Preserve]
#endif
public NetworkPlayer() { }
[Type(0, "float32")] public float x = 0;
[Type(1, "float32")] public float y = 5;
[Type(2, "float32")] public float z = 0;
[Type(3, "float32")] public float vx = 0;
[Type(4, "float32")] public float vy = 0;
[Type(5, "float32")] public float vz = 0;
[Type(6, "float32")] public float rx = 0;
[Type(7, "float32")] public float ry = 0;
[Type(8, "float32")] public float rz = 0;
[Type(9, "float32")] public float rw = 1;
[Type(10, "float64")] public double t = 0;
[Type(11, "string")] public string name = "";
[Type(12, "float32")] public float colorR = 1;
[Type(13, "float32")] public float colorG = 1;
[Type(14, "float32")] public float colorB = 1;
[Type(15, "float32")] public float avx = 0;
[Type(16, "float32")] public float avy = 0;
[Type(17, "float32")] public float avz = 0;
[Type(18, "boolean")] public bool isEliminated = false;
[Type(19, "boolean")] public bool isQualified = false;
[Type(20, "boolean")] public bool isReady = false;
[Type(21, "int8")] public sbyte checkpointIndex = 0;
}
public partial class NetworkState : Schema
{
#if UNITY_5_3_OR_NEWER
[Preserve]
#endif
public NetworkState() { }
[Type(0, "map", typeof(MapSchema<NetworkPlayer>))]
public MapSchema<NetworkPlayer> players = null;
[Type(1, "string")] public string phase = "lobby";
[Type(2, "float32")] public float countdown = 0;
[Type(3, "int8")] public sbyte roundNumber = 1;
[Type(4, "int8")] public sbyte totalRounds = 3;
[Type(5, "int8")] public sbyte playersAlive = 0;
[Type(6, "string")] public string gameMode = "race";
[Type(7, "string")] public string winnerName = "";
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 0ce16348bc0580b49860d9bd80e7bec0

View File

@@ -24,10 +24,16 @@ public class RemotePlayerController : MonoBehaviour
[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
@@ -61,6 +67,7 @@ public class RemotePlayerController : MonoBehaviour
SessionId = sessionId;
PlayerName = playerName;
PlayerColor = color;
SpawnTime = Time.time;
_currentRotation = transform.rotation;
_bufferCount = 0;
_initialized = true;
@@ -99,12 +106,17 @@ public class RemotePlayerController : MonoBehaviour
// Add a trigger collider slightly larger than the physics collider
// so the local player can detect bumps
var existingCollider = GetComponent<SphereCollider>();
float baseRadius = existingCollider != null ? existingCollider.radius : 0.5f;
_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)
@@ -114,12 +126,16 @@ public class RemotePlayerController : MonoBehaviour
if (playerController != null)
playerController.enabled = false;
// Create floating name label
// 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.
@@ -149,7 +165,16 @@ public class RemotePlayerController : MonoBehaviour
void Update()
{
if (!_initialized || _bufferCount == 0) return;
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;
@@ -248,15 +273,37 @@ public class RemotePlayerController : MonoBehaviour
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");

View File

@@ -101,108 +101,7 @@ public class GameHUD : MonoBehaviour
void OnGUI()
{
if (_phase == "lobby" && !_localRaceActive) return;
ImGuiSkin.EnsureReady();
var nm = NetworkManager.Instance;
// ── Countdown (center, large) ─────────────────────────────────────
if (_phase == "countdown" && _countdown > 0f)
{
float scale = 1f + _countdownPulse * 0.4f;
float fontSize = 96f * scale;
var countStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = Mathf.RoundToInt(fontSize),
fontStyle = FontStyle.Bold,
};
countStyle.normal.textColor = new Color(1f, 0.85f, 0.1f, 1f);
GUI.Label(new Rect(0, Screen.height * 0.3f, Screen.width, 120f),
Mathf.CeilToInt(_countdown).ToString(), countStyle);
// "Préparez-vous !" label below
var subStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 22,
fontStyle = FontStyle.Bold,
};
subStyle.normal.textColor = new Color(1f, 1f, 1f, 0.8f);
string modeLabel = _gameMode switch {
"race" => "COURSE",
"survival" => "SURVIVAL",
"teams" => "ÉQUIPES",
_ => _gameMode.ToUpper()
};
GUI.Label(new Rect(0, Screen.height * 0.3f + 110f, Screen.width, 36f),
$"— {modeLabel} —", subStyle);
return;
}
// ── Top-left: Round & Mode ─────────────────────────────────────────
float panelX = 12f;
float panelY = 12f;
float panelW = 220f;
float panelH = 70f;
GUI.color = new Color(0.08f, 0.08f, 0.12f, 0.85f);
GUI.DrawTexture(new Rect(panelX, panelY, panelW, panelH), _bgTex);
GUI.color = Color.white;
var roundStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleLeft,
fontSize = 14,
fontStyle = FontStyle.Bold,
};
roundStyle.normal.textColor = new Color(1f, 0.85f, 0.1f);
GUI.Label(new Rect(panelX + 8f, panelY + 4f, panelW - 16f, 28f),
$"ROUND {_roundNumber} / {_totalRounds}", roundStyle);
var modeStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleLeft, fontSize = 12 };
modeStyle.normal.textColor = new Color(0.7f, 0.7f, 0.85f);
string modeFull = _gameMode switch {
"race" => "COURSE", "survival" => "SURVIVAL", "teams" => "ÉQUIPES", _ => _gameMode.ToUpper()
};
GUI.Label(new Rect(panelX + 8f, panelY + 32f, panelW - 16f, 24f), modeFull, modeStyle);
// ── Top-right: Players alive ──────────────────────────────────────
float prX = Screen.width - 180f;
GUI.color = new Color(0.08f, 0.08f, 0.12f, 0.85f);
GUI.DrawTexture(new Rect(prX, panelY, 168f, panelH), _bgTex);
GUI.color = Color.white;
var aliveStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 28,
fontStyle = FontStyle.Bold,
};
aliveStyle.normal.textColor = new Color(0.3f, 1f, 0.5f);
GUI.Label(new Rect(prX, panelY + 2f, 168f, 40f), $"{_cachedPlayersAlive}", aliveStyle);
var aliveLabel = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11 };
aliveLabel.normal.textColor = new Color(0.6f, 0.6f, 0.7f);
GUI.Label(new Rect(prX, panelY + 40f, 168f, 22f), "joueurs en jeu", aliveLabel);
// ── Round timer (top center) ──────────────────────────────────────
float displayTimer = _timerRunning ? _roundTimer : (_localRaceActive ? _localRaceTimer : -1f);
if (displayTimer >= 0f)
{
int mins = Mathf.FloorToInt(displayTimer / 60f);
int secs = Mathf.FloorToInt(displayTimer % 60f);
var timerStyle = new GUIStyle(GUI.skin.label)
{
alignment = TextAnchor.MiddleCenter,
fontSize = 18,
fontStyle = FontStyle.Bold,
};
timerStyle.normal.textColor = new Color(0.85f, 0.85f, 0.9f, 0.9f);
GUI.Label(new Rect(Screen.width * 0.5f - 60f, panelY, 120f, 40f),
$"{mins:00}:{secs:00}", timerStyle);
}
// Race UI disabled — free-roam mode has no rounds/countdown/timer.
}
// Static accessors for cross-script use