// Golf Card Game - Replay Viewer class ReplayViewer { constructor() { this.frames = []; this.metadata = null; this.currentFrame = 0; this.isPlaying = false; this.playbackSpeed = 1.0; this.playInterval = null; this.gameId = null; this.shareCode = null; this.initElements(); this.bindEvents(); } initElements() { this.replayScreen = document.getElementById('replay-screen'); this.replayTitle = document.getElementById('replay-title'); this.replayMeta = document.getElementById('replay-meta'); this.replayBoard = document.getElementById('replay-board'); this.eventDescription = document.getElementById('replay-event-description'); this.controlsContainer = document.getElementById('replay-controls'); this.frameCounter = document.getElementById('replay-frame-counter'); this.timelineSlider = document.getElementById('replay-timeline'); this.speedSelect = document.getElementById('replay-speed'); // Control buttons this.btnStart = document.getElementById('replay-btn-start'); this.btnPrev = document.getElementById('replay-btn-prev'); this.btnPlay = document.getElementById('replay-btn-play'); this.btnNext = document.getElementById('replay-btn-next'); this.btnEnd = document.getElementById('replay-btn-end'); // Action buttons this.btnShare = document.getElementById('replay-btn-share'); this.btnExport = document.getElementById('replay-btn-export'); this.btnBack = document.getElementById('replay-btn-back'); } bindEvents() { if (this.btnStart) this.btnStart.onclick = () => this.goToFrame(0); if (this.btnEnd) this.btnEnd.onclick = () => this.goToFrame(this.frames.length - 1); if (this.btnPrev) this.btnPrev.onclick = () => this.prevFrame(); if (this.btnNext) this.btnNext.onclick = () => this.nextFrame(); if (this.btnPlay) this.btnPlay.onclick = () => this.togglePlay(); if (this.timelineSlider) { this.timelineSlider.oninput = (e) => { this.goToFrame(parseInt(e.target.value)); }; } if (this.speedSelect) { this.speedSelect.onchange = (e) => { this.playbackSpeed = parseFloat(e.target.value); if (this.isPlaying) { this.stopPlayback(); this.startPlayback(); } }; } if (this.btnShare) { this.btnShare.onclick = () => this.showShareDialog(); } if (this.btnExport) { this.btnExport.onclick = () => this.exportGame(); } if (this.btnBack) { this.btnBack.onclick = () => this.hide(); } // Keyboard controls document.addEventListener('keydown', (e) => { if (!this.replayScreen || !this.replayScreen.classList.contains('active')) return; switch (e.key) { case 'ArrowLeft': e.preventDefault(); this.prevFrame(); break; case 'ArrowRight': e.preventDefault(); this.nextFrame(); break; case ' ': e.preventDefault(); this.togglePlay(); break; case 'Home': e.preventDefault(); this.goToFrame(0); break; case 'End': e.preventDefault(); this.goToFrame(this.frames.length - 1); break; } }); } async loadReplay(gameId) { this.gameId = gameId; this.shareCode = null; try { const token = localStorage.getItem('authToken'); const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; const response = await fetch(`/api/replay/game/${gameId}`, { headers }); if (!response.ok) { throw new Error('Failed to load replay'); } const data = await response.json(); this.frames = data.frames; this.metadata = data.metadata; this.currentFrame = 0; this.show(); this.render(); this.updateControls(); } catch (error) { console.error('Failed to load replay:', error); this.showError('Failed to load replay. You may not have permission to view this game.'); } } async loadSharedReplay(shareCode) { this.shareCode = shareCode; this.gameId = null; try { const response = await fetch(`/api/replay/shared/${shareCode}`); if (!response.ok) { throw new Error('Replay not found or expired'); } const data = await response.json(); this.frames = data.frames; this.metadata = data.metadata; this.gameId = data.game_id; this.currentFrame = 0; // Update title with share info if (data.title) { this.replayTitle.textContent = data.title; } this.show(); this.render(); this.updateControls(); } catch (error) { console.error('Failed to load shared replay:', error); this.showError('Replay not found or has expired.'); } } show() { // Hide other screens document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); this.replayScreen.classList.add('active'); // Update title if (!this.shareCode && this.metadata) { this.replayTitle.textContent = 'Game Replay'; } // Update meta if (this.metadata) { const players = this.metadata.players.join(' vs '); const duration = this.formatDuration(this.metadata.duration); const rounds = `${this.metadata.total_rounds} hole${this.metadata.total_rounds > 1 ? 's' : ''}`; this.replayMeta.innerHTML = `${players} | ${rounds} | ${duration}`; } } hide() { this.stopPlayback(); this.replayScreen.classList.remove('active'); // Return to lobby document.getElementById('lobby-screen').classList.add('active'); } render() { if (!this.frames.length) return; const frame = this.frames[this.currentFrame]; const state = frame.state; this.renderBoard(state); this.renderEventInfo(frame); this.updateTimeline(); } renderBoard(state) { const currentPlayerId = state.current_player_id; // Build HTML for all players let html = '
'; state.players.forEach((player, idx) => { const isCurrent = player.id === currentPlayerId; html += `
${this.escapeHtml(player.name)} Score: ${player.score} | Total: ${player.total_score}
${this.renderPlayerCards(player.cards)}
`; }); html += '
'; // Center area (deck and discard) html += `
${state.deck_remaining}
${state.discard_top ? this.renderCard(state.discard_top, true) : '
'}
${state.drawn_card ? `
Drawn: ${this.renderCard(state.drawn_card, true)}
` : ''}
`; // Game info html += `
Round ${state.current_round} / ${state.total_rounds} Phase: ${this.formatPhase(state.phase)}
`; this.replayBoard.innerHTML = html; } renderPlayerCards(cards) { let html = '
'; // Render as 2 rows x 3 columns for (let row = 0; row < 2; row++) { html += '
'; for (let col = 0; col < 3; col++) { const idx = row * 3 + col; const card = cards[idx]; if (card) { html += this.renderCard(card, card.face_up); } else { html += '
'; } } html += '
'; } html += '
'; return html; } renderCard(card, revealed = false) { if (!revealed || !card.face_up) { return '
'; } const suit = card.suit; const rank = card.rank; const isRed = suit === 'hearts' || suit === 'diamonds'; const suitSymbol = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || ''; return `
${rank} ${suitSymbol}
`; } renderEventInfo(frame) { const descriptions = { 'game_created': 'Game created', 'player_joined': `${frame.event_data?.player_name || 'Player'} joined`, 'player_left': `Player left the game`, 'game_started': 'Game started', 'round_started': `Round ${frame.event_data?.round || ''} started`, 'initial_flip': `${this.getPlayerName(frame.player_id)} revealed initial cards`, 'card_drawn': `${this.getPlayerName(frame.player_id)} drew from ${frame.event_data?.source || 'deck'}`, 'card_swapped': `${this.getPlayerName(frame.player_id)} swapped a card`, 'card_discarded': `${this.getPlayerName(frame.player_id)} discarded`, 'card_flipped': `${this.getPlayerName(frame.player_id)} flipped a card`, 'flip_skipped': `${this.getPlayerName(frame.player_id)} skipped flip`, 'knock_early': `${this.getPlayerName(frame.player_id)} knocked early!`, 'round_ended': `Round ended`, 'game_ended': `Game over! ${this.metadata?.winner || 'Winner'} wins!`, }; const desc = descriptions[frame.event_type] || frame.event_type; const time = this.formatTimestamp(frame.timestamp); this.eventDescription.innerHTML = ` ${time} ${desc} `; } getPlayerName(playerId) { if (!playerId || !this.frames.length) return 'Player'; const currentState = this.frames[this.currentFrame]?.state; if (!currentState) return 'Player'; const player = currentState.players.find(p => p.id === playerId); return player?.name || 'Player'; } updateControls() { if (this.timelineSlider) { this.timelineSlider.max = Math.max(0, this.frames.length - 1); this.timelineSlider.value = this.currentFrame; } // Show/hide share button based on whether we own the game if (this.btnShare) { this.btnShare.style.display = this.gameId && localStorage.getItem('authToken') ? '' : 'none'; } } updateTimeline() { if (this.timelineSlider) { this.timelineSlider.value = this.currentFrame; } if (this.frameCounter) { this.frameCounter.textContent = `${this.currentFrame + 1} / ${this.frames.length}`; } } goToFrame(index) { this.currentFrame = Math.max(0, Math.min(index, this.frames.length - 1)); this.render(); } nextFrame() { if (this.currentFrame < this.frames.length - 1) { this.currentFrame++; this.render(); } else if (this.isPlaying) { this.togglePlay(); // Stop at end } } prevFrame() { if (this.currentFrame > 0) { this.currentFrame--; this.render(); } } togglePlay() { this.isPlaying = !this.isPlaying; if (this.btnPlay) { this.btnPlay.textContent = this.isPlaying ? '⏸' : '▶'; } if (this.isPlaying) { this.startPlayback(); } else { this.stopPlayback(); } } startPlayback() { const baseInterval = 1000; // 1 second between frames this.playInterval = setInterval(() => { this.nextFrame(); }, baseInterval / this.playbackSpeed); } stopPlayback() { if (this.playInterval) { clearInterval(this.playInterval); this.playInterval = null; } } async showShareDialog() { if (!this.gameId) return; const modal = document.createElement('div'); modal.className = 'modal active'; modal.id = 'share-modal'; modal.innerHTML = ` `; document.body.appendChild(modal); const generateBtn = modal.querySelector('#share-generate-btn'); const cancelBtn = modal.querySelector('#share-cancel-btn'); const copyBtn = modal.querySelector('#share-copy-btn'); cancelBtn.onclick = () => modal.remove(); generateBtn.onclick = async () => { const title = modal.querySelector('#share-title').value || null; const expiry = modal.querySelector('#share-expiry').value || null; try { const token = localStorage.getItem('authToken'); const response = await fetch(`/api/replay/game/${this.gameId}/share`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ title, expires_days: expiry ? parseInt(expiry) : null, }), }); if (!response.ok) { throw new Error('Failed to create share link'); } const data = await response.json(); const fullUrl = `${window.location.origin}/replay/${data.share_code}`; modal.querySelector('#share-link').value = fullUrl; modal.querySelector('#share-result').classList.remove('hidden'); generateBtn.classList.add('hidden'); } catch (error) { console.error('Failed to create share link:', error); alert('Failed to create share link'); } }; copyBtn.onclick = () => { const input = modal.querySelector('#share-link'); input.select(); document.execCommand('copy'); copyBtn.textContent = 'Copied!'; setTimeout(() => copyBtn.textContent = 'Copy', 2000); }; } async exportGame() { if (!this.gameId) return; try { const token = localStorage.getItem('authToken'); const response = await fetch(`/api/replay/game/${this.gameId}/export`, { headers: { 'Authorization': `Bearer ${token}`, }, }); if (!response.ok) { throw new Error('Failed to export game'); } const data = await response.json(); // Download as JSON file const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `golf-game-${this.gameId.substring(0, 8)}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (error) { console.error('Failed to export game:', error); alert('Failed to export game'); } } showError(message) { this.show(); this.replayBoard.innerHTML = `

${this.escapeHtml(message)}

`; } formatDuration(seconds) { if (!seconds) return '0:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } formatTimestamp(seconds) { return this.formatDuration(seconds); } formatPhase(phase) { const phases = { 'waiting': 'Waiting', 'initial_flip': 'Initial Flip', 'playing': 'Playing', 'final_turn': 'Final Turn', 'round_over': 'Round Over', 'game_over': 'Game Over', }; return phases[phase] || phase; } escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } } // Global instance const replayViewer = new ReplayViewer(); // Check URL for replay links document.addEventListener('DOMContentLoaded', () => { const path = window.location.pathname; // Handle /replay/{share_code} URLs if (path.startsWith('/replay/')) { const shareCode = path.substring(8); if (shareCode) { replayViewer.loadSharedReplay(shareCode); } } }); // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = { ReplayViewer, replayViewer }; }