(() => { 'use strict'; // ── CLEAR STALE MATCH STATE ON PAGE LOAD ────────────────────────────────── // After server restart, old matchId in localStorage points to non-existent match. // Always clear on load to prevent "ghost matches" after PM2 restart or reconnect failures. localStorage.removeItem('pp1v1.matchId'); 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 PLAYER_SUMMARY_URL = window.PP1V1_PLAYER_SUMMARY_URL || '/api/matches/ping-pong/1v1/player-summary.php'; const CURRENT_USER = window.PP1V1_CURRENT_USER || {}; 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'), playerAvatar: document.getElementById('playerAvatar'), opponentAvatar: document.getElementById('opponentAvatar'), playerName: document.getElementById('playerName'), playerRole: document.getElementById('playerRole'), playerConnStatus: document.getElementById('playerConnStatus'), playerMeta: document.getElementById('playerMeta'), playerHighlights: document.getElementById('playerHighlights'), opponentFooterName: document.getElementById('opponentFooterName'), opponentFooterState: document.getElementById('opponentFooterState'), opponentFooterMeta: document.getElementById('opponentFooterMeta'), opponentFooterStats: document.getElementById('opponentFooterStats'), 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'), wrap: document.getElementById('wrap'), }; 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 playerSummary = null; let currentQueueSize = null; let mouseAimY = null; let mouseControlArmed = false; let keyUp = false; let keyDown = false; let move = 0; let lastSentTargetY = null; let seq = 0; let bgMusic = null; let rewardsJobId = null; let pollTimer = null; let countdownTimer = null; let countdownToken = null; let lobbyTimer = null; let postMatchTimer = null; let rewardPollState = 'idle'; let matchMeta = null; let setBreakUntil = 0; let setBreakInfo = null; // Connection quality tracking let opponentConnStatus = null; // 'connected' | 'disconnected' | 'weak' let opponentPingMs = null; let ownPingMs = null; let ownPingStatus = null; // 'connected' | 'weak' let pingTimer = null; let lastPingSentAt = 0; const CONTROL_HINT = 'Sterowanie: myszka albo W/S albo strzałki.'; const REWARD_ANIMATION_MS = 6500; const KICK_DEBOUNCE_MS = 80; // min ms between kick sounds let lastKickAt = 0; 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; } // ─── OWN PING DISPLAY ──────────────────────────────────────────────────── function classifyPing(rttMs) { if (!Number.isFinite(rttMs)) return 'connected'; if (rttMs > 300) return 'weak'; if (rttMs > 150) return 'weak'; return 'connected'; } function updateOwnConnStatus() { if (!el.playerConnStatus) return; if (!matchId || ownPingMs == null) { el.playerConnStatus.hidden = true; el.playerConnStatus.className = 'player-conn-status'; return; } const st = classifyPing(ownPingMs); ownPingStatus = st; el.playerConnStatus.hidden = false; el.playerConnStatus.className = `player-conn-status state-${st}`; el.playerConnStatus.textContent = st === 'weak' ? `Słaby sygnał (${ownPingMs} ms)` : `${ownPingMs} ms`; el.playerConnStatus.title = `Twoje opóźnienie: ${ownPingMs} ms`; } function resetConnStatus() { opponentConnStatus = null; opponentPingMs = null; ownPingMs = null; ownPingStatus = null; if (el.playerConnStatus) { el.playerConnStatus.hidden = true; el.playerConnStatus.className = 'player-conn-status'; } // Reset canvas border if (canvas) { canvas.classList.remove('opponent-disconnected', 'opponent-weak', 'opponent-connected'); } } function updateCanvasBorderStatus() { if (!canvas || !matchId) return; canvas.classList.remove('opponent-disconnected', 'opponent-weak', 'opponent-connected'); if (opponentConnStatus === 'disconnected') { canvas.classList.add('opponent-disconnected'); } else if (opponentConnStatus === 'weak' || (opponentPingMs != null && opponentPingMs > 150)) { canvas.classList.add('opponent-weak'); } else if (opponentConnStatus === 'connected' && matchId) { canvas.classList.add('opponent-connected'); } } // ─── PING INTERVAL ──────────────────────────────────────────────────────── function startPingInterval() { if (pingTimer) return; pingTimer = setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN && matchId) { lastPingSentAt = Date.now(); send({ type: 'ping', t: lastPingSentAt, rtt: ownPingMs }); } }, 3000); } function stopPingInterval() { if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } } function formatInteger(value) { return new Intl.NumberFormat('pl-PL').format(Number(value) || 0); } function formatCurrency(value) { return `${new Intl.NumberFormat('pl-PL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(Number(value) || 0)} Playons`; } function formatMemberSince(value) { if (!value) return 'konto aktywne'; const date = new Date(value); if (Number.isNaN(date.getTime())) return 'konto aktywne'; return `od ${date.toLocaleDateString('pl-PL', { year: 'numeric', month: 'long' })}`; } function normalizeDisplayName(value) { if (typeof value !== 'string') return ''; return value.trim(); } function getInitial(value, fallback = '?') { const text = normalizeDisplayName(value); if (!text) return fallback; return text.slice(0, 1).toUpperCase(); } function setAvatarElement(element, avatarUrl, fallbackText, altText) { if (!element) return; const safeFallback = getInitial(fallbackText, '?'); // Defensive constraints: global styles in legacy pages can override img sizing. element.style.setProperty('overflow', 'hidden', 'important'); element.style.setProperty('display', 'grid', 'important'); element.style.setProperty('place-items', 'center', 'important'); if (avatarUrl) { element.innerHTML = ''; const img = document.createElement('img'); img.src = avatarUrl; img.alt = altText || 'Avatar'; img.loading = 'lazy'; img.style.setProperty('width', '100%', 'important'); img.style.setProperty('height', '100%', 'important'); img.style.setProperty('min-width', '100%', 'important'); img.style.setProperty('min-height', '100%', 'important'); img.style.setProperty('max-width', '100%', 'important'); img.style.setProperty('max-height', '100%', 'important'); img.style.setProperty('object-fit', 'cover', 'important'); img.style.setProperty('object-position', 'center center', 'important'); img.style.setProperty('display', 'block', 'important'); img.addEventListener('error', () => { element.textContent = safeFallback; }, { once: true }); element.appendChild(img); return; } element.textContent = safeFallback; } function buildAvatarUrlByUserId(userId) { if (!Number.isFinite(Number(userId)) || Number(userId) <= 0) { return null; } return `/account/avatar.php?u=${encodeURIComponent(String(userId))}`; } function getOpponentName() { const username = normalizeDisplayName(matchMeta?.opponentUsername); if (username) { return username; } if (matchMeta?.opponentUserId != null) { return `Gracz #${matchMeta.opponentUserId}`; } return 'Przeciwnik'; } function getOpponentIdLabel() { if (matchMeta?.opponentUserId == null) { return '—'; } return String(matchMeta.opponentUserId); } function summarizeQueueState() { if (matchId) { return { value: 'Mecz aktywny', sub: 'Stół jest zajęty', tone: 'live' }; } if (isSearching) { return { value: currentQueueSize != null ? `${formatInteger(currentQueueSize)} w kolejce` : 'Szukam rywala', sub: 'Dobieramy przeciwnika 1v1', tone: 'live', }; } return { value: 'Gotowy', sub: 'Możesz wejść do kolejki', tone: 'live' }; } function renderPlayerSummary(summary) { playerSummary = summary || playerSummary; const effective = playerSummary || { userId: CURRENT_USER.userId || userId || null, username: CURRENT_USER.username || 'Gracz', role: CURRENT_USER.role || 'user', memberSince: '', email: CURRENT_USER.email || '', emailVerified: false, accountStatus: 'active', balance: 0, matchesPlayed: 0, matchesWon: 0, matchesLost: 0, tournamentsPlayed: 0, tournamentsWon: 0, leaguesParticipated: 0, totalTransactions: 0, winRate: 0, }; const username = effective.username || CURRENT_USER.username || 'Gracz'; const queueCard = summarizeQueueState(); const accountStatus = effective.accountStatus === 'active' ? 'konto aktywne' : `status: ${effective.accountStatus}`; setAvatarElement( el.playerAvatar, effective.avatarUrl || CURRENT_USER.avatarUrl || buildAvatarUrlByUserId(effective.userId || CURRENT_USER.userId), username, 'Twoj avatar' ); if (el.playerName) el.playerName.textContent = username; if (el.playerRole) { const rawRole = String(effective.role || CURRENT_USER.role || 'user'); el.playerRole.textContent = rawRole === 'user' ? 'Player' : rawRole.toUpperCase(); } if (el.playerMeta) { el.playerMeta.textContent = `ID #${effective.userId || userId || '—'} • ${formatMemberSince(effective.memberSince)} • ${accountStatus}`; } if (!el.playerHighlights) return; const cards = [ { label: 'Saldo', value: formatCurrency(effective.balance), sub: 'Stan konta', tone: 'money' }, { label: 'Mecze', value: formatInteger(effective.matchesPlayed), sub: `${formatInteger(effective.matchesWon)}W / ${formatInteger(effective.matchesLost)}P` }, { label: 'Win rate', value: `${Number(effective.winRate || 0).toFixed(1)}%`, sub: 'Skuteczność' }, { label: 'Matchmaking', value: queueCard.value, sub: queueCard.sub, tone: queueCard.tone }, ]; el.playerHighlights.innerHTML = ''; for (const card of cards) { const item = document.createElement('div'); item.className = `player-highlight${card.tone ? ` tone-${card.tone}` : ''}`; const label = document.createElement('div'); label.className = 'player-highlight-label'; label.textContent = card.label; const value = document.createElement('div'); value.className = 'player-highlight-value'; value.textContent = card.value; const sub = document.createElement('div'); sub.className = 'player-highlight-sub'; sub.textContent = card.sub; item.append(label, value, sub); el.playerHighlights.appendChild(item); } } function renderOpponentFooter() { const hasOpponent = Boolean(normalizeDisplayName(matchMeta?.opponentUsername) || matchMeta?.opponentUserId != null); const opponentName = hasOpponent ? getOpponentName() : '—'; const opponentId = hasOpponent && matchMeta?.opponentUserId != null ? `#${matchMeta.opponentUserId}` : '—'; const opponentAvatarUrl = hasOpponent ? buildAvatarUrlByUserId(matchMeta?.opponentUserId) : null; setAvatarElement(el.opponentAvatar, opponentAvatarUrl, opponentName, 'Avatar przeciwnika'); // Determine display state and CSS class for the connection badge let opponentStateText; let opponentStateClass; if (!hasOpponent) { opponentStateText = '—'; opponentStateClass = ''; } else if (opponentConnStatus === 'disconnected') { opponentStateText = 'Odłączony'; opponentStateClass = 'state-disconnected'; } else if (opponentConnStatus === 'weak' || (opponentPingMs != null && opponentPingMs > 150)) { const ms = opponentPingMs != null ? ` (${opponentPingMs} ms)` : ''; opponentStateText = `Słaby sygnał${ms}`; opponentStateClass = 'state-weak'; } else if (matchId) { opponentStateText = 'Połączony'; opponentStateClass = 'state-connected'; } else { opponentStateText = hasOpponent ? 'Dobierany' : '—'; opponentStateClass = ''; } const matchState = hasOpponent && matchId ? 'Aktywny' : '—'; if (el.opponentFooterName) { el.opponentFooterName.textContent = opponentName; el.opponentFooterName.title = hasOpponent ? opponentName : 'Brak przeciwnika'; } if (el.opponentFooterState) { el.opponentFooterState.textContent = opponentStateText; el.opponentFooterState.className = `opponent-footer-state${opponentStateClass ? ' ' + opponentStateClass : ''}`; el.opponentFooterState.title = hasOpponent ? `Status przeciwnika: ${opponentStateText}` : 'Status przeciwnika'; } if (el.opponentFooterMeta) { el.opponentFooterMeta.textContent = hasOpponent ? `${opponentId} • dane przeciwnika zostały przypisane do aktywnego meczu.` : 'Pola uzupełnią się po dobraniu przeciwnika.'; } if (!el.opponentFooterStats) return; const cards = [ { label: 'Nick', value: opponentName, sub: hasOpponent ? 'Aktywny rywal' : 'Puste pole' }, { label: 'ID', value: opponentId, sub: hasOpponent ? 'Identyfikator konta' : 'Puste pole' }, { label: 'Status', value: opponentStateText, sub: hasOpponent ? 'Połączenie meczu' : 'Puste pole', tone: opponentStateClass === 'state-disconnected' ? 'danger' : opponentStateClass === 'state-weak' ? 'warn' : '' }, { label: 'Mecz', value: matchState, sub: hasOpponent ? 'Stan pojedynku' : 'Puste pole' }, ]; el.opponentFooterStats.innerHTML = ''; for (const card of cards) { const item = document.createElement('div'); item.className = `opponent-card${card.tone ? ` tone-${card.tone}` : ''}`; const label = document.createElement('div'); label.className = 'opponent-card-label'; label.textContent = card.label; const value = document.createElement('div'); value.className = 'opponent-card-value'; value.textContent = card.value; const sub = document.createElement('div'); sub.className = 'opponent-card-sub'; sub.textContent = card.sub; item.append(label, value, sub); el.opponentFooterStats.appendChild(item); } updateCanvasBorderStatus(); } async function loadPlayerSummary() { try { const response = await fetch(PLAYER_SUMMARY_URL, { credentials: 'include' }); const json = await response.json().catch(() => null); if (!response.ok || !json?.success || !json?.data) { throw new Error(json?.error || `Summary error ${response.status}`); } renderPlayerSummary(json.data); } catch { renderPlayerSummary(null); } } function updateBadge() { const ownName = normalizeDisplayName(playerSummary?.username) || normalizeDisplayName(CURRENT_USER.username) || 'Ty'; const ownLabel = `${ownName} #${userId ?? '—'}`; el.badge.textContent = `Ty: ${ownLabel}`; el.badge.title = `Ty: ${ownLabel}`; renderOpponentFooter(); } 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 = 'panel-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: normalizeDisplayName(meta.opponentUsername) || (meta.opponentUserId != null ? `Gracz #${meta.opponentUserId}` : 'Łączenie…') }, { label: 'ID przeciwnika', value: meta.opponentUserId ?? '—' }, { label: 'Twoja strona', value: sideInfo.label, tone: sideInfo.tone }, { label: 'Kolor prowadzący', value: sideInfo.color, tone: sideInfo.tone }, { label: 'Sterowanie', value: 'Myszka albo W/S albo strzałki' }, { label: 'Sety meczu', value: `do ${meta.setsToWin ?? 3} wygranych`, tone: 'gold' }, { label: 'Punkty seta', value: `do ${meta.pointsToWin ?? 11}`, tone: 'gold' }, { label: 'Nagroda', value: 'Rozliczenie wraca na konto po meczu', tone: 'gold' }, ]; } function translateMatchEndReason(reason) { const reasons = { 'sets': 'Wygrana w setach', 'both_disconnect': 'Obaj gracze rozłączyli się', 'forfeit_left': 'Lewy gracz się rozłączył (automatyczna wygrana)', 'forfeit_right': 'Prawy gracz się rozłączył (automatyczna wygrana)', 'disconnect_timeout_left': 'Rozłączenie gracza trwało ponad 10 sekund (remis)', 'disconnect_timeout_right': 'Rozłączenie gracza trwało ponad 10 sekund (remis)', }; return reasons[reason] || reason || 'Koniec meczu'; } function buildPostMatchGrid(payload, outcome) { const didDraw = outcome === 'draw'; const didWin = outcome === 'win'; const opponentUsername = didDraw ? (normalizeDisplayName(matchMeta?.opponentUsername) || (matchMeta?.opponentUserId != null ? `Gracz #${matchMeta.opponentUserId}` : 'Przeciwnik')) : (didWin ? (normalizeDisplayName(payload?.loserUsername) || normalizeDisplayName(matchMeta?.opponentUsername) || (matchMeta?.opponentUserId != null ? `Gracz #${matchMeta.opponentUserId}` : 'Przeciwnik')) : (normalizeDisplayName(payload?.winnerUsername) || normalizeDisplayName(matchMeta?.opponentUsername) || (matchMeta?.opponentUserId != null ? `Gracz #${matchMeta.opponentUserId}` : 'Przeciwnik'))); const opponentUserId = didDraw ? (matchMeta?.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: translateMatchEndReason(payload?.reason), tone: didDraw ? 'gold' : (didWin ? 'blue' : 'pink') }, ]; } function returnToLobby() { clearCountdownTimer(); clearLobbyTimer(); clearPostMatchTimer(); if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } stopPingInterval(); resetConnStatus(); localStorage.removeItem('pp1v1.matchId'); rewardsJobId = null; matchMeta = null; matchId = null; side = null; setBreakUntil = 0; setBreakInfo = null; el.wrap?.classList.remove('side-left', 'side-right'); 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ę', `Przeciwnik jest potwierdzony. Wejdź na środek ekranu, złap rytm i ustaw rękę pod pierwszą wymianę z ${normalizeDisplayName(matchMeta.opponentUsername) || (matchMeta.opponentUserId != null ? `graczem #${matchMeta.opponentUserId}` : 'rywalem')}.`, [], { mode: 'countdown', badge: 'Przygotuj się', stage: `Stoły gotowe • grasz ${sideInfo.color} po stronie ${sideInfo.label} • start za moment`, heroNumber: '10', heroLabel: 'Sekund do wejścia w pierwszy serwis. Ustaw paletkę, sprawdź stronę i przygotuj się na otwarcie meczu.', 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 ? 'Ostatnia sekunda. Utrzymaj środek i wejdź w pierwszy ruch.' : 'Countdown do rozpoczęcia wymiany. Złap pozycję i przygotuj reakcję na pierwszy serwis.' ); setOverlayProgress(1 - (remainingMs / totalMs)); setOverlayStage(`Grasz kolorem ${sideInfo.color} po stronie ${sideInfo.label} • przeciwnik: ${normalizeDisplayName(matchMeta?.opponentUsername) || (matchMeta?.opponentUserId != null ? `gracz #${matchMeta.opponentUserId}` : 'rywal')}.`); if (remainingMs <= 0) { clearCountdownTimer(); hideOverlay(); startBgMusic(); setStatus(`Mecz trwa z ${normalizeDisplayName(matchMeta?.opponentUsername) || (matchMeta?.opponentUserId != null ? `graczem #${matchMeta.opponentUserId}` : 'przeciwnikiem')}.`); } }; updateCountdown(); countdownTimer = setInterval(updateCountdown, 100); } function startPostMatchAnimation(payload) { const outcome = didDrawLastMatch() ? 'draw' : (didWinLastMatch() ? 'win' : 'lose'); const didWin = outcome === 'win'; const didDraw = outcome === 'draw'; const title = didDraw ? 'Remis' : (didWin ? 'Zwycięstwo!' : 'Porażka'); const opponentUsername = didDraw ? (normalizeDisplayName(matchMeta?.opponentUsername) || (matchMeta?.opponentUserId != null ? `Gracz #${matchMeta.opponentUserId}` : 'Przeciwnik')) : (didWin ? (normalizeDisplayName(payload?.loserUsername) || normalizeDisplayName(matchMeta?.opponentUsername) || (matchMeta?.opponentUserId != null ? `Gracz #${matchMeta.opponentUserId}` : 'Przeciwnik')) : (normalizeDisplayName(payload?.winnerUsername) || normalizeDisplayName(matchMeta?.opponentUsername) || (matchMeta?.opponentUserId != null ? `Gracz #${matchMeta.opponentUserId}` : 'Przeciwnik'))); const heroScore = `${payload?.sets?.left ?? 0}:${payload?.sets?.right ?? 0}`; const stages = didDraw ? ['Potwierdzanie remisu', 'Zwrot stawek', 'Zamykanie stołu', 'Powrót do lobby 1v1'] : (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, didDraw ? `Mecz zakończył się remisem po rozłączeniu trwającym ponad 10 sekund. Otrzymujecie zwrot stawki. Za chwilę wrócisz automatycznie do lobby 1v1.` : `${didWin ? 'Pokonałeś' : 'Przegrałeś z'} ${opponentUsername}. Za chwilę wrócisz automatycznie do lobby 1v1.`, [], { mode: didDraw ? 'rewards' : (didWin ? 'victory' : 'defeat'), badge: didDraw ? 'Remis' : (didWin ? 'Wygrana' : 'Porażka'), stage: stages[0], heroNumber: heroScore, heroLabel: 'wynik setów', progress: { value: 0.05 }, gridItems: buildPostMatchGrid(payload, outcome), 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); } const BG_MUSIC_COUNT = 3; let bgMusicLastIdx = -1; function pickBgMusicIdx() { if (BG_MUSIC_COUNT <= 1) return 1; let next; do { next = Math.floor(Math.random() * BG_MUSIC_COUNT) + 1; } while (next === bgMusicLastIdx); bgMusicLastIdx = next; return next; } function startBgMusic() { stopBgMusic(); const idx = pickBgMusicIdx(); const src = `${SOUND_BASE}/onlinePingPong${idx}.mp3`; try { const clip = new Audio(src); clip.volume = 0.5; clip.addEventListener('ended', () => { bgMusic = null; startBgMusic(); }, { once: true }); clip.play().catch(() => {}); bgMusic = clip; } catch { /* ignore */ } } function stopBgMusic() { if (bgMusic) { bgMusic.pause(); bgMusic.currentTime = 0; bgMusic = null; } } 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 didDrawLastMatch() { return !!lastEndPayload && lastEndPayload.isDraw === true; } 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; currentQueueSize = null; pendingFind = false; updateButtons(); renderPlayerSummary(playerSummary); send({ type: 'queue.leave' }); setStatus('Wyszukiwanie anulowane.'); } function notifyIntentionalMatchLeave() { if (!matchId) return; send({ type: 'match.leave', reason: 'user_left' }); } 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; // Clamp always — even outside canvas the paddle should go to the nearest edge 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; }); // Track pointer on the whole window so the paddle continues to edge // when the cursor leaves the canvas area. window.addEventListener('pointermove', (event) => { if (mouseControlArmed) updateMouseAim(event); }); window.addEventListener('mousemove', (event) => { if (mouseControlArmed) updateMouseAim(event); }); canvas.addEventListener('pointermove', (event) => { updateMouseAim(event); }); canvas.addEventListener('pointerenter', (event) => { updateMouseAim(event); }); canvas.addEventListener('mousemove', (event) => { updateMouseAim(event); }); canvas.addEventListener('pointerdown', (event) => { updateMouseAim(event); }); // Do NOT reset mouseAimY on leave — keep last clamped position so the // paddle holds at top or bottom until the cursor comes back. canvas.addEventListener('mouseleave', () => { // intentionally empty }); canvas.addEventListener('pointerleave', () => { // intentionally empty }); 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(); // Set-break countdown drawn directly on the canvas (no overlay dimming) if (setBreakUntil && Date.now() < setBreakUntil) { const remaining = Math.ceil((setBreakUntil - Date.now()) / 1000); const setNum = setBreakInfo?.currentSet ?? 1; const sL = setBreakInfo?.sets?.left ?? 0; const sR = setBreakInfo?.sets?.right ?? 0; ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const bigFont = Math.floor(Math.min(w, h) * 0.3); ctx.font = `900 ${bigFont}px Lato, sans-serif`; ctx.shadowBlur = 40; ctx.shadowColor = '#00fff7'; ctx.fillStyle = '#00fff7'; ctx.fillText(String(remaining), w / 2, h / 2 - h * 0.06); ctx.shadowBlur = 0; const smallFont = Math.floor(Math.min(w, h) * 0.07); ctx.font = `bold ${smallFont}px Lato, sans-serif`; ctx.fillStyle = 'rgba(223,252,255,0.85)'; const bottomLabel = setBreakInfo?.isPreStart ? 'Mecz zaraz się rozpocznie' : `Set ${setBreakInfo?.currentSet ?? 1} • ${setBreakInfo?.sets?.left ?? 0} : ${setBreakInfo?.sets?.right ?? 0}`; ctx.fillText(bottomLabel, w / 2, h / 2 + h * 0.18); 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; loadPlayerSummary().catch(() => {}); 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; lastState = null; renderState = null; lastRenderAt = 0; setBreakUntil = 0; setBreakInfo = null; matchMeta = { matchId: msg.matchId, side: msg.side || side, opponentUserId: msg.opponentUserId || null, opponentUsername: normalizeDisplayName(msg.opponentUsername) || (msg.opponentUserId != null ? `Gracz #${msg.opponentUserId}` : 'Przeciwnik'), warmupEndsAt: msg.warmupEndsAt || (Date.now() + 10_000), pointsToWin: msg.pointsToWin || 11, setsToWin: msg.setsToWin || 3, }; el.wrap?.classList.remove('side-left', 'side-right'); if (side) el.wrap?.classList.add(`side-${side}`); updateBadge(); opponentConnStatus = null; opponentPingMs = null; startPingInterval(); if ((msg.warmupEndsAt || 0) > Date.now()) { startMatchCountdown(matchMeta); } else { // Match already running — hide overlay and resume game immediately hideOverlay(); if (!bgMusic) startBgMusic(); setStatus(`Mecz trwa z ${normalizeDisplayName(matchMeta.opponentUsername) || 'przeciwnikiem'}.`); } return; } if (msg.type === 'match.snapshot') { // Sent when match lives on a different worker, or match has already ended const snap = msg.snapshot; if (snap?.ended) { // Match ended while we were disconnected — clear stale state localStorage.removeItem('pp1v1.matchId'); if (matchId) { matchId = null; side = null; matchMeta = null; setBreakUntil = 0; setBreakInfo = null; renderState = null; lastState = null; updateBadge(); updateButtons(); setStatus('Mecz zakończony podczas rozłączenia.'); } } else if (snap?.matchId) { // Match still running on another worker — state will arrive via match.state matchId = snap.matchId; } 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'; currentQueueSize = Number.isFinite(Number(msg.queueSize)) ? Number(msg.queueSize) : null; updateButtons(); renderPlayerSummary(playerSummary); if (msg.status === 'searching') { const suffix = Number.isFinite(Number(msg.queueSize)) ? ` (${msg.queueSize} w kolejce)` : ''; setStatus('Szukam przeciwnika…' + suffix); } else { currentQueueSize = null; renderPlayerSummary(playerSummary); 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; el.wrap?.classList.remove('side-left', 'side-right'); if (side) el.wrap?.classList.add(`side-${side}`); matchMeta = { matchId, side, opponentUserId: msg.opponentUserId || null, opponentUsername: normalizeDisplayName(msg.opponentUsername) || (msg.opponentUserId != null ? `Gracz #${msg.opponentUserId}` : 'Przeciwnik'), warmupEndsAt: msg.warmupEndsAt || (Date.now() + 10_000), pointsToWin: msg.pointsToWin || 11, setsToWin: msg.setsToWin || 3, }; lastSentTargetY = null; isSearching = false; currentQueueSize = null; renderState = null; lastRenderAt = 0; opponentConnStatus = null; opponentPingMs = null; localStorage.setItem('pp1v1.matchId', matchId); updateButtons(); renderPlayerSummary(playerSummary); setStatus(`Mecz znaleziony z ${matchMeta.opponentUsername}.`); startMatchCountdown(matchMeta); startPingInterval(); return; } if (msg.type === 'match.state') { if (lastState) { const scoreChanged = lastState.scoreL !== msg.state.scoreL || lastState.scoreR !== msg.state.scoreR; // Only detect flip when ball is actually moving (not during pendingReset/serve pause) const prevVx = lastState.ball.vx || 0; const curVx = msg.state.ball.vx || 0; const prevVy = lastState.ball.vy || 0; const curVy = msg.state.ball.vy || 0; const ballMoving = Math.abs(curVx) > 0.01 || Math.abs(curVy) > 0.01; const vxFlipped = ballMoving && prevVx !== 0 && Math.sign(prevVx) !== Math.sign(curVx); const vyFlipped = ballMoving && prevVy !== 0 && Math.sign(prevVy) !== Math.sign(curVy); if (scoreChanged || vxFlipped || vyFlipped) { const now = Date.now(); if (now - lastKickAt >= KICK_DEBOUNCE_MS) { lastKickAt = now; 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(); startBgMusic(); } } el.score.textContent = formatMatchScore(lastState); return; } if (msg.type === 'match.set_break') { setBreakUntil = Date.now() + (msg.breakDurationMs || 3000); setBreakInfo = { sets: msg.sets || { left: 0, right: 0 }, currentSet: msg.currentSet || 1, isPreStart: !!msg.isPreStart, }; return; } if (msg.type === 'match.end') { isSearching = false; lastEndPayload = msg.payload ?? null; renderState = null; lastRenderAt = 0; matchId = null; setBreakUntil = 0; setBreakInfo = null; stopPingInterval(); resetConnStatus(); setStatus('Mecz zakończony.'); updateButtons(); loadPlayerSummary().catch(() => {}); stopBgMusic(); playSound(didDrawLastMatch() ? 'win' : (didWinLastMatch() ? 'win' : 'lose')); startPostMatchAnimation(msg.payload); return; } if (msg.type === 'rewards.done') { // Direct MySQL write succeeded — stats are already in DB rewardPollState = 'done'; loadPlayerSummary().catch(() => {}); return; } if (msg.type === 'rewards.queued') { const jobId = msg.response?.jobId; if (jobId) { rewardsJobId = jobId; // If PHP processed inline, status may already be 'done' if (msg.response?.status === 'done') { rewardPollState = 'done'; loadPlayerSummary().catch(() => {}); } else { pollRewards(jobId); } } return; } if (msg.type === 'rewards.error') { rewardPollState = 'failed'; } if (msg.type === 'pong') { if (typeof msg.t === 'number') { ownPingMs = Math.round(Date.now() - msg.t); updateOwnConnStatus(); } return; } if (msg.type === 'opponent.ping') { if (typeof msg.pingMs === 'number' && Number.isFinite(msg.pingMs)) { opponentPingMs = Math.round(msg.pingMs); // Only override a 'disconnected' status if the ping update confirms activity if (opponentConnStatus !== 'disconnected') { opponentConnStatus = opponentPingMs > 150 ? 'weak' : 'connected'; } renderOpponentFooter(); } return; } if (msg.type === 'opponent.status') { opponentConnStatus = msg.status; // 'connected' | 'disconnected' | 'weak' if (msg.status === 'connected') opponentPingMs = null; renderOpponentFooter(); return; } }); 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; // Unlock browser autoplay policy on first user gesture Object.values(audio).forEach(clip => { clip.play().catch(() => {}); clip.pause(); clip.currentTime = 0; }); const unlockBg = new Audio(`${SOUND_BASE}/onlinePingPong1.mp3`); unlockBg.volume = 0; unlockBg.play().catch(() => {}); unlockBg.pause(); 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' }); notifyIntentionalMatchLeave(); ws.close(1000, 'user_left'); } mouseAimY = null; mouseControlArmed = false; lastSentTargetY = null; localStorage.removeItem('pp1v1.matchId'); window.location.href = '/disciplines/ping-pong/'; }); window.addEventListener('beforeunload', () => { manualClose = true; if (isSearching) { send({ type: 'queue.leave' }); } notifyIntentionalMatchLeave(); try { if (ws && ws.readyState === WebSocket.OPEN) { ws.close(1000, 'page_unload'); } } catch { // ignore unload race } }); } window.addEventListener('resize', resizeCanvas); resizeCanvas(); setupInput(); wireButtons(); renderPlayerSummary(null); loadPlayerSummary().catch(() => {}); requestAnimationFrame(draw); showOverlay('Ping-Pong 1v1', 'Masz już pod ręką najważniejsze dane całego konta. Połącz się z serwerem, wejdź do kolejki i rozpocznij mecz rankingowy 1v1 do 3 setó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: 'Tempo startu', value: '10 sekund na ustawienie pozycji', tone: 'gold' }, { label: 'Format', value: 'Best of 5 • set do 11', tone: 'gold' }, { label: 'Po meczu', value: 'Nagrody i statystyki wracają na konto' }, ], }); })();