feat: Rediseño completo de UI, SCORM y ajustes de experiencia

This commit is contained in:
2026-07-28 15:29:37 -06:00
parent ba8f7e5059
commit 0525ec8c8c
42 changed files with 1638 additions and 630 deletions
+3 -3
View File
@@ -2,12 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" href="/img/logo-solo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ruleta</title> <title>Ruleta de Sostenibilidad TIP</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+31 -4
View File
@@ -25,6 +25,37 @@
"vite": "^8.1.1" "vite": "^8.1.1"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1282,7 +1313,6 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2102,7 +2132,6 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2144,7 +2173,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2307,7 +2335,6 @@
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.5", "picomatch": "^4.0.5",
+1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"postbuild": "node scripts/generate-scorm-manifest.js",
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview" "preview": "vite preview"
}, },
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+2 -2
View File
@@ -13,9 +13,9 @@
<organizations default="ruleta_org"> <organizations default="ruleta_org">
<organization identifier="ruleta_org"> <organization identifier="ruleta_org">
<title>Ruleta Interactiva</title> <title>Ruleta de Sostenibilidad TIP</title>
<item identifier="item_1" identifierref="resource_1"> <item identifier="item_1" identifierref="resource_1">
<title>Ruleta Interactiva</title> <title>Ruleta de Sostenibilidad TIP</title>
<adlcp:masteryscore>80</adlcp:masteryscore> <adlcp:masteryscore>80</adlcp:masteryscore>
</item> </item>
</organization> </organization>
+53
View File
@@ -0,0 +1,53 @@
import { readdirSync, statSync, readFileSync, writeFileSync } from 'fs'
import { join, relative, sep } from 'path'
const DIST = join(import.meta.dirname, '..', 'dist')
const MANIFEST_TEMPLATE = join(import.meta.dirname, '..', 'public', 'imsmanifest.xml')
const MANIFEST_OUT = join(DIST, 'imsmanifest.xml')
/**
* Escanea recursivamente dist/ y genera un <file href="..."/> por cada archivo,
* usando '/' como separador (requerido por SCORM).
*/
function scanFiles(dir, basePath = '') {
const entries = readdirSync(dir)
const files = []
for (const entry of entries) {
const full = join(dir, entry)
const relativePath = basePath ? `${basePath}/${entry}` : entry
if (statSync(full).isDirectory()) {
files.push(...scanFiles(full, relativePath))
} else if (entry !== 'imsmanifest.xml') {
// SCORM usa '/' como separador de ruta
files.push(relativePath.split(sep).join('/'))
}
}
return files
}
// Leer template del manifiesto
let manifest = readFileSync(MANIFEST_TEMPLATE, 'utf-8')
// Escanear archivos en dist/
const distFiles = scanFiles(DIST)
console.log(`📁 Archivos encontrados en dist/: ${distFiles.length}`)
// Generar los <file> elements
const fileElements = distFiles
.map((f) => ` <file href="${f}"/>`)
.join('\n')
// Reemplazar el bloque de resources en el manifiesto
// Busca <resource ...> y reemplaza su contenido de <file> manteniendo el resto
const resourceRegex = /(<resource\s+identifier="resource_1"[^>]*>)([\s\S]*?)(<\/resource>)/
manifest = manifest.replace(resourceRegex, (_, open, _inner, close) => {
return `${open}\n${fileElements}\n ${close}`
})
// Escribir manifiesto actualizado en dist/
writeFileSync(MANIFEST_OUT, manifest, 'utf-8')
console.log('✅ imsmanifest.xml generado con todos los archivos del build')
console.log(`${MANIFEST_OUT}`)
+2
View File
@@ -0,0 +1,2 @@
// @ts-nocheck
import{readdirSync,statSync,readFileSync,writeFileSync}from"fs";import{join,relative,sep}from"path";const DIST=join(import.meta.dirname,"..","dist"),MANIFEST_TEMPLATE=join(import.meta.dirname,"..","public","imsmanifest.xml"),MANIFEST_OUT=join(DIST,"imsmanifest.xml");function scanFiles(e,i=""){const s=readdirSync(e),n=[];for(const o of s){const s=join(e,o),t=i?`${i}/${o}`:o;statSync(s).isDirectory()?n.push(...scanFiles(s,t)):"imsmanifest.xml"!==o&&n.push(t.split(sep).join("/"))}return n}let manifest=readFileSync(MANIFEST_TEMPLATE,"utf-8");const distFiles=scanFiles(DIST);console.log(`📁 Archivos encontrados en dist/: ${distFiles.length}`);const fileElements=distFiles.map(e=>` <file href="${e}"/>`).join("\n"),resourceRegex=/(<resource\s+identifier="resource_1"[^>]*>)([\s\S]*?)(<\/resource>)/;manifest=manifest.replace(resourceRegex,(e,i,s,n)=>`${i}\n${fileElements}\n ${n}`),writeFileSync(MANIFEST_OUT,manifest,"utf-8"),console.log("✅ imsmanifest.xml generado con todos los archivos del build"),console.log(`${MANIFEST_OUT}`);
+1 -184
View File
@@ -1,184 +1 @@
.counter { /* App.css — Los estilos del proyecto se manejan con Tailwind CSS (ver index.css) */
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+74 -14
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import MainLayout from './layouts/MainLayout' import ScaleWrapper from './components/layout/ScaleWrapper'
import Intro from './components/screens/Intro'
import Cover from './components/screens/Cover' import Cover from './components/screens/Cover'
import Game from './components/screens/Game' import Game from './components/screens/Game'
import Result from './components/screens/Result' import Result from './components/screens/Result'
@@ -7,7 +8,7 @@ import { consumirIntento, intentosAgotados } from './hooks/useIntentos'
import { setScore, setLessonStatus, finishSCORM } from './scorm/scorm' import { setScore, setLessonStatus, finishSCORM } from './scorm/scorm'
import { useSCORM } from './hooks/useSCORM' import { useSCORM } from './hooks/useSCORM'
type Screen = 'cover' | 'game' | 'result' type Screen = 'intro' | 'cover' | 'game' | 'result'
interface ResultState { interface ResultState {
aprobado: boolean aprobado: boolean
@@ -17,20 +18,24 @@ interface ResultState {
export default function App() { export default function App() {
useSCORM() useSCORM()
const [screen, setScreen] = useState<Screen>(() => const [screen, setScreen] = useState<Screen>(() =>
intentosAgotados() ? 'result' : 'cover' intentosAgotados() ? 'result' : 'intro'
) )
const [resultState, setResultState] = useState<ResultState>({ const [resultState, setResultState] = useState<ResultState>({
aprobado: false, aprobado: false,
sinIntentos: intentosAgotados(), sinIntentos: intentosAgotados(),
}) })
function handleIntroContinue() {
setScreen('cover')
}
function handleStart() { function handleStart() {
consumirIntento() consumirIntento()
setScreen('game') setScreen('game')
} }
function handleGameEnd(aprobado: boolean, correctas: number) { function handleGameEnd(aprobado: boolean, correctas: number) {
setScore(correctas * 10, 100, 0) setScore(Math.round((correctas / 12) * 100), 100, 0)
setLessonStatus(aprobado ? 'passed' : 'failed') setLessonStatus(aprobado ? 'passed' : 'failed')
finishSCORM() finishSCORM()
setResultState({ aprobado, sinIntentos: intentosAgotados() }) setResultState({ aprobado, sinIntentos: intentosAgotados() })
@@ -42,16 +47,71 @@ export default function App() {
} }
return ( return (
<MainLayout> <>
{screen === 'cover' && <Cover onStart={handleStart} />} {screen === 'intro' && (
{screen === 'game' && <Game onEnd={handleGameEnd} />} <ScaleWrapper
{screen === 'result' && ( backgroundImage="./img/fondo1.jpg"
<Result barProps={{
aprobado={resultState.aprobado} preguntaActual: 1,
sinIntentos={resultState.sinIntentos} totalPreguntas: 4,
onReintentar={handleReintentar} mensaje: 'Da clic en la flecha para continuar',
/> puedeAnterior: false,
puedeSiguiente: true,
onNext: handleIntroContinue,
}}
>
<Intro />
</ScaleWrapper>
)} )}
</MainLayout> {screen === 'cover' && (
<ScaleWrapper
backgroundImage="./img/fondo2.jpg"
barProps={{
preguntaActual: 2,
totalPreguntas: 4,
mensaje: 'Presiona Comenzar para iniciar',
puedeAnterior: true,
onPrev: () => setScreen('intro'),
puedeSiguiente: true,
onNext: handleStart,
}}
>
<Cover onStart={handleStart} />
</ScaleWrapper>
)}
{screen === 'game' && (
<ScaleWrapper
backgroundImage="./img/fondo3.jpg"
barProps={{
preguntaActual: 3,
totalPreguntas: 4,
mensaje: 'Gira la ruleta y responde correctamente',
puedeAnterior: false,
puedeSiguiente: false,
}}
>
<Game onEnd={handleGameEnd} />
</ScaleWrapper>
)}
{screen === 'result' && (
<ScaleWrapper
backgroundImage="./img/fondo4.jpg"
barProps={{
preguntaActual: 4,
totalPreguntas: 4,
mensaje: 'Puedes cerrar esta ventana',
puedeAnterior: true,
onPrev: () => setScreen('cover'),
puedeSiguiente: false,
}}
>
<Result
aprobado={resultState.aprobado}
sinIntentos={resultState.sinIntentos}
onReintentar={handleReintentar}
/>
</ScaleWrapper>
)}
</>
) )
} }
+166
View File
@@ -0,0 +1,166 @@
import { useState, useEffect } from 'react'
import type { Pregunta, Categoria } from '../../types'
interface FeedbackModalProps {
pregunta: Pregunta
categoria: Categoria
feedbackCorrecto: boolean
tiempoAgotado: boolean
onCerrar: () => void
}
export default function FeedbackModal({
pregunta,
categoria: _categoria,
feedbackCorrecto,
tiempoAgotado,
onCerrar,
}: FeedbackModalProps) {
const [visible, setVisible] = useState(false)
useEffect(() => {
const t = setTimeout(() => setVisible(true), 10)
return () => clearTimeout(t)
}, [])
const esCorrecto = feedbackCorrecto
const label = esCorrecto ? 'Correcto' : tiempoAgotado ? 'Tiempo agotado' : 'Incorrecto'
const accentColor = esCorrecto ? '#39ad4a' : '#ed1f24'
const imgSrc = esCorrecto ? './img/correcto.png' : './img/incorrecto.png'
const imagenALaIzquierda = !esCorrecto
const reflexion = pregunta.reflexion ?? pregunta.retroalimentacion
const datoCurioso = pregunta.datoCurioso
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 transition-opacity duration-300"
style={{ opacity: visible ? 1 : 0 }}
>
<div className="relative">
{/* ─── Barra superior flotante (25px sobre el modal) ─── */}
<div
className="absolute left-1/2 -translate-x-1/2 z-10 flex items-center gap-3"
style={{ top: '-90px' }}
>
{imagenALaIzquierda && (
<img
src={imgSrc}
alt={label}
style={{ width: '180px', height: '180px', marginRight: '-80px',zIndex: '5' }}
className="object-contain"
/>
)}
<span
className="font-extrabold text-white px-8 py-3 rounded-full tracking-wide"
style={{
backgroundColor: accentColor,
fontSize: '40px',
marginRight: '50px',
paddingRight: '60px',
paddingLeft: '60px'
}}
>
{label}
</span>
{!imagenALaIzquierda && (
<img
src={imgSrc}
alt={label}
style={{ width: '120px', height: '120px', marginLeft:'-100px' }}
className="object-contain"
/>
)}
</div>
{/* ─── Tarjeta del modal ─── */}
<div
className="flex flex-col justify-center transition-all duration-300"
style={{
width: '1700px',
maxWidth: '1824px',
height: '594px',
background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
borderRadius: '18px',
boxShadow: '0 8px 40px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1)',
opacity: visible ? 1 : 0,
transform: visible ? 'translateY(0) scale(1)' : 'translateY(12px) scale(0.96)',
overflow: 'hidden',
}}
>
{/* ─── Botón cerrar ─── */}
<button
onClick={onCerrar}
aria-label="Cerrar"
className="absolute top-4 right-6 cursor-pointer transition-all duration-200 hover:scale-110 hover:opacity-70 z-10"
style={{
background: 'none',
border: 'none',
fontSize: '80px',
color: 'rgb(0, 0, 0)',
padding: '0',
lineHeight: '1',
}}
>
×
</button>
{/* ─── Tiempo agotado ─── */}
{tiempoAgotado && (
<div className="flex flex-col items-center justify-center flex-1 gap-6">
<img
src="./img/incorrecto.png"
alt="Tiempo agotado"
style={{ width: '140px', height: '140px' }}
className="object-contain"
/>
<p
className="text-center"
style={{ color: '#555555', fontSize: '26px', maxWidth: '500px' }}
>
No te preocupes, gira la ruleta de nuevo para continuar con la siguiente pregunta.
</p>
</div>
)}
{/* ─── Reflexión ─── */}
{!tiempoAgotado && (
<div className="px-12 pt-10 pb-6">
<p
className="leading-relaxed"
style={{ color: '#333333', fontSize: '22px', lineHeight: '1.6' }}
>
<strong>Reflexión:</strong> {reflexion}
</p>
</div>
)}
{/* ─── ¿Sabías qué? ─── */}
{!tiempoAgotado && datoCurioso && (
<div className="flex items-stretch mt-4">
<div
className="flex items-center justify-center px-8 py-5 shrink-0"
style={{ backgroundColor: '#383838', borderRadius:'0px 45px 45px 0px' }}
>
<span
className="font-bold text-white whitespace-nowrap"
style={{ fontSize: '28px' }}
>
¿Sabías qué?
</span>
</div>
<div className="flex-1 px-6 py-5">
<p
className="leading-relaxed"
style={{ color: '#444444', fontSize: '22px', lineHeight: '1.6' }}
>
{datoCurioso}
</p>
</div>
</div>
)}
</div>
</div>
</div>
)
}
+6 -38
View File
@@ -1,52 +1,20 @@
import { useEffect, useRef, useState } from 'react'
import type { Pregunta, Categoria } from '../../types' import type { Pregunta, Categoria } from '../../types'
import { TIEMPO_LIMITE } from '../../hooks/useRuleta'
interface QuestionModalProps { interface QuestionModalProps {
pregunta: Pregunta pregunta: Pregunta
categoria: Categoria categoria: Categoria
mostrarFeedback: boolean mostrarFeedback: boolean
feedbackCorrecto: boolean feedbackCorrecto: boolean
tiempoRestante: number
onResponder: (indice: number | null) => void onResponder: (indice: number | null) => void
onCerrar: () => void onCerrar: () => void
} }
const LETRAS = ['A', 'B', 'C', 'D'] const LETRAS = ['A', 'B', 'C', 'D']
const TIEMPO_LIMITE = 30
export default function QuestionModal({ pregunta, categoria, mostrarFeedback, feedbackCorrecto, onResponder, onCerrar }: QuestionModalProps) { export default function QuestionModal({ pregunta, categoria, mostrarFeedback, feedbackCorrecto, tiempoRestante, onResponder, onCerrar }: QuestionModalProps) {
const [tiempo, setTiempo] = useState(TIEMPO_LIMITE) const porcentaje = (tiempoRestante / TIEMPO_LIMITE) * 100
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const tiempoAgotadoRef = useRef(false)
useEffect(() => {
if (mostrarFeedback) {
if (intervalRef.current) clearInterval(intervalRef.current)
return
}
setTiempo(TIEMPO_LIMITE)
tiempoAgotadoRef.current = false
intervalRef.current = setInterval(() => {
setTiempo((prev) => {
if (prev <= 1) {
clearInterval(intervalRef.current!)
return 0
}
return prev - 1
})
}, 1000)
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [pregunta.id, mostrarFeedback])
useEffect(() => {
if (tiempo === 0 && !mostrarFeedback && !tiempoAgotadoRef.current) {
tiempoAgotadoRef.current = true
onResponder(null)
}
}, [tiempo, mostrarFeedback])
const porcentaje = (tiempo / TIEMPO_LIMITE) * 100
const colorBarra = porcentaje > 50 ? '#22c55e' : porcentaje > 25 ? '#f97316' : '#ef4444' const colorBarra = porcentaje > 50 ? '#22c55e' : porcentaje > 25 ? '#f97316' : '#ef4444'
return ( return (
@@ -66,7 +34,7 @@ export default function QuestionModal({ pregunta, categoria, mostrarFeedback, fe
<div className="px-5 pt-4"> <div className="px-5 pt-4">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-500">Tiempo restante</span> <span className="text-sm font-medium text-gray-500">Tiempo restante</span>
<span className="text-sm font-bold" style={{ color: colorBarra }}>{tiempo}s</span> <span className="text-sm font-bold" style={{ color: colorBarra }}>{tiempoRestante}s</span>
</div> </div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden"> <div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div <div
@@ -106,7 +74,7 @@ export default function QuestionModal({ pregunta, categoria, mostrarFeedback, fe
</h3> </h3>
{feedbackCorrecto {feedbackCorrecto
? <p className="text-gray-600">{pregunta.retroalimentacion}</p> ? <p className="text-gray-600">{pregunta.retroalimentacion}</p>
: <p className="text-gray-600">{tiempo === 0 ? 'Se agotó el tiempo.' : 'Respuesta incorrecta.'}</p> : <p className="text-gray-600">{tiempoRestante === 0 ? 'Se agotó el tiempo.' : 'Respuesta incorrecta.'}</p>
} }
<button <button
onClick={onCerrar} onClick={onCerrar}
+214 -73
View File
@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import type { Categoria } from '../../../types' import type { Categoria } from '../../../types'
interface RuletaSVGProps { interface RuletaSVGProps {
@@ -7,92 +8,232 @@ interface RuletaSVGProps {
onClick: () => void onClick: () => void
} }
/* ─── Constantes geométricas ──────────────────── */
const CX = 250
const CY = 265
const SEGMENT_R = 195
const RING_R = 207
const RING_STROKE = 26
const CENTER_R = 28
const VIEWBOX_W = 500
const VIEWBOX_H = 550
const TOTAL_SEGMENTS = 4
/* ─── Estilos visuales por segmento ───────────── */
interface SegmentStyle {
bg: string
textColor: string
iconColor: string
}
const SEGMENT_STYLES: SegmentStyle[] = [
{ bg: '#DC2626', textColor: '#FFFFFF', iconColor: '#FFFFFF' }, // Agua — rojo
{ bg: '#D1D5DB', textColor: '#1F2937', iconColor: '#374151' }, // Residuos — gris claro
{ bg: '#374151', textColor: '#FFFFFF', iconColor: '#FBBF24' }, // Energía — gris oscuro
{ bg: '#F9FAFB', textColor: '#1F2937', iconColor: '#2563EB' }, // Electromovilidad — blanco
]
/* ─── Utilidad: polar → cartesiano ────────────── */
function polarToCartesian(cx: number, cy: number, angle: number, radius: number) {
const rad = (angle - 90) * (Math.PI / 180)
return {
x: cx + radius * Math.cos(rad),
y: cy + radius * Math.sin(rad),
}
}
/* ─── Path de sector circular ─────────────────── */
function sectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
const start = polarToCartesian(cx, cy, startAngle, r)
const end = polarToCartesian(cx, cy, endAngle, r)
const largeArc = endAngle - startAngle > 180 ? 1 : 0
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
}
/* ═══════════════════════════════════════════════
COMPONENTE PRINCIPAL
═══════════════════════════════════════════════ */
export default function RuletaSVG({ categorias, rotacion, isSpinning, onClick }: RuletaSVGProps) { export default function RuletaSVG({ categorias, rotacion, isSpinning, onClick }: RuletaSVGProps) {
const cx = 200 const angleStep = 360 / TOTAL_SEGMENTS
const cy = 200
const r = 180
const total = categorias.length
const angleStep = 360 / total
function polarToCartesian(angle: number, radius: number) { /* Puntos decorativos del anillo exterior */
const rad = (angle - 90) * (Math.PI / 180) const decorElements = useMemo(() => {
return { const dots: { x: number; y: number; isStar: boolean }[] = []
x: cx + radius * Math.cos(rad), for (let i = 0; i < 24; i++) {
y: cy + radius * Math.sin(rad), const angle = (i * 15)
const pos = polarToCartesian(CX, CY, angle, RING_R)
dots.push({ x: pos.x, y: pos.y, isStar: i % 3 === 0 })
} }
} return dots
}, [])
function sectorPath(startAngle: number, endAngle: number) {
const start = polarToCartesian(startAngle, r)
const end = polarToCartesian(endAngle, r)
const largeArc = endAngle - startAngle > 180 ? 1 : 0
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
}
return ( return (
<div className="relative flex items-center justify-center"> <div className="relative flex flex-col items-center justify-center w-full select-none">
{/* Indicador */} {/* ─── Puntero (fuera del SVG, fijo arriba) ─── */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-2 z-10"> <div className="absolute top-0 left-1/2 -translate-x-1/2 z-20" style={{ marginTop: '35px' }}>
<div className="w-0 h-0 border-l-[12px] border-r-[12px] border-t-[24px] border-l-transparent border-r-transparent border-t-gray-800 drop-shadow-md" /> <img
src="./img/indicador.png"
alt="Indicador"
style={{ width: '86px', height: '96px' }}
className="object-contain drop-shadow-[0_4px_6px_rgba(0,0,0,0.35)]"
/>
</div> </div>
{/* Ruleta SVG */} {/* ─── SVG principal (ruleta + anillo + soporte) ─── */}
<svg <svg
width="400" viewBox={`0 0 ${VIEWBOX_W} ${VIEWBOX_H}`}
height="400"
viewBox="0 0 400 400"
onClick={!isSpinning ? onClick : undefined} onClick={!isSpinning ? onClick : undefined}
className={`select-none drop-shadow-xl ${!isSpinning ? 'cursor-pointer' : 'cursor-not-allowed'}`} className={`w-[90%] h-auto select-none drop-shadow-[0_10px_25px_rgba(0,0,0,0.4)] ${!isSpinning ? 'cursor-pointer' : 'cursor-not-allowed'}`}
style={{
transition: 'transform 4s cubic-bezier(0.25, 0.1, 0.25, 1)',
transform: `rotate(${rotacion}deg)`,
}}
> >
{categorias.map((cat, i) => { <defs>
const startAngle = i * angleStep {/* Sombra del anillo */}
const endAngle = startAngle + angleStep <filter id="ringShadow" x="-10%" y="-10%" width="120%" height="120%">
const midAngle = startAngle + angleStep / 2 <feDropShadow dx="0" dy="3" stdDeviation="6" floodColor="#000" floodOpacity="0.35" />
const textPos = polarToCartesian(midAngle, r * 0.62) </filter>
{/* Sombra del botón central */}
<filter id="centerShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#000" floodOpacity="0.3" />
</filter>
</defs>
return ( {/* ══════ GRUPO GIRATORIO: Segmentos + centro ══════ */}
<g key={cat.id}> <g
{/* Sector */} style={{
<path transformOrigin: `${CX}px ${CY}px`,
d={sectorPath(startAngle, endAngle)} transform: `rotate(${rotacion}deg)`,
fill={cat.color} transition: 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)',
stroke="white" }}
strokeWidth="3" >
/> {categorias.slice(0, TOTAL_SEGMENTS).map((cat, i) => {
{/* Emoji */} const startAngle = i * angleStep
<text const endAngle = startAngle + angleStep
x={textPos.x} const midAngle = startAngle + angleStep / 2
y={textPos.y - 14} const style = SEGMENT_STYLES[i] ?? SEGMENT_STYLES[0]
textAnchor="middle"
dominantBaseline="middle"
fontSize="28"
>
{cat.emoji}
</text>
{/* Nombre */}
<text
x={textPos.x}
y={textPos.y + 16}
textAnchor="middle"
dominantBaseline="middle"
fontSize="13"
fontWeight="bold"
fill="white"
>
{cat.nombre}
</text>
</g>
)
})}
{/* Centro */} // Posición de la imagen (~58% del radio)
<circle cx={cx} cy={cy} r={22} fill="white" stroke="#e5e7eb" strokeWidth="3" /> const imgCenter = polarToCartesian(CX, CY, midAngle, SEGMENT_R * 0.58)
<circle cx={cx} cy={cy} r={10} fill="#7C3AED" /> // Posición del texto: radio y offset por categoría
const textRadius = i === 0 ? SEGMENT_R * 0.58 : SEGMENT_R * 0.84
const textPosRaw = polarToCartesian(CX, CY, midAngle, textRadius)
const textOffsetX = i === 0 ? 0 : i === 1 ? -40 : i === 2 ? 30 : i === 3 ? 38 : 0
const textOffsetY = i === 0 ? -45 : i === 1 ? 10 : i === 3 ? -12 : 0
const textPos = { x: textPosRaw.x + textOffsetX, y: textPosRaw.y + textOffsetY }
const imgSize = 60
/* Imágenes por segmento */
const imgs = ['agua.png', 'residuos.png', 'energia.png', 'movilidad.png']
/* Determinar si el texto es multilínea */
const esMultilinea = cat.nombre.length > 20
const lineas = esMultilinea ? cat.nombre.split(' y ') : [cat.nombre]
return (
<g key={cat.id}>
{/* Sector de color base */}
<path
d={sectorPath(CX, CY, SEGMENT_R, startAngle, endAngle)}
fill={style.bg}
stroke="#FFFFFF"
strokeWidth="2.5"
/>
{/* Imagen PNG (arriba, más cerca del centro) */}
<image
href={`./img/${imgs[i]}`}
x={imgCenter.x - imgSize / 2}
y={imgCenter.y - imgSize / 2}
width={imgSize}
height={imgSize}
preserveAspectRatio="xMidYMid meet"
/>
{/* Texto del nombre (debajo de la imagen) */}
<text
textAnchor="middle"
dominantBaseline="middle"
fill={style.textColor}
fontWeight="800"
fontSize={esMultilinea ? '11' : '14'}
letterSpacing="0.5"
>
{lineas.map((linea, j) => (
<tspan
key={j}
x={textPos.x}
y={textPos.y}
dy={j === 0 ? (esMultilinea ? -7 : 0) : 15}
>
{linea}{j < lineas.length - 1 ? ' y' : ''}
</tspan>
))}
</text>
</g>
)
})}
{/* ─── Botón central ─── */}
<g>
{/* Círculo exterior (borde rojo) */}
<circle cx={CX} cy={CY} r={CENTER_R + 6} fill="#DC2626" filter="url(#centerShadow)" />
{/* Círculo blanco */}
<circle cx={CX} cy={CY} r={CENTER_R} fill="#FFFFFF" />
{/* Logo TIP */}
<image
href="./img/logo-solo.png"
x={CX - 18}
y={CY - 18}
width="36"
height="36"
preserveAspectRatio="xMidYMid meet"
/>
</g>
</g>
{/* ══════ ANILLO EXTERIOR (estático) ══════ */}
<g filter="url(#ringShadow)">
<circle
cx={CX} cy={CY} r={RING_R}
fill="none"
stroke="#2D2D2D"
strokeWidth={RING_STROKE}
/>
{/* Borde interior del anillo */}
<circle
cx={CX} cy={CY} r={RING_R - RING_STROKE / 2 + 1}
fill="none"
stroke="#1F2937"
strokeWidth="2"
opacity="0.6"
/>
{/* Borde exterior del anillo */}
<circle
cx={CX} cy={CY} r={RING_R + RING_STROKE / 2 - 1}
fill="none"
stroke="#1F2937"
strokeWidth="2"
opacity="0.5"
/>
</g>
{/* ══════ ELEMENTOS DECORATIVOS DEL ANILLO ══════ */}
{decorElements.map((d, i) => (
<g key={i}>
{d.isStar ? (
/* Estrella de 4 puntas */
<g transform={`translate(${d.x}, ${d.y})`}>
<path
d="M0,-7 L1.5,-2 L7,0 L1.5,2 L0,7 L-1.5,2 L-7,0 L-1.5,-2 Z"
fill="#F9FAFB"
opacity="0.85"
/>
</g>
) : (
/* Círculo decorativo */
<circle cx={d.x} cy={d.y} r="3.5" fill="#9CA3AF" opacity="0.7" />
)}
</g>
))}
</svg> </svg>
</div> </div>
) )
+9 -4
View File
@@ -1,9 +1,14 @@
export default function Header() { export default function Header() {
return ( return (
<header className="w-full bg-purple-700 text-white shadow-md"> <header
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center justify-between"> className="w-full bg-transparent flex items-center shrink-0"
<span className="text-2xl font-bold tracking-wide">🎡 Ruleta</span> style={{
<span className="text-sm opacity-75">Actividad Interactiva</span> height: '90px',
}}
>
{/* Logo izquierdo */}
<div style={{ marginLeft: '75px', display: 'flex', alignItems: 'center' }}>
<img src="./img/logo.png" alt="TIP" style={{ height: '50px', width: 'auto' }} />
</div> </div>
</header> </header>
) )
+65
View File
@@ -0,0 +1,65 @@
import type { ReactNode } from 'react'
import { useScale } from '../../hooks/useScale'
import Header from './Header'
import ActivityBar from '../ui/ActivityBar'
interface ScaleWrapperProps {
children: ReactNode
/** Imagen de fondo opcional (ruta relativa a public/) */
backgroundImage?: string
/** Props para el ActivityBar. Si no se pasa, la pantalla hija debe renderizar su propio ActivityBar. */
barProps?: {
preguntaActual?: number
totalPreguntas?: number
mensaje: string
puedeAnterior?: boolean
puedeSiguiente?: boolean
onPrev?: () => void
onNext?: () => void
}
}
/**
* Envoltorio de escalado adaptativo 1920×1080.
* Incluye Header + contenido + ActivityBar (si se pasan barProps).
*/
export default function ScaleWrapper({ children, backgroundImage, barProps }: ScaleWrapperProps) {
const scaleRef = useScale()
return (
<div className="w-screen h-screen overflow-hidden bg-white relative">
<div
ref={scaleRef}
className="flex flex-col overflow-hidden"
style={{
width: '1920px',
height: '1080px',
...(backgroundImage
? {
backgroundImage: `url('${backgroundImage}')`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}
: { backgroundColor: '#ffffff' }),
}}
>
<Header />
<div className="flex-1 flex flex-col overflow-hidden" style={{ minHeight: 0 }}>
{children}
</div>
{barProps && (
<ActivityBar
preguntaActual={barProps.preguntaActual ?? 0}
totalPreguntas={barProps.totalPreguntas ?? 0}
mensaje={barProps.mensaje}
puedeAnterior={barProps.puedeAnterior ?? false}
puedeSiguiente={barProps.puedeSiguiente ?? false}
onPrev={barProps.onPrev}
onNext={barProps.onNext}
/>
)}
</div>
</div>
)
}
+59 -22
View File
@@ -1,5 +1,3 @@
import hero from '../../assets/hero.png'
import Button from '../ui/Button'
import { useAudio } from '../../hooks/useAudio' import { useAudio } from '../../hooks/useAudio'
interface CoverProps { interface CoverProps {
@@ -14,33 +12,72 @@ export default function Cover({ onStart }: CoverProps) {
onStart() onStart()
} }
return ( return (
<div className="flex flex-col-reverse md:flex-row items-center justify-center text-center gap-10 py-10"> <div className="flex flex-row items-center justify-center text-center gap-10 py-10 w-full h-full">
{/* Texto izquierdo */} {/* Imagen izquierda */}
<div className="flex-1 flex flex-col gap-5 text-center md:text-left">
<h1 className="text-4xl font-bold text-purple-700 leading-tight">
¡Bienvenido a la Ruleta Interactiva!
</h1>
<p className="text-gray-600 text-lg">
Pon a prueba tus conocimientos girando la ruleta.
Responde cada pregunta correctamente y acumula el mayor puntaje posible.
</p>
<div>
<Button size="lg" onClick={handleStart}>
Comenzar
</Button>
</div>
</div>
{/* Imagen derecha */}
<div className="flex-1 flex justify-center"> <div className="flex-1 flex justify-center">
<img <img
src={hero} src="./img/02.png"
alt="Ruleta Interactiva" alt="Ruleta Interactiva"
className="w-full max-w-sm object-contain drop-shadow-xl" className="object-contain drop-shadow-xl"
style={{ width: '80%', height: 'auto' }}
/> />
</div> </div>
{/* Texto derecho */}
<div className="flex-1 flex flex-col justify-center gap-0 text-center" style={{ marginRight: '45px' }}>
{/* Soporte con degradado */}
<div
className="rounded-3xl p-8 shadow-lg"
style={{ background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)' }}
>
<p className="text-gray-700 leading-relaxed" style={{ fontSize: '22px' }}>
Las reglas son muy sencillas: la ruleta girará, la suerte elegirá una categoría
y te tocará responder una pregunta. Contarás con 30 segundos para responder
cada una.
</p>
<p className="text-gray-700 leading-relaxed mt-4" style={{ fontSize: '22px' }}>
No se trata de encontrar la respuesta perfecta, sino de reflexionar y responder
desde tu experiencia y perspectiva. ¡Prepárate para poner a prueba tus
conocimientos y disfrutar de la dinámica!
</p>
</div>
{/* Soporte negro */}
<div
className="py-5 px-8 shadow-lg flex items-center justify-center"
style={{ backgroundColor: '#383838',
width: '65%',
alignSelf: 'center',
borderRadius: '0px 0px 25px 25px'
}}
>
<h1 className="text-white font-bold" style={{ fontSize: '32px' }}>
¡y que empiece a girar!
</h1>
</div>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '25px' }}>
<button
onClick={handleStart}
className="font-semibold transition-all duration-200 cursor-pointer hover:scale-105 hover:brightness-110"
style={{
backgroundColor: '#ed1f24',
color: '#ffffff',
border: '5px solid #ffffff',
padding: '14px 28px',
fontSize: '30px',
borderRadius: '50px',
fontWeight: 'bold',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.85)',
}}
>
Comenzar
</button>
</div>
</div>
</div> </div>
) )
} }
+139 -35
View File
@@ -1,13 +1,23 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useRuleta } from '../../hooks/useRuleta' import { useRuleta } from '../../hooks/useRuleta'
import { sounds } from '../../audio/AudioController' import { sounds } from '../../audio/AudioController'
import QuestionModal from '../game/QuestionModal'
import RuletaSVG from '../game/Ruleta/RuletaSVG' import RuletaSVG from '../game/Ruleta/RuletaSVG'
import FeedbackModal from '../game/FeedbackModal'
interface GameProps { interface GameProps {
onEnd: (aprobado: boolean, correctas: number) => void onEnd: (aprobado: boolean, correctas: number) => void
} }
const LETRAS = ['A', 'B', 'C', 'D']
/** Mapeo de nombre de categoría → imagen PNG */
const CAT_IMG: Record<string, string> = {
'Agua': 'agua.png',
'Residuos': 'residuos.png',
'Energía': 'energia.png',
'Electromovilidad': 'movilidad.png',
}
export default function Game({ onEnd }: GameProps) { export default function Game({ onEnd }: GameProps) {
const { estado, categorias, girar, responder, cerrarFeedback, MAX_PREGUNTAS } = useRuleta() const { estado, categorias, girar, responder, cerrarFeedback, MAX_PREGUNTAS } = useRuleta()
@@ -20,49 +30,143 @@ export default function Game({ onEnd }: GameProps) {
girar() girar()
} }
const pregunta = estado.preguntaActual
const categoria = estado.categoriaActual
return ( return (
<div className="flex flex-col items-center gap-6 py-6 w-full"> <div className="flex flex-col h-full w-full px-0 pt-8 relative">
{/* Instrucciones */} {/* ─── Contenedor rojo con imagen + nombre de categoría (top-right) ─── */}
<div className="bg-purple-50 border border-purple-200 rounded-xl px-6 py-4 max-w-lg text-center"> {categoria && (
<p className="text-purple-700 font-semibold text-lg">¿Cómo jugar?</p> <div
<p className="text-gray-600 mt-1"> className="absolute top-0 right-0 z-20 flex items-center gap-3 pl-5 pr-6"
Haz clic en la <strong>ruleta</strong> para girarla y responde correctamente para ganar puntos. style={{
</p> backgroundColor: '#ed1f24',
</div> borderRadius: '0px 0px 0px 25px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
<div className="flex flex-col items-center gap-4 w-full"> maxWidth: '520px',
}}
{/* Progreso de preguntas */} >
<div className="flex gap-2"> {/* Círculo blanco con imagen de categoría */}
{Array.from({ length: MAX_PREGUNTAS }).map((_, i) => ( {CAT_IMG[categoria.nombre] && (
<div <div
key={i} className="rounded-full flex items-center justify-center shrink-0"
className={`w-6 h-6 rounded-full border-2 border-purple-600 transition-all duration-500 ${ style={{
i < estado.preguntasRespondidas ? 'bg-purple-600' : 'bg-white' width: '52px',
}`} height: '52px',
/> backgroundColor: '#FFFFFF',
))} boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
}}
>
<img
src={`./img/${CAT_IMG[categoria.nombre]}`}
alt={categoria.nombre}
className="object-contain"
style={{ width: '36px', height: '36px' }}
/>
</div>
)}
<span
className="text-white font-extrabold tracking-wide py-3 whitespace-nowrap"
style={{ fontSize: '22px' }}
>
{categoria.nombre}
</span>
</div>
)}
{/* ─── Contenido principal: Ruleta + Panel de pregunta ─── */}
<div className="flex-1 flex items-center justify-center min-h-0">
{/* ─── Columna izquierda: Ruleta 40% ─── */}
<div className="flex flex-col items-center justify-center z-10" style={{ width: '40%', marginRight: '-170px' }}>
<RuletaSVG
categorias={categorias}
rotacion={estado.rotacion}
isSpinning={estado.isSpinning}
onClick={handleGirar}
/>
</div> </div>
{/* Ruleta SVG */} {/* ─── Columna derecha: Panel de pregunta 60% ─── */}
<RuletaSVG <div className="flex flex-col justify-center pl-16 pr-10" style={{ width: '60%' }}>
categorias={categorias} {pregunta && !estado.completado && (
rotacion={estado.rotacion} <div
isSpinning={estado.isSpinning} className="rounded-2xl shadow-xl overflow-hidden"
onClick={handleGirar} style={{
/> background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
marginLeft: '-80px',
}}
>
{/* Header con pregunta (fondo transparente) */}
<div
className="px-6 py-4 flex flex-col items-center gap-1"
style={{ marginLeft: '50px' }}
>
<p className="font-bold text-center" style={{ color: '#ed1f24', fontSize: '25px' }}>{pregunta.pregunta}</p>
</div>
{/* Opciones */}
<div className="p-5 flex flex-col gap-3" style={{ marginLeft: '115px' }}>
{pregunta.opciones.map((opcion, i) => (
<button
key={i}
onClick={() => responder(i)}
disabled={estado.mostrarFeedback}
className="group flex items-stretch w-full rounded-lg border border-gray-200 bg-gray-50 hover:bg-[#383838] hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 overflow-hidden text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<span
className="text-white font-bold w-10 flex items-center justify-center shrink-0 group-hover:text-white"
style={{ backgroundColor: '#ed1f24' }}
>
{LETRAS[i]}
</span>
<span className="px-4 py-3 text-gray-700 font-medium group-hover:text-white" style={{ fontSize: '22px' }}>{opcion}</span>
</button>
))}
</div>
</div>
)}
{/* Temporizador debajo del panel */}
{pregunta && !estado.completado && (
<div className="flex justify-center mt-3">
<span
className="font-bold text-white bg-black rounded-md px-[10px] py-[10px]"
style={{ fontSize: '22px' }}
>
{estado.tiempoRestante}seg
</span>
</div>
)}
{/* Sin pregunta activa: placeholder */}
{!pregunta && !estado.completado && (
<div
className="flex flex-col items-center justify-center text-center p-10 rounded-2xl shadow-xl"
style={{
background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
}}
>
<p className="text-gray-700 font-medium" style={{ fontSize: '22px' }}>
Gira la ruleta para obtener una pregunta
</p>
<p className="text-gray-500 mt-2" style={{ fontSize: '22px' }}>
Cada respuesta correcta te acerca al objetivo de {MAX_PREGUNTAS} preguntas
</p>
</div>
)}
</div>
</div> </div>
{/* Modal pregunta */} {/* ─── Modal de feedback ─── */}
{estado.preguntaActual && !estado.completado && ( {estado.mostrarFeedback && pregunta && categoria && (
<QuestionModal <FeedbackModal
pregunta={estado.preguntaActual} pregunta={pregunta}
categoria={estado.categoriaActual!} categoria={categoria}
mostrarFeedback={estado.mostrarFeedback}
feedbackCorrecto={estado.feedbackCorrecto} feedbackCorrecto={estado.feedbackCorrecto}
onResponder={responder} tiempoAgotado={estado.tiempoRestante === 0}
onCerrar={cerrarFeedback} onCerrar={cerrarFeedback}
/> />
)} )}
+179
View File
@@ -0,0 +1,179 @@
/* ─── SVG de Agua ──────────────────────────────────── */
function IconoAgua() {
return (
<svg viewBox="0 0 120 120" width="105" height="105" className="transition-transform duration-300 group-hover:scale-110">
{/* Gota de agua */}
<path
d="M60 15 C60 15 30 55 30 75 C30 91.6 43.4 105 60 105 C76.6 105 90 91.6 90 75 C90 55 60 15 60 15Z"
fill="#3B82F6"
opacity="0.85"
/>
{/* Brillo */}
<ellipse cx="50" cy="60" rx="10" ry="16" fill="white" opacity="0.3" transform="rotate(-15 50 60)" />
{/* Grifo simplificado arriba */}
<rect x="52" y="4" width="16" height="18" rx="6" fill="#64748B" />
<rect x="48" y="16" width="24" height="8" rx="3" fill="#64748B" />
<circle cx="60" cy="8" r="5" fill="#3B82F6" />
</svg>
)
}
/* ─── SVG de Residuos y Circularidad ───────────────── */
function IconoResiduos() {
return (
<svg viewBox="0 0 120 120" width="105" height="105" className="transition-transform duration-300 group-hover:scale-110">
{/* Contenedor de reciclaje */}
<rect x="30" y="50" width="60" height="55" rx="5" fill="#16A34A" />
<rect x="32" y="52" width="56" height="10" rx="3" fill="#15803D" />
<line x1="35" y1="55" x2="85" y2="55" stroke="#86EFAC" strokeWidth="3" strokeLinecap="round" />
<line x1="35" y1="62" x2="85" y2="62" stroke="#86EFAC" strokeWidth="3" strokeLinecap="round" />
<line x1="35" y1="69" x2="85" y2="69" stroke="#86EFAC" strokeWidth="3" strokeLinecap="round" />
{/* Símbolo de reciclaje ♻ */}
<g transform="translate(60, 32) scale(0.55)">
<path d="M0-28 L-24 14 L-8 14 L-8 28 L8 28 L8 14 L24 14 Z"
fill="none" stroke="#16A34A" strokeWidth="6" strokeLinejoin="round" />
<path d="M24-14 L0-42 L-24-14 L-10-6 L-2-20 L6-6 Z"
fill="none" stroke="#16A34A" strokeWidth="6" strokeLinejoin="round"
transform="rotate(120)" style={{ transformOrigin: '0 0' }} />
<path d="M-24-14 L0-42 L24-14 L10-6 L2-20 L-6-6 Z"
fill="none" stroke="#16A34A" strokeWidth="6" strokeLinejoin="round"
transform="rotate(240)" style={{ transformOrigin: '0 0' }} />
</g>
</svg>
)
}
/* ─── SVG de Energía ───────────────────────────────── */
function IconoEnergia() {
return (
<svg viewBox="0 0 120 120" width="105" height="105" className="transition-transform duration-300 group-hover:scale-110">
{/* Sol */}
<circle cx="85" cy="20" r="12" fill="#FACC15" />
{[0, 45, 90, 135, 180, 225, 270, 315].map((angle, i) => (
<line key={i}
x1={85 + 8 * Math.cos(angle * Math.PI / 180)}
y1={20 + 8 * Math.sin(angle * Math.PI / 180)}
x2={85 + 16 * Math.cos(angle * Math.PI / 180)}
y2={20 + 16 * Math.sin(angle * Math.PI / 180)}
stroke="#FACC15" strokeWidth="2.5" strokeLinecap="round" />
))}
{/* Colinas verdes */}
<ellipse cx="35" cy="85" rx="42" ry="28" fill="#22C55E" opacity="0.8" />
<ellipse cx="75" cy="90" rx="38" ry="22" fill="#16A34A" opacity="0.7" />
{/* Aerogenerador */}
<rect x="32" y="38" width="4" height="40" rx="2" fill="#94A3B8" />
<g transform="translate(34, 38)">
<rect x="-2" y="-28" width="4" height="27" rx="2" fill="#CBD5E1" transform="rotate(0)" />
<rect x="-2" y="-28" width="4" height="27" rx="2" fill="#CBD5E1" transform="rotate(120)" />
<rect x="-2" y="-28" width="4" height="27" rx="2" fill="#CBD5E1" transform="rotate(240)" />
<circle cx="0" cy="0" r="4" fill="#64748B" />
</g>
{/* Panel solar */}
<rect x="62" y="65" width="28" height="18" rx="2" fill="#3B82F6" opacity="0.8" transform="rotate(-8 76 74)" />
<line x1="68" y1="72" x2="84" y2="70" stroke="#BFDBFE" strokeWidth="1" />
<line x1="69" y1="76" x2="85" y2="74" stroke="#BFDBFE" strokeWidth="1" />
<line x1="70" y1="80" x2="86" y2="78" stroke="#BFDBFE" strokeWidth="1" />
</svg>
)
}
/* ─── SVG de Electromovilidad ──────────────────────── */
function IconoElectromovilidad() {
return (
<svg viewBox="0 0 120 120" width="105" height="105" className="transition-transform duration-300 group-hover:scale-110">
{/* Carretera */}
<rect x="5" y="78" width="110" height="6" rx="3" fill="#CBD5E1" />
<line x1="20" y1="81" x2="40" y2="81" stroke="white" strokeWidth="2" strokeDasharray="6,4" />
<line x1="55" y1="81" x2="75" y2="81" stroke="white" strokeWidth="2" strokeDasharray="6,4" />
<line x1="90" y1="81" x2="105" y2="81" stroke="white" strokeWidth="2" strokeDasharray="6,4" />
{/* Coche eléctrico azul */}
<g transform="translate(45, 50)">
{/* Carrocería */}
<rect x="0" y="6" width="50" height="18" rx="8" fill="#3B82F6" />
{/* Techo / cabina */}
<path d="M12 6 L18 -6 L36 -6 L42 6 Z" fill="#3B82F6" />
{/* Ventanas */}
<path d="M14 4 L19 -4 L25 -4 L25 4 Z" fill="#BFDBFE" />
<path d="M27 -4 L34 -4 L39 4 L27 4 Z" fill="#BFDBFE" />
{/* Ruedas */}
<circle cx="12" cy="24" r="6" fill="#374151" />
<circle cx="12" cy="24" r="2.5" fill="#9CA3AF" />
<circle cx="38" cy="24" r="6" fill="#374151" />
<circle cx="38" cy="24" r="2.5" fill="#9CA3AF" />
{/* Faro */}
<rect x="48" y="14" width="3" height="5" rx="1" fill="#FACC15" />
</g>
{/* Cargador / estación */}
<rect x="8" y="48" width="8" height="30" rx="3" fill="#64748B" />
<rect x="6" y="44" width="12" height="6" rx="2" fill="#475569" />
{/* Cable */}
<path d="M20 48 Q28 40 42 52" fill="none" stroke="#FACC15" strokeWidth="2.5" strokeLinecap="round" />
{/* Indicador verde */}
<circle cx="12" cy="52" r="2" fill="#22C55E" />
{/* Rayo */}
<path d="M10 38 L7 42 L11 42 L9 46 L14 40 L10 40 Z" fill="#FACC15" transform="scale(0.7) translate(12, 14)" />
</svg>
)
}
/* ─── Datos de categorías ──────────────────────────── */
const CATEGORIAS = [
{ id: 'agua', nombre: 'Agua', Icono: IconoAgua },
{ id: 'residuos', nombre: 'Residuos y', nombre2: 'Circularidad', Icono: IconoResiduos },
{ id: 'energia', nombre: 'Energía', Icono: IconoEnergia },
{ id: 'electromovilidad', nombre: 'Electromovilidad', nombre2: 'y Transporte', nombre3: 'Sostenible', Icono: IconoElectromovilidad },
]
export default function Intro() {
return (
<div className="relative w-full h-full flex flex-col">
{/* ─── Contenido scrolleable ──────────────────── */}
<div className="relative z-10 flex w-full flex-1 overflow-y-auto px-16 pb-6">
{/* ─── Columna izquierda (60%): bienvenida ───────── */}
<div className="flex flex-col items-start justify-center" style={{ width: '65%' }}>
<h1 style={{ color: '#000000', fontSize: '105px', fontWeight: 'bold', lineHeight: '1.1', textAlign: 'center', width: '100%' }}>¡BIENVENIDOS!</h1>
<img src="./img/01.png" alt="Logo Ruleta" style={{ marginTop: '20px', width: '70%', height: 'auto' }} />
</div>
{/* ─── Columna derecha (40%): texto ─────────── */}
<div className="flex flex-col justify-center" style={{ width: '35%', paddingRight: '20px' }}>
<div className="relative z-10 w-full bg-transparent rounded-3xl p-10" style={{top: '-10%'}}>
{/* Texto introductorio */}
<p className="text-gray-700 leading-relaxed mb-8" style={{ fontSize: '22px' }}>
Hoy vamos a poner a prueba nuestros conocimientos y a reflexionar juntos sobre
cómo nuestras acciones impactan el planeta. En esta ruleta tenemos{' '}
<strong>4 ejes fundamentales para el futuro:</strong>
</p>
{/* Lista de ejes con imágenes */}
<div className="flex flex-col gap-5">
{CATEGORIAS.map((cat, i) => {
const imagenes = ['agua.png', 'residuos.png', 'energia.png', 'movilidad.png']
return (
<div key={cat.id} className="flex items-center gap-4">
<img
src={`./img/${imagenes[i]}`}
alt={cat.nombre}
className="shrink-0 object-contain"
style={{ width: '75px', height: '78px' }}
/>
<div>
<strong className="text-gray-800" style={{ fontSize: '22px' }}>
{cat.nombre}
{cat.nombre2 ? ` ${cat.nombre2}` : ''}
{cat.nombre3 ? ` ${cat.nombre3}` : ''}
</strong>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
)
}
+75 -39
View File
@@ -4,49 +4,85 @@ interface ResultProps {
onReintentar: () => void onReintentar: () => void
} }
export default function Result({ aprobado, sinIntentos, onReintentar }: ResultProps) { export default function Result(_props: ResultProps) {
return ( return (
<div className="flex flex-col items-center justify-center gap-8 py-10 w-full text-center"> <div className="flex w-full h-full">
{/* ─── Columna izquierda 50% ─── */}
{/* Aprobado */} <div className="flex flex-col justify-center" style={{ width: '50%', paddingRight: '20px' }}>
{aprobado && ( <div style={{ position: 'relative' }}>
<> {/* Cabecera roja flotante */}
<span className="text-8xl">🏆</span> <div
<h2 className="text-4xl font-bold text-purple-700">¡Felicidades!</h2> className="px-8 py-6"
<p className="text-gray-600 text-xl max-w-md"> style={{
Has completado la actividad exitosamente. ¡Excelente desempeño! backgroundColor: '#ed1f24',
</p> borderRadius: '25px',
</> width: '90%',
)} position: 'absolute',
top: '-55px',
{/* Reprobado con intentos disponibles */} left: '50%',
{!aprobado && !sinIntentos && ( transform: 'translateX(-50%)',
<> zIndex: 10,
<span className="text-8xl">💪</span> }}
<h2 className="text-4xl font-bold text-orange-500">¡Casi lo logras!</h2>
<p className="text-gray-600 text-xl max-w-md">
Aún te falta un poco. Vuelve a intentarlo, ¡ puedes!
</p>
<button
onClick={onReintentar}
className="bg-purple-600 hover:bg-purple-700 text-white font-bold px-10 py-4 rounded-xl text-lg transition-colors"
> >
Reintentar <p
</button> className="font-bold text-white text-center"
</> style={{ fontSize: '28px', lineHeight: '1.4' }}
)} >
¡Y con este último giro cerramos nuestra ruleta del día de hoy!
</p>
</div>
{/* Sin intentos */} {/* Contenido degradado */}
{sinIntentos && ( <div
<> className="px-10 py-8"
<span className="text-8xl">😔</span> style={{
<h2 className="text-4xl font-bold text-red-600">Intentos agotados</h2> background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
<p className="text-gray-600 text-xl max-w-md"> borderRadius: '0px 25px 25px 0px',
Has utilizado todos tus intentos disponibles. Te recomendamos repasar el contenido del curso antes de volver a intentarlo. boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
opacity: 0.88,
paddingTop: '95px',
}}
>
<p
className="text-gray-700 mb-6"
style={{ fontSize: '22px', lineHeight: '1.6' }}
>
Recorrimos desde la gestión del agua y los residuos, hasta la transición hacia energías más limpias y la movilidad del futuro. Más allá de las respuestas correctas o los puntos, lo verdaderamente importante es que la sostenibilidad no se queda en esta pantalla: se construye con las decisiones que tomamos todos los días.
</p>
<p
className="text-gray-700"
style={{ fontSize: '22px', lineHeight: '1.6', fontWeight: '600' }}
>
Muchísimas gracias a todos por su participación, su energía y sus aportaciones.
</p>
</div>
</div>
</div>
{/* ─── Columna derecha 50% ─── */}
<div className="flex flex-col items-center justify-center" style={{ width: '50%', paddingLeft: '20px' }}>
<img
src="./img/03.png"
alt="Resultado"
className="object-contain mb-8"
style={{ width: '80%', height: 'auto' }}
/>
<div
className="px-8 py-6 rounded-xl text-center"
style={{
backgroundColor: '#383838',
width: '90%',
}}
>
<p
className="font-bold text-white"
style={{ fontSize: '22px', lineHeight: '1.5' }}
>
¡Nos llevamos grandes aprendizajes para seguir cuidando el planeta juntos!
</p> </p>
</> </div>
)} </div>
</div> </div>
) )
} }
+119
View File
@@ -0,0 +1,119 @@
/* ─── Barra inferior de actividad ─────────────── */
interface ActivityBarProps {
preguntaActual?: number
totalPreguntas?: number
mensaje: string
onPrev?: () => void
onNext?: () => void
puedeAnterior?: boolean
puedeSiguiente?: boolean
}
/* ─── Flecha izquierda ────────────────────────── */
function FlechaIzq({ disabled }: { disabled?: boolean }) {
return (
<svg viewBox="0 0 24 40" width="28" height="44" fill="none">
<path
d="M20 4 L6 20 L20 36"
stroke={disabled ? '#cbd5e1' : '#DC2626'}
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/* ─── Flecha derecha ──────────────────────────── */
function FlechaDer({ disabled }: { disabled?: boolean }) {
return (
<svg viewBox="0 0 24 40" width="28" height="44" fill="none">
<path
d="M4 4 L18 20 L4 36"
stroke={disabled ? '#cbd5e1' : '#DC2626'}
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default function ActivityBar({
preguntaActual = 0,
totalPreguntas = 0,
mensaje,
onPrev,
onNext,
puedeAnterior = false,
puedeSiguiente = false,
}: ActivityBarProps) {
const counterText = totalPreguntas > 0
? `${preguntaActual}/${totalPreguntas}`
: '0/0'
return (
<footer
className="w-full flex items-center justify-end gap-0 shrink-0"
style={{
backgroundColor: '#f8f9fa05',
height: '80px',
paddingRight: '0px',
boxShadow: '0 -1px 4px rgba(0,0,0,0.04)',
}}
>
{/* ─── Instrucción en soporte rojo ─────────── */}
<div
className="flex items-center justify-center px-8 py-3"
style={{
backgroundColor: '#ee2b33',
minWidth: '280px',
borderRadius: '25px 0px 0px 0px',
boxShadow: '0 2px 6px rgba(238,43,51,0.3)'
}}
>
<span className="text-white font-semibold leading-tight text-center" style={{ fontSize: '22px' }}>
{mensaje}
</span>
</div>
{/* ─── Botones de navegación ───────────────── */}
<div
className="flex items-center gap-3"
style={{
height: '100%',
backgroundColor: 'rgba(255, 255, 255, 0.6)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.85)',
padding: '0 16px',
}}
>
<button
onClick={onPrev}
disabled={!puedeAnterior}
aria-label="Anterior"
className="p-2 rounded-full transition-transform duration-200 cursor-pointer
disabled:opacity-30 disabled:cursor-not-allowed
hover:scale-125"
>
<FlechaIzq disabled={!puedeAnterior} />
</button>
<span className="text-gray-700 font-bold tabular-nums select-none min-w-[60px] text-center" style={{ fontSize: '22px' }}>
{counterText}
</span>
<button
onClick={onNext}
disabled={!puedeSiguiente}
aria-label="Siguiente"
className="p-2 rounded-full transition-transform duration-200 cursor-pointer
disabled:opacity-30 disabled:cursor-not-allowed
hover:scale-125"
>
<FlechaDer disabled={!puedeSiguiente} />
</button>
</div>
</footer>
)
}
+89
View File
@@ -0,0 +1,89 @@
/* ─── Indicador de navegación (esquina superior derecha) ─── */
interface NavIndicatorProps {
preguntaActual: number
totalPreguntas: number
onPrev?: () => void
onNext?: () => void
puedeAnterior: boolean
puedeSiguiente: boolean
}
/* ─── Flecha izquierda ────────────────────────── */
function FlechaIzq({ disabled }: { disabled?: boolean }) {
return (
<svg viewBox="0 0 24 40" width="18" height="30" fill="none">
<path
d="M20 4 L6 20 L20 36"
stroke={disabled ? '#cbd5e1' : '#DC2626'}
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
/* ─── Flecha derecha ──────────────────────────── */
function FlechaDer({ disabled }: { disabled?: boolean }) {
return (
<svg viewBox="0 0 24 40" width="18" height="30" fill="none">
<path
d="M4 4 L18 20 L4 36"
stroke={disabled ? '#cbd5e1' : '#DC2626'}
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default function NavIndicator({
preguntaActual,
totalPreguntas,
onPrev,
onNext,
puedeAnterior,
puedeSiguiente,
}: NavIndicatorProps) {
const counterText = totalPreguntas > 0
? `${preguntaActual}/${totalPreguntas}`
: '0/0'
return (
<div
className="absolute flex items-center gap-3 z-30"
style={{ top: '20px', right: '40px' }}
>
{/* Flecha izquierda */}
<button
onClick={onPrev}
disabled={!puedeAnterior}
aria-label="Anterior"
className="p-2 rounded-full transition-colors duration-200 cursor-pointer
disabled:opacity-30 disabled:cursor-not-allowed
hover:bg-gray-100 active:bg-gray-200"
>
<FlechaIzq disabled={!puedeAnterior} />
</button>
{/* Contador */}
<span className="text-gray-700 font-bold text-2xl tabular-nums select-none min-w-[60px] text-center">
{counterText}
</span>
{/* Flecha derecha */}
<button
onClick={onNext}
disabled={!puedeSiguiente}
aria-label="Siguiente"
className="p-2 rounded-full transition-colors duration-200 cursor-pointer
disabled:opacity-30 disabled:cursor-not-allowed
hover:bg-gray-100 active:bg-gray-200"
>
<FlechaDer disabled={!puedeSiguiente} />
</button>
</div>
)
}
+196 -169
View File
@@ -1,297 +1,324 @@
{ {
"categorias": [ "categorias": [
{ {
"id": 0, "id": 0,
"nombre": "Historia", "nombre": "Agua",
"color": "#7C3AED", "color": "#0284c7",
"emoji": "🏛️", "emoji": "💧",
"preguntas": [ "preguntas": [
{ {
"id": 1, "id": 1,
"pregunta": "¿En qué año cayó el Muro de Berlín?", "pregunta": "¿Cuánta agua se puede ahorrar al día si cerramos la llave mientras nos cepillamos los dientes?",
"opciones": ["1987", "1989", "1991", "1993"], "opciones": ["Menos de 5 litros", "Entre 20 y 30 litros", "Más de 100 litros"],
"correcta": 1, "correcta": 1,
"retroalimentacion": "El Muro de Berlín cayó el 9 de noviembre de 1989, marcando el fin de la Guerra Fría." "retroalimentacion": "Un gesto de unos segundos, repetido todos los días, se convierte en un hábito que suma litros de agua cuidada sin ningún esfuerzo extra.",
"reflexion": "Un gesto de unos segundos, repetido todos los días, se convierte en un hábito que suma litros de agua cuidada sin ningún esfuerzo extra.",
"datoCurioso": "Si cada persona cerrara la llave al cepillarse los dientes, ahorraríamos millones de litros de agua diariamente en el mundo."
}, },
{ {
"id": 2, "id": 2,
"pregunta": "¿Quién fue el primer presidente de los Estados Unidos?", "pregunta": "¿Aproximadamente cuántos litros de agua se necesitan para producir una sola hamburguesa?",
"opciones": ["Abraham Lincoln", "Thomas Jefferson", "George Washington", "Benjamin Franklin"], "opciones": ["200 litros", "800 litros", "2,000 litros"],
"correcta": 2, "correcta": 2,
"retroalimentacion": "George Washington fue el primer presidente de EE.UU., gobernando de 1789 a 1797." "retroalimentacion": "Nuestras decisiones alimentarias tienen un impacto oculto en el agua; comer con conciencia también es una forma de cuidar este recurso.",
"reflexion": "Nuestras decisiones alimentarias tienen un impacto oculto en el agua; comer con conciencia también es una forma de cuidar este recurso.",
"datoCurioso": "Cultivar el pasto para alimentar al ganado y procesar la carne genera una huella hídrica altísima: hasta 2,000 litros de agua por hamburguesa."
}, },
{ {
"id": 3, "id": 3,
"pregunta": "¿En qué año comenzó la Primera Guerra Mundial?", "pregunta": "¿Cuánta agua puede desperdiciar aproximadamente una llave que gotea constantemente?",
"opciones": ["1912", "1914", "1916", "1918"], "opciones": ["Menos de 1 litro al año", "Hasta miles de litros al año", "Exactamente 10 litros al año"],
"correcta": 1, "correcta": 1,
"retroalimentacion": "La Primera Guerra Mundial comenzó en 1914 tras el asesinato del archiduque Francisco Fernando." "retroalimentacion": "Una fuga pequeña puede parecer insignificante, pero el desperdicio acumulado puede ser enorme.",
"reflexion": "Una fuga pequeña puede parecer insignificante, pero el desperdicio acumulado puede ser enorme.",
"datoCurioso": "Reparar una llave o tubería con fugas es una de las formas más sencillas de evitar el desperdicio de agua."
}, },
{ {
"id": 4, "id": 4,
"pregunta": "¿Qué civilización construyó las pirámides de Giza?", "pregunta": "¿Qué porcentaje aproximado del agua del planeta es agua dulce?",
"opciones": ["Griega", "Romana", "Egipcia", "Mesopotámica"], "opciones": ["Menos del 3%", "25%", "Más del 50%"],
"correcta": 2, "correcta": 0,
"retroalimentacion": "Las pirámides de Giza fueron construidas por los antiguos egipcios hace más de 4,500 años." "retroalimentacion": "Aunque la Tierra parece tener abundante agua, solo una pequeña fracción es agua dulce.",
"reflexion": "Aunque la Tierra parece tener abundante agua, solo una pequeña fracción es agua dulce.",
"datoCurioso": "Cerca del 97% del agua del planeta es salada y gran parte del agua dulce está congelada o se encuentra bajo tierra."
}, },
{ {
"id": 5, "id": 5,
"pregunta": "¿En qué año llegó Cristóbal Colón a América?", "pregunta": "¿Qué acción ayuda más a reducir el consumo de agua al bañarnos?",
"opciones": ["1488", "1490", "1492", "1495"], "opciones": ["Tomar duchas más cortas", "Dejar correr el agua mientras nos enjabonamos", "Aumentar la presión del agua"],
"correcta": 2, "correcta": 0,
"retroalimentacion": "Cristóbal Colón llegó a América el 12 de octubre de 1492." "retroalimentacion": "Reducir algunos minutos de la ducha puede generar un ahorro importante cuando se convierte en un hábito diario.",
"reflexion": "Reducir algunos minutos de la ducha puede generar un ahorro importante cuando se convierte en un hábito diario.",
"datoCurioso": "Cada minuto adicional en la ducha representa más agua utilizada, por lo que reducir el tiempo puede hacer una diferencia significativa."
}, },
{ {
"id": 6, "id": 6,
"pregunta": "¿Quién fue Napoleón Bonaparte?", "pregunta": "¿Qué porcentaje aproximado del agua que utilizamos en casa se puede desperdiciar por fugas?",
"opciones": ["Rey de España", "Emperador de Francia", "Presidente de Italia", "General inglés"], "opciones": ["Ninguna, las fugas no representan un problema", "Una cantidad importante si no se detectan y reparan", "Exactamente el 90% del consumo"],
"correcta": 1, "correcta": 1,
"retroalimentacion": "Napoleón Bonaparte fue Emperador de Francia entre 1804 y 1815." "retroalimentacion": "Revisar periódicamente llaves, tuberías y sanitarios ayuda a detectar pérdidas que muchas veces pasan desapercibidas.",
"reflexion": "Revisar periódicamente llaves, tuberías y sanitarios ayuda a detectar pérdidas que muchas veces pasan desapercibidas.",
"datoCurioso": "Algunas fugas pueden ser silenciosas y difíciles de detectar, pero aun así desperdiciar agua continuamente."
}, },
{ {
"id": 7, "id": 7,
"pregunta": "¿En qué país se originó la Revolución Industrial?", "pregunta": "¿Qué actividad tiene una huella hídrica?",
"opciones": ["Francia", "Alemania", "Estados Unidos", "Inglaterra"], "opciones": ["Solo beber agua", "Solo bañarse", "Prácticamente todos los productos y actividades que realizamos"],
"correcta": 3, "correcta": 2,
"retroalimentacion": "La Revolución Industrial comenzó en Inglaterra a mediados del siglo XVIII." "retroalimentacion": "El agua que utilizamos no siempre es visible; también está presente en la producción de alimentos, ropa, energía y otros productos.",
"reflexion": "El agua que utilizamos no siempre es visible; también está presente en la producción de alimentos, ropa, energía y otros productos.",
"datoCurioso": "La huella hídrica considera el agua utilizada directa e indirectamente para producir bienes y servicios."
}, },
{ {
"id": 8, "id": 8,
"pregunta": "¿Cuál fue el primer país en conceder el voto a la mujer?", "pregunta": "¿Cuál de estas acciones ayuda a ahorrar agua al lavar ropa?",
"opciones": ["Francia", "Nueva Zelanda", "Estados Unidos", "Suecia"], "opciones": ["Utilizar la lavadora con cargas completas", "Lavar una sola prenda por ciclo", "Usar siempre el ciclo más largo"],
"correcta": 1, "correcta": 0,
"retroalimentacion": "Nueva Zelanda fue el primer país en conceder el voto a la mujer en 1893." "retroalimentacion": "Aprovechar mejor cada ciclo de lavado permite utilizar menos agua y energía por prenda.",
"reflexion": "Aprovechar mejor cada ciclo de lavado permite utilizar menos agua y energía por prenda.",
"datoCurioso": "Planificar las cargas de lavado es una forma sencilla de hacer más eficiente el uso de los recursos."
} }
] ]
}, },
{ {
"id": 1, "id": 1,
"nombre": "Geografía", "nombre": "Residuos y Circularidad",
"color": "#059669", "color": "#16a34a",
"emoji": "🌍", "emoji": "♻️",
"preguntas": [ "preguntas": [
{ {
"id": 9, "id": 9,
"pregunta": "¿Cuál es el río más largo del mundo?", "pregunta": "¿Todo lo que se tira en el contenedor de reciclaje termina reciclándose?",
"opciones": ["Amazonas", "Nilo", "Yangtsé", "Misisipi"], "opciones": ["Sí, siempre", "No, si está mal separado o sucio contamina el resto"],
"correcta": 1, "correcta": 1,
"retroalimentacion": "El río Nilo, con aproximadamente 6,650 km, es considerado el río más largo del mundo." "retroalimentacion": "Separar bien nuestros residuos, informándonos antes de tirarlos, evita que se desperdicie el esfuerzo de reciclar.",
"reflexion": "Separar bien nuestros residuos, informándonos antes de tirarlos, evita que se desperdicie el esfuerzo de reciclar.",
"datoCurioso": "Un solo cartón con grasa de pizza puede contaminar el resto del material en el contenedor de reciclaje."
}, },
{ {
"id": 10, "id": 10,
"pregunta": "¿Cuál es el país más grande del mundo por superficie?", "pregunta": "¿La economía circular —reducir, reutilizar, reparar, reciclar— es algo que solo pueden aplicar las grandes industrias?",
"opciones": ["China", "Canadá", "Estados Unidos", "Rusia"], "opciones": ["Sí, requiere infraestructura industrial", "No, cualquier persona puede aplicarla desde casa"],
"correcta": 3, "correcta": 1,
"retroalimentacion": "Rusia es el país más grande del mundo con 17.1 millones de km²." "retroalimentacion": "La circularidad comienza en las decisiones diarias de cada persona: compostar, reparar, reutilizar y elegir productos duraderos.",
"reflexion": "La circularidad comienza en las decisiones diarias de cada persona: compostar, reparar, reutilizar y elegir productos duraderos.",
"datoCurioso": "Todos podemos aplicar la economía circular en casa, en la escuela y en la comunidad; no es exclusiva de las grandes industrias."
}, },
{ {
"id": 11, "id": 11,
"pregunta": "¿En qué continente se encuentra el desierto del Sahara?", "pregunta": "¿Cuál es la mejor forma de reducir la cantidad de residuos que generamos?",
"opciones": ["Asia", "América", "África", "Australia"], "opciones": ["Comprar más productos desechables", "Reducir el consumo innecesario", "Comprar productos con más empaques"],
"correcta": 2, "correcta": 1,
"retroalimentacion": "El Sahara se encuentra en África y es el desierto cálido más grande del mundo." "retroalimentacion": "El mejor residuo es el que no se genera. Antes de comprar, podemos preguntarnos si realmente necesitamos el producto.",
"reflexion": "El mejor residuo es el que no se genera. Antes de comprar, podemos preguntarnos si realmente necesitamos el producto.",
"datoCurioso": "La prevención y reducción de residuos son preferibles a tener que gestionar los residuos después de que ya fueron generados."
}, },
{ {
"id": 12, "id": 12,
"pregunta": "¿Cuál es la capital de Australia?", "pregunta": "¿Qué significa reutilizar un producto?",
"opciones": ["Sídney", "Melbourne", "Canberra", "Brisbane"], "opciones": ["Usarlo nuevamente para el mismo u otro propósito", "Tirarlo después del primer uso", "Comprar uno nuevo para reemplazarlo"],
"correcta": 2, "correcta": 0,
"retroalimentacion": "Canberra es la capital de Australia, no Sídney como muchos creen." "retroalimentacion": "Reutilizar prolonga la vida útil de los productos y evita generar nuevos residuos.",
"reflexion": "Reutilizar prolonga la vida útil de los productos y evita generar nuevos residuos.",
"datoCurioso": "Muchos objetos cotidianos pueden tener una segunda vida antes de convertirse en residuos."
}, },
{ {
"id": 13, "id": 13,
"pregunta": "¿Cuántos océanos hay en el planeta?", "pregunta": "¿Qué tipo de residuos pueden utilizarse para hacer composta?",
"opciones": ["3", "4", "5", "6"], "opciones": ["Restos de frutas y verduras", "Pilas y baterías", "Vidrio y aluminio"],
"correcta": 2, "correcta": 0,
"retroalimentacion": "Hay 5 océanos: Pacífico, Atlántico, Índico, Ártico y Antártico." "retroalimentacion": "Los residuos orgánicos pueden transformarse en un recurso en lugar de terminar en un relleno sanitario.",
"reflexion": "Los residuos orgánicos pueden transformarse en un recurso en lugar de terminar en un relleno sanitario.",
"datoCurioso": "La composta permite aprovechar materia orgánica y convertirla en un material que puede mejorar la calidad del suelo."
}, },
{ {
"id": 14, "id": 14,
"pregunta": "¿Cuál es el volcán activo más alto del mundo?", "pregunta": "¿Por qué es importante separar correctamente los residuos desde el origen?",
"opciones": ["Etna", "Kilimanjaro", "Ojos del Salado", "Cotopaxi"], "opciones": ["Para facilitar su aprovechamiento y disposición adecuada", "Para que todos los residuos se mezclen mejor", "Porque los residuos separados pesan menos"],
"correcta": 3, "correcta": 0,
"retroalimentacion": "El Cotopaxi en Ecuador es uno de los volcanes activos más altos del mundo con 5,897 metros." "retroalimentacion": "La separación comienza en el lugar donde generamos el residuo. Si lo hacemos correctamente, ayudamos a que los materiales puedan aprovecharse mejor.",
"reflexion": "La separación comienza en el lugar donde generamos el residuo. Si lo hacemos correctamente, ayudamos a que los materiales puedan aprovecharse mejor.",
"datoCurioso": "En TIP México los residuos se separan en orgánicos, inorgánicos reciclables, inorgánicos no reciclables y papel."
}, },
{ {
"id": 15, "id": 15,
"pregunta": "¿En qué país se encuentra la Torre de Pisa?", "pregunta": "¿Qué deberíamos hacer con las pilas usadas?",
"opciones": ["España", "Francia", "Italia", "Portugal"], "opciones": ["Tirarlas junto con la basura común", "Llevarlas a un punto de recolección adecuado", "Tirarlas al drenaje"],
"correcta": 2, "correcta": 1,
"retroalimentacion": "La Torre de Pisa se encuentra en la ciudad de Pisa, Italia." "retroalimentacion": "Algunos residuos requieren un manejo especial para evitar impactos ambientales.",
"reflexion": "Algunos residuos requieren un manejo especial para evitar impactos ambientales.",
"datoCurioso": "Las pilas pueden contener materiales que requieren una disposición diferente a la de los residuos comunes."
}, },
{ {
"id": 16, "id": 16,
"pregunta": "¿Cuál es el país más pequeño del mundo?", "pregunta": "¿Cuál de estas opciones genera menos residuos?",
"opciones": ["Mónaco", "San Marino", "Liechtenstein", "Ciudad del Vaticano"], "opciones": ["Usar una botella desechable nueva todos los días", "Utilizar una botella reutilizable", "Comprar varias botellas para tener reservas"],
"correcta": 3, "correcta": 1,
"retroalimentacion": "Ciudad del Vaticano es el país más pequeño del mundo con apenas 0.44 km²." "retroalimentacion": "Sustituir productos de un solo uso por alternativas reutilizables es una forma sencilla de reducir la cantidad de residuos que generamos.",
"reflexion": "Sustituir productos de un solo uso por alternativas reutilizables es una forma sencilla de reducir la cantidad de residuos que generamos.",
"datoCurioso": "Las decisiones de consumo cotidiano pueden ayudar a disminuir significativamente los residuos de un solo uso."
} }
] ]
}, },
{ {
"id": 2, "id": 2,
"nombre": "Ciencia", "nombre": "Energía",
"color": "#DC2626", "color": "#ea580c",
"emoji": "🔬", "emoji": "",
"preguntas": [ "preguntas": [
{ {
"id": 17, "id": 17,
"pregunta": "¿Cuál es el elemento más abundante en el universo?", "pregunta": "¿Cuánta menos energía consumen los focos LED comparados con los incandescentes?",
"opciones": ["Oxígeno", "Carbono", "Hidrógeno", "Helio"], "opciones": ["20%", "50%", "Hasta 80%"],
"correcta": 2, "correcta": 2,
"retroalimentacion": "El hidrógeno es el elemento más abundante del universo, constituyendo el 75% de su masa." "retroalimentacion": "Cambiar la iluminación es una de las acciones más simples y de mayor retorno para reducir el consumo energético en casa y en el trabajo.",
"reflexion": "Cambiar la iluminación es una de las acciones más simples y de mayor retorno para reducir el consumo energético en casa y en el trabajo.",
"datoCurioso": "Cambiar un solo foco incandescente por uno LED puede significar un ahorro de cientos de pesos al año, además de reducir el consumo de energía."
}, },
{ {
"id": 18, "id": 18,
"pregunta": "¿A qué velocidad viaja la luz en el vacío?", "pregunta": "¿Los aparatos electrónicos en \"modo espera\" (standby) siguen consumiendo energía aunque parezcan apagados?",
"opciones": ["200,000 km/s", "300,000 km/s", "400,000 km/s", "150,000 km/s"], "opciones": ["Sí, es lo que se conoce como \"consumo fantasma\"", "No, si la pantalla está apagada no consumen nada"],
"correcta": 1, "correcta": 0,
"retroalimentacion": "La luz viaja a aproximadamente 299,792 km/s en el vacío." "retroalimentacion": "Desconectar los aparatos o usar regletas con interruptor es un cambio pequeño que evita un gasto de energía invisible pero constante.",
"reflexion": "Desconectar los aparatos o usar regletas con interruptor es un cambio pequeño que evita un gasto de energía invisible pero constante.",
"datoCurioso": "Un televisor en modo espera sigue consumiendo electricidad aunque parezca apagado; este consumo fantasma, multiplicado por millones de personas, es significativo."
}, },
{ {
"id": 19, "id": 19,
"pregunta": "¿Cuántos huesos tiene el cuerpo humano adulto?", "pregunta": "¿Qué significa ser eficiente energéticamente?",
"opciones": ["196", "206", "216", "226"], "opciones": ["Utilizar más energía para obtener mejores resultados", "Obtener el mismo resultado utilizando menos energía", "No utilizar energía nunca"],
"correcta": 1, "correcta": 1,
"retroalimentacion": "El cuerpo humano adulto tiene 206 huesos." "retroalimentacion": "La eficiencia energética no significa dejar de hacer las cosas, sino hacerlas mejor utilizando menos recursos.",
"reflexion": "La eficiencia energética no significa dejar de hacer las cosas, sino hacerlas mejor utilizando menos recursos.",
"datoCurioso": "Un equipo eficiente puede ofrecer el mismo servicio que otro utilizando menos electricidad."
}, },
{ {
"id": 20, "id": 20,
"pregunta": "¿Cuál es el planeta más grande del sistema solar?", "pregunta": "¿Qué fuente de energía aprovechan los paneles solares?",
"opciones": ["Saturno", "Neptuno", "Júpiter", "Urano"], "opciones": ["La energía del viento", "La energía del sol", "La energía de las olas"],
"correcta": 2, "correcta": 1,
"retroalimentacion": "Júpiter es el planeta más grande del sistema solar." "retroalimentacion": "Las energías renovables aprovechan recursos naturales que se regeneran continuamente.",
"reflexion": "Las energías renovables aprovechan recursos naturales que se regeneran continuamente.",
"datoCurioso": "La energía solar puede utilizarse para generar electricidad y reducir la dependencia de fuentes fósiles."
}, },
{ {
"id": 21, "id": 21,
"pregunta": "¿Qué gas es el más abundante en la atmósfera terrestre?", "pregunta": "¿Qué acción ayuda a reducir el consumo de energía en una oficina?",
"opciones": ["Oxígeno", "Dióxido de carbono", "Nitrógeno", "Argón"], "opciones": ["Dejar los equipos encendidos toda la noche", "Apagar o configurar los equipos cuando no se utilizan", "Mantener todas las pantallas encendidas"],
"correcta": 2, "correcta": 1,
"retroalimentacion": "El nitrógeno constituye aproximadamente el 78% de la atmósfera terrestre." "retroalimentacion": "La gestión responsable de los equipos de trabajo puede reducir consumos innecesarios.",
"reflexion": "La gestión responsable de los equipos de trabajo puede reducir consumos innecesarios.",
"datoCurioso": "Configurar computadoras y monitores para entrar en modo ahorro de energía ayuda a reducir el consumo durante los periodos de inactividad."
}, },
{ {
"id": 22, "id": 22,
"pregunta": "¿Cuántos cromosomas tiene una célula humana normal?", "pregunta": "¿Qué ocurre cuando utilizamos menos energía eléctrica?",
"opciones": ["23", "44", "46", "48"], "opciones": ["Siempre aumentan las emisiones", "Podemos reducir el consumo de recursos y las emisiones asociadas a la generación de electricidad", "No ocurre nada"],
"correcta": 2, "correcta": 1,
"retroalimentacion": "Las células humanas normales tienen 46 cromosomas, organizados en 23 pares." "retroalimentacion": "Ahorrar energía puede beneficiar tanto al medio ambiente como a la economía de las personas y las organizaciones.",
"reflexion": "Ahorrar energía puede beneficiar tanto al medio ambiente como a la economía de las personas y las organizaciones.",
"datoCurioso": "El impacto ambiental de consumir electricidad depende, entre otros factores, de cómo se genera esa electricidad."
}, },
{ {
"id": 23, "id": 23,
"pregunta": "¿Quién formuló la teoría de la relatividad?", "pregunta": "¿Cuál de estas acciones ayuda a utilizar de manera más eficiente el aire acondicionado?",
"opciones": ["Isaac Newton", "Albert Einstein", "Nikola Tesla", "Stephen Hawking"], "opciones": ["Mantener puertas y ventanas abiertas", "Mantener cerrados los espacios climatizados", "Encenderlo con las puertas abiertas"],
"correcta": 1, "correcta": 1,
"retroalimentacion": "Albert Einstein formuló la teoría de la relatividad especial en 1905 y la general en 1915." "retroalimentacion": "Evitar la pérdida de aire acondicionado permite que el equipo trabaje menos y consuma menos energía.",
"reflexion": "Evitar la pérdida de aire acondicionado permite que el equipo trabaje menos y consuma menos energía.",
"datoCurioso": "El uso adecuado de los sistemas de climatización puede ayudar a reducir significativamente el consumo energético."
}, },
{ {
"id": 24, "id": 24,
"pregunta": "¿Cuál es el símbolo químico del oro?", "pregunta": "¿Qué tipo de iluminación suele ser más eficiente?",
"opciones": ["Go", "Ag", "Au", "Or"], "opciones": ["La iluminación incandescente", "La iluminación LED", "Todas consumen exactamente lo mismo"],
"correcta": 2, "correcta": 1,
"retroalimentacion": "El símbolo químico del oro es Au, del latín Aurum." "retroalimentacion": "Elegir tecnologías eficientes permite obtener la iluminación que necesitamos utilizando menos energía.",
"reflexion": "Elegir tecnologías eficientes permite obtener la iluminación que necesitamos utilizando menos energía.",
"datoCurioso": "Además de consumir menos energía, los focos LED suelen tener una vida útil más larga que los focos incandescentes."
} }
] ]
}, },
{ {
"id": 3, "id": 3,
"nombre": "Arte y Literatura", "nombre": "Electromovilidad y Transporte Sostenible",
"color": "#D97706", "color": "#7c3aed",
"emoji": "🎨", "emoji": "🚗",
"preguntas": [ "preguntas": [
{ {
"id": 25, "id": 25,
"pregunta": "¿Quién escribió 'Cien años de soledad'?", "pregunta": "¿Qué acción tiene mayor impacto positivo para reducir tu huella de carbono en el transporte diario?",
"opciones": ["Mario Vargas Llosa", "Pablo Neruda", "Gabriel García Márquez", "Jorge Luis Borges"], "opciones": ["Usar transporte público o compartir el auto", "Viajar siempre en auto propio, aunque sea solo"],
"correcta": 2, "correcta": 0,
"retroalimentacion": "Gabriel García Márquez escribió Cien años de soledad en 1967, obra cumbre del realismo mágico." "retroalimentacion": "Elegir cómo nos transportamos es una de las decisiones cotidianas con mayor peso en nuestra huella ambiental.",
"reflexion": "Elegir cómo nos transportamos es una de las decisiones cotidianas con mayor peso en nuestra huella ambiental.",
"datoCurioso": "Un solo autobús puede reemplazar hasta 40 autos, reduciendo la congestión, el consumo de combustible y la contaminación."
}, },
{ {
"id": 26, "id": 26,
"pregunta": "¿Quién pintó 'La noche estrellada'?", "pregunta": "¿TIP México cuenta con infraestructura de carga para vehículos eléctricos e híbridos en sus instalaciones?",
"opciones": ["Pablo Picasso", "Claude Monet", "Salvador Dalí", "Vincent van Gogh"], "opciones": ["Sí, cuenta con 10 cargadores eléctricos", "No, aún no se ha implementado"],
"correcta": 3, "correcta": 0,
"retroalimentacion": "La noche estrellada fue pintada por Vincent van Gogh en 1889." "retroalimentacion": "La movilidad sostenible también se construye desde la infraestructura que facilita la transición hacia vehículos de menor impacto ambiental.",
"reflexion": "La movilidad sostenible también se construye desde la infraestructura que facilita la transición hacia vehículos de menor impacto ambiental.",
"datoCurioso": "TIP México cuenta con 10 cargadores eléctricos instalados como parte de su compromiso con la movilidad sostenible."
}, },
{ {
"id": 27, "id": 27,
"pregunta": "¿En qué país nació Mozart?", "pregunta": "¿Cuál es la principal fuente de energía de un vehículo eléctrico?",
"opciones": ["Alemania", "Austria", "Italia", "Francia"], "opciones": ["Gasolina", "Electricidad almacenada en una batería", "Diésel"],
"correcta": 1, "correcta": 1,
"retroalimentacion": "Wolfgang Amadeus Mozart nació en Salzburgo, Austria, en 1756." "retroalimentacion": "La electromovilidad cambia la forma en que utilizamos la energía para movernos.",
"reflexion": "La electromovilidad cambia la forma en que utilizamos la energía para movernos.",
"datoCurioso": "Los vehículos eléctricos utilizan uno o más motores eléctricos alimentados por la energía almacenada en sus baterías."
}, },
{ {
"id": 28, "id": 28,
"pregunta": "¿Quién escribió 'Don Quijote de la Mancha'?", "pregunta": "¿Qué caracteriza a un vehículo híbrido?",
"opciones": ["Lope de Vega", "Miguel de Cervantes", "Francisco de Quevedo", "Garcilaso de la Vega"], "opciones": ["Combina un motor de combustión con un sistema eléctrico", "Funciona únicamente con gasolina", "No utiliza ningún tipo de batería"],
"correcta": 1, "correcta": 0,
"retroalimentacion": "Don Quijote de la Mancha fue escrita por Miguel de Cervantes y publicada en 1605." "retroalimentacion": "La tecnología híbrida combina diferentes sistemas de propulsión para mejorar la eficiencia en determinadas condiciones.",
"reflexion": "La tecnología híbrida combina diferentes sistemas de propulsión para mejorar la eficiencia en determinadas condiciones.",
"datoCurioso": "Los vehículos híbridos pueden utilizar un motor de combustión interna y uno o más motores eléctricos."
}, },
{ {
"id": 29, "id": 29,
"pregunta": "¿Quién compuso la Quinta Sinfonía?", "pregunta": "¿Qué es un vehículo híbrido enchufable (PHEV)?",
"opciones": ["Mozart", "Bach", "Beethoven", "Chopin"], "opciones": ["Un vehículo que puede cargarse conectándolo a una fuente eléctrica y también utiliza un motor de combustión", "Un vehículo que solo utiliza gasolina", "Un vehículo que no tiene batería"],
"correcta": 2, "correcta": 0,
"retroalimentacion": "La Quinta Sinfonía fue compuesta por Ludwig van Beethoven entre 1804 y 1808." "retroalimentacion": "Los vehículos híbridos enchufables combinan la posibilidad de conducir utilizando electricidad con la flexibilidad de un motor de combustión.",
"reflexion": "Los vehículos híbridos enchufables combinan la posibilidad de conducir utilizando electricidad con la flexibilidad de un motor de combustión.",
"datoCurioso": "Los PHEV pueden recorrer cierta distancia utilizando únicamente energía eléctrica, dependiendo del modelo y de la carga disponible."
}, },
{ {
"id": 30, "id": 30,
"pregunta": "¿En qué museo se encuentra la Mona Lisa?", "pregunta": "¿Qué sucede con parte de la energía durante el frenado regenerativo?",
"opciones": ["Museo del Prado", "Museo Británico", "Museo del Louvre", "Museo Uffizi"], "opciones": ["Puede recuperarse y almacenarse nuevamente en la batería", "Desaparece completamente", "Se transforma en gasolina"],
"correcta": 2, "correcta": 0,
"retroalimentacion": "La Mona Lisa se encuentra en el Museo del Louvre en París, Francia." "retroalimentacion": "Esta tecnología permite aprovechar parte de la energía del movimiento que normalmente se perdería durante el frenado.",
"reflexion": "Esta tecnología permite aprovechar parte de la energía del movimiento que normalmente se perdería durante el frenado.",
"datoCurioso": "El frenado regenerativo convierte parte de la energía cinética del vehículo en electricidad."
}, },
{ {
"id": 31, "id": 31,
"pregunta": "¿Quién escribió 'Romeo y Julieta'?", "pregunta": "¿Qué elemento es fundamental para impulsar la adopción de vehículos eléctricos?",
"opciones": ["Charles Dickens", "William Shakespeare", "Jane Austen", "Victor Hugo"], "opciones": ["Una infraestructura adecuada de carga", "Eliminar todas las alternativas de transporte", "Evitar nuevas tecnologías"],
"correcta": 1, "correcta": 0,
"retroalimentacion": "Romeo y Julieta fue escrita por William Shakespeare alrededor de 1595." "retroalimentacion": "Contar con puntos de carga accesibles facilita que más personas y empresas consideren la movilidad eléctrica.",
"reflexion": "Contar con puntos de carga accesibles facilita que más personas y empresas consideren la movilidad eléctrica.",
"datoCurioso": "La infraestructura de carga es un elemento clave para integrar los vehículos eléctricos a las actividades diarias."
}, },
{ {
"id": 32, "id": 32,
"pregunta": "¿Qué movimiento artístico lideró Salvador Dalí?", "pregunta": "¿Cuál de estas opciones puede ayudar a reducir las emisiones asociadas al transporte?",
"opciones": ["Cubismo", "Impresionismo", "Surrealismo", "Expresionismo"], "opciones": ["Usar transporte público, compartir el auto o utilizar vehículos de menor emisión", "Utilizar siempre un vehículo individual aunque el trayecto sea corto", "Mantener el vehículo encendido cuando está estacionado"],
"correcta": 2, "correcta": 0,
"retroalimentacion": "Salvador Dalí fue uno de los principales representantes del Surrealismo." "retroalimentacion": "La movilidad sostenible no depende de una única solución; combinar diferentes alternativas puede ayudarnos a reducir el impacto ambiental de nuestros traslados.",
"reflexion": "La movilidad sostenible no depende de una única solución; combinar diferentes alternativas puede ayudarnos a reducir el impacto ambiental de nuestros traslados.",
"datoCurioso": "La movilidad sostenible incluye opciones como el transporte público, la movilidad activa, el uso compartido de vehículos y la electromovilidad."
} }
] ]
} }
],
"bancoGenerico": [
{
"id": 101,
"pregunta": "¿Cuál es el océano más grande del mundo?",
"opciones": ["Atlántico", "Índico", "Pacífico", "Ártico"],
"correcta": 2,
"retroalimentacion": "El océano Pacífico es el más grande, cubriendo más de 165 millones de km²."
},
{
"id": 102,
"pregunta": "¿Cuántos planetas tiene el sistema solar?",
"opciones": ["7", "8", "9", "10"],
"correcta": 1,
"retroalimentacion": "El sistema solar tiene 8 planetas desde que Plutón fue reclasificado en 2006."
},
{
"id": 103,
"pregunta": "¿Cuál es el animal terrestre más rápido?",
"opciones": ["León", "Guepardo", "Antílope", "Caballo"],
"correcta": 1,
"retroalimentacion": "El guepardo puede alcanzar velocidades de hasta 120 km/h, siendo el animal terrestre más rápido."
},
{
"id": 104,
"pregunta": "¿En qué año se fundó la ONU?",
"opciones": ["1943", "1945", "1947", "1950"],
"correcta": 1,
"retroalimentacion": "La ONU fue fundada el 24 de octubre de 1945 tras el fin de la Segunda Guerra Mundial."
},
{
"id": 105,
"pregunta": "¿Cuál es el idioma más hablado en el mundo?",
"opciones": ["Inglés", "Español", "Mandarín", "Hindi"],
"correcta": 2,
"retroalimentacion": "El mandarín es el idioma con más hablantes nativos, con más de 900 millones."
}
] ]
} }
+11
View File
@@ -2,11 +2,19 @@ const KEY_INTENTOS = 'ruleta_intentos_usados'
const KEY_AGOTADOS = 'ruleta_intentos_agotados' const KEY_AGOTADOS = 'ruleta_intentos_agotados'
const MAX_INTENTOS = 3 const MAX_INTENTOS = 3
let intentosInfinitos = false
export function setIntentosInfinitos(valor: boolean) {
intentosInfinitos = valor
}
function getIntentosUsados(): number { function getIntentosUsados(): number {
if (intentosInfinitos) return 0
return parseInt(sessionStorage.getItem(KEY_INTENTOS) ?? '0', 10) return parseInt(sessionStorage.getItem(KEY_INTENTOS) ?? '0', 10)
} }
function setIntentosUsados(n: number): void { function setIntentosUsados(n: number): void {
if (intentosInfinitos) return
sessionStorage.setItem(KEY_INTENTOS, String(n)) sessionStorage.setItem(KEY_INTENTOS, String(n))
if (n >= MAX_INTENTOS) { if (n >= MAX_INTENTOS) {
sessionStorage.setItem(KEY_AGOTADOS, 'true') sessionStorage.setItem(KEY_AGOTADOS, 'true')
@@ -14,14 +22,17 @@ function setIntentosUsados(n: number): void {
} }
export function intentosAgotados(): boolean { export function intentosAgotados(): boolean {
if (intentosInfinitos) return false
return sessionStorage.getItem(KEY_AGOTADOS) === 'true' return sessionStorage.getItem(KEY_AGOTADOS) === 'true'
} }
export function intentosRestantes(): number { export function intentosRestantes(): number {
if (intentosInfinitos) return 999
return Math.max(0, MAX_INTENTOS - getIntentosUsados()) return Math.max(0, MAX_INTENTOS - getIntentosUsados())
} }
export function consumirIntento(): void { export function consumirIntento(): void {
if (intentosInfinitos) return
setIntentosUsados(getIntentosUsados() + 1) setIntentosUsados(getIntentosUsados() + 1)
} }
+112 -38
View File
@@ -1,13 +1,15 @@
import { useState, useRef } from 'react' import { useState, useRef, useEffect, useMemo } from 'react'
import type { Pregunta, Categoria, PreguntasData } from '../types' import type { Pregunta, Categoria, PreguntasData } from '../types'
import data from '../data/preguntas.json' import data from '../data/preguntas.json'
import { sounds } from '../audio/AudioController' import { sounds } from '../audio/AudioController'
const { categorias, bancoGenerico } = data as PreguntasData const { categorias } = data as PreguntasData
const MAX_PREGUNTAS = 10 const MAX_PREGUNTAS = 12
const MIN_CORRECTAS = 8 const PREGUNTAS_POR_CATEGORIA = 3
const MIN_CORRECTAS = 10 // 80% de 12 para aprobar
const SECCIONES = categorias.length const SECCIONES = categorias.length
const ANGULO_SECCION = 360 / SECCIONES const ANGULO_SECCION = 360 / SECCIONES
export const TIEMPO_LIMITE = 30
export interface EstadoJuego { export interface EstadoJuego {
isSpinning: boolean isSpinning: boolean
@@ -20,11 +22,34 @@ export interface EstadoJuego {
correctas: number correctas: number
completado: boolean completado: boolean
aprobado: boolean aprobado: boolean
preguntasUsadas: Record<number, number[]> /** Cuántas preguntas se han usado ya por categoría (id → conteo) */
bancoPreguntasUsadas: number[] preguntasUsadasPorCategoria: Record<number, number>
/** Pool de 3 preguntas pre-seleccionadas por categoría (id → preguntas) */
preguntasPool: Record<number, Pregunta[]>
tiempoRestante: number
}
/**
* Pre-selecciona aleatoriamente 3 preguntas de cada categoría.
* Se ejecuta una sola vez al montar el hook.
*/
function crearPool(): Record<number, Pregunta[]> {
const pool: Record<number, Pregunta[]> = {}
for (const cat of categorias) {
const indices = Array.from({ length: cat.preguntas.length }, (_, i) => i)
// FisherYates shuffle
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[indices[i], indices[j]] = [indices[j], indices[i]]
}
pool[cat.id] = indices.slice(0, PREGUNTAS_POR_CATEGORIA).map((i) => cat.preguntas[i])
}
return pool
} }
export function useRuleta() { export function useRuleta() {
const poolInicial = useMemo(() => crearPool(), [])
const [estado, setEstado] = useState<EstadoJuego>({ const [estado, setEstado] = useState<EstadoJuego>({
isSpinning: false, isSpinning: false,
rotacion: 0, rotacion: 0,
@@ -36,26 +61,66 @@ export function useRuleta() {
correctas: 0, correctas: 0,
completado: false, completado: false,
aprobado: false, aprobado: false,
preguntasUsadas: {}, preguntasUsadasPorCategoria: {},
bancoPreguntasUsadas: [], preguntasPool: poolInicial,
tiempoRestante: TIEMPO_LIMITE,
}) })
const rotacionRef = useRef(0) const rotacionRef = useRef(0)
function seleccionarPregunta(categoria: Categoria, preguntasUsadas: Record<number, number[]>, bancoPreguntasUsadas: number[]): { pregunta: Pregunta, esGenerica: boolean } { // ─── Temporizador por pregunta ───────────────────────────────────────────
const usadas = preguntasUsadas[categoria.id] ?? [] useEffect(() => {
const disponibles = categoria.preguntas.filter((_, i) => !usadas.includes(i)) if (estado.mostrarFeedback || !estado.preguntaActual || estado.completado) return
if (disponibles.length > 0) { const interval = setInterval(() => {
const idx = Math.floor(Math.random() * disponibles.length) setEstado((prev) => {
return { pregunta: disponibles[idx], esGenerica: false } if (prev.mostrarFeedback || !prev.preguntaActual) return prev
if (prev.tiempoRestante <= 1) {
sounds.incorrect?.play()
const nuevasRespondidas = prev.preguntasRespondidas + 1
const completado = nuevasRespondidas >= MAX_PREGUNTAS
const aprobado = completado && prev.correctas >= MIN_CORRECTAS
return {
...prev,
tiempoRestante: 0,
preguntasRespondidas: nuevasRespondidas,
mostrarFeedback: true,
feedbackCorrecto: false,
completado,
aprobado,
}
}
return { ...prev, tiempoRestante: prev.tiempoRestante - 1 }
})
}, 1000)
return () => clearInterval(interval)
}, [estado.preguntaActual?.id, estado.mostrarFeedback, estado.completado])
/**
* Busca la siguiente pregunta disponible.
* 1. Intenta usar la categoría donde cayó la ruleta (si le quedan preguntas).
* 2. Si no, recorre las demás categorías en orden circular.
* 3. Retorna null si ya se usaron las 12 preguntas.
*/
function obtenerSiguientePregunta(
catIndice: number,
usadasPorCat: Record<number, number>,
pool: Record<number, Pregunta[]>,
): { pregunta: Pregunta; categoria: Categoria } | null {
// Recorrer desde catIndice en círculo
for (let vuelta = 0; vuelta < SECCIONES; vuelta++) {
const idx = (catIndice + vuelta) % SECCIONES
const cat = categorias[idx]
const usadas = usadasPorCat[cat.id] ?? 0
const preguntasCat = pool[cat.id] ?? []
if (usadas < preguntasCat.length) {
return { pregunta: preguntasCat[usadas], categoria: cat }
}
} }
return null
// Banco genérico
const disponiblesGenericas = bancoGenerico.filter((_, i) => !bancoPreguntasUsadas.includes(i))
const pool = disponiblesGenericas.length > 0 ? disponiblesGenericas : bancoGenerico
const idx = Math.floor(Math.random() * pool.length)
return { pregunta: pool[idx], esGenerica: true }
} }
function girar() { function girar() {
@@ -73,33 +138,36 @@ export function useRuleta() {
setTimeout(() => { setTimeout(() => {
const anguloNormalizado = ((360 - (rotacionRef.current % 360)) + 360) % 360 const anguloNormalizado = ((360 - (rotacionRef.current % 360)) + 360) % 360
const indiceCategoria = Math.floor(anguloNormalizado / ANGULO_SECCION) % SECCIONES const indiceCategoria = Math.floor(anguloNormalizado / ANGULO_SECCION) % SECCIONES
const categoria = categorias[indiceCategoria]
setEstado((prev) => { setEstado((prev) => {
const { pregunta, esGenerica } = seleccionarPregunta(categoria, prev.preguntasUsadas, prev.bancoPreguntasUsadas) const resultado = obtenerSiguientePregunta(
const originalIdx = esGenerica indiceCategoria,
? bancoGenerico.indexOf(pregunta) prev.preguntasUsadasPorCategoria,
: categoria.preguntas.indexOf(pregunta) prev.preguntasPool,
)
if (!resultado) {
// Ya no hay preguntas — marcar como completado
return {
...prev,
completado: true,
aprobado: prev.correctas >= MIN_CORRECTAS,
}
}
return { return {
...prev, ...prev,
preguntaActual: pregunta, preguntaActual: resultado.pregunta,
categoriaActual: categoria, categoriaActual: resultado.categoria,
preguntasUsadas: esGenerica ? prev.preguntasUsadas : { tiempoRestante: TIEMPO_LIMITE,
...prev.preguntasUsadas,
[categoria.id]: [...(prev.preguntasUsadas[categoria.id] ?? []), originalIdx],
},
bancoPreguntasUsadas: esGenerica
? [...prev.bancoPreguntasUsadas, originalIdx]
: prev.bancoPreguntasUsadas,
} }
}) })
}, 4000) }, 4000)
} }
function responder(indiceOpcion: number | null) { function responder(indiceOpcion: number) {
if (!estado.preguntaActual) return if (!estado.preguntaActual || !estado.categoriaActual) return
const esCorrecta = indiceOpcion !== null && indiceOpcion === estado.preguntaActual.correcta const esCorrecta = indiceOpcion === estado.preguntaActual.correcta
if (esCorrecta) sounds.correct?.play() if (esCorrecta) sounds.correct?.play()
else sounds.incorrect?.play() else sounds.incorrect?.play()
@@ -117,6 +185,11 @@ export function useRuleta() {
feedbackCorrecto: esCorrecta, feedbackCorrecto: esCorrecta,
completado, completado,
aprobado, aprobado,
preguntasUsadasPorCategoria: {
...prev.preguntasUsadasPorCategoria,
[prev.categoriaActual!.id]:
(prev.preguntasUsadasPorCategoria[prev.categoriaActual!.id] ?? 0) + 1,
},
})) }))
} }
@@ -127,8 +200,9 @@ export function useRuleta() {
categoriaActual: null, categoriaActual: null,
mostrarFeedback: false, mostrarFeedback: false,
isSpinning: false, isSpinning: false,
tiempoRestante: TIEMPO_LIMITE,
})) }))
} }
return { estado, categorias, girar, responder, cerrarFeedback, MAX_PREGUNTAS, MIN_CORRECTAS } return { estado, categorias, girar, responder, cerrarFeedback, MAX_PREGUNTAS, MIN_CORRECTAS, TIEMPO_LIMITE }
} }
+24 -4
View File
@@ -1,23 +1,43 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useScale } from '../hooks/useScale' import { useScale } from '../hooks/useScale'
import Header from '../components/layout/Header' import Header from '../components/layout/Header'
import Footer from '../components/layout/Footer' import ActivityBar from '../components/ui/ActivityBar'
interface MainLayoutProps { interface MainLayoutProps {
children: ReactNode children: ReactNode
/** Props para el ActivityBar. Si no se pasa, la pantalla hija debe renderizar su propio ActivityBar. */
barProps?: {
preguntaActual?: number
totalPreguntas?: number
mensaje: string
puedeAnterior?: boolean
puedeSiguiente?: boolean
onPrev?: () => void
onNext?: () => void
}
} }
export default function MainLayout({ children }: MainLayoutProps) { export default function MainLayout({ children, barProps }: MainLayoutProps) {
const scaleRef = useScale() const scaleRef = useScale()
return ( return (
<div className="w-screen h-screen overflow-hidden bg-white relative"> <div className="w-screen h-screen overflow-hidden bg-white relative">
<div ref={scaleRef} className="bg-gray-100 flex flex-col"> <div ref={scaleRef} className="bg-gray-100 flex flex-col">
<Header /> <Header />
<main className="flex-1 flex items-center justify-center px-4 py-8"> <main className="flex-1 flex flex-col items-center justify-center px-0 pt-8 overflow-hidden" style={{ minHeight: 0 }}>
{children} {children}
</main> </main>
<Footer /> {barProps && (
<ActivityBar
preguntaActual={barProps.preguntaActual ?? 0}
totalPreguntas={barProps.totalPreguntas ?? 0}
mensaje={barProps.mensaje}
puedeAnterior={barProps.puedeAnterior ?? false}
puedeSiguiente={barProps.puedeSiguiente ?? false}
onPrev={barProps.onPrev}
onNext={barProps.onNext}
/>
)}
</div> </div>
</div> </div>
) )
+4
View File
@@ -2,6 +2,10 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { setIntentosInfinitos } from './hooks/useIntentos'
// Activar intentos infinitos
setIntentosInfinitos(true)
// Simulador SCORM solo en desarrollo // Simulador SCORM solo en desarrollo
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
+4 -1
View File
@@ -4,6 +4,10 @@ export interface Pregunta {
opciones: string[] opciones: string[]
correcta: number correcta: number
retroalimentacion: string retroalimentacion: string
/** Texto de reflexión educativa (opcional, usa retroalimentacion si no se provee) */
reflexion?: string
/** Dato curioso para la sección "¿Sabías qué?" (opcional) */
datoCurioso?: string
} }
export interface Categoria { export interface Categoria {
@@ -16,5 +20,4 @@ export interface Categoria {
export interface PreguntasData { export interface PreguntasData {
categorias: Categoria[] categorias: Categoria[]
bancoGenerico: Pregunta[]
} }