v3.1.0: Invite-gated auth, Glicko-2 ratings, matchmaking queue
- Enforce invite codes on registration (INVITE_ONLY=true by default) - Bootstrap admin account for first-time setup - Require authentication for WebSocket connections and room creation - Add Glicko-2 rating system with multiplayer pairwise comparisons - Add Redis-backed matchmaking queue with expanding rating window - Auto-start matched games with standard rules after countdown - Add "Find Game" button and matchmaking UI to client - Add rating column to leaderboard - Scale down docker-compose.prod.yml for 512MB droplet Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
440
client/app.js
440
client/app.js
@@ -79,8 +79,7 @@ class GolfGame {
|
||||
const roomCode = params.get('room');
|
||||
if (roomCode) {
|
||||
this.roomCodeInput.value = roomCode.toUpperCase();
|
||||
// Focus name input so user can quickly enter name and join
|
||||
this.playerNameInput.focus();
|
||||
this.roomCodeInput.focus();
|
||||
// Clean up URL without reloading
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
@@ -367,16 +366,24 @@ class GolfGame {
|
||||
initElements() {
|
||||
// Screens
|
||||
this.lobbyScreen = document.getElementById('lobby-screen');
|
||||
this.matchmakingScreen = document.getElementById('matchmaking-screen');
|
||||
this.waitingScreen = document.getElementById('waiting-screen');
|
||||
this.gameScreen = document.getElementById('game-screen');
|
||||
|
||||
// Lobby elements
|
||||
this.playerNameInput = document.getElementById('player-name');
|
||||
this.roomCodeInput = document.getElementById('room-code');
|
||||
this.findGameBtn = document.getElementById('find-game-btn');
|
||||
this.createRoomBtn = document.getElementById('create-room-btn');
|
||||
this.joinRoomBtn = document.getElementById('join-room-btn');
|
||||
this.lobbyError = document.getElementById('lobby-error');
|
||||
|
||||
// Matchmaking elements
|
||||
this.matchmakingStatus = document.getElementById('matchmaking-status');
|
||||
this.matchmakingTime = document.getElementById('matchmaking-time');
|
||||
this.matchmakingQueueInfo = document.getElementById('matchmaking-queue-info');
|
||||
this.cancelMatchmakingBtn = document.getElementById('cancel-matchmaking-btn');
|
||||
this.matchmakingTimer = null;
|
||||
|
||||
// Waiting room elements
|
||||
this.displayRoomCode = document.getElementById('display-room-code');
|
||||
this.copyRoomCodeBtn = document.getElementById('copy-room-code');
|
||||
@@ -470,6 +477,8 @@ class GolfGame {
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.findGameBtn?.addEventListener('click', () => { this.playSound('click'); this.findGame(); });
|
||||
this.cancelMatchmakingBtn?.addEventListener('click', () => { this.playSound('click'); this.cancelMatchmaking(); });
|
||||
this.createRoomBtn.addEventListener('click', () => { this.playSound('click'); this.createRoom(); });
|
||||
this.joinRoomBtn.addEventListener('click', () => { this.playSound('click'); this.joinRoom(); });
|
||||
this.startGameBtn.addEventListener('click', () => { this.playSound('success'); this.startGame(); });
|
||||
@@ -502,9 +511,6 @@ class GolfGame {
|
||||
});
|
||||
|
||||
// Enter key handlers
|
||||
this.playerNameInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.createRoomBtn.click();
|
||||
});
|
||||
this.roomCodeInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.joinRoomBtn.click();
|
||||
});
|
||||
@@ -612,7 +618,13 @@ class GolfGame {
|
||||
connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host || 'localhost:8000';
|
||||
const wsUrl = `${protocol}//${host}/ws`;
|
||||
let wsUrl = `${protocol}//${host}/ws`;
|
||||
|
||||
// Attach auth token if available
|
||||
const token = this.authManager?.token;
|
||||
if (token) {
|
||||
wsUrl += `?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
@@ -636,6 +648,14 @@ class GolfGame {
|
||||
};
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null; // Prevent error message on intentional close
|
||||
this.ws.close();
|
||||
}
|
||||
this.connect();
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
@@ -682,6 +702,7 @@ class GolfGame {
|
||||
case 'round_started':
|
||||
// Clear any countdown from previous hole
|
||||
this.clearNextHoleCountdown();
|
||||
this.dismissScoresheetModal();
|
||||
this.nextRoundBtn.classList.remove('waiting');
|
||||
// Clear round winner highlights
|
||||
this.roundWinnerNames = new Set();
|
||||
@@ -867,15 +888,80 @@ class GolfGame {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'queue_joined':
|
||||
this.showScreen('matchmaking');
|
||||
this.startMatchmakingTimer();
|
||||
this.updateMatchmakingStatus(data);
|
||||
break;
|
||||
|
||||
case 'queue_status':
|
||||
this.updateMatchmakingStatus(data);
|
||||
break;
|
||||
|
||||
case 'queue_matched':
|
||||
this.stopMatchmakingTimer();
|
||||
if (this.matchmakingStatus) {
|
||||
this.matchmakingStatus.textContent = 'Match found!';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'queue_left':
|
||||
this.stopMatchmakingTimer();
|
||||
this.showScreen('lobby');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
this.showError(data.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Matchmaking
|
||||
findGame() {
|
||||
this.connect();
|
||||
this.ws.onopen = () => {
|
||||
this.send({ type: 'queue_join' });
|
||||
};
|
||||
}
|
||||
|
||||
cancelMatchmaking() {
|
||||
this.send({ type: 'queue_leave' });
|
||||
this.stopMatchmakingTimer();
|
||||
this.showScreen('lobby');
|
||||
}
|
||||
|
||||
startMatchmakingTimer() {
|
||||
this.matchmakingStartTime = Date.now();
|
||||
this.stopMatchmakingTimer();
|
||||
this.matchmakingTimer = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - this.matchmakingStartTime) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
if (this.matchmakingTime) {
|
||||
this.matchmakingTime.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
stopMatchmakingTimer() {
|
||||
if (this.matchmakingTimer) {
|
||||
clearInterval(this.matchmakingTimer);
|
||||
this.matchmakingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateMatchmakingStatus(data) {
|
||||
if (this.matchmakingQueueInfo) {
|
||||
const parts = [];
|
||||
if (data.queue_size) parts.push(`${data.queue_size} player${data.queue_size !== 1 ? 's' : ''} in queue`);
|
||||
if (data.position) parts.push(`Position: #${data.position}`);
|
||||
this.matchmakingQueueInfo.textContent = parts.join(' \u2022 ');
|
||||
}
|
||||
}
|
||||
|
||||
// Room Actions
|
||||
createRoom() {
|
||||
const name = this.playerNameInput.value.trim() || 'Player';
|
||||
const name = this.authManager?.user?.username || 'Player';
|
||||
this.connect();
|
||||
this.ws.onopen = () => {
|
||||
this.send({ type: 'create_room', player_name: name });
|
||||
@@ -883,7 +969,7 @@ class GolfGame {
|
||||
}
|
||||
|
||||
joinRoom() {
|
||||
const name = this.playerNameInput.value.trim() || 'Player';
|
||||
const name = this.authManager?.user?.username || 'Player';
|
||||
const code = this.roomCodeInput.value.trim().toUpperCase();
|
||||
|
||||
if (code.length !== 4) {
|
||||
@@ -1509,20 +1595,260 @@ class GolfGame {
|
||||
}
|
||||
|
||||
showKnockBanner(playerName) {
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'knock-banner';
|
||||
banner.innerHTML = `<span>${playerName ? playerName + ' knocked!' : 'KNOCK!'}</span>`;
|
||||
document.body.appendChild(banner);
|
||||
const duration = window.TIMING?.knock?.statusDuration || 2500;
|
||||
const message = playerName ? `${playerName} KNOCKED!` : 'KNOCK!';
|
||||
|
||||
this.setStatus(message, 'knock');
|
||||
document.body.classList.add('screen-shake');
|
||||
|
||||
setTimeout(() => {
|
||||
banner.classList.add('fading');
|
||||
document.body.classList.remove('screen-shake');
|
||||
}, 800);
|
||||
setTimeout(() => {
|
||||
banner.remove();
|
||||
}, 1100);
|
||||
}, 300);
|
||||
|
||||
// Restore normal status after duration
|
||||
this._knockStatusTimeout = setTimeout(() => {
|
||||
// Only clear if still showing knock status
|
||||
if (this.statusMessage.classList.contains('knock')) {
|
||||
this.setStatus('Final turn!', 'opponent-turn');
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// --- V3_17: Scoresheet Modal ---
|
||||
|
||||
showScoresheetModal(scores, gameState, rankings) {
|
||||
// Remove existing modal if any
|
||||
const existing = document.getElementById('scoresheet-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
// Also update side panel data (silently)
|
||||
this.showScoreboard(scores, false, rankings);
|
||||
|
||||
const cardValues = gameState?.card_values || this.getDefaultCardValues();
|
||||
const scoringRules = gameState?.scoring_rules || {};
|
||||
const knockerId = gameState?.finisher_id;
|
||||
const currentRound = gameState?.current_round || '?';
|
||||
const totalRounds = gameState?.total_rounds || '?';
|
||||
|
||||
// Find round winner(s)
|
||||
const roundScores = scores.map(s => s.score);
|
||||
const minRoundScore = Math.min(...roundScores);
|
||||
|
||||
// Build player rows - knocker first, then others
|
||||
const ordered = [...gameState.players].sort((a, b) => {
|
||||
if (a.id === knockerId) return -1;
|
||||
if (b.id === knockerId) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const playerRowsHtml = ordered.map(player => {
|
||||
const scoreData = scores.find(s => s.name === player.name) || {};
|
||||
const result = this.calculateColumnScores(player.cards, cardValues, scoringRules, false);
|
||||
const isKnocker = player.id === knockerId;
|
||||
const isLowScore = scoreData.score === minRoundScore;
|
||||
const colIndices = [[0, 3], [1, 4], [2, 5]];
|
||||
|
||||
// Badge
|
||||
let badge = '';
|
||||
if (isKnocker) badge = '<span class="ss-badge ss-badge-knock">KNOCKED</span>';
|
||||
else if (isLowScore) badge = '<span class="ss-badge ss-badge-low">LOW SCORE</span>';
|
||||
|
||||
// Build columns
|
||||
const columnsHtml = colIndices.map((indices, c) => {
|
||||
const col = result.columns[c];
|
||||
const topCard = player.cards[indices[0]];
|
||||
const bottomCard = player.cards[indices[1]];
|
||||
const isPair = col.isPair;
|
||||
|
||||
const topMini = this.renderMiniCard(topCard, isPair);
|
||||
const bottomMini = this.renderMiniCard(bottomCard, isPair);
|
||||
|
||||
let colScore;
|
||||
if (isPair) {
|
||||
const pairLabel = col.pairValue !== 0 ? `PAIR ${col.pairValue}` : 'PAIR 0';
|
||||
colScore = `<div class="ss-col-score ss-pair">${pairLabel}</div>`;
|
||||
} else {
|
||||
const val = col.topValue + col.bottomValue;
|
||||
const cls = val < 0 ? 'ss-negative' : '';
|
||||
colScore = `<div class="ss-col-score ${cls}">${val >= 0 ? '+' + val : val}</div>`;
|
||||
}
|
||||
|
||||
return `<div class="ss-column${isPair ? ' ss-column-paired' : ''}">
|
||||
${topMini}${bottomMini}
|
||||
${colScore}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Bonuses
|
||||
let bonusHtml = '';
|
||||
if (result.bonuses.length > 0) {
|
||||
bonusHtml = result.bonuses.map(b => {
|
||||
const label = b.type === 'wolfpack' ? 'WOLFPACK' : 'FOUR OF A KIND';
|
||||
return `<span class="ss-bonus">${label} ${b.value}</span>`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
const roundScore = scoreData.score !== undefined ? scoreData.score : '-';
|
||||
const totalScore = scoreData.total !== undefined ? scoreData.total : '-';
|
||||
|
||||
return `<div class="ss-player-row">
|
||||
<div class="ss-player-header">
|
||||
<span class="ss-player-name">${player.name}</span>
|
||||
${badge}
|
||||
</div>
|
||||
<div class="ss-columns">${columnsHtml}</div>
|
||||
${bonusHtml ? `<div class="ss-bonuses">${bonusHtml}</div>` : ''}
|
||||
<div class="ss-scores">
|
||||
<span>Hole: <strong>${roundScore}</strong></span>
|
||||
<span>Total: <strong>${totalScore}</strong></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Create modal
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'scoresheet-modal';
|
||||
modal.className = 'scoresheet-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="scoresheet-content">
|
||||
<div class="ss-header">Hole ${currentRound} of ${totalRounds}</div>
|
||||
<div class="ss-players">${playerRowsHtml}</div>
|
||||
<button class="btn btn-primary ss-next-btn" id="ss-next-btn">Next Hole</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
this.setStatus('Hole complete');
|
||||
|
||||
// Bind next button
|
||||
const nextBtn = document.getElementById('ss-next-btn');
|
||||
nextBtn.addEventListener('click', () => {
|
||||
this.playSound('click');
|
||||
this.dismissScoresheetModal();
|
||||
this.nextRound();
|
||||
});
|
||||
|
||||
// Start countdown
|
||||
this.startScoresheetCountdown(nextBtn);
|
||||
|
||||
// Animate entrance
|
||||
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
this.animateScoresheetEntrance(modal);
|
||||
}
|
||||
}
|
||||
|
||||
renderMiniCard(card, isPaired) {
|
||||
if (!card || !card.rank) return '<div class="ss-mini-card ss-mini-back"></div>';
|
||||
|
||||
const suit = card.suit;
|
||||
const isRed = suit === 'hearts' || suit === 'diamonds';
|
||||
const symbol = this.getSuitSymbol(suit);
|
||||
const rank = card.rank === '★' ? '★' : card.rank;
|
||||
const classes = [
|
||||
'ss-mini-card',
|
||||
isRed ? 'ss-red' : 'ss-black',
|
||||
isPaired ? 'ss-mini-paired' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return `<div class="${classes}">${rank}${symbol}</div>`;
|
||||
}
|
||||
|
||||
animateScoresheetEntrance(modal) {
|
||||
const T = window.TIMING?.scoresheet || {};
|
||||
const playerRows = modal.querySelectorAll('.ss-player-row');
|
||||
const nextBtn = modal.querySelector('.ss-next-btn');
|
||||
|
||||
// Start everything hidden
|
||||
playerRows.forEach(row => {
|
||||
row.style.opacity = '0';
|
||||
row.style.transform = 'translateY(10px)';
|
||||
});
|
||||
if (nextBtn) {
|
||||
nextBtn.style.opacity = '0';
|
||||
}
|
||||
|
||||
// Stagger player rows in
|
||||
if (window.anime) {
|
||||
anime({
|
||||
targets: Array.from(playerRows),
|
||||
opacity: [0, 1],
|
||||
translateY: [10, 0],
|
||||
delay: anime.stagger(T.playerStagger || 150),
|
||||
duration: 300,
|
||||
easing: 'easeOutCubic',
|
||||
complete: () => {
|
||||
// Animate paired columns glow
|
||||
setTimeout(() => {
|
||||
modal.querySelectorAll('.ss-column-paired').forEach(col => {
|
||||
col.classList.add('ss-pair-glow');
|
||||
});
|
||||
}, T.pairGlowDelay || 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Fade in button after rows
|
||||
const totalRowDelay = (playerRows.length - 1) * (T.playerStagger || 150) + 300;
|
||||
anime({
|
||||
targets: nextBtn,
|
||||
opacity: [0, 1],
|
||||
delay: totalRowDelay,
|
||||
duration: 200,
|
||||
easing: 'easeOutCubic',
|
||||
});
|
||||
} else {
|
||||
// No anime.js - show immediately
|
||||
playerRows.forEach(row => {
|
||||
row.style.opacity = '1';
|
||||
row.style.transform = '';
|
||||
});
|
||||
if (nextBtn) nextBtn.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
startScoresheetCountdown(btn) {
|
||||
this.clearScoresheetCountdown();
|
||||
const COUNTDOWN_SECONDS = 15;
|
||||
let remaining = COUNTDOWN_SECONDS;
|
||||
|
||||
const update = () => {
|
||||
if (this.isHost) {
|
||||
btn.textContent = `Next Hole (${remaining}s)`;
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
btn.textContent = `Next hole in ${remaining}s...`;
|
||||
btn.disabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
this.scoresheetCountdownInterval = setInterval(() => {
|
||||
remaining--;
|
||||
if (remaining <= 0) {
|
||||
this.clearScoresheetCountdown();
|
||||
if (this.isHost) {
|
||||
this.dismissScoresheetModal();
|
||||
this.nextRound();
|
||||
} else {
|
||||
btn.textContent = 'Waiting for host...';
|
||||
}
|
||||
} else {
|
||||
update();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
clearScoresheetCountdown() {
|
||||
if (this.scoresheetCountdownInterval) {
|
||||
clearInterval(this.scoresheetCountdownInterval);
|
||||
this.scoresheetCountdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
dismissScoresheetModal() {
|
||||
this.clearScoresheetCountdown();
|
||||
const modal = document.getElementById('scoresheet-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
|
||||
// --- V3_02: Dealing Animation ---
|
||||
@@ -1641,8 +1967,8 @@ class GolfGame {
|
||||
const newState = this.postRevealState || this.gameState;
|
||||
|
||||
if (!oldState || !newState) {
|
||||
// Fallback: show scoreboard immediately
|
||||
this.showScoreboard(scores, false, rankings);
|
||||
// Fallback: show scoresheet immediately
|
||||
this.showScoresheetModal(scores, this.gameState, rankings);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1687,18 +2013,14 @@ class GolfGame {
|
||||
this.highlightPlayerArea(player.id, false);
|
||||
}
|
||||
|
||||
// All revealed - run score tally before showing scoreboard
|
||||
// All revealed - show scoresheet modal
|
||||
this.revealAnimationInProgress = false;
|
||||
this.preRevealState = null;
|
||||
this.postRevealState = null;
|
||||
this.renderGame();
|
||||
|
||||
// V3_07: Animated score tallying
|
||||
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
await this.runScoreTally(newState.players, knockerId);
|
||||
}
|
||||
|
||||
this.showScoreboard(scores, false, rankings);
|
||||
// V3_17: Scoresheet modal replaces tally + side panel scoreboard
|
||||
this.showScoresheetModal(scores, newState, rankings);
|
||||
}
|
||||
|
||||
getCardsToReveal(oldState, newState) {
|
||||
@@ -2558,6 +2880,7 @@ class GolfGame {
|
||||
|
||||
nextRound() {
|
||||
this.clearNextHoleCountdown();
|
||||
this.clearScoresheetCountdown();
|
||||
this.send({ type: 'next_round' });
|
||||
this.gameButtons.classList.add('hidden');
|
||||
this.nextRoundBtn.classList.remove('waiting');
|
||||
@@ -2585,7 +2908,19 @@ class GolfGame {
|
||||
|
||||
// UI Helpers
|
||||
showScreen(screen) {
|
||||
// Accept string names or DOM elements
|
||||
if (typeof screen === 'string') {
|
||||
const screenMap = {
|
||||
'lobby': this.lobbyScreen,
|
||||
'matchmaking': this.matchmakingScreen,
|
||||
'waiting': this.waitingScreen,
|
||||
'game': this.gameScreen,
|
||||
};
|
||||
screen = screenMap[screen] || screen;
|
||||
}
|
||||
|
||||
this.lobbyScreen.classList.remove('active');
|
||||
this.matchmakingScreen?.classList.remove('active');
|
||||
this.waitingScreen.classList.remove('active');
|
||||
this.gameScreen.classList.remove('active');
|
||||
if (this.rulesScreen) {
|
||||
@@ -3873,13 +4208,8 @@ class GolfGame {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show game buttons
|
||||
this.gameButtons.classList.remove('hidden');
|
||||
this.newGameBtn.classList.add('hidden');
|
||||
this.nextRoundBtn.classList.remove('hidden');
|
||||
|
||||
// Start countdown for next hole
|
||||
this.startNextHoleCountdown();
|
||||
// V3_17: Scoresheet modal handles Next Hole button/countdown now
|
||||
// Side panel just updates data silently
|
||||
}
|
||||
|
||||
startNextHoleCountdown() {
|
||||
@@ -4144,13 +4474,32 @@ class AuthManager {
|
||||
this.initElements();
|
||||
this.bindEvents();
|
||||
this.updateUI();
|
||||
|
||||
// Validate stored token on load
|
||||
if (this.token) {
|
||||
this.validateToken();
|
||||
}
|
||||
}
|
||||
|
||||
async validateToken() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': `Bearer ${this.token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
this.logout();
|
||||
}
|
||||
} catch {
|
||||
// Network error - keep token, will fail on next action
|
||||
}
|
||||
}
|
||||
|
||||
initElements() {
|
||||
this.authBar = document.getElementById('auth-bar');
|
||||
this.authUsername = document.getElementById('auth-username');
|
||||
this.logoutBtn = document.getElementById('auth-logout-btn');
|
||||
this.authButtons = document.getElementById('auth-buttons');
|
||||
this.authPrompt = document.getElementById('auth-prompt');
|
||||
this.lobbyGameControls = document.getElementById('lobby-game-controls');
|
||||
this.loginBtn = document.getElementById('login-btn');
|
||||
this.signupBtn = document.getElementById('signup-btn');
|
||||
this.modal = document.getElementById('auth-modal');
|
||||
@@ -4162,6 +4511,7 @@ class AuthManager {
|
||||
this.loginError = document.getElementById('login-error');
|
||||
this.signupFormContainer = document.getElementById('signup-form-container');
|
||||
this.signupForm = document.getElementById('signup-form');
|
||||
this.signupInviteCode = document.getElementById('signup-invite-code');
|
||||
this.signupUsername = document.getElementById('signup-username');
|
||||
this.signupEmail = document.getElementById('signup-email');
|
||||
this.signupPassword = document.getElementById('signup-password');
|
||||
@@ -4247,10 +4597,6 @@ class AuthManager {
|
||||
|
||||
this.setAuth(data.token, data.user);
|
||||
this.hideModal();
|
||||
|
||||
if (data.user.username && this.game.playerNameInput) {
|
||||
this.game.playerNameInput.value = data.user.username;
|
||||
}
|
||||
} catch (err) {
|
||||
this.loginError.textContent = 'Connection error';
|
||||
}
|
||||
@@ -4260,6 +4606,7 @@ class AuthManager {
|
||||
e.preventDefault();
|
||||
this.clearErrors();
|
||||
|
||||
const invite_code = this.signupInviteCode?.value.trim() || null;
|
||||
const username = this.signupUsername.value.trim();
|
||||
const email = this.signupEmail.value.trim() || null;
|
||||
const password = this.signupPassword.value;
|
||||
@@ -4268,7 +4615,7 @@ class AuthManager {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
body: JSON.stringify({ invite_code, username, email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -4280,10 +4627,6 @@ class AuthManager {
|
||||
|
||||
this.setAuth(data.token, data.user);
|
||||
this.hideModal();
|
||||
|
||||
if (data.user.username && this.game.playerNameInput) {
|
||||
this.game.playerNameInput.value = data.user.username;
|
||||
}
|
||||
} catch (err) {
|
||||
this.signupError.textContent = 'Connection error';
|
||||
}
|
||||
@@ -4308,16 +4651,15 @@ class AuthManager {
|
||||
updateUI() {
|
||||
if (this.user) {
|
||||
this.authBar?.classList.remove('hidden');
|
||||
this.authButtons?.classList.add('hidden');
|
||||
this.authPrompt?.classList.add('hidden');
|
||||
this.lobbyGameControls?.classList.remove('hidden');
|
||||
if (this.authUsername) {
|
||||
this.authUsername.textContent = this.user.username;
|
||||
}
|
||||
if (this.game.playerNameInput && !this.game.playerNameInput.value) {
|
||||
this.game.playerNameInput.value = this.user.username;
|
||||
}
|
||||
} else {
|
||||
this.authBar?.classList.add('hidden');
|
||||
this.authButtons?.classList.remove('hidden');
|
||||
this.authPrompt?.classList.remove('hidden');
|
||||
this.lobbyGameControls?.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,35 +19,52 @@
|
||||
<h1><img src="golfball-logo.svg" alt="" class="golfball-logo"><span class="golfer-swing">🏌️</span><span class="kicked-ball">⚪</span> <span class="golf-title">Golf</span></h1>
|
||||
<p class="subtitle">6-Card Golf Card Game <button id="rules-btn" class="btn btn-small btn-rules">Rules</button> <button id="leaderboard-btn" class="btn btn-small leaderboard-btn">Leaderboard</button></p>
|
||||
|
||||
<!-- Auth buttons for guests (hidden until auth check confirms not logged in) -->
|
||||
<div id="auth-buttons" class="auth-buttons hidden">
|
||||
<button id="login-btn" class="btn btn-small">Login</button>
|
||||
<button id="signup-btn" class="btn btn-small btn-primary">Sign Up</button>
|
||||
<!-- Auth prompt for unauthenticated users -->
|
||||
<div id="auth-prompt" class="auth-prompt">
|
||||
<p>Log in or sign up to play.</p>
|
||||
<div class="button-group">
|
||||
<button id="login-btn" class="btn btn-primary">Login</button>
|
||||
<button id="signup-btn" class="btn btn-secondary">Sign Up</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="player-name">Your Name</label>
|
||||
<input type="text" id="player-name" placeholder="Enter your name" maxlength="12">
|
||||
</div>
|
||||
<!-- Game controls (shown only when authenticated) -->
|
||||
<div id="lobby-game-controls" class="hidden">
|
||||
<div class="button-group">
|
||||
<button id="find-game-btn" class="btn btn-primary">Find Game</button>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="create-room-btn" class="btn btn-primary">Create Room</button>
|
||||
</div>
|
||||
<div class="divider">or</div>
|
||||
|
||||
<div class="divider">or</div>
|
||||
<div class="button-group">
|
||||
<button id="create-room-btn" class="btn btn-secondary">Create Private Room</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="room-code">Room Code</label>
|
||||
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="room-code">Join Private Room</label>
|
||||
<input type="text" id="room-code" placeholder="ABCD" maxlength="4">
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
||||
<div class="button-group">
|
||||
<button id="join-room-btn" class="btn btn-secondary">Join Room</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="lobby-error" class="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Matchmaking Screen -->
|
||||
<div id="matchmaking-screen" class="screen">
|
||||
<h2>Finding Game...</h2>
|
||||
<div class="matchmaking-spinner"></div>
|
||||
<p id="matchmaking-status">Searching for opponents...</p>
|
||||
<p id="matchmaking-time" class="matchmaking-timer">0:00</p>
|
||||
<p id="matchmaking-queue-info" class="matchmaking-info"></p>
|
||||
<div class="button-group">
|
||||
<button id="cancel-matchmaking-btn" class="btn btn-danger">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waiting Room Screen -->
|
||||
<div id="waiting-screen" class="screen">
|
||||
<div class="room-code-banner">
|
||||
@@ -717,6 +734,7 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<button class="leaderboard-tab" data-metric="avg_score">Avg Score</button>
|
||||
<button class="leaderboard-tab" data-metric="knockouts">Knockouts</button>
|
||||
<button class="leaderboard-tab" data-metric="streak">Best Streak</button>
|
||||
<button class="leaderboard-tab" data-metric="rating">Rating</button>
|
||||
</div>
|
||||
|
||||
<div id="leaderboard-content">
|
||||
@@ -816,6 +834,9 @@ TOTAL: 0 + 8 + 16 = 24 points</pre>
|
||||
<div id="signup-form-container" class="hidden">
|
||||
<h3>Sign Up</h3>
|
||||
<form id="signup-form">
|
||||
<div class="form-group">
|
||||
<input type="text" id="signup-invite-code" placeholder="Invite Code" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" id="signup-username" placeholder="Username" required minlength="3" maxlength="20">
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ class LeaderboardComponent {
|
||||
avg_score: 'Avg Score',
|
||||
knockouts: 'Knockouts',
|
||||
streak: 'Best Streak',
|
||||
rating: 'Rating',
|
||||
};
|
||||
|
||||
this.metricFormats = {
|
||||
@@ -34,6 +35,7 @@ class LeaderboardComponent {
|
||||
avg_score: (v) => v.toFixed(1),
|
||||
knockouts: (v) => v.toLocaleString(),
|
||||
streak: (v) => v.toLocaleString(),
|
||||
rating: (v) => Math.round(v).toLocaleString(),
|
||||
};
|
||||
|
||||
this.init();
|
||||
|
||||
355
client/style.css
355
client/style.css
@@ -3299,15 +3299,26 @@ input::placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Auth buttons in lobby */
|
||||
.auth-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
/* Auth prompt in lobby (shown when not logged in) */
|
||||
.auth-prompt {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.auth-buttons.hidden {
|
||||
.auth-prompt p {
|
||||
margin-bottom: 15px;
|
||||
color: #ccc;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.auth-prompt .button-group {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-prompt.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3391,6 +3402,48 @@ input::placeholder {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
MATCHMAKING SCREEN
|
||||
=========================================== */
|
||||
|
||||
#matchmaking-screen {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
#matchmaking-screen h2 {
|
||||
color: #f4a460;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.matchmaking-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #f4a460;
|
||||
border-radius: 50%;
|
||||
margin: 20px auto;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.matchmaking-timer {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin: 15px 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.matchmaking-info {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.9em;
|
||||
margin: 10px 0 20px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
LEADERBOARD COMPONENTS
|
||||
=========================================== */
|
||||
@@ -4402,56 +4455,21 @@ input::placeholder {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.knock-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 400;
|
||||
pointer-events: none;
|
||||
animation: knock-banner-in 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.knock-banner span {
|
||||
display: block;
|
||||
font-size: 4em;
|
||||
/* V3_17: Knock status message - golden gradient with pulsing glow */
|
||||
.status-message.knock {
|
||||
background: linear-gradient(135deg, #f4a460 0%, #e67e22 50%, #d4750e 100%);
|
||||
color: #1a1a2e;
|
||||
font-size: 1.3em;
|
||||
font-weight: 900;
|
||||
color: #ffe082;
|
||||
background: rgba(20, 20, 36, 0.95);
|
||||
padding: 20px 50px;
|
||||
border-radius: 12px;
|
||||
border: 3px solid rgba(255, 215, 0, 0.5);
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
letter-spacing: 0.15em;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
animation: knock-pulse 0.6s ease-in-out 3;
|
||||
box-shadow: 0 0 15px rgba(244, 164, 96, 0.5);
|
||||
}
|
||||
|
||||
@keyframes knock-banner-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.knock-banner span {
|
||||
animation: knock-text-in 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes knock-text-in {
|
||||
0% { transform: scale(0); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.knock-banner.fading {
|
||||
animation: knock-banner-out 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes knock-banner-out {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
@keyframes knock-pulse {
|
||||
0%, 100% { box-shadow: 0 0 15px rgba(244, 164, 96, 0.5); }
|
||||
50% { box-shadow: 0 0 25px rgba(244, 164, 96, 0.8), 0 0 40px rgba(230, 126, 34, 0.3); }
|
||||
}
|
||||
|
||||
@keyframes screen-shake {
|
||||
@@ -4466,25 +4484,7 @@ body.screen-shake {
|
||||
animation: screen-shake 0.3s ease-out;
|
||||
}
|
||||
|
||||
.opponent-knock-banner {
|
||||
position: fixed;
|
||||
top: 30%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(135deg, #ff6b35 0%, #d63031 100%);
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
border-radius: 12px;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
z-index: 200;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
animation: prompt-entrance 0.3s ease-out;
|
||||
}
|
||||
|
||||
.opponent-knock-banner.fading {
|
||||
animation: prompt-fade 0.3s ease-out forwards;
|
||||
}
|
||||
/* opponent-knock-banner removed in V3_17 - knock uses status bar now */
|
||||
|
||||
/* --- V3_07: Score Tallying Animation --- */
|
||||
.card-value-overlay {
|
||||
@@ -4660,6 +4660,215 @@ body.screen-shake {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* --- V3_17: Scoresheet Modal --- */
|
||||
.scoresheet-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 300;
|
||||
animation: fadeInBg 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeInBg {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.scoresheet-content {
|
||||
background: linear-gradient(145deg, #1a472a 0%, #0d3320 100%);
|
||||
border-radius: 16px;
|
||||
padding: 24px 28px;
|
||||
max-width: 520px;
|
||||
width: 92%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow:
|
||||
0 16px 50px rgba(0, 0, 0, 0.6),
|
||||
0 0 60px rgba(244, 164, 96, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
border: 2px solid rgba(244, 164, 96, 0.25);
|
||||
animation: modalSlideIn 0.4s ease;
|
||||
}
|
||||
|
||||
.ss-header {
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #f4a460;
|
||||
margin-bottom: 18px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.ss-players {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.ss-player-row {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.ss-player-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ss-player-name {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.ss-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ss-badge-knock {
|
||||
background: linear-gradient(135deg, #f4a460 0%, #e67e22 100%);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.ss-badge-low {
|
||||
background: linear-gradient(135deg, #27ae60 0%, #1e8449 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ss-columns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ss-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.ss-column-paired {
|
||||
background: rgba(244, 164, 96, 0.08);
|
||||
border: 1px solid rgba(244, 164, 96, 0.15);
|
||||
}
|
||||
|
||||
.ss-column-paired.ss-pair-glow {
|
||||
animation: ss-pair-glow-pulse 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes ss-pair-glow-pulse {
|
||||
0% { box-shadow: 0 0 0 rgba(244, 164, 96, 0); }
|
||||
50% { box-shadow: 0 0 12px rgba(244, 164, 96, 0.4); }
|
||||
100% { box-shadow: 0 0 0 rgba(244, 164, 96, 0); }
|
||||
}
|
||||
|
||||
.ss-mini-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 28px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
background: #f5f0e8;
|
||||
color: #1a1a2e;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.ss-mini-card.ss-red {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.ss-mini-card.ss-black {
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.ss-mini-card.ss-mini-paired {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.ss-mini-card.ss-mini-back {
|
||||
background: linear-gradient(135deg, #2c5f8a 0%, #1a3a5c 100%);
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.ss-col-score {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.ss-col-score.ss-pair {
|
||||
color: #f4a460;
|
||||
}
|
||||
|
||||
.ss-col-score.ss-negative {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.ss-bonuses {
|
||||
margin: 4px 0 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ss-bonus {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 800;
|
||||
color: #81d4fa;
|
||||
background: rgba(100, 181, 246, 0.15);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.ss-scores {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ss-scores strong {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ss-next-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 18px;
|
||||
padding: 10px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* --- V3_11: Swap Animation --- */
|
||||
.traveling-card {
|
||||
position: fixed;
|
||||
|
||||
@@ -128,6 +128,18 @@ const TIMING = {
|
||||
pulseDelay: 200, // Delay before card appears (pulse visible first)
|
||||
},
|
||||
|
||||
// V3_17: Knock notification
|
||||
knock: {
|
||||
statusDuration: 2500, // How long the knock status message persists
|
||||
},
|
||||
|
||||
// V3_17: Scoresheet modal
|
||||
scoresheet: {
|
||||
playerStagger: 150, // Delay between player row animations
|
||||
columnStagger: 80, // Delay between column animations within a row
|
||||
pairGlowDelay: 200, // Delay before paired columns glow
|
||||
},
|
||||
|
||||
// Player swap animation steps - smooth continuous motion
|
||||
playerSwap: {
|
||||
flipToReveal: 400, // Initial flip to show card
|
||||
|
||||
Reference in New Issue
Block a user