1382 lines
64 KiB
React
1382 lines
64 KiB
React
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} />
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|