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:
229
game/Assets/Scripts/Stats/StatsTracker.cs
Normal file
229
game/Assets/Scripts/Stats/StatsTracker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user