diff --git a/Dockerfile b/Dockerfile index 5f7bba8..a5b5815 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,12 @@ WORKDIR /app # eerst alleen package-files voor betere layer-caching 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 public ./public diff --git a/README.md b/README.md index 2994b24..d2f5b10 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,12 @@ deploybaar op je eigen Kubernetes-node. ## Features - 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. - **Host** opent de kamer met `?host=` en start heimelijk met de `S`-toets (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). - Power-ups: **โšก boost** en **๐Ÿ›ก schild** (respawnen na 4,5s). - 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). 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` > 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 | 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` | -| 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) @@ -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. - Baan is **deterministisch** opgebouwd uit รฉรฉn random seed โ†’ eerlijk & identiek voor elke speler. -- De host is een normale speler; de `host_start`-melding wordt alleen geaccepteerd - van sockets die met het juiste token zijn aangemeld. +- De host is een **toeschouwer** (neemt geen spelerslot in); de `host_start`-melding + wordt alleen geaccepteerd van sockets die met het juiste token zijn aangemeld. ## Flow & regels diff --git a/public/client.js b/public/client.js index 41402fc..27c52c0 100644 --- a/public/client.js +++ b/public/client.js @@ -10,13 +10,15 @@ /* ---------- state ---------- */ 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 snap = null; // laatste snapshot let track = null; // {obstacles, powerups} 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 followTilt = 0; // kanteling van de gevolgde koploper (alleen toeschouwer) + let followPrevX = null; let fx = []; let shake = 0; let lastSent = 0; @@ -84,14 +86,17 @@ $('joinBtn').onclick = () => connect(); $('nameInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') connect(); }); + // Host-URL (?host=): meteen door als toeschouwer, geen aanmeldformulier. + if (hostToken()) connect(); /* ---------- message-handling ---------- */ function handle(m) { switch (m.type) { 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; snap = m.snapshot; track = m.track; + document.body.classList.toggle('spectator', me.spectator); applySnapshot(m.snapshot); $('join').classList.add('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) { input.x = null; input.steer = 0; steerPointer = null; lastTiltX = null; } for (const key in rp) if (!seen.has(key)) delete rp[key]; prevPhase = 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); advanceFx(dt); shake = Math.max(0, shake - dt * 40); @@ -174,7 +191,31 @@ function draw(t) { const meSnap = snap ? snap.players.find((p) => p.id === me.id) : null; 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; } const r = rp[me.id]; @@ -192,7 +233,7 @@ painter.render(ctx, { W, H, t, me: { d: selfD }, trackLength: snap.trackLength, track, cars, fx, - speedFactor: (meSnap.speed || 0) / 300, + speedFactor: (meSnap.speed || 0) / 380, shake, }); } @@ -215,10 +256,11 @@ const rem = Math.max(0, Math.ceil((snap.countdownEndsAt - now) / 1000)); $('cdNum').textContent = rem; updateBoard(rem); + updateKoplopers(); return; } - if (phase === 'race' && meSnap) { + if (phase === 'race') { const rem = Math.max(0, snap.raceEndsAt - now); const tot = Math.ceil(rem / 1000); 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')}`; if (tEl.textContent !== txt) tEl.textContent = txt; tEl.classList.toggle('danger', tot <= 10); - $('hudSpeed').textContent = Math.round((meSnap.speed || 0) * 0.36) + ' km/u'; - $('hudPos').textContent = '#' + meSnap.rank + ' / ' + snap.players.length; - $('hudDist').textContent = meSnap.d + ' m'; + if (meSnap) { + $('hudSpeed').textContent = Math.round((meSnap.speed || 0) * 0.36) + ' km/u'; + $('hudPos').textContent = '#' + meSnap.rank + ' / ' + snap.players.length; + $('hudDist').textContent = meSnap.d + ' m'; + } } updateBoard(0); + updateKoplopers(); 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 = `${p.rank}${esc(p.name)}${p.finished ? '๐Ÿ ' + fmtTime(p.finishTime) : Math.round(p.d) + ' m'}`; + 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 = ''; function updateLead() { if (!snap || phase !== 'race' || snap.players.length === 0) { @@ -319,37 +398,47 @@ } - /* ---------- in-/uitvoer ---------- */ - const touchesLeft = new Map(); - const touchesRight = new Map(); - - canvas.addEventListener('pointerdown', (e) => { - canvas.setPointerCapture(e.pointerId); - const side = e.clientX < W / 2 ? 'L' : 'R'; - (side === 'L' ? touchesLeft : touchesRight).set(e.pointerId, true); - updateSteerFromTouches(); - }); - canvas.addEventListener('pointermove', (e) => { - if (!touchesLeft.has(e.pointerId) && !touchesRight.has(e.pointerId)) return; - touchesLeft.delete(e.pointerId); - touchesRight.delete(e.pointerId); - const side = e.clientX < W / 2 ? 'L' : 'R'; - (side === 'L' ? touchesLeft : touchesRight).set(e.pointerId, true); - updateSteerFromTouches(); - }); - 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; + /* ---------- in-/uitvoer: vinger volgen (geen vegen) ---------- */ + // De auto rijdt meteen naar de horizontale plek waar je vinger op de baan + // staat en blijft daar staan als je loslaat. Pijltjestoetsen sturen klassiek. + let steerPointer = null; // id van de actieve vinger + const roadXFromClient = (clientX) => { + const roadW = Math.min(W * 0.88, 460); // zelfde schaal als render.js + const roadLeft = (W - roadW) / 2; + return clamp((clientX - roadLeft) / roadW * ROAD_W, 30, ROAD_W - 30); + }; + let lastTiltX = null; // vorige vingerpositie, voor de kanteltilt + function tiltTarget() { + if (input.x != null) { + const tilt = lastTiltX == null ? 0 : clamp((input.x - lastTiltX) * 0.06, -1, 1); + lastTiltX = input.x; + return tilt; + } + lastTiltX = null; + return input.steer; } + 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) => { - if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') input.steer = -1; - if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') 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; input.x = null; } if (e.key.toLowerCase() === 's' && me.host && (phase === 'lobby' || phase === 'results') && document.activeElement !== $('nameInput')) { sendHostStart(); @@ -358,10 +447,10 @@ window.addEventListener('keyup', (e) => { if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A' || 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() { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'host_start' })); @@ -370,7 +459,7 @@ // input naar server setInterval(() => { 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); diff --git a/public/index.html b/public/index.html index 4590fd0..32338eb 100644 --- a/public/index.html +++ b/public/index.html @@ -20,7 +20,7 @@

-

๐Ÿ•น๏ธ Hou links of rechts van het scherm vast om te sturen, ontwijk pylonen & olie, pak power-ups en word de snelste!

+

๐Ÿ•น๏ธ 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!

@@ -43,10 +43,16 @@
+
-
โ—€ stuur
+
โ—€ volgen
๐ŸŒ
-
stuur โ–ถ
+
volgen โ–ถ
diff --git a/public/render.js b/public/render.js index 697b25b..bd5d293 100644 --- a/public/render.js +++ b/public/render.js @@ -226,7 +226,7 @@ window.TurboPainter = (function () { ctx.fillStyle = p.isMe ? '#fff' : rgba('#eaf4ff', 0.92); ctx.shadowColor = '#000'; 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(); } diff --git a/public/style.css b/public/style.css index ecd9ff0..95e2035 100644 --- a/public/style.css +++ b/public/style.css @@ -195,6 +195,48 @@ input::placeholder { color: #667; } } #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 { 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 { diff --git a/server/engine.js b/server/engine.js index aae906f..03f107e 100644 --- a/server/engine.js +++ b/server/engine.js @@ -11,9 +11,11 @@ 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 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 @@ -79,7 +81,7 @@ class Game extends EventEmitter { finishTime: 0, rank: 0, boostUntil: 0, shieldUntil: 0, slipUntil: 0, slowUntil: 0, - steer: 0, steerSm: 0, + steer: 0, steerSm: 0, followX: null, joinedAt: Date.now(), }; 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.d = 0; p.speed = 0; p.finished = false; p.finishTime = 0; p.boostUntil = p.shieldUntil = p.slipUntil = p.slowUntil = 0; - p.steerSm = 0; + p.steerSm = 0; p.followX = null; } this.track = null; } @@ -154,7 +156,7 @@ class Game extends EventEmitter { 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.steerSm = 0; p.followX = null; }); this.emit('race_start', this.track); } @@ -169,9 +171,10 @@ class Game extends EventEmitter { 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 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'; @@ -215,15 +218,20 @@ class Game extends EventEmitter { 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); + // 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) { diff --git a/server/index.js b/server/index.js index a7c7311..7455f91 100644 --- a/server/index.js +++ b/server/index.js @@ -3,6 +3,7 @@ * Turbo Race - HTTP + WebSocket server * Serveert de statische frontend en draait de spelengine. * Host-eindpunt: verbind met ?host= en druk op "s". + * De host is toeschouwer: rijdt NIET zelf mee, maar volgt de koplopers live. */ const path = require('path'); const http = require('http'); @@ -70,6 +71,20 @@ wss.on('connection', (ws) => { try { msg = JSON.parse(buf.toString()); } catch { return; } switch (msg.type) { 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; try { player = game.addPlayer(msg.name, msg.color, msg.hostToken, ADMIN_TOKEN); @@ -81,6 +96,7 @@ wss.on('connection', (ws) => { type: 'joined', id: player.id, host: player.host, + spectator: false, self: { name: player.name, color: player.color }, snapshot: game.snapshot(), track: game.track, @@ -92,20 +108,29 @@ wss.on('connection', (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)); + 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; } case 'host_start': { - const id = sockToId.get(ws); - const p = id && game.players.get(id); - if (!p || !p.host) break; + const allowed = ws.isHost || (() => { + const id = sockToId.get(ws); + const p = id && game.players.get(id); + return !!(p && p.host); + })(); + if (!allowed) 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 (!ws.isHost && !p) break; if (game.readyAgain()) broadcast({ type: 'phase', phase: 'lobby', reset: true }); break; } diff --git a/test/simulate.js b/test/simulate.js index debf077..ed37074 100644 --- a/test/simulate.js +++ b/test/simulate.js @@ -2,7 +2,7 @@ /* * Turbo Race - headless test/simulatie * 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 */ const assert = require('assert'); @@ -191,16 +191,20 @@ async function integration() { const hostC = mkClient(); 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 }); - await hostC.next('joined'); - test('host is de 20e speler', () => assert.strictEqual(clients.length + 1, 20)); + const hostJoined = await hostC.next('joined'); + 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) => { http.get(`http://127.0.0.1:${PORT}/healthz`, (r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => res(b)); }).on('error', () => res('')); }); - test('healthz rapporteert 20 spelers', () => { - assert.ok(httpHealth.includes('"players":20'), 'healthz: ' + httpHealth); + test('healthz rapporteert 19 spelers (host telt niet mee)', () => { + assert.ok(httpHealth.includes('"players":19'), 'healthz: ' + httpHealth); }); // 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); test('race loopt door naar results-fase', () => assert.ok(resultsSeen, 'geen results-fase gezien')); if (resultsSeen) { - test('uitslag: 20 gerangschikte spelers', () => { - assert.strictEqual(resultsSeen.players.length, 20); + test('uitslag: 19 gerangschikte spelers', () => { + assert.strictEqual(resultsSeen.players.length, 19); 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', () => { const first = resultsSeen.players.find((p) => p.rank === 1);