feat: Ajuste de ejecucion de ruleta, aplicacion de CC

This commit is contained in:
2026-07-29 11:12:33 -06:00
parent 0525ec8c8c
commit 1ea384bcf9
11 changed files with 115 additions and 84 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 459 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

+5 -5
View File
@@ -77,8 +77,8 @@ export default function FeedbackModal({
<div <div
className="flex flex-col justify-center transition-all duration-300" className="flex flex-col justify-center transition-all duration-300"
style={{ style={{
width: '1700px', width: '1100px',
maxWidth: '1824px', maxWidth: '1100px',
height: '594px', height: '594px',
background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)', background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
borderRadius: '18px', borderRadius: '18px',
@@ -128,7 +128,7 @@ export default function FeedbackModal({
<div className="px-12 pt-10 pb-6"> <div className="px-12 pt-10 pb-6">
<p <p
className="leading-relaxed" className="leading-relaxed"
style={{ color: '#333333', fontSize: '22px', lineHeight: '1.6' }} style={{ color: '#333333', fontSize: '26px', lineHeight: '1.6' }}
> >
<strong>Reflexión:</strong> {reflexion} <strong>Reflexión:</strong> {reflexion}
</p> </p>
@@ -144,7 +144,7 @@ export default function FeedbackModal({
> >
<span <span
className="font-bold text-white whitespace-nowrap" className="font-bold text-white whitespace-nowrap"
style={{ fontSize: '28px' }} style={{ fontSize: '32px' }}
> >
¿Sabías qué? ¿Sabías qué?
</span> </span>
@@ -152,7 +152,7 @@ export default function FeedbackModal({
<div className="flex-1 px-6 py-5"> <div className="flex-1 px-6 py-5">
<p <p
className="leading-relaxed" className="leading-relaxed"
style={{ color: '#444444', fontSize: '22px', lineHeight: '1.6' }} style={{ color: '#444444', fontSize: '26px', lineHeight: '1.6' }}
> >
{datoCurioso} {datoCurioso}
</p> </p>
+8 -6
View File
@@ -71,8 +71,8 @@ export default function RuletaSVG({ categorias, rotacion, isSpinning, onClick }:
return ( return (
<div className="relative flex flex-col items-center justify-center w-full select-none"> <div className="relative flex flex-col items-center justify-center w-full select-none">
{/* ─── Puntero (fuera del SVG, fijo arriba) ─── */} {/* ─── Puntero ─── */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 z-20" style={{ marginTop: '35px' }}> <div className="absolute top-0 left-1/2 -translate-x-1/2 z-20" style={{ marginTop: '0px' }}>
<img <img
src="./img/indicador.png" src="./img/indicador.png"
alt="Indicador" alt="Indicador"
@@ -81,7 +81,7 @@ export default function RuletaSVG({ categorias, rotacion, isSpinning, onClick }:
/> />
</div> </div>
{/* ─── SVG principal (ruleta + anillo + soporte) ─── */} {/* ─── SVG principal ─── */}
<svg <svg
viewBox={`0 0 ${VIEWBOX_W} ${VIEWBOX_H}`} viewBox={`0 0 ${VIEWBOX_W} ${VIEWBOX_H}`}
onClick={!isSpinning ? onClick : undefined} onClick={!isSpinning ? onClick : undefined}
@@ -101,7 +101,8 @@ export default function RuletaSVG({ categorias, rotacion, isSpinning, onClick }:
{/* ══════ GRUPO GIRATORIO: Segmentos + centro ══════ */} {/* ══════ GRUPO GIRATORIO: Segmentos + centro ══════ */}
<g <g
style={{ style={{
transformOrigin: `${CX}px ${CY}px`, transformBox: 'fill-box',
transformOrigin: 'center center',
transform: `rotate(${rotacion}deg)`, transform: `rotate(${rotacion}deg)`,
transition: 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)', transition: 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)',
}} }}
@@ -120,7 +121,8 @@ export default function RuletaSVG({ categorias, rotacion, isSpinning, onClick }:
const textOffsetX = i === 0 ? 0 : i === 1 ? -40 : i === 2 ? 30 : i === 3 ? 38 : 0 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 textOffsetY = i === 0 ? -45 : i === 1 ? 10 : i === 3 ? -12 : 0
const textPos = { x: textPosRaw.x + textOffsetX, y: textPosRaw.y + textOffsetY } const textPos = { x: textPosRaw.x + textOffsetX, y: textPosRaw.y + textOffsetY }
const imgSize = 60 const imgSize = i === 1 ? 58 : i === 2 ? 95 : i === 3 ? 88 : 70
const imgOffsetY = i === 0 ? 12 : i === 3 ? 15 : 0
/* Imágenes por segmento */ /* Imágenes por segmento */
const imgs = ['agua.png', 'residuos.png', 'energia.png', 'movilidad.png'] const imgs = ['agua.png', 'residuos.png', 'energia.png', 'movilidad.png']
@@ -142,7 +144,7 @@ export default function RuletaSVG({ categorias, rotacion, isSpinning, onClick }:
<image <image
href={`./img/${imgs[i]}`} href={`./img/${imgs[i]}`}
x={imgCenter.x - imgSize / 2} x={imgCenter.x - imgSize / 2}
y={imgCenter.y - imgSize / 2} y={imgCenter.y - imgSize / 2 + imgOffsetY}
width={imgSize} width={imgSize}
height={imgSize} height={imgSize}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
+2 -2
View File
@@ -3,12 +3,12 @@ export default function Header() {
<header <header
className="w-full bg-transparent flex items-center shrink-0" className="w-full bg-transparent flex items-center shrink-0"
style={{ style={{
height: '90px', height: '130px',
}} }}
> >
{/* Logo izquierdo */} {/* Logo izquierdo */}
<div style={{ marginLeft: '75px', display: 'flex', alignItems: 'center' }}> <div style={{ marginLeft: '75px', display: 'flex', alignItems: 'center' }}>
<img src="./img/logo.png" alt="TIP" style={{ height: '50px', width: 'auto' }} /> <img src="./img/logo.png" alt="TIP" style={{ height: '110px', width: 'auto' }} />
</div> </div>
</header> </header>
) )
+6 -4
View File
@@ -32,15 +32,17 @@ export default function Cover({ onStart }: CoverProps) {
className="rounded-3xl p-8 shadow-lg" className="rounded-3xl p-8 shadow-lg"
style={{ background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)' }} style={{ background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)' }}
> >
<p className="text-gray-700 leading-relaxed" style={{ fontSize: '22px' }}> <p className="text-gray-700 leading-relaxed" style={{ fontSize: '26px' }}>
Las reglas son muy sencillas: la ruleta girará, la suerte elegirá una categoría 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 y te tocará responder una pregunta. Contarás con 30 segundos para responder
cada una. cada una.
</p> </p>
<p className="text-gray-700 leading-relaxed mt-4" style={{ fontSize: '22px' }}> <p className="text-gray-700 leading-relaxed mt-4" style={{ fontSize: '26px' }}>
No se trata de encontrar la respuesta perfecta, sino de reflexionar y responder 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 desde tu experiencia y perspectiva.
conocimientos y disfrutar de la dinámica! </p>
<p className="text-gray-700 leading-relaxed mt-4" style={{ fontSize: '26px' }}>
¡Prepárate para poner a prueba tus conocimientos y disfrutar de la dinámica!
</p> </p>
</div> </div>
+26 -10
View File
@@ -13,9 +13,9 @@ const LETRAS = ['A', 'B', 'C', 'D']
/** Mapeo de nombre de categoría → imagen PNG */ /** Mapeo de nombre de categoría → imagen PNG */
const CAT_IMG: Record<string, string> = { const CAT_IMG: Record<string, string> = {
'Agua': 'agua.png', 'Agua': 'agua.png',
'Residuos': 'residuos.png', 'Residuos y Circularidad': 'residuos.png',
'Energía': 'energia.png', 'Energía': 'energia.png',
'Electromovilidad': 'movilidad.png', 'Electromovilidad y Transporte Sostenible': 'movilidad.png',
} }
export default function Game({ onEnd }: GameProps) { export default function Game({ onEnd }: GameProps) {
@@ -44,7 +44,7 @@ export default function Game({ onEnd }: GameProps) {
backgroundColor: '#ed1f24', backgroundColor: '#ed1f24',
borderRadius: '0px 0px 0px 25px', borderRadius: '0px 0px 0px 25px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)', boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
maxWidth: '520px', maxWidth: '700px',
}} }}
> >
{/* Círculo blanco con imagen de categoría */} {/* Círculo blanco con imagen de categoría */}
@@ -52,29 +52,45 @@ export default function Game({ onEnd }: GameProps) {
<div <div
className="rounded-full flex items-center justify-center shrink-0" className="rounded-full flex items-center justify-center shrink-0"
style={{ style={{
width: '52px', width: '80px',
height: '52px', height: '80px',
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)', boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
marginTop: '-19px',
marginBottom: '-55px',
position: 'relative',
zIndex: 30,
}} }}
> >
<img <img
src={`./img/${CAT_IMG[categoria.nombre]}`} src={`./img/${CAT_IMG[categoria.nombre]}`}
alt={categoria.nombre} alt={categoria.nombre}
className="object-contain" className="object-contain"
style={{ width: '36px', height: '36px' }} style={{ width: '50px', height: '50px' }}
/> />
</div> </div>
)} )}
<span <span
className="text-white font-extrabold tracking-wide py-3 whitespace-nowrap" className="text-white font-extrabold tracking-wide py-3 whitespace-nowrap"
style={{ fontSize: '22px' }} style={{ fontSize: categoria.nombre.length > 20 ? '26px' : '30px' }}
> >
{categoria.nombre} {categoria.nombre}
</span> </span>
</div> </div>
)} )}
{/* ─── Contador de preguntas (bajo la barra roja) ─── */}
<div className="absolute top-0 right-0 z-10" style={{ marginTop: '72px' }}>
<div
className="px-4 py-1"
style={{ backgroundColor: '#000000', borderRadius: '10px 0px 0px 10px' }}
>
<span className="text-white font-bold" style={{ fontSize: '26px' }}>
{Math.min(estado.preguntasRespondidas + 1, MAX_PREGUNTAS)}/{MAX_PREGUNTAS}
</span>
</div>
</div>
{/* ─── Contenido principal: Ruleta + Panel de pregunta ─── */} {/* ─── Contenido principal: Ruleta + Panel de pregunta ─── */}
<div className="flex-1 flex items-center justify-center min-h-0"> <div className="flex-1 flex items-center justify-center min-h-0">
@@ -148,10 +164,10 @@ export default function Game({ onEnd }: GameProps) {
background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)', background: 'linear-gradient(180deg, #ffffff 0%, #d8dcec 100%)',
}} }}
> >
<p className="text-gray-700 font-medium" style={{ fontSize: '22px' }}> <p className="text-gray-700 font-medium" style={{ fontSize: '26px' }}>
Gira la ruleta para obtener una pregunta Gira la ruleta para obtener una pregunta
</p> </p>
<p className="text-gray-500 mt-2" style={{ fontSize: '22px' }}> <p className="text-gray-500 mt-2" style={{ fontSize: '26px' }}>
Cada respuesta correcta te acerca al objetivo de {MAX_PREGUNTAS} preguntas Cada respuesta correcta te acerca al objetivo de {MAX_PREGUNTAS} preguntas
</p> </p>
</div> </div>
+11 -5
View File
@@ -139,11 +139,14 @@ export default function Intro() {
{/* ─── Columna derecha (40%): texto ─────────── */} {/* ─── Columna derecha (40%): texto ─────────── */}
<div className="flex flex-col justify-center" style={{ width: '35%', paddingRight: '20px' }}> <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%'}}> <div className="relative z-10 w-full bg-transparent rounded-3xl p-10" style={{ top: 'calc(-10% - 65px)' }}>
{/* Texto introductorio */} {/* Texto introductorio */}
<p className="text-gray-700 leading-relaxed mb-8" style={{ fontSize: '22px' }}> <p className="text-gray-700 leading-relaxed mb-6" style={{ fontSize: '26px' }}>
Hoy vamos a poner a prueba nuestros conocimientos y a reflexionar juntos sobre Hoy vamos a poner a prueba nuestros conocimientos y a reflexionar juntos sobre
cómo nuestras acciones impactan el planeta. En esta ruleta tenemos{' '} cómo nuestras acciones impactan el planeta.
</p>
<p className="text-gray-700 leading-relaxed mb-8" style={{ fontSize: '26px' }}>
En esta ruleta tenemos{' '}
<strong>4 ejes fundamentales para el futuro:</strong> <strong>4 ejes fundamentales para el futuro:</strong>
</p> </p>
@@ -157,10 +160,13 @@ export default function Intro() {
src={`./img/${imagenes[i]}`} src={`./img/${imagenes[i]}`}
alt={cat.nombre} alt={cat.nombre}
className="shrink-0 object-contain" className="shrink-0 object-contain"
style={{ width: '75px', height: '78px' }} style={{
width: i === 1 ? '48px' : '60px',
height: i === 1 ? '50px' : '62px',
}}
/> />
<div> <div>
<strong className="text-gray-800" style={{ fontSize: '22px' }}> <strong className="text-gray-800" style={{ fontSize: '24px' }}>
{cat.nombre} {cat.nombre}
{cat.nombre2 ? ` ${cat.nombre2}` : ''} {cat.nombre2 ? ` ${cat.nombre2}` : ''}
{cat.nombre3 ? ` ${cat.nombre3}` : ''} {cat.nombre3 ? ` ${cat.nombre3}` : ''}
+3 -3
View File
@@ -45,13 +45,13 @@ export default function Result(_props: ResultProps) {
> >
<p <p
className="text-gray-700 mb-6" className="text-gray-700 mb-6"
style={{ fontSize: '22px', lineHeight: '1.6' }} style={{ fontSize: '28px', 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. 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>
<p <p
className="text-gray-700" className="text-gray-700"
style={{ fontSize: '22px', lineHeight: '1.6', fontWeight: '600' }} style={{ fontSize: '28px', lineHeight: '1.6', fontWeight: '600' }}
> >
Muchísimas gracias a todos por su participación, su energía y sus aportaciones. Muchísimas gracias a todos por su participación, su energía y sus aportaciones.
</p> </p>
@@ -77,7 +77,7 @@ export default function Result(_props: ResultProps) {
> >
<p <p
className="font-bold text-white" className="font-bold text-white"
style={{ fontSize: '22px', lineHeight: '1.5' }} style={{ fontSize: '28px', lineHeight: '1.5' }}
> >
¡Nos llevamos grandes aprendizajes para seguir cuidando el planeta juntos! ¡Nos llevamos grandes aprendizajes para seguir cuidando el planeta juntos!
</p> </p>
+54 -49
View File
@@ -67,6 +67,17 @@ export function useRuleta() {
}) })
const rotacionRef = useRef(0) const rotacionRef = useRef(0)
const isSpinningRef = useRef(false)
const completadoRef = useRef(false)
const usadasRef = useRef<Record<number, number>>({})
const poolRef = useRef(poolInicial)
const girarRef = useRef<() => void>(() => {})
useEffect(() => {
usadasRef.current = estado.preguntasUsadasPorCategoria
poolRef.current = estado.preguntasPool
completadoRef.current = estado.completado
}, [estado.preguntasUsadasPorCategoria, estado.preguntasPool, estado.completado])
// ─── Temporizador por pregunta ─────────────────────────────────────────── // ─── Temporizador por pregunta ───────────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -89,6 +100,12 @@ export function useRuleta() {
feedbackCorrecto: false, feedbackCorrecto: false,
completado, completado,
aprobado, aprobado,
preguntasUsadasPorCategoria: {
...prev.preguntasUsadasPorCategoria,
...(prev.categoriaActual
? { [prev.categoriaActual.id]: (prev.preguntasUsadasPorCategoria[prev.categoriaActual.id] ?? 0) + 1 }
: {}),
},
} }
} }
@@ -99,38 +116,29 @@ export function useRuleta() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [estado.preguntaActual?.id, estado.mostrarFeedback, estado.completado]) }, [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
}
function girar() { function girar() {
if (estado.isSpinning || estado.completado) return if (isSpinningRef.current || completadoRef.current) return
const hayDisponibles = categorias.some((cat) => {
const usadas = usadasRef.current[cat.id] ?? 0
return usadas < (poolRef.current[cat.id]?.length ?? 0)
})
if (!hayDisponibles) {
setEstado((prev) => ({
...prev,
completado: true,
aprobado: prev.correctas >= MIN_CORRECTAS,
}))
return
}
isSpinningRef.current = true
sounds.wheelSpin?.play() sounds.wheelSpin?.play()
const vueltas = 3 + Math.random() * 2 const vueltas = 3 + Math.random() * 2
const anguloFinal = Math.random() * 360 const anguloExtra = Math.random() * 360
const rotacionTotal = rotacionRef.current + vueltas * 360 + anguloFinal const delta = vueltas * 360 + anguloExtra
const rotacionTotal = rotacionRef.current + delta
rotacionRef.current = rotacionTotal rotacionRef.current = rotacionTotal
setEstado((prev) => ({ ...prev, isSpinning: true, rotacion: rotacionTotal })) setEstado((prev) => ({ ...prev, isSpinning: true, rotacion: rotacionTotal }))
@@ -138,33 +146,29 @@ 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 cat = categorias[indiceCategoria]
const usadas = usadasRef.current[cat.id] ?? 0
const preguntasCat = poolRef.current[cat.id] ?? []
setEstado((prev) => { // Categoría sin preguntas → re-girar
const resultado = obtenerSiguientePregunta( if (usadas >= preguntasCat.length) {
indiceCategoria, isSpinningRef.current = false
prev.preguntasUsadasPorCategoria, setEstado((prev) => ({ ...prev, isSpinning: false }))
prev.preguntasPool, setTimeout(() => girarRef.current(), 600)
) return
}
if (!resultado) { setEstado((prev) => ({
// Ya no hay preguntas — marcar como completado ...prev,
return { preguntaActual: preguntasCat[usadas],
...prev, categoriaActual: cat,
completado: true, tiempoRestante: TIEMPO_LIMITE,
aprobado: prev.correctas >= MIN_CORRECTAS, }))
}
}
return {
...prev,
preguntaActual: resultado.pregunta,
categoriaActual: resultado.categoria,
tiempoRestante: TIEMPO_LIMITE,
}
})
}, 4000) }, 4000)
} }
girarRef.current = girar
function responder(indiceOpcion: number) { function responder(indiceOpcion: number) {
if (!estado.preguntaActual || !estado.categoriaActual) return if (!estado.preguntaActual || !estado.categoriaActual) return
const esCorrecta = indiceOpcion === estado.preguntaActual.correcta const esCorrecta = indiceOpcion === estado.preguntaActual.correcta
@@ -194,6 +198,7 @@ export function useRuleta() {
} }
function cerrarFeedback() { function cerrarFeedback() {
isSpinningRef.current = false
setEstado((prev) => ({ setEstado((prev) => ({
...prev, ...prev,
preguntaActual: null, preguntaActual: null,