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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
50
frontend/src/components/NavBar.jsx
Normal file
50
frontend/src/components/NavBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
frontend/src/pages/ChatPage.jsx
Normal file
185
frontend/src/pages/ChatPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
frontend/src/pages/StatsPage.jsx
Normal file
139
frontend/src/pages/StatsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user