chore: add game server (Colyseus) - flat files

This commit is contained in:
2026-05-15 09:12:10 +02:00
parent c4d9d9b53a
commit 70a3e376b2
10 changed files with 1607 additions and 1 deletions

View File

@@ -0,0 +1,4 @@
node_modules
npm-debug.log
.git
.gitignore

View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 2567
CMD ["node", "src/index.js"]

1306
rolld_backend/game/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "rolld-game",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"@colyseus/core": "^0.17.39",
"@colyseus/schema": "^4.0.15",
"@colyseus/ws-transport": "^0.17.9",
"cors": "^2.8.6",
"express": "^4.22.1",
"zod": "^4.3.6"
}
}

View File

@@ -0,0 +1,30 @@
const cors = require('cors');
const { Server } = require('@colyseus/core');
const { WebSocketTransport } = require('@colyseus/ws-transport');
const { ArenaRoom } = require('./rooms/ArenaRoom');
const PORT = process.env.PORT || 2567;
// Colyseus 0.17 express callback receives the transport's internal Express app
const gameServer = new Server({
transport: new WebSocketTransport(),
express: (app) => {
app.use(cors());
app.get('/health', (_req, res) => {
res.json({ service: 'game', status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/', (_req, res) => {
res.send('🎮 Game server running');
});
},
});
// Define rooms
gameServer.define('arena', ArenaRoom);
console.log('✅ ArenaRoom registered');
gameServer.listen(PORT).then(() => {
console.log(`🎮 Game server running on ws://localhost:${PORT}`);
});

View File

@@ -0,0 +1,120 @@
const { Room } = require("@colyseus/core");
const { GameState, Player } = require("../schema/GameState");
class ArenaRoom extends Room {
maxClients = 20;
onCreate(options) {
this.setState(new GameState());
this.setPatchRate(16); // ~62.5 Hz state broadcast
console.log(`[ArenaRoom] Room ${this.roomId} created (patchRate=16ms ~62Hz)`);
// Handle position updates from clients
this.onMessage("position", (client, data) => {
const player = this.state.players.get(client.sessionId);
if (!player) return;
player.x = data.x ?? player.x;
player.y = data.y ?? player.y;
player.z = data.z ?? player.z;
player.vx = data.vx ?? player.vx;
player.vy = data.vy ?? player.vy;
player.vz = data.vz ?? player.vz;
player.rx = data.rx ?? player.rx;
player.ry = data.ry ?? player.ry;
player.rz = data.rz ?? player.rz;
player.rw = data.rw ?? player.rw;
player.avx = data.avx ?? player.avx;
player.avy = data.avy ?? player.avy;
player.avz = data.avz ?? player.avz;
player.t = Date.now();
});
// Handle chat messages (optional, for future)
this.onMessage("chat", (client, data) => {
this.broadcast("chat", {
sender: client.sessionId,
name: this.state.players.get(client.sessionId)?.name || "???",
message: data.message,
});
});
}
onJoin(client, options) {
console.log(`[ArenaRoom] ${client.sessionId} joined (name: ${options.name || "anonymous"})`);
const player = new Player();
player.name = options.name || "Joueur";
player.colorR = options.colorR ?? 1;
player.colorG = options.colorG ?? 0.4;
player.colorB = options.colorB ?? 0.2;
// Find a spawn position away from other players
const spawnPos = this._findSpawnPosition();
player.x = spawnPos.x;
player.y = spawnPos.y;
player.z = spawnPos.z;
player.t = Date.now();
this.state.players.set(client.sessionId, player);
}
onLeave(client, consented) {
console.log(`[ArenaRoom] ${client.sessionId} left (consented: ${consented})`);
this.state.players.delete(client.sessionId);
}
onDispose() {
console.log(`[ArenaRoom] Room ${this.roomId} disposed`);
}
/**
* Find a spawn position elevated and away from existing players.
* Tries up to 10 random positions, picks the one farthest from others.
* Falls back to random if no good spot found.
*/
_findSpawnPosition() {
const MIN_DIST = 3.0;
const SPAWN_Y = 5; // elevated spawn — ball drops naturally
const RANGE = 20;
let bestPos = { x: 0, y: SPAWN_Y, z: 0 };
let bestMinDist = 0;
const existingPositions = [];
this.state.players.forEach((p) => {
existingPositions.push({ x: p.x, z: p.z });
});
// If no existing players, just random
if (existingPositions.length === 0) {
return {
x: (Math.random() - 0.5) * RANGE,
y: SPAWN_Y,
z: (Math.random() - 0.5) * RANGE,
};
}
for (let attempt = 0; attempt < 10; attempt++) {
const cx = (Math.random() - 0.5) * RANGE;
const cz = (Math.random() - 0.5) * RANGE;
let minDist = Infinity;
for (const p of existingPositions) {
const dx = cx - p.x;
const dz = cz - p.z;
const d = Math.sqrt(dx * dx + dz * dz);
if (d < minDist) minDist = d;
}
if (minDist >= MIN_DIST) {
return { x: cx, y: SPAWN_Y, z: cz };
}
if (minDist > bestMinDist) {
bestMinDist = minDist;
bestPos = { x: cx, y: SPAWN_Y, z: cz };
}
}
return bestPos;
}
}
module.exports = { ArenaRoom };

View File

@@ -0,0 +1,59 @@
const { Schema, MapSchema, defineTypes } = require("@colyseus/schema");
class Player extends Schema {
constructor() {
super();
this.x = 0;
this.y = 5;
this.z = 0;
this.vx = 0;
this.vy = 0;
this.vz = 0;
this.rx = 0;
this.ry = 0;
this.rz = 0;
this.rw = 1;
this.t = 0;
this.name = "";
this.colorR = 1;
this.colorG = 1;
this.colorB = 1;
this.avx = 0;
this.avy = 0;
this.avz = 0;
}
}
defineTypes(Player, {
x: "float32",
y: "float32",
z: "float32",
vx: "float32",
vy: "float32",
vz: "float32",
rx: "float32",
ry: "float32",
rz: "float32",
rw: "float32",
t: "float64",
name: "string",
colorR: "float32",
colorG: "float32",
colorB: "float32",
avx: "float32",
avy: "float32",
avz: "float32",
});
class GameState extends Schema {
constructor() {
super();
this.players = new MapSchema();
}
}
defineTypes(GameState, {
players: { map: Player },
});
module.exports = { GameState, Player };