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 { useState } from 'react'
|
||||||
import { IS_DEV } from './env'
|
import { IS_DEV } from './env'
|
||||||
import DevBanner from './components/DevBanner'
|
import DevBanner from './components/DevBanner'
|
||||||
|
import NavBar from './components/NavBar'
|
||||||
import Hero from './components/Hero'
|
import Hero from './components/Hero'
|
||||||
import GelShowcase from './components/GelShowcase'
|
import GelShowcase from './components/GelShowcase'
|
||||||
import KerboulistanBanner from './components/KerboulistanBanner'
|
import KerboulistanBanner from './components/KerboulistanBanner'
|
||||||
import GameCanvas from './components/GameCanvas'
|
import GameCanvas from './components/GameCanvas'
|
||||||
import Footer from './components/Footer'
|
import Footer from './components/Footer'
|
||||||
|
import StatsPage from './pages/StatsPage'
|
||||||
|
import ChatPage from './pages/ChatPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [page, setPage] = useState('home')
|
||||||
|
|
||||||
if (isPlaying) {
|
if (page === 'play') {
|
||||||
return <GameCanvas onBack={() => setIsPlaying(false)} />
|
return <GameCanvas onBack={() => setPage('home')} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<DevBanner />
|
<DevBanner />
|
||||||
{/* Offset content when dev banner is visible */}
|
<NavBar page={page} setPage={setPage} />
|
||||||
{IS_DEV && <div className="h-8" />}
|
|
||||||
<Hero onPlay={() => setIsPlaying(true)} />
|
{page === 'home' && (
|
||||||
<GelShowcase />
|
<>
|
||||||
<KerboulistanBanner />
|
{IS_DEV && <div className="h-8" />}
|
||||||
<Footer />
|
<div className="pt-14">
|
||||||
|
<Hero onPlay={() => setPage('play')} />
|
||||||
|
<GelShowcase />
|
||||||
|
<KerboulistanBanner />
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{page === 'stats' && <StatsPage />}
|
||||||
|
{page === 'chat' && <ChatPage />}
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -376,9 +376,9 @@ public class PlayerController : MonoBehaviour
|
|||||||
{
|
{
|
||||||
if (context.started)
|
if (context.started)
|
||||||
{
|
{
|
||||||
// Touche appuyée
|
|
||||||
isJumpPressed = true;
|
isJumpPressed = true;
|
||||||
jumpPressTime = 0f;
|
jumpPressTime = 0f;
|
||||||
|
StatsTracker.Instance?.RegisterJump();
|
||||||
Debug.Log("Jump Started");
|
Debug.Log("Jump Started");
|
||||||
}
|
}
|
||||||
else if (context.performed)
|
else if (context.performed)
|
||||||
@@ -516,6 +516,7 @@ public class PlayerController : MonoBehaviour
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_lastBumpTime[id] = Time.time;
|
_lastBumpTime[id] = Time.time;
|
||||||
|
StatsTracker.Instance?.RegisterBump();
|
||||||
|
|
||||||
// Repulsion direction: from remote toward local player
|
// Repulsion direction: from remote toward local player
|
||||||
Vector3 dir = (transform.position - other.transform.position).normalized;
|
Vector3 dir = (transform.position - other.transform.position).normalized;
|
||||||
@@ -606,6 +607,13 @@ public class PlayerController : MonoBehaviour
|
|||||||
fontStyle = FontStyle.Bold
|
fontStyle = FontStyle.Bold
|
||||||
};
|
};
|
||||||
labelStyle.normal.textColor = new Color(1f, 1f, 1f, _gaugeDisplayAlpha * 0.9f);
|
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);
|
GUI.Label(new Rect(x, y - 26f, barWidth, 24f), "JUMP POWER", labelStyle);
|
||||||
|
|
||||||
// Ensure textures
|
// Ensure textures
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ public class GameManager : MonoBehaviour
|
|||||||
{
|
{
|
||||||
case GamePhase.Lobby:
|
case GamePhase.Lobby:
|
||||||
SetPlayerActive(NetworkManager.Instance?.IsConnected ?? false);
|
SetPlayerActive(NetworkManager.Instance?.IsConnected ?? false);
|
||||||
SetSpectatorActive(false);
|
|
||||||
gameHUD?.SetPhase("lobby");
|
gameHUD?.SetPhase("lobby");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ public class NetworkManager : MonoBehaviour
|
|||||||
Debug.Log($"[Network] Game over — Winner: {msg.winner}");
|
Debug.Log($"[Network] Game over — Winner: {msg.winner}");
|
||||||
OnGameEnd?.Invoke(msg.winner);
|
OnGameEnd?.Invoke(msg.winner);
|
||||||
});
|
});
|
||||||
|
_room.OnMessage<ChatUI.ChatMessage>("chat", msg =>
|
||||||
|
{
|
||||||
|
ChatUI.Instance?.ReceiveChatMessage(msg);
|
||||||
|
});
|
||||||
|
|
||||||
_room.OnLeave += OnRoomLeave;
|
_room.OnLeave += OnRoomLeave;
|
||||||
OnConnected?.Invoke();
|
OnConnected?.Invoke();
|
||||||
@@ -206,6 +210,12 @@ public class NetworkManager : MonoBehaviour
|
|||||||
await _room.Send("checkpointReached", new { index });
|
await _room.Send("checkpointReached", new { index });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async void SendChatMessage(string text)
|
||||||
|
{
|
||||||
|
if (_room != null && IsConnected)
|
||||||
|
await _room.Send("chat", new { text });
|
||||||
|
}
|
||||||
|
|
||||||
// ─── State Callbacks ─────────────────────────────────────────────────
|
// ─── State Callbacks ─────────────────────────────────────────────────
|
||||||
|
|
||||||
private void _OnPhaseChanged(string phase)
|
private void _OnPhaseChanged(string phase)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ public class CheckpointSystem : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
_localCheckpointIndex++;
|
_localCheckpointIndex++;
|
||||||
|
StatsTracker.Instance?.RegisterCheckpoint();
|
||||||
NetworkManager.Instance?.SendCheckpoint(_localCheckpointIndex);
|
NetworkManager.Instance?.SendCheckpoint(_localCheckpointIndex);
|
||||||
|
|
||||||
Debug.Log($"[Checkpoint] Reached {_localCheckpointIndex}/{checkpoints.Length}");
|
Debug.Log($"[Checkpoint] Reached {_localCheckpointIndex}/{checkpoints.Length}");
|
||||||
@@ -79,6 +80,7 @@ public class CheckpointSystem : MonoBehaviour
|
|||||||
if (_localCheckpointIndex >= checkpoints.Length)
|
if (_localCheckpointIndex >= checkpoints.Length)
|
||||||
{
|
{
|
||||||
_finished = true;
|
_finished = true;
|
||||||
|
StatsTracker.Instance?.RegisterFinish(GameHUD.Instance != null ? GameHUD.Instance.LocalRaceTimer : 0f);
|
||||||
Debug.Log("[Checkpoint] FINISH LINE reached!");
|
Debug.Log("[Checkpoint] FINISH LINE reached!");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
259
game/Assets/Scripts/UI/ChatUI.cs
Normal file
259
game/Assets/Scripts/UI/ChatUI.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -89,6 +89,8 @@ public class GameHUD : MonoBehaviour
|
|||||||
_countdownPulse = Mathf.Max(0f, _countdownPulse - Time.deltaTime * 3f);
|
_countdownPulse = Mathf.Max(0f, _countdownPulse - Time.deltaTime * 3f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public float LocalRaceTimer => _localRaceTimer;
|
||||||
|
|
||||||
public void SetPhase(string phase) => _phase = phase;
|
public void SetPhase(string phase) => _phase = phase;
|
||||||
public void SetCountdown(float v) => _countdown = v;
|
public void SetCountdown(float v) => _countdown = v;
|
||||||
public void SetRoundInfo(int round, string mode) { _roundNumber = round; _gameMode = mode; }
|
public void SetRoundInfo(int round, string mode) { _roundNumber = round; _gameMode = mode; }
|
||||||
|
|||||||
52
rolld_backend/game/src/chat/ChatManager.js
Normal file
52
rolld_backend/game/src/chat/ChatManager.js
Normal 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 };
|
||||||
@@ -2,28 +2,98 @@ const cors = require('cors');
|
|||||||
const { Server } = require('@colyseus/core');
|
const { Server } = require('@colyseus/core');
|
||||||
const { WebSocketTransport } = require('@colyseus/ws-transport');
|
const { WebSocketTransport } = require('@colyseus/ws-transport');
|
||||||
const { ArenaRoom } = require('./rooms/ArenaRoom');
|
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;
|
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({
|
const gameServer = new Server({
|
||||||
transport: new WebSocketTransport(),
|
transport: new WebSocketTransport(),
|
||||||
express: (app) => {
|
express: (app) => {
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
app.use(require('express').json());
|
||||||
|
|
||||||
app.get('/health', (_req, res) => {
|
app.get('/health', (_req, res) => {
|
||||||
res.json({ service: 'game', status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ service: 'game', status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/', (_req, res) => {
|
app.get('/', (_req, res) => res.send('🎮 Game server running'));
|
||||||
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 = gameServer;
|
||||||
gameServer.define('arena', ArenaRoom);
|
|
||||||
console.log('✅ ArenaRoom registered');
|
gameServer.define('arena', ArenaRoom).then(() => {
|
||||||
|
console.log('✅ ArenaRoom registered');
|
||||||
|
});
|
||||||
|
|
||||||
gameServer.listen(PORT).then(() => {
|
gameServer.listen(PORT).then(() => {
|
||||||
console.log(`🎮 Game server running on ws://localhost:${PORT}`);
|
console.log(`🎮 Game server running on ws://localhost:${PORT}`);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { Room } = require("@colyseus/core");
|
const { Room } = require("@colyseus/core");
|
||||||
const { GameState, Player } = require("../schema/GameState");
|
const { GameState, Player } = require("../schema/GameState");
|
||||||
|
const Chat = require("../chat/ChatManager");
|
||||||
|
|
||||||
const LOBBY_TIMEOUT = 30;
|
const LOBBY_TIMEOUT = 30;
|
||||||
const COUNTDOWN_DURATION = 3;
|
const COUNTDOWN_DURATION = 3;
|
||||||
@@ -46,6 +47,13 @@ class ArenaRoom extends Room {
|
|||||||
this._checkAllReady();
|
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) => {
|
this.onMessage("checkpointReached", (client, data) => {
|
||||||
if (this.state.phase !== "playing") return;
|
if (this.state.phase !== "playing") return;
|
||||||
const player = this.state.players.get(client.sessionId);
|
const player = this.state.players.get(client.sessionId);
|
||||||
|
|||||||
101
rolld_backend/game/src/stats/StatsManager.js
Normal file
101
rolld_backend/game/src/stats/StatsManager.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user