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

@@ -1,29 +1,42 @@
import { useState } from 'react'
import { IS_DEV } from './env'
import DevBanner from './components/DevBanner'
import NavBar from './components/NavBar'
import Hero from './components/Hero'
import GelShowcase from './components/GelShowcase'
import KerboulistanBanner from './components/KerboulistanBanner'
import GameCanvas from './components/GameCanvas'
import Footer from './components/Footer'
import StatsPage from './pages/StatsPage'
import ChatPage from './pages/ChatPage'
function App() {
const [isPlaying, setIsPlaying] = useState(false)
const [page, setPage] = useState('home')
if (isPlaying) {
return <GameCanvas onBack={() => setIsPlaying(false)} />
if (page === 'play') {
return <GameCanvas onBack={() => setPage('home')} />
}
return (
<div className="min-h-screen">
<DevBanner />
{/* Offset content when dev banner is visible */}
<NavBar page={page} setPage={setPage} />
{page === 'home' && (
<>
{IS_DEV && <div className="h-8" />}
<Hero onPlay={() => setIsPlaying(true)} />
<div className="pt-14">
<Hero onPlay={() => setPage('play')} />
<GelShowcase />
<KerboulistanBanner />
<Footer />
</div>
</>
)}
{page === 'stats' && <StatsPage />}
{page === 'chat' && <ChatPage />}
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { theme } from '../env'
const LINKS = [
{ id: 'home', label: 'Accueil' },
{ id: 'stats', label: 'Stats' },
{ id: 'chat', label: 'Chat' },
]
export default function NavBar({ page, setPage }) {
return (
<nav className="fixed top-0 left-0 right-0 z-50 glass border-b border-rolld-border/60">
<div className="max-w-6xl mx-auto px-4 h-14 flex items-center justify-between">
<button
onClick={() => setPage('home')}
className="font-black text-lg tracking-tight"
style={{ color: theme.accent }}
>
ROLL'D
</button>
<div className="flex items-center gap-1">
{LINKS.map(link => (
<button
key={link.id}
onClick={() => setPage(link.id)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
page === link.id
? 'text-white'
: 'text-rolld-muted hover:text-rolld-text'
}`}
style={page === link.id ? { background: `rgba(${theme.accentRgb}, 0.2)`, color: theme.accentLight } : {}}
>
{link.label}
</button>
))}
<button
onClick={() => setPage('play')}
className="ml-3 px-4 py-2 rounded-xl text-sm font-bold text-white transition-all duration-200 hover:scale-105"
style={{
backgroundImage: `linear-gradient(to right, ${theme.accent}, ${theme.gradientTo})`,
}}
>
Jouer
</button>
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,185 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { theme } from '../env'
const SERVER = 'https://game.rolld.kerboul.me'
const POLL_INTERVAL = 3000
export default function ChatPage() {
const [messages, setMessages] = useState([])
const [inputText, setInputText] = useState('')
const [playerName, setPlayerName] = useState(() => localStorage.getItem('rolld_chat_name') || '')
const [editingName, setEditingName] = useState(!localStorage.getItem('rolld_chat_name'))
const [nameInput, setNameInput] = useState(playerName)
const [sending, setSending] = useState(false)
const lastTimestampRef = useRef(0)
const bottomRef = useRef(null)
const inputRef = useRef(null)
const fetchMessages = useCallback(async () => {
try {
const res = await fetch(`${SERVER}/chat/history?since=${lastTimestampRef.current}`)
if (!res.ok) return
const data = await res.json()
if (!Array.isArray(data) || data.length === 0) return
setMessages(prev => {
const updated = [...prev, ...data]
// Deduplicate by id
const seen = new Set()
const deduped = updated.filter(m => {
if (seen.has(m.id)) return false
seen.add(m.id)
return true
})
return deduped.slice(-200)
})
const maxTs = Math.max(...data.map(m => m.timestamp))
if (maxTs > lastTimestampRef.current) lastTimestampRef.current = maxTs
} catch {
// silently ignore network errors
}
}, [])
useEffect(() => {
fetchMessages()
const id = setInterval(fetchMessages, POLL_INTERVAL)
return () => clearInterval(id)
}, [fetchMessages])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const saveName = () => {
const n = nameInput.trim()
if (!n) return
setPlayerName(n)
localStorage.setItem('rolld_chat_name', n)
setEditingName(false)
setTimeout(() => inputRef.current?.focus(), 50)
}
const sendMessage = async () => {
const text = inputText.trim()
if (!text || !playerName || sending) return
setSending(true)
setInputText('')
try {
await fetch(`${SERVER}/chat/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: playerName, text }),
})
// Immediately poll to get our own message back
await fetchMessages()
} catch {
// ignore
} finally {
setSending(false)
inputRef.current?.focus()
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
const formatTime = (ts) => {
const d = new Date(ts)
return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}
return (
<div className="min-h-screen pt-20 px-4 flex flex-col">
<div className="max-w-2xl mx-auto w-full flex flex-col flex-1" style={{ maxHeight: 'calc(100vh - 5rem)' }}>
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-3xl font-black text-rolld-text">Chat général</h1>
<p className="text-rolld-muted text-sm mt-1">Partagé entre le jeu et le site rafraîchi toutes les 3s</p>
</div>
{/* Name badge */}
{!editingName ? (
<button
onClick={() => { setNameInput(playerName); setEditingName(true) }}
className="flex items-center gap-2 px-3 py-1.5 rounded-xl border border-rolld-border bg-rolld-surface text-sm hover:border-rolld-accent/40 transition-colors"
>
<span className="text-rolld-muted">Joueur :</span>
<span className="font-bold" style={{ color: theme.accentLight }}>{playerName}</span>
<span className="text-rolld-muted text-xs"></span>
</button>
) : (
<div className="flex items-center gap-2">
<input
autoFocus
value={nameInput}
onChange={e => setNameInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveName()}
placeholder="Ton pseudo"
maxLength={24}
className="w-36 px-3 py-1.5 rounded-xl border border-rolld-accent/60 bg-rolld-surface text-rolld-text text-sm outline-none focus:border-rolld-accent"
/>
<button
onClick={saveName}
className="px-3 py-1.5 rounded-xl text-sm font-bold text-white"
style={{ background: theme.accent }}
>
OK
</button>
</div>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto rounded-2xl border border-rolld-border bg-rolld-surface p-4 space-y-1 min-h-0">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center text-rolld-muted text-sm">
Aucun message pour l'instant. Soyez le premier !
</div>
) : (
messages.map(msg => (
<div key={msg.id} className="flex items-baseline gap-2 py-0.5 group">
<span className="text-rolld-muted text-xs font-mono shrink-0 opacity-60 group-hover:opacity-100 transition-opacity w-11">
{formatTime(msg.timestamp)}
</span>
<span className="text-sm font-bold shrink-0" style={{ color: theme.accentLight }}>
{msg.name}
</span>
<span className="text-sm text-rolld-text break-words min-w-0">
{msg.text}
</span>
</div>
))
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="mt-3 flex gap-2 pb-4">
<input
ref={inputRef}
value={inputText}
onChange={e => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={playerName ? 'Écrire un message (Entrée pour envoyer)' : 'Choisissez d\'abord un pseudo →'}
maxLength={200}
disabled={!playerName || editingName}
className="flex-1 px-4 py-3 rounded-xl border border-rolld-border bg-rolld-surface text-rolld-text text-sm outline-none focus:border-rolld-accent/60 disabled:opacity-40 transition-colors"
/>
<button
onClick={sendMessage}
disabled={!inputText.trim() || !playerName || sending || editingName}
className="px-5 py-3 rounded-xl text-sm font-bold text-white transition-all duration-200 hover:scale-105 disabled:opacity-40 disabled:hover:scale-100"
style={{ backgroundImage: `linear-gradient(to right, ${theme.accent}, ${theme.gradientTo})` }}
>
Envoyer
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,139 @@
import { useState, useEffect, useCallback } from 'react'
import { theme } from '../env'
const SERVER = 'https://game.rolld.kerboul.me'
const TABS = [
{ key: 'totalDistance', label: 'Distance', unit: 'm', format: v => Math.round(v).toLocaleString('fr-FR') },
{ key: 'maxSpeed', label: 'Vitesse max', unit: 'm/s', format: v => v.toFixed(1) },
{ key: 'totalJumps', label: 'Sauts', unit: '', format: v => v.toLocaleString('fr-FR') },
{ key: 'bestRaceTime', label: 'Meilleur temps', unit: '', format: v => {
const m = Math.floor(v / 60)
const s = (v % 60).toFixed(2).padStart(5, '0')
return `${m}:${s}`
}},
{ key: 'racesPlayed', label: 'Courses', unit: '', format: v => v.toLocaleString('fr-FR') },
{ key: 'bumpsGiven', label: 'Bumps', unit: '', format: v => v.toLocaleString('fr-FR') },
]
export default function StatsPage() {
const [activeTab, setActiveTab] = useState(TABS[0].key)
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(false)
const [lastRefresh, setLastRefresh] = useState(null)
const fetchLeaderboard = useCallback(async (key) => {
setLoading(true)
try {
const res = await fetch(`${SERVER}/stats/leaderboard/${key}`)
if (!res.ok) throw new Error(res.statusText)
const data = await res.json()
setRows(data)
setLastRefresh(new Date())
} catch {
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchLeaderboard(activeTab)
const id = setInterval(() => fetchLeaderboard(activeTab), 30_000)
return () => clearInterval(id)
}, [activeTab, fetchLeaderboard])
const currentTab = TABS.find(t => t.key === activeTab)
const medalColor = (i) => {
if (i === 0) return '#f39c12'
if (i === 1) return '#9b9b9b'
if (i === 2) return '#cd7f32'
return theme.accentLight
}
return (
<div className="min-h-screen pt-20 px-4">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="mb-8 text-center">
<h1 className="text-4xl font-black text-rolld-text mb-2">Classements</h1>
<p className="text-rolld-muted text-sm">Top 10 joueurs par catégorie mis à jour en temps réel</p>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-2 justify-center mb-8">
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
activeTab === tab.key
? 'text-white'
: 'text-rolld-muted hover:text-rolld-text bg-rolld-surface border border-rolld-border'
}`}
style={activeTab === tab.key ? {
background: `linear-gradient(to right, ${theme.accent}, ${theme.gradientTo})`,
} : {}}
>
{tab.label}
</button>
))}
</div>
{/* Table */}
<div className="rounded-2xl overflow-hidden border border-rolld-border bg-rolld-surface">
<div className="px-6 py-4 border-b border-rolld-border flex items-center justify-between">
<span className="text-rolld-text font-semibold">{currentTab.label}</span>
<div className="flex items-center gap-3">
{lastRefresh && (
<span className="text-rolld-muted text-xs">
{lastRefresh.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
)}
<button
onClick={() => fetchLeaderboard(activeTab)}
disabled={loading}
className="text-xs text-rolld-accent hover:text-rolld-accent-light transition-colors disabled:opacity-40"
>
{loading ? '...' : 'Actualiser'}
</button>
</div>
</div>
{loading && rows.length === 0 ? (
<div className="py-16 text-center text-rolld-muted text-sm">Chargement</div>
) : rows.length === 0 ? (
<div className="py-16 text-center text-rolld-muted text-sm">Aucune donnée pour l'instant.</div>
) : (
<table className="w-full">
<tbody>
{rows.map((row, i) => (
<tr
key={row.name}
className={`border-b border-rolld-border/50 last:border-0 transition-colors hover:bg-rolld-border/20 ${
i === 0 ? 'bg-rolld-border/10' : ''
}`}
>
<td className="px-6 py-4 w-12">
<span className="text-sm font-bold" style={{ color: medalColor(i) }}>
{i < 3 ? ['🥇', '🥈', '🥉'][i] : `#${i + 1}`}
</span>
</td>
<td className="px-2 py-4 flex-1 text-rolld-text font-medium">
{row.name}
</td>
<td className="px-6 py-4 text-right font-mono text-sm" style={{ color: theme.accentLight }}>
{currentTab.format(row[activeTab])}
{currentTab.unit && <span className="text-rolld-muted ml-1">{currentTab.unit}</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)
}

View File

@@ -376,9 +376,9 @@ public class PlayerController : MonoBehaviour
{
if (context.started)
{
// Touche appuyée
isJumpPressed = true;
jumpPressTime = 0f;
StatsTracker.Instance?.RegisterJump();
Debug.Log("Jump Started");
}
else if (context.performed)
@@ -516,6 +516,7 @@ public class PlayerController : MonoBehaviour
return;
_lastBumpTime[id] = Time.time;
StatsTracker.Instance?.RegisterBump();
// Repulsion direction: from remote toward local player
Vector3 dir = (transform.position - other.transform.position).normalized;
@@ -606,6 +607,13 @@ public class PlayerController : MonoBehaviour
fontStyle = FontStyle.Bold
};
labelStyle.normal.textColor = new Color(1f, 1f, 1f, _gaugeDisplayAlpha * 0.9f);
// Outline: draw 4× in black at ±1px, then once in white
var shadowStyle = new GUIStyle(labelStyle);
shadowStyle.normal.textColor = new Color(0f, 0f, 0f, _gaugeDisplayAlpha * 0.55f);
GUI.Label(new Rect(x + 1f, y - 25f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x - 1f, y - 27f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x + 1f, y - 27f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x - 1f, y - 25f, barWidth, 24f), "JUMP POWER", shadowStyle);
GUI.Label(new Rect(x, y - 26f, barWidth, 24f), "JUMP POWER", labelStyle);
// Ensure textures

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; }

View File

@@ -0,0 +1,52 @@
const fs = require("fs");
const path = require("path");
const DATA_FILE = path.join(__dirname, "../../data/chat.json");
const MAX_MESSAGES = 200;
let _messages = [];
let _nextId = 1;
function _load() {
try {
if (fs.existsSync(DATA_FILE)) {
const data = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
_messages = data.messages || [];
_nextId = data.nextId || (_messages.length + 1);
}
} catch (e) {
console.warn("[Chat] Failed to load chat.json:", e.message);
}
}
function _save() {
try {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify({ messages: _messages, nextId: _nextId }, null, 2));
} catch (e) {
console.warn("[Chat] Failed to save chat.json:", e.message);
}
}
function push(name, text) {
if (!name || !text || typeof name !== "string" || typeof text !== "string") return null;
name = name.slice(0, 32);
text = text.slice(0, 200);
if (text.trim().length === 0) return null;
const msg = { id: _nextId++, timestamp: Date.now(), name, text: text.trim() };
_messages.push(msg);
if (_messages.length > MAX_MESSAGES) _messages.splice(0, _messages.length - MAX_MESSAGES);
_save();
return msg;
}
function getHistory(since) {
const ts = Number(since) || 0;
return ts === 0 ? _messages.slice(-50) : _messages.filter((m) => m.timestamp > ts);
}
_load();
console.log(`[Chat] Loaded ${_messages.length} message(s)`);
module.exports = { push, getHistory };

View File

@@ -2,28 +2,98 @@ const cors = require('cors');
const { Server } = require('@colyseus/core');
const { WebSocketTransport } = require('@colyseus/ws-transport');
const { ArenaRoom } = require('./rooms/ArenaRoom');
const Stats = require('./stats/StatsManager');
const Chat = require('./chat/ChatManager');
const { z } = require('zod');
const PORT = process.env.PORT || 2567;
// Colyseus 0.17 express callback receives the transport's internal Express app
const statsUpdateSchema = z.object({
name: z.string().min(1).max(32),
stats: z.object({
totalDistance: z.number().optional(),
totalJumps: z.number().optional(),
maxSpeed: z.number().optional(),
bestRaceTime: z.number().optional(),
racesPlayed: z.number().optional(),
qualifications: z.number().optional(),
eliminations: z.number().optional(),
checkpointsTotal: z.number().optional(),
bumpsGiven: z.number().optional(),
totalPlaytime: z.number().optional(),
}),
});
const chatSendSchema = z.object({
name: z.string().min(1).max(32),
text: z.string().min(1).max(200),
});
let _gameServer;
const gameServer = new Server({
transport: new WebSocketTransport(),
express: (app) => {
app.use(cors());
app.use(require('express').json());
app.get('/health', (_req, res) => {
res.json({ service: 'game', status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/', (_req, res) => {
res.send('🎮 Game server running');
app.get('/', (_req, res) => res.send('🎮 Game server running'));
// ── Stats ────────────────────────────────────────────────────────────
app.get('/stats', (_req, res) => {
res.json(Stats.getAll());
});
app.get('/stats/leaderboard/:key', (req, res) => {
const board = Stats.getLeaderboard(req.params.key);
if (!board) return res.status(400).json({ error: 'invalid key' });
res.json(board);
});
app.post('/stats/update', (req, res) => {
const parsed = statsUpdateSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues });
const ok = Stats.update(parsed.data.name, parsed.data.stats);
res.json({ ok });
});
// ── Chat ─────────────────────────────────────────────────────────────
app.get('/chat/history', (req, res) => {
res.json(Chat.getHistory(req.query.since));
});
app.post('/chat/send', (req, res) => {
const parsed = chatSendSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues });
const msg = Chat.push(parsed.data.name, parsed.data.text);
if (!msg) return res.status(429).json({ error: 'empty or invalid message' });
// Broadcast to all active Colyseus rooms
if (_gameServer) {
try {
const rooms = _gameServer.matchMaker?.rooms;
if (rooms) {
for (const room of rooms.values()) {
room.broadcast('chat', msg);
}
}
} catch (_) {}
}
res.json(msg);
});
},
});
// Define rooms
gameServer.define('arena', ArenaRoom);
_gameServer = gameServer;
gameServer.define('arena', ArenaRoom).then(() => {
console.log('✅ ArenaRoom registered');
});
gameServer.listen(PORT).then(() => {
console.log(`🎮 Game server running on ws://localhost:${PORT}`);

View File

@@ -1,5 +1,6 @@
const { Room } = require("@colyseus/core");
const { GameState, Player } = require("../schema/GameState");
const Chat = require("../chat/ChatManager");
const LOBBY_TIMEOUT = 30;
const COUNTDOWN_DURATION = 3;
@@ -46,6 +47,13 @@ class ArenaRoom extends Room {
this._checkAllReady();
});
this.onMessage("chat", (client, data) => {
const player = this.state.players.get(client.sessionId);
if (!player || !data.text) return;
const msg = Chat.push(player.name, data.text);
if (msg) this.broadcast("chat", msg);
});
this.onMessage("checkpointReached", (client, data) => {
if (this.state.phase !== "playing") return;
const player = this.state.players.get(client.sessionId);

View File

@@ -0,0 +1,101 @@
const fs = require("fs");
const path = require("path");
const DATA_FILE = path.join(__dirname, "../../data/stats.json");
const VALID_KEYS = [
"totalDistance", "totalJumps", "maxSpeed", "bestRaceTime",
"racesPlayed", "qualifications", "eliminations",
"checkpointsTotal", "bumpsGiven", "totalPlaytime",
];
let _stats = {};
const _lastUpdate = new Map(); // name → timestamp, for rate-limiting
function _load() {
try {
if (fs.existsSync(DATA_FILE)) {
_stats = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
}
} catch (e) {
console.warn("[Stats] Failed to load stats.json, starting fresh:", e.message);
_stats = {};
}
}
function _save() {
try {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(_stats, null, 2));
} catch (e) {
console.warn("[Stats] Failed to save stats.json:", e.message);
}
}
function _defaults() {
return {
totalDistance: 0,
totalJumps: 0,
maxSpeed: 0,
bestRaceTime: null,
racesPlayed: 0,
qualifications: 0,
eliminations: 0,
checkpointsTotal: 0,
bumpsGiven: 0,
totalPlaytime: 0,
};
}
function update(name, delta) {
if (!name || typeof name !== "string" || name.length > 32) return false;
const now = Date.now();
const last = _lastUpdate.get(name) || 0;
if (now - last < 5000) return false; // rate-limit: 1 update per 5s per player
_lastUpdate.set(name, now);
if (!_stats[name]) _stats[name] = _defaults();
const p = _stats[name];
for (const key of VALID_KEYS) {
if (delta[key] === undefined) continue;
const val = Number(delta[key]);
if (isNaN(val)) continue;
if (key === "maxSpeed") {
p.maxSpeed = Math.max(p.maxSpeed, val);
} else if (key === "bestRaceTime") {
if (val > 0 && (p.bestRaceTime === null || val < p.bestRaceTime)) {
p.bestRaceTime = val;
}
} else {
p[key] = (p[key] || 0) + val;
}
}
_save();
return true;
}
function getAll() {
return Object.entries(_stats).map(([name, s]) => ({ name, ...s }));
}
function getLeaderboard(key) {
if (!VALID_KEYS.includes(key)) return [];
return Object.entries(_stats)
.map(([name, s]) => ({ name, value: s[key] ?? 0 }))
.filter((e) => e.value !== null && e.value > 0)
.sort((a, b) => {
// bestRaceTime: lower is better
if (key === "bestRaceTime") return a.value - b.value;
return b.value - a.value;
})
.slice(0, 10);
}
_load();
console.log(`[Stats] Loaded ${Object.keys(_stats).length} player(s)`);
module.exports = { update, getAll, getLeaderboard, VALID_KEYS };