Files
ranked-eqao/frontend/src/pages/RankedGame.jsx
T

1382 lines
64 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router-dom';
import treeBg from '../assets/tree_bg_svg.svg';
const STAGE_SIZE = 11;
const API = import.meta.env.VITE_API_URL;
const NAV_BTN = {
padding: '14px 40px', borderRadius: 999,
fontFamily: 'system-ui,sans-serif', fontSize: 17, fontWeight: 600,
cursor: 'pointer', border: '1.5px solid #b6d8f5',
background: '#e8f4fd', color: '#1a73e8',
};
const FORFEIT_BTN = {
padding: '14px 28px', borderRadius: 999, fontFamily: 'system-ui,sans-serif',
fontSize: 14, fontWeight: 600, cursor: 'pointer',
border: '1.5px solid #e57373', background: '#fff', color: '#c62828',
};
function authHeaders() {
const token = sessionStorage.getItem('ranked_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
function SpeechBubbleIcon({ size = 44 }) {
return (
<svg width={size} height={size} viewBox="0 0 44 44" fill="none">
<rect x="4" y="4" width="30" height="24" rx="6" stroke="white" strokeWidth="2.2" />
<path d="M9 28 L6 38 L20 28" stroke="white" strokeWidth="2.2" strokeLinejoin="round" fill="none" />
</svg>
);
}
function GlobalStyles() {
return (
<style>{`
@keyframes emote-fade { 0%,65%{opacity:1;}100%{opacity:0;} }
@keyframes slide-in-right { from{transform:translateX(100%);}to{transform:translateX(0);} }
@keyframes done-overlay { 0%,70%{opacity:1;}100%{opacity:0;} }
@keyframes spin { to{transform:rotate(360deg);} }
@keyframes questionEnter { from{opacity:0;transform:translateX(20px)} to{opacity:1;transform:translateX(0)} }
@keyframes answerPulse { 0%{transform:scale(1)} 50%{transform:scale(0.97)} 100%{transform:scale(1)} }
@keyframes bubblePop { 0%{transform:scale(0.7)} 70%{transform:scale(1.1)} 100%{transform:scale(1)} }
@keyframes nameFlash { 0%{color:#e67e22} 50%{color:#f5c518} 100%{color:#e67e22} }
@keyframes timerPulse { 0%{color:#1a73e8} 50%{color:#ef4444} 100%{color:#1a73e8} }
@keyframes notifBounce { 0%{transform:translateX(120%)} 80%{transform:translateX(-8px)} 100%{transform:translateX(0)} }
@keyframes stageReveal { from{opacity:0;transform:translateY(24px)} to{opacity:1;transform:translateY(0)} }
@keyframes submitGlow { from{box-shadow:0 0 8px #ff69b4aa} to{box-shadow:0 0 22px #ff69b4, 0 0 40px #ff69b466} }
@keyframes slideInLeft { from{opacity:0;transform:translateX(-30px)} to{opacity:1;transform:translateX(0)} }
@keyframes slideInRight { from{opacity:0;transform:translateX(30px)} to{opacity:1;transform:translateX(0)} }
@keyframes overlayFade { from{opacity:0} to{opacity:1} }
@keyframes resultReveal { from{opacity:0;transform:scale(0.85)} to{opacity:1;transform:scale(1)} }
@keyframes defeatTitleIn { from{opacity:0;transform:translateY(-16px)} to{opacity:1;transform:translateY(0)} }
@keyframes statsRowIn { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
@keyframes screenFlash { 0%{opacity:1} 100%{opacity:0} }
`}</style>
);
}
function OpponentProgressStrip({ progress, total, name, dced, disconnected, emoteBubble, stageLabel }) {
const prevProgressRef = useRef(progress);
const [nameFlashing, setNameFlashing] = useState(false);
useEffect(() => {
if (progress > prevProgressRef.current) {
setNameFlashing(true);
const t = setTimeout(() => setNameFlashing(false), 500);
prevProgressRef.current = progress;
return () => clearTimeout(t);
}
prevProgressRef.current = progress;
}, [progress]);
const items = [];
for (let qi = 0; qi < total; qi++) {
const filled = qi < progress;
items.push(
<div key={`b-${qi}`} style={{ width: 34, height: 34, borderRadius: '50%', border: `2px solid ${filled ? '#e67e22' : '#ccc'}`, background: filled ? '#e67e22' : '#fff', color: filled ? '#fff' : '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif', flexShrink: 0, padding: 0, boxSizing: 'border-box', transition: 'background 0.15s, border-color 0.15s' }}>{qi + 1}</div>
);
if (qi < total - 1) {
items.push(<div key={`l-${qi}`} style={{ flex: 1, height: 3, minWidth: 2, background: filled ? '#e67e22' : '#ddd' }} />);
}
}
return (
<div style={{ position: 'relative' }}>
{emoteBubble && (
<div key={emoteBubble.key} style={{
position: 'absolute', top: -36, left: 58,
background: '#1a1a2a', color: '#fff', borderRadius: 10,
padding: '5px 12px', fontSize: 13, fontWeight: 600,
border: '1px solid rgba(255,255,255,0.15)', pointerEvents: 'none',
whiteSpace: 'nowrap', animation: 'emote-fade 3s forwards',
boxShadow: '0 2px 8px rgba(0,0,0,0.4)', zIndex: 10,
}}>
{emoteBubble.text}
<div style={{ position: 'absolute', bottom: -6, left: 14, width: 0, height: 0, borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '6px solid #1a1a2a' }} />
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 120, minWidth: 120, maxWidth: 120, display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0, overflow: 'hidden' }}>
<span style={{ fontSize: 11, fontWeight: 700, color: (dced || disconnected) ? '#c62828' : '#e67e22', textTransform: 'uppercase', letterSpacing: '0.05em', fontFamily: 'system-ui,sans-serif', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', animation: nameFlashing ? 'nameFlash 0.5s ease-out' : 'none' }}>{name}</span>
{dced && <span style={{ fontSize: 10, fontWeight: 700, color: '#fff', background: '#c62828', borderRadius: 4, padding: '1px 5px', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.04em' }}>DC'd</span>}
{!dced && disconnected && <span style={{ fontSize: 10, fontWeight: 700, color: '#fff', background: '#e57373', borderRadius: 4, padding: '1px 5px', fontFamily: 'system-ui,sans-serif', letterSpacing: '0.04em' }}>disconnected?</span>}
{stageLabel && <span style={{ fontSize: 10, color: '#aaa', fontFamily: 'system-ui,sans-serif' }}>Stage {stageLabel}</span>}
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', flexWrap: 'nowrap', opacity: (dced || disconnected) ? 0.5 : 1 }}>
{items}
</div>
</div>
</div>
);
}
function ProgressStrip({ total, current, answers, onJump }) {
const [fillCounts, setFillCounts] = useState({});
const prevAnswersRef = useRef({});
useEffect(() => {
const updates = {};
for (let qi = 0; qi < total; qi++) {
if (answers[qi] != null && prevAnswersRef.current[qi] == null) updates[qi] = true;
}
if (Object.keys(updates).length > 0) {
setFillCounts(prev => {
const next = { ...prev };
for (const qi of Object.keys(updates)) next[Number(qi)] = (prev[Number(qi)] || 0) + 1;
return next;
});
}
prevAnswersRef.current = answers;
}, [answers, total]);
const items = [];
for (let qi = 0; qi < total; qi++) {
const active = qi === current, answered = answers[qi] != null, filled = answered;
const popCount = fillCounts[qi] || 0;
items.push(
<button key={`b-${qi}-${popCount}`} onClick={() => onJump && onJump(qi)} style={{ width: 34, height: 34, borderRadius: '50%', border: `2px solid ${filled || active ? '#1a73e8' : '#ccc'}`, background: filled ? '#1a73e8' : active ? '#e8f0fe' : '#fff', color: filled ? '#fff' : active ? '#1a73e8' : '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, fontFamily: 'system-ui,-apple-system,sans-serif', flexShrink: 0, cursor: 'pointer', padding: 0, boxSizing: 'border-box', transition: 'background 0.15s, border-color 0.15s', animation: popCount > 0 ? 'bubblePop 0.25s ease-out' : 'none' }}>{qi + 1}</button>
);
if (qi < total - 1) items.push(<div key={`l-${qi}`} style={{ flex: 1, height: 3, minWidth: 2, background: answers[qi] != null ? '#1a73e8' : '#ddd' }} />);
}
return <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap', width: '100%' }}>{items}</div>;
}
function QuestionScreen({ question: q, qIdx, answers, selected, onSelect, onNext, onBack, onJump, opponentProgress, opponentName, opponentDced, opponentConnected, onForfeit, emoteBubble, stageNum, timerDisplay, retryBanner, opponentStage, grade }) {
const [pulsingIdx, setPulsingIdx] = useState(null);
const [hoverIdx, setHoverIdx] = useState(null);
const [timerFlash, setTimerFlash] = useState(false);
const lastPulseRef = useRef(-1);
useEffect(() => {
const handler = (e) => {
if (e.target.tagName === 'INPUT') return;
const i = +e.key - 1;
if (i >= 0 && i < q.choices.length) { onSelect(i); return; }
if ((e.key === 'Enter' || e.key === 'ArrowRight') && qIdx < STAGE_SIZE - 1) onNext();
if (e.key === 'ArrowLeft' && qIdx > 0) onBack();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [q.choices.length, qIdx, onSelect, onNext, onBack]);
useEffect(() => {
if (!timerDisplay) return;
const parts = timerDisplay.split(':');
if (parts.length !== 2) return;
const totalSeconds = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
if (totalSeconds > 0 && totalSeconds % 30 === 0 && totalSeconds !== lastPulseRef.current) {
lastPulseRef.current = totalSeconds;
setTimerFlash(true);
const t = setTimeout(() => setTimerFlash(false), 500);
return () => clearTimeout(t);
}
}, [timerDisplay]);
return (
<div style={{ background: '#07080f', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
<div className="arena-bg"><div className="arena-grain" /></div>
<div style={{ maxWidth: 760, margin: '0 auto', background: '#fff', minHeight: '100vh', display: 'flex', flexDirection: 'column', position: 'relative', zIndex: 1, borderTop: '3px solid #1a73e8', boxShadow: '0 0 60px rgba(26,115,232,0.08), 0 0 40px rgba(0,60,120,0.10)' }}>
{retryBanner != null && (
<div style={{ background: '#fef2f2', borderBottom: '1px solid #fca5a5', padding: '10px 32px', fontSize: 14, fontWeight: 600, color: '#991b1b', fontFamily: 'system-ui,sans-serif' }}>
{retryBanner === 1 ? '1 answer incorrect' : `${retryBanner} answers incorrect`} — fix them and resubmit
</div>
)}
<div style={{ borderBottom: '1px solid #e8e8e8', padding: '20px 32px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<p style={{ fontSize: 36, fontWeight: 700, color: '#333', margin: 0, lineHeight: 1, fontFamily: "'Barlow Condensed', system-ui, sans-serif", letterSpacing: '0.01em' }}>
Stage {stageNum ?? 1} · Question {qIdx + 1} of {STAGE_SIZE}
</p>
{grade && (
<span style={{ fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 4, background: 'rgba(26,115,232,0.08)', border: '1px solid rgba(26,115,232,0.25)', color: '#1a73e8', letterSpacing: '0.06em', fontFamily: 'system-ui,sans-serif', flexShrink: 0 }}>
{grade}
</span>
)}
</div>
{timerDisplay && <span style={{ fontSize: 19, fontWeight: 700, color: '#1a73e8', fontFamily: 'monospace', letterSpacing: 2, flexShrink: 0, animation: timerFlash ? 'timerPulse 0.5s ease-out' : 'none' }}>{timerDisplay}</span>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ width: 120, minWidth: 120, maxWidth: 120, fontSize: 11, fontWeight: 700, color: '#1a73e8', textTransform: 'uppercase', letterSpacing: '0.05em', fontFamily: 'system-ui,sans-serif', flexShrink: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>You</span>
<div style={{ flex: 1 }}><ProgressStrip total={STAGE_SIZE} current={qIdx} answers={answers} onJump={onJump} /></div>
</div>
<OpponentProgressStrip progress={opponentProgress} total={STAGE_SIZE} name={opponentName} dced={opponentDced} disconnected={!opponentConnected && !opponentDced} emoteBubble={emoteBubble} stageLabel={opponentStage} />
</div>
</div>
<div key={qIdx} style={{ flex: 1, padding: '28px 32px 24px', animation: 'questionEnter 0.2s ease-out' }}>
<p style={{ fontSize: 24, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 28px 0' }}>{q.question}</p>
<div style={q.layout === 'grid' ? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 } : { display: 'flex', flexDirection: 'column', gap: 12 }}>
{q.choices.map((choice, i) => {
const sel = selected === i;
const hov = hoverIdx === i && !sel;
return (
<button
key={i}
onClick={() => { onSelect(i); setPulsingIdx(i); setTimeout(() => setPulsingIdx(p => p === i ? null : p), 150); }}
onMouseEnter={() => setHoverIdx(i)}
onMouseLeave={() => setHoverIdx(null)}
style={{
minHeight: 64, padding: '18px 24px',
background: sel ? '#e8f0fe' : hov ? '#eef2ff' : '#f7f8fa',
border: `2px solid ${sel ? '#1a73e8' : hov ? '#1a73e8' : '#e0e0e0'}`,
borderRadius: 12, fontSize: 20, color: sel ? '#1a73e8' : '#222',
textAlign: 'left', cursor: 'pointer', fontWeight: sel ? 600 : 400,
fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45,
transition: 'background 0.12s, border-color 0.12s',
animation: pulsingIdx === i ? 'answerPulse 0.15s ease-out' : 'none',
}}
>
<span style={{ fontSize: 14, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 20, opacity: 0.7 }}>{i + 1}</span>{choice}
</button>
);
})}
</div>
</div>
<div style={{ borderTop: '1px solid #e0e0e0', boxShadow: '0 -2px 8px rgba(0,0,0,0.06)', padding: '16px 32px', display: 'flex', justifyContent: 'space-between', gap: 14 }}>
<button style={FORFEIT_BTN} onClick={onForfeit}>Forfeit</button>
<div style={{ display: 'flex', gap: 14 }}>
{qIdx > 0 && <button className="quiz-nav-btn" style={NAV_BTN} onClick={onBack}> Back</button>}
{qIdx === STAGE_SIZE - 1 ? (
<button className="quiz-nav-btn" style={{ padding: '14px 40px', borderRadius: 999, fontSize: 17, fontWeight: 700, cursor: 'pointer', border: 'none', fontFamily: 'system-ui,sans-serif', background: '#ff69b4', color: '#000', boxShadow: '0 0 6px #ff69b4aa', animation: 'submitGlow 1.5s infinite alternate' }} onClick={onNext}>Submit ★</button>
) : (
<button className="quiz-nav-btn" style={NAV_BTN} onClick={onNext}>Next </button>
)}
</div>
</div>
</div>
</div>
);
}
function SubmittingOverlay() {
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.85)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9000,
animation: 'done-overlay 1.2s forwards',
}}>
<div style={{
fontSize: 'clamp(48px,8vw,96px)',
fontWeight: 900,
color: '#fff',
fontFamily: '"Russo One", system-ui, sans-serif',
letterSpacing: '-0.02em',
}}>Done.</div>
</div>
);
}
function EmotePicker({ open, onToggle, onSend, onQuickChat }) {
return (
<div style={{
position: 'fixed', right: 20, top: '50%', transform: 'translateY(-50%)',
zIndex: 1400, display: 'flex', alignItems: 'center', gap: 8,
}}>
{open && (
<div style={{
background: '#1a1a2a', border: '1px solid rgba(255,255,255,0.15)',
borderRadius: 16, padding: 16, width: 300,
boxShadow: '0 4px 24px rgba(0,0,0,0.5)',
display: 'flex', flexDirection: 'column', gap: 12,
}}>
{/* Section 1: 2×4 emote grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{Array.from({ length: 8 }, (_, i) => (
<button
key={i}
onClick={() => onSend(`emote_${i + 1}`)}
style={{ height: 100, borderRadius: 12, background: '#2a2a3a', border: '1px solid rgba(255,255,255,0.1)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, transition: 'border-color 0.15s, background 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#c9971c'; e.currentTarget.style.background = '#32324a'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; e.currentTarget.style.background = '#2a2a3a'; }}
>
<div style={{ width: 64, height: 64, background: '#3a3a4a', borderRadius: 8 }} />
</button>
))}
</div>
<div style={{ height: 1, background: 'rgba(255,255,255,0.1)' }} />
{/* Section 2: 1×4 emoji row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
{Array.from({ length: 4 }, (_, i) => (
<button
key={i}
onClick={() => onSend(`emoji_${i + 1}`)}
style={{ height: 64, borderRadius: 10, background: '#2a2a3a', border: '1px solid rgba(255,255,255,0.1)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, transition: 'border-color 0.15s, background 0.15s' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#c9971c'; e.currentTarget.style.background = '#32324a'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; e.currentTarget.style.background = '#2a2a3a'; }}
>
<div style={{ width: 40, height: 40, background: '#3a3a4a', borderRadius: 6 }} />
</button>
))}
</div>
<div style={{ height: 1, background: 'rgba(255,255,255,0.1)' }} />
{/* Section 3: 2×4 quick-chat grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
{['1','2','3','4','5','6','7','8'].map((label) => (
<button
key={label}
onClick={() => onQuickChat(label)}
style={{ height: 44, borderRadius: 8, background: 'rgba(37,99,235,0.2)', border: '1px solid rgba(37,99,235,0.4)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, transition: 'border-color 0.15s, background 0.15s', color: '#93c5fd', fontSize: 14, fontWeight: 700, fontFamily: 'system-ui,sans-serif' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(37,99,235,0.7)'; e.currentTarget.style.background = 'rgba(37,99,235,0.35)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(37,99,235,0.4)'; e.currentTarget.style.background = 'rgba(37,99,235,0.2)'; }}
>
{label}
</button>
))}
</div>
</div>
)}
{/* Toggle button */}
<button
onClick={onToggle}
style={{ width: 56, height: 56, background: '#1a1a2a', border: '1px solid rgba(255,255,255,0.15)', borderRadius: 12, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, transition: 'border-color 0.15s, background 0.15s', flexShrink: 0 }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f5c518'; e.currentTarget.style.background = '#22223a'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(255,255,255,0.15)'; e.currentTarget.style.background = '#1a1a2a'; }}
>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="28" height="20" rx="4" ry="4" stroke="white" strokeWidth="2.5" fill="none"/>
<line x1="7" y1="8" x2="25" y2="8" stroke="white" strokeWidth="1.8" strokeLinecap="round"/>
<line x1="7" y1="12" x2="25" y2="12" stroke="white" strokeWidth="1.8" strokeLinecap="round"/>
<line x1="7" y1="16" x2="20" y2="16" stroke="white" strokeWidth="1.8" strokeLinecap="round"/>
<path d="M13 22 L16 28 L19 22" fill="white" stroke="white" strokeWidth="1.5" strokeLinejoin="round"/>
</svg>
</button>
</div>
);
}
function WrongAnswerCard({ notif, onDismiss }) {
const [fading, setFading] = useState(false);
const dismissRef = useRef(onDismiss);
dismissRef.current = onDismiss;
useEffect(() => {
const t1 = setTimeout(() => setFading(true), 7500);
const t2 = setTimeout(() => dismissRef.current(notif.id), 8000);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [notif.id]);
return (
<div style={{
background: '#fff',
borderRadius: 0,
border: 'none',
borderTop: '3px solid #dc2626',
padding: 24,
minWidth: 420,
maxWidth: 480,
boxShadow: '0 4px 20px rgba(0,0,0,0.18)',
opacity: fading ? 0 : 1,
transition: 'opacity 0.5s',
animation: 'notifBounce 0.35s ease-out',
position: 'relative',
fontFamily: 'system-ui,sans-serif',
}}>
<button
onClick={() => onDismiss(notif.id)}
style={{ position: 'absolute', top: 10, right: 14, background: 'none', border: 'none', color: '#999', fontSize: 20, cursor: 'pointer', padding: 0, lineHeight: 1 }}
>×</button>
<div style={{ fontSize: 17, fontWeight: 700, color: '#111', marginBottom: 12, paddingRight: 28, fontFamily: 'system-ui,sans-serif' }}>
{notif.opponentName} got this question WRONG!
</div>
<div style={{ fontSize: 16, color: '#111', marginBottom: 16, whiteSpace: 'pre-line', lineHeight: 1.6 }}>
{notif.question}
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{notif.choices.map((choice, i) => (
<div
key={i}
style={{
padding: '14px 18px',
borderRadius: 8,
fontSize: 15,
textAlign: 'left',
cursor: 'default',
marginBottom: 8,
width: '100%',
boxSizing: 'border-box',
background: i === notif.correctIndex ? '#f0fdf4' : '#fef2f2',
border: `2px solid ${i === notif.correctIndex ? '#22c55e' : '#fca5a5'}`,
color: i === notif.correctIndex ? '#166534' : '#991b1b',
}}
>
{choice}
</div>
))}
</div>
</div>
);
}
function WrongAnswerNotifications({ notifications, onDismiss }) {
if (!notifications.length) return null;
return (
<div style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1500, display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'flex-end', pointerEvents: 'none' }}>
{notifications.map(n => (
<div key={n.id} style={{ pointerEvents: 'all' }}>
<WrongAnswerCard notif={n} onDismiss={onDismiss} />
</div>
))}
</div>
);
}
function MinecraftChat({ messages, myUserId, gameId, emotePickerOpen, onChatOpen }) {
const [inputActive, setInputActive] = useState(false);
const [inputValue, setInputValue] = useState('');
const [flashKey, setFlashKey] = useState(null);
const inputRef = useRef(null);
const prevLenRef = useRef(0);
const inputActiveRef = useRef(false);
inputActiveRef.current = inputActive;
const onChatOpenRef = useRef(onChatOpen);
onChatOpenRef.current = onChatOpen;
// Close chat when emote picker opens
useEffect(() => {
if (emotePickerOpen) {
setInputActive(false);
setInputValue('');
}
}, [emotePickerOpen]);
useEffect(() => {
if (messages.length > prevLenRef.current && !inputActiveRef.current) {
const newest = messages[messages.length - 1];
if (newest && newest.userId !== myUserId) {
const k = `${newest.sentAt}-${newest.userId}`;
setFlashKey(k);
setTimeout(() => setFlashKey(null), 1000);
}
}
prevLenRef.current = messages.length;
}, [messages.length, myUserId]);
useEffect(() => {
function handler(e) {
if (e.key === '/' && e.target.tagName !== 'INPUT' && !inputActiveRef.current) {
e.preventDefault();
setInputActive(true);
onChatOpenRef.current?.();
}
}
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
useEffect(() => {
if (inputActive) inputRef.current?.focus();
}, [inputActive]);
async function handleSend() {
const trimmed = inputValue.trim();
setInputActive(false);
setInputValue('');
if (!trimmed) return;
try {
await fetch(`${API}/game/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ gameId, message: trimmed }),
});
} catch {}
}
function handleKeyDown(e) {
if (e.key === 'Enter') { e.preventDefault(); handleSend(); }
if (e.key === 'Escape') { setInputActive(false); setInputValue(''); }
}
const shown = messages.slice(-6);
return (
<div style={{ position: 'fixed', bottom: 12, left: 12, width: 340, zIndex: 500, display: 'flex', flexDirection: 'column', gap: 2, alignItems: 'flex-start' }}>
{shown.map((msg, i) => {
const isMe = msg.userId === myUserId;
const k = `${msg.sentAt}-${msg.userId}`;
const isFlash = k === flashKey;
return (
<div key={`${k}-${i}`} style={{ maxWidth: '100%', pointerEvents: 'none' }}>
<span style={{
display: 'block',
background: 'rgba(0,0,0,0.55)',
padding: '2px 6px',
borderRadius: 2,
fontSize: 13,
fontFamily: 'system-ui,sans-serif',
lineHeight: 1.5,
wordBreak: 'break-word',
boxShadow: isFlash ? '0 0 0 1px rgba(255,255,255,0.85)' : 'none',
}}>
<span style={{ color: isMe ? '#f5c518' : '#67e8f9', fontWeight: 700 }}>{msg.username}</span>
<span style={{ color: '#fff' }}>: {msg.text}</span>
</span>
</div>
);
})}
{inputActive ? (
<input
ref={inputRef}
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => { setInputActive(false); setInputValue(''); }}
placeholder="Press Enter to send, Esc to cancel"
maxLength={120}
style={{
width: '100%',
background: 'rgba(0,0,0,0.7)',
color: '#fff',
border: 'none',
outline: 'none',
padding: '6px 8px',
fontFamily: 'system-ui,sans-serif',
fontSize: 13,
boxSizing: 'border-box',
}}
/>
) : (
<div
onClick={() => { setInputActive(true); onChatOpenRef.current?.(); }}
style={{
width: '100%',
background: 'rgba(0,0,0,0.55)',
color: 'rgba(255,255,255,0.4)',
padding: '8px 12px',
borderRadius: 4,
fontSize: 13,
fontFamily: 'system-ui,sans-serif',
cursor: 'text',
boxSizing: 'border-box',
userSelect: 'none',
}}
>
Press / to chat...
</div>
)}
</div>
);
}
function DefeatOverlay({ opponentName, onViewResults }) {
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000, animation: 'overlayFade 0.4s ease-out' }}>
<div style={{ background: '#fff', borderRadius: 16, padding: '40px 48px', textAlign: 'center', maxWidth: 380, width: '90%', fontFamily: 'system-ui,sans-serif', boxShadow: '0 8px 32px rgba(0,0,0,0.2)', animation: 'resultReveal 0.3s ease-out 0.15s both' }}>
<div style={{ fontSize: 64, marginBottom: 12 }}>💀</div>
<h1 style={{ fontSize: 32, fontWeight: 800, color: '#c62828', margin: '0 0 8px' }}>Defeated!</h1>
<p style={{ fontSize: 16, color: '#555', margin: '0 0 24px', lineHeight: 1.5 }}>{opponentName} finished before you.</p>
<button onClick={onViewResults} style={{ padding: '12px 36px', fontSize: 16, fontWeight: 600, background: '#1a73e8', color: '#fff', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>See Results</button>
</div>
</div>
);
}
function WaitingScreen({ opponentName, opponentProgress, opponentDced, opponentConnected, onForfeit, emoteBubble, opponentStage }) {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 24 }}>
{opponentDced
? <div style={{ fontSize: 48 }}>🔌</div>
: <div style={{ width: 48, height: 48, borderRadius: '50%', border: '3px solid #e0e0e0', borderTopColor: '#ff69b4', animation: 'spin 0.6s linear infinite' }} />
}
<h2 style={{ fontSize: 22, fontWeight: 600, color: opponentDced ? '#c62828' : '#333', margin: 0 }}>
{opponentDced ? `${opponentName} disconnected!` : `Waiting for ${opponentName}...`}
</h2>
<p style={{ fontSize: 15, color: '#888', margin: 0 }}>
{opponentDced ? 'Awarding victory...' : 'You finished! Now waiting for your opponent to complete.'}
</p>
<div style={{ width: '100%', maxWidth: 480, marginTop: 8 }}>
<OpponentProgressStrip progress={opponentProgress} total={STAGE_SIZE} name={opponentName} dced={opponentDced} disconnected={!opponentConnected && !opponentDced} emoteBubble={emoteBubble} stageLabel={opponentStage} />
</div>
<button onClick={onForfeit} style={{ marginTop: 16, ...FORFEIT_BTN }}>Forfeit & Leave</button>
</div>
);
}
function ForfeitConfirmModal({ onConfirm, onCancel }) {
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 3000 }}>
<div style={{ background: '#fff', borderRadius: 16, padding: '36px 40px', textAlign: 'center', maxWidth: 360, width: '90%', fontFamily: 'system-ui,sans-serif', boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>🏳️</div>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#c62828', margin: '0 0 8px' }}>Forfeit?</h2>
<p style={{ fontSize: 15, color: '#555', margin: '0 0 24px', lineHeight: 1.5 }}>This counts as a loss and will reduce your ELO.</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
<button onClick={onCancel} style={{ padding: '11px 28px', fontSize: 15, fontWeight: 600, background: '#f5f5f5', color: '#555', border: '1.5px solid #ccc', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>Cancel</button>
<button onClick={onConfirm} style={{ padding: '11px 28px', fontSize: 15, fontWeight: 700, background: '#c62828', color: '#fff', border: 'none', borderRadius: 999, cursor: 'pointer', fontFamily: 'system-ui,sans-serif' }}>Forfeit</button>
</div>
</div>
</div>
);
}
function CompleteScreen({ won, opponentName, duration, eloChange, newElo, opponentDced, youDced, onDashboard, onPlayAgain }) {
const [reveal, setReveal] = useState(false);
useEffect(() => {
const t = setTimeout(() => setReveal(true), 60);
return () => clearTimeout(t);
}, []);
const fmtDuration = duration > 0
? `${String(Math.floor(duration / 1000 / 60)).padStart(2, '0')}:${String(Math.floor(duration / 1000) % 60).padStart(2, '0')}`
: '';
const accent = won ? '#f5c518' : '#dc2626';
const accentGlow = won ? 'rgba(245,197,24,0.45)' : 'rgba(220,38,38,0.5)';
const accentDim = won ? 'rgba(245,197,24,0.09)' : 'rgba(220,38,38,0.09)';
const accentHov = won ? 'rgba(245,197,24,0.18)' : 'rgba(220,38,38,0.18)';
const eloVal = eloChange !== 0 ? `${eloChange > 0 ? '+' : ''}${eloChange}` : '';
const eloColor = eloChange > 0 ? '#22c55e' : eloChange < 0 ? '#ef4444' : '#555';
const stats = [
{ label: 'Time', value: fmtDuration, color: '#b8bcd8' },
{ label: 'ELO', value: eloVal, color: eloColor },
{ label: 'Rating', value: newElo != null ? newElo.toLocaleString() : '', color: '#b8bcd8' },
];
const opponentLine = won
? (opponentDced ? 'OPPONENT DISCONNECTED' : 'YOU DEFEATED')
: 'DEFEATED BY';
return (
<div style={{
minHeight: '100vh', background: '#07080f',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
fontFamily: 'system-ui,sans-serif', position: 'relative', overflow: 'hidden',
}}>
{/* Atmospheric top-glow */}
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, height: '55%', pointerEvents: 'none',
background: won
? 'radial-gradient(ellipse 80% 70% at 50% -15%, rgba(245,197,24,0.22) 0%, transparent 65%)'
: 'radial-gradient(ellipse 80% 70% at 50% -15%, rgba(190,0,0,0.28) 0%, transparent 65%)',
}} />
{/* Hard vignette on edges */}
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
background: 'radial-gradient(ellipse 100% 100% at 50% 50%, transparent 55%, rgba(0,0,0,0.65) 100%)',
}} />
{/* Subtle scanline texture */}
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
backgroundImage: 'repeating-linear-gradient(0deg, transparent, transparent 3px, rgba(0,0,0,0.06) 3px, rgba(0,0,0,0.06) 4px)',
}} />
{/* Flash on mount */}
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 5,
background: won ? 'rgba(245,197,24,0.10)' : 'rgba(180,0,0,0.14)',
opacity: reveal ? 0 : 1, transition: 'opacity 0.5s ease-out',
}} />
{/* Content */}
<div style={{
position: 'relative', zIndex: 2,
width: '100%', maxWidth: 500, padding: '0 28px', boxSizing: 'border-box', textAlign: 'center',
opacity: reveal ? 1 : 0, transform: reveal ? 'none' : 'translateY(24px)',
transition: 'opacity 0.5s ease-out, transform 0.5s ease-out',
}}>
{/* Eyebrow */}
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: '0.32em', textTransform: 'uppercase',
color: `${accent}55`, marginBottom: 20,
}}>
Ranked Match
</div>
{/* Icon */}
<div style={{
fontSize: 72, lineHeight: 1, marginBottom: 10,
filter: `drop-shadow(0 0 32px ${accentGlow})`,
animation: 'resultReveal 0.55s cubic-bezier(.22,1,.36,1) 0.1s both',
}}>
{won ? '🏆' : '💀'}
</div>
{/* Title */}
<div style={{
fontSize: 'clamp(72px,12vw,112px)', fontWeight: 900,
color: accent, letterSpacing: '0.04em', lineHeight: 0.88,
fontFamily: '"Russo One","Impact",system-ui,sans-serif',
textShadow: `0 0 48px ${accentGlow}, 0 4px 0 rgba(0,0,0,0.6)`,
animation: 'defeatTitleIn 0.5s cubic-bezier(.22,1,.36,1) 0.12s both',
}}>
{won ? 'VICTORY' : 'DEFEAT'}
</div>
{/* Accent rule */}
<div style={{
width: 90, height: 2, margin: '22px auto 22px',
background: `linear-gradient(90deg, transparent, ${accent}, transparent)`,
}} />
{/* Opponent */}
<div style={{ marginBottom: 28 }}>
<div style={{
fontSize: 10, fontWeight: 700, letterSpacing: '0.2em',
textTransform: 'uppercase', color: '#2e3450', marginBottom: 8,
}}>
{opponentLine}
</div>
<div style={{
fontSize: 20, fontWeight: 700, letterSpacing: '0.01em',
color: won ? 'rgba(255,255,255,0.82)' : 'rgba(255,110,110,0.9)',
}}>
{opponentName}
</div>
</div>
{/* Stats */}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 8, marginBottom: 32,
animation: 'statsRowIn 0.45s ease-out 0.28s both',
}}>
{stats.map(s => (
<div key={s.label} style={{
background: 'rgba(255,255,255,0.025)',
border: '1px solid rgba(255,255,255,0.055)',
borderRadius: 8, padding: '18px 8px',
}}>
<div style={{
fontSize: 9, fontWeight: 700, letterSpacing: '0.16em',
color: '#252840', textTransform: 'uppercase', marginBottom: 10,
}}>
{s.label}
</div>
<div style={{
fontSize: 22, fontWeight: 700, color: s.color,
fontFamily: 'monospace', letterSpacing: '0.02em',
}}>
{s.value}
</div>
</div>
))}
</div>
{/* Actions */}
<div style={{
display: 'flex', gap: 10, justifyContent: 'center',
animation: 'statsRowIn 0.45s ease-out 0.44s both',
}}>
<button
onClick={onPlayAgain}
style={{
padding: '12px 28px', borderRadius: 5, fontSize: 12, fontWeight: 700,
letterSpacing: '0.12em', textTransform: 'uppercase', cursor: 'pointer',
border: `1px solid ${accent}55`, background: accentDim, color: accent,
fontFamily: 'system-ui,sans-serif', transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = accentHov; e.currentTarget.style.borderColor = `${accent}88`; }}
onMouseLeave={e => { e.currentTarget.style.background = accentDim; e.currentTarget.style.borderColor = `${accent}55`; }}
>
Play Again
</button>
<button
onClick={onDashboard}
style={{
padding: '12px 28px', borderRadius: 5, fontSize: 12, fontWeight: 700,
letterSpacing: '0.12em', textTransform: 'uppercase', cursor: 'pointer',
border: '1px solid rgba(255,255,255,0.09)', background: 'transparent',
color: '#3c4260', fontFamily: 'system-ui,sans-serif',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.2)';
e.currentTarget.style.color = '#7a80a0';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.09)';
e.currentTarget.style.color = '#3c4260';
}}
>
Dashboard
</button>
</div>
</div>
</div>
);
}
function StageTransition({ onNext }) {
useEffect(() => {
const id = setTimeout(onNext, 1500);
return () => clearTimeout(id);
}, [onNext]);
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#fff', fontFamily: 'system-ui,sans-serif', gap: 16 }}>
<p style={{ fontSize: 11, color: '#999', letterSpacing: 3, textTransform: 'uppercase', margin: 0 }}>Stage 1 Complete</p>
<h1 style={{ fontSize: 'clamp(30px,5vw,52px)', fontWeight: 800, color: '#1a73e8', margin: 0, textAlign: 'center' }}>Stage 2 — Get Ready</h1>
</div>
);
}
export default function RankedGame() {
const navigate = useNavigate();
const { state } = useLocation();
// Router state is lost on hard reload — try to reconstruct from sessionStorage
let effectiveState = state;
if (!effectiveState?.gameId || !effectiveState?.questions) {
try {
const savedId = sessionStorage.getItem('ranked_active_game');
if (savedId) {
const savedInit = sessionStorage.getItem(`ranked_init_${savedId}`);
if (savedInit) effectiveState = { gameId: savedId, ...JSON.parse(savedInit) };
}
} catch {}
}
if (!effectiveState?.gameId || !effectiveState?.questions) {
return <Navigate to="/dashboard" replace />;
}
const { gameId, questions: initialQuestionsObj, opponentName: initOppName, opponentAvatar: initOppAvatar, myName: initMyName, myAvatar: initMyAvatar, grade } = effectiveState;
const [phase, setPhase] = useState(() => {
try {
const saved = sessionStorage.getItem('rg_phase_' + gameId);
if (saved && saved !== 'intro1') return saved;
} catch {}
return 'intro1';
});
const [stage, setStage] = useState(() => {
try { const v = sessionStorage.getItem('ranked_stage_' + gameId); return v ? parseInt(v, 10) : 1; } catch { return 1; }
});
const [stage1Questions] = useState(() => initialQuestionsObj?.stage1 ?? []);
const [stage2Questions] = useState(() => initialQuestionsObj?.stage2 ?? []);
const questions = stage === 1 ? stage1Questions : stage2Questions;
const [qIdx, setQIdx] = useState(() => {
try { const v = sessionStorage.getItem('ranked_qidx_' + gameId); return v ? parseInt(v, 10) : 0; } catch { return 0; }
});
const [answers, setAnswers] = useState(() => {
try { const v = sessionStorage.getItem('ranked_answers_' + gameId); return v ? JSON.parse(v) : {}; } catch { return {}; }
});
const [selected, setSelected] = useState(() => {
try {
const savedAnswers = sessionStorage.getItem('ranked_answers_' + gameId);
const savedQIdx = sessionStorage.getItem('ranked_qidx_' + gameId);
const parsedAnswers = savedAnswers ? JSON.parse(savedAnswers) : {};
const parsedQIdx = savedQIdx ? parseInt(savedQIdx, 10) : 0;
return parsedAnswers[parsedQIdx] ?? null;
} catch { return null; }
});
const [opponentProgress, setOpponentProgress] = useState(0);
const [opponentStage, setOpponentStage] = useState(1);
const opponentStageRef = useRef(1);
const [opponentName] = useState(initOppName);
const [opponentAvatar] = useState(initOppAvatar);
const [playerWon, setPlayerWon] = useState(null);
const [showDefeatOverlay, setShowDefeatOverlay] = useState(false);
const [startTime] = useState(() => Date.now());
const [duration, setDuration] = useState(0);
const [eloChange, setEloChange] = useState(0);
const [newElo, setNewElo] = useState(null);
const [opponentDced, setOpponentDced] = useState(false);
const [youDced, setYouDced] = useState(false);
const [opponentConnected, setOpponentConnected] = useState(true);
const [showForfeitConfirm, setShowForfeitConfirm] = useState(false);
const [wrongCount, setWrongCount] = useState(0);
const [resultsViewQ, setResultsViewQ] = useState(0);
// Emote state
const [emotePickerOpen, setEmotePickerOpen] = useState(false);
const [opponentEmoteBubble, setOpponentEmoteBubble] = useState(null);
const lastDisplayedEmoteRef = useRef(null);
// Wrong answer notifications
const [wrongAnswerNotifs, setWrongAnswerNotifs] = useState([]);
const opponentPrevStateRef = useRef({ currentQuestion: 0, correctAnswers: 0 });
const [chatMessages, setChatMessages] = useState([]);
const [myUserId] = useState(() => {
try {
const token = sessionStorage.getItem('ranked_token');
if (!token) return null;
return JSON.parse(atob(token.split('.')[1])).userId;
} catch { return null; }
});
const pollRef = useRef(null);
const completedRef = useRef(false);
const submittedRef = useRef(false);
const [retryBanner, setRetryBanner] = useState(null);
const [timerDisplay, setTimerDisplay] = useState('00:00');
const timerRef = useRef(null);
const timerStartRef = useRef(null);
const stageRef = useRef(1);
stageRef.current = stage;
const questionsRef = useRef([]);
questionsRef.current = questions;
const ssRestoredRef = useRef(sessionStorage.getItem('ranked_qidx_' + gameId) !== null);
const firstPollDoneRef = useRef(false);
useEffect(() => {
sessionStorage.setItem('ranked_answers_' + gameId, JSON.stringify(answers));
}, [answers]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
sessionStorage.setItem('ranked_qidx_' + gameId, String(qIdx));
}, [qIdx]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
sessionStorage.setItem('ranked_stage_' + gameId, String(stage));
}, [stage]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
sessionStorage.setItem('rg_phase_' + gameId, phase);
}, [phase]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (phase === 'done') {
const id = setTimeout(() => setPhase('waiting'), 1200);
return () => clearTimeout(id);
}
}, [phase]);
useEffect(() => {
if (!sessionStorage.getItem('ranked_init_' + gameId)) {
sessionStorage.setItem('ranked_active_game', gameId);
sessionStorage.setItem('ranked_init_' + gameId, JSON.stringify({
questions: initialQuestionsObj,
opponentName: initOppName,
opponentAvatar: initOppAvatar,
myName: initMyName,
myAvatar: initMyAvatar,
grade,
}));
}
const savedTimerStart = sessionStorage.getItem('ranked_timer_start_' + gameId);
const savedPhaseForTimer = sessionStorage.getItem('rg_phase_' + gameId);
if (savedTimerStart) {
timerStartRef.current = parseInt(savedTimerStart, 10);
} else if (savedPhaseForTimer && savedPhaseForTimer !== 'intro1') {
// Restored session that's past the intro start timer now
timerStartRef.current = Date.now();
sessionStorage.setItem('ranked_timer_start_' + gameId, String(timerStartRef.current));
}
// else: timer starts when user clicks Next on intro1, not on mount
timerRef.current = setInterval(() => {
if (!timerStartRef.current) return;
const ms = Date.now() - timerStartRef.current;
const s = Math.floor(ms / 1000);
setTimerDisplay(`${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`);
}, 1000);
pollGameStatus();
return () => {
clearInterval(pollRef.current);
clearInterval(timerRef.current);
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function clearGameStorage() {
['answers', 'qidx', 'stage', 'timer_start', 'init'].forEach(k =>
sessionStorage.removeItem(`ranked_${k}_${gameId}`)
);
sessionStorage.removeItem('rg_phase_' + gameId);
sessionStorage.removeItem('rg_timerstart_' + gameId);
if (sessionStorage.getItem('ranked_active_game') === gameId)
sessionStorage.removeItem('ranked_active_game');
}
function submitProgress(currentQuestion, correctAnswers, finished, timeSpentMs) {
fetch(`${API}/game/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ gameId, currentQuestion, correctAnswers, finished, timeSpentMs, stage: stageRef.current }),
}).catch(() => {});
}
function pollGameStatus() {
pollRef.current = setInterval(() => {
if (completedRef.current) { clearInterval(pollRef.current); return; }
fetch(`${API}/game/status/${gameId}`, { headers: authHeaders() })
.then(r => r.json())
.then(data => {
if (data.opponentDced) setOpponentDced(true);
if (data.youDced) setYouDced(true);
if (data.opponentConnected === false) setOpponentConnected(false);
else if (data.opponentConnected === true && !data.opponentDced) setOpponentConnected(true);
if (data.status === 'completed') {
clearInterval(pollRef.current);
clearInterval(timerRef.current);
const you = data.players.you;
const won = data.youWon === true;
completedRef.current = true;
setDuration(you.timeSpentMs || (Date.now() - startTime));
setEloChange(you.eloChange || 0);
setNewElo(you.newElo ?? null);
clearGameStorage();
setPlayerWon(won);
if (won) {
setPhase('complete');
} else {
setShowDefeatOverlay(true);
}
} else {
const opp = data.players.opponent;
const prevOpp = opponentPrevStateRef.current;
// Wrong answer detection
if (
opp.currentQuestion === prevOpp.currentQuestion + 1 &&
opp.correctAnswers === prevOpp.correctAnswers
) {
const wrongQ = questionsRef.current[prevOpp.currentQuestion];
if (wrongQ) {
const id = Date.now() + Math.random();
setWrongAnswerNotifs(prev => [...prev, {
id,
question: wrongQ.question,
correctIndex: wrongQ.correct,
choices: wrongQ.choices,
opponentName,
timestamp: Date.now(),
}].slice(-3));
setTimeout(() => {
setWrongAnswerNotifs(prev => prev.filter(n => n.id !== id));
}, 8200);
}
}
opponentPrevStateRef.current = { currentQuestion: opp.currentQuestion, correctAnswers: opp.correctAnswers };
setOpponentProgress(opp.currentQuestion + 1);
const newOppStage = opp.currentStage ?? 1;
if (newOppStage !== opponentStageRef.current) {
opponentStageRef.current = newOppStage;
setOpponentStage(newOppStage);
opponentPrevStateRef.current = { currentQuestion: 0, correctAnswers: 0 };
}
// Emote bubble
if (opp.lastEmote && opp.lastEmote !== lastDisplayedEmoteRef.current) {
lastDisplayedEmoteRef.current = opp.lastEmote;
const key = Date.now();
setOpponentEmoteBubble({ text: opp.lastEmote, key });
setTimeout(() => setOpponentEmoteBubble(null), 3100);
}
if (data.chatMessages) {
setChatMessages(data.chatMessages);
}
if (!firstPollDoneRef.current && !ssRestoredRef.current) {
const sp = data.players.you.savedProgress;
if (sp) {
if (sp.currentQuestion > 0) setQIdx(sp.currentQuestion);
if (sp.stage > 1) setStage(sp.stage);
}
}
firstPollDoneRef.current = true;
}
})
.catch(() => {});
}, 1000);
}
function handleNext() {
const newAnswers = { ...answers, [qIdx]: selected };
setAnswers(newAnswers);
const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length;
if (qIdx < STAGE_SIZE - 1) {
submitProgress(qIdx + 1, correctCount, false);
const next = qIdx + 1;
setQIdx(next);
setSelected(newAnswers[next] ?? null);
} else {
const wc = STAGE_SIZE - correctCount;
setWrongCount(wc);
setResultsViewQ(0);
setPhase(phase === 'questions1' ? 'results1' : 'results2');
}
}
function handleBack() {
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
setAnswers(newAnswers);
const prev = qIdx - 1;
const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length;
submitProgress(prev, correctCount, false);
setQIdx(prev);
setSelected(newAnswers[prev] ?? null);
}
function handleJump(targetIdx) {
const newAnswers = selected !== null ? { ...answers, [qIdx]: selected } : answers;
setAnswers(newAnswers);
const correctCount = questions.filter((q, i) => newAnswers[i] === q.correct).length;
submitProgress(targetIdx, correctCount, false);
setQIdx(targetIdx);
setSelected(newAnswers[targetIdx] ?? null);
}
async function handleForfeit() {
setShowForfeitConfirm(false);
clearInterval(pollRef.current);
clearInterval(timerRef.current);
completedRef.current = true;
try {
const r = await fetch(`${API}/game/forfeit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ gameId }),
});
if (r.ok) {
const data = await r.json();
setEloChange(data.eloChange ?? 0);
setNewElo(data.newElo ?? null);
}
} catch {}
clearGameStorage();
setDuration(0);
setPlayerWon(false);
setPhase('complete');
}
async function handleSendEmote(emote) {
setEmotePickerOpen(false);
try {
await fetch(`${API}/game/emote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ gameId, emote }),
});
} catch {}
}
async function handleSendQuickChat(text) {
setEmotePickerOpen(false);
try {
await fetch(`${API}/game/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ gameId, message: text }),
});
} catch {}
}
function dismissNotif(id) {
setWrongAnswerNotifs(prev => prev.filter(n => n.id !== id));
}
const showMultiplayerUI = phase === 'questions1' || phase === 'questions2' || phase === 'waiting';
const resultsQuestion = questions[resultsViewQ] ?? questions[0];
const resultsUserAns = answers[resultsViewQ];
return (
<>
<GlobalStyles />
{phase === 'intro1' && (
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
<div style={{ flex: 1, overflow: 'hidden' }}>
<img src={treeBg} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '64px 56px', background: '#fff', position: 'relative', minHeight: '100vh' }}>
<h1 style={{ fontSize: 'clamp(24px,3vw,38px)', fontWeight: 800, color: '#111', margin: '0 0 20px 0', lineHeight: 1.2 }}>
You are ready to begin Stage 1 in Mathematics.
</h1>
<p style={{ fontSize: 18, color: '#555', margin: 0, lineHeight: 1.6 }}>
Answer the next 11 questions to complete this stage.
</p>
<div style={{ position: 'absolute', bottom: 32, right: 40 }}>
<button
style={NAV_BTN}
onClick={() => {
timerStartRef.current = Date.now();
sessionStorage.setItem('ranked_timer_start_' + gameId, String(timerStartRef.current));
setPhase('questions1');
}}
>Next </button>
</div>
</div>
</div>
)}
{(phase === 'questions1' || phase === 'questions2') && (
<QuestionScreen
question={questions[qIdx]} qIdx={qIdx} answers={answers} selected={selected}
onSelect={setSelected} onNext={handleNext} onBack={handleBack} onJump={handleJump}
opponentProgress={opponentProgress} opponentName={opponentName}
opponentDced={opponentDced} opponentConnected={opponentConnected}
onForfeit={() => setShowForfeitConfirm(true)}
emoteBubble={opponentEmoteBubble}
stageNum={stage} timerDisplay={timerDisplay} retryBanner={retryBanner}
opponentStage={opponentStage} grade={grade}
/>
)}
{(phase === 'results1' || phase === 'results2') && (
<div style={{ minHeight: '100vh', background: '#fff', fontFamily: 'system-ui,-apple-system,sans-serif', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '28px 40px 0' }}>
<h1 style={{ fontSize: 28, fontWeight: 800, color: '#111', margin: '0 0 16px 0' }}>Mathematics</h1>
<div style={{ height: 1, background: '#e0e0e0', marginBottom: 16 }} />
{wrongCount > 0 && (
<p style={{ fontSize: 18, fontWeight: 600, color: '#c62828', margin: '0 0 16px 0' }}>
{wrongCount} {wrongCount === 1 ? 'question was' : 'questions were'} incorrect.
</p>
)}
</div>
<div style={{ flex: 1, display: 'flex', padding: '0 40px', gap: 32, overflow: 'auto' }}>
<div style={{ flex: '0 0 40%', paddingBottom: 32, paddingTop: 16, animation: 'slideInLeft 0.3s ease-out' }}>
<p style={{ fontSize: 20, color: '#111', lineHeight: 1.7, whiteSpace: 'pre-line', fontWeight: 500, margin: '0 0 20px 0' }}>
{resultsQuestion?.question}
</p>
<div style={resultsQuestion?.layout === 'grid' ? { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 } : { display: 'flex', flexDirection: 'column', gap: 10 }}>
{(resultsQuestion?.choices ?? []).map((choice, i) => {
const sel = resultsUserAns === i;
return (
<button
key={i}
onClick={() => setAnswers(prev => ({ ...prev, [resultsViewQ]: i }))}
style={{ minHeight: 56, padding: '14px 20px', background: sel ? '#e8f0fe' : '#f7f8fa', border: `2px solid ${sel ? '#1a73e8' : '#e0e0e0'}`, borderRadius: 12, fontSize: 17, color: sel ? '#1a73e8' : '#222', textAlign: 'left', cursor: 'pointer', fontWeight: sel ? 600 : 400, fontFamily: 'system-ui,-apple-system,sans-serif', lineHeight: 1.45, transition: 'background 0.12s, border-color 0.12s' }}
>
<span style={{ fontSize: 13, fontWeight: 700, color: sel ? '#1a73e8' : '#aaa', marginRight: 16, opacity: 0.7 }}>{i + 1}</span>{choice}
</button>
);
})}
</div>
<div style={{ marginTop: 16, padding: '10px 14px', background: '#f5f5f5', borderRadius: 8, fontSize: 13, color: '#666', fontFamily: 'system-ui,sans-serif' }}>
Your answer: {resultsUserAns != null ? resultsQuestion?.choices[resultsUserAns] : '—'}
</div>
</div>
<div style={{ flex: '0 0 58%', paddingBottom: 32, paddingTop: 16, animation: 'slideInRight 0.3s ease-out 0.05s both' }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#333', margin: '0 0 16px 0' }}>
{phase === 'results1' ? 'Stage 1' : 'Stage 2'}
</h2>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{Array.from({ length: STAGE_SIZE }, (_, i) => {
const active = i === resultsViewQ;
return (
<button
key={i}
onClick={() => setResultsViewQ(i)}
style={{ padding: active ? '8px 18px' : '6px 4px', borderRadius: active ? 8 : 0, border: 'none', background: active ? '#1a73e8' : 'transparent', color: active ? '#fff' : '#1a73e8', fontSize: 15, fontWeight: active ? 700 : 500, cursor: 'pointer', fontFamily: 'system-ui,sans-serif', textDecoration: active ? 'none' : 'underline', transition: 'background 0.12s' }}
>
Question {i + 1}
</button>
);
})}
</div>
</div>
</div>
<div style={{ padding: '16px 40px', borderTop: '1px solid #e0e0e0', display: 'flex', justifyContent: 'flex-end' }}>
{wrongCount === 0 ? (
<button
style={NAV_BTN}
onClick={() => {
if (phase === 'results1') {
submitProgress(STAGE_SIZE, STAGE_SIZE, true, timerStartRef.current ? Date.now() - timerStartRef.current : 0);
setPhase('intro2');
setQIdx(0);
setAnswers({});
setSelected(null);
setResultsViewQ(0);
} else {
const elapsed = timerStartRef.current ? Date.now() - timerStartRef.current : 0;
clearInterval(timerRef.current);
submittedRef.current = true;
submitProgress(STAGE_SIZE, STAGE_SIZE, true, elapsed);
setPhase('done');
setResultsViewQ(0);
}
}}
>Continue </button>
) : (
<button
style={NAV_BTN}
onClick={() => {
setPhase(phase === 'results1' ? 'questions1' : 'questions2');
setQIdx(0);
setSelected(answers[0] ?? null);
setResultsViewQ(0);
}}
>Try Again</button>
)}
</div>
</div>
)}
{phase === 'intro2' && (
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: 'system-ui,-apple-system,sans-serif' }}>
<div style={{ flex: 1, overflow: 'hidden' }}>
<img src={treeBg} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
<div key="intro2-content" style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '64px 56px', background: '#fff', position: 'relative', minHeight: '100vh' }}>
<h1 style={{ fontSize: 'clamp(24px,3vw,38px)', fontWeight: 800, color: '#111', margin: '0 0 20px 0', lineHeight: 1.2, animation: 'stageReveal 0.4s ease-out 0.1s both' }}>
You are ready to begin Stage 2 in Mathematics.
</h1>
<p style={{ fontSize: 18, color: '#555', margin: 0, lineHeight: 1.6, animation: 'stageReveal 0.4s ease-out 0.25s both' }}>
Answer the next 11 questions to complete this stage.
</p>
<div style={{ position: 'absolute', bottom: 32, right: 40 }}>
<button
style={{ ...NAV_BTN, animation: 'stageReveal 0.4s ease-out 0.4s both' }}
onClick={() => {
stageRef.current = 2;
setStage(2);
setQIdx(0);
setAnswers({});
setSelected(null);
setRetryBanner(null);
setPhase('questions2');
}}
>Next </button>
</div>
</div>
</div>
)}
{phase === 'done' && <SubmittingOverlay />}
{phase === 'waiting' && (
<WaitingScreen
opponentName={opponentName} opponentProgress={opponentProgress}
opponentDced={opponentDced} opponentConnected={opponentConnected}
onForfeit={() => setShowForfeitConfirm(true)}
emoteBubble={opponentEmoteBubble}
opponentStage={opponentStage}
/>
)}
{phase === 'complete' && (
<CompleteScreen
won={playerWon} opponentName={opponentName} duration={duration}
eloChange={eloChange} newElo={newElo} opponentDced={opponentDced} youDced={youDced}
onDashboard={() => { clearGameStorage(); navigate('/dashboard'); }}
onPlayAgain={() => { clearGameStorage(); navigate('/queue', { state: { grade } }); }}
/>
)}
{showDefeatOverlay && (
<DefeatOverlay opponentName={opponentName} onViewResults={() => { setShowDefeatOverlay(false); setPhase('complete'); }} />
)}
{showForfeitConfirm && (
<ForfeitConfirmModal onConfirm={handleForfeit} onCancel={() => setShowForfeitConfirm(false)} />
)}
{showMultiplayerUI && (
<>
<EmotePicker
open={emotePickerOpen}
onToggle={() => setEmotePickerOpen(v => !v)}
onSend={handleSendEmote}
onQuickChat={handleSendQuickChat}
/>
<MinecraftChat messages={chatMessages} myUserId={myUserId} gameId={gameId} emotePickerOpen={emotePickerOpen} onChatOpen={() => setEmotePickerOpen(false)} />
<WrongAnswerNotifications notifications={wrongAnswerNotifs} onDismiss={dismissNotif} />
</>
)}
</>
);
}