feat: add Unity project (Assets, ProjectSettings)
This commit is contained in:
171
game/Assets/Scripts/Network/DebugNetworkUI.cs
Normal file
171
game/Assets/Scripts/Network/DebugNetworkUI.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Debug overlay:
|
||||
/// – Always-visible HUD strip at the top (player name, status, room, FPS)
|
||||
/// – Detailed panel toggled with F1 (full network + physics info)
|
||||
/// Uses Dear ImGui–style skin via ImGuiSkin.
|
||||
/// </summary>
|
||||
public class DebugNetworkUI : MonoBehaviour
|
||||
{
|
||||
private bool _detailsVisible = false;
|
||||
private Vector2 _scrollPos;
|
||||
|
||||
// FPS tracking
|
||||
private float _fpsTimer;
|
||||
private int _fpsCount;
|
||||
private float _currentFps;
|
||||
|
||||
void Update()
|
||||
{
|
||||
// FPS counter
|
||||
_fpsTimer += Time.unscaledDeltaTime;
|
||||
_fpsCount++;
|
||||
if (_fpsTimer >= 0.5f)
|
||||
{
|
||||
_currentFps = _fpsCount / _fpsTimer;
|
||||
_fpsTimer = 0f;
|
||||
_fpsCount = 0;
|
||||
}
|
||||
|
||||
// Toggle detailed panel with F1
|
||||
if (Keyboard.current != null && Keyboard.current[Key.F1].wasPressedThisFrame)
|
||||
_detailsVisible = !_detailsVisible;
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
ImGuiSkin.EnsureReady();
|
||||
|
||||
var nm = NetworkManager.Instance;
|
||||
if (nm == null) return;
|
||||
|
||||
DrawHUDStrip(nm);
|
||||
|
||||
if (_detailsVisible)
|
||||
DrawDetailPanel(nm);
|
||||
|
||||
// Hint
|
||||
GUI.Label(new Rect(10, Screen.height - 25, 300, 20), "F1 — Debug details", ImGuiSkin.Footer);
|
||||
}
|
||||
|
||||
// ───────── HUD Strip (always visible) ─────────
|
||||
|
||||
private void DrawHUDStrip(NetworkManager nm)
|
||||
{
|
||||
float h = 28;
|
||||
ImGuiSkin.DrawHudStripBg(h);
|
||||
|
||||
string dot = nm.IsConnected
|
||||
? "<color=#44FF44>\u25CF</color>"
|
||||
: "<color=#FF4444>\u25CF</color>";
|
||||
|
||||
string info;
|
||||
if (nm.IsConnected)
|
||||
{
|
||||
string name = !string.IsNullOrEmpty(nm.LocalPlayerName) ? nm.LocalPlayerName : "\u2014";
|
||||
string room = !string.IsNullOrEmpty(nm.RoomId) ? nm.RoomId[..Mathf.Min(8, nm.RoomId.Length)] : "\u2014";
|
||||
string sess = !string.IsNullOrEmpty(nm.LocalSessionId) ? nm.LocalSessionId[..Mathf.Min(6, nm.LocalSessionId.Length)] : "\u2014";
|
||||
info = $" {dot} <b>{name}</b> | Room {room} | Sess {sess} | {nm.PlayerCount}P | {nm.serverURL} | {_currentFps:F0} FPS";
|
||||
}
|
||||
else
|
||||
{
|
||||
info = $" {dot} {nm.ConnectionStatus} | {nm.serverURL} | {_currentFps:F0} FPS";
|
||||
}
|
||||
|
||||
GUI.Label(new Rect(0, 0, Screen.width, h), info, ImGuiSkin.HudLabel);
|
||||
}
|
||||
|
||||
// ───────── Detail Panel (F1) ─────────
|
||||
|
||||
private void DrawDetailPanel(NetworkManager nm)
|
||||
{
|
||||
float w = 360, h = 480;
|
||||
float x = Screen.width - w - 12;
|
||||
float y = 38;
|
||||
|
||||
ImGuiSkin.BeginWindowAt(x, y, w, h, "Network Debug");
|
||||
|
||||
// ── Connection ──
|
||||
ImGuiSkin.DrawSectionHeader("CONNECTION");
|
||||
GUILayout.Space(2);
|
||||
GUIStyle statusStyle = nm.IsConnected ? ImGuiSkin.StatusGreen : ImGuiSkin.StatusRed;
|
||||
GUILayout.Label($"\u25CF {nm.ConnectionStatus}", statusStyle);
|
||||
|
||||
ImGuiSkin.DrawField("Server", nm.serverURL);
|
||||
ImGuiSkin.DrawField("Room ID", string.IsNullOrEmpty(nm.RoomId) ? "\u2014" : nm.RoomId);
|
||||
ImGuiSkin.DrawField("Session", string.IsNullOrEmpty(nm.LocalSessionId) ? "\u2014" : nm.LocalSessionId);
|
||||
ImGuiSkin.DrawField("Players", nm.PlayerCount.ToString());
|
||||
ImGuiSkin.DrawField("FPS", $"{_currentFps:F0}");
|
||||
|
||||
if (!string.IsNullOrEmpty(nm.LastError))
|
||||
{
|
||||
GUILayout.Space(2);
|
||||
GUILayout.Label($"\u26A0 {nm.LastError}", ImGuiSkin.StatusRed);
|
||||
}
|
||||
|
||||
GUILayout.Space(6);
|
||||
|
||||
// ── Local Player ──
|
||||
ImGuiSkin.DrawSectionHeader("LOCAL PLAYER");
|
||||
GUILayout.Space(2);
|
||||
ImGuiSkin.DrawField("Name", string.IsNullOrEmpty(nm.LocalPlayerName) ? "\u2014" : nm.LocalPlayerName);
|
||||
|
||||
var state = nm.GetLocalPlayerState();
|
||||
if (state != null)
|
||||
ImGuiSkin.DrawField("Server Pos", $"({state.x:F1}, {state.y:F1}, {state.z:F1})");
|
||||
|
||||
var pc = FindFirstObjectByType<PlayerController>();
|
||||
if (pc != null && pc.isActiveAndEnabled)
|
||||
{
|
||||
var pos = pc.transform.position;
|
||||
ImGuiSkin.DrawField("Live Pos", $"({pos.x:F1}, {pos.y:F1}, {pos.z:F1})");
|
||||
var rb = pc.GetComponent<Rigidbody>();
|
||||
if (rb != null)
|
||||
{
|
||||
var v = rb.linearVelocity;
|
||||
ImGuiSkin.DrawField("Velocity", $"({v.x:F1}, {v.y:F1}, {v.z:F1}) [{v.magnitude:F1} m/s]");
|
||||
}
|
||||
}
|
||||
|
||||
GUILayout.Space(6);
|
||||
|
||||
// ── Remote Players ──
|
||||
ImGuiSkin.DrawSectionHeader("REMOTE PLAYERS");
|
||||
GUILayout.Space(2);
|
||||
_scrollPos = GUILayout.BeginScrollView(_scrollPos, ImGuiSkin.ScrollView, GUILayout.Height(100));
|
||||
|
||||
if (nm.RemotePlayers != null && nm.RemotePlayers.Count > 0)
|
||||
{
|
||||
foreach (var kvp in nm.RemotePlayers)
|
||||
{
|
||||
if (kvp.Value == null) continue;
|
||||
var rp = kvp.Value;
|
||||
string dist = "";
|
||||
if (pc != null && pc.isActiveAndEnabled)
|
||||
{
|
||||
float d = Vector3.Distance(pc.transform.position, rp.transform.position);
|
||||
dist = $" [{d:F1}m]";
|
||||
}
|
||||
GUILayout.Label($" {rp.PlayerName} ({kvp.Key[..Mathf.Min(6, kvp.Key.Length)]}){dist}", ImGuiSkin.Label);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
GUILayout.Label(" (aucun joueur distant)", ImGuiSkin.LabelDim);
|
||||
}
|
||||
|
||||
GUILayout.EndScrollView();
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (nm.IsConnected)
|
||||
{
|
||||
if (GUILayout.Button("Déconnecter", ImGuiSkin.Button, GUILayout.Height(28)))
|
||||
nm.LeaveRoom();
|
||||
}
|
||||
|
||||
ImGuiSkin.EndWindow();
|
||||
}
|
||||
|
||||
}
|
||||
2
game/Assets/Scripts/Network/DebugNetworkUI.cs.meta
Normal file
2
game/Assets/Scripts/Network/DebugNetworkUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0b20e36c3b15f32449bf872f27bee467
|
||||
337
game/Assets/Scripts/Network/LobbyUI.cs
Normal file
337
game/Assets/Scripts/Network/LobbyUI.cs
Normal file
@@ -0,0 +1,337 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Lobby UI displayed at scene start. Player enters a name, picks a color,
|
||||
/// and clicks "Rejoindre" to connect to the arena.
|
||||
/// Manages the full pre-game → in-game transition:
|
||||
/// - Hides the Player hierarchy until connected
|
||||
/// - Activates a spectator camera while in lobby
|
||||
/// - Teleports the player ball to the server spawn position on join
|
||||
/// Uses Dear ImGui–style skin via ImGuiSkin.
|
||||
/// </summary>
|
||||
public class LobbyUI : MonoBehaviour
|
||||
{
|
||||
[Header("Scene References")]
|
||||
[Tooltip("The root 'Player' GameObject (contains PlayerSphere + cameras). Will be deactivated until connected.")]
|
||||
public GameObject playerRoot;
|
||||
|
||||
[Tooltip("The spectator camera GameObject (SpectatorCamera component).")]
|
||||
public SpectatorCamera spectatorCamera;
|
||||
|
||||
// Preset colors for selection
|
||||
private static readonly Color[] PresetColors = new Color[]
|
||||
{
|
||||
new Color(1f, 0.35f, 0.2f), // Orange-red
|
||||
new Color(0.2f, 0.6f, 1f), // Blue
|
||||
new Color(0.3f, 1f, 0.4f), // Green
|
||||
new Color(1f, 0.85f, 0.1f), // Yellow
|
||||
new Color(0.8f, 0.3f, 1f), // Purple
|
||||
new Color(1f, 0.5f, 0.7f), // Pink
|
||||
};
|
||||
|
||||
private static readonly string[] ColorNames = new string[]
|
||||
{
|
||||
"Rouge", "Bleu", "Vert", "Jaune", "Violet", "Rose"
|
||||
};
|
||||
|
||||
// UI state
|
||||
private bool _lobbyActive = true;
|
||||
private string _playerName = "";
|
||||
private int _selectedColorIndex = 0;
|
||||
private string _statusMessage = "";
|
||||
private bool _isConnecting = false;
|
||||
private bool _isReady = false;
|
||||
|
||||
// Cached color preview texture (avoid per-frame leak)
|
||||
private Texture2D _colorPreviewTex;
|
||||
private int _lastPreviewColorIndex = -1;
|
||||
|
||||
void Start()
|
||||
{
|
||||
// Generate a default name
|
||||
_playerName = "Joueur" + Random.Range(100, 999);
|
||||
|
||||
// --- Hide the player hierarchy until connected ---
|
||||
if (playerRoot != null)
|
||||
playerRoot.SetActive(false);
|
||||
|
||||
// --- Activate spectator camera ---
|
||||
if (spectatorCamera != null)
|
||||
{
|
||||
// Wire the gameplay camera reference so spectator knows what to re-enable
|
||||
var gameplayCam = playerRoot?.GetComponentInChildren<Camera>(true);
|
||||
if (gameplayCam != null)
|
||||
spectatorCamera.gameplayCamera = gameplayCam;
|
||||
|
||||
spectatorCamera.Activate();
|
||||
}
|
||||
|
||||
// Subscribe to network events
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.OnConnected += OnConnected;
|
||||
NetworkManager.Instance.OnDisconnected += OnDisconnected;
|
||||
}
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.OnConnected -= OnConnected;
|
||||
NetworkManager.Instance.OnDisconnected -= OnDisconnected;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnected()
|
||||
{
|
||||
_lobbyActive = false;
|
||||
_isConnecting = false;
|
||||
_statusMessage = "";
|
||||
|
||||
// --- Activate the player hierarchy ---
|
||||
if (playerRoot != null)
|
||||
playerRoot.SetActive(true);
|
||||
|
||||
// Teleport player ball to the server-assigned spawn position
|
||||
var nm = NetworkManager.Instance;
|
||||
if (nm != null && playerRoot != null)
|
||||
{
|
||||
var pc = playerRoot.GetComponentInChildren<PlayerController>(true);
|
||||
if (pc != null)
|
||||
{
|
||||
// Get spawn pos from the local player's state in the room
|
||||
var localState = nm.GetLocalPlayerState();
|
||||
if (localState != null)
|
||||
{
|
||||
Vector3 spawnPos = new Vector3(localState.x, localState.y, localState.z);
|
||||
var rb = pc.GetComponent<Rigidbody>();
|
||||
if (rb != null)
|
||||
{
|
||||
rb.linearVelocity = Vector3.zero;
|
||||
rb.angularVelocity = Vector3.zero;
|
||||
rb.position = spawnPos;
|
||||
}
|
||||
pc.transform.position = spawnPos;
|
||||
Debug.Log($"[Lobby] Player teleported to spawn: {spawnPos}");
|
||||
}
|
||||
pc.enabled = true;
|
||||
|
||||
// Setup local player visuals: 50% color tint + floating name label
|
||||
pc.SetupLocalPlayer(nm.LocalPlayerName, nm.LocalPlayerColor);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Switch from spectator to gameplay camera ---
|
||||
if (spectatorCamera != null)
|
||||
spectatorCamera.Deactivate();
|
||||
|
||||
// Unlock cursor for gameplay
|
||||
Cursor.lockState = CursorLockMode.Locked;
|
||||
Cursor.visible = false;
|
||||
}
|
||||
|
||||
private void OnDisconnected()
|
||||
{
|
||||
_lobbyActive = true;
|
||||
_isConnecting = false;
|
||||
_isReady = false;
|
||||
_statusMessage = "Déconnecté du serveur";
|
||||
|
||||
// Show cursor for lobby
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
|
||||
// --- Deactivate the player hierarchy ---
|
||||
if (playerRoot != null)
|
||||
{
|
||||
var pc = playerRoot.GetComponentInChildren<PlayerController>(true);
|
||||
if (pc != null) pc.enabled = false;
|
||||
playerRoot.SetActive(false);
|
||||
}
|
||||
|
||||
// --- Re-enable spectator camera ---
|
||||
if (spectatorCamera != null)
|
||||
spectatorCamera.Activate();
|
||||
}
|
||||
|
||||
void OnGUI()
|
||||
{
|
||||
if (!_lobbyActive) return;
|
||||
|
||||
ImGuiSkin.EnsureReady();
|
||||
|
||||
if (Cursor.lockState != CursorLockMode.None)
|
||||
{
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
Cursor.visible = true;
|
||||
}
|
||||
|
||||
ImGuiSkin.DrawOverlay();
|
||||
|
||||
bool isConnected = NetworkManager.Instance != null && NetworkManager.Instance.IsConnected;
|
||||
|
||||
if (!isConnected)
|
||||
{
|
||||
// ── Pre-connect panel ────────────────────────────────────────
|
||||
float panelWidth = 420;
|
||||
float panelHeight = 440;
|
||||
ImGuiSkin.BeginWindow(panelWidth, panelHeight, "ROLL'D");
|
||||
|
||||
GUILayout.Label("Rejoindre l'arène multijoueur", ImGuiSkin.WindowSubtitle);
|
||||
GUILayout.Space(16);
|
||||
|
||||
ImGuiSkin.DrawSectionHeader("PSEUDO");
|
||||
GUILayout.Space(4);
|
||||
_playerName = GUILayout.TextField(_playerName, 16, ImGuiSkin.TextField, GUILayout.Height(30));
|
||||
GUILayout.Space(12);
|
||||
|
||||
ImGuiSkin.DrawSectionHeader("COULEUR");
|
||||
GUILayout.Space(6);
|
||||
|
||||
GUILayout.BeginHorizontal();
|
||||
for (int i = 0; i < PresetColors.Length; i++)
|
||||
{
|
||||
Color c = PresetColors[i];
|
||||
bool selected = _selectedColorIndex == i;
|
||||
Color prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = selected ? c : c * 0.7f;
|
||||
GUIStyle btnStyle = new GUIStyle(ImGuiSkin.ButtonSmall)
|
||||
{
|
||||
fontStyle = selected ? FontStyle.Bold : FontStyle.Normal,
|
||||
};
|
||||
if (selected) btnStyle.normal.textColor = Color.white;
|
||||
string label = selected ? $"▸ {ColorNames[i]}" : ColorNames[i];
|
||||
if (GUILayout.Button(label, btnStyle, GUILayout.Height(32), GUILayout.Width(60)))
|
||||
_selectedColorIndex = i;
|
||||
GUI.backgroundColor = prevBg;
|
||||
}
|
||||
GUILayout.EndHorizontal();
|
||||
GUILayout.Space(4);
|
||||
|
||||
if (_colorPreviewTex == null || _lastPreviewColorIndex != _selectedColorIndex)
|
||||
{
|
||||
if (_colorPreviewTex == null)
|
||||
{
|
||||
_colorPreviewTex = new Texture2D(1, 1, TextureFormat.RGBA32, false);
|
||||
_colorPreviewTex.hideFlags = HideFlags.HideAndDontSave;
|
||||
}
|
||||
_colorPreviewTex.SetPixel(0, 0, PresetColors[_selectedColorIndex]);
|
||||
_colorPreviewTex.Apply();
|
||||
_lastPreviewColorIndex = _selectedColorIndex;
|
||||
}
|
||||
GUILayout.BeginHorizontal();
|
||||
GUILayout.FlexibleSpace();
|
||||
GUILayout.Box(_colorPreviewTex, GUIStyle.none, GUILayout.Width(80), GUILayout.Height(16));
|
||||
GUILayout.FlexibleSpace();
|
||||
GUILayout.EndHorizontal();
|
||||
GUILayout.Space(16);
|
||||
|
||||
GUI.enabled = !_isConnecting && !string.IsNullOrWhiteSpace(_playerName);
|
||||
string buttonText = _isConnecting ? "Connexion..." : "▶ Rejoindre l'arène";
|
||||
if (GUILayout.Button(buttonText, ImGuiSkin.ButtonAccent, GUILayout.Height(44)))
|
||||
JoinArena();
|
||||
GUI.enabled = true;
|
||||
GUILayout.Space(8);
|
||||
|
||||
if (!string.IsNullOrEmpty(_statusMessage))
|
||||
{
|
||||
bool isError = _statusMessage.Contains("Erreur") || _statusMessage.Contains("Déconnecté");
|
||||
GUIStyle statusStyle = isError ? ImGuiSkin.StatusRed : new GUIStyle(ImGuiSkin.Hint);
|
||||
if (!isError) statusStyle.normal.textColor = ImGuiSkin.ColYellow;
|
||||
GUILayout.Label(_statusMessage, statusStyle);
|
||||
}
|
||||
|
||||
ImGuiSkin.EndWindow();
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Waiting room panel (connected, waiting for game to start) ──
|
||||
float panelWidth = 380;
|
||||
float panelHeight = 320;
|
||||
ImGuiSkin.BeginWindow(panelWidth, panelHeight, "SALLE D'ATTENTE");
|
||||
|
||||
GUILayout.Label("En attente des joueurs...", ImGuiSkin.WindowSubtitle);
|
||||
GUILayout.Space(12);
|
||||
|
||||
// Player list
|
||||
ImGuiSkin.DrawSectionHeader("JOUEURS CONNECTÉS");
|
||||
GUILayout.Space(4);
|
||||
var nm = NetworkManager.Instance;
|
||||
if (nm != null && nm.IsConnected)
|
||||
{
|
||||
// We can't directly iterate NetworkState.players from here easily,
|
||||
// so show basic count
|
||||
var style = new GUIStyle(GUI.skin.label) { fontSize = 13 };
|
||||
style.normal.textColor = new Color(0.75f, 0.75f, 0.85f);
|
||||
GUILayout.Label($" {nm.PlayerCount} joueur(s) dans la salle", style);
|
||||
}
|
||||
GUILayout.Space(16);
|
||||
|
||||
// Ready button
|
||||
if (!_isReady)
|
||||
{
|
||||
if (GUILayout.Button("✔ Je suis prêt !", ImGuiSkin.ButtonAccent, GUILayout.Height(44)))
|
||||
{
|
||||
_isReady = true;
|
||||
NetworkManager.Instance?.SendReady();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var readyStyle = new GUIStyle(GUI.skin.label)
|
||||
{
|
||||
alignment = TextAnchor.MiddleCenter,
|
||||
fontSize = 16,
|
||||
fontStyle = FontStyle.Bold,
|
||||
};
|
||||
readyStyle.normal.textColor = new Color(0.3f, 1f, 0.5f);
|
||||
GUILayout.Label("✔ Prêt ! En attente des autres...", readyStyle, GUILayout.Height(44));
|
||||
}
|
||||
|
||||
GUILayout.Space(8);
|
||||
var hintStyle = new GUIStyle(ImGuiSkin.Hint);
|
||||
hintStyle.normal.textColor = new Color(0.5f, 0.5f, 0.6f);
|
||||
GUILayout.Label("La partie démarre quand tout le monde est prêt\nou automatiquement après 30 secondes.", hintStyle);
|
||||
|
||||
ImGuiSkin.EndWindow();
|
||||
}
|
||||
}
|
||||
|
||||
private void JoinArena()
|
||||
{
|
||||
if (NetworkManager.Instance == null)
|
||||
{
|
||||
_statusMessage = "Erreur : NetworkManager introuvable";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_playerName))
|
||||
{
|
||||
_statusMessage = "Entrez un pseudo";
|
||||
return;
|
||||
}
|
||||
|
||||
_isConnecting = true;
|
||||
_statusMessage = "Connexion au serveur...";
|
||||
|
||||
Color selectedColor = PresetColors[_selectedColorIndex];
|
||||
NetworkManager.Instance.JoinArena(_playerName.Trim(), selectedColor);
|
||||
|
||||
// Monitor for errors after a delay
|
||||
Invoke(nameof(CheckConnectionTimeout), 10f);
|
||||
}
|
||||
|
||||
private void CheckConnectionTimeout()
|
||||
{
|
||||
if (_isConnecting && !NetworkManager.Instance.IsConnected)
|
||||
{
|
||||
_isConnecting = false;
|
||||
_statusMessage = "Erreur : Timeout de connexion. Vérifiez que le serveur est lancé.";
|
||||
if (!string.IsNullOrEmpty(NetworkManager.Instance.LastError))
|
||||
{
|
||||
_statusMessage += $"\n{NetworkManager.Instance.LastError}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/Network/LobbyUI.cs.meta
Normal file
2
game/Assets/Scripts/Network/LobbyUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad2d984dd466289479165976d300cc09
|
||||
394
game/Assets/Scripts/Network/NetworkManager.cs
Normal file
394
game/Assets/Scripts/Network/NetworkManager.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Colyseus;
|
||||
using Colyseus.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton managing the Colyseus connection, room lifecycle, remote player spawning,
|
||||
/// and game-phase events (eliminated, qualified, roundStart, roundEnd, gameEnd).
|
||||
/// </summary>
|
||||
public class NetworkManager : MonoBehaviour
|
||||
{
|
||||
public static NetworkManager Instance { get; private set; }
|
||||
|
||||
[Header("Connection")]
|
||||
[Tooltip("Colyseus server endpoint (overridden by frontend via SetServerURL)")]
|
||||
public string serverURL = "ws://localhost:2567";
|
||||
|
||||
[Header("Prefab")]
|
||||
[Tooltip("Prefab for remote players (must have RemotePlayerController)")]
|
||||
public GameObject remotePlayerPrefab;
|
||||
|
||||
// --- Public state for UI ---
|
||||
public bool IsConnected { get; private set; }
|
||||
public string RoomId { get; private set; } = "";
|
||||
public string LocalSessionId { get; private set; } = "";
|
||||
public int PlayerCount { get; private set; }
|
||||
public string ConnectionStatus { get; private set; } = "Déconnecté";
|
||||
public string LastError { get; private set; } = "";
|
||||
|
||||
// Expose remote players for debug UI
|
||||
public Dictionary<string, RemotePlayerController> RemotePlayers => _remotePlayers;
|
||||
|
||||
// Local player info (set during join)
|
||||
public string LocalPlayerName { get; private set; } = "";
|
||||
public Color LocalPlayerColor { get; private set; } = Color.white;
|
||||
|
||||
// --- Events ---
|
||||
public event Action OnConnected;
|
||||
public event Action OnDisconnected;
|
||||
public event Action<string> OnPlayerJoined;
|
||||
public event Action<string> OnPlayerLeft;
|
||||
|
||||
// Game flow events
|
||||
public event Action<string> OnPhaseChanged; // phase name
|
||||
public event Action<float> OnCountdownChanged; // seconds remaining
|
||||
public event Action<string, string> OnEliminated; // sessionId, reason
|
||||
public event Action<string> OnQualified; // sessionId
|
||||
public event Action<int, string> OnRoundStart; // roundNumber, mode
|
||||
public event Action<int> OnRoundEnd; // roundNumber
|
||||
public event Action<string> OnGameEnd; // winnerName
|
||||
public event Action<float> OnDeathZoneYChanged; // for survival mode
|
||||
|
||||
// --- Internals ---
|
||||
private Client _client;
|
||||
private Room<NetworkState> _room;
|
||||
private StateCallbackStrategy<NetworkState> _callbacks;
|
||||
private readonly Dictionary<string, RemotePlayerController> _remotePlayers = new();
|
||||
private float _broadcastTimer;
|
||||
private const float BROADCAST_INTERVAL = 0.01667f; // ~60/sec
|
||||
private bool _isJoining;
|
||||
|
||||
private Transform _localPlayer;
|
||||
private Rigidbody _localPlayerRb;
|
||||
|
||||
private Vector3 _lastSentPos;
|
||||
private Vector3 _lastSentVel;
|
||||
private Vector3 _lastSentAngVel;
|
||||
private const float POS_THRESHOLD = 0.005f;
|
||||
private const float VEL_THRESHOLD = 0.05f;
|
||||
|
||||
private string _lastPhase = "";
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!IsConnected || _room == null) return;
|
||||
|
||||
_broadcastTimer += Time.deltaTime;
|
||||
if (_broadcastTimer >= BROADCAST_INTERVAL)
|
||||
{
|
||||
_broadcastTimer = 0f;
|
||||
BroadcastPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called from frontend JS via SendMessage to override the server URL.</summary>
|
||||
public void SetServerURL(string url)
|
||||
{
|
||||
serverURL = url;
|
||||
Debug.Log($"[Network] Server URL set to: {url}");
|
||||
}
|
||||
|
||||
public NetworkPlayer GetLocalPlayerState()
|
||||
{
|
||||
if (_room == null || _room.State.players == null || string.IsNullOrEmpty(LocalSessionId)) return null;
|
||||
_room.State.players.TryGetValue(LocalSessionId, out var player);
|
||||
return player;
|
||||
}
|
||||
|
||||
// ─── Join / Leave ────────────────────────────────────────────────────
|
||||
|
||||
public async void JoinArena(string playerName, Color color)
|
||||
{
|
||||
if (_isJoining || IsConnected)
|
||||
{
|
||||
Debug.LogWarning("[Network] Already connecting or connected.");
|
||||
return;
|
||||
}
|
||||
|
||||
_isJoining = true;
|
||||
ConnectionStatus = "Connexion en cours...";
|
||||
LastError = "";
|
||||
LocalPlayerName = playerName;
|
||||
LocalPlayerColor = color;
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"[Network] Connecting to {serverURL}...");
|
||||
_client = new Client(serverURL);
|
||||
|
||||
var options = new Dictionary<string, object>
|
||||
{
|
||||
{ "name", playerName },
|
||||
{ "colorR", color.r },
|
||||
{ "colorG", color.g },
|
||||
{ "colorB", color.b }
|
||||
};
|
||||
|
||||
_room = await _client.JoinOrCreate<NetworkState>("arena", options);
|
||||
LocalSessionId = _room.SessionId;
|
||||
RoomId = _room.RoomId;
|
||||
IsConnected = true;
|
||||
ConnectionStatus = "Connecté";
|
||||
|
||||
Debug.Log($"[Network] Joined room {RoomId} as {LocalSessionId}");
|
||||
|
||||
_callbacks = Callbacks.Get(_room);
|
||||
|
||||
// Players
|
||||
_callbacks.OnAdd(state => state.players, (key, player) => OnPlayerAdd(key, player));
|
||||
_callbacks.OnRemove(state => state.players, (key, player) => OnPlayerRemove(key, player));
|
||||
|
||||
// Game state changes
|
||||
_callbacks.Listen(state => state.phase, (newValue, prevValue) => _OnPhaseChanged(newValue));
|
||||
_callbacks.Listen(state => state.countdown, (newValue, prevValue) => OnCountdownChanged?.Invoke(newValue));
|
||||
_callbacks.Listen(state => state.deathZoneY, (newValue, prevValue) => OnDeathZoneYChanged?.Invoke(newValue));
|
||||
|
||||
// Server messages
|
||||
_room.OnMessage<EliminatedMsg>("eliminated", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Eliminated: {msg.sessionId} ({msg.reason})");
|
||||
OnEliminated?.Invoke(msg.sessionId, msg.reason);
|
||||
});
|
||||
_room.OnMessage<QualifiedMsg>("qualified", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Qualified: {msg.sessionId}");
|
||||
OnQualified?.Invoke(msg.sessionId);
|
||||
});
|
||||
_room.OnMessage<RoundStartMsg>("roundStart", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Round {msg.round} started ({msg.mode})");
|
||||
OnRoundStart?.Invoke(msg.round, msg.mode);
|
||||
});
|
||||
_room.OnMessage<RoundEndMsg>("roundEnd", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Round {msg.round} ended");
|
||||
OnRoundEnd?.Invoke(msg.round);
|
||||
});
|
||||
_room.OnMessage<GameEndMsg>("gameEnd", msg =>
|
||||
{
|
||||
Debug.Log($"[Network] Game over — Winner: {msg.winner}");
|
||||
OnGameEnd?.Invoke(msg.winner);
|
||||
});
|
||||
|
||||
_room.OnLeave += OnRoomLeave;
|
||||
OnConnected?.Invoke();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[Network] Failed to join: {e.Message}");
|
||||
ConnectionStatus = "Erreur de connexion";
|
||||
LastError = e.Message;
|
||||
IsConnected = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isJoining = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async void LeaveRoom()
|
||||
{
|
||||
if (_room != null) await _room.Leave();
|
||||
Cleanup();
|
||||
}
|
||||
|
||||
public async void SendReady()
|
||||
{
|
||||
if (_room != null && IsConnected)
|
||||
await _room.Send("ready", null);
|
||||
}
|
||||
|
||||
public async void SendCheckpoint(int index)
|
||||
{
|
||||
if (_room != null && IsConnected)
|
||||
await _room.Send("checkpointReached", new { index });
|
||||
}
|
||||
|
||||
public async void SendDeathZoneHit()
|
||||
{
|
||||
if (_room != null && IsConnected)
|
||||
await _room.Send("deathZoneHit", null);
|
||||
}
|
||||
|
||||
public async void SendInZone(bool inZone)
|
||||
{
|
||||
if (_room != null && IsConnected)
|
||||
await _room.Send("inZone", new { inZone });
|
||||
}
|
||||
|
||||
// ─── State Callbacks ─────────────────────────────────────────────────
|
||||
|
||||
private void _OnPhaseChanged(string phase)
|
||||
{
|
||||
if (phase == _lastPhase) return;
|
||||
_lastPhase = phase;
|
||||
Debug.Log($"[Network] Phase → {phase}");
|
||||
OnPhaseChanged?.Invoke(phase);
|
||||
}
|
||||
|
||||
private void OnPlayerAdd(string sessionId, NetworkPlayer player)
|
||||
{
|
||||
Debug.Log($"[Network] Player joined: {sessionId} ({player.name})");
|
||||
PlayerCount = _room.State.players?.Count ?? 0;
|
||||
|
||||
if (sessionId == LocalSessionId) return;
|
||||
|
||||
if (remotePlayerPrefab != null)
|
||||
{
|
||||
Vector3 spawnPos = new Vector3(player.x, player.y, player.z);
|
||||
GameObject remoteBall = Instantiate(remotePlayerPrefab, spawnPos, Quaternion.identity);
|
||||
remoteBall.name = $"RemotePlayer_{player.name}_{sessionId[..6]}";
|
||||
|
||||
var controller = remoteBall.GetComponent<RemotePlayerController>()
|
||||
?? remoteBall.AddComponent<RemotePlayerController>();
|
||||
|
||||
controller.Initialize(sessionId, player.name,
|
||||
new Color(player.colorR, player.colorG, player.colorB));
|
||||
|
||||
_remotePlayers[sessionId] = controller;
|
||||
}
|
||||
|
||||
_callbacks.OnChange(player, () => OnPlayerChange(sessionId, player));
|
||||
OnPlayerJoined?.Invoke(sessionId);
|
||||
}
|
||||
|
||||
private void OnPlayerRemove(string sessionId, NetworkPlayer player)
|
||||
{
|
||||
Debug.Log($"[Network] Player left: {sessionId}");
|
||||
PlayerCount = _room.State.players?.Count ?? 0;
|
||||
|
||||
if (_remotePlayers.TryGetValue(sessionId, out var controller))
|
||||
{
|
||||
if (controller != null && controller.gameObject != null)
|
||||
Destroy(controller.gameObject);
|
||||
_remotePlayers.Remove(sessionId);
|
||||
}
|
||||
|
||||
OnPlayerLeft?.Invoke(sessionId);
|
||||
}
|
||||
|
||||
private void OnPlayerChange(string sessionId, NetworkPlayer player)
|
||||
{
|
||||
if (sessionId == LocalSessionId) return;
|
||||
|
||||
if (_remotePlayers.TryGetValue(sessionId, out var controller))
|
||||
{
|
||||
controller.SetTargetState(
|
||||
new Vector3(player.x, player.y, player.z),
|
||||
new Vector3(player.vx, player.vy, player.vz),
|
||||
new Quaternion(player.rx, player.ry, player.rz, player.rw),
|
||||
player.t,
|
||||
new Vector3(player.avx, player.avy, player.avz)
|
||||
);
|
||||
|
||||
// Sync team color changes (for teams mode)
|
||||
controller.UpdateTeamColor(player.team,
|
||||
new Color(player.colorR, player.colorG, player.colorB));
|
||||
|
||||
// Hide/show eliminated remote players
|
||||
controller.SetVisible(!player.isEliminated);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Position Broadcasting ────────────────────────────────────────────
|
||||
|
||||
private void BroadcastPosition()
|
||||
{
|
||||
if (_room == null || !IsConnected) return;
|
||||
|
||||
if (_localPlayer == null)
|
||||
{
|
||||
var pc = FindFirstObjectByType<PlayerController>();
|
||||
if (pc != null)
|
||||
{
|
||||
_localPlayer = pc.transform;
|
||||
_localPlayerRb = pc.GetComponent<Rigidbody>();
|
||||
}
|
||||
else return;
|
||||
}
|
||||
|
||||
Vector3 pos = _localPlayer.position;
|
||||
Vector3 vel = _localPlayerRb != null ? _localPlayerRb.linearVelocity : Vector3.zero;
|
||||
Vector3 angVel = _localPlayerRb != null ? _localPlayerRb.angularVelocity : Vector3.zero;
|
||||
|
||||
if (Vector3.Distance(pos, _lastSentPos) < POS_THRESHOLD &&
|
||||
Vector3.Distance(vel, _lastSentVel) < VEL_THRESHOLD &&
|
||||
Vector3.Distance(angVel, _lastSentAngVel) < VEL_THRESHOLD)
|
||||
return;
|
||||
|
||||
_lastSentPos = pos;
|
||||
_lastSentVel = vel;
|
||||
_lastSentAngVel = angVel;
|
||||
|
||||
Quaternion rot = _localPlayer.rotation;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "x", pos.x }, { "y", pos.y }, { "z", pos.z },
|
||||
{ "vx", vel.x }, { "vy", vel.y }, { "vz", vel.z },
|
||||
{ "rx", rot.x }, { "ry", rot.y }, { "rz", rot.z }, { "rw", rot.w },
|
||||
{ "avx", angVel.x }, { "avy", angVel.y }, { "avz", angVel.z }
|
||||
};
|
||||
|
||||
_ = _room.Send("position", data);
|
||||
}
|
||||
|
||||
// ─── Room Lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
private void OnRoomLeave(int code)
|
||||
{
|
||||
Debug.Log($"[Network] Left room (code: {code})");
|
||||
Cleanup();
|
||||
OnDisconnected?.Invoke();
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
IsConnected = false;
|
||||
ConnectionStatus = "Déconnecté";
|
||||
RoomId = "";
|
||||
PlayerCount = 0;
|
||||
LocalPlayerName = "";
|
||||
LocalPlayerColor = Color.white;
|
||||
_lastPhase = "";
|
||||
|
||||
foreach (var kvp in _remotePlayers)
|
||||
{
|
||||
if (kvp.Value != null && kvp.Value.gameObject != null)
|
||||
Destroy(kvp.Value.gameObject);
|
||||
}
|
||||
_remotePlayers.Clear();
|
||||
|
||||
_room = null;
|
||||
_client = null;
|
||||
_callbacks = null;
|
||||
_localPlayer = null;
|
||||
_localPlayerRb = null;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (_room != null) _ = _room.Leave(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Message DTOs ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Serializable] public class EliminatedMsg { public string sessionId; public string name; public string reason; }
|
||||
[Serializable] public class QualifiedMsg { public string sessionId; public string name; }
|
||||
[Serializable] public class RoundStartMsg { public int round; public string mode; public int totalRounds; }
|
||||
[Serializable] public class RoundEndMsg { public int round; }
|
||||
[Serializable] public class GameEndMsg { public string winner; }
|
||||
2
game/Assets/Scripts/Network/NetworkManager.cs.meta
Normal file
2
game/Assets/Scripts/Network/NetworkManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a218ec39b39bcc459a0c0d0ca10207b
|
||||
48
game/Assets/Scripts/Network/NetworkSchema.cs
Normal file
48
game/Assets/Scripts/Network/NetworkSchema.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Colyseus.Schema;
|
||||
|
||||
// Must match server-side defineTypes field order exactly
|
||||
public partial class NetworkPlayer : Schema
|
||||
{
|
||||
[Type(0, "int32")] public int userId = 0;
|
||||
[Type(1, "float32")] public float x = 0;
|
||||
[Type(2, "float32")] public float y = 5;
|
||||
[Type(3, "float32")] public float z = 0;
|
||||
[Type(4, "float32")] public float vx = 0;
|
||||
[Type(5, "float32")] public float vy = 0;
|
||||
[Type(6, "float32")] public float vz = 0;
|
||||
[Type(7, "float32")] public float rx = 0;
|
||||
[Type(8, "float32")] public float ry = 0;
|
||||
[Type(9, "float32")] public float rz = 0;
|
||||
[Type(10, "float32")] public float rw = 1;
|
||||
[Type(11, "float64")] public double t = 0;
|
||||
[Type(12, "string")] public string name = "";
|
||||
[Type(13, "float32")] public float colorR = 1;
|
||||
[Type(14, "float32")] public float colorG = 1;
|
||||
[Type(15, "float32")] public float colorB = 1;
|
||||
[Type(16, "float32")] public float avx = 0;
|
||||
[Type(17, "float32")] public float avy = 0;
|
||||
[Type(18, "float32")] public float avz = 0;
|
||||
// Game state
|
||||
[Type(19, "boolean")] public bool isEliminated = false;
|
||||
[Type(20, "boolean")] public bool isQualified = false;
|
||||
[Type(21, "int8")] public int team = 0;
|
||||
[Type(22, "int8")] public int checkpointIndex = 0;
|
||||
[Type(23, "boolean")] public bool isReady = false;
|
||||
}
|
||||
|
||||
public partial class NetworkState : Schema
|
||||
{
|
||||
[Type(0, "map", typeof(MapSchema<NetworkPlayer>))]
|
||||
public MapSchema<NetworkPlayer> players;
|
||||
|
||||
[Type(1, "string")] public string phase = "lobby";
|
||||
[Type(2, "float32")] public float countdown = 0;
|
||||
[Type(3, "int8")] public int roundNumber = 1;
|
||||
[Type(4, "int8")] public int totalRounds = 4;
|
||||
[Type(5, "int8")] public int playersAlive = 0;
|
||||
[Type(6, "string")] public string gameMode = "race";
|
||||
[Type(7, "float32")] public float deathZoneY = -100;
|
||||
[Type(8, "int16")] public int teamScoreRed = 0;
|
||||
[Type(9, "int16")] public int teamScoreBlue = 0;
|
||||
[Type(10, "string")] public string winnerName = "";
|
||||
}
|
||||
2
game/Assets/Scripts/Network/NetworkSchema.cs.meta
Normal file
2
game/Assets/Scripts/Network/NetworkSchema.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ce16348bc0580b49860d9bd80e7bec0
|
||||
333
game/Assets/Scripts/Network/RemotePlayerController.cs
Normal file
333
game/Assets/Scripts/Network/RemotePlayerController.cs
Normal file
@@ -0,0 +1,333 @@
|
||||
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;
|
||||
|
||||
// Public info
|
||||
public string SessionId { get; private set; }
|
||||
public string PlayerName { get; private set; }
|
||||
public Color PlayerColor { get; private set; }
|
||||
|
||||
// --- 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;
|
||||
_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
|
||||
var existingCollider = GetComponent<SphereCollider>();
|
||||
float baseRadius = existingCollider != null ? existingCollider.radius : 0.5f;
|
||||
var trigger = gameObject.AddComponent<SphereCollider>();
|
||||
trigger.isTrigger = true;
|
||||
trigger.radius = baseRadius * 1.15f; // 15% larger
|
||||
|
||||
// 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
|
||||
CreateNameLabel();
|
||||
|
||||
Debug.Log($"[RemotePlayer] Initialized: {playerName} ({sessionId[..6]}) color={color}");
|
||||
}
|
||||
|
||||
/// <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 || _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;
|
||||
}
|
||||
|
||||
// Keep name label floating ABOVE the ball (world position, not local)
|
||||
// Billboard: always face camera, locked to vertical axis
|
||||
if (_nameLabelObj != null)
|
||||
{
|
||||
_nameLabelObj.transform.position = transform.position + Vector3.up * 1.5f;
|
||||
var cam = Camera.main;
|
||||
if (cam != null)
|
||||
{
|
||||
// Billboard locked to Y axis — only rotate around vertical
|
||||
Vector3 lookDir = _nameLabelObj.transform.position - cam.transform.position;
|
||||
lookDir.y = 0f; // Lock to horizontal plane
|
||||
if (lookDir.sqrMagnitude > 0.001f)
|
||||
_nameLabelObj.transform.rotation = Quaternion.LookRotation(lookDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GameObject _nameLabelObj; // Keep reference for billboard update
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5f5ad6331ffe0d4491eab78cc3b0993
|
||||
82
game/Assets/Scripts/Network/SpectatorCamera.cs
Normal file
82
game/Assets/Scripts/Network/SpectatorCamera.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Spectator camera that slowly orbits around the arena center while the player
|
||||
/// is in the lobby (not yet connected). Automatically disables itself and yields
|
||||
/// to the gameplay Cinemachine camera once the player joins.
|
||||
/// Attach to a dedicated GameObject with a Camera component.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Camera))]
|
||||
public class SpectatorCamera : MonoBehaviour
|
||||
{
|
||||
[Header("Orbit Settings")]
|
||||
[Tooltip("World-space point the camera orbits around")]
|
||||
public Vector3 orbitCenter = Vector3.zero;
|
||||
|
||||
[Tooltip("Radius of the orbit circle")]
|
||||
public float orbitRadius = 30f;
|
||||
|
||||
[Tooltip("Height above the orbit center")]
|
||||
public float orbitHeight = 18f;
|
||||
|
||||
[Tooltip("Degrees per second")]
|
||||
public float orbitSpeed = 12f;
|
||||
|
||||
[Tooltip("Downward pitch angle in degrees")]
|
||||
public float pitchAngle = 30f;
|
||||
|
||||
// Internal
|
||||
private float _angle;
|
||||
private Camera _cam;
|
||||
|
||||
// Reference to the gameplay camera (CinemachineBrain) — set by LobbyUI
|
||||
[HideInInspector] public Camera gameplayCamera;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_cam = GetComponent<Camera>();
|
||||
_angle = Random.Range(0f, 360f); // start at random angle for variety
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
_angle += orbitSpeed * Time.deltaTime;
|
||||
if (_angle >= 360f) _angle -= 360f;
|
||||
|
||||
float rad = _angle * Mathf.Deg2Rad;
|
||||
Vector3 pos = orbitCenter + new Vector3(
|
||||
Mathf.Cos(rad) * orbitRadius,
|
||||
orbitHeight,
|
||||
Mathf.Sin(rad) * orbitRadius
|
||||
);
|
||||
|
||||
transform.position = pos;
|
||||
transform.LookAt(orbitCenter + Vector3.up * 2f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switch to spectator view — enable this camera, disable gameplay camera.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
_cam.enabled = true;
|
||||
gameObject.SetActive(true);
|
||||
|
||||
// Disable the gameplay camera so we're the active one
|
||||
if (gameplayCamera != null)
|
||||
gameplayCamera.enabled = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switch back to gameplay view — disable this camera, enable gameplay camera.
|
||||
/// </summary>
|
||||
public void Deactivate()
|
||||
{
|
||||
_cam.enabled = false;
|
||||
gameObject.SetActive(false);
|
||||
|
||||
// Re-enable the gameplay camera
|
||||
if (gameplayCamera != null)
|
||||
gameplayCamera.enabled = true;
|
||||
}
|
||||
}
|
||||
2
game/Assets/Scripts/Network/SpectatorCamera.cs.meta
Normal file
2
game/Assets/Scripts/Network/SpectatorCamera.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00ccf355f5f18234f9056c4ef6b82395
|
||||
Reference in New Issue
Block a user