314 lines
10 KiB
JavaScript
314 lines
10 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 = 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;
|
|
|