diff --git a/COMPANERO.md b/COMPANERO.md index 6d1b753..5ff56d0 100644 --- a/COMPANERO.md +++ b/COMPANERO.md @@ -87,6 +87,8 @@ Los consumibles no suman a ese total: cuestan de 5 a 15 y son opcionales. - **Un deseo por visita**, distinto del de la visita anterior: probar una comida, un baño de burbujas, algo nuevo para ponerse, jugar o caricias. Primero se elige el tipo, todos igual de probables, y después cuál comida: así las siete comidas no acaparan los deseos. - **Ganas (#39):** de comer, de jugar y de bañarse, de 0 a 3. Suben una cada 3, 4 y 5 partidas terminadas en toda la app (leídas de las estadísticas del núcleo, que el compañero nunca escribe). **Crecen jugando, nunca por el tiempo fuera:** un año sin jugar las deja como estaban. En 3, el globo lo dice («¡Uf, tanto mirarte jugar me abrió el apetito!») y la cara es antojada. Atenderlas las baja a 0, y si estaban en 2 o más suman +2 de vínculo. Si nadie las atiende no pasa nada. - **Tocarlo:** responde con risa, guiño, sonrojo o un giro, nunca dos veces seguidas igual, y vuelve a mostrar su deseo. Cinco toques en 3 segundos lo marean un momento: es un gesto gracioso, no un castigo. Suma vínculo con la regla de 30 segundos y no da monedas. +- **Gestos y caricias (#40):** solo, cada 8 a 15 segundos se estira, bosteza, saluda, baila o mira a los lados; nunca con la pantalla oculta, con movimiento reducido, dormido o a mitad de otra reacción. Deslizar el dedo sobre él es una caricia (con corazones), y un doble toque, un saltito con risa. +- **Sonidos (#40):** comer, el baño, jugar, la risa y el bostezo, generados en el teléfono (sin archivos ni red). Solo suenan con el sonido de la app encendido. - **Escenas:** comer lleva la comida a la boca, el jabón da un baño de burbujas y jugar trae una pelota que rebota. Al comer mastica («ñam») y en el baño se sonroja. Con movimiento reducido se ve la misma escena, quieta. ## Datos diff --git a/tools/test-companero.js b/tools/test-companero.js index 807d46a..547ab01 100644 --- a/tools/test-companero.js +++ b/tools/test-companero.js @@ -1064,6 +1064,75 @@ async function testGanasPagina() { }); } +/* ------------------------------------------------ gestures, strokes and sounds (#40) */ + +// Injected before the page loads: the idle wait of 8 to 15 s becomes 1/100 of it, the page can be +// told it is hidden, and every sound made (an oscillator or a buffer played) is counted. The core +// opens its own AudioContext on the first touch even with sound off, so contexts prove nothing. +// The app itself carries no test hook. +const SONDA = `(() => { + const st = window.setTimeout; + window.setTimeout = (f, ms, ...a) => st(f, ms >= 8000 && ms <= 15000 ? ms / 100 : ms, ...a); + window.__oculto = false; + Object.defineProperty(Document.prototype, 'hidden', { configurable: true, get: () => window.__oculto }); + window.__audios = 0; + const B = window.BaseAudioContext || window.AudioContext; + for (const m of ['createOscillator', 'createBufferSource']) { + const f = B.prototype[m]; + B.prototype[m] = function (...x) { window.__audios++; return f.apply(this, x); }; + } +})();`; + +async function testGestos() { + head('Gestos en reposo, caricias con el dedo, doble toque y sonidos (#40)'); + await navegador(async ({ abrir, evaluar, cdp }) => { + await cdp('Page.addScriptToEvaluateOnNewDocument', { source: SONDA }); + // every gesture seen in 2.5 s of leaving it alone + const mirar = `new Promise((r) => { const vistos = new Set(); const t = setInterval(() => { const g = document.querySelector('.cp-compa').dataset.gesto; if (g) vistos.add(g); }, 40); setTimeout(() => { clearInterval(t); r([...vistos]); }, 2500); })`; + await abrir('/games/companero/index.html'); + await evaluar(preparar({ monedas: 100, settings: { sfx: false, music: false } })); + await abrir('/games/companero/index.html'); + const vistos = await evaluar(mirar); + ok(vistos.length >= 1 && vistos.every((g) => ['estirar', 'bostezar', 'saludar', 'bailar', 'mirar'].includes(g)), 'en reposo hace gestos: ' + vistos.join(', ')); + await evaluar(`window.__oculto = true; document.dispatchEvent(new Event('visibilitychange')); true`); await evaluar(pausa(200)); + ok((await evaluar(mirar)).length === 0, 'con la pantalla oculta no hace ninguno'); + await evaluar(`window.__oculto = false; document.dispatchEvent(new Event('visibilitychange')); true`); + + // a stroke of the finger across the companion + const v0 = await evaluar(`+document.querySelector('[data-vinculo]').dataset.vinculo`); + // the visit's wish is random: a cuddle it wished for adds its +3 too + const extra = (await evaluar(`document.querySelector('[data-deseo]').dataset.deseo`)) === 'caricias' ? 3 : 0; + const caricia = (y) => `(() => { const c = document.querySelector('.cp-compa'), r = c.getBoundingClientRect(), y = r.top + r.height * ${y}; + const ev = (t, x) => c.dispatchEvent(new PointerEvent(t, { bubbles: true, pointerId: 7, pointerType: 'touch', clientX: x, clientY: y })); + ev('pointerdown', r.left + 60); for (let i = 1; i <= 8; i++) ev('pointermove', r.left + 60 + i * 18); ev('pointerup', r.left + 204); return true; })()`; + await evaluar(caricia(0.45)); await evaluar(pausa(150)); + const tras = await evaluar(`({ v: +document.querySelector('[data-vinculo]').dataset.vinculo, e: document.querySelector('.cp-compa').dataset.expresion, corazones: document.querySelectorAll('.cp-compa .cp-corazon').length, r: document.querySelector('.cp-compa').dataset.reaccion })`); + ok(tras.v === v0 + 1 + extra && tras.e === 'feliz', 'deslizar el dedo lo acaricia: +1 de vínculo y cara feliz'); + ok(tras.corazones >= 2, 'aparecen corazones por donde pasa el dedo (' + tras.corazones + ')'); + await evaluar(caricia(0.6)); await evaluar(pausa(150)); + ok(await evaluar(`+document.querySelector('[data-vinculo]').dataset.vinculo`) === v0 + 1 + extra, 'otra caricia dentro de 30 s no vuelve a sumar'); + + await evaluar(pausa(2700)); + await evaluar(`(() => { const c = document.querySelector('.cp-compa'); c.click(); c.click(); return true; })()`); await evaluar(pausa(100)); + ok(await evaluar(`document.querySelector('.cp-compa').dataset.reaccion`) === 'doble' && await evaluar(`document.querySelector('.cp-compa').dataset.expresion`) === 'feliz', 'un doble toque da un saltito y una risa'); + + await evaluar(`document.querySelector('[data-accion="jugar"]').click(); true`); + await evaluar(`document.querySelector('[data-consumible="fruta"]').click(); true`); await evaluar(pausa(200)); await evaluar(aceptar); + ok(await evaluar(`window.__audios`) === 0, 'con el sonido apagado en Ajustes no suena nada'); + + await abrir('/games/companero/index.html'); + await evaluar(preparar({ monedas: 100, settings: { sfx: true, music: false } })); + await abrir('/games/companero/index.html'); + // a double tap always laughs + await evaluar(`(() => { const c = document.querySelector('.cp-compa'); c.click(); c.click(); return true; })()`); await evaluar(pausa(200)); + ok(await evaluar(`window.__audios`) >= 1, 'con el sonido encendido, la risa suena tras tocarlo'); + + await cdp('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + await abrir('/games/companero/index.html'); + ok((await evaluar(mirar)).length === 0, 'con movimiento reducido no hay gestos sueltos'); + }); +} + /* ------------------------------------------------ run */ const GROUPS = [...fs.readFileSync(__filename, 'utf8').matchAll(/^(?:async )?function (test[A-Za-z0-9]+)\(\) \{/gm)].map((m) => m[1]); diff --git a/www/games.json b/www/games.json index 5b02e37..215fdfc 100644 --- a/www/games.json +++ b/www/games.json @@ -267,7 +267,7 @@ "color": "#E9B486", "soft": "#FAEEDF", "added": "2026-10-28", - "files": ["games/companero/economia.js", "games/companero/arte.js", "games/companero/disenos.js", "games/companero/prendas.js", "games/companero/expresiones.js", "games/companero/cuidado.js", "games/companero/escenas.js"] + "files": ["games/companero/economia.js", "games/companero/arte.js", "games/companero/disenos.js", "games/companero/prendas.js", "games/companero/expresiones.js", "games/companero/cuidado.js", "games/companero/escenas.js", "games/companero/sonidos.js"] }, { "id": "morris", diff --git a/www/games/companero/index.html b/www/games/companero/index.html index 6cb8c23..87f2a69 100644 --- a/www/games/companero/index.html +++ b/www/games/companero/index.html @@ -60,6 +60,25 @@ /* a tap's spin (#33): a quick turn on the spot */ @keyframes cp-giro { 0% { transform: rotateY(0); } 100% { transform: rotateY(360deg); } } .cp-giro { animation: cp-giro .6s ease-in-out; } + /* idle gestures (#40): the whole body, or only the face, moves for 1.2 s */ + .cp-dibujo svg { transform-origin: 50% 100%; } + .cp-dibujo svg #cara { transform-box: fill-box; transform-origin: 50% 90%; } + .cp-gesto-estirar { animation: cp-estirar 1.2s ease-in-out; } + @keyframes cp-estirar { 0%, 100% { transform: scale(1); } 45% { transform: scale(.95, 1.08); } 70% { transform: scale(1.02, .98); } } + .cp-gesto-bailar { animation: cp-bailar 1.2s ease-in-out; } + @keyframes cp-bailar { 0%, 100% { transform: none; } 20% { transform: translateX(-4px) rotate(-7deg); } 40% { transform: translateX(4px) rotate(7deg); } + 60% { transform: translateX(-4px) rotate(-7deg); } 80% { transform: translateX(4px) rotate(7deg); } } + .cp-gesto-saludar { animation: cp-saludar 1.2s ease-in-out; } + @keyframes cp-saludar { 0%, 100% { transform: none; } 25%, 75% { transform: rotate(-4deg) translateY(-3px); } 50% { transform: rotate(4deg); } } + .cp-gesto-bostezar #cara { animation: cp-bostezar 1.2s ease-in-out; } + @keyframes cp-bostezar { 0%, 100% { transform: none; } 30%, 70% { transform: rotate(-8deg) translateY(-2px); } } + .cp-gesto-mirar #cara { animation: cp-mirar 1.2s ease-in-out; } + @keyframes cp-mirar { 0%, 100% { transform: none; } 25%, 40% { transform: translateX(-4px) rotate(-4deg); } 65%, 80% { transform: translateX(4px) rotate(4deg); } } + /* hearts where a finger strokes it */ + .cp-compa { touch-action: pan-y; } + .cp-corazon { position: absolute; z-index: 2; pointer-events: none; color: #D7263D; font-size: 18px; line-height: 1; + transform: translate(-50%, -50%); animation: cp-corazon .9s ease-out forwards; } + @keyframes cp-corazon { to { transform: translate(-50%, -180%) scale(1.3); opacity: 0; } } /* The care scenes (#32): the resting pose in escenas.js is the still picture; these only move it */ .cp-esc-bocado, .cp-esc-burbuja, .cp-esc-pelota { transform-box: fill-box; transform-origin: center; } .cp-esc-bocado { animation: cp-comer 1.8s ease-in-out forwards; } @@ -75,7 +94,7 @@ @keyframes cp-pelota { 0%, 30%, 60%, 90%, 100% { transform: translateY(0); } 15% { transform: translateY(-28px) rotate(120deg); } 45% { transform: translateY(-18px) rotate(240deg); } 75% { transform: translateY(-8px) rotate(330deg); } } .cp-item .cp-icono svg { width: 40px; height: 40px; } - @media (prefers-reduced-motion: reduce) { .cp-salto, .cp-giro, .cp-esc-bocado, .cp-esc-burbuja, .cp-esc-pelota { animation: none; } } + @media (prefers-reduced-motion: reduce) { .cp-corazon, .cp-gesto-estirar, .cp-gesto-bailar, .cp-gesto-saludar, .cp-gesto-bostezar #cara, .cp-gesto-mirar #cara, .cp-salto, .cp-giro, .cp-esc-bocado, .cp-esc-burbuja, .cp-esc-pelota { animation: none; } }
@@ -88,6 +107,7 @@ +