laatste versie
This commit is contained in:
+6
-1
@@ -6,7 +6,12 @@ WORKDIR /app
|
|||||||
|
|
||||||
# eerst alleen package-files voor betere layer-caching
|
# eerst alleen package-files voor betere layer-caching
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev && \
|
||||||
|
# OS-patches ophalen (o.a. OpenSSL) en de npm-CLI + corepack verwijderen:
|
||||||
|
# die zijn niet nodig in runtime ("node server/index.js") en zouden anders
|
||||||
|
# via de gebundelde tooling (tar/pacote/glob/...) CVE's in de image brengen.
|
||||||
|
apk upgrade --no-cache && \
|
||||||
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /tmp/*
|
||||||
|
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ deploybaar op je eigen Kubernetes-node.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Verticale scrollende weg, portretmodus, **eenhandig besturen**
|
- Verticale scrollende weg, portretmodus, **eenhandig besturen**
|
||||||
(linkerhelft / rechterhelft van het scherm vasthouden).
|
(zet je vinger op de baan; de auto rijdt direct naar die plek en blijft staan).
|
||||||
- Keyboard-up / pijltjes op desktop.
|
- Keyboard-up / pijltjes op desktop.
|
||||||
- **Host** opent de kamer met `?host=<token>` en start heimelijk met de `S`-toets
|
- **Host** opent de kamer met `?host=<token>` en start heimelijk met de `S`-toets
|
||||||
(30-seconden countdown, nieuwe aanmelders blijven welkom).
|
(30-seconden countdown, nieuwe aanmelders blijven welkom).
|
||||||
|
De host **rijdt niet mee**, maar volgt als toeschouwer de **koploper (#1)**
|
||||||
|
en ziet live de top 5 in het koploperspaneel.
|
||||||
- Obstakels: **pylonen** (vertragen) en **olievlekken** (wegslijden).
|
- Obstakels: **pylonen** (vertragen) en **olievlekken** (wegslijden).
|
||||||
- Power-ups: **⚡ boost** en **🛡 schild** (respawnen na 4,5s).
|
- Power-ups: **⚡ boost** en **🛡 schild** (respawnen na 4,5s).
|
||||||
- Live positie, snelheid, afstand, leaderboard en minimap.
|
- Live positie, snelheid, afstand, leaderboard en minimap.
|
||||||
@@ -28,18 +30,20 @@ npm start # → http://localhost:8080
|
|||||||
Open `http://localhost:8080` op je laptop én op je telefoon (zelfde wifi).
|
Open `http://localhost:8080` op je laptop én op je telefoon (zelfde wifi).
|
||||||
Vul een naam in, klik aanmelden, en stuur met je vingers.
|
Vul een naam in, klik aanmelden, en stuur met je vingers.
|
||||||
|
|
||||||
> 🎮 **De host is ook gewoon een speler.** Open als host:
|
> 🎮 **De host is toeschouwer en rijdt niet zelf mee.** Open als host:
|
||||||
> `http://localhost:8080/?host=dev-host`
|
> `http://localhost:8080/?host=dev-host`
|
||||||
> en druk op de **S**-toets om de start-telling in gang te zetten
|
> en druk op de **S**-toets om de start-telling in gang te zetten
|
||||||
> (pas als de kamer vol is of iedereen klaar is 😉).
|
> (pas als de kamer vol is of iedereen klaar is 😉). Tijdens de race volgt de
|
||||||
|
> **camera de nummer 1** (ook bij wisselende koppositie) en zie je live de
|
||||||
|
> **top 5 koplopers**; na afloop de volledige uitslag.
|
||||||
|
|
||||||
### Besturing
|
### Besturing
|
||||||
|
|
||||||
| Apparaat | Actie |
|
| Apparaat | Actie |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Telefoon | Linkerhelft vasthouden = links, rechterhelft = rechts |
|
| Telefoon | Zet je vinger op de baan; de auto rijdt direct naar die plek en blijft staan tot je beweegt |
|
||||||
| Toetsenbord | `←` / `A` en `→` / `D` |
|
| Toetsenbord | `←` / `A` en `→` / `D` |
|
||||||
| Host | `S` in lobby of uitslag = race (laten) starten |
|
| Host | `S` in lobby of uitslag = race (laten) starten · camera volgt de koploper (#1), rijdt niet mee |
|
||||||
|
|
||||||
## Configuratie (omgevingsvariabelen)
|
## Configuratie (omgevingsvariabelen)
|
||||||
|
|
||||||
@@ -130,8 +134,8 @@ test/simulate.js headless test (fluweel: 20 clients ipv 1)
|
|||||||
- Engine loop 40 Hz; **snapshots op ~16 Hz**; clients **interpoleren** de rendering.
|
- Engine loop 40 Hz; **snapshots op ~16 Hz**; clients **interpoleren** de rendering.
|
||||||
- Baan is **deterministisch** opgebouwd uit één random seed → eerlijk & identiek
|
- Baan is **deterministisch** opgebouwd uit één random seed → eerlijk & identiek
|
||||||
voor elke speler.
|
voor elke speler.
|
||||||
- De host is een normale speler; de `host_start`-melding wordt alleen geaccepteerd
|
- De host is een **toeschouwer** (neemt geen spelerslot in); de `host_start`-melding
|
||||||
van sockets die met het juiste token zijn aangemeld.
|
wordt alleen geaccepteerd van sockets die met het juiste token zijn aangemeld.
|
||||||
|
|
||||||
## Flow & regels
|
## Flow & regels
|
||||||
|
|
||||||
|
|||||||
+130
-41
@@ -10,13 +10,15 @@
|
|||||||
|
|
||||||
/* ---------- state ---------- */
|
/* ---------- state ---------- */
|
||||||
let ws = null;
|
let ws = null;
|
||||||
const me = { id: null, host: false, name: '', color: '#ff3fa4' };
|
const me = { id: null, host: false, spectator: false, name: '', color: '#ff3fa4' };
|
||||||
let phase = 'lobby';
|
let phase = 'lobby';
|
||||||
let snap = null; // laatste snapshot
|
let snap = null; // laatste snapshot
|
||||||
let track = null; // {obstacles, powerups}
|
let track = null; // {obstacles, powerups}
|
||||||
const rp = {}; // render-state per speler {rx, rd, ...}
|
const rp = {}; // render-state per speler {rx, rd, ...}
|
||||||
const input = { steer: 0 };
|
const input = { steer: 0, x: null };
|
||||||
let visSteer = 0; // visuele stuursmoothing (alleen voor rendering)
|
let visSteer = 0; // visuele stuursmoothing (alleen voor rendering)
|
||||||
|
let followTilt = 0; // kanteling van de gevolgde koploper (alleen toeschouwer)
|
||||||
|
let followPrevX = null;
|
||||||
let fx = [];
|
let fx = [];
|
||||||
let shake = 0;
|
let shake = 0;
|
||||||
let lastSent = 0;
|
let lastSent = 0;
|
||||||
@@ -84,14 +86,17 @@
|
|||||||
|
|
||||||
$('joinBtn').onclick = () => connect();
|
$('joinBtn').onclick = () => connect();
|
||||||
$('nameInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') connect(); });
|
$('nameInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') connect(); });
|
||||||
|
// Host-URL (?host=<token>): meteen door als toeschouwer, geen aanmeldformulier.
|
||||||
|
if (hostToken()) connect();
|
||||||
|
|
||||||
/* ---------- message-handling ---------- */
|
/* ---------- message-handling ---------- */
|
||||||
function handle(m) {
|
function handle(m) {
|
||||||
switch (m.type) {
|
switch (m.type) {
|
||||||
case 'joined':
|
case 'joined':
|
||||||
me.id = m.id; me.host = !!m.host;
|
me.id = m.id; me.host = !!m.host; me.spectator = !!m.spectator;
|
||||||
me.name = m.self.name; me.color = m.self.color;
|
me.name = m.self.name; me.color = m.self.color;
|
||||||
snap = m.snapshot; track = m.track;
|
snap = m.snapshot; track = m.track;
|
||||||
|
document.body.classList.toggle('spectator', me.spectator);
|
||||||
applySnapshot(m.snapshot);
|
applySnapshot(m.snapshot);
|
||||||
$('join').classList.add('hidden');
|
$('join').classList.add('hidden');
|
||||||
$('room').classList.remove('hidden');
|
$('room').classList.remove('hidden');
|
||||||
@@ -130,6 +135,7 @@
|
|||||||
}
|
}
|
||||||
if (hard) { rp[p.id].rx = p.x; rp[p.id].rd = p.d; }
|
if (hard) { rp[p.id].rx = p.x; rp[p.id].rd = p.d; }
|
||||||
}
|
}
|
||||||
|
if (hard) { input.x = null; input.steer = 0; steerPointer = null; lastTiltX = null; }
|
||||||
for (const key in rp) if (!seen.has(key)) delete rp[key];
|
for (const key in rp) if (!seen.has(key)) delete rp[key];
|
||||||
prevPhase = newPhase;
|
prevPhase = newPhase;
|
||||||
phase = newPhase;
|
phase = newPhase;
|
||||||
@@ -160,7 +166,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
visSteer += (input.steer - visSteer) * Math.min(1, dt * 12);
|
visSteer += (tiltTarget() - visSteer) * Math.min(1, dt * 12);
|
||||||
|
|
||||||
|
// toeschouwer: kantel van de gevolgde koploper uit diens laterale beweging
|
||||||
|
if (me.spectator && snap && snap.players.length) {
|
||||||
|
const lead = snap.players.slice().sort((a, b) => a.rank - b.rank)[0];
|
||||||
|
const lr = rp[lead.id];
|
||||||
|
if (lr) {
|
||||||
|
const vx = lr.rx - (followPrevX == null ? lr.rx : followPrevX);
|
||||||
|
followPrevX = lr.rx;
|
||||||
|
followTilt += (clamp(vx * 1.4, -1, 1) - followTilt) * Math.min(1, dt * 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
draw(ts);
|
draw(ts);
|
||||||
advanceFx(dt);
|
advanceFx(dt);
|
||||||
shake = Math.max(0, shake - dt * 40);
|
shake = Math.max(0, shake - dt * 40);
|
||||||
@@ -174,7 +191,31 @@
|
|||||||
function draw(t) {
|
function draw(t) {
|
||||||
const meSnap = snap ? snap.players.find((p) => p.id === me.id) : null;
|
const meSnap = snap ? snap.players.find((p) => p.id === me.id) : null;
|
||||||
if (!meSnap) {
|
if (!meSnap) {
|
||||||
painter.render(ctx, { W, H, t, me: { d: 0 }, trackLength: snap ? snap.trackLength : 23000, track, cars: [], fx: [], speedFactor: 0, shake: 0 });
|
// Toeschouwer (host): camera volgt de koploper (#1) live.
|
||||||
|
const leader = snap && snap.players.length
|
||||||
|
? snap.players.slice().sort((a, b) => a.rank - b.rank)[0] : null;
|
||||||
|
const leadR = leader && rp[leader.id];
|
||||||
|
const camD = leader ? (leadR ? leadR.rd : leader.d) : 0;
|
||||||
|
const cars = [];
|
||||||
|
let leadSpeed = 0;
|
||||||
|
for (const key in rp) {
|
||||||
|
const o = rp[key];
|
||||||
|
const isFollow = !!leader && key === leader.id;
|
||||||
|
if (isFollow) leadSpeed = o.speed;
|
||||||
|
cars.push({
|
||||||
|
x: o.rx, d: o.rd, name: o.name, color: o.color,
|
||||||
|
boost: o.boost, shield: o.shield, slip: o.slip, finished: o.finished,
|
||||||
|
isMe: isFollow,
|
||||||
|
suffix: isFollow ? ' • koploper' : undefined,
|
||||||
|
steer: isFollow ? followTilt : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
painter.render(ctx, {
|
||||||
|
W, H, t, me: { d: camD },
|
||||||
|
trackLength: snap ? snap.trackLength : 23000, track, cars, fx,
|
||||||
|
speedFactor: leadSpeed / 380,
|
||||||
|
shake: 0,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const r = rp[me.id];
|
const r = rp[me.id];
|
||||||
@@ -192,7 +233,7 @@
|
|||||||
painter.render(ctx, {
|
painter.render(ctx, {
|
||||||
W, H, t, me: { d: selfD },
|
W, H, t, me: { d: selfD },
|
||||||
trackLength: snap.trackLength, track, cars, fx,
|
trackLength: snap.trackLength, track, cars, fx,
|
||||||
speedFactor: (meSnap.speed || 0) / 300,
|
speedFactor: (meSnap.speed || 0) / 380,
|
||||||
shake,
|
shake,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -215,10 +256,11 @@
|
|||||||
const rem = Math.max(0, Math.ceil((snap.countdownEndsAt - now) / 1000));
|
const rem = Math.max(0, Math.ceil((snap.countdownEndsAt - now) / 1000));
|
||||||
$('cdNum').textContent = rem;
|
$('cdNum').textContent = rem;
|
||||||
updateBoard(rem);
|
updateBoard(rem);
|
||||||
|
updateKoplopers();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === 'race' && meSnap) {
|
if (phase === 'race') {
|
||||||
const rem = Math.max(0, snap.raceEndsAt - now);
|
const rem = Math.max(0, snap.raceEndsAt - now);
|
||||||
const tot = Math.ceil(rem / 1000);
|
const tot = Math.ceil(rem / 1000);
|
||||||
const min = Math.floor(tot / 60), sec = tot % 60;
|
const min = Math.floor(tot / 60), sec = tot % 60;
|
||||||
@@ -226,12 +268,15 @@
|
|||||||
const txt = `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
const txt = `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
if (tEl.textContent !== txt) tEl.textContent = txt;
|
if (tEl.textContent !== txt) tEl.textContent = txt;
|
||||||
tEl.classList.toggle('danger', tot <= 10);
|
tEl.classList.toggle('danger', tot <= 10);
|
||||||
$('hudSpeed').textContent = Math.round((meSnap.speed || 0) * 0.36) + ' km/u';
|
if (meSnap) {
|
||||||
$('hudPos').textContent = '#' + meSnap.rank + ' / ' + snap.players.length;
|
$('hudSpeed').textContent = Math.round((meSnap.speed || 0) * 0.36) + ' km/u';
|
||||||
$('hudDist').textContent = meSnap.d + ' m';
|
$('hudPos').textContent = '#' + meSnap.rank + ' / ' + snap.players.length;
|
||||||
|
$('hudDist').textContent = meSnap.d + ' m';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBoard(0);
|
updateBoard(0);
|
||||||
|
updateKoplopers();
|
||||||
updateLead();
|
updateLead();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +313,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- host/toeschouwer: koplopers live ---------- */
|
||||||
|
let lastKopSig = '';
|
||||||
|
function updateKoplopers() {
|
||||||
|
const panel = $('spectate');
|
||||||
|
if (!me.spectator) { panel.classList.add('hidden'); return; }
|
||||||
|
const inRace = phase === 'race';
|
||||||
|
panel.classList.toggle('hidden', !inRace);
|
||||||
|
$('board').classList.toggle('hidden', inRace); // toeschouwer ziet het grote paneel i.p.v. de lijst
|
||||||
|
if (!inRace || !snap) return;
|
||||||
|
const sorted = snap.players.slice().sort((a, b) => a.rank - b.rank);
|
||||||
|
const cnt = sorted.length;
|
||||||
|
const top = sorted.slice(0, 5);
|
||||||
|
const remain = cnt - top.length;
|
||||||
|
const sig = cnt + '|' + top.map((p) => `${p.id}:${p.rank}:${p.d}:${p.finished}`).join(',');
|
||||||
|
if (sig === lastKopSig) return;
|
||||||
|
lastKopSig = sig;
|
||||||
|
$('kopCount').textContent = cnt === 1 ? '1 rijder op de baan' : cnt + ' rijders op de baan';
|
||||||
|
const ol = $('kopList');
|
||||||
|
ol.innerHTML = '';
|
||||||
|
top.forEach((p) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
if (p.rank === 1) li.className = 'top';
|
||||||
|
li.innerHTML = `<span class="rk">${p.rank}</span><span class="dot" style="background:${p.color};color:${p.color}"></span><span class="nm">${esc(p.name)}</span><span class="st">${p.finished ? '🏁 ' + fmtTime(p.finishTime) : Math.round(p.d) + ' m'}</span>`;
|
||||||
|
ol.appendChild(li);
|
||||||
|
});
|
||||||
|
if (remain > 0) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'rest';
|
||||||
|
li.textContent = '… en ' + remain + ' anderen';
|
||||||
|
ol.appendChild(li);
|
||||||
|
}
|
||||||
|
$('kopFollow').textContent = sorted.length ? '📷 camera volgt ' + sorted[0].name + ' · #1' : '';
|
||||||
|
}
|
||||||
|
|
||||||
let lastLead = '';
|
let lastLead = '';
|
||||||
function updateLead() {
|
function updateLead() {
|
||||||
if (!snap || phase !== 'race' || snap.players.length === 0) {
|
if (!snap || phase !== 'race' || snap.players.length === 0) {
|
||||||
@@ -319,37 +398,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ---------- in-/uitvoer ---------- */
|
/* ---------- in-/uitvoer: vinger volgen (geen vegen) ---------- */
|
||||||
const touchesLeft = new Map();
|
// De auto rijdt meteen naar de horizontale plek waar je vinger op de baan
|
||||||
const touchesRight = new Map();
|
// staat en blijft daar staan als je loslaat. Pijltjestoetsen sturen klassiek.
|
||||||
|
let steerPointer = null; // id van de actieve vinger
|
||||||
canvas.addEventListener('pointerdown', (e) => {
|
const roadXFromClient = (clientX) => {
|
||||||
canvas.setPointerCapture(e.pointerId);
|
const roadW = Math.min(W * 0.88, 460); // zelfde schaal als render.js
|
||||||
const side = e.clientX < W / 2 ? 'L' : 'R';
|
const roadLeft = (W - roadW) / 2;
|
||||||
(side === 'L' ? touchesLeft : touchesRight).set(e.pointerId, true);
|
return clamp((clientX - roadLeft) / roadW * ROAD_W, 30, ROAD_W - 30);
|
||||||
updateSteerFromTouches();
|
};
|
||||||
});
|
let lastTiltX = null; // vorige vingerpositie, voor de kanteltilt
|
||||||
canvas.addEventListener('pointermove', (e) => {
|
function tiltTarget() {
|
||||||
if (!touchesLeft.has(e.pointerId) && !touchesRight.has(e.pointerId)) return;
|
if (input.x != null) {
|
||||||
touchesLeft.delete(e.pointerId);
|
const tilt = lastTiltX == null ? 0 : clamp((input.x - lastTiltX) * 0.06, -1, 1);
|
||||||
touchesRight.delete(e.pointerId);
|
lastTiltX = input.x;
|
||||||
const side = e.clientX < W / 2 ? 'L' : 'R';
|
return tilt;
|
||||||
(side === 'L' ? touchesLeft : touchesRight).set(e.pointerId, true);
|
}
|
||||||
updateSteerFromTouches();
|
lastTiltX = null;
|
||||||
});
|
return input.steer;
|
||||||
const endTouch = (e) => { touchesLeft.delete(e.pointerId); touchesRight.delete(e.pointerId); updateSteerFromTouches(); };
|
|
||||||
canvas.addEventListener('pointerup', endTouch);
|
|
||||||
canvas.addEventListener('pointercancel', endTouch);
|
|
||||||
|
|
||||||
function updateSteerFromTouches() {
|
|
||||||
if (touchesRight.size > 0 && touchesLeft.size === 0) input.steer = 1;
|
|
||||||
else if (touchesLeft.size > 0 && touchesRight.size === 0) input.steer = -1;
|
|
||||||
else input.steer = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
if (steerPointer) return; // één vinger tegelijk
|
||||||
|
steerPointer = e.pointerId;
|
||||||
|
if (canvas.setPointerCapture) canvas.setPointerCapture(e.pointerId);
|
||||||
|
input.x = roadXFromClient(e.clientX);
|
||||||
|
input.steer = 0;
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointermove', (e) => {
|
||||||
|
if (steerPointer !== e.pointerId) return;
|
||||||
|
input.x = roadXFromClient(e.clientX);
|
||||||
|
});
|
||||||
|
const endFinger = (e) => {
|
||||||
|
if (steerPointer !== e.pointerId) return;
|
||||||
|
steerPointer = null; // laatste plek blijft vastgehouden (input.x)
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointerup', endFinger);
|
||||||
|
canvas.addEventListener('pointercancel', endFinger);
|
||||||
|
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') input.steer = -1;
|
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') { input.steer = -1; input.x = null; }
|
||||||
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') input.steer = 1;
|
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') { input.steer = 1; input.x = null; }
|
||||||
if (e.key.toLowerCase() === 's' && me.host && (phase === 'lobby' || phase === 'results')
|
if (e.key.toLowerCase() === 's' && me.host && (phase === 'lobby' || phase === 'results')
|
||||||
&& document.activeElement !== $('nameInput')) {
|
&& document.activeElement !== $('nameInput')) {
|
||||||
sendHostStart();
|
sendHostStart();
|
||||||
@@ -358,10 +447,10 @@
|
|||||||
window.addEventListener('keyup', (e) => {
|
window.addEventListener('keyup', (e) => {
|
||||||
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A' ||
|
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A' ||
|
||||||
e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
|
e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
|
||||||
if (touchesLeft.size === 0 && touchesRight.size === 0) input.steer = 0;
|
if (!steerPointer) { input.steer = 0; input.x = null; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.addEventListener('blur', () => { input.steer = 0; });
|
window.addEventListener('blur', () => { input.steer = 0; input.x = null; steerPointer = null; });
|
||||||
|
|
||||||
function sendHostStart() {
|
function sendHostStart() {
|
||||||
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'host_start' }));
|
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'host_start' }));
|
||||||
@@ -370,7 +459,7 @@
|
|||||||
// input naar server
|
// input naar server
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (ws && ws.readyState === 1 && me.id) {
|
if (ws && ws.readyState === 1 && me.id) {
|
||||||
ws.send(JSON.stringify({ type: 'input', steer: input.steer }));
|
ws.send(JSON.stringify({ type: 'input', steer: input.steer, x: input.x }));
|
||||||
}
|
}
|
||||||
}, 33);
|
}, 33);
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -20,7 +20,7 @@
|
|||||||
<div id="colors" class="colors"></div>
|
<div id="colors" class="colors"></div>
|
||||||
<button id="joinBtn" class="btn">Aanmelden ▶</button>
|
<button id="joinBtn" class="btn">Aanmelden ▶</button>
|
||||||
<p id="joinMsg" class="msg"></p>
|
<p id="joinMsg" class="msg"></p>
|
||||||
<p class="hint">🕹️ Hou links of rechts van het scherm vast om te sturen, ontwijk pylonen & olie, pak power-ups en word de snelste!</p>
|
<p class="hint">🕹️ Zet je vinger op de baan — de auto rijdt meteen naar die plek en blijft er staan. Ontwijk pylonen & olie, pak power-ups en word de snelste!</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -43,10 +43,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="minimapWrap"><canvas id="minimap"></canvas></div>
|
<div id="minimapWrap"><canvas id="minimap"></canvas></div>
|
||||||
<div id="toasts"></div>
|
<div id="toasts"></div>
|
||||||
|
<div id="spectate" class="hidden">
|
||||||
|
<div class="spectate-head">🏆 KOPLOPERS</div>
|
||||||
|
<div id="kopCount" class="spectate-count"></div>
|
||||||
|
<ol id="kopList"></ol>
|
||||||
|
<div id="kopFollow" class="spectate-follow"></div>
|
||||||
|
</div>
|
||||||
<div id="controls">
|
<div id="controls">
|
||||||
<div class="ctrl"><span class="arrow">◀</span> stuur</div>
|
<div class="ctrl"><span class="arrow">◀</span> volgen</div>
|
||||||
<div class="tipzoom" id="tipZoom">🌐</div>
|
<div class="tipzoom" id="tipZoom">🌐</div>
|
||||||
<div class="ctrl">stuur <span class="arrow">▶</span></div>
|
<div class="ctrl">volgen <span class="arrow">▶</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -226,7 +226,7 @@ window.TurboPainter = (function () {
|
|||||||
ctx.fillStyle = p.isMe ? '#fff' : rgba('#eaf4ff', 0.92);
|
ctx.fillStyle = p.isMe ? '#fff' : rgba('#eaf4ff', 0.92);
|
||||||
ctx.shadowColor = '#000';
|
ctx.shadowColor = '#000';
|
||||||
ctx.shadowBlur = 4;
|
ctx.shadowBlur = 4;
|
||||||
ctx.fillText(p.name + (p.isMe ? ' • jij' : ''), 0, -h * 0.62);
|
ctx.fillText(p.name + (p.suffix || (p.isMe ? ' • jij' : '')), 0, -h * 0.62);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,48 @@ input::placeholder { color: #667; }
|
|||||||
}
|
}
|
||||||
#minimap { width: 100%; height: 100%; display: block; }
|
#minimap { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* ---- Host / toeschouwer: koploperspaneel ---- */
|
||||||
|
body.spectator #hudSpeed,
|
||||||
|
body.spectator #hudPos,
|
||||||
|
body.spectator #hudDist,
|
||||||
|
body.spectator #minimapWrap,
|
||||||
|
body.spectator #controls { display: none; }
|
||||||
|
|
||||||
|
#spectate {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(86px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%);
|
||||||
|
width: min(300px, 86vw);
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--gold);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, .45), 0 0 30px rgba(255, 217, 61, .18);
|
||||||
|
}
|
||||||
|
.spectate-head {
|
||||||
|
font-size: 14px; font-weight: 800; letter-spacing: 3px;
|
||||||
|
color: var(--gold); text-shadow: 0 0 14px rgba(255, 217, 61, .8);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.spectate-count { font-size: 11px; letter-spacing: 1.5px; color: var(--neon); margin-bottom: 8px; text-transform: uppercase; }
|
||||||
|
.spectate-follow {
|
||||||
|
margin-top: 8px; font-size: 11.5px; letter-spacing: .4px;
|
||||||
|
color: var(--neon); text-shadow: 0 0 8px rgba(0, 240, 255, .5);
|
||||||
|
}
|
||||||
|
#kopList { list-style: none; }
|
||||||
|
#kopList li {
|
||||||
|
display: flex; gap: 8px; align-items: center;
|
||||||
|
font-size: 14px; padding: 5px 6px; border-radius: 8px; color: #e6eeff;
|
||||||
|
}
|
||||||
|
#kopList li.top { background: rgba(255, 217, 61, .16); box-shadow: inset 0 0 0 1px rgba(255, 217, 61, .55); }
|
||||||
|
#kopList li.rest { color: #8fa0d0; font-size: 12px; justify-content: center; padding-top: 4px; }
|
||||||
|
#kopList .rk { width: 20px; text-align: right; color: #8fa0d0; font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||||
|
#kopList li.top .rk { color: var(--gold); }
|
||||||
|
#kopList .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; box-shadow: 0 0 8px currentColor; }
|
||||||
|
#kopList .nm { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#kopList .st { font-size: 11.5px; color: #9fb0dd; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
|
||||||
/* Toasts */
|
/* Toasts */
|
||||||
#toasts { position: absolute; top: calc(170px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; width: 86%; }
|
#toasts { position: absolute; top: calc(170px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; width: 86%; }
|
||||||
.toast {
|
.toast {
|
||||||
|
|||||||
+26
-18
@@ -11,9 +11,11 @@ const ROAD_W = 420; // wegbreedte in wereld-eenheden (= px)
|
|||||||
const CAR_W = 24; // breedte auto
|
const CAR_W = 24; // breedte auto
|
||||||
const CAR_HALF_X = CAR_W / 2;
|
const CAR_HALF_X = CAR_W / 2;
|
||||||
const CAR_HALF_Y = 13; // halve verticale hitbox
|
const CAR_HALF_Y = 13; // halve verticale hitbox
|
||||||
const CRUISE = 300; // kruissnelheid (u/s)
|
const CRUISE = 380; // kruissnelheid (u/s)
|
||||||
const BOOST_BONUS = 175; // extra snelheid tijdens boost
|
const BOOST_BONUS = 220; // extra snelheid tijdens boost
|
||||||
const LATERAL = 165; // laterale stuursnelheid (u/s)
|
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 STEER_RESPONSE = 7; // hoe snel stuuruitslag inslingert (hoger = directer, lager = vloeiender)
|
||||||
const OBJ_VH = 12; // verticale halve hoogte van obstakels
|
const OBJ_VH = 12; // verticale halve hoogte van obstakels
|
||||||
const PICKUP_VH = 14; // verticale halve hoogte van power-ups
|
const PICKUP_VH = 14; // verticale halve hoogte van power-ups
|
||||||
@@ -79,7 +81,7 @@ class Game extends EventEmitter {
|
|||||||
finishTime: 0,
|
finishTime: 0,
|
||||||
rank: 0,
|
rank: 0,
|
||||||
boostUntil: 0, shieldUntil: 0, slipUntil: 0, slowUntil: 0,
|
boostUntil: 0, shieldUntil: 0, slipUntil: 0, slowUntil: 0,
|
||||||
steer: 0, steerSm: 0,
|
steer: 0, steerSm: 0, followX: null,
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
};
|
};
|
||||||
this.players.set(player.id, player);
|
this.players.set(player.id, player);
|
||||||
@@ -115,7 +117,7 @@ class Game extends EventEmitter {
|
|||||||
p.x = clamp(40 + Math.random() * (ROAD_W - 80), CAR_HALF_X, ROAD_W - CAR_HALF_X);
|
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.d = 0; p.speed = 0; p.finished = false; p.finishTime = 0;
|
||||||
p.boostUntil = p.shieldUntil = p.slipUntil = p.slowUntil = 0;
|
p.boostUntil = p.shieldUntil = p.slipUntil = p.slowUntil = 0;
|
||||||
p.steerSm = 0;
|
p.steerSm = 0; p.followX = null;
|
||||||
}
|
}
|
||||||
this.track = null;
|
this.track = null;
|
||||||
}
|
}
|
||||||
@@ -154,7 +156,7 @@ class Game extends EventEmitter {
|
|||||||
p.d = 0; p.speed = 0;
|
p.d = 0; p.speed = 0;
|
||||||
p.finished = false; p.finishTime = 0;
|
p.finished = false; p.finishTime = 0;
|
||||||
p.boostUntil = p.shieldUntil = p.slipUntil = p.slowUntil = 0;
|
p.boostUntil = p.shieldUntil = p.slipUntil = p.slowUntil = 0;
|
||||||
p.steerSm = 0;
|
p.steerSm = 0; p.followX = null;
|
||||||
});
|
});
|
||||||
this.emit('race_start', this.track);
|
this.emit('race_start', this.track);
|
||||||
}
|
}
|
||||||
@@ -169,9 +171,10 @@ class Game extends EventEmitter {
|
|||||||
let d = 700;
|
let d = 700;
|
||||||
let puD = 1500;
|
let puD = 1500;
|
||||||
while (d < this.trackLength - 900) {
|
while (d < this.trackLength - 900) {
|
||||||
const step = 250 + rng() * 190;
|
const step = 330 + rng() * 220;
|
||||||
// aantal geblokkeerde banen: nooit alle 3, zodat er altijd een doorgang is
|
// aantal geblokkeerde banen: nooit alle 3, zodat er altijd een doorgang is;
|
||||||
const blockCount = rng() < 0.18 ? 1 : 2;
|
// 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);
|
const idx = [0, 1, 2].sort(() => rng() - 0.5).slice(0, blockCount).sort((a, b) => a - b);
|
||||||
for (const li of idx) {
|
for (const li of idx) {
|
||||||
const type = rng() < 0.72 ? 'pylon' : 'oil';
|
const type = rng() < 0.72 ? 'pylon' : 'oil';
|
||||||
@@ -215,15 +218,20 @@ class Game extends EventEmitter {
|
|||||||
if (p.slowUntil > now) target = Math.min(target, 70);
|
if (p.slowUntil > now) target = Math.min(target, 70);
|
||||||
p.speed += (target - p.speed) * Math.min(1, dt * 2.2);
|
p.speed += (target - p.speed) * Math.min(1, dt * 2.2);
|
||||||
|
|
||||||
// Sturen: geleidelijk inslingeren (smoother) i.p.v. direct volle uitslag.
|
// Sturen: vinger-volgmodus (invoer 'x') = snel glijden naar de positie
|
||||||
// p.steer is het ruwe doel van de client, p.steerSm de opgerampte waarde.
|
// onder je vinger; toetsenbordmodus (invoer 'steer') = geleidelijk sturen.
|
||||||
p.steerSm += (p.steer - p.steerSm) * Math.min(1, dt * STEER_RESPONSE);
|
if (p.followX != null) {
|
||||||
|
let move = clamp((p.followX - p.x) * Math.min(1, dt * FOLLOW_RESPONSE),
|
||||||
// Sturen (olie = wegglijden)
|
-FOLLOW_MAX * dt, FOLLOW_MAX * dt);
|
||||||
let mv = p.steerSm * LATERAL * dt;
|
if (p.slipUntil > now) move *= 0.35;
|
||||||
if (p.slipUntil > now) mv = p.steerSm * LATERAL * 0.3 * dt + (Math.random() - 0.5) * 260 * dt;
|
p.x = clamp(p.x + move, CAR_HALF_X, ROAD_W - CAR_HALF_X);
|
||||||
p.x = clamp(p.x + mv + (p.slipUntil > now ? (Math.random() - 0.5) * 90 * dt : 0),
|
} else {
|
||||||
CAR_HALF_X, ROAD_W - CAR_HALF_X);
|
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;
|
p.d += p.speed * dt;
|
||||||
if (p.d >= this.trackLength && !p.finished) {
|
if (p.d >= this.trackLength && !p.finished) {
|
||||||
|
|||||||
+30
-5
@@ -3,6 +3,7 @@
|
|||||||
* Turbo Race - HTTP + WebSocket server
|
* Turbo Race - HTTP + WebSocket server
|
||||||
* Serveert de statische frontend en draait de spelengine.
|
* Serveert de statische frontend en draait de spelengine.
|
||||||
* Host-eindpunt: verbind met ?host=<ADMIN_TOKEN> en druk op "s".
|
* Host-eindpunt: verbind met ?host=<ADMIN_TOKEN> en druk op "s".
|
||||||
|
* De host is toeschouwer: rijdt NIET zelf mee, maar volgt de koplopers live.
|
||||||
*/
|
*/
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
@@ -70,6 +71,20 @@ wss.on('connection', (ws) => {
|
|||||||
try { msg = JSON.parse(buf.toString()); } catch { return; }
|
try { msg = JSON.parse(buf.toString()); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'join': {
|
case 'join': {
|
||||||
|
const isHost = !!(msg.hostToken && ADMIN_TOKEN && String(msg.hostToken) === String(ADMIN_TOKEN));
|
||||||
|
if (isHost) {
|
||||||
|
// Host = toeschouwer: neemt geen spelerslot in, toont de koplopers.
|
||||||
|
ws.isHost = true;
|
||||||
|
send(ws, {
|
||||||
|
type: 'joined',
|
||||||
|
host: true,
|
||||||
|
spectator: true,
|
||||||
|
self: { name: 'Host', color: '#ffd93d' },
|
||||||
|
snapshot: game.snapshot(),
|
||||||
|
track: game.track,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
let player;
|
let player;
|
||||||
try {
|
try {
|
||||||
player = game.addPlayer(msg.name, msg.color, msg.hostToken, ADMIN_TOKEN);
|
player = game.addPlayer(msg.name, msg.color, msg.hostToken, ADMIN_TOKEN);
|
||||||
@@ -81,6 +96,7 @@ wss.on('connection', (ws) => {
|
|||||||
type: 'joined',
|
type: 'joined',
|
||||||
id: player.id,
|
id: player.id,
|
||||||
host: player.host,
|
host: player.host,
|
||||||
|
spectator: false,
|
||||||
self: { name: player.name, color: player.color },
|
self: { name: player.name, color: player.color },
|
||||||
snapshot: game.snapshot(),
|
snapshot: game.snapshot(),
|
||||||
track: game.track,
|
track: game.track,
|
||||||
@@ -92,20 +108,29 @@ wss.on('connection', (ws) => {
|
|||||||
if (!id) break;
|
if (!id) break;
|
||||||
const p = game.players.get(id);
|
const p = game.players.get(id);
|
||||||
if (!p) break;
|
if (!p) break;
|
||||||
p.steer = Math.max(-1, Math.min(1, Number(msg.steer) || 0));
|
if (typeof msg.x === 'number') {
|
||||||
|
p.followX = Math.max(12, Math.min(408, Number(msg.x))); // vinger volgen
|
||||||
|
p.steer = 0;
|
||||||
|
} else {
|
||||||
|
p.steer = Math.max(-1, Math.min(1, Number(msg.steer) || 0));
|
||||||
|
p.followX = null; // toetsenbord
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'host_start': {
|
case 'host_start': {
|
||||||
const id = sockToId.get(ws);
|
const allowed = ws.isHost || (() => {
|
||||||
const p = id && game.players.get(id);
|
const id = sockToId.get(ws);
|
||||||
if (!p || !p.host) break;
|
const p = id && game.players.get(id);
|
||||||
|
return !!(p && p.host);
|
||||||
|
})();
|
||||||
|
if (!allowed) break;
|
||||||
if (game.hostStart()) broadcast({ type: 'phase', phase: 'countdown', hostStart: true });
|
if (game.hostStart()) broadcast({ type: 'phase', phase: 'countdown', hostStart: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ready_again': {
|
case 'ready_again': {
|
||||||
const id = sockToId.get(ws);
|
const id = sockToId.get(ws);
|
||||||
const p = id && game.players.get(id);
|
const p = id && game.players.get(id);
|
||||||
if (!p) break;
|
if (!ws.isHost && !p) break;
|
||||||
if (game.readyAgain()) broadcast({ type: 'phase', phase: 'lobby', reset: true });
|
if (game.readyAgain()) broadcast({ type: 'phase', phase: 'lobby', reset: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-8
@@ -2,7 +2,7 @@
|
|||||||
/*
|
/*
|
||||||
* Turbo Race - headless test/simulatie
|
* Turbo Race - headless test/simulatie
|
||||||
* 1) engine-unit-test (fases, vol, host-token, botsingen, power-ups, ranking)
|
* 1) engine-unit-test (fases, vol, host-token, botsingen, power-ups, ranking)
|
||||||
* 2) integratietest: echte server + 20 websocket-clients + host die de race start
|
* 2) integratietest: echte server + 19 spelers + host (toeschouwer) die de race start
|
||||||
* Run: node test/simulate.js
|
* Run: node test/simulate.js
|
||||||
*/
|
*/
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
@@ -191,16 +191,20 @@ async function integration() {
|
|||||||
const hostC = mkClient();
|
const hostC = mkClient();
|
||||||
await new Promise((res, rej) => { hostC.w.once('open', res); hostC.w.once('error', rej); });
|
await new Promise((res, rej) => { hostC.w.once('open', res); hostC.w.once('error', rej); });
|
||||||
hostC.send({ type: 'join', name: 'Boss', color: '#ff3fa4', hostToken: ADMIN });
|
hostC.send({ type: 'join', name: 'Boss', color: '#ff3fa4', hostToken: ADMIN });
|
||||||
await hostC.next('joined');
|
const hostJoined = await hostC.next('joined');
|
||||||
test('host is de 20e speler', () => assert.strictEqual(clients.length + 1, 20));
|
test('host wordt toeschouwer (telt niet als speler, geen eigen auto)', () => {
|
||||||
|
assert.strictEqual(hostJoined.host, true);
|
||||||
|
assert.strictEqual(hostJoined.spectator, true);
|
||||||
|
assert.strictEqual(hostJoined.id, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
const httpHealth = await new Promise((res) => {
|
const httpHealth = await new Promise((res) => {
|
||||||
http.get(`http://127.0.0.1:${PORT}/healthz`, (r) => {
|
http.get(`http://127.0.0.1:${PORT}/healthz`, (r) => {
|
||||||
let b = ''; r.on('data', (d) => b += d); r.on('end', () => res(b));
|
let b = ''; r.on('data', (d) => b += d); r.on('end', () => res(b));
|
||||||
}).on('error', () => res(''));
|
}).on('error', () => res(''));
|
||||||
});
|
});
|
||||||
test('healthz rapporteert 20 spelers', () => {
|
test('healthz rapporteert 19 spelers (host telt niet mee)', () => {
|
||||||
assert.ok(httpHealth.includes('"players":20'), 'healthz: ' + httpHealth);
|
assert.ok(httpHealth.includes('"players":19'), 'healthz: ' + httpHealth);
|
||||||
});
|
});
|
||||||
|
|
||||||
// niet-host mag niet kunnen starten (inline, synchrone volgorde)
|
// niet-host mag niet kunnen starten (inline, synchrone volgorde)
|
||||||
@@ -243,10 +247,10 @@ async function integration() {
|
|||||||
const resultsSeen = await watchSnapshot(hostC, 'results', 26000).catch(() => null);
|
const resultsSeen = await watchSnapshot(hostC, 'results', 26000).catch(() => null);
|
||||||
test('race loopt door naar results-fase', () => assert.ok(resultsSeen, 'geen results-fase gezien'));
|
test('race loopt door naar results-fase', () => assert.ok(resultsSeen, 'geen results-fase gezien'));
|
||||||
if (resultsSeen) {
|
if (resultsSeen) {
|
||||||
test('uitslag: 20 gerangschikte spelers', () => {
|
test('uitslag: 19 gerangschikte spelers', () => {
|
||||||
assert.strictEqual(resultsSeen.players.length, 20);
|
assert.strictEqual(resultsSeen.players.length, 19);
|
||||||
const ranks = resultsSeen.players.map((p) => p.rank).sort((a, b) => a - b);
|
const ranks = resultsSeen.players.map((p) => p.rank).sort((a, b) => a - b);
|
||||||
assert.deepStrictEqual(ranks, [...Array(20).keys()].map((i) => i + 1));
|
assert.deepStrictEqual(ranks, [...Array(19).keys()].map((i) => i + 1));
|
||||||
});
|
});
|
||||||
test('er is een duidelijke koploper/winnaar', () => {
|
test('er is een duidelijke koploper/winnaar', () => {
|
||||||
const first = resultsSeen.players.find((p) => p.rank === 1);
|
const first = resultsSeen.players.find((p) => p.rank === 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user