(() => { 'use strict'; const WS_URL = window.PP1V1_WS_URL; const TICKET_URL = '/api/matches/ping-pong/1v1/ticket.php'; const STATUS_URL = '/api/matches/ping-pong/1v1/status.php'; const LOBBY_URL = '/disciplines/ping-pong/1v1/'; const SOUND_BASE = '/disciplines/ping-pong/sounds'; const SOUND_LIBRARY = { kick: `${SOUND_BASE}/kick.mp3`, win: `${SOUND_BASE}/won.mp3`, lose: `${SOUND_BASE}/gameOver.mp3`, }; const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); canvas.style.touchAction = 'none'; const el = { status: document.getElementById('status'), badge: document.getElementById('badge'), score: document.getElementById('score'), btnFind: document.getElementById('btnFind'), btnLeave: document.getElementById('btnLeave'), overlay: document.getElementById('overlay'), overlayBadge: document.getElementById('overlayBadge'), overlayStage: document.getElementById('overlayStage'), overlayTitle: document.getElementById('overlayTitle'), overlayHero: document.getElementById('overlayHero'), overlayHeroNumber: document.getElementById('overlayHeroNumber'), overlayHeroLabel: document.getElementById('overlayHeroLabel'), overlayProgress: document.getElementById('overlayProgress'), overlayProgressBar: document.getElementById('overlayProgressBar'), overlayText: document.getElementById('overlayText'), overlayGrid: document.getElementById('overlayGrid'), overlayButtons: document.getElementById('overlayButtons'), overlayHint: document.getElementById('overlayHint'), }; let ws = null; let ticket = null; let userId = null; let isConnected = false; let isConnecting = false; let isSearching = false; let pendingFind = false; let manualClose = false; let matchId = null; let side = null; let lastState = null; let lastStateAt = 0; let renderState = null; let lastRenderAt = 0; let lastEndPayload = null; let mouseAimY = null; let mouseControlArmed = false; let keyUp = false; let keyDown = false; let move = 0; let lastSentTargetY = null; let seq = 0; let rewardsJobId = null; let pollTimer = null; let countdownTimer = null; let countdownToken = null; let lobbyTimer = null; let postMatchTimer = null; let rewardPollState = 'idle'; let matchMeta = null; const CONTROL_HINT = 'Sterowanie: myszka albo W/S albo strzałki.'; const REWARD_ANIMATION_MS = 6500; const audio = Object.fromEntries(Object.entries(SOUND_LIBRARY).map(([key, src]) => { const clip = new Audio(src); clip.preload = 'auto'; clip.volume = key === 'kick' ? 0.3 : 0.52; return [key, clip]; })); function setStatus(text) { el.status.textContent = text; } function updateBadge() { const ownLabel = `Twój ID: ${userId ?? '—'}`; const opponentLabel = `ID przeciwnika: ${matchMeta?.opponentUserId ?? '—'}`; el.badge.textContent = `${ownLabel} • ${opponentLabel}`; } function updateButtons() { el.btnFind.disabled = !!matchId || isSearching; el.btnLeave.textContent = isSearching && !matchId ? 'Anuluj szukanie' : 'Wyjście'; } function resizeCanvas() { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = Math.floor(rect.width * dpr); canvas.height = Math.floor(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } function showOverlay(title, text, buttons = [], options = {}) { el.overlay.dataset.mode = options.mode || ''; if (options.badge) { el.overlayBadge.hidden = false; el.overlayBadge.textContent = options.badge; } else { el.overlayBadge.hidden = true; el.overlayBadge.textContent = ''; } if (options.stage) { el.overlayStage.hidden = false; el.overlayStage.textContent = options.stage; } else { el.overlayStage.hidden = true; el.overlayStage.textContent = ''; } el.overlayTitle.textContent = title; if (options.heroNumber != null || options.heroLabel) { el.overlayHero.hidden = false; el.overlayHeroNumber.textContent = options.heroNumber ?? ''; el.overlayHeroLabel.textContent = options.heroLabel ?? ''; } else { el.overlayHero.hidden = true; el.overlayHeroNumber.textContent = ''; el.overlayHeroLabel.textContent = ''; } if (options.progress) { el.overlayProgress.hidden = false; el.overlayProgress.dataset.indeterminate = options.progress.indeterminate ? 'true' : 'false'; el.overlayProgressBar.style.width = `${Math.max(0, Math.min(100, Math.round((options.progress.value ?? 0) * 100)))}%`; } else { el.overlayProgress.hidden = true; el.overlayProgress.dataset.indeterminate = 'false'; el.overlayProgressBar.style.width = '0%'; } el.overlayText.textContent = text; el.overlayGrid.innerHTML = ''; if (Array.isArray(options.gridItems) && options.gridItems.length) { el.overlayGrid.hidden = false; for (const item of options.gridItems) { const card = document.createElement('div'); card.className = `overlay-card${item.tone ? ` tone-${item.tone}` : ''}`; const label = document.createElement('div'); label.className = 'overlay-card-label'; label.textContent = item.label; const value = document.createElement('div'); value.className = 'overlay-card-value'; value.textContent = item.value; card.append(label, value); el.overlayGrid.appendChild(card); } } else { el.overlayGrid.hidden = true; } el.overlayButtons.innerHTML = ''; for (const button of buttons) { const btn = document.createElement('button'); btn.className = 'btn' + (button.secondary ? ' secondary' : ''); btn.textContent = button.label; btn.addEventListener('click', button.onClick); el.overlayButtons.appendChild(btn); } el.overlayButtons.style.display = buttons.length ? 'flex' : 'none'; el.overlayHint.textContent = options.hint || CONTROL_HINT; el.overlay.style.display = 'flex'; } function hideOverlay() { el.overlay.dataset.mode = ''; el.overlayBadge.hidden = true; el.overlayBadge.textContent = ''; el.overlayStage.hidden = true; el.overlayStage.textContent = ''; el.overlayHero.hidden = true; el.overlayHeroNumber.textContent = ''; el.overlayHeroLabel.textContent = ''; el.overlayProgress.hidden = true; el.overlayProgress.dataset.indeterminate = 'false'; el.overlayProgressBar.style.width = '0%'; el.overlayGrid.hidden = true; el.overlayGrid.innerHTML = ''; el.overlayButtons.style.display = 'flex'; el.overlayHint.textContent = CONTROL_HINT; el.overlay.style.display = 'none'; } function clearCountdownTimer() { if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } countdownToken = null; } function clearLobbyTimer() { if (lobbyTimer) { clearTimeout(lobbyTimer); lobbyTimer = null; } } function clearPostMatchTimer() { if (postMatchTimer) { clearInterval(postMatchTimer); postMatchTimer = null; } } function setOverlayStage(text) { el.overlayStage.hidden = !text; el.overlayStage.textContent = text || ''; } function setOverlayHero(number, label) { el.overlayHero.hidden = number == null && !label; el.overlayHeroNumber.textContent = number ?? ''; el.overlayHeroLabel.textContent = label ?? ''; } function setOverlayProgress(value, indeterminate = false) { el.overlayProgress.hidden = false; el.overlayProgress.dataset.indeterminate = indeterminate ? 'true' : 'false'; el.overlayProgressBar.style.width = `${Math.max(0, Math.min(100, Math.round(value * 100)))}%`; } function setOverlayHint(text) { el.overlayHint.textContent = text || CONTROL_HINT; } function shouldKeepOverlayVisible() { return el.overlay.dataset.mode === 'countdown' || el.overlay.dataset.mode === 'victory' || el.overlay.dataset.mode === 'defeat'; } function getSideDescriptor(currentSide) { if (currentSide === 'left') { return { label: 'lewa', color: 'niebieski', tone: 'blue', moveHint: 'Twoja paletka jest po lewej stronie stołu.', }; } return { label: 'prawa', color: 'różowy', tone: 'pink', moveHint: 'Twoja paletka jest po prawej stronie stołu.', }; } function buildPreMatchGrid(meta) { const sideInfo = getSideDescriptor(meta.side); return [ { label: 'Przeciwnik', value: meta.opponentUsername || 'Łączenie…' }, { label: 'ID przeciwnika', value: meta.opponentUserId ?? '—' }, { label: 'Strona', value: sideInfo.label, tone: sideInfo.tone }, { label: 'Kolor', value: sideInfo.color, tone: sideInfo.tone }, { label: 'Sterowanie', value: 'Myszka lub W/S lub strzałki' }, { label: 'Sety', value: `do ${meta.setsToWin ?? 3} wygranych`, tone: 'gold' }, { label: 'Punkty', value: `do ${meta.pointsToWin ?? 11}`, tone: 'gold' }, ]; } function buildPostMatchGrid(payload, didWin) { const opponentUsername = didWin ? (payload?.loserUsername || matchMeta?.opponentUsername || 'przeciwnik') : (payload?.winnerUsername || matchMeta?.opponentUsername || 'przeciwnik'); const opponentUserId = didWin ? (payload?.loserUserId || matchMeta?.opponentUserId || '—') : (payload?.winnerUserId || matchMeta?.opponentUserId || '—'); return [ { label: 'Przeciwnik', value: opponentUsername }, { label: 'ID przeciwnika', value: opponentUserId }, { label: 'Wynik setów', value: `${payload?.sets?.left ?? 0}:${payload?.sets?.right ?? 0}`, tone: 'gold' }, { label: 'Wynik punktów', value: `${payload?.points?.left ?? 0}:${payload?.points?.right ?? 0}` }, { label: 'Powód zakończenia', value: payload?.reason === 'sets' ? 'zwycięstwo w setach' : (payload?.reason || 'koniec meczu'), tone: didWin ? 'blue' : 'pink' }, ]; } function returnToLobby() { clearCountdownTimer(); clearLobbyTimer(); clearPostMatchTimer(); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } localStorage.removeItem('pp1v1.matchId'); rewardsJobId = null; matchMeta = null; matchId = null; side = null; updateBadge(); manualClose = true; try { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { ws.close(1000, 'return_to_lobby'); } } catch { // ignore } window.location.href = LOBBY_URL; } function startMatchCountdown(meta) { matchMeta = { ...matchMeta, ...meta, }; updateBadge(); const token = `${matchMeta.matchId || matchId || 'match'}:${matchMeta.side}:${matchMeta.warmupEndsAt}`; if (countdownToken === token && countdownTimer) { return; } clearCountdownTimer(); countdownToken = token; const sideInfo = getSideDescriptor(matchMeta.side); showOverlay( 'Mecz startuje za chwilę', `Grasz z ${matchMeta.opponentUsername || 'przeciwnikiem'}. ${sideInfo.moveHint}`, [], { mode: 'countdown', badge: 'Przygotuj się', stage: `Stoły gotowe • mecz do ${matchMeta.pointsToWin ?? 11} punktów i ${matchMeta.setsToWin ?? 3} setów`, heroNumber: '10', heroLabel: 'sekund do startu', progress: { value: 0 }, gridItems: buildPreMatchGrid(matchMeta), hint: `${CONTROL_HINT} ${sideInfo.moveHint}`, } ); const totalMs = Math.max(1000, (matchMeta.warmupEndsAt || (Date.now() + 10_000)) - Date.now()); const updateCountdown = () => { const remainingMs = Math.max(0, (matchMeta?.warmupEndsAt || Date.now()) - Date.now()); const remainingSeconds = Math.max(0, Math.ceil(remainingMs / 1000)); setOverlayHero(String(remainingSeconds), remainingSeconds === 1 ? 'sekunda do startu' : 'sekund do startu'); setOverlayProgress(1 - (remainingMs / totalMs)); setOverlayStage(`Grasz kolorem ${sideInfo.color} po stronie ${sideInfo.label}.`); if (remainingMs <= 0) { clearCountdownTimer(); hideOverlay(); setStatus(`Mecz trwa z ${matchMeta?.opponentUsername || 'przeciwnikiem'}.`); } }; updateCountdown(); countdownTimer = setInterval(updateCountdown, 100); } function startPostMatchAnimation(payload) { const didWin = didWinLastMatch(); const title = didWin ? 'Zwycięstwo!' : 'Porażka'; const opponentUsername = didWin ? (payload?.loserUsername || matchMeta?.opponentUsername || 'przeciwnik') : (payload?.winnerUsername || matchMeta?.opponentUsername || 'przeciwnik'); const heroScore = `${payload?.sets?.left ?? 0}:${payload?.sets?.right ?? 0}`; const stages = didWin ? ['Potwierdzanie wyniku', 'Przydzielanie nagrody', 'Zamykanie stołu', 'Powrót do lobby 1v1'] : ['Zapisywanie wyniku', 'Przydzielanie nagrody pocieszenia', 'Zamykanie stołu', 'Powrót do lobby 1v1']; clearCountdownTimer(); clearLobbyTimer(); clearPostMatchTimer(); localStorage.removeItem('pp1v1.matchId'); rewardPollState = 'pending'; showOverlay( title, `${didWin ? 'Pokonałeś' : 'Przegrałeś z'} ${opponentUsername}. Za chwilę wrócisz automatycznie do lobby 1v1.`, [], { mode: didWin ? 'victory' : 'defeat', badge: didWin ? 'Wygrana' : 'Porażka', stage: stages[0], heroNumber: heroScore, heroLabel: 'wynik setów', progress: { value: 0.05 }, gridItems: buildPostMatchGrid(payload, didWin), hint: 'Wynik został zapisany. Za chwilę nastąpi powrót do ekranu szukania meczu.', } ); const startedAt = Date.now(); const tick = () => { const elapsed = Date.now() - startedAt; const progress = Math.min(1, elapsed / REWARD_ANIMATION_MS); const stageIndex = Math.min(stages.length - 1, Math.floor(progress * stages.length)); const remainingSeconds = Math.max(0, Math.ceil((REWARD_ANIMATION_MS - elapsed) / 1000)); setOverlayProgress(progress, rewardPollState === 'pending'); setOverlayStage(`${stages[stageIndex]}${remainingSeconds ? ` • ${remainingSeconds}s` : ''}`); if (rewardPollState === 'done') { setOverlayHint('Nagrody zostały przyznane. Za chwilę wracasz do lobby 1v1.'); } else if (rewardPollState === 'failed') { setOverlayHint('Rozliczenie nagród domknie się w tle. Powrót do lobby 1v1 za chwilę.'); } if (elapsed >= REWARD_ANIMATION_MS) { clearPostMatchTimer(); returnToLobby(); } }; tick(); postMatchTimer = setInterval(tick, 120); } function playSound(name) { const source = audio[name]; if (!source) return; try { const clip = source.cloneNode(); clip.volume = source.volume; clip.play().catch(() => {}); } catch { // ignore browser audio restrictions } } function didWinLastMatch() { return !!lastEndPayload && !!userId && lastEndPayload.winnerUserId === userId; } function formatMatchScore(state) { const setsL = Number.isFinite(state?.setsL) ? state.setsL : 0; const setsR = Number.isFinite(state?.setsR) ? state.setsR : 0; const scoreL = Number.isFinite(state?.scoreL) ? state.scoreL : 0; const scoreR = Number.isFinite(state?.scoreR) ? state.scoreR : 0; return `${setsL}:${setsR} sety • ${scoreL}:${scoreR}`; } async function fetchTicket() { const res = await fetch(TICKET_URL, { method: 'GET', credentials: 'include' }); const json = await res.json().catch(() => null); if (!res.ok || !json?.success) { throw new Error(json?.error || `Ticket error ${res.status}`); } return json.ticket; } function send(msg) { if (!ws || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify(msg)); } async function ensureConnected() { if (isConnected && ws && ws.readyState === WebSocket.OPEN) return; if (isConnecting) return; await connect(); } function joinQueue() { pendingFind = false; isSearching = false; updateButtons(); send({ type: 'queue.join' }); setStatus('Dołączanie do kolejki…'); } function leaveQueue() { if (!isSearching) return; isSearching = false; pendingFind = false; updateButtons(); send({ type: 'queue.leave' }); setStatus('Wyszukiwanie anulowane.'); } function computeMove() { if (keyUp && !keyDown) return -1; if (keyDown && !keyUp) return 1; return 0; } function getDesiredTargetY() { if (keyUp || keyDown) return null; if (!mouseControlArmed) return null; if (mouseAimY == null) return null; return clamp(mouseAimY, 0.12, 0.88); } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function lerp(current, target, amount) { return current + (target - current) * amount; } function cloneRenderState(state) { return { paddleL: { y: state.paddleL.y }, paddleR: { y: state.paddleR.y }, ball: { x: state.ball.x, y: state.ball.y, vx: state.ball.vx || 0, vy: state.ball.vy || 0, }, }; } function predictBallY(y, vy, dt, min, max) { let nextY = y + vy * dt; let nextVy = vy; let safety = 0; while ((nextY < min || nextY > max) && safety < 4) { if (nextY < min) { nextY = min + (min - nextY); nextVy = Math.abs(nextVy); } else { nextY = max - (nextY - max); nextVy = -Math.abs(nextVy); } safety += 1; } return { y: clamp(nextY, min, max), vy: nextVy }; } function getRenderState(now) { if (!lastState) return null; if (!renderState) { renderState = cloneRenderState(lastState); lastRenderAt = now; return renderState; } const frameDt = lastRenderAt ? Math.min(0.05, Math.max(0.001, (now - lastRenderAt) / 1000)) : 0.016; lastRenderAt = now; const snapshotAge = Math.min(0.08, Math.max(0, (Date.now() - lastStateAt) / 1000)); const targetBallX = clamp(lastState.ball.x + (lastState.ball.vx || 0) * snapshotAge, 0, 1); const predictedBall = predictBallY(lastState.ball.y, lastState.ball.vy || 0, snapshotAge, 0.015, 0.985); const paddleLerp = Math.min(1, frameDt * 16); const ballLerp = Math.min(1, frameDt * 20); renderState.paddleL.y = lerp(renderState.paddleL.y, lastState.paddleL.y, paddleLerp); renderState.paddleR.y = lerp(renderState.paddleR.y, lastState.paddleR.y, paddleLerp); renderState.ball.x = lerp(renderState.ball.x, targetBallX, ballLerp); renderState.ball.y = lerp(renderState.ball.y, predictedBall.y, ballLerp); renderState.ball.vx = lastState.ball.vx || 0; renderState.ball.vy = predictedBall.vy; return renderState; } function updateMouseAim(event) { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; const clientY = event.clientY ?? event.pageY; if (!Number.isFinite(clientY)) return; const y = (clientY - rect.top) / rect.height; mouseAimY = Math.max(0.12, Math.min(0.88, y)); mouseControlArmed = true; } function setupInput() { window.addEventListener('keydown', (e) => { if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') keyUp = true; if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') keyDown = true; if (['ArrowUp', 'ArrowDown', 'w', 'W', 's', 'S'].includes(e.key)) { mouseControlArmed = false; } if (['ArrowUp', 'ArrowDown', 'w', 'W', 's', 'S'].includes(e.key)) e.preventDefault(); }, { passive: false }); window.addEventListener('keyup', (e) => { if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') keyUp = false; if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') keyDown = false; }); canvas.addEventListener('pointermove', (event) => { updateMouseAim(event); }); canvas.addEventListener('pointerenter', (event) => { updateMouseAim(event); }); canvas.addEventListener('mousemove', (event) => { updateMouseAim(event); }); canvas.addEventListener('pointerdown', (event) => { updateMouseAim(event); }); canvas.addEventListener('mouseleave', () => { mouseAimY = null; mouseControlArmed = false; }); canvas.addEventListener('pointerleave', () => { mouseAimY = null; mouseControlArmed = false; }); setInterval(() => { const next = computeMove(); const nextTargetY = getDesiredTargetY(); const targetChanged = nextTargetY == null ? lastSentTargetY != null : lastSentTargetY == null || Math.abs(nextTargetY - lastSentTargetY) > 0.004; if (next === move && !targetChanged) return; move = next; lastSentTargetY = nextTargetY; send({ type: 'match.input', seq: ++seq, move, targetY: nextTargetY }); }, 33); } function draw() { const now = performance.now(); const w = canvas.clientWidth; const h = canvas.clientHeight; const radius = Math.min(18, w * 0.025, h * 0.04); ctx.clearRect(0, 0, w, h); ctx.save(); clipRoundedRect(ctx, 0, 0, w, h, radius); ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, w, h); ctx.save(); ctx.fillStyle = 'rgba(0,255,247,0.06)'; ctx.fillRect(0, 0, w * 0.12, h); ctx.fillStyle = 'rgba(255,0,110,0.06)'; ctx.fillRect(w * 0.88, 0, w * 0.12, h); ctx.restore(); ctx.save(); ctx.strokeStyle = 'rgba(0,255,247,0.25)'; ctx.setLineDash([10, 10]); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke(); ctx.restore(); if (!lastState) { ctx.fillStyle = 'rgba(223,252,255,0.75)'; ctx.font = '16px Lato, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Połącz się i kliknij "Szukaj meczu"', w / 2, h / 2); ctx.restore(); requestAnimationFrame(draw); return; } const state = getRenderState(now); const paddleHalf = 0.12; const paddleH = (paddleHalf * 2) * h; const paddleW = Math.max(10, Math.floor(w * 0.012)); const xL = 0.06 * w; const xR = 0.94 * w; const yL = (state.paddleL.y * h) - paddleH / 2; const yR = (state.paddleR.y * h) - paddleH / 2; ctx.save(); ctx.shadowBlur = 20; ctx.shadowColor = '#0080ff'; ctx.fillStyle = '#0080ff'; ctx.fillRect(xL - paddleW / 2, yL, paddleW, paddleH); ctx.shadowColor = '#ff006e'; ctx.fillStyle = '#ff006e'; ctx.fillRect(xR - paddleW / 2, yR, paddleW, paddleH); ctx.restore(); const ballR = Math.max(6, Math.floor(Math.min(w, h) * 0.015)); const bx = state.ball.x * w; const by = state.ball.y * h; ctx.save(); ctx.shadowBlur = 30; ctx.shadowColor = '#00fff7'; ctx.fillStyle = '#00fff7'; ctx.beginPath(); ctx.arc(bx, by, ballR, 0, Math.PI * 2); ctx.fill(); ctx.restore(); ctx.restore(); requestAnimationFrame(draw); } function clipRoundedRect(context, x, y, width, height, radius) { context.beginPath(); context.moveTo(x + radius, y); context.lineTo(x + width - radius, y); context.quadraticCurveTo(x + width, y, x + width, y + radius); context.lineTo(x + width, y + height - radius); context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); context.lineTo(x + radius, y + height); context.quadraticCurveTo(x, y + height, x, y + height - radius); context.lineTo(x, y + radius); context.quadraticCurveTo(x, y, x + radius, y); context.closePath(); context.clip(); } async function pollRewards(jobId) { if (pollTimer) clearInterval(pollTimer); const tick = async () => { try { const res = await fetch(`${STATUS_URL}?jobId=${encodeURIComponent(jobId)}`, { credentials: 'include' }); const json = await res.json().catch(() => null); if (!res.ok || !json?.success) return; const st = json.job?.status; if (st === 'done') { rewardPollState = 'done'; clearInterval(pollTimer); pollTimer = null; return; } if (st === 'failed') { rewardPollState = 'failed'; clearInterval(pollTimer); pollTimer = null; } } catch { // ignore } }; await tick(); pollTimer = setInterval(tick, 1200); } async function connect() { if (!WS_URL) { showOverlay('Konfiguracja', 'Brak PP1V1_WS_URL na stronie.', [ { label: 'Wróć', onClick: () => window.location.href = '/disciplines/ping-pong/' } ]); return; } if (isConnecting) { return; } isConnecting = true; try { setStatus('Pobieranie ticketu…'); ticket = await fetchTicket(); setStatus('Łączenie z serwerem…'); await new Promise((resolve, reject) => { let settled = false; let helloReceived = false; const fail = (message) => { if (settled) return; settled = true; isConnecting = false; isConnected = false; try { if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) { manualClose = true; ws.close(); } } catch { // ignore } reject(new Error(message)); }; ws = new WebSocket(WS_URL); ws.addEventListener('open', () => { const lastMatch = localStorage.getItem('pp1v1.matchId'); send({ type: 'hello', ticket, matchId: lastMatch || undefined }); }); ws.addEventListener('error', () => { fail('Nie udało się połączyć z serwerem gry. Adres WebSocket nie odpowiada lub reverse proxy dla /ping-pong-1v1 nie jest skonfigurowane.'); }); ws.addEventListener('message', (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === 'hello.ok') { helloReceived = true; settled = true; isConnecting = false; userId = msg.userId; isConnected = true; updateBadge(); setStatus('Połączono.'); updateButtons(); if (!shouldKeepOverlayVisible()) { hideOverlay(); } if (pendingFind) joinQueue(); resolve(); return; } if (msg.type === 'match.reconnected') { matchId = msg.matchId; side = msg.side || side; matchMeta = { ...matchMeta, matchId: msg.matchId, side: msg.side || side, opponentUserId: msg.opponentUserId || matchMeta?.opponentUserId, opponentUsername: msg.opponentUsername || matchMeta?.opponentUsername, warmupEndsAt: msg.warmupEndsAt || matchMeta?.warmupEndsAt, pointsToWin: msg.pointsToWin || matchMeta?.pointsToWin, setsToWin: msg.setsToWin || matchMeta?.setsToWin, }; if ((msg.warmupEndsAt || 0) > Date.now()) { startMatchCountdown(matchMeta); } return; } if (msg.type === 'hello.error') { if (msg.error === 'duplicate_session') { fail('To konto jest już połączone z trybem 1v1 w innej karcie lub przeglądarce. Zamknij tamtą sesję i spróbuj ponownie.'); return; } if (msg.error === 'missing_username' || msg.error === 'invalid_username') { fail('Nie możesz wejść do gry bez poprawnego username. Ustaw nick na koncie i zaloguj się ponownie.'); return; } fail(msg.error || 'Autoryzacja WebSocket nie powiodła się.'); return; } if (msg.type === 'queue.status') { isSearching = msg.status === 'searching'; updateButtons(); if (msg.status === 'searching') { const suffix = Number.isFinite(Number(msg.queueSize)) ? ` (${msg.queueSize} w kolejce)` : ''; setStatus('Szukam przeciwnika…' + suffix); } else { setStatus('Gotowy do wyszukania meczu.'); } return; } if (msg.type === 'error') { isSearching = false; updateButtons(); if (msg.error === 'duplicate_session') { fail('To konto jest już połączone z trybem 1v1 w innej karcie lub przeglądarce. Zamknij tamtą sesję i spróbuj ponownie.'); return; } if (msg.error === 'already_in_match') { fail('To konto jest już w aktywnym meczu 1v1.'); return; } setStatus('Nie udało się dołączyć do kolejki.'); fail(msg.error || 'Serwer odrzucił żądanie.'); return; } if (msg.type === 'match.found') { matchId = msg.matchId; side = msg.side; matchMeta = { matchId, side, opponentUserId: msg.opponentUserId || null, opponentUsername: msg.opponentUsername || 'przeciwnik', warmupEndsAt: msg.warmupEndsAt || (Date.now() + 10_000), pointsToWin: msg.pointsToWin || 11, setsToWin: msg.setsToWin || 3, }; lastSentTargetY = null; isSearching = false; renderState = null; lastRenderAt = 0; localStorage.setItem('pp1v1.matchId', matchId); updateButtons(); setStatus(`Mecz znaleziony z ${matchMeta.opponentUsername}.`); startMatchCountdown(matchMeta); return; } if (msg.type === 'match.state') { if (lastState) { const scoreChanged = lastState.scoreL !== msg.state.scoreL || lastState.scoreR !== msg.state.scoreR; const vxFlipped = Math.sign(lastState.ball.vx || 0) !== Math.sign(msg.state.ball.vx || 0); const vyFlipped = Math.sign(lastState.ball.vy || 0) !== Math.sign(msg.state.ball.vy || 0); if (scoreChanged || vxFlipped || vyFlipped) { playSound('kick'); } } lastState = msg.state; lastStateAt = Date.now(); if (!renderState) { renderState = cloneRenderState(lastState); lastRenderAt = 0; } if (msg.state?.warmupEndsAt) { matchMeta = { ...matchMeta, matchId: msg.matchId, side, warmupEndsAt: msg.state.warmupEndsAt, pointsToWin: msg.state.pointsToWin, setsToWin: msg.state.setsToWin, }; if (msg.state.isWarmup && side) { startMatchCountdown(matchMeta); } else if (!msg.state.isWarmup && el.overlay.dataset.mode === 'countdown') { clearCountdownTimer(); hideOverlay(); } } el.score.textContent = formatMatchScore(lastState); return; } if (msg.type === 'match.end') { isSearching = false; lastEndPayload = msg.payload ?? null; renderState = null; lastRenderAt = 0; matchId = null; setStatus('Mecz zakończony.'); updateButtons(); playSound(didWinLastMatch() ? 'win' : 'lose'); startPostMatchAnimation(msg.payload); return; } if (msg.type === 'rewards.queued') { const jobId = msg.response?.jobId; if (jobId) { rewardsJobId = jobId; pollRewards(jobId); } return; } if (msg.type === 'rewards.error') { rewardPollState = 'failed'; } }); ws.addEventListener('close', async () => { isConnected = false; updateButtons(); if (!settled && !helloReceived) { fail('Nie udało się zestawić połączenia WebSocket. Publiczny adres /ping-pong-1v1 zwraca 404 albo nie jest podpięty do serwera Node.'); return; } if (manualClose) { manualClose = false; return; } setStatus('Rozłączono. Próba ponownego połączenia…'); for (let i = 0; i < 5; i++) { await new Promise(r => setTimeout(r, 500 + i * 700)); try { await connect(); return; } catch { // keep trying } } showOverlay('Połączenie', 'Nie udało się połączyć z serwerem.', [ { label: 'Wróć do menu', onClick: () => window.location.href = '/disciplines/ping-pong/' } ]); }); }); } finally { isConnecting = false; } } function wireButtons() { updateButtons(); el.btnFind.addEventListener('click', async () => { if (matchId || isSearching) return; if (!isConnected || !ws || ws.readyState !== WebSocket.OPEN) { pendingFind = true; try { await ensureConnected(); } catch (e) { pendingFind = false; showOverlay('Błąd', String(e.message || e), [{ label: 'OK', onClick: () => hideOverlay() }]); } return; } joinQueue(); }); el.btnLeave.addEventListener('click', () => { if (isSearching && !matchId) { leaveQueue(); return; } manualClose = true; if (ws && ws.readyState === WebSocket.OPEN) { if (isSearching) send({ type: 'queue.leave' }); ws.close(1000, 'user_left'); } mouseAimY = null; mouseControlArmed = false; lastSentTargetY = null; localStorage.removeItem('pp1v1.matchId'); window.location.href = '/disciplines/ping-pong/'; }); window.addEventListener('beforeunload', () => { if (isSearching) { send({ type: 'queue.leave' }); } }); } window.addEventListener('resize', resizeCanvas); resizeCanvas(); setupInput(); wireButtons(); requestAnimationFrame(draw); showOverlay('Ping-Pong 1v1', 'Połącz się z serwerem i znajdź mecz. Po znalezieniu przeciwnika zagrasz mecz 3 setowy do 11 punktów.', [ { label: 'Połącz', onClick: () => connect().catch(e => showOverlay('Błąd', String(e.message || e), [{ label: 'OK', onClick: () => hideOverlay() }])) }, { label: 'Wróć', secondary: true, onClick: () => window.location.href = '/disciplines/ping-pong/' }, ], { badge: 'Tryb online', stage: 'Matchmaking 1v1', gridItems: [ { label: 'Sterowanie', value: 'Myszka albo W/S albo strzałki' }, { label: 'Mecz', value: 'Do 11 punktów', tone: 'gold' }, { label: 'Sety', value: 'Do 3 wygranych', tone: 'gold' }, { label: 'Po meczu', value: 'Wygrywasz nagrodę całej puli!' }, ], }); })();