Files
game2/server/engine.js
T
martijn.diersmann@pm.me 4bdac1e6e8 laatste versie
2026-09-26 23:25:04 +02:00

322 lines
11 KiB
JavaScript

'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 = 380; // kruissnelheid (u/s)
const BOOST_BONUS = 220; // extra snelheid tijdens boost
const LATERAL = 195; // laterale stuursnelheid bij toetsenbordstuur (u/s)
const FOLLOW_RESPONSE = 12; // hoe snel de auto naar de vingerpositie glijdt
const FOLLOW_MAX = 460; // maximale laterale volgsnelheid (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, followX: null,
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; p.followX = null;
}
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; p.followX = null;
});
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 = 330 + rng() * 220;
// aantal geblokkeerde banen: nooit alle 3, zodat er altijd een doorgang is;
// vaker maar 1 baan geblokkeerd = minder drukke baan
const blockCount = rng() < 0.45 ? 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: vinger-volgmodus (invoer 'x') = snel glijden naar de positie
// onder je vinger; toetsenbordmodus (invoer 'steer') = geleidelijk sturen.
if (p.followX != null) {
let move = clamp((p.followX - p.x) * Math.min(1, dt * FOLLOW_RESPONSE),
-FOLLOW_MAX * dt, FOLLOW_MAX * dt);
if (p.slipUntil > now) move *= 0.35;
p.x = clamp(p.x + move, CAR_HALF_X, ROAD_W - CAR_HALF_X);
} else {
p.steerSm += (p.steer - p.steerSm) * Math.min(1, dt * STEER_RESPONSE);
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;