1692 lines
62 KiB
JavaScript
1692 lines
62 KiB
JavaScript
(() => {
|
|
'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' },
|
|
],
|
|
});
|
|
})();
|