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:
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 { 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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
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