togethere.cloud/private_html/disciplines/ping-pong/1v1/js/online.js

1067 lines
36 KiB
JavaScript

(() => {
'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!' },
],
});
})();