fitst version
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
'use strict';
|
||||
/*
|
||||
* Turbo Race - spelengine (server-authoritatief)
|
||||
* 20 spelers, verticaal scrollende weg, obstakels + power-ups,
|
||||
* race max 120s, countdown na host-start.
|
||||
*/
|
||||
const { EventEmitter } = require('events');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ROAD_W = 420; // wegbreedte in wereld-eenheden (= px)
|
||||
const CAR_W = 24; // breedte auto
|
||||
const CAR_HALF_X = CAR_W / 2;
|
||||
const CAR_HALF_Y = 13; // halve verticale hitbox
|
||||
const CRUISE = 300; // kruissnelheid (u/s)
|
||||
const BOOST_BONUS = 175; // extra snelheid tijdens boost
|
||||
const LATERAL = 165; // laterale stuursnelheid (u/s)
|
||||
const STEER_RESPONSE = 7; // hoe snel stuuruitslag inslingert (hoger = directer, lager = vloeiender)
|
||||
const OBJ_VH = 12; // verticale halve hoogte van obstakels
|
||||
const PICKUP_VH = 14; // verticale halve hoogte van power-ups
|
||||
const PICKUP_RADIUS = 16; // horizontale straal power-up
|
||||
|
||||
function clamp(n, lo, hi) { return n < lo ? lo : (n > hi ? hi : n); }
|
||||
|
||||
// Deterministische PRNG zodat de baan eerlijk (identiek) is voor iedereen
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
class Game extends EventEmitter {
|
||||
constructor(opts = {}) {
|
||||
super();
|
||||
this.maxPlayers = opts.maxPlayers || 20;
|
||||
this.countdownS = opts.countdownS || 30;
|
||||
this.raceDurationS = opts.raceDurationS || 90; // race max 1:30
|
||||
this.trackLength = opts.trackLength || 23000; // finish = einde van de baan
|
||||
|
||||
this.players = new Map(); // id -> player
|
||||
this.order = []; // aanmeld-volgorde
|
||||
this.phase = 'lobby'; // lobby | countdown | race | results
|
||||
this.countdownEndsAt = 0;
|
||||
this.raceEndsAt = 0;
|
||||
this.raceStartedAt = 0;
|
||||
this.track = null; // { seed, obstacles[], powerups[] }
|
||||
this._idc = 1;
|
||||
}
|
||||
|
||||
_randomColor() {
|
||||
const hue = Math.floor(Math.random() * 360);
|
||||
return `hsl(${hue} 90% 62%)`;
|
||||
}
|
||||
|
||||
_newId() { return 'p' + (this._idc++); }
|
||||
|
||||
addPlayer(nameRaw, color, hostToken, adminToken) {
|
||||
if (this.players.size >= this.maxPlayers) {
|
||||
const e = new Error(`Vol zit: max ${this.maxPlayers} spelers`);
|
||||
e.code = 'FULL'; throw e;
|
||||
}
|
||||
if (this.phase === 'race') {
|
||||
const e = new Error('De race is al bezig');
|
||||
e.code = 'LATE'; throw e;
|
||||
}
|
||||
const name = String(nameRaw || '').trim().slice(0, 14) ||
|
||||
('Anoniem-' + Math.floor(2 + Math.random() * 98));
|
||||
const player = {
|
||||
id: this._newId(),
|
||||
name,
|
||||
color: /^#[0-9a-fA-F]{6}$/.test(String(color)) ? color : this._randomColor(),
|
||||
host: !!(hostToken && adminToken && String(hostToken) === String(adminToken)),
|
||||
x: clamp(40 + Math.random() * (ROAD_W - 80), CAR_HALF_X, ROAD_W - CAR_HALF_X),
|
||||
d: 0,
|
||||
speed: 0,
|
||||
finished: false,
|
||||
finishTime: 0,
|
||||
rank: 0,
|
||||
boostUntil: 0, shieldUntil: 0, slipUntil: 0, slowUntil: 0,
|
||||
steer: 0, steerSm: 0,
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
this.players.set(player.id, player);
|
||||
this.order.push(player.id);
|
||||
this.emit('join', player);
|
||||
return player;
|
||||
}
|
||||
|
||||
removePlayer(id) {
|
||||
if (!this.players.delete(id)) return;
|
||||
this.order = this.order.filter((x) => x !== id);
|
||||
this.emit('leave', id);
|
||||
}
|
||||
|
||||
hostStart() {
|
||||
if (this.phase !== 'lobby' && this.phase !== 'results') return false;
|
||||
this.phase = 'countdown';
|
||||
this.countdownEndsAt = Date.now() + this.countdownS * 1000;
|
||||
this.emit('countdown');
|
||||
return true;
|
||||
}
|
||||
|
||||
readyAgain() {
|
||||
if (this.phase !== 'results') return false;
|
||||
this._resetRaceState();
|
||||
this.phase = 'lobby';
|
||||
this.emit('lobby_reset');
|
||||
return true;
|
||||
}
|
||||
|
||||
_resetRaceState() {
|
||||
for (const p of this.players.values()) {
|
||||
p.x = clamp(40 + Math.random() * (ROAD_W - 80), CAR_HALF_X, ROAD_W - CAR_HALF_X);
|
||||
p.d = 0; p.speed = 0; p.finished = false; p.finishTime = 0;
|
||||
p.boostUntil = p.shieldUntil = p.slipUntil = p.slowUntil = 0;
|
||||
p.steerSm = 0;
|
||||
}
|
||||
this.track = null;
|
||||
}
|
||||
|
||||
update(dtMs) {
|
||||
const now = Date.now();
|
||||
if (this.phase === 'countdown' && now >= this.countdownEndsAt) {
|
||||
this._startRace(now);
|
||||
} else if (this.phase === 'race') {
|
||||
this._updatePhysics(dtMs / 1000, now);
|
||||
const ended = now >= this.raceEndsAt || this._allFinished();
|
||||
if (ended) {
|
||||
this._rank();
|
||||
this.phase = 'results';
|
||||
this.emit('results');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_allFinished() {
|
||||
if (this.players.size === 0) return false;
|
||||
for (const p of this.players.values()) if (!p.finished) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
_startRace(now) {
|
||||
this.phase = 'race';
|
||||
this.raceStartedAt = now;
|
||||
this.raceEndsAt = now + this.raceDurationS * 1000;
|
||||
this._generateTrack();
|
||||
// Startgrid: posities verdeeld van links naar rechts
|
||||
const arr = [...this.players.values()];
|
||||
const slots = ROAD_W / (arr.length + 1);
|
||||
arr.forEach((p, i) => {
|
||||
p.x = slots * (i + 1);
|
||||
p.d = 0; p.speed = 0;
|
||||
p.finished = false; p.finishTime = 0;
|
||||
p.boostUntil = p.shieldUntil = p.slipUntil = p.slowUntil = 0;
|
||||
p.steerSm = 0;
|
||||
});
|
||||
this.emit('race_start', this.track);
|
||||
}
|
||||
|
||||
_generateTrack() {
|
||||
const seed = crypto.randomBytes(4).readUInt32LE(0) || 1;
|
||||
const rng = mulberry32(seed);
|
||||
const obstacles = [];
|
||||
const powerups = [];
|
||||
const lanes = [70, 210, 350];
|
||||
let oid = 0, pid = 0;
|
||||
let d = 700;
|
||||
let puD = 1500;
|
||||
while (d < this.trackLength - 900) {
|
||||
const step = 250 + rng() * 190;
|
||||
// aantal geblokkeerde banen: nooit alle 3, zodat er altijd een doorgang is
|
||||
const blockCount = rng() < 0.18 ? 1 : 2;
|
||||
const idx = [0, 1, 2].sort(() => rng() - 0.5).slice(0, blockCount).sort((a, b) => a - b);
|
||||
for (const li of idx) {
|
||||
const type = rng() < 0.72 ? 'pylon' : 'oil';
|
||||
obstacles.push({
|
||||
id: 'o' + (oid++),
|
||||
type,
|
||||
d: Math.round(d + (rng() - 0.5) * 10),
|
||||
x: clamp(lanes[li] + (rng() - 0.5) * 26, 30, ROAD_W - 30),
|
||||
hh: type === 'oil' ? 26 : 14,
|
||||
});
|
||||
}
|
||||
// power-ups
|
||||
if (d >= puD && d + 500 < this.trackLength - 900) {
|
||||
const kind = pid % 2 === 0 ? 'boost' : 'shield';
|
||||
const x = lanes[Math.floor(rng() * 3)] + (rng() - 0.5) * 34;
|
||||
powerups.push({
|
||||
id: 'p' + (pid++),
|
||||
kind,
|
||||
d: Math.round(puD),
|
||||
x: clamp(x, 36, ROAD_W - 36),
|
||||
respawnAt: 0,
|
||||
});
|
||||
puD += 700 + rng() * 240;
|
||||
}
|
||||
d += step;
|
||||
}
|
||||
if (powerups.length === 0) {
|
||||
powerups.push({ id: 'p0', kind: 'boost', d: 1500, x: 210, respawnAt: 0 });
|
||||
}
|
||||
this.track = { seed, obstacles, powerups };
|
||||
return this.track;
|
||||
}
|
||||
|
||||
_updatePhysics(dt, now) {
|
||||
this._rank();
|
||||
for (const p of this.players.values()) {
|
||||
if (p.finished) { p.speed = 0; continue; }
|
||||
|
||||
// Snelheidsdoel
|
||||
let target = CRUISE + (p.boostUntil > now ? BOOST_BONUS : 0);
|
||||
if (p.slowUntil > now) target = Math.min(target, 70);
|
||||
p.speed += (target - p.speed) * Math.min(1, dt * 2.2);
|
||||
|
||||
// Sturen: geleidelijk inslingeren (smoother) i.p.v. direct volle uitslag.
|
||||
// p.steer is het ruwe doel van de client, p.steerSm de opgerampte waarde.
|
||||
p.steerSm += (p.steer - p.steerSm) * Math.min(1, dt * STEER_RESPONSE);
|
||||
|
||||
// Sturen (olie = wegglijden)
|
||||
let mv = p.steerSm * LATERAL * dt;
|
||||
if (p.slipUntil > now) mv = p.steerSm * LATERAL * 0.3 * dt + (Math.random() - 0.5) * 260 * dt;
|
||||
p.x = clamp(p.x + mv + (p.slipUntil > now ? (Math.random() - 0.5) * 90 * dt : 0),
|
||||
CAR_HALF_X, ROAD_W - CAR_HALF_X);
|
||||
|
||||
p.d += p.speed * dt;
|
||||
if (p.d >= this.trackLength && !p.finished) {
|
||||
p.finished = true;
|
||||
p.finishTime = Math.max(1, now - this.raceStartedAt);
|
||||
this.emit('finish', { id: p.id, name: p.name, slot: p.rank });
|
||||
}
|
||||
}
|
||||
this._collisions(now);
|
||||
}
|
||||
|
||||
_collisions(now) {
|
||||
if (!this.track) return;
|
||||
for (const p of this.players.values()) {
|
||||
if (p.finished) continue;
|
||||
for (const o of this.track.obstacles) {
|
||||
if (Math.abs(p.d - o.d) < OBJ_VH + CAR_HALF_Y &&
|
||||
Math.abs(p.x - o.x) < o.hh + CAR_HALF_X) {
|
||||
if (p.shieldUntil > now) continue;
|
||||
if (o.type === 'pylon' && p.slowUntil <= now) {
|
||||
p.slowUntil = now + 900;
|
||||
p.speed = Math.min(p.speed, 70);
|
||||
this.emit('hit', { id: p.id, kind: 'pylon' });
|
||||
} else if (o.type === 'oil' && p.slipUntil <= now) {
|
||||
p.slipUntil = now + 1300;
|
||||
p.speed *= 0.8;
|
||||
this.emit('hit', { id: p.id, kind: 'oil' });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const pu of this.track.powerups) {
|
||||
if (pu.respawnAt > now) continue;
|
||||
if (Math.abs(p.d - pu.d) < PICKUP_VH + CAR_HALF_Y &&
|
||||
Math.abs(p.x - pu.x) < PICKUP_RADIUS + CAR_HALF_X) {
|
||||
if (pu.kind === 'boost') p.boostUntil = now + 2600;
|
||||
else p.shieldUntil = now + 5000;
|
||||
pu.respawnAt = now + 4500;
|
||||
this.emit('pickup', { id: p.id, kind: pu.kind });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_rank() {
|
||||
const arr = [...this.players.values()];
|
||||
arr.sort((a, b) => {
|
||||
if (a.finished && b.finished) return a.finishTime - b.finishTime;
|
||||
if (a.finished) return -1;
|
||||
if (b.finished) return 1;
|
||||
return b.d - a.d;
|
||||
});
|
||||
arr.forEach((p, i) => { p.rank = i + 1; });
|
||||
return arr;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
const now = Date.now();
|
||||
this._rank();
|
||||
const players = [...this.players.values()].map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
color: p.color,
|
||||
x: +p.x.toFixed(1),
|
||||
d: Math.round(p.d),
|
||||
speed: Math.round(p.speed),
|
||||
finished: p.finished,
|
||||
finishTime: p.finishTime,
|
||||
rank: p.rank,
|
||||
boost: p.boostUntil > now,
|
||||
shield: p.shieldUntil > now,
|
||||
slip: p.slipUntil > now,
|
||||
}));
|
||||
return {
|
||||
type: 'snapshot',
|
||||
phase: this.phase,
|
||||
countdownEndsAt: this.countdownEndsAt,
|
||||
raceEndsAt: this.raceEndsAt,
|
||||
raceDuration: this.raceDurationS,
|
||||
trackLength: this.trackLength,
|
||||
maxPlayers: this.maxPlayers,
|
||||
players,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Game;
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
/*
|
||||
* Turbo Race - HTTP + WebSocket server
|
||||
* Serveert de statische frontend en draait de spelengine.
|
||||
* Host-eindpunt: verbind met ?host=<ADMIN_TOKEN> en druk op "s".
|
||||
*/
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const Game = require('./engine');
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '8080', 10);
|
||||
const MAX_PLAYERS = parseInt(process.env.MAX_PLAYERS || '20', 10);
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'dev-host';
|
||||
const COUNTDOWN_S = parseInt(process.env.COUNTDOWN_S || '30', 10);
|
||||
const RACE_DURATION_S = parseInt(process.env.RACE_DURATION_S || '90', 10);
|
||||
const TRACK_LENGTH = parseInt(process.env.TRACK_LENGTH || '23000', 10);
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
app.get('/healthz', (req, res) => res.status(200).json({ ok: true, phase: game.phase, players: game.players.size }));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
const game = new Game({
|
||||
maxPlayers: MAX_PLAYERS,
|
||||
countdownS: COUNTDOWN_S,
|
||||
raceDurationS: RACE_DURATION_S,
|
||||
trackLength: TRACK_LENGTH,
|
||||
});
|
||||
|
||||
// socket -> playerId
|
||||
const sockToId = new Map();
|
||||
|
||||
function broadcast(obj, except) {
|
||||
const data = JSON.stringify(obj);
|
||||
for (const client of wss.clients) {
|
||||
if (client.readyState === client.OPEN && client !== except) client.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
function send(ws, obj) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/* ---------------- engine-events -> naar alle clients ---------------- */
|
||||
game.on('join', (p) =>
|
||||
broadcast({ type: 'player_joined', player: { id: p.id, name: p.name, color: p.color } }));
|
||||
game.on('leave', (id) => broadcast({ type: 'player_left', id }));
|
||||
game.on('countdown', () => broadcast({ type: 'phase', phase: 'countdown' }));
|
||||
game.on('race_start', (track) => {
|
||||
broadcast({ type: 'track', track });
|
||||
broadcast(game.snapshot());
|
||||
});
|
||||
game.on('lobby_reset', () => broadcast({ type: 'phase', phase: 'lobby' }));
|
||||
game.on('hit', (e) => broadcast({ type: 'fx', id: e.id, kind: 'hit:' + e.kind }));
|
||||
game.on('pickup', (e) => broadcast({ type: 'fx', id: e.id, kind: 'pickup:' + e.kind }));
|
||||
game.on('finish', (e) =>
|
||||
broadcast({ type: 'announce', text: `🏁 ${e.name} is over de finish!` }));
|
||||
|
||||
/* ---------------- websocket ---------------- */
|
||||
wss.on('connection', (ws) => {
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('message', (buf) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||
switch (msg.type) {
|
||||
case 'join': {
|
||||
let player;
|
||||
try {
|
||||
player = game.addPlayer(msg.name, msg.color, msg.hostToken, ADMIN_TOKEN);
|
||||
} catch (e) {
|
||||
return send(ws, { type: 'error', code: e.code || 'ERR', msg: e.message });
|
||||
}
|
||||
sockToId.set(ws, player.id);
|
||||
send(ws, {
|
||||
type: 'joined',
|
||||
id: player.id,
|
||||
host: player.host,
|
||||
self: { name: player.name, color: player.color },
|
||||
snapshot: game.snapshot(),
|
||||
track: game.track,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'input': {
|
||||
const id = sockToId.get(ws);
|
||||
if (!id) break;
|
||||
const p = game.players.get(id);
|
||||
if (!p) break;
|
||||
p.steer = Math.max(-1, Math.min(1, Number(msg.steer) || 0));
|
||||
break;
|
||||
}
|
||||
case 'host_start': {
|
||||
const id = sockToId.get(ws);
|
||||
const p = id && game.players.get(id);
|
||||
if (!p || !p.host) break;
|
||||
if (game.hostStart()) broadcast({ type: 'phase', phase: 'countdown', hostStart: true });
|
||||
break;
|
||||
}
|
||||
case 'ready_again': {
|
||||
const id = sockToId.get(ws);
|
||||
const p = id && game.players.get(id);
|
||||
if (!p) break;
|
||||
if (game.readyAgain()) broadcast({ type: 'phase', phase: 'lobby', reset: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => {});
|
||||
ws.on('close', () => {
|
||||
const id = sockToId.get(ws);
|
||||
sockToId.delete(ws);
|
||||
if (id) game.removePlayer(id);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------------- heartbeat + game-loop + broadcasts ---------------- */
|
||||
setInterval(() => {
|
||||
for (const client of wss.clients) {
|
||||
if (!client.isAlive) { client.terminate(); continue; }
|
||||
client.isAlive = false;
|
||||
client.ping();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
setInterval(() => game.update(25), 25);
|
||||
|
||||
setInterval(() => {
|
||||
if (wss.clients.size > 0) broadcast(game.snapshot());
|
||||
}, 62.5);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🏎️ Turbo Race draait op http://0.0.0.0:${PORT}`);
|
||||
console.log(` Max spelers: ${MAX_PLAYERS} | countdown: ${COUNTDOWN_S}s | race: max ${RACE_DURATION_S}s | baan: ${TRACK_LENGTH}m`);
|
||||
console.log(` Host-modus (open in browser): http://<host>/?host=${ADMIN_TOKEN} -> druk op S om te starten`);
|
||||
});
|
||||
Reference in New Issue
Block a user