'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;