Enlace a la aplicación html
Aplicación html en un Site
Código html
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>¡Aprendo los Números! · Del 1 al 10</title>
<style>
/* =====================================================================
¡APRENDO LOS NÚMEROS! — Aplicación educativa infantil (3-6 años)
Un único archivo autocontenido. Sin dependencias externas.
===================================================================== */
:root{
--color-bg: #FFF6E9;
--color-bg2: #FFEFD6;
--coral: #FF6F61;
--coral-dark: #E9573F;
--turquoise: #21C0CE;
--turquoise-dark: #159AA6;
--yellow: #FFC93C;
--yellow-dark: #F0A800;
--green: #63C77B;
--green-dark: #3FA85C;
--purple: #9B87F5;
--purple-dark: #7A63E0;
--pink: #FF8FB1;
--ink: #3A3550;
--ink-soft: #6B6480;
--white: #FFFFFF;
--shadow-sm: 0 3px 0 rgba(0,0,0,0.10), 0 6px 14px rgba(58,53,80,0.12);
--shadow-lg: 0 6px 0 rgba(0,0,0,0.10), 0 14px 30px rgba(58,53,80,0.18);
--radius-lg: 28px;
--radius-md: 20px;
--radius-sm: 14px;
--font: -apple-system, "Segoe UI Rounded", "Segoe UI", "Arial Rounded MT Bold", Verdana, sans-serif;
}
*{ box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html,body{
height:100%; margin:0; padding:0;
font-family: var(--font);
background: radial-gradient(circle at 20% 10%, var(--color-bg2), var(--color-bg) 60%);
color: var(--ink);
overscroll-behavior: none;
-webkit-user-select:none; user-select:none;
}
#app{
position:fixed; inset:0;
overflow-y:auto; overflow-x:hidden;
-webkit-overflow-scrolling: touch;
display:flex; flex-direction:column;
}
button{ font-family:inherit; border:none; background:none; cursor:pointer; color:inherit; touch-action: manipulation; }
img,svg{ -webkit-user-drag:none; user-drag:none; }
h1,h2,h3,p{ margin:0; }
/* ---------- Barra superior ---------- */
.top-bar{
display:flex; align-items:center; justify-content:space-between;
padding: 14px 16px; gap:10px; flex-wrap:wrap;
}
.icon-btn{
width:56px; height:56px; min-width:56px; border-radius:50%;
background: var(--white); box-shadow: var(--shadow-sm);
display:flex; align-items:center; justify-content:center;
transition: transform .12s ease;
}
.icon-btn:active{ transform: scale(0.90); }
.icon-btn svg{ width:28px; height:28px; }
.top-bar-title{
font-size: clamp(18px,3.2vw,26px); font-weight:800; color:var(--ink);
display:flex; align-items:center; gap:8px; flex:1; justify-content:center;
}
/* ---------- Botones genéricos ---------- */
.btn{
border-radius: 999px; padding: 16px 30px; font-weight:800;
font-size: clamp(16px,2.6vw,20px); box-shadow: var(--shadow-sm);
transition: transform .12s ease, box-shadow .12s ease;
display:inline-flex; align-items:center; gap:10px; justify-content:center;
}
.btn:active{ transform: translateY(3px) scale(0.98); box-shadow:0 2px 0 rgba(0,0,0,.1); }
.btn-primary{ background: var(--coral); color:var(--white); }
.btn-secondary{ background: var(--turquoise); color:var(--white); }
.btn-yellow{ background: var(--yellow); color:#5A3E00; }
.btn-purple{ background: var(--purple); color:var(--white); }
.btn-outline{ background: var(--white); color: var(--ink); border:3px solid #EEE3D0; }
.btn-lg{ padding: 20px 40px; font-size: clamp(20px,3.4vw,26px); border-radius: var(--radius-lg); }
.btn[disabled]{ opacity:.45; pointer-events:none; }
/* ---------- Pantallas ---------- */
.screen{ flex:1; display:flex; flex-direction:column; padding: 4px 18px 34px; animation: fadeUp .35s ease; }
@keyframes fadeUp{ from{opacity:0; transform:translateY(14px);} to{opacity:1; transform:translateY(0);} }
/* ---------- Menú principal ---------- */
.menu-hero{
text-align:center; padding: 6px 10px 18px;
}
.menu-hero h1{
font-size: clamp(26px,5.4vw,42px); font-weight:900; color: var(--coral-dark);
text-shadow: 2px 3px 0 rgba(255,255,255,.7);
letter-spacing: .5px;
}
.menu-hero p{ margin-top:6px; font-size: clamp(14px,2.4vw,18px); color: var(--ink-soft); font-weight:600; }
.menu-illustration{ display:flex; justify-content:center; margin: 6px 0 4px; }
.menu-illustration svg{ width: min(46vw, 220px); height:auto; }
.difficulty-row{
display:flex; gap:10px; justify-content:center; flex-wrap:wrap; margin: 6px 0 22px;
}
.difficulty-btn{
border-radius: 999px; padding: 12px 20px; font-weight:800; font-size: clamp(14px,2.2vw,17px);
background: var(--white); box-shadow: var(--shadow-sm); color: var(--ink-soft);
border: 3px solid transparent;
display:flex; align-items:center; gap:8px;
}
.difficulty-btn .dot{ width:12px; height:12px; border-radius:50%; background:#DDD; }
.difficulty-btn.active{ border-color: var(--turquoise); color: var(--turquoise-dark); background:#E9FBFB; }
.difficulty-btn.active .dot{ background: var(--turquoise); }
.games-grid{
display:grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px; max-width: 980px; margin: 0 auto; width:100%;
}
.game-card{
background: var(--white); border-radius: var(--radius-md); padding: 18px 14px 16px;
box-shadow: var(--shadow-sm); display:flex; flex-direction:column; align-items:center; gap:8px;
transition: transform .12s ease;
position:relative; overflow:hidden;
}
.game-card:active{ transform: scale(0.95); }
.game-card .icon-wrap{
width:74px; height:74px; border-radius:50%; display:flex; align-items:center; justify-content:center;
}
.game-card .icon-wrap svg{ width:44px; height:44px; }
.game-card .g-name{ font-weight:800; font-size: clamp(13px,2.1vw,15.5px); text-align:center; line-height:1.15; }
.game-card .g-stars{ display:flex; gap:2px; }
.game-card .g-stars svg{ width:14px; height:14px; }
.game-card .completed-badge{
position:absolute; top:8px; right:8px; width:24px; height:24px; border-radius:50%;
background: var(--green); display:flex; align-items:center; justify-content:center;
}
.game-card .completed-badge svg{ width:14px; height:14px; }
.menu-footer-row{
display:flex; justify-content:center; gap:12px; margin-top:26px; flex-wrap:wrap;
}
/* ---------- Pantalla de juego ---------- */
.game-content{
flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap: 22px; padding: 10px 6px 20px; max-width: 900px; margin:0 auto; width:100%;
}
.progress-dots{ display:flex; gap:8px; justify-content:center; flex-wrap:wrap; }
.progress-dots .pd{ width:12px; height:12px; border-radius:50%; background:#EADFC9; transition: all .2s ease; }
.progress-dots .pd.done{ background: var(--green); }
.progress-dots .pd.current{ background: var(--coral); transform: scale(1.3); }
.prompt-text{
font-size: clamp(18px,3.6vw,26px); font-weight:800; text-align:center; color:var(--ink);
background: var(--white); padding: 10px 22px; border-radius: 999px; box-shadow: var(--shadow-sm);
}
.timer-bar-wrap{ width:min(320px,80vw); height:14px; background:#EADFC9; border-radius:999px; overflow:hidden; }
.timer-bar{ height:100%; background: linear-gradient(90deg, var(--green), var(--yellow), var(--coral)); border-radius:999px; transition: width .1s linear; }
.object-grid{
display:flex; flex-wrap:wrap; gap:10px; justify-content:center; align-items:center;
max-width: 480px; background: var(--white); padding: 18px; border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);
}
.object-grid svg{ animation: popIn .35s ease backwards; }
@keyframes popIn{ from{ transform: scale(0); opacity:0; } to{ transform: scale(1); opacity:1; } }
.options-grid{ display:flex; gap:14px; flex-wrap:wrap; justify-content:center; max-width: 560px; }
.option-btn{
width: 84px; height: 84px; border-radius: var(--radius-md); background: var(--white);
box-shadow: var(--shadow-sm); font-size: 34px; font-weight:900; color: var(--ink);
display:flex; align-items:center; justify-content:center; border:4px solid transparent;
}
.option-btn:active{ transform: scale(0.92); }
.option-btn.correct{ background: var(--green); color:#fff; border-color: var(--green-dark); animation: bounce .5s ease; }
.option-btn.incorrect{ background: var(--coral); color:#fff; animation: shake .4s ease; }
.option-btn.dimmed{ opacity:.4; pointer-events:none; }
.option-card{
min-width:120px; padding:14px; border-radius: var(--radius-md); background:var(--white);
box-shadow: var(--shadow-sm); display:flex; flex-direction:column; align-items:center; gap:6px;
border:4px solid transparent;
}
.option-card:active{ transform: scale(0.95); }
.option-card.correct{ background:#E7F9EC; border-color: var(--green); }
.option-card.incorrect{ background:#FDEAE8; border-color: var(--coral); animation: shake .4s ease; }
@keyframes shake{ 0%,100%{transform:translateX(0);} 20%{transform:translateX(-8px);} 40%{transform:translateX(8px);} 60%{transform:translateX(-6px);} 80%{transform:translateX(6px);} }
@keyframes bounce{ 0%{transform:scale(1);} 30%{transform:scale(1.18);} 60%{transform:scale(0.95);} 100%{transform:scale(1);} }
/* Big number cards (mayor/menor) */
.bignum-row{ display:flex; gap:26px; justify-content:center; flex-wrap:wrap; }
.bignum-card{
width:120px; height:150px; border-radius: var(--radius-lg); background: var(--white);
box-shadow: var(--shadow-sm); display:flex; align-items:center; justify-content:center;
font-size:64px; font-weight:900; border:5px solid transparent;
}
.bignum-card:active{ transform: scale(0.94); }
/* Ordenar */
.order-slots{ display:flex; gap:8px; flex-wrap:wrap; justify-content:center; }
.order-slot{
width:52px; height:60px; border-radius:14px; background: rgba(255,255,255,0.6);
border:3px dashed #D9C9A8; display:flex; align-items:center; justify-content:center;
font-size:26px; font-weight:900; color: var(--ink-soft);
}
.order-slot.filled{ background: var(--white); border-style:solid; border-color: var(--green); color:var(--ink); }
.order-pool{ display:flex; gap:10px; flex-wrap:wrap; justify-content:center; margin-top:6px; }
.order-chip{
width:56px; height:56px; border-radius:16px; background: var(--purple); color:#fff;
font-size:26px; font-weight:900; display:flex; align-items:center; justify-content:center;
box-shadow: var(--shadow-sm);
}
.order-chip:active{ transform: scale(0.9); }
.order-chip.used{ opacity:0.25; pointer-events:none; }
/* Arrastrar */
.drop-zones{ display:flex; gap:18px; flex-wrap:wrap; justify-content:center; }
.drop-zone{
width:110px; min-height:110px; border-radius: var(--radius-md); background: rgba(255,255,255,0.65);
border:4px dashed #D9C9A8; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; padding:8px;
transition: background .15s ease, border-color .15s ease;
}
.drop-zone.hover{ background:#E9FBFB; border-color: var(--turquoise); }
.drop-zone.solved{ background:#E7F9EC; border-color: var(--green); border-style:solid; }
.drop-zone .dz-objects{ display:flex; flex-wrap:wrap; gap:2px; justify-content:center; max-width:80px; }
.drop-zone .dz-objects svg{ width:16px; height:16px; }
.drag-pool{ display:flex; gap:14px; flex-wrap:wrap; justify-content:center; }
.drag-token{
width:64px; height:64px; border-radius:18px; background: var(--yellow); color:#5A3E00;
font-size:28px; font-weight:900; display:flex; align-items:center; justify-content:center;
box-shadow: var(--shadow-sm); touch-action:none; position:relative; z-index:2;
}
.drag-token.dragging{ box-shadow: var(--shadow-lg); z-index: 50; }
.drag-token.placed{ opacity:0; pointer-events:none; }
/* Globos */
.balloon-field{
position:relative; width:min(560px,92vw); height: 360px; background: linear-gradient(180deg,#CFF3F7,#F3FBFD);
border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow:hidden;
}
.balloon{
position:absolute; width:70px; height:88px; cursor:pointer;
animation: floaty 3.4s ease-in-out infinite;
}
.balloon .b-shape{ width:100%; height:100%; display:flex; align-items:center; justify-content:center; position:relative; }
.balloon .b-num{ position:absolute; top:30%; left:50%; transform:translate(-50%,-50%); font-size:24px; font-weight:900; color:#fff; }
@keyframes floaty{ 0%,100%{ transform: translateY(0);} 50%{ transform: translateY(-14px);} }
.balloon.popping{ animation: popOut .35s ease forwards; }
@keyframes popOut{ to{ transform: scale(1.7); opacity:0; } }
/* Memoria */
.memory-grid{ display:grid; grid-template-columns: repeat(4, 74px); gap:10px; justify-content:center; }
.memory-card{
width:74px; height:74px; border-radius:16px; background: var(--purple); box-shadow: var(--shadow-sm);
display:flex; align-items:center; justify-content:center; position:relative; perspective:600px;
}
.memory-card .mc-inner{ width:100%; height:100%; position:relative; transform-style:preserve-3d; transition: transform .4s ease; }
.memory-card.flipped .mc-inner, .memory-card.matched .mc-inner{ transform: rotateY(180deg); }
.memory-card .mc-face{ position:absolute; inset:0; backface-visibility:hidden; border-radius:16px; display:flex; align-items:center; justify-content:center; }
.memory-card .mc-back{ background: var(--purple); font-size:26px; }
.memory-card .mc-front{ background: var(--white); transform: rotateY(180deg); font-size:26px; font-weight:900; }
.memory-card.matched .mc-front{ background:#E7F9EC; }
/* Ruleta */
.wheel-wrap{ position:relative; width:min(280px,70vw); height:min(280px,70vw); }
.wheel-svg{ width:100%; height:100%; transition: transform 3.2s cubic-bezier(.17,.67,.16,1); }
.wheel-pointer{ position:absolute; top:-14px; left:50%; transform:translateX(-50%); width:0; height:0;
border-left:14px solid transparent; border-right:14px solid transparent; border-top:22px solid var(--coral-dark); z-index:5; }
/* ---------- Modales ---------- */
.modal-overlay{
position:fixed; inset:0; background: rgba(58,53,80,0.55); display:flex; align-items:center; justify-content:center;
padding: 20px; z-index: 100; animation: fadeIn .2s ease;
}
@keyframes fadeIn{ from{opacity:0;} to{opacity:1;} }
.modal-box{
background: var(--white); border-radius: var(--radius-lg); padding: 28px 24px; max-width: 420px; width:100%;
box-shadow: var(--shadow-lg); text-align:center; animation: popIn .3s ease;
max-height: 86vh; overflow-y:auto;
}
.modal-box h2{ font-size: clamp(20px,3.6vw,26px); font-weight:900; color: var(--coral-dark); margin-bottom:10px; }
.modal-box p{ color: var(--ink-soft); font-weight:600; line-height:1.5; margin-bottom:6px; }
.help-item{ display:flex; align-items:center; gap:12px; text-align:left; margin:12px 0; }
.help-item .hi-icon{ width:44px; height:44px; min-width:44px; border-radius:50%; background:#F3EEFF; display:flex; align-items:center; justify-content:center; }
.help-item .hi-icon svg{ width:24px; height:24px; }
.modal-actions{ display:flex; gap:10px; justify-content:center; margin-top:18px; flex-wrap:wrap; }
/* Celebración */
.celebration-box{ text-align:center; }
.celebration-box .cb-emoji-wrap{ margin: 6px 0 14px; }
.celebration-box .cb-emoji-wrap svg{ width:96px; height:96px; }
.celebration-box h2{ font-size: clamp(24px,4.4vw,32px); }
/* Confeti y estrellas */
.fx-layer{ position:fixed; inset:0; pointer-events:none; z-index: 200; overflow:hidden; }
.confetti-piece{ position:absolute; top:-20px; border-radius:2px; animation: confettiFall linear forwards; }
@keyframes confettiFall{ to{ transform: translateY(115vh) rotate(540deg); opacity:0.9; } }
.star-particle{ position:absolute; animation: starBurst .8s ease forwards; }
@keyframes starBurst{ 0%{ transform: translate(0,0) scale(0.4) rotate(0deg); opacity:1;} 100%{ transform: translate(var(--dx), var(--dy)) scale(1.1) rotate(180deg); opacity:0; } }
/* Botón sonido flotante ondas */
.sound-off-slash{ position:relative; }
.sound-off-slash::after{
content:''; position:absolute; width:34px; height:3px; background: var(--coral); transform: rotate(45deg);
top:50%; left:50%; margin:-1.5px 0 0 -17px; border-radius:2px;
}
/* Utilidades responsive */
@media (max-width: 420px){
.memory-grid{ grid-template-columns: repeat(4, 64px); }
.memory-card{ width:64px; height:64px; }
.bignum-card{ width:96px; height:120px; font-size:48px; }
}
@media (orientation: landscape) and (max-height: 480px){
.top-bar{ padding:8px 14px; }
.icon-btn{ width:44px; height:44px; }
.icon-btn svg{ width:22px; height:22px; }
.menu-illustration{ display:none; }
.screen{ padding-bottom:12px; }
}
.visually-hidden{ position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); }
</style>
</head>
<body>
<div id="app" role="main" aria-live="polite"></div>
<script>
'use strict';
/* =====================================================================
1. UTILIDADES GENERALES
===================================================================== */
function rand(min, max){ return Math.floor(Math.random() * (max - min + 1)) + min; }
function shuffle(arr){
const a = arr.slice();
for(let i = a.length - 1; i > 0; i--){
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function pick(arr){ return arr[rand(0, arr.length - 1)]; }
function uniqueNumbersExcluding(count, min, max, excluded){
const excl = new Set(excluded || []);
const pool = [];
for(let n = min; n <= max; n++){ if(!excl.has(n)) pool.push(n); }
return shuffle(pool).slice(0, count);
}
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
function el(html){
const t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstElementChild;
}
/* =====================================================================
2. SONIDO — Web Audio API (sin ficheros externos)
===================================================================== */
let audioCtx = null;
function ensureAudio(){
if(!audioCtx){
try{ audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch(e){ audioCtx = null; }
}
if(audioCtx && audioCtx.state === 'suspended'){ audioCtx.resume(); }
}
function tone(freq, startOffset, dur, type, vol){
if(!state.soundOn || !audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type || 'sine';
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(audioCtx.destination);
const t0 = audioCtx.currentTime + startOffset;
const v = vol || 0.18;
gain.gain.setValueAtTime(0, t0);
gain.gain.linearRampToValueAtTime(v, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, t0 + dur);
osc.start(t0);
osc.stop(t0 + dur + 0.03);
}
function playSuccess(){ ensureAudio(); tone(523.25,0,0.15,'sine'); tone(659.25,0.09,0.15,'sine'); tone(783.99,0.18,0.28,'sine'); }
function playError(){ ensureAudio(); tone(190,0,0.22,'sawtooth',0.10); }
function playCelebration(){ ensureAudio(); [523,587,659,698,784,880,988].forEach((f,i)=>tone(f, i*0.085, 0.22, 'triangle', 0.16)); }
function playPop(){ ensureAudio(); tone(320,0,0.07,'square',0.14); tone(160,0.05,0.09,'square',0.09); }
function playTap(){ ensureAudio(); tone(700,0,0.05,'square',0.06); }
function playWheel(){ ensureAudio(); tone(500,0,0.05,'square',0.05); }
/* =====================================================================
3. ALMACENAMIENTO — LocalStorage
===================================================================== */
const STORAGE_KEY = 'numerosApp_v1';
function loadProgress(){
try{
const raw = localStorage.getItem(STORAGE_KEY);
if(raw){ const parsed = JSON.parse(raw); if(parsed && typeof parsed === 'object') return parsed; }
}catch(e){}
return { difficulty: 1, soundOn: true, totalStars: 0, stats: {} };
}
function saveProgress(){
try{
localStorage.setItem(STORAGE_KEY, JSON.stringify({
difficulty: state.difficulty, soundOn: state.soundOn, totalStars: state.totalStars, stats: state.stats
}));
}catch(e){}
}
/* =====================================================================
4. ICONOS SVG (ilustraciones vectoriales sencillas, sin dependencias)
===================================================================== */
const PALETTE = ['#FF6F61','#21C0CE','#FFC93C','#63C77B','#9B87F5','#FF8FB1'];
function shapeMarkup(type, color){
switch(type){
case 'star':
return `<path d="M50 6 L61 37 L96 38 L68 58 L78 92 L50 72 L22 92 L32 58 L4 38 L39 37 Z" fill="${color}"/>`;
case 'apple':
return `<circle cx="50" cy="58" r="33" fill="${color}"/><path d="M50 26 Q44 10 56 6" stroke="#6b4423" stroke-width="6" fill="none" stroke-linecap="round"/><ellipse cx="63" cy="17" rx="11" ry="6.5" fill="#63C77B" transform="rotate(28 63 17)"/>`;
case 'balloon':
return `<ellipse cx="50" cy="42" rx="28" ry="36" fill="${color}"/><path d="M50 78 L50 94" stroke="#9a9a9a" stroke-width="2" fill="none"/><path d="M45 78 Q50 86 55 78 Z" fill="${color}"/>`;
case 'flower':
return `<g fill="${color}"><circle cx="50" cy="26" r="15"/><circle cx="74" cy="50" r="15"/><circle cx="50" cy="74" r="15"/><circle cx="26" cy="50" r="15"/></g><circle cx="50" cy="50" r="13" fill="#FFC93C"/>`;
case 'fish':
return `<ellipse cx="42" cy="50" rx="33" ry="22" fill="${color}"/><path d="M75 50 L96 34 L96 66 Z" fill="${color}"/><circle cx="26" cy="45" r="4.5" fill="#fff"/><circle cx="26" cy="45" r="2.2" fill="#333"/>`;
case 'ball':
return `<circle cx="50" cy="50" r="33" fill="${color}"/><path d="M20 50 Q50 30 80 50 M20 50 Q50 70 80 50 M50 17 L50 83" stroke="#fff" stroke-width="4.5" fill="none"/>`;
default:
return `<circle cx="50" cy="50" r="33" fill="${color}"/>`;
}
}
function iconSVG(type, color, size){
size = size || 44;
return `<svg viewBox="0 0 100 100" width="${size}" height="${size}" aria-hidden="true">${shapeMarkup(type, color)}</svg>`;
}
const OBJECT_ICON_TYPES = ['star','apple','balloon','flower','fish','ball'];
function iconStarSolid(color){ return `<svg viewBox="0 0 24 24" width="20" height="20"><path d="M12 1 L15 8.5 L23 9 L17 14 L19 22 L12 17.5 L5 22 L7 14 L1 9 L9 8.5 Z" fill="${color||'#FFC93C'}"/></svg>`; }
function iconCheck(color){ return `<svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 12 L10 18 L20 6" stroke="${color||'#fff'}" stroke-width="3.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`; }
function iconHome(){ return `<svg viewBox="0 0 24 24" width="26" height="26"><path d="M4 11 L12 4 L20 11 V20 H14 V14 H10 V20 H4 Z" fill="#FF6F61"/></svg>`; }
function iconSound(on){
if(on) return `<svg viewBox="0 0 24 24" width="26" height="26"><path d="M4 9 H8 L13 4 V20 L8 15 H4 Z" fill="#21C0CE"/><path d="M17 8 Q20 12 17 16" stroke="#21C0CE" stroke-width="2.4" fill="none" stroke-linecap="round"/><path d="M19.5 5.5 Q24.5 12 19.5 18.5" stroke="#21C0CE" stroke-width="2.4" fill="none" stroke-linecap="round"/></svg>`;
return `<svg viewBox="0 0 24 24" width="26" height="26"><path d="M4 9 H8 L13 4 V20 L8 15 H4 Z" fill="#B9B4C7"/></svg>`;
}
function iconHelp(){ return `<svg viewBox="0 0 24 24" width="26" height="26"><circle cx="12" cy="12" r="10" fill="#FFC93C"/><text x="12" y="17" font-size="14" font-weight="900" text-anchor="middle" fill="#5A3E00">?</text></svg>`; }
function iconReset(){ return `<svg viewBox="0 0 24 24" width="26" height="26"><path d="M20 12 A8 8 0 1 1 17 6" stroke="#9B87F5" stroke-width="2.6" fill="none" stroke-linecap="round"/><path d="M20 3 V8 H15" stroke="#9B87F5" stroke-width="2.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`; }
function iconBack(){ return `<svg viewBox="0 0 24 24" width="26" height="26"><path d="M15 4 L7 12 L15 20" stroke="#3A3550" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`; }
/* Mascota simple para la portada */
function iconMascot(){
return `<svg viewBox="0 0 220 200" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="110" cy="175" rx="70" ry="14" fill="#00000012"/>
<circle cx="110" cy="95" r="70" fill="#FFC93C"/>
<circle cx="82" cy="88" r="10" fill="#3A3550"/>
<circle cx="138" cy="88" r="10" fill="#3A3550"/>
<circle cx="85" cy="85" r="3" fill="#fff"/>
<circle cx="141" cy="85" r="3" fill="#fff"/>
<path d="M85 118 Q110 138 135 118" stroke="#3A3550" stroke-width="6" fill="none" stroke-linecap="round"/>
<circle cx="55" cy="100" r="12" fill="#FFB020"/>
<circle cx="165" cy="100" r="12" fill="#FFB020"/>
<path d="M70 40 Q60 10 40 20" stroke="#F0A800" stroke-width="8" fill="none" stroke-linecap="round"/>
<path d="M150 40 Q160 10 180 20" stroke="#F0A800" stroke-width="8" fill="none" stroke-linecap="round"/>
<g font-family="inherit" font-weight="900" font-size="30" fill="#fff">
<circle cx="110" cy="150" r="20" fill="#FF6F61"/>
<text x="110" y="159" text-anchor="middle">5</text>
</g>
</svg>`;
}
/* =====================================================================
5. EFECTOS VISUALES (confeti, estrellas, capa flotante)
===================================================================== */
function fxLayer(){
let layer = document.getElementById('fx-layer');
if(!layer){
layer = document.createElement('div');
layer.id = 'fx-layer';
layer.className = 'fx-layer';
document.body.appendChild(layer);
}
return layer;
}
function confettiBurst(count){
const layer = fxLayer();
const colors = PALETTE;
for(let i = 0; i < (count || 26); i++){
const piece = document.createElement('div');
piece.className = 'confetti-piece';
const size = rand(6, 11);
piece.style.width = size + 'px';
piece.style.height = (size * 1.6) + 'px';
piece.style.left = rand(0, 100) + 'vw';
piece.style.background = pick(colors);
piece.style.animationDuration = (rand(18, 30) / 10) + 's';
piece.style.opacity = '0.95';
layer.appendChild(piece);
setTimeout(() => piece.remove(), 3200);
}
}
function starBurstAt(x, y, count){
const layer = fxLayer();
for(let i = 0; i < (count || 10); i++){
const s = document.createElement('div');
s.className = 'star-particle';
s.style.left = x + 'px';
s.style.top = y + 'px';
const angle = Math.random() * Math.PI * 2;
const dist = rand(40, 110);
s.style.setProperty('--dx', Math.cos(angle) * dist + 'px');
s.style.setProperty('--dy', Math.sin(angle) * dist + 'px');
s.innerHTML = iconStarSolid(pick(PALETTE));
layer.appendChild(s);
setTimeout(() => s.remove(), 850);
}
}
function starBurstFromEvent(evtTargetEl){
const r = evtTargetEl.getBoundingClientRect();
starBurstAt(r.left + r.width / 2, r.top + r.height / 2, 12);
}
/* =====================================================================
6. ESTADO GLOBAL
===================================================================== */
const state = Object.assign({
screen: 'menu', // 'menu' | 'game' | 'help' | 'reset-confirm'
currentGame: null,
sessionStreak: 0
}, loadProgress());
function setDifficulty(level){
state.difficulty = level;
saveProgress();
renderApp();
}
function toggleSound(){
state.soundOn = !state.soundOn;
if(state.soundOn) ensureAudio();
saveProgress();
renderApp();
}
function markGameCompleted(gameId, stars){
const prev = state.stats[gameId] || { timesCompleted: 0, bestStars: 0 };
const best = Math.max(prev.bestStars || 0, stars);
state.totalStars = (state.totalStars || 0) - (prev.bestStars || 0) + best;
state.stats[gameId] = { timesCompleted: (prev.timesCompleted || 0) + 1, bestStars: best };
saveProgress();
}
function resetAllProgress(){
state.totalStars = 0;
state.stats = {};
state.difficulty = 1;
state.sessionStreak = 0;
saveProgress();
}
/* =====================================================================
7. DEFINICIÓN DE JUEGOS
Cada juego expone: id, name, icon(type), color, totalRounds(dif),
generateRound(dif) -> datos de la ronda, render(container, round, api)
api = { correct(el), incorrect(el), locked() }
===================================================================== */
const GAMES = [];
function difRange(dif){
if(dif === 1) return { min: 1, max: 5, options: 3, timer: false };
if(dif === 2) return { min: 1, max: 8, options: 4, timer: false };
return { min: 1, max: 10, options: 4, timer: true };
}
/* ---- Juego 1: Contar objetos ---- */
GAMES.push({
id: 'contar', name: 'Cuenta y elige', color: 'var(--coral)', iconType: 'apple',
totalRounds: function(){ return 5; },
useTimer: true,
generateRound: function(dif){
const r = difRange(dif);
const count = rand(r.min, r.max);
const objType = pick(OBJECT_ICON_TYPES);
const color = pick(PALETTE);
const distractors = uniqueNumbersExcluding(r.options - 1, 1, 10, [count]);
const options = shuffle([count, ...distractors]);
return { count, objType, color, options, answer: count };
},
render: function(container, round, api){
container.innerHTML = `
<p class="prompt-text">¿Cuántos hay? Toca el número correcto</p>
<div class="object-grid">${Array.from({length: round.count}).map((_,i)=>`<span style="animation-delay:${i*0.05}s">${iconSVG(round.objType, round.color, 42)}</span>`).join('')}</div>
<div class="options-grid">
${round.options.map(n => `<button class="option-btn" data-num="${n}">${n}</button>`).join('')}
</div>`;
container.querySelectorAll('.option-btn').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.num, 10);
if(val === round.answer){ api.correct(btn); }
else { api.incorrect(btn, container.querySelector(`[data-num="${round.answer}"]`)); }
});
});
}
});
/* ---- Juego 2: Relacionar número con cantidad ---- */
GAMES.push({
id: 'relacionar', name: 'Número y cantidad', color: 'var(--turquoise)', iconType: 'flower',
totalRounds: function(){ return 5; },
useTimer: true,
generateRound: function(dif){
const r = difRange(dif);
const target = rand(r.min, r.max);
const objType = pick(OBJECT_ICON_TYPES);
const color = pick(PALETTE);
const distractorCounts = uniqueNumbersExcluding(r.options - 1, r.min, r.max, [target]);
const groups = shuffle([target, ...distractorCounts]);
return { target, objType, color, groups, answer: target };
},
render: function(container, round, api){
container.innerHTML = `
<p class="prompt-text">Toca el grupo que tiene <strong>${round.target}</strong></p>
<div class="options-grid">
${round.groups.map(c => `
<button class="option-card" data-num="${c}">
<div style="display:flex;flex-wrap:wrap;gap:3px;max-width:90px;justify-content:center;">
${Array.from({length:c}).map(()=>iconSVG(round.objType, round.color, 22)).join('')}
</div>
</button>`).join('')}
</div>`;
container.querySelectorAll('.option-card').forEach(card => {
card.addEventListener('click', () => {
const val = parseInt(card.dataset.num, 10);
if(val === round.answer){ api.correct(card); }
else { api.incorrect(card, container.querySelector(`.option-card[data-num="${round.answer}"]`)); }
});
});
}
});
/* ---- Juego 3: Arrastrar número a su cantidad ---- */
GAMES.push({
id: 'arrastrar', name: 'Arrastra al número', color: 'var(--yellow)', iconType: 'ball',
totalRounds: function(){ return 5; },
useTimer: false,
generateRound: function(dif){
const r = difRange(dif);
const zoneCount = dif === 1 ? 2 : 3;
const numbers = uniqueNumbersExcluding(zoneCount, r.min, r.max, []);
const objType = pick(OBJECT_ICON_TYPES);
const color = pick(PALETTE);
return { numbers: shuffle(numbers), zoneOrder: shuffle(numbers), objType, color, solved: 0 };
},
render: function(container, round, api){
container.innerHTML = `
<p class="prompt-text">Arrastra cada número a su cantidad</p>
<div class="drop-zones">
${round.zoneOrder.map(n => `
<div class="drop-zone" data-need="${n}">
<div class="dz-objects">${Array.from({length:n}).map(()=>iconSVG(round.objType, round.color, 16)).join('')}</div>
</div>`).join('')}
</div>
<div class="drag-pool">
${round.numbers.map(n => `<div class="drag-token" data-value="${n}">${n}</div>`).join('')}
</div>`;
const zones = Array.from(container.querySelectorAll('.drop-zone'));
const tokens = Array.from(container.querySelectorAll('.drag-token'));
tokens.forEach(token => {
let dragging = false, offsetX = 0, offsetY = 0, startX = 0, startY = 0;
token.addEventListener('pointerdown', (ev) => {
if(token.classList.contains('placed')) return;
dragging = true;
token.setPointerCapture(ev.pointerId);
token.classList.add('dragging');
const rect = token.getBoundingClientRect();
offsetX = ev.clientX - rect.left; offsetY = ev.clientY - rect.top;
startX = rect.left; startY = rect.top;
token.style.position = 'fixed';
token.style.left = rect.left + 'px';
token.style.top = rect.top + 'px';
token.style.width = rect.width + 'px';
token.style.height = rect.height + 'px';
token.style.margin = '0';
});
token.addEventListener('pointermove', (ev) => {
if(!dragging) return;
token.style.left = (ev.clientX - offsetX) + 'px';
token.style.top = (ev.clientY - offsetY) + 'px';
zones.forEach(z => {
const zr = z.getBoundingClientRect();
const inside = ev.clientX >= zr.left && ev.clientX <= zr.right && ev.clientY >= zr.top && ev.clientY <= zr.bottom;
z.classList.toggle('hover', inside && !z.classList.contains('solved'));
});
});
const endDrag = (ev) => {
if(!dragging) return;
dragging = false;
token.classList.remove('dragging');
let landedZone = null;
zones.forEach(z => {
const zr = z.getBoundingClientRect();
const inside = ev.clientX >= zr.left && ev.clientX <= zr.right && ev.clientY >= zr.top && ev.clientY <= zr.bottom;
z.classList.remove('hover');
if(inside && !z.classList.contains('solved')) landedZone = z;
});
if(landedZone && parseInt(token.dataset.value,10) === parseInt(landedZone.dataset.need,10)){
landedZone.classList.add('solved');
token.classList.add('placed');
token.style.position = ''; token.style.left = ''; token.style.top = '';
playPop(); starBurstFromEvent(landedZone);
round.solved++;
if(round.solved >= zones.length){ api.correct(null); }
} else {
token.style.position = '';
token.style.left = ''; token.style.top = '';
token.style.width = ''; token.style.height = ''; token.style.margin = '';
if(landedZone) playError();
}
};
token.addEventListener('pointerup', endDrag);
token.addEventListener('pointercancel', endDrag);
});
}
});
/* ---- Juego 4: Ordenar números ---- */
GAMES.push({
id: 'ordenar', name: 'Ordena los números', color: 'var(--purple)', iconType: 'star',
totalRounds: function(dif){ return 3; },
useTimer: false,
generateRound: function(dif){
const length = dif === 1 ? 4 : (dif === 2 ? 6 : 8);
const start = rand(1, Math.max(1, 10 - length + 1));
const sequence = Array.from({length}, (_,i) => start + i);
return { sequence, pool: shuffle(sequence), placed: [] };
},
render: function(container, round, api){
container.innerHTML = `
<p class="prompt-text">Toca los números en orden, del más pequeño al más grande</p>
<div class="order-slots">${round.sequence.map(() => `<div class="order-slot"></div>`).join('')}</div>
<div class="order-pool">${round.pool.map(n => `<button class="order-chip" data-num="${n}">${n}</button>`).join('')}</div>`;
const slots = Array.from(container.querySelectorAll('.order-slot'));
container.querySelectorAll('.order-chip').forEach(chip => {
chip.addEventListener('click', () => {
const val = parseInt(chip.dataset.num, 10);
const expected = round.sequence[round.placed.length];
if(val === expected){
slots[round.placed.length].textContent = val;
slots[round.placed.length].classList.add('filled');
chip.classList.add('used');
round.placed.push(val);
playTap();
if(round.placed.length === round.sequence.length){ api.correct(null); }
} else {
chip.classList.add('incorrect');
playError();
setTimeout(() => chip.classList.remove('incorrect'), 400);
}
});
});
}
});
/* ---- Juego 5: ¿Qué número falta? ---- */
GAMES.push({
id: 'falta', name: '¿Qué número falta?', color: 'var(--green)', iconType: 'fish',
totalRounds: function(){ return 5; },
useTimer: true,
generateRound: function(dif){
const length = dif === 1 ? 4 : 5;
const maxStart = 10 - length;
const start = rand(1, Math.max(1, maxStart));
const sequence = Array.from({length}, (_,i) => start + i);
const blankIndex = rand(dif === 1 ? 1 : 0, length - 2 >= 0 ? length - 2 : length - 1);
const answer = sequence[blankIndex];
const r = difRange(dif);
const distractors = uniqueNumbersExcluding(r.options - 1, 1, 10, [answer]);
const options = shuffle([answer, ...distractors]);
return { sequence, blankIndex, answer, options };
},
render: function(container, round, api){
container.innerHTML = `
<p class="prompt-text">¿Qué número falta en la secuencia?</p>
<div class="order-slots">
${round.sequence.map((n,i) => i === round.blankIndex
? `<div class="order-slot" style="border-color:#FF6F61;color:#FF6F61;">?</div>`
: `<div class="order-slot filled">${n}</div>`).join('')}
</div>
<div class="options-grid">${round.options.map(n => `<button class="option-btn" data-num="${n}">${n}</button>`).join('')}</div>`;
container.querySelectorAll('.option-btn').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.num, 10);
if(val === round.answer){ api.correct(btn); }
else { api.incorrect(btn, container.querySelector(`[data-num="${round.answer}"]`)); }
});
});
}
});
/* ---- Juego 6: Escoge el número mayor ---- */
GAMES.push({
id: 'mayor', name: 'El número mayor', color: 'var(--coral)', iconType: 'star',
totalRounds: function(){ return 5; },
useTimer: true,
generateRound: function(dif){
const r = difRange(dif);
let a = rand(r.min, r.max), b = rand(r.min, r.max);
while(b === a){ b = rand(r.min, r.max); }
return { a, b, answer: Math.max(a,b) };
},
render: function(container, round, api){
container.innerHTML = `
<p class="prompt-text">Toca el número MÁS GRANDE</p>
<div class="bignum-row">
<button class="bignum-card" data-num="${round.a}">${round.a}</button>
<button class="bignum-card" data-num="${round.b}">${round.b}</button>
</div>`;
container.querySelectorAll('.bignum-card').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.num, 10);
if(val === round.answer){ api.correct(btn); }
else { api.incorrect(btn, container.querySelector(`[data-num="${round.answer}"]`)); }
});
});
}
});
/* ---- Juego 7: Escoge el número menor ---- */
GAMES.push({
id: 'menor', name: 'El número menor', color: 'var(--turquoise)', iconType: 'flower',
totalRounds: function(){ return 5; },
useTimer: true,
generateRound: function(dif){
const r = difRange(dif);
let a = rand(r.min, r.max), b = rand(r.min, r.max);
while(b === a){ b = rand(r.min, r.max); }
return { a, b, answer: Math.min(a,b) };
},
render: function(container, round, api){
container.innerHTML = `
<p class="prompt-text">Toca el número MÁS PEQUEÑO</p>
<div class="bignum-row">
<button class="bignum-card" data-num="${round.a}">${round.a}</button>
<button class="bignum-card" data-num="${round.b}">${round.b}</button>
</div>`;
container.querySelectorAll('.bignum-card').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.num, 10);
if(val === round.answer){ api.correct(btn); }
else { api.incorrect(btn, container.querySelector(`[data-num="${round.answer}"]`)); }
});
});
}
});
/* ---- Juego 8: Explota el globo ---- */
GAMES.push({
id: 'globo', name: 'Explota el globo', color: 'var(--pink)', iconType: 'balloon',
totalRounds: function(){ return 6; },
useTimer: true,
generateRound: function(dif){
const r = difRange(dif);
const balloonCount = clamp(r.options + 1, 4, 6);
const target = rand(r.min, r.max);
const others = uniqueNumbersExcluding(balloonCount - 1, 1, 10, [target]);
const nums = shuffle([target, ...others]);
const positions = nums.map(() => ({ left: rand(4, 74), top: rand(6, 62), delay: (Math.random()*2).toFixed(2) }));
return { target, nums, positions };
},
render: function(container, round, api){
container.innerHTML = `
<p class="prompt-text">Explota el globo con el número <strong>${round.target}</strong></p>
<div class="balloon-field">
${round.nums.map((n,i) => `
<div class="balloon" data-num="${n}" style="left:${round.positions[i].left}%; top:${round.positions[i].top}%; animation-delay:${round.positions[i].delay}s;">
<div class="b-shape">${iconSVG('balloon', pick(PALETTE), 70)}<span class="b-num">${n}</span></div>
</div>`).join('')}
</div>`;
container.querySelectorAll('.balloon').forEach(b => {
b.addEventListener('click', () => {
const val = parseInt(b.dataset.num, 10);
if(val === round.target){
b.classList.add('popping');
playPop(); starBurstFromEvent(b);
setTimeout(() => api.correct(null), 250);
} else {
b.style.animation = 'shake .4s ease';
playError();
setTimeout(() => { b.style.animation = ''; }, 400);
api.wrongAttempt && api.wrongAttempt();
}
});
});
}
});
/* ---- Juego 9: Memoria de números ---- */
GAMES.push({
id: 'memoria', name: 'Memoria numérica', color: 'var(--purple)', iconType: 'apple',
totalRounds: function(){ return 1; },
useTimer: false,
generateRound: function(dif){
const pairCount = dif === 1 ? 4 : (dif === 2 ? 6 : 8);
const numbers = uniqueNumbersExcluding(pairCount, 1, 10, []);
const objType = pick(OBJECT_ICON_TYPES);
const color = pick(PALETTE);
const cards = [];
numbers.forEach(n => {
cards.push({ kind: 'num', value: n, key: n });
cards.push({ kind: 'qty', value: n, key: n });
});
return { cards: shuffle(cards), objType, color, flipped: [], matched: 0, total: numbers.length, lock: false };
},
render: function(container, round, api){
container.innerHTML = `
<p class="prompt-text">Encuentra las parejas: número y cantidad</p>
<div class="memory-grid">
${round.cards.map((c,i) => `
<div class="memory-card" data-index="${i}">
<div class="mc-inner">
<div class="mc-face mc-back">${iconSVG('star', '#fff', 26)}</div>
<div class="mc-face mc-front">${c.kind === 'num' ? c.value : `<div style="display:flex;flex-wrap:wrap;gap:1px;max-width:60px;justify-content:center;">${Array.from({length:c.value}).map(()=>iconSVG(round.objType, round.color, 12)).join('')}</div>`}</div>
</div>
</div>`).join('')}
</div>`;
const cardsEls = Array.from(container.querySelectorAll('.memory-card'));
cardsEls.forEach(cardEl => {
cardEl.addEventListener('click', () => {
if(round.lock) return;
const idx = parseInt(cardEl.dataset.index, 10);
if(cardEl.classList.contains('flipped') || cardEl.classList.contains('matched')) return;
cardEl.classList.add('flipped');
playTap();
round.flipped.push(idx);
if(round.flipped.length === 2){
round.lock = true;
const [i1, i2] = round.flipped;
const c1 = round.cards[i1], c2 = round.cards[i2];
const isMatch = c1.key === c2.key && c1.kind !== c2.kind;
setTimeout(() => {
if(isMatch){
cardsEls[i1].classList.add('matched');
cardsEls[i2].classList.add('matched');
round.matched++;
playPop(); starBurstFromEvent(cardsEls[i2]);
if(round.matched === round.total){ setTimeout(() => api.correct(null), 300); }
} else {
cardsEls[i1].classList.remove('flipped');
cardsEls[i2].classList.remove('flipped');
playError();
}
round.flipped = [];
round.lock = false;
}, 700);
}
});
});
}
});
/* ---- Juego 10: Ruleta de números ---- */
GAMES.push({
id: 'ruleta', name: 'La ruleta', color: 'var(--yellow)', iconType: 'star',
totalRounds: function(){ return 5; },
useTimer: false,
generateRound: function(dif){
const r = difRange(dif);
const wheelMax = dif === 1 ? 6 : 10;
const slots = Array.from({length: wheelMax}, (_, i) => i + 1);
const target = pick(slots);
const distractors = uniqueNumbersExcluding(r.options - 1, 1, 10, [target]);
const options = shuffle([target, ...distractors]);
return { slots, target, options, spun: false };
},
render: function(container, round, api){
const n = round.slots.length;
const colors = PALETTE;
const segAngle = 360 / n;
let paths = '';
for(let i = 0; i < n; i++){
const startA = i * segAngle, endA = startA + segAngle;
const x1 = 100 + 90 * Math.cos(Math.PI * (startA - 90) / 180);
const y1 = 100 + 90 * Math.sin(Math.PI * (startA - 90) / 180);
const x2 = 100 + 90 * Math.cos(Math.PI * (endA - 90) / 180);
const y2 = 100 + 90 * Math.sin(Math.PI * (endA - 90) / 180);
const midA = startA + segAngle / 2;
const tx = 100 + 62 * Math.cos(Math.PI * (midA - 90) / 180);
const ty = 100 + 62 * Math.sin(Math.PI * (midA - 90) / 180);
paths += `<path d="M100,100 L${x1},${y1} A90,90 0 0,1 ${x2},${y2} Z" fill="${colors[i % colors.length]}" stroke="#fff" stroke-width="2"/>`;
paths += `<text x="${tx}" y="${ty}" font-size="16" font-weight="900" fill="#fff" text-anchor="middle" dominant-baseline="middle">${round.slots[i]}</text>`;
}
container.innerHTML = `
<p class="prompt-text">Gira la ruleta y encuentra el número</p>
<div class="wheel-wrap">
<div class="wheel-pointer"></div>
<svg class="wheel-svg" id="wheel-svg" viewBox="0 0 200 200">${paths}</svg>
</div>
<button class="btn btn-purple btn-lg" id="spin-btn">Girar 🎯</button>
<div class="options-grid" id="wheel-options" style="display:none;"></div>`;
const wheelSvg = container.querySelector('#wheel-svg');
const spinBtn = container.querySelector('#spin-btn');
const optionsWrap = container.querySelector('#wheel-options');
spinBtn.addEventListener('click', () => {
if(round.spun) return;
round.spun = true;
playWheel();
const targetIndex = round.slots.indexOf(round.target);
const segCenter = targetIndex * segAngle + segAngle / 2;
const spins = 4;
const finalRotation = (360 * spins) + (360 - segCenter);
wheelSvg.style.transform = `rotate(${finalRotation}deg)`;
spinBtn.disabled = true;
setTimeout(() => {
playSuccess();
optionsWrap.style.display = 'flex';
optionsWrap.innerHTML = round.options.map(nn => `<button class="option-btn" data-num="${nn}">${nn}</button>`).join('');
optionsWrap.querySelectorAll('.option-btn').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.num, 10);
if(val === round.target){ api.correct(btn); }
else { api.incorrect(btn, optionsWrap.querySelector(`[data-num="${round.target}"]`)); }
});
});
}, 3300);
});
}
});
/* =====================================================================
8. CONTROLADOR DE PARTIDA (rondas, puntuación, celebración, final)
===================================================================== */
const GameController = {
game: null, dif: 1, roundIndex: 0, total: 1, correctInRound: 0, correctCount: 0, round: null,
timerCtrl: null,
start(gameId){
this.game = GAMES.find(g => g.id === gameId);
this.dif = state.difficulty;
this.roundIndex = 0;
this.correctCount = 0;
this.total = this.game.totalRounds(this.dif);
state.currentGame = gameId;
state.screen = 'game';
this.playRound();
},
playRound(){
this.round = this.game.generateRound(this.dif);
renderApp();
},
handleWrongAttemptGentle(){
// Para juegos con varios intentos (globos): no penaliza el flujo, solo feedback.
},
correct(sourceEl){
playSuccess();
if(sourceEl){
sourceEl.classList.add('correct');
starBurstFromEvent(sourceEl);
} else {
confettiBurst(14);
}
this.correctCount++;
state.sessionStreak++;
this.clearTimer();
const streakHit = state.sessionStreak > 0 && state.sessionStreak % 5 === 0;
setTimeout(() => {
if(streakHit){
showCelebration(() => this.advance());
} else {
this.advance();
}
}, sourceEl ? 650 : 500);
},
incorrect(sourceEl, correctEl){
playError();
state.sessionStreak = 0;
if(sourceEl) sourceEl.classList.add('incorrect');
if(correctEl) correctEl.classList.add('correct');
this.clearTimer();
setTimeout(() => this.advance(), 900);
},
timeExpired(){
state.sessionStreak = 0;
playError();
setTimeout(() => this.advance(), 500);
},
clearTimer(){
if(this.timerCtrl){ this.timerCtrl.cancel(); this.timerCtrl = null; }
},
advance(){
this.roundIndex++;
if(this.roundIndex >= this.total){
this.finish();
} else {
this.playRound();
}
},
finish(){
const ratio = this.correctCount / this.total;
const stars = ratio >= 0.99 ? 3 : (ratio >= 0.6 ? 2 : 1);
markGameCompleted(this.game.id, stars);
state.screen = 'complete';
this._lastStars = stars;
renderApp();
confettiBurst(36);
},
exit(){
this.clearTimer();
state.screen = 'menu';
state.currentGame = null;
renderApp();
}
};
function startRoundTimer(seconds, onTick, onExpire){
let remaining = seconds * 10;
const total = remaining;
const id = setInterval(() => {
remaining--;
onTick(remaining / total);
if(remaining <= 0){
clearInterval(id);
onExpire();
}
}, 100);
return { cancel(){ clearInterval(id); } };
}
/* =====================================================================
9. RENDERIZADO DE PANTALLAS
===================================================================== */
const app = document.getElementById('app');
function renderApp(){
if(state.screen === 'menu') return renderMenu();
if(state.screen === 'game') return renderGameScreen();
if(state.screen === 'complete') return renderCompleteScreen();
}
function topBar(opts){
opts = opts || {};
return `
<div class="top-bar">
${opts.showBack ? `<button class="icon-btn" data-action="${opts.backAction || 'go-menu'}" aria-label="Volver">${iconBack()}</button>` : `<span style="width:56px;"></span>`}
<div class="top-bar-title">${opts.title || ''}</div>
<div style="display:flex; gap:8px;">
<button class="icon-btn ${state.soundOn ? '' : 'sound-off-slash'}" data-action="toggle-sound" aria-label="Sonido">${iconSound(state.soundOn)}</button>
${opts.showHelp === false ? '' : `<button class="icon-btn" data-action="open-help" aria-label="Ayuda">${iconHelp()}</button>`}
</div>
</div>`;
}
function renderMenu(){
const diffLabels = [
{v:1, l:'Inicio'}, {v:2, l:'Intermedio'}, {v:3, l:'Avanzado'}
];
app.innerHTML = `
${topBar({ title: '' })}
<div class="screen">
<div class="menu-hero">
<div class="menu-illustration">${iconMascot()}</div>
<h1>¡Aprendo los Números!</h1>
<p>Cuenta, ordena y juega del 1 al 10</p>
</div>
<div class="difficulty-row">
${diffLabels.map(d => `<button class="difficulty-btn ${state.difficulty===d.v?'active':''}" data-action="select-difficulty" data-value="${d.v}"><span class="dot"></span>${d.l}</button>`).join('')}
</div>
<div class="games-grid">
${GAMES.map((g,i) => {
const stats = state.stats[g.id];
const stars = stats ? stats.bestStars : 0;
return `
<button class="game-card" data-action="start-game" data-game="${g.id}">
${stats ? `<div class="completed-badge">${iconCheck('#fff')}</div>` : ''}
<div class="icon-wrap" style="background:${PALETTE[i % PALETTE.length]}22;">${iconSVG(g.iconType, PALETTE[i % PALETTE.length], 42)}</div>
<div class="g-name">${g.name}</div>
<div class="g-stars">${[1,2,3].map(n => iconStarSolid(n<=stars ? '#FFC93C' : '#E7E1D2')).join('')}</div>
</button>`;
}).join('')}
</div>
<div class="menu-footer-row">
<button class="btn btn-outline" data-action="reset-progress">${iconReset()} Reiniciar progreso</button>
</div>
</div>`;
}
function renderGameScreen(){
const g = GameController.game;
app.innerHTML = `
${topBar({ showBack:true, backAction:'exit-game', title: g.name })}
<div class="screen">
<div class="progress-dots">
${Array.from({length: GameController.total}).map((_,i) => `<span class="pd ${i < GameController.roundIndex ? 'done' : ''} ${i === GameController.roundIndex ? 'current' : ''}"></span>`).join('')}
</div>
${(state.difficulty === 3 && g.useTimer) ? `<div class="timer-bar-wrap"><div class="timer-bar" id="timer-bar" style="width:100%;"></div></div>` : ''}
<div class="game-content" id="game-content"></div>
</div>`;
const contentEl = document.getElementById('game-content');
const api = {
correct: (elm) => GameController.correct(elm),
incorrect: (elm, correctElm) => GameController.incorrect(elm, correctElm),
wrongAttempt: () => GameController.handleWrongAttemptGentle()
};
g.render(contentEl, GameController.round, api);
if(state.difficulty === 3 && g.useTimer){
const bar = document.getElementById('timer-bar');
GameController.timerCtrl = startRoundTimer(9, (frac) => {
if(bar) bar.style.width = Math.max(0, frac * 100) + '%';
}, () => {
contentEl.querySelectorAll('button').forEach(b => b.setAttribute('disabled','true'));
GameController.timeExpired();
});
}
}
function renderCompleteScreen(){
const g = GameController.game;
const stars = GameController._lastStars || 1;
app.innerHTML = `
${topBar({ showHelp:false, title:'' })}
<div class="screen" style="align-items:center; justify-content:center; text-align:center; gap:18px;">
<div style="margin-top:10px;">${iconMascot()}</div>
<h2 style="font-size:clamp(22px,4vw,30px); color:var(--coral-dark); font-weight:900;">¡Muy bien! Has terminado ${g.name}</h2>
<div style="display:flex; gap:10px;">
${[1,2,3].map(n => `<span style="animation:popIn .4s ease ${n*0.12}s backwards; display:inline-block;">${iconStarSolid(n<=stars ? '#FFC93C':'#E7E1D2')}</span>`).join('')}
</div>
<div style="display:flex; gap:12px; flex-wrap:wrap; justify-content:center; margin-top:10px;">
<button class="btn btn-primary btn-lg" data-action="start-game" data-game="${g.id}">Jugar de nuevo</button>
<button class="btn btn-outline btn-lg" data-action="go-menu">${iconHome()} Menú</button>
</div>
</div>`;
// Ajustar tamaño de estrellas del icono (iconStarSolid es pequeño); escalamos visualmente
app.querySelectorAll('.screen > div[style*="display:flex"] > span svg').forEach(svg => { svg.setAttribute('width','40'); svg.setAttribute('height','40'); });
}
/* ---- Modal de ayuda ---- */
function showHelpModal(){
const overlay = el(`
<div class="modal-overlay" data-action="close-modal">
<div class="modal-box" id="modal-inner"></div>
</div>`);
document.body.appendChild(overlay);
const inner = overlay.querySelector('#modal-inner');
inner.addEventListener('click', (e) => e.stopPropagation());
inner.innerHTML = `
<h2>¿Cómo se juega?</h2>
<p>Toca los botones grandes con el dedo o el ratón.</p>
<div class="help-item"><div class="hi-icon">${iconSVG('star', '#9B87F5', 24)}</div><p style="margin:0;">Elige la respuesta correcta tocándola.</p></div>
<div class="help-item"><div class="hi-icon">${iconSVG('ball', '#FFC93C', 24)}</div><p style="margin:0;">En algunos juegos puedes arrastrar con el dedo.</p></div>
<div class="help-item"><div class="hi-icon">${iconSound(true)}</div><p style="margin:0;">Puedes apagar o encender el sonido cuando quieras.</p></div>
<div class="modal-actions"><button class="btn btn-primary" data-action="close-modal">¡Entendido!</button></div>`;
}
function showResetConfirm(){
const overlay = el(`<div class="modal-overlay" data-action="close-modal"><div class="modal-box" id="modal-inner"></div></div>`);
document.body.appendChild(overlay);
const inner = overlay.querySelector('#modal-inner');
inner.addEventListener('click', (e) => e.stopPropagation());
inner.innerHTML = `
<h2>¿Reiniciar progreso?</h2>
<p>Se borrarán todas las estrellas y juegos completados. Esta acción no se puede deshacer.</p>
<div class="modal-actions">
<button class="btn btn-outline" data-action="close-modal">Cancelar</button>
<button class="btn btn-primary" data-action="confirm-reset">Sí, reiniciar</button>
</div>`;
}
function showCelebration(onDone){
playCelebration();
confettiBurst(30);
const messages = ['¡Fantástico!','¡Lo estás haciendo genial!','¡Eres un campeón!','¡Excelente trabajo!','¡Sigue así!','¡Qué bien cuentas!','¡Cada vez sabes más!','¡Increíble!'];
const overlay = el(`
<div class="modal-overlay">
<div class="modal-box celebration-box">
<div class="cb-emoji-wrap">${iconMascot()}</div>
<h2>${pick(messages)}</h2>
<p>¡5 aciertos seguidos!</p>
</div>
</div>`);
document.body.appendChild(overlay);
setTimeout(() => {
overlay.remove();
onDone();
}, 1700);
overlay.addEventListener('click', () => {
overlay.remove();
onDone();
}, { once: true });
}
/* =====================================================================
10. DELEGACIÓN GLOBAL DE EVENTOS
===================================================================== */
document.addEventListener('click', (e) => {
const actionEl = e.target.closest('[data-action]');
if(!actionEl) return;
const action = actionEl.dataset.action;
ensureAudio();
switch(action){
case 'go-menu':
GameController.clearTimer();
state.screen = 'menu'; state.currentGame = null;
renderApp();
break;
case 'exit-game':
GameController.exit();
break;
case 'select-difficulty':
setDifficulty(parseInt(actionEl.dataset.value, 10));
break;
case 'toggle-sound':
toggleSound();
break;
case 'open-help':
showHelpModal();
break;
case 'close-modal':
const ov = e.target.closest('.modal-overlay');
if(ov) ov.remove();
break;
case 'reset-progress':
showResetConfirm();
break;
case 'confirm-reset':
resetAllProgress();
const ov2 = e.target.closest('.modal-overlay');
if(ov2) ov2.remove();
renderApp();
break;
case 'start-game':
const gid = actionEl.dataset.game;
document.querySelectorAll('.modal-overlay').forEach(m => m.remove());
GameController.start(gid);
break;
default: break;
}
});
/* =====================================================================
11. INICIO
===================================================================== */
renderApp();
</script>
</body>
</html>