From 407e3b0659b6a676161d1b4c44c9c59cc6d8904b Mon Sep 17 00:00:00 2001 From: cmena92 Date: Fri, 30 Oct 2026 02:35:37 -0600 Subject: [PATCH] Companion: eating, bathing and playing shown, and four new foods (#32) Art first, in design/companero/: - comidas/: the eight consumables drawn in the companion's style (the four of #9 replace their emoji, plus watermelon, cookie, ice cream and herbal tea); - escenas.json and escenas.mjs: the food resting by each animal's mouth (the owl's beak, the rabbit's teeth), bubbles around the body and a beach ball at its feet, checked in headless Chrome on the eight animals dressed: 72 scenes, none leaving the frame or covering the face, every food by the mouth. The preview sheet shows them. In the app, www/games/companero/escenas.js is the one source for both: a layer over the dressed companion whose resting pose is the still picture under reduced motion; the page's CSS moves it (the food goes to the mouth and is eaten, bubbles rise and pop, the ball bounces) for two seconds, and a new care replaces it at once. Watermelon 6, cookie 7, ice cream 12 and herbal tea 9 join the catalogue with their wishes in five languages. A wish now picks its kind first, all as likely (a food, a bath, clothes, play, a cuddle), so seven foods do not crowd out the rest. COMPANERO.md lists the new prices. --- COMPANERO.md | 7 +- design/companero/comidas/fruta.svg | 3 + design/companero/comidas/galleta.svg | 3 + design/companero/comidas/helado.svg | 3 + design/companero/comidas/jabon.svg | 3 + design/companero/comidas/pastel.svg | 3 + design/companero/comidas/sandia.svg | 3 + design/companero/comidas/te.svg | 3 + design/companero/comidas/zanahoria.svg | 3 + design/companero/escenas.json | 34 ++ design/companero/escenas.mjs | 83 +++++ design/companero/exportar.mjs | 6 +- design/companero/vista-previa.html | 454 +++++++++++++++++++++++++ design/companero/vista-previa.mjs | 11 + tools/test-companero.js | 71 +++- www/games.json | 2 +- www/games/companero/arte.js | 98 ++++++ www/games/companero/cuidado.js | 14 +- www/games/companero/economia.js | 4 + www/games/companero/escenas.js | 59 ++++ www/games/companero/index.html | 56 ++- 21 files changed, 894 insertions(+), 29 deletions(-) create mode 100644 design/companero/comidas/fruta.svg create mode 100644 design/companero/comidas/galleta.svg create mode 100644 design/companero/comidas/helado.svg create mode 100644 design/companero/comidas/jabon.svg create mode 100644 design/companero/comidas/pastel.svg create mode 100644 design/companero/comidas/sandia.svg create mode 100644 design/companero/comidas/te.svg create mode 100644 design/companero/comidas/zanahoria.svg create mode 100644 design/companero/escenas.json create mode 100644 design/companero/escenas.mjs create mode 100644 www/games/companero/escenas.js diff --git a/COMPANERO.md b/COMPANERO.md index fa1f331f..c87284f9 100644 --- a/COMPANERO.md +++ b/COMPANERO.md @@ -54,6 +54,10 @@ Las compras pasan por `C.coins.spend`, que solo acepta precios enteros y positiv | `fruta` | consumible | comida | 5 | | `zanahoria` | consumible | comida | 8 | | `pastel` | consumible | comida | 15 | +| `sandia` | consumible | comida | 6 | +| `galleta` | consumible | comida | 7 | +| `helado` | consumible | comida | 12 | +| `te` | consumible | té de hierbas | 9 | | `jabon` | consumible | baño de burbujas | 10 | Los cuatro personajes del original (zorro, búho, gato y oso) también son animales del compañero. Se compran en la tienda del Perfil, no aquí, y el compañero solo lee si ya son tuyos. @@ -69,7 +73,8 @@ Los consumibles no suman a ese total: cuestan de 5 a 15 y son opcionales. - **Gratis:** acariciar, cepillar y jugar. - **Vínculo:** +1 por cada cuidado y +3 extra al cumplir el deseo de la visita. Una misma acción gratuita suma como mucho una vez cada 30 segundos, pero se puede hacer siempre. - **Cinco niveles**, a los 0, 10, 25, 50 y 100 puntos. El 100 es el máximo y se muestra desde el principio. -- **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. +- **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. +- **Escenas:** comer lleva la comida a la boca, el jabón da un baño de burbujas y jugar trae una pelota que rebota. Con movimiento reducido se ve la misma escena, quieta. ## Datos diff --git a/design/companero/comidas/fruta.svg b/design/companero/comidas/fruta.svg new file mode 100644 index 00000000..0e5e97ba --- /dev/null +++ b/design/companero/comidas/fruta.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/companero/comidas/galleta.svg b/design/companero/comidas/galleta.svg new file mode 100644 index 00000000..6b01d9d0 --- /dev/null +++ b/design/companero/comidas/galleta.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/companero/comidas/helado.svg b/design/companero/comidas/helado.svg new file mode 100644 index 00000000..f6ea52ce --- /dev/null +++ b/design/companero/comidas/helado.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/companero/comidas/jabon.svg b/design/companero/comidas/jabon.svg new file mode 100644 index 00000000..62490abe --- /dev/null +++ b/design/companero/comidas/jabon.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/companero/comidas/pastel.svg b/design/companero/comidas/pastel.svg new file mode 100644 index 00000000..22e51ffc --- /dev/null +++ b/design/companero/comidas/pastel.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/companero/comidas/sandia.svg b/design/companero/comidas/sandia.svg new file mode 100644 index 00000000..2f0d5a7c --- /dev/null +++ b/design/companero/comidas/sandia.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/companero/comidas/te.svg b/design/companero/comidas/te.svg new file mode 100644 index 00000000..1adb06f5 --- /dev/null +++ b/design/companero/comidas/te.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/companero/comidas/zanahoria.svg b/design/companero/comidas/zanahoria.svg new file mode 100644 index 00000000..13ae315f --- /dev/null +++ b/design/companero/comidas/zanahoria.svg @@ -0,0 +1,3 @@ + + + diff --git a/design/companero/escenas.json b/design/companero/escenas.json new file mode 100644 index 00000000..92fb71d1 --- /dev/null +++ b/design/companero/escenas.json @@ -0,0 +1,34 @@ +{ + "nota": "Escenas de cuidado (#32): comer, bañarse y jugar, encima del compañero ya vestido. boca: el punto adonde llega la comida (la boca, el pico del búho o los dientes del conejo). burbujas y pelota: comunes a los ocho, en el marco de 120 x 170 y fuera de la zona de rasgos. comidas: los consumibles que se comen; el jabón es el baño.", + "bocas": { + "fox": [60, 83], + "owl": [60, 70], + "cat": [60, 81], + "bear": [60, 83], + "capybara": [60, 85], + "conejo": [60, 86], + "panda": [60, 83], + "nutria": [60, 86] + }, + "comidas": [ + "fruta", + "zanahoria", + "pastel", + "sandia", + "galleta", + "helado", + "te" + ], + "burbujas": [ + [22, 104, 8], + [98, 100, 7], + [16, 138, 6], + [103, 134, 9], + [24, 84, 4.5], + [86, 156, 6], + [62, 110, 4], + [30, 160, 5], + [96, 80, 5] + ], + "pelota": [100, 149, 12] +} diff --git a/design/companero/escenas.mjs b/design/companero/escenas.mjs new file mode 100644 index 00000000..2994c8ab --- /dev/null +++ b/design/companero/escenas.mjs @@ -0,0 +1,83 @@ +// Puts the care scenes of #32 (eating, a bubble bath, playing) on the eight companions, dressed, +// and checks every animal and scene in headless Chrome: nothing may leave the 120 x 170 frame, the +// bubbles and the ball may not cover the features zone, and the food must rest by the mouth. +// Usage: node design/companero/escenas.mjs (CHROME=/path/to/chrome to pick a browser) +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { anclajes, chrome, componer } from "./prendas.mjs"; + +const here = path.dirname(new URL(import.meta.url).pathname); +const leer = (...p) => fs.readFileSync(path.join(here, ...p), "utf8"); +const json = JSON.parse(leer("escenas.json")); + +// The logic itself lives in the app (www/games/companero/escenas.js), one source for both +const require = createRequire(import.meta.url); +const app = require("../../www/games/companero/escenas.js"); +export const comidas = [...json.comidas, "jabon"]; +export const datosEscenas = { + bocas: json.bocas, + burbujas: json.burbujas, + pelota: json.pelota, + comidas: Object.fromEntries(comidas.map((id) => [id, leer("comidas", id + ".svg")])), +}; +export function escena(svg, animal, tipo, comida) { + return app.escena(svg, animal, tipo, datosEscenas, { comida }); +} + +// Every animal with each food eaten, the bath and the ball, dressed so the scene sits over clothes +export const casos = Object.keys(anclajes).flatMap((a) => [ + ...json.comidas.map((c) => [a, "comer", c]), + [a, "banio"], + [a, "jugar"], +]); +const vestido = (a) => componer(a, ["gorra", "lazo", "sueter"]); + +export function comprobar() { + const html = `${casos + .map(([a, t, c]) => `
${escena(vestido(a), a, t, c)}
`) + .join("")}
`;
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "escenas-"));
+  const archivo = path.join(dir, "comprobar.html");
+  fs.writeFileSync(archivo, html);
+  const r = spawnSync(chrome(), ["--headless=new", "--no-sandbox", `--user-data-dir=${dir}/perfil`, "--virtual-time-budget=3000", "--dump-dom", "file://" + archivo], { encoding: "utf8" });
+  fs.rmSync(dir, { recursive: true, force: true });
+  const m = r.stdout.match(/
([\s\S]*?)<\/pre>/);
+  if (!m) throw new Error("the browser returned no measurements");
+  const medidas = JSON.parse(m[1].replace(/"/g, '"').replace(/&/g, "&"));
+
+  const fallas = [];
+  const corta = (b, z) => b.x0 < z.x1 && z.x0 < b.x1 && b.y0 < z.y1 && z.y0 < b.y1;
+  for (const s of medidas) {
+    const nombre = `${s.a} + ${s.t}`;
+    if (!s.hay || !s.partes.length) { fallas.push(`${nombre}: no dibuja la escena`); continue; }
+    for (const b of s.partes) {
+      if (b.x0 < -0.5 || b.y0 < -0.5 || b.x1 > 120.5 || b.y1 > 170.5) fallas.push(`${nombre}: se sale del marco`);
+      if (s.t !== "comer" && corta(b, anclajes[s.a].rasgos)) fallas.push(`${nombre}: cubre la zona de rasgos`);
+    }
+    if (s.t === "comer") {
+      const b = s.partes[0], [bx, by] = json.bocas[s.a];
+      const cerca = bx >= b.x0 - 4 && bx <= b.x1 + 4 && by >= b.y0 - 4 && by <= b.y1 + 4;
+      if (!cerca) fallas.push(`${nombre}: la comida no queda junto a la boca`);
+    }
+  }
+  return { escenas: medidas.length, esperadas: casos.length, fallas };
+}
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  const { escenas, esperadas, fallas } = comprobar();
+  fallas.forEach((f) => console.error("✗ " + f));
+  console.log(`${escenas} escenas, ${fallas.length} fallas`);
+  if (escenas !== esperadas || fallas.length) process.exit(1);
+}
diff --git a/design/companero/exportar.mjs b/design/companero/exportar.mjs
index ac23f1ec..69510988 100644
--- a/design/companero/exportar.mjs
+++ b/design/companero/exportar.mjs
@@ -5,6 +5,7 @@ import fs from "node:fs";
 import path from "node:path";
 import { datosPrendas } from "./prendas.mjs";
 import { datosExpresiones } from "./expresiones.mjs";
+import { datosEscenas } from "./escenas.mjs";
 
 const here = path.dirname(new URL(import.meta.url).pathname);
 const dir = path.join(here, "cuerpos");
@@ -27,10 +28,11 @@ fs.writeFileSync(
   var ARTE = { cuerpos: ${JSON.stringify(cuerpos, null, 2).replace(/\n/g, "\n  ")},
     disenos: ${JSON.stringify(datos, null, 2).replace(/\n/g, "\n  ")},
     prendas: ${JSON.stringify(datosPrendas, null, 2).replace(/\n/g, "\n  ")},
-    expresiones: ${JSON.stringify(datosExpresiones, null, 2).replace(/\n/g, "\n  ")} };
+    expresiones: ${JSON.stringify(datosExpresiones, null, 2).replace(/\n/g, "\n  ")},
+    escenas: ${JSON.stringify(datosEscenas, null, 2).replace(/\n/g, "\n  ")} };
   if (typeof module === 'object' && module.exports) module.exports = ARTE;
   else root.CompaneroArte = ARTE;
 })(this);
 `,
 );
-console.log(`arte.js: ${Object.keys(cuerpos).length} cuerpos, ${disenos.length} diseños, ${datosPrendas.catalogo.length} prendas, ${Object.keys(datosExpresiones.animales).length} caras con expresiones`);
+console.log(`arte.js: ${Object.keys(cuerpos).length} cuerpos, ${disenos.length} diseños, ${datosPrendas.catalogo.length} prendas, ${Object.keys(datosExpresiones.animales).length} caras con expresiones, ${Object.keys(datosEscenas.comidas).length} comidas`);
diff --git a/design/companero/vista-previa.html b/design/companero/vista-previa.html
index 21a72ea7..e0fc2a6f 100644
--- a/design/companero/vista-previa.html
+++ b/design/companero/vista-previa.html
@@ -1977,6 +1977,460 @@
   
 
 
nutria
+

Comidas y escenas de cuidado (#32)

La pose quieta de cada escena: la que se ve con movimiento reducido y donde termina la animación.

Comidas · 48 px

+ + +
fruta
+ + +
zanahoria
+ + +
pastel
+ + +
sandia
+ + +
galleta
+ + +
helado
+ + +
te
+ + +
jabon

comer

+ + + + + + + + + + + + +
fox · fruta
+ + + + + + + + + + + + +
owl · zanahoria
+ + + + + + + + + + + + +
cat · pastel
+ + + + + + + + + + + + +
bear · sandia
+ + + + + + + + + + + + + + + + + + + +
capybara · galleta
+ + + + + + + + + + + + + + + + + + + + + + + + + +
conejo · helado
+ + + + + + + + + + + + + + + + + + + + + + + +
panda · te
+ + + + + + + + + + + + + + + + + + + + +
nutria · fruta

banio

+ + + + + + + + + + + + +
fox
+ + + + + + + + + + + + +
owl
+ + + + + + + + + + + + +
cat
+ + + + + + + + + + + + +
bear
+ + + + + + + + + + + + + + + + + + + +
capybara
+ + + + + + + + + + + + + + + + + + + + + + + + + +
conejo
+ + + + + + + + + + + + + + + + + + + + + + + +
panda
+ + + + + + + + + + + + + + + + + + + + +
nutria

jugar

+ + + + + + + + + + + + +
fox
+ + + + + + + + + + + + +
owl
+ + + + + + + + + + + + +
cat
+ + + + + + + + + + + + +
bear
+ + + + + + + + + + + + + + + + + + + +
capybara
+ + + + + + + + + + + + + + + + + + + + + + + + + +
conejo
+ + + + + + + + + + + + + + + + + + + + + + + +
panda
+ + + + + + + + + + + + + + + + + + + + +
nutria

Prendas (#16)

Gorra · cabeza

diff --git a/design/companero/vista-previa.mjs b/design/companero/vista-previa.mjs index 1b979d7a..e1fbfd2a 100644 --- a/design/companero/vista-previa.mjs +++ b/design/companero/vista-previa.mjs @@ -7,6 +7,7 @@ import vm from "node:vm"; import { componer, catalogo } from "./prendas.mjs"; import { aplicar, disenos } from "./disenos.mjs"; import { expresar, expresiones } from "./expresiones.mjs"; +import { escena, comidas as comidasEscena } from "./escenas.mjs"; const here = path.dirname(new URL(import.meta.url).pathname); const core = fs.readFileSync(path.join(here, "../../www/lib/chogan.js"), "utf8"); @@ -63,6 +64,15 @@ const caras = `

Expresiones .map((t) => `

${t} px

${expresiones.map((e) => `

${e}

${filaExpr(e, Math.round((t * 170) / 120))}`).join("")}`) .join("")}
`; +// The foods and the care scenes (#32), on the companions dressed, at the size the app draws them +const COMIDAS_FILA = ["fruta", "zanahoria", "pastel", "sandia", "galleta", "helado", "te", "fruta"]; +const enEscena = (id, tipo, comida, alto) => + escena(componer(id, ["lazo"]), id, tipo, comida).replace('width="120" height="170"', `width="${Math.round((alto * 120) / 170)}" height="${alto}"`); +const filaEscena = (tipo, alto) => + `
${ids.map((id, i) => `
${enEscena(id, tipo, COMIDAS_FILA[i], alto)}
${id}${tipo === "comer" ? " · " + COMIDAS_FILA[i] : ""}
`).join("")}
`; +const iconos = `
${comidasEscena.map((c) => `
${fs.readFileSync(path.join(here, "comidas", c + ".svg"), "utf8")}
${c}
`).join("")}
`; +const escenas = `

Comidas y escenas de cuidado (#32)

La pose quieta de cada escena: la que se ve con movimiento reducido y donde termina la animación.

Comidas · 48 px

${iconos}${["comer", "banio", "jugar"].map((t) => `

${t}

${filaEscena(t, 150)}`).join("")}
`; + fs.writeFileSync( path.join(here, "vista-previa.html"), `Compañero · bocetos de animales @@ -72,6 +82,7 @@ fs.writeFileSync( ${tema("Tema claro", "#FBF6EF", "#2C2620")}${tema("Tema oscuro", "#1E1B18", "#F2EDE6")} ${cuerpos("Cuerpo entero · tema claro", "#FBF6EF", "#2C2620")}${cuerpos("Cuerpo entero · tema oscuro", "#1E1B18", "#F2EDE6")} ${caras} +${escenas} ${ropa} ${colores("Diseños exclusivos (#17) · tema claro", "#FBF6EF", "#2C2620")}${colores("Diseños exclusivos (#17) · tema oscuro", "#1E1B18", "#F2EDE6")}

Anclajes de la ropa

Rojo: cabeza (gorro, corona, lentes) · verde: cuello (lazo, pañuelo) · azul: cuerpo (suéter, capa).

${filaCuerpos(170, true)}
`, diff --git a/tools/test-companero.js b/tools/test-companero.js index 17f26250..3a5f3c1a 100644 --- a/tools/test-companero.js +++ b/tools/test-companero.js @@ -81,6 +81,8 @@ function testCatalogo() { ok(E.VERSION === 1, 'el catálogo tiene versión'); ok(precio('capybara') === 0 && precio('conejo') === 180 && precio('panda') === 260 && precio('nutria') === 340, 'animales nuevos con los precios de #6'); ok(precio('fruta') === 5 && precio('zanahoria') === 8 && precio('pastel') === 15 && precio('jabon') === 10, 'consumibles con los precios de #9'); + ok(precio('sandia') === 6 && precio('galleta') === 7 && precio('helado') === 12 && precio('te') === 9, 'las comidas nuevas de #32: sandía 6, galleta 7, helado 12, té 9'); + ok(E.CATALOGO.filter((a) => a.tipo === 'consumible').map((a) => a.id).join() === 'fruta,zanahoria,pastel,sandia,galleta,helado,te,jabon', 'ocho consumibles: siete comidas y el jabón'); ok(['gorra', 'corona', 'lentes', 'lazo', 'panuelo', 'sueter', 'capa'].every((id) => precio(id) > 0), 'las siete prendas de #16 están a la venta'); // a single source: the design prices of the art (#17) and the catalogue agree const disenos = JSON.parse(fs.readFileSync(path.join(ROOT, 'design/companero/disenos.json'), 'utf8')).disenos; @@ -320,7 +322,10 @@ function testCuidadoLogica() { head('Cuidado: acciones, vínculo y deseos (#9)'); ok(!!K && typeof K.crear === 'function', 'www/games/companero/cuidado.js exporta crear()'); if (!K) return; - ok(K.DESEOS.map((d) => d.id).join() === 'fruta,zanahoria,pastel,banio,ropa,jugar,caricias', 'los deseos son los de la tabla de #9'); + ok(K.DESEOS.map((d) => d.id).join() === 'fruta,zanahoria,pastel,sandia,galleta,helado,te,banio,ropa,jugar,caricias', 'los deseos son los de #9 con las comidas nuevas de #32'); + const tipos = {}; + for (let i = 0; i < 100; i++) { const c = companero(); c.dado(() => (i + 0.5) / 100); const d = c.c.visitar().id; const t = ['banio', 'ropa', 'jugar', 'caricias'].includes(d) ? d : 'comida'; tipos[t] = (tipos[t] || 0) + 1; } + ok(Object.keys(tipos).length === 5 && Object.values(tipos).every((v) => v === 20), 'las comidas no acaparan los deseos: comida, baño, ropa, jugar y caricias salen igual de seguido (' + JSON.stringify(tipos) + ')'); ok(K.NIVELES.join() === '0,10,25,50,100' && K.MAXIMO === 100, 'cinco niveles, a los 0, 10, 25, 50 y 100 puntos (el máximo)'); const x = companero({ saldo: 100 }); @@ -343,7 +348,7 @@ function testDeseos() { head('Deseos amables: uno por visita, distinto, opcional'); if (!K) return ok(false, 'sin módulo'); const x = companero({ saldo: 100 }); - x.dado(() => 0.85); // the dice point at «jugar» + x.dado(() => 0.7); // the dice point at «jugar» const d1 = x.c.visitar(); ok(d1 && d1.id === 'jugar' && !d1.cumplido, 'al abrir hay exactamente un deseo de la tabla (jugar)'); const r = x.c.hecho({ accion: 'jugar' }); @@ -353,16 +358,16 @@ function testDeseos() { ok(d2.id !== 'jugar', 'en la visita siguiente el deseo es otro, aunque el dado repita (' + d2.id + ')'); const y = companero({ saldo: 100 }); - y.dado(() => 0.15); // «zanahoria» + y.dado(() => 0.04); // a food, and of the foods «zanahoria» ok(y.c.visitar().id === 'zanahoria', 'un deseo de comida'); y.eco.comprar('fruta'); y.pasar(500); ok(!y.c.alimentar('fruta').cumplioDeseo && !y.c.deseo().cumplido, 'otra comida no cumple el deseo de zanahoria'); y.eco.comprar('zanahoria'); ok(y.c.alimentar('zanahoria').cumplioDeseo, 'la zanahoria sí lo cumple'); - const z = companero(); z.dado(() => 0.6); + const z = companero(); z.dado(() => 0.5); ok(z.c.visitar().id === 'ropa' && z.c.hecho({ equipar: 'prenda' }).cumplioDeseo, 'equipar una prenda cumple el deseo de ropa'); - const w = companero(); w.dado(() => 0.45); + const w = companero(); w.dado(() => 0.3); ok(w.c.visitar().id === 'banio', 'el deseo del baño de burbujas'); const sinCumplir = w.c.vinculo().puntos; w.crearCuidado().visitar(); @@ -574,7 +579,7 @@ async function testCuidadoPagina() { ok(!!inicio.deseo && inicio.texto.length > 5, 'al abrir hay un globo con un deseo (' + inicio.deseo + ')'); ok(inicio.expr === 'antojado', 'mientras el deseo espera, el compañero está antojado'); ok(await evaluar(`[...document.querySelectorAll('[data-accion]')].map((b) => b.dataset.accion).join()`) === 'acariciar,cepillar,jugar', 'tres acciones gratuitas: acariciar, cepillar y jugar'); - ok(await evaluar(`[...document.querySelectorAll('[data-consumible]')].map((b) => b.dataset.consumible).join()`) === 'fruta,zanahoria,pastel,jabon', 'cuatro consumibles: fruta, zanahoria, pastel y jabón'); + ok(await evaluar(`[...document.querySelectorAll('[data-consumible]')].map((b) => b.dataset.consumible).join()`) === 'fruta,zanahoria,pastel,sandia,galleta,helado,te,jabon', 'ocho consumibles: siete comidas y el jabón'); const libre = inicio.deseo === 'caricias' ? 'jugar' : 'acariciar'; await evaluar(`document.querySelector('[data-accion="${libre}"]').click()`); @@ -584,6 +589,7 @@ async function testCuidadoPagina() { // fulfil whatever the visit wished for const cumplir = { fruta: ['consumible', 'fruta'], zanahoria: ['consumible', 'zanahoria'], pastel: ['consumible', 'pastel'], banio: ['consumible', 'jabon'], + sandia: ['consumible', 'sandia'], galleta: ['consumible', 'galleta'], helado: ['consumible', 'helado'], te: ['consumible', 'te'], ropa: ['prenda-item', 'lazo'], jugar: ['accion', 'jugar'], caricias: ['accion', 'cepillar'], }[inicio.deseo]; await evaluar(pausa(200)); @@ -837,6 +843,59 @@ async function testGlobo() { }); } +/* ------------------------------------------------ eating, bathing and playing (#32) */ + +function testEscenasFuente() { + head('Las escenas: una sola fuente para la app y el arte (#32)'); + const X = path.join(ROOT, 'www/games/companero/escenas.js'); + const A = fs.existsSync(ARTE) ? require(ARTE) : null; + ok(fs.existsSync(X) && !!A && !!A.escenas, 'escenas.js existe y arte.js trae sus datos'); + if (!fs.existsSync(X) || !A || !A.escenas) return; + const dir = path.join(ROOT, 'design/companero'); + const json = JSON.parse(fs.readFileSync(path.join(dir, 'escenas.json'), 'utf8')); + const comidas = json.comidas.concat(['jabon']); + ok(comidas.every((c) => A.escenas.comidas[c] === fs.readFileSync(path.join(dir, 'comidas', c + '.svg'), 'utf8')), 'las ocho comidas de arte.js son idénticas a design/companero/comidas/'); + ok(JSON.stringify(A.escenas.bocas) === JSON.stringify(json.bocas) && JSON.stringify(A.escenas.burbujas) === JSON.stringify(json.burbujas), 'bocas y burbujas idénticas a escenas.json'); + const { escena } = require(X); + const capa = escena(A.cuerpos.capybara, 'capybara', 'comer', A.escenas, { comida: 'helado' }); + ok(/data-escena="comer"/.test(capa) && /data-comida="helado"/.test(capa) && escena(A.cuerpos.fox, 'fox', 'nada', A.escenas) === A.cuerpos.fox, 'escena() agrega su capa, y una escena desconocida no cambia nada'); + const r = require('child_process').spawnSync(process.execPath, [path.join(dir, 'escenas.mjs')], { encoding: 'utf8' }); + ok(r.status === 0 && /72 escenas, 0 fallas/.test(r.stdout), 'design/companero/escenas.mjs: ' + (r.stdout.trim() || r.stderr.trim().split('\n')[0])); +} + +async function testEscenasPagina() { + head('Comer, bañarse y jugar se ven en la página (#32)'); + await navegador(async ({ abrir, evaluar, cdp }) => { + await abrir('/games/companero/index.html'); + await evaluar(preparar({ monedas: 300 })); + await abrir('/games/companero/index.html'); + ok(await evaluar(`[...document.querySelectorAll('[data-consumible]')].every((b) => b.querySelector('svg') && !/[\\u{1F300}-\\u{1FAFF}]/u.test(b.textContent))`), 'cada consumible muestra su dibujo, sin emojis'); + const capa = `(() => { const c = document.querySelector('.cp-compa [data-escena]'); if (!c) return null; return { escena: c.dataset.escena, comida: c.dataset.comida || '', anima: c.getAnimations({ subtree: true }).length }; })()`; + const comprar = async (id) => { await evaluar(`document.querySelector('[data-consumible="${id}"]').click(); true`); await evaluar(pausa(200)); await evaluar(aceptar); await evaluar(pausa(150)); }; + + await comprar('helado'); + let c = await evaluar(capa); + ok(c && c.escena === 'comer' && c.comida === 'helado' && c.anima > 0, 'al darle un helado, el helado va a su boca, animado'); + await evaluar(pausa(2300)); + ok(await evaluar(capa) === null, 'la escena termina sola en menos de 2,5 s'); + await comprar('jabon'); + c = await evaluar(capa); + ok(c && c.escena === 'banio' && c.anima > 0, 'el jabón da un baño de burbujas'); + await evaluar(`document.querySelector('[data-accion="jugar"]').click(); true`); await evaluar(pausa(100)); + c = await evaluar(capa); + ok(c && c.escena === 'jugar', 'jugar trae la pelota, y reemplaza al baño sin esperar a que termine'); + await evaluar(pausa(2300)); + await evaluar(`document.querySelector('[data-accion="acariciar"]').click(); true`); await evaluar(pausa(100)); + ok(await evaluar(capa) === null && await evaluar(`document.querySelector('.cp-compa').dataset.expresion === 'feliz'`), 'acariciar no trae escena: solo la cara feliz'); + + await cdp('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + await abrir('/games/companero/index.html'); + await comprar('galleta'); + c = await evaluar(capa); + ok(c && c.escena === 'comer' && c.comida === 'galleta' && c.anima === 0, 'con movimiento reducido, la galleta aparece quieta junto a la boca, sin animación'); + }); +} + /* ------------------------------------------------ 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 448b34d7..5b02e37d 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"] + "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"] }, { "id": "morris", diff --git a/www/games/companero/arte.js b/www/games/companero/arte.js index d85641b6..ed03f6a7 100644 --- a/www/games/companero/arte.js +++ b/www/games/companero/arte.js @@ -667,6 +667,104 @@ "panda": 26, "nutria": 24 } + }, + escenas: { + "bocas": { + "fox": [ + 60, + 83 + ], + "owl": [ + 60, + 70 + ], + "cat": [ + 60, + 81 + ], + "bear": [ + 60, + 83 + ], + "capybara": [ + 60, + 85 + ], + "conejo": [ + 60, + 86 + ], + "panda": [ + 60, + 83 + ], + "nutria": [ + 60, + 86 + ] + }, + "burbujas": [ + [ + 22, + 104, + 8 + ], + [ + 98, + 100, + 7 + ], + [ + 16, + 138, + 6 + ], + [ + 103, + 134, + 9 + ], + [ + 24, + 84, + 4.5 + ], + [ + 86, + 156, + 6 + ], + [ + 62, + 110, + 4 + ], + [ + 30, + 160, + 5 + ], + [ + 96, + 80, + 5 + ] + ], + "pelota": [ + 100, + 149, + 12 + ], + "comidas": { + "fruta": "\n \n\n", + "zanahoria": "\n \n\n", + "pastel": "\n \n\n", + "sandia": "\n \n\n", + "galleta": "\n \n\n", + "helado": "\n \n\n", + "te": "\n \n\n", + "jabon": "\n \n\n" + } } }; if (typeof module === 'object' && module.exports) module.exports = ARTE; else root.CompaneroArte = ARTE; diff --git a/www/games/companero/cuidado.js b/www/games/companero/cuidado.js index d7298fd5..12b2519d 100644 --- a/www/games/companero/cuidado.js +++ b/www/games/companero/cuidado.js @@ -10,17 +10,24 @@ 'use strict'; var VERSION = 1; - // The wishes of #9, in order; each says what fulfils it + // The wishes of #9 and the foods of #32, in order; each says what fulfils it var DESEOS = [ { id: 'fruta', usar: 'fruta' }, { id: 'zanahoria', usar: 'zanahoria' }, { id: 'pastel', usar: 'pastel' }, + { id: 'sandia', usar: 'sandia' }, + { id: 'galleta', usar: 'galleta' }, + { id: 'helado', usar: 'helado' }, + { id: 'te', usar: 'te' }, { id: 'banio', usar: 'jabon' }, { id: 'ropa', equipar: 'prenda' }, { id: 'jugar', acciones: ['jugar'] }, { id: 'caricias', acciones: ['acariciar', 'cepillar'] } ]; var ACCIONES = ['acariciar', 'cepillar', 'jugar']; + // A wish is first a kind, all as likely, then which food: seven foods do not crowd out the rest + var COMIDAS = DESEOS.filter(function (d) { return d.usar && d.usar !== 'jabon'; }).map(function (d) { return d.id; }); + var TIPOS = [COMIDAS, ['banio'], ['ropa'], ['jugar'], ['caricias']]; var NIVELES = [0, 10, 25, 50, 100]; var MAXIMO = 100; var POR_CUIDADO = 1, POR_DESEO = 3; @@ -55,7 +62,10 @@ // A visit is each time the space opens: one wish, never the same as the last visit's function visitar() { - var i = Math.min(DESEOS.length - 1, Math.max(0, Math.floor(azar() * DESEOS.length))); + var x = Math.min(0.999999, Math.max(0, azar())) * TIPOS.length; + var tipo = TIPOS[Math.floor(x)]; + var id = tipo[Math.min(tipo.length - 1, Math.floor((x - Math.floor(x)) * tipo.length))]; + var i = DESEOS.indexOf(buscar(id)); if (DESEOS[i].id === d.ultimoDeseo) i = (i + 1) % DESEOS.length; d.deseo = { id: DESEOS[i].id, cumplido: false }; d.ultimoDeseo = DESEOS[i].id; diff --git a/www/games/companero/economia.js b/www/games/companero/economia.js index f1b3cbb4..d20834a5 100644 --- a/www/games/companero/economia.js +++ b/www/games/companero/economia.js @@ -31,6 +31,10 @@ { id: 'fruta', tipo: 'consumible', precio: 5 }, { id: 'zanahoria', tipo: 'consumible', precio: 8 }, { id: 'pastel', tipo: 'consumible', precio: 15 }, + { id: 'sandia', tipo: 'consumible', precio: 6 }, + { id: 'galleta', tipo: 'consumible', precio: 7 }, + { id: 'helado', tipo: 'consumible', precio: 12 }, + { id: 'te', tipo: 'consumible', precio: 9 }, { id: 'jabon', tipo: 'consumible', precio: 10 } ].map(function (a) { a.estado = 'activo'; return a; }); diff --git a/www/games/companero/escenas.js b/www/games/companero/escenas.js new file mode 100644 index 00000000..0c646398 --- /dev/null +++ b/www/games/companero/escenas.js @@ -0,0 +1,59 @@ +/* Capybara's companion: what is seen while it eats, bathes or plays (epic #2, #32). + One source for the app and for design/companero/escenas.mjs, which checks every animal and scene. + A scene is a layer drawn over the dressed companion, marked data-escena. Its resting pose is the + still picture shown under reduced motion: the food by the mouth, the bubbles around the body, the + ball at its feet. The movement comes from the page's CSS on the classes below. Pure string work, + no DOM: the mouths, the bubbles, the ball and the food art come in as `datos`. */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.CompaneroEscenas = factory(); +})(this, function () { + 'use strict'; + + var ESCENAS = ['comer', 'banio', 'jugar']; + + function n(x) { return +x.toFixed(2); } + // the inside of a 24 x 24 food drawing, without its wrapper + function interior(svg) { return (svg.match(/([\s\S]*?)<\/g>\s*<\/svg>/) || [])[1] || ''; } + + function comer(boca, comida) { + // the food rests by the mouth, a little to the side, 22 units across + var x = boca[0] + 7, y = boca[1] - 1, k = 22 / 24; + return '' + + '' + interior(comida.svg) + ''; + } + function banio(burbujas) { + return '' + burbujas.map(function (b, i) { + var x = b[0], y = b[1], r = b[2]; + return '' + + '' + + ''; + }).join('') + ''; + } + function jugar(p) { + var x = p[0], y = p[1], r = p[2]; + // a beach ball: white, with a red and a blue wedge + return '' + + '' + + '' + + '' + + ''; + } + + // datos: { bocas: { animal: [x, y] }, burbujas: [[x, y, r]], pelota: [x, y, r], comidas: { id: svg } } + // opciones.comida: the food eaten, for «comer» + function escena(svg, animal, tipo, datos, opciones) { + var capa = ''; + if (tipo === 'comer') { + var id = opciones && opciones.comida; + if (!datos.bocas[animal] || !datos.comidas[id]) return svg; + capa = comer(datos.bocas[animal], { id: id, svg: datos.comidas[id] }); + } else if (tipo === 'banio') capa = banio(datos.burbujas); + else if (tipo === 'jugar') capa = jugar(datos.pelota); + else return svg; + // over everything, the clothes included + return svg.replace(/<\/svg>\s*$/, capa + '\n'); + } + + return { ESCENAS: ESCENAS, escena: escena }; +}); diff --git a/www/games/companero/index.html b/www/games/companero/index.html index 4f7eacb1..edfd1bd0 100644 --- a/www/games/companero/index.html +++ b/www/games/companero/index.html @@ -48,10 +48,24 @@ .cp-vinculo { margin: 0; text-align: center; font-size: .9rem; } .cp-corazones { color: #D7263D; letter-spacing: 2px; } .cp-accion { min-height: 56px; justify-content: center; font-weight: 600; } - .cp-emoji { font-size: 1.8rem; line-height: 1; } @keyframes cp-salto { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(-8%); } } .cp-salto { animation: cp-salto .5s ease; } - @media (prefers-reduced-motion: reduce) { .cp-salto { animation: none; } } + /* 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; } + @keyframes cp-comer { + 0% { transform: translate(30px, 30px) scale(.9); opacity: 0; } 15% { opacity: 1; } + 45% { transform: translate(0, 0) scale(1); } 60% { transform: scale(.75); } 75% { transform: scale(.5); } + 90% { transform: scale(.25); opacity: 1; } 100% { transform: scale(0); opacity: 0; } } + .cp-esc-burbuja { animation: cp-burbuja 1.8s ease-out both; } + @keyframes cp-burbuja { + 0% { transform: scale(0); opacity: 0; } 25% { transform: scale(1); opacity: 1; } + 70% { transform: translateY(-6px) scale(1.05); opacity: 1; } 100% { transform: translateY(-14px) scale(1.3); opacity: 0; } } + .cp-esc-pelota { animation: cp-pelota 1.8s ease-in-out; } + @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-esc-bocado, .cp-esc-burbuja, .cp-esc-pelota { animation: none; } } @@ -63,6 +77,7 @@ +