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

@@ -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);
console.log('✅ ArenaRoom registered');
_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 };