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 @@
+