Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions COMPANERO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions tools/test-companero.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
2 changes: 1 addition & 1 deletion www/games.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
99 changes: 96 additions & 3 deletions www/games/companero/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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; } }
</style>
</head>
<body>
Expand All @@ -88,6 +107,7 @@
<script src="expresiones.js"></script>
<script src="cuidado.js"></script>
<script src="escenas.js"></script>
<script src="sonidos.js"></script>
<script>
(function () {
'use strict';
Expand Down Expand Up @@ -157,6 +177,8 @@
var K = window.CompaneroCuidado.crear({ store: ctx.store, economia: E,
partidas: function () { var st = C.stats && C.stats.get ? C.stats.get() : null; return st && st.plays; } });
K.visitar(); // each opening is a visit: one kind wish, never the last visit's
// its own sounds, only with the app's sound on (#40); the setting is read, never written
var S = window.CompaneroSonidos.crear({ activo: function () { return !!(C.state.settings && C.state.settings.sfx); } });
var CONSUMIBLES = window.CompaneroEconomia.CATALOGO.filter(function (a) { return a.tipo === 'consumible'; }).map(function (a) { return a.id; });
// the face of the moment: the opening one, or a short-lived one after something happens
var momento = null, relojMomento = null, relojSueno = null;
Expand Down Expand Up @@ -409,7 +431,8 @@
function despertar() {
if (relojSueno) clearTimeout(relojSueno);
if (momento === 'dormido') { momento = null; render(); }
relojSueno = setTimeout(function () { momento = 'dormido'; render(); }, 45000);
// a gesture or a reaction still running must not wake it when it ends
relojSueno = setTimeout(function () { terminarGesto(); if (relojMomento) clearTimeout(relojMomento); momento = 'dormido'; render(); }, 45000);
}
['pointerdown', 'keydown'].forEach(function (ev) { document.addEventListener(ev, despertar, true); });
despertar();
Expand All @@ -426,24 +449,91 @@
// Five touches within 3 s leave it dizzy for a moment: a funny face, never a punishment.
var REACCIONES = ['feliz', 'guino', 'sonrojado', 'giro'];
var toques = [], ultimaReaccion = null;
var ultimoToque = 0, sinClic = false;
function tocarCompa() {
var t = Date.now();
// the click that ends a stroke of the finger is not a touch
if (sinClic) { sinClic = false; return; }
var t = Date.now(), doble = t - ultimoToque < 250;
ultimoToque = t;
toques = toques.filter(function (x) { return t - x < 3000; });
toques.push(t);
mostrarGlobo();
K.hecho({ accion: 'tocar' });
C.feedback('tap');
var r;
if (toques.length >= 5) { toques = []; r = 'mareado'; mostrar('mareado', 2000); }
// a double tap: a little jump and a laugh
else if (doble) { r = 'doble'; mostrar('feliz', 1500, null, 'cp-salto'); S.tocar('risa'); }
else {
var opciones = REACCIONES.filter(function (x) { return x !== ultimaReaccion; });
r = opciones[Math.floor(Math.random() * opciones.length)];
mostrar(r === 'giro' ? 'feliz' : r, 1500, null, r === 'giro' ? 'cp-giro' : 'cp-salto');
if (r === 'feliz' || r === 'giro') S.tocar('risa');
}
ultimaReaccion = r;
compa.dataset.reaccion = r;
}
compa.addEventListener('click', tocarCompa);

// A stroke of the finger across it is a cuddle (#40): hearts follow the finger, and the bond counts
// it as «acariciar», under the same 30 s rule
var trazoCaricia = null;
function corazon(x, y) {
var r = compa.getBoundingClientRect();
var h = el('span', { class: 'cp-corazon', 'aria-hidden': 'true', text: '♥' });
h.style.left = (x - r.left) + 'px'; h.style.top = (y - r.top) + 'px';
compa.appendChild(h);
setTimeout(function () { if (h.parentNode) h.parentNode.removeChild(h); }, 900);
}
compa.addEventListener('pointerdown', function (e) { trazoCaricia = { x: e.clientX, y: e.clientY, recorrido: 0, ultimo: 0, contado: false }; });
compa.addEventListener('pointermove', function (e) {
var p = trazoCaricia;
if (!p) return;
p.recorrido += Math.hypot(e.clientX - p.x, e.clientY - p.y);
p.x = e.clientX; p.y = e.clientY;
if (p.recorrido - p.ultimo >= 30) { p.ultimo = p.recorrido; corazon(e.clientX, e.clientY); }
if (!p.contado && p.recorrido >= 40) {
p.contado = true;
C.haptic('select');
reaccionar(K.hecho({ accion: 'acariciar' }));
}
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (ev) {
compa.addEventListener(ev, function () {
if (trazoCaricia && trazoCaricia.recorrido > 10) { sinClic = true; setTimeout(function () { sinClic = false; }, 400); }
trazoCaricia = null;
});
});

// Idle gestures (#40): left alone, every 8 to 15 s it stretches, yawns, waves, dances or looks
// around. Never while hidden, under reduced motion, asleep or in the middle of a reaction.
var GESTOS = { estirar: null, bostezar: 'sorprendido', saludar: 'guino', bailar: 'feliz', mirar: null };
var relojGesto = null, finGesto = null, ultimoGesto = null;
function programarGesto() {
if (relojGesto) clearTimeout(relojGesto);
relojGesto = setTimeout(gesto, 8000 + Math.random() * 7000);
}
function terminarGesto() {
if (finGesto) { clearTimeout(finGesto); finGesto = null; }
if (compa.dataset.gesto) { compa.dataset.gesto = ''; momento = null; render(); }
}
function gesto() {
if (document.hidden || reduceMotion() || momento || escena) { programarGesto(); return; }
var lista = Object.keys(GESTOS).filter(function (g) { return g !== ultimoGesto; });
var g = lista[Math.floor(Math.random() * lista.length)];
ultimoGesto = g;
mostrar(GESTOS[g] || K.expresionAlAbrir(), 1300, null, 'cp-gesto-' + g);
compa.dataset.gesto = g;
if (g === 'bostezar') S.tocar('bostezo', true);
finGesto = setTimeout(function () { finGesto = null; compa.dataset.gesto = ''; programarGesto(); }, 1300);
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) { if (relojGesto) clearTimeout(relojGesto); terminarGesto(); }
else programarGesto();
});
// a touch anywhere starts the wait over
['pointerdown', 'keydown'].forEach(function (ev) { document.addEventListener(ev, function () { if (!compa.dataset.gesto) programarGesto(); }, true); });
programarGesto();
compa.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tocarCompa(); }
});
Expand All @@ -457,8 +547,11 @@
}, { passive: false });

// A care shows the happy face and, for eating, a bath or play, its scene for two seconds
var SONIDO_ESCENA = { comer: 'crunch', banio: 'burbujas', jugar: 'rebote' };
function reaccionar(r, conEscena) {
if (!r || !r.ok) return;
if (conEscena) S.tocar(SONIDO_ESCENA[conEscena.tipo]);
terminarGesto();
if (r.cumplioDeseo) mostrarGlobo();
C.feedback(r.cumplioDeseo ? 'good' : 'tap');
// a wish fulfilled is joy; otherwise eating chews (ñam) and a bath blushes
Expand Down
Loading
Loading