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,28 +1,41 @@
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 */}
{IS_DEV && <div className="h-8" />}
<Hero onPlay={() => setIsPlaying(true)} />
<GelShowcase />
<KerboulistanBanner />
<Footer />
<NavBar page={page} setPage={setPage} />
{page === 'home' && (
<>
{IS_DEV && <div className="h-8" />}
<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>
)
}