/* Turbo Race - client: netwerk, input, rendering, HUD */ (function () { 'use strict'; const painter = window.TurboPainter; const ROAD_W = 420; const K = 0.32; // zelfde schaal als render.js const $ = (id) => document.getElementById(id); const clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n); /* ---------- state ---------- */ let ws = null; const me = { id: null, host: 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 }; let visSteer = 0; // visuele stuursmoothing (alleen voor rendering) let fx = []; let shake = 0; let lastSent = 0; const canvas = $('game'); const ctx = canvas.getContext('2d'); const mm = $('minimap'); const mmCtx = mm.getContext('2d'); let W = 0, H = 0, DPR = 1; function resize() { DPR = Math.min(window.devicePixelRatio || 1, 2); W = window.innerWidth; H = window.innerHeight; canvas.width = Math.round(W * DPR); canvas.height = Math.round(H * DPR); canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); mm.width = Math.round(mm.clientWidth * DPR); mm.height = Math.round(mm.clientHeight * DPR); mmCtx.setTransform(DPR, 0, 0, DPR, 0, 0); } window.addEventListener('resize', resize); window.addEventListener('orientationchange', () => setTimeout(resize, 200)); /* ---------- kleurenpalet ---------- */ const PALETTE = ['#ff3fa4', '#00f0ff', '#ffd93d', '#3dff88', '#ff7a1a', '#8f5bff', '#ff4d6d', '#4dffd6', '#ff9ff3', '#b3ff4d']; let chosenColor = PALETTE[0]; (function initColors() { const box = $('colors'); PALETTE.forEach((c) => { const s = document.createElement('button'); s.type = 'button'; s.className = 'swatch' + (c === chosenColor ? ' on' : ''); s.style.background = c; s.onclick = () => { chosenColor = c; box.querySelectorAll('.swatch').forEach((el) => el.classList.remove('on')); s.classList.add('on'); }; box.appendChild(s); }); })(); /* ---------- verbinding ---------- */ function hostToken() { try { return new URLSearchParams(location.search).get('host') || ''; } catch { return ''; } } function connect() { const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'; ws = new WebSocket(proto + location.host + '/ws'); ws.onopen = () => { ws.send(JSON.stringify({ type: 'join', name: $('nameInput').value, color: chosenColor, hostToken: hostToken(), })); }; ws.onmessage = (ev) => { let m; try { m = JSON.parse(ev.data); } catch { return; } handle(m); }; ws.onclose = () => { if (phase !== 'results') $('dead').classList.remove('hidden'); }; ws.onerror = () => {}; } $('joinBtn').onclick = () => connect(); $('nameInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') connect(); }); /* ---------- message-handling ---------- */ function handle(m) { switch (m.type) { case 'joined': me.id = m.id; me.host = !!m.host; me.name = m.self.name; me.color = m.self.color; snap = m.snapshot; track = m.track; applySnapshot(m.snapshot); $('join').classList.add('hidden'); $('room').classList.remove('hidden'); break; case 'snapshot': applySnapshot(m); break; case 'track': track = m.track; break; case 'fx': onFx(m.id, m.kind); break; case 'announce': toast(m.text, 'gold'); break; case 'error': showJoinError(m.msg); break; case 'phase': // bevestiging; snapshot volgt zo break; } } /* ---------- snapshot & interpolatie ---------- */ let prevPhase = 'lobby'; function applySnapshot(s) { snap = s; const newPhase = s.phase; const hard = (prevPhase !== newPhase) && (newPhase === 'race' || newPhase === 'lobby'); const seen = new Set(); for (const p of s.players) { seen.add(p.id); if (!rp[p.id]) { rp[p.id] = { rx: p.x, rd: p.d, name: p.name, color: p.color, boost: false, shield: false, slip: false, finished: false, rank: 0, speed: 0 }; } if (hard) { rp[p.id].rx = p.x; rp[p.id].rd = p.d; } } for (const key in rp) if (!seen.has(key)) delete rp[key]; prevPhase = newPhase; phase = newPhase; if (phase === 'countdown') $('countdown').classList.remove('hidden'); if (phase === 'race') { $('countdown').classList.add('hidden'); $('results').classList.add('hidden'); } if (phase === 'results') { $('countdown').classList.add('hidden'); buildResults(s); } if (phase === 'lobby') { $('results').classList.add('hidden'); $('countdown').classList.add('hidden'); } } /* ---------- render-loop ---------- */ let raf = 0, lastT = performance.now(), accHud = 0, accMinimap = 0; function frame(ts) { const dt = Math.min(0.05, (ts - lastT) / 1000); lastT = ts; accHud += dt; accMinimap += dt; if (snap) { for (const p of snap.players) { const r = rp[p.id]; if (!r) continue; const k = (p.id === me.id) ? (1 - Math.exp(-dt * 18)) : (1 - Math.exp(-dt * 7)); r.rx += (p.x - r.rx) * k; r.rd += (p.d - r.rd) * k; r.name = p.name; r.color = p.color; r.boost = p.boost; r.shield = p.shield; r.slip = p.slip; r.finished = p.finished; r.speed = p.speed; r.rank = p.rank; } } visSteer += (input.steer - visSteer) * Math.min(1, dt * 12); draw(ts); advanceFx(dt); shake = Math.max(0, shake - dt * 40); if (accHud > 0.1) { accHud = 0; updateHud(); } if (accMinimap > 0.22) { accMinimap = 0; drawMinimap(); } raf = requestAnimationFrame(frame); } 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 }); return; } const r = rp[me.id]; const selfD = r ? r.rd : meSnap.d; const cars = []; for (const key in rp) { const o = rp[key]; 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: key === me.id, steer: key === me.id ? visSteer : 0, }); } painter.render(ctx, { W, H, t, me: { d: selfD }, trackLength: snap.trackLength, track, cars, fx, speedFactor: (meSnap.speed || 0) / 300, shake, }); } function showJoinError(msg) { const el = $('joinMsg'); el.textContent = msg || 'Kon niet aanmelden'; el.className = 'msg err'; } /* ---------- HUD & lijsten ---------- */ let lastBoardSig = ''; function updateHud() { if (!snap) return; const now = Date.now(); const meSnap = snap.players.find((p) => p.id === me.id); if (phase === 'countdown') { const rem = Math.max(0, Math.ceil((snap.countdownEndsAt - now) / 1000)); $('cdNum').textContent = rem; updateBoard(rem); return; } if (phase === 'race' && meSnap) { const rem = Math.max(0, snap.raceEndsAt - now); const tot = Math.ceil(rem / 1000); const min = Math.floor(tot / 60), sec = tot % 60; const tEl = $('hudTimer'); 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'; } updateBoard(0); updateLead(); } function updateBoard(remaining) { if (!snap) return; const inRace = phase === 'race' || phase === 'results'; const head = inRace ? '๐Ÿ WEDSTRIJD' : `๐Ÿ‘ฅ AANMELDERS (${snap.players.length}/${snap.maxPlayers})`; const sig = head + '|' + snap.players.map((p) => `${p.id}:${p.rank}:${p.d}`).join(',') + '|host' + me.host; if (sig === lastBoardSig) return; lastBoardSig = sig; $('boardHead').textContent = head; const ol = $('boardList'); ol.innerHTML = ''; const sorted = snap.players.slice().sort((a, b) => a.rank - b.rank); for (const p of sorted) { const li = document.createElement('li'); const cls = []; if (p.id === me.id) cls.push('me'); if (inRace && p.rank === 1) cls.push('top'); if (cls.length) li.className = cls.join(' '); if (inRace) { li.innerHTML = `${p.rank}${esc(p.name)}${p.finished ? fmtTime(p.finishTime) : Math.round(p.d) + ' m'}`; } else { li.innerHTML = `${p.rank}${esc(p.name)}`; } ol.appendChild(li); } if (me.host && !inRace) { const li = document.createElement('li'); li.className = 'st'; li.innerHTML = `๐Ÿ‘‘ Jij bent host โ€” druk op S${remaining != null ? ' (nog ' + remaining + 's voor start)' : ''}`; ol.appendChild(li); } } let lastLead = ''; function updateLead() { if (!snap || phase !== 'race' || snap.players.length === 0) { if (lastLead !== '') { lastLead = ''; $('lead').classList.add('hidden'); } return; } const sorted = snap.players.slice().sort((a, b) => a.rank - b.rank); const top = sorted[0]; const key = top.id + top.d; if (key === lastLead) return; lastLead = key; $('leadText').innerHTML = `๐Ÿ‘‘ ${esc(top.name)} ligt AAN KOP`; $('lead').classList.remove('hidden'); } function drawMinimap() { if (!snap || !track) return; const w = mm.clientWidth, h = mm.clientHeight; mmCtx.clearRect(0, 0, w, h); const total = snap.trackLength || 23000; const yFor = (d) => h - 3 - (d / total) * (h - 8); // finishlijn mmCtx.fillStyle = 'rgba(255,217,61,0.85)'; mmCtx.fillRect(1, 2, w - 2, 2); for (const p of snap.players) { const yy = yFor(p.d); const isMe = p.id === me.id; const isTop = p.rank === 1; mmCtx.beginPath(); mmCtx.arc(w / 2, yy, isTop ? 3 : 2.2, 0, 7); mmCtx.fillStyle = p.color; mmCtx.fill(); if (isMe) { mmCtx.strokeStyle = '#fff'; mmCtx.lineWidth = 1; mmCtx.beginPath(); mmCtx.arc(w / 2, yy, 4.2, 0, 7); mmCtx.stroke(); } } } function fmtTime(ms) { const s = ms / 1000; const m = Math.floor(s / 60); const ss = Math.floor(s % 60); return `${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}`; } function esc(str) { return String(str).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } /* ---------- 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; } 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.toLowerCase() === 's' && me.host && (phase === 'lobby' || phase === 'results') && document.activeElement !== $('nameInput')) { sendHostStart(); } }); 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; } }); window.addEventListener('blur', () => { input.steer = 0; }); function sendHostStart() { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'host_start' })); } // input naar server setInterval(() => { if (ws && ws.readyState === 1 && me.id) { ws.send(JSON.stringify({ type: 'input', steer: input.steer })); } }, 33); /* ---------- fx & toasts ---------- */ function onFx(id, kind) { const r = rp[id]; if (!r) return; const meSnap = snap && snap.players.find((p) => p.id === me.id); const selfD = meSnap ? rp[me.id] ? rp[me.id].rd : meSnap.d : 0; const roadW = Math.min(W * 0.88, 460); const scaleX = roadW / ROAD_W; const carY = Math.min(H * 0.62, H - 140); const sx = (W - roadW) / 2 + r.rx * scaleX; const sy = carY - (r.rd - selfD) * K; const col = kind.indexOf('boost') >= 0 ? '#00f0ff' : kind.indexOf('shield') >= 0 ? '#3dff88' : kind.indexOf('pylon') >= 0 ? '#ffd93d' : '#ff4d6d'; if (kind.indexOf('hit') === 0) { burst(sx, sy, col, 14); burst(sx, sy, '#fff', 6); } else { pulseFx(sx, sy, col); burst(sx, sy, col, 12); } if (id === me.id) { if (kind === 'hit:pylon') toast('๐Ÿ’ฅ Je raakt een pylon!', ''); else if (kind === 'hit:oil') toast('๐ŸŒ€ Olie! Je glijdt wegโ€ฆ', ''); else if (kind === 'pickup:boost') toast('โšก BOOST!', 'gold'); else if (kind === 'pickup:shield') toast('๐Ÿ›ก Schild actief!', 'gold'); if (kind.indexOf('hit') === 0) shake = 14; } } function burst(sx, sy, color, n) { for (let i = 0; i < n; i++) { const a = Math.random() * Math.PI * 2; const sp = 40 + Math.random() * 160; fx.push({ kind: 'dot', sx, sy, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, size: 2 + Math.random() * 4, color, life: 0, maxLife: 0.4 + Math.random() * 0.4, }); } } function pulseFx(sx, sy, color) { fx.push({ kind: 'pulse', sx, sy, vx: 0, vy: 0, size: 14, color, life: 0, maxLife: 0.5 }); } function advanceFx(dt) { for (let i = fx.length - 1; i >= 0; i--) { const f = fx[i]; f.life += dt; if (f.life >= f.maxLife) { fx.splice(i, 1); continue; } if (f.kind === 'dot') { f.sx += f.vx * dt; f.sy += f.vy * dt; f.vx *= 0.94; f.vy *= 0.94; } } } function toast(text, cls) { const box = $('toasts'); const el = document.createElement('div'); el.className = 'toast' + (cls ? ' ' + cls : ''); el.textContent = text; box.appendChild(el); setTimeout(() => el.remove(), 2600); while (box.children.length > 3) box.firstChild.remove(); } /* ---------- uitslag ---------- */ function buildResults(s) { $('results').classList.remove('hidden'); const sorted = s.players.slice().sort((a, b) => a.rank - b.rank); const podium = $('podium'); podium.innerHTML = ''; const medals = { 1: '๐Ÿฅ‡', 2: '๐Ÿฅˆ', 3: '๐Ÿฅ‰' }; for (const p of sorted.slice(0, 3)) { const d = document.createElement('div'); d.className = 'pod g' + p.rank; d.innerHTML = `${medals[p.rank]}
${esc(p.name)}
`; podium.appendChild(d); } const ol = $('resultsList'); ol.innerHTML = ''; sorted.forEach((p) => { const li = document.createElement('li'); if (p.id === me.id) li.className = 'me'; li.innerHTML = `${p.rank}${esc(p.name)}${p.finished ? fmtTime(p.finishTime) : 'niet gefinisht ยท ' + Math.round(p.d) + ' m'}`; ol.appendChild(li); }); $('resSub').textContent = me.host ? 'Druk op S om een nieuwe race te starten (of gebruik de knop).' : 'De host kan een nieuwe race starten.'; } $('againBtn').onclick = () => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'ready_again' })); }; /* ---------- start ---------- */ resize(); requestAnimationFrame(frame); })();