feat: stats + chat + frontend pages (Stats, Chat, NavBar)

Backend:
- StatsManager.js: JSON persistence, leaderboard, rate-limit 1/5s
- ChatManager.js: 200-msg buffer, JSON persistence
- index.js: routes GET/POST /stats, /chat/history, /chat/send (Zod validation)
- ArenaRoom.js: chat handler broadcasts to room + persists via ChatManager

Unity:
- StatsTracker.cs: distance, maxSpeed, jumps, bumps, checkpoints, raceTime tracking
- ChatUI.cs: F3 toggle, bottom-right panel, polling 3s, unread badge
- NetworkManager.cs: SendChatMessage() + OnMessage<ChatUI.ChatMessage>(chat)
- CheckpointSystem.cs: RegisterCheckpoint/Finish hooks
- PlayerController.cs: RegisterJump/Bump hooks, physics rebalance, billboard fix
- GameHUD.cs: LocalRaceTimer, SetTotalRounds, OnRoundStart signature fix
- GameManager.cs: spectator cam reconnect fix

Frontend:
- NavBar.jsx: fixed top nav, Accueil/Stats/Chat/Jouer
- App.jsx: page state (home/play/stats/chat) + NavBar
- StatsPage.jsx: 6-tab leaderboard, auto-refresh 30s
- ChatPage.jsx: polling 3s, localStorage name, Enter to send

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 18:33:06 +02:00
parent 526d30c569
commit 5c98f1638a
15 changed files with 1144 additions and 17 deletions

View File

@@ -142,7 +142,6 @@ public class GameManager : MonoBehaviour
{
case GamePhase.Lobby:
SetPlayerActive(NetworkManager.Instance?.IsConnected ?? false);
SetSpectatorActive(false);
gameHUD?.SetPhase("lobby");
break;

View File

@@ -171,6 +171,10 @@ public class NetworkManager : MonoBehaviour
Debug.Log($"[Network] Game over — Winner: {msg.winner}");
OnGameEnd?.Invoke(msg.winner);
});
_room.OnMessage<ChatUI.ChatMessage>("chat", msg =>
{
ChatUI.Instance?.ReceiveChatMessage(msg);
});
_room.OnLeave += OnRoomLeave;
OnConnected?.Invoke();
@@ -206,6 +210,12 @@ public class NetworkManager : MonoBehaviour
await _room.Send("checkpointReached", new { index });
}
public async void SendChatMessage(string text)
{
if (_room != null && IsConnected)
await _room.Send("chat", new { text });
}
// ─── State Callbacks ─────────────────────────────────────────────────
private void _OnPhaseChanged(string phase)

View File

@@ -67,6 +67,7 @@ public class CheckpointSystem : MonoBehaviour
}
_localCheckpointIndex++;
StatsTracker.Instance?.RegisterCheckpoint();
NetworkManager.Instance?.SendCheckpoint(_localCheckpointIndex);
Debug.Log($"[Checkpoint] Reached {_localCheckpointIndex}/{checkpoints.Length}");
@@ -79,6 +80,7 @@ public class CheckpointSystem : MonoBehaviour
if (_localCheckpointIndex >= checkpoints.Length)
{
_finished = true;
StatsTracker.Instance?.RegisterFinish(GameHUD.Instance != null ? GameHUD.Instance.LocalRaceTimer : 0f);
Debug.Log("[Checkpoint] FINISH LINE reached!");
}
else

View File

@@ -0,0 +1,229 @@
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
/// <summary>
/// Tracks per-session and per-round player statistics and uploads them to the game server.
/// All HTTP calls use UnityWebRequest coroutines (WebGL-safe, no async/await).
/// </summary>
public class StatsTracker : MonoBehaviour
{
public static StatsTracker Instance { get; private set; }
private const string SERVER_URL = "https://game.rolld.kerboul.me";
// Cumulative session stats (accumulate across rounds)
private float _totalDistance;
private int _totalJumps;
private float _maxSpeed;
private float _bestRaceTime; // 0 = not set
private int _racesPlayed;
private int _qualifications;
private int _eliminations;
private int _checkpointsTotal;
private int _bumpsGiven;
private float _totalPlaytime;
// Per-round deltas (reset after each send)
private float _roundDistance;
private float _roundMaxSpeed;
private float _sessionStart;
private Vector3 _lastPos;
private bool _trackingActive;
private PlayerController _pc;
private Rigidbody _rb;
void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
_sessionStart = Time.time;
}
void Start()
{
_pc = GetComponent<PlayerController>();
_rb = GetComponent<Rigidbody>();
var nm = NetworkManager.Instance;
if (nm != null)
{
nm.OnRoundStart += OnRoundStart;
nm.OnRoundEnd += OnRoundEnd;
nm.OnQualified += OnQualified;
nm.OnEliminated += OnEliminated;
nm.OnConnected += OnConnected;
nm.OnDisconnected += OnDisconnected;
}
}
void OnDestroy()
{
var nm = NetworkManager.Instance;
if (nm != null)
{
nm.OnRoundStart -= OnRoundStart;
nm.OnRoundEnd -= OnRoundEnd;
nm.OnQualified -= OnQualified;
nm.OnEliminated -= OnEliminated;
nm.OnConnected -= OnConnected;
nm.OnDisconnected -= OnDisconnected;
}
}
void FixedUpdate()
{
if (!_trackingActive || _rb == null || _pc == null || !_pc.enabled) return;
Vector3 pos = transform.position;
float delta = Vector3.Distance(pos, _lastPos);
if (delta < 20f) // sanity cap against teleports
{
_roundDistance += delta;
_totalDistance += delta;
}
_lastPos = pos;
float speed = _rb.linearVelocity.magnitude;
if (speed > _roundMaxSpeed) _roundMaxSpeed = speed;
if (speed > _maxSpeed) _maxSpeed = speed;
}
// ─── Public hooks ────────────────────────────────────────────────────
public void RegisterJump()
{
_totalJumps++;
}
public void RegisterBump()
{
_bumpsGiven++;
}
public void RegisterCheckpoint()
{
_checkpointsTotal++;
}
public void RegisterFinish(float raceTime)
{
if (raceTime <= 0f) return;
if (_bestRaceTime <= 0f || raceTime < _bestRaceTime)
_bestRaceTime = raceTime;
}
// ─── Event handlers ──────────────────────────────────────────────────
private void OnConnected()
{
_lastPos = transform.position;
_trackingActive = true;
}
private void OnDisconnected()
{
_trackingActive = false;
_totalPlaytime += Time.time - _sessionStart;
SendStats(); // best-effort on disconnect
}
private void OnRoundStart(int round, string mode, int totalRounds)
{
_racesPlayed++;
_roundDistance = 0f;
_roundMaxSpeed = 0f;
_lastPos = transform.position;
_trackingActive = true;
}
private void OnRoundEnd(int round)
{
_trackingActive = false;
SendStats();
_roundDistance = 0f;
_roundMaxSpeed = 0f;
}
private void OnQualified(string sessionId)
{
if (sessionId == NetworkManager.Instance?.LocalSessionId)
_qualifications++;
}
private void OnEliminated(string sessionId, string reason)
{
if (sessionId == NetworkManager.Instance?.LocalSessionId)
_eliminations++;
}
// ─── HTTP send ───────────────────────────────────────────────────────
private void SendStats()
{
var nm = NetworkManager.Instance;
if (nm == null || string.IsNullOrEmpty(nm.LocalPlayerName)) return;
StartCoroutine(DoSendStats(nm.LocalPlayerName));
}
private IEnumerator DoSendStats(string playerName)
{
_totalPlaytime += Time.time - _sessionStart;
_sessionStart = Time.time;
var payload = new StatsPayload
{
name = playerName,
stats = new StatsData
{
totalDistance = _totalDistance,
totalJumps = _totalJumps,
maxSpeed = _maxSpeed,
bestRaceTime = _bestRaceTime > 0f ? _bestRaceTime : 0f,
racesPlayed = _racesPlayed,
qualifications = _qualifications,
eliminations = _eliminations,
checkpointsTotal = _checkpointsTotal,
bumpsGiven = _bumpsGiven,
totalPlaytime = _totalPlaytime,
}
};
string json = JsonUtility.ToJson(payload);
byte[] body = Encoding.UTF8.GetBytes(json);
using var req = new UnityWebRequest($"{SERVER_URL}/stats/update", "POST");
req.uploadHandler = new UploadHandlerRaw(body);
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success)
Debug.LogWarning($"[Stats] Upload failed: {req.error}");
else
Debug.Log($"[Stats] Uploaded for {playerName}");
}
// ─── DTOs ─────────────────────────────────────────────────────────────
[System.Serializable]
private class StatsPayload { public string name; public StatsData stats; }
[System.Serializable]
private class StatsData
{
public float totalDistance;
public int totalJumps;
public float maxSpeed;
public float bestRaceTime;
public int racesPlayed;
public int qualifications;
public int eliminations;
public int checkpointsTotal;
public int bumpsGiven;
public float totalPlaytime;
}
}

View File

@@ -0,0 +1,259 @@
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Networking;
/// <summary>
/// General chat panel. Toggle with F3.
/// Polls GET /chat/history every 3s and sends via POST /chat/send (or Colyseus if connected).
/// Uses ImGuiSkin for visual consistency.
/// </summary>
public class ChatUI : MonoBehaviour
{
public static ChatUI Instance { get; private set; }
public static bool IsVisible { get; private set; }
private const string SERVER_URL = "https://game.rolld.kerboul.me";
private const float POLL_INTERVAL = 3f;
private const int MAX_DISPLAY = 50;
private bool _visible;
private string _inputText = "";
private Vector2 _scrollPos;
private float _pollTimer;
private long _lastTimestamp;
private bool _autoScroll = true;
private readonly List<ChatMessage> _messages = new();
private int _unreadCount;
// Cached textures for badge
private static Texture2D _badgeTex;
void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
// Initial load
StartCoroutine(DoPoll());
}
void Update()
{
if (Keyboard.current != null && Keyboard.current[Key.F3].wasPressedThisFrame)
Toggle();
if (_visible)
{
_pollTimer += Time.deltaTime;
if (_pollTimer >= POLL_INTERVAL) { _pollTimer = 0f; StartCoroutine(DoPoll()); }
// Send on Enter (only when chat input has text)
if (!string.IsNullOrWhiteSpace(_inputText) &&
Keyboard.current != null && Keyboard.current[Key.Enter].wasPressedThisFrame)
{
TrySend();
}
}
}
private void Toggle()
{
_visible = !_visible;
IsVisible = _visible;
if (_visible)
{
_unreadCount = 0;
_autoScroll = true;
_pollTimer = POLL_INTERVAL; // poll immediately
Cursor.lockState = CursorLockMode.None;
Cursor.visible = true;
}
else
{
// Only re-lock if no other UI is open
if (!KeyBindingUI.IsVisible)
{
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
}
}
void OnGUI()
{
if (!_visible)
{
DrawBadge();
return;
}
ImGuiSkin.EnsureReady();
// Panel bottom-right, doesn't obstruct the center
float w = 460f;
float h = 440f;
float x = Screen.width - w - 12f;
float y = Screen.height - h - 12f;
ImGuiSkin.BeginWindowAt(x, y, w, h, "CHAT GÉNÉRAL");
// ── Message history ───────────────────────────────────────────
float listH = h - 130f;
_scrollPos = GUILayout.BeginScrollView(_scrollPos, ImGuiSkin.ScrollView, GUILayout.Height(listH));
foreach (var msg in _messages)
{
var ts = System.DateTimeOffset.FromUnixTimeMilliseconds(msg.timestamp).ToLocalTime();
string timeStr = ts.ToString("HH:mm");
var timeStyle = new GUIStyle(ImGuiSkin.LabelDim) { fontSize = 10, fixedWidth = 36f };
var nameStyle = new GUIStyle(ImGuiSkin.LabelBold);
nameStyle.normal.textColor = ImGuiSkin.ColAccent;
var textStyle = new GUIStyle(ImGuiSkin.LabelRich);
GUILayout.BeginHorizontal();
GUILayout.Label(timeStr, timeStyle);
GUILayout.Label(msg.name + " :", nameStyle, GUILayout.Width(100f));
GUILayout.Label(msg.text, textStyle);
GUILayout.EndHorizontal();
GUILayout.Space(1f);
}
if (_autoScroll) _scrollPos.y = float.MaxValue;
GUILayout.EndScrollView();
ImGuiSkin.Separator();
GUILayout.Space(4f);
// ── Input row ────────────────────────────────────────────────
GUILayout.BeginHorizontal();
GUI.SetNextControlName("ChatInput");
_inputText = GUILayout.TextField(_inputText, 200, ImGuiSkin.TextField, GUILayout.Height(28f));
bool canSend = !string.IsNullOrWhiteSpace(_inputText) && PlayerName.Length > 0;
GUI.enabled = canSend;
if (GUILayout.Button("Envoyer", ImGuiSkin.Button, GUILayout.Width(80f), GUILayout.Height(28f)))
TrySend();
GUI.enabled = true;
GUILayout.EndHorizontal();
GUILayout.Space(4f);
GUILayout.Label("F3 — Ouvrir / Fermer · Entrée — Envoyer", ImGuiSkin.Footer);
ImGuiSkin.EndWindow();
// Auto-focus input field
GUI.FocusControl("ChatInput");
}
private void DrawBadge()
{
if (_unreadCount <= 0) return;
if (_badgeTex == null)
{
_badgeTex = new Texture2D(1, 1);
_badgeTex.SetPixel(0, 0, Color.white);
_badgeTex.Apply();
}
float bx = Screen.width - 68f;
float by = Screen.height - 32f;
GUI.color = new Color(0.9f, 0.2f, 0.2f, 0.9f);
GUI.DrawTexture(new Rect(bx, by, 56f, 22f), _badgeTex);
GUI.color = Color.white;
var s = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 11, fontStyle = FontStyle.Bold };
s.normal.textColor = Color.white;
GUI.Label(new Rect(bx, by, 56f, 22f), $"💬 {_unreadCount}", s);
}
// ─── Send ────────────────────────────────────────────────────────────
private string PlayerName => NetworkManager.Instance?.LocalPlayerName ?? "";
private void TrySend()
{
string text = _inputText.Trim();
if (string.IsNullOrEmpty(text) || PlayerName.Length == 0) return;
_inputText = "";
_autoScroll = true;
var nm = NetworkManager.Instance;
if (nm != null && nm.IsConnected)
{
// Fast path: through Colyseus (room broadcasts it back to all players AND saves to ChatManager)
nm.SendChatMessage(text);
}
else
{
// Fallback: direct HTTP (for frontend-only visitors or disconnected state)
StartCoroutine(DoSend(PlayerName, text));
}
}
// ─── HTTP polling ─────────────────────────────────────────────────────
private IEnumerator DoPoll()
{
string url = $"{SERVER_URL}/chat/history?since={_lastTimestamp}";
using var req = UnityWebRequest.Get(url);
yield return req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) yield break;
var wrapper = JsonUtility.FromJson<MessageListWrapper>($"{{\"items\":{req.downloadHandler.text}}}");
if (wrapper?.items == null) yield break;
int added = 0;
foreach (var msg in wrapper.items)
{
if (msg.timestamp > _lastTimestamp)
{
_messages.Add(msg);
_lastTimestamp = msg.timestamp;
added++;
}
}
if (_messages.Count > MAX_DISPLAY)
_messages.RemoveRange(0, _messages.Count - MAX_DISPLAY);
if (added > 0 && !_visible)
_unreadCount += added;
}
private IEnumerator DoSend(string name, string text)
{
var payload = new SendPayload { name = name, text = text };
string json = JsonUtility.ToJson(payload);
byte[] body = Encoding.UTF8.GetBytes(json);
using var req = new UnityWebRequest($"{SERVER_URL}/chat/send", "POST");
req.uploadHandler = new UploadHandlerRaw(body);
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yield return req.SendWebRequest();
}
// Called by NetworkManager when a "chat" message arrives via Colyseus
public void ReceiveChatMessage(ChatMessage msg)
{
if (_messages.Count >= MAX_DISPLAY)
_messages.RemoveAt(0);
_messages.Add(msg);
if (msg.timestamp > _lastTimestamp) _lastTimestamp = msg.timestamp;
if (!_visible) _unreadCount++;
_autoScroll = true;
}
// ─── DTOs ─────────────────────────────────────────────────────────────
[System.Serializable]
public class ChatMessage { public int id; public long timestamp; public string name; public string text; }
[System.Serializable]
private class SendPayload { public string name; public string text; }
[System.Serializable]
private class MessageListWrapper { public List<ChatMessage> items; }
}

View File

@@ -89,6 +89,8 @@ public class GameHUD : MonoBehaviour
_countdownPulse = Mathf.Max(0f, _countdownPulse - Time.deltaTime * 3f);
}
public float LocalRaceTimer => _localRaceTimer;
public void SetPhase(string phase) => _phase = phase;
public void SetCountdown(float v) => _countdown = v;
public void SetRoundInfo(int round, string mode) { _roundNumber = round; _gameMode = mode; }