// Shared ZERO ERROR game. Loaded before the main application. function ZeroErrorBrandMark({ white = false, className = '' }) { const primary = window.ZERO_ERROR_CONFIG?.brand?.[white ? 'whiteMark' : 'blackMark']; const fallback = white ? 'blanco.png' : 'negro.png'; const [src, setSrc] = React.useState(primary || fallback); return { if (src !== fallback) setSrc(fallback); else event.currentTarget.style.visibility = 'hidden'; }} />; } function ZeroErrorFigure({ state }) { return {state === 'won' && } ; } function ZeroErrorIllustration({ kind, state }) { const won = state === 'won'; const descriptions = { bridge: 'Un personaje cruza un puente suspendido.', boulder: 'Un personaje evita una roca que rueda cuesta abajo.', sea: 'Un personaje navega cerca de un tiburón.', press: 'Un personaje evita una prensa que baja desde el techo.' }; return {kind === 'bridge' && <> } {kind === 'boulder' && <> } {kind === 'sea' && <> } {kind === 'press' && <> } {won && } ; } function ZeroErrorScene({ scene, state, paused }) { const [broken, setBroken] = React.useState(false); const video = React.useRef(null); const kind = ['bridge', 'boulder', 'sea', 'press'].includes(scene?.builtin) ? scene.builtin : 'bridge'; const src = scene?.[state === 'lost' ? 'loss' : state === 'won' ? 'win' : 'risk']; React.useEffect(() => setBroken(false), [src]); React.useEffect(() => { if (!video.current) return; if (paused) video.current.pause(); else { const play = video.current.play(); if (play?.catch) play.catch(() => {}); } }, [paused, src]); return
{src && !broken ? (/\.mp4(?:[?#]|$)/i.test(src) ?
; } function ZeroErrorGame({ exercise, secondsPerQuestion = 0, contextLabel, training = false, onClose, onComplete }) { const questions = exercise?.questions || []; const [index, setIndex] = React.useState(0); const [answers, setAnswers] = React.useState([]); const [state, setState] = React.useState(training ? 'playing' : 'ready'); const [timeLeft, setTimeLeft] = React.useState(secondsPerQuestion); const [scene] = React.useState(() => window.ZeroError.pickScene()); const [motionPaused, setMotionPaused] = React.useState(() => Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)); const [pageHidden, setPageHidden] = React.useState(document.hidden); const dialog = React.useRef(null); React.useEffect(() => { const previousOverflow = document.body.style.overflow; const previousFocus = document.activeElement; document.body.style.overflow = 'hidden'; dialog.current?.focus(); return () => { document.body.style.overflow = previousOverflow; if (previousFocus?.isConnected && previousFocus.focus) previousFocus.focus(); }; }, []); React.useEffect(() => { if (dialog.current) dialog.current.scrollTop = 0; }, [state, index]); React.useEffect(() => { const update = () => setPageHidden(document.hidden); document.addEventListener('visibilitychange', update); return () => document.removeEventListener('visibilitychange', update); }, []); const locked = React.useRef(false); const sent = React.useRef(false); const deadline = React.useRef(training ? Date.now() + secondsPerQuestion * 1000 : 0); const question = questions[index]; const lastAnswer = answers[answers.length - 1]; const notify = result => { if (!sent.current) { sent.current = true; onComplete?.(result); } }; React.useEffect(() => { if (!training || (state !== 'won' && state !== 'lost')) return; const id = window.setTimeout(() => notify(window.ZeroError.result(questions, answers)), 1100); return () => window.clearTimeout(id); }, [state, training]); const respond = (value, timedOut = false) => { if (locked.current || state !== 'playing' || !question) return; locked.current = true; const expired = timedOut || (secondsPerQuestion > 0 && Date.now() >= deadline.current); const isCorrect = !expired && value === question.correct; const entry = { ...question, userAnswer:expired ? null : value, isCorrect, timedOut:expired, learningId:question.learningId||question.id,sourceQuestionNumber:question.sourceQuestionNumber || index + 1, correctAnswerLabel:question.options.find(o => o.value === question.correct)?.label, explanation:expired ? 'Se acabó el tiempo.' : question.corrections?.[value] || '' }; const next = [...answers, entry]; setAnswers(next); if (!isCorrect || index + 1 === questions.length) { setState(isCorrect ? 'won' : 'lost'); // Level progression is saved immediately; training continues on the result button. if (!training) notify(window.ZeroError.result(questions, next)); } else { setIndex(i => i + 1); deadline.current = Date.now() + secondsPerQuestion * 1000; setTimeLeft(secondsPerQuestion); } }; React.useEffect(() => { locked.current = false; }, [index]); React.useEffect(() => { if (state !== 'playing' || !secondsPerQuestion) return; const id = window.setInterval(() => { const remaining = Math.max(0, Math.ceil((deadline.current - Date.now()) / 1000)); setTimeLeft(remaining); if (!remaining) respond(null, true); }, 250); return () => window.clearInterval(id); }, [state, index, secondsPerQuestion]); const finish = () => { if (training) notify(window.ZeroError.result(questions, answers)); else onClose?.(); }; const leave = () => { if (state === 'won' || state === 'lost') { finish(); return; } if (state === 'playing') { locked.current = true; setState('lost'); if (!training) notify(window.ZeroError.result(questions, answers)); return; } if (training) { notify(window.ZeroError.result(questions, [])); return; } onClose?.(); }; const handleDialogKeys = event => { if (event.key === 'Escape') { event.preventDefault(); leave(); } if (event.key !== 'Tab') return; const buttons = Array.from(dialog.current.querySelectorAll('button:not(:disabled)')); const first = buttons[0], last = buttons[buttons.length - 1]; if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current)) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } }; return
{training ? 'ENGLISH LEVEL TRAINING' : 'PASO A PASO'}

ZERO ERROR

Todos Bilingües. Así de fácil.
01 VIDAUn error termina la ronda.
a.isCorrect).length} de ${questions.length} correctas`}>{questions.map((q, i) => )}

{contextLabel || exercise?.title}

{state === 'ready' &&
EL RETO

10 sin equivocarte.
¡Dale!

{training ? 'Tres preguntas. Un solo error anula los tres puntos. Después continúa tu ruta.' : `Supera las ${questions.length} preguntas sin fallar para desbloquear la siguiente casilla.`}

{secondsPerQuestion > 0 &&

{secondsPerQuestion} segundos por pregunta.

}
} {state === 'playing' && question && <>
PREGUNTA {String(index + 1).padStart(2, '0')} / {questions.length}{secondsPerQuestion > 0 && {timeLeft}s}
{secondsPerQuestion > 0 &&
} {question.media&&
{/\.(mp4|webm)(?:[?#]|$)/i.test(question.media)?
}

{question.question}

{question.options.map((option, i) => )}
} {(state === 'lost' || state === 'won') &&
{state === 'won' ? 'MISIÓN CUMPLIDA' : 'RONDA TERMINADA'}

{state === 'won' ? 'IMPECABLE.' : training ? 'RONDA PERDIDA' : 'Fíjate por qué te equivocaste. ↓'}

{state === 'won' ? `${questions.length} de ${questions.length}. Cero errores.` : training ? '0 de 3 puntos. Tu ruta continúa.' : 'Esta casilla sigue pendiente. Vuelve a intentarlo desde el mapa.'}

{!training && state === 'lost' && lastAnswer?.isCorrect === false &&
{lastAnswer.timedOut ? 'Tiempo agotado.' : 'Respuesta correcta:'} {lastAnswer.correctAnswerLabel}

{lastAnswer.explanation}

}
}
; } function ZeroErrorExerciseModal({ exercise, onClose, onSubmitResult, title }) { return { const questionResults = result.answers.map(a => ({learningId:a.learningId,questionNumber:a.sourceQuestionNumber, masterIds:a.masterIds||[],correct:a.isCorrect, primaryTopics:a.primaryTopics, additionalTopics:a.additionalTopics})); onSubmitResult?.(result.checked ? 100 : 0, {...result, score:result.checked ? 100 : 0, questionResults, exercise}); }}/>; } function MiniZeroErrorChallengeModal({ isOpen, exercise, secondsPerQuestion, contextLabel, onClose, onComplete }) { if (!isOpen) return null; return ; }