diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html
index a9d9c6d..8853a5b 100644
--- a/android/app/assets/carinha.html
+++ b/android/app/assets/carinha.html
@@ -279,6 +279,13 @@ function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
}
+ if (espiralAtiva()) {
+ p.traco.setAttribute("d", caminhoEspiral(cx, cy, Math.min(rx, ry) * 0.95, lado));
+ p.traco.setAttribute("stroke-width", rx * 0.17);
+ p.traco.setAttribute("stroke", rgb(cor));
+ p.traco.style.display = "";
+ return;
+ }
if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
@@ -372,12 +379,12 @@ function sequencias() {
const pula = (n, alto, baixo, d) => Array.from({ length: n }, (_, i) => [{ dy: i % 2 ? baixo : alto }, d]);
const rir = [[{ forma: "feliz", boca: "aberta", esc: 1.08, dy: -4 }, 0.12], ...pula(8, -6, 2, 0.1),
[{ forma: "feliz", boca: "sorriso", dy: 0 }, 0.25], [{ forma: "aberto", boca: "neutra", esc: 1.0 }, 0.3]];
- const bocejar = [[{ ab: 0.5, boca: "aberta", dy: -4, esc: 1.05 }, 0.5], [{ ab: 0.3, dy: -5 }, 1.0],
+ const bocejar = [[{ ab: 0.5, boca: "aberta", dy: -4, esc: 1.05 }, 0.5], [{ ab: 0.25, dy: -5 }, 1.0],
[{ ab: 0.0, boca: "neutra", dy: 2, esc: 1.0 }, 0.25], [{ ab: 1.0, dy: 0 }, 0.35]];
const confuso = [[{ ab2: 0.55, dx: -8, esc: 0.98 }, 0.25], [{ dx: 8 }, 0.45], [{ dx: -6 }, 0.4], [{}, 0.4],
[{ ab2: 1.0, dx: 0, esc: 1.0 }, 0.3]];
- const roda = Array.from({ length: 16 }, (_, i) => [{ dx: 14 * Math.cos(i * Math.PI / 4), dy: 9 * Math.sin(i * Math.PI / 4) }, 0.1]);
- const tonto = [[{ esc: 0.94, boca: "aberta" }, 0.15], ...roda, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]];
+ const balanca = Array.from({ length: 8 }, (_, i) => [{ dx: i % 2 ? -5 : 5, dy: i % 2 ? 2 : -2 }, 0.2]);
+ const tonto = [[{ esc: 1.05, boca: "aberta" }, 0.15], ...balanca, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]];
const timido = [[{ forma: "feliz", boca: "sorriso", dx: -14, dy: 9, esc: 0.9, cor: ROSA }, 0.35], [{}, 1.2],
[{ dx: 0, dy: 0, esc: 1.0, forma: "aberto", cor: CIANO }, 0.4]];
const comemorar = [[{ forma: "feliz", boca: "sorriso", cor: AMARELO, dy: -10, esc: 1.15 }, 0.15], [{ dy: 2, cor: ROSA }, 0.15],
@@ -458,9 +465,24 @@ function atualizarFeicao(s) {
for (const c of ["curva", "lado", "abre", "dx"]) a[c] += (b[c] - a[c]) * k;
for (let i = 0; i < 2; i++) { a.sob[i] += (b.sob[i] - a.sob[i]) * k; a.inc[i] += (b.inc[i] - a.inc[i]) * k; }
}
+/* Gestos soltos mais humanos: quase sempre coisas sutis (olhar de lado, piscar devagar, olhar para
+ baixo); de vez em quando um gesto maior do humor, nunca o mesmo duas vezes seguidas. Gestos
+ "para alguém" (acenar, tímido, piscadinha...) só quando tem gente por perto. */
+const SUTIS = ["olhar_esq", "olhar_dir", "olhar_baixo", "piscar2", "olhar_esq", "olhar_dir"];
+const PARA_ALGUEM = ["acenar", "coracao", "piscadinha", "timido", "esconder", "espiar", "surpreso"];
+const RECENTES = [];
+function alguemOlhando() {
+ const agora = performance.now() / 1000;
+ return (MOV.ativo && agora - MOV.ultimoMov < 30) || (TRACK.alvo && agora - TRACK.visto < 30);
+}
function gestoSolto() {
- const lista = LIVRES_HUMOR[HUMOR] || LIVRES;
- return lista[Math.floor(Math.random() * lista.length)];
+ const grande = Math.random() < ({ feliz: 0.3, entediado: 0.25, bravo: 0.3 }[HUMOR] || 0.18);
+ let lista = grande ? (LIVRES_HUMOR[HUMOR] || LIVRES) : SUTIS;
+ if (!alguemOlhando()) lista = lista.filter((g) => !PARA_ALGUEM.includes(g));
+ const opcoes = lista.filter((g) => !RECENTES.includes(g));
+ const g = (opcoes.length ? opcoes : SUTIS)[Math.floor(Math.random() * (opcoes.length || SUTIS.length))];
+ RECENTES.push(g); if (RECENTES.length > 2) RECENTES.shift();
+ return g;
}
function proximo() {
@@ -476,7 +498,22 @@ function proximo() {
r.kf_t = 0;
}
+const ESPIRAL = { ate: 0 }; // tonto: espiral no lugar dos olhos até este instante
+const espiralAtiva = () => performance.now() / 1000 < ESPIRAL.ate;
+function caminhoEspiral(cx, cy, R, lado) {
+ const voltas = 2.75, giro = performance.now() / 1000 * 7 * lado;
+ let d = "M " + cx + "," + cy;
+ for (let th = 0.22; th <= voltas * Math.PI * 2; th += 0.22) {
+ const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
+ d += " L " + (cx + Math.cos(a) * rr).toFixed(1) + "," + (cy + Math.sin(a) * rr).toFixed(1);
+ }
+ return d;
+}
+
function animar(nome) {
+ if (nome === "tonto") ESPIRAL.ate = performance.now() / 1000 + 2.1;
+ // mãos (esconder, espiar, acenar, pensar, bocejar) valem também quando o gesto vem sozinho
+ if (SEQS[nome] && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(nome);
if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo();
@@ -502,6 +539,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null;
+const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
function atividade(nome, segundos) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
@@ -523,9 +561,18 @@ function repouso(t) { // pose calma: olha devagar, "respira
r.boca = "neutra";
r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
- // o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
- r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
- r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
+ // olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
+ // centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
+ if (t >= OLHAR.prox || t < OLHAR.prox - 10) {
+ const longe = Math.random() < 0.25;
+ OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
+ OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
+ OLHAR.prox = t + 1.2 + Math.random() * 3;
+ }
+ OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
+ OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
+ r.dx = OLHAR.x + 1.0 * Math.sin(t * 0.9) + MOV.olharX;
+ r.dy = OLHAR.y + 0.8 * Math.sin(t * 1.3) + MOV.olharY;
if (ATIV) { // fazendo algo: olha para a atividade
const v = ATIV.v;
r.dx = r.dx * (1 - v) * 0.3 + ATIV.x * v;
@@ -555,7 +602,7 @@ function desenhar() {
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) {
window.MotorRosto.desenhar({
- estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
+ estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo, espiral: espiralAtiva(),
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
@@ -660,7 +707,6 @@ function trata(ordem) {
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
- else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
diff --git a/android/app/assets/rosto-motor.js b/android/app/assets/rosto-motor.js
index d17a361..d80c85b 100644
--- a/android/app/assets/rosto-motor.js
+++ b/android/app/assets/rosto-motor.js
@@ -107,7 +107,20 @@
}
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
+ // Tonto: uma espiral girando no lugar de cada olho (cada um gira para um lado, como no emoji 😵💫).
+ function espiral(c, o, cx, cy, R, lado) {
+ const voltas = 2.75, giro = (o.t || 0) * 7 * lado;
+ c.save(); c.strokeStyle = css(o.cor); c.lineWidth = Math.max(2, R * 0.17); c.lineCap = 'round';
+ c.beginPath();
+ for (let th = 0; th <= voltas * Math.PI * 2; th += 0.22) {
+ const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
+ if (th) c.lineTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr); else c.moveTo(cx, cy);
+ }
+ c.stroke(); c.restore();
+ }
+
function olhoPlano(c, o, p, est, e) {
+ if (o.espiral) { espiral(c, o, p.cx, p.cy, Math.min(est.w, est.h) * p.ry * 0.5, p.lado || 1); return; }
const respira = 1 + 0.012 * Math.sin(o.t * 1.4);
const temConteudo = !!(o.icone || o.desenho);
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 };
@@ -162,6 +175,7 @@
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) {
+ if (o.espiral) { espiral(c, o, p.cx, p.cy, est.w * p.ry * 0.5, p.lado || 1); return; }
const R = est.w * p.ry * 0.5 * Math.max(1, p.ab) * (1 + 0.01 * Math.sin(o.t * 1.4));
const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save();
@@ -289,7 +303,7 @@
// ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
// de rosto de mesmo nome no carinha.html.
- const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
+ const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8, bocejar: 2.1 };
const maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
@@ -387,6 +401,16 @@
const y = fora + (y0 - fora) * v;
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
+ } else if (maos.modo === 'bocejar') {
+ // mão na frente da boca, com tapinhas, e os Zzz subindo do olho
+ const ida = voltinha(k / 0.25), volta = faixaDe(k, 0.78, 1);
+ const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
+ const bx = (e.cx + d.cx) / 2 + e.ry * 0.12, by = e.cy + e.ry * 1.2;
+ const tapa = k > 0.3 && k < 0.75 ? Math.abs(Math.sin((k - 0.3) * Math.PI * 6)) * s * 0.12 : 0;
+ mao(c, o, bx + e.ry * 0.5 * (1 - Math.min(1, v)), fora + (by + s * 0.55 - fora) * v + tapa, s * 0.9, -0.22, -1, 0.15, 0, false, true, true);
+ c.save(); c.globalAlpha *= Math.min(1, v);
+ desenharZzz(c, Object.assign({}, o, { dormindo: true }), olhos, 0.8);
+ c.restore();
} else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
diff --git a/carinha.html b/carinha.html
index a9d9c6d..8853a5b 100644
--- a/carinha.html
+++ b/carinha.html
@@ -279,6 +279,13 @@ function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
}
+ if (espiralAtiva()) {
+ p.traco.setAttribute("d", caminhoEspiral(cx, cy, Math.min(rx, ry) * 0.95, lado));
+ p.traco.setAttribute("stroke-width", rx * 0.17);
+ p.traco.setAttribute("stroke", rgb(cor));
+ p.traco.style.display = "";
+ return;
+ }
if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
@@ -372,12 +379,12 @@ function sequencias() {
const pula = (n, alto, baixo, d) => Array.from({ length: n }, (_, i) => [{ dy: i % 2 ? baixo : alto }, d]);
const rir = [[{ forma: "feliz", boca: "aberta", esc: 1.08, dy: -4 }, 0.12], ...pula(8, -6, 2, 0.1),
[{ forma: "feliz", boca: "sorriso", dy: 0 }, 0.25], [{ forma: "aberto", boca: "neutra", esc: 1.0 }, 0.3]];
- const bocejar = [[{ ab: 0.5, boca: "aberta", dy: -4, esc: 1.05 }, 0.5], [{ ab: 0.3, dy: -5 }, 1.0],
+ const bocejar = [[{ ab: 0.5, boca: "aberta", dy: -4, esc: 1.05 }, 0.5], [{ ab: 0.25, dy: -5 }, 1.0],
[{ ab: 0.0, boca: "neutra", dy: 2, esc: 1.0 }, 0.25], [{ ab: 1.0, dy: 0 }, 0.35]];
const confuso = [[{ ab2: 0.55, dx: -8, esc: 0.98 }, 0.25], [{ dx: 8 }, 0.45], [{ dx: -6 }, 0.4], [{}, 0.4],
[{ ab2: 1.0, dx: 0, esc: 1.0 }, 0.3]];
- const roda = Array.from({ length: 16 }, (_, i) => [{ dx: 14 * Math.cos(i * Math.PI / 4), dy: 9 * Math.sin(i * Math.PI / 4) }, 0.1]);
- const tonto = [[{ esc: 0.94, boca: "aberta" }, 0.15], ...roda, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]];
+ const balanca = Array.from({ length: 8 }, (_, i) => [{ dx: i % 2 ? -5 : 5, dy: i % 2 ? 2 : -2 }, 0.2]);
+ const tonto = [[{ esc: 1.05, boca: "aberta" }, 0.15], ...balanca, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]];
const timido = [[{ forma: "feliz", boca: "sorriso", dx: -14, dy: 9, esc: 0.9, cor: ROSA }, 0.35], [{}, 1.2],
[{ dx: 0, dy: 0, esc: 1.0, forma: "aberto", cor: CIANO }, 0.4]];
const comemorar = [[{ forma: "feliz", boca: "sorriso", cor: AMARELO, dy: -10, esc: 1.15 }, 0.15], [{ dy: 2, cor: ROSA }, 0.15],
@@ -458,9 +465,24 @@ function atualizarFeicao(s) {
for (const c of ["curva", "lado", "abre", "dx"]) a[c] += (b[c] - a[c]) * k;
for (let i = 0; i < 2; i++) { a.sob[i] += (b.sob[i] - a.sob[i]) * k; a.inc[i] += (b.inc[i] - a.inc[i]) * k; }
}
+/* Gestos soltos mais humanos: quase sempre coisas sutis (olhar de lado, piscar devagar, olhar para
+ baixo); de vez em quando um gesto maior do humor, nunca o mesmo duas vezes seguidas. Gestos
+ "para alguém" (acenar, tímido, piscadinha...) só quando tem gente por perto. */
+const SUTIS = ["olhar_esq", "olhar_dir", "olhar_baixo", "piscar2", "olhar_esq", "olhar_dir"];
+const PARA_ALGUEM = ["acenar", "coracao", "piscadinha", "timido", "esconder", "espiar", "surpreso"];
+const RECENTES = [];
+function alguemOlhando() {
+ const agora = performance.now() / 1000;
+ return (MOV.ativo && agora - MOV.ultimoMov < 30) || (TRACK.alvo && agora - TRACK.visto < 30);
+}
function gestoSolto() {
- const lista = LIVRES_HUMOR[HUMOR] || LIVRES;
- return lista[Math.floor(Math.random() * lista.length)];
+ const grande = Math.random() < ({ feliz: 0.3, entediado: 0.25, bravo: 0.3 }[HUMOR] || 0.18);
+ let lista = grande ? (LIVRES_HUMOR[HUMOR] || LIVRES) : SUTIS;
+ if (!alguemOlhando()) lista = lista.filter((g) => !PARA_ALGUEM.includes(g));
+ const opcoes = lista.filter((g) => !RECENTES.includes(g));
+ const g = (opcoes.length ? opcoes : SUTIS)[Math.floor(Math.random() * (opcoes.length || SUTIS.length))];
+ RECENTES.push(g); if (RECENTES.length > 2) RECENTES.shift();
+ return g;
}
function proximo() {
@@ -476,7 +498,22 @@ function proximo() {
r.kf_t = 0;
}
+const ESPIRAL = { ate: 0 }; // tonto: espiral no lugar dos olhos até este instante
+const espiralAtiva = () => performance.now() / 1000 < ESPIRAL.ate;
+function caminhoEspiral(cx, cy, R, lado) {
+ const voltas = 2.75, giro = performance.now() / 1000 * 7 * lado;
+ let d = "M " + cx + "," + cy;
+ for (let th = 0.22; th <= voltas * Math.PI * 2; th += 0.22) {
+ const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
+ d += " L " + (cx + Math.cos(a) * rr).toFixed(1) + "," + (cy + Math.sin(a) * rr).toFixed(1);
+ }
+ return d;
+}
+
function animar(nome) {
+ if (nome === "tonto") ESPIRAL.ate = performance.now() / 1000 + 2.1;
+ // mãos (esconder, espiar, acenar, pensar, bocejar) valem também quando o gesto vem sozinho
+ if (SEQS[nome] && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(nome);
if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo();
@@ -502,6 +539,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null;
+const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
function atividade(nome, segundos) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
@@ -523,9 +561,18 @@ function repouso(t) { // pose calma: olha devagar, "respira
r.boca = "neutra";
r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
- // o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
- r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
- r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
+ // olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
+ // centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
+ if (t >= OLHAR.prox || t < OLHAR.prox - 10) {
+ const longe = Math.random() < 0.25;
+ OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
+ OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
+ OLHAR.prox = t + 1.2 + Math.random() * 3;
+ }
+ OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
+ OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
+ r.dx = OLHAR.x + 1.0 * Math.sin(t * 0.9) + MOV.olharX;
+ r.dy = OLHAR.y + 0.8 * Math.sin(t * 1.3) + MOV.olharY;
if (ATIV) { // fazendo algo: olha para a atividade
const v = ATIV.v;
r.dx = r.dx * (1 - v) * 0.3 + ATIV.x * v;
@@ -555,7 +602,7 @@ function desenhar() {
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) {
window.MotorRosto.desenhar({
- estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
+ estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo, espiral: espiralAtiva(),
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
@@ -660,7 +707,6 @@ function trata(ordem) {
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
- else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
diff --git a/cerebro.py b/cerebro.py
index e15fb3f..e40a7d2 100644
--- a/cerebro.py
+++ b/cerebro.py
@@ -7,8 +7,19 @@ import time
from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json
-GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar')
+GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar',
+ 'espiar','bocejar','confuso')
EVENTOS=('som','movimento','saudacao','sozinho')
+# O Qwen pequeno ignora "varie" e repetia 'esconder' sem parar. Cada evento só oferece gestos que
+# fazem sentido (barulho: susto ou olhar; movimento: olhar e acenar) e os dois últimos ficam de fora.
+POR_EVENTO={
+ 'som':('surpreso','olhar_esq','olhar_dir','pensar','piscar','confuso'),
+ 'movimento':('olhar_esq','olhar_dir','acenar','feliz','piscadinha','surpreso','espiar'),
+ 'saudacao':('acenar','feliz','coracao','piscadinha'),
+ 'sozinho':('pensar','olhar_baixo','olhar_esq','olhar_dir','bocejar','piscar'),
+}
+INTERVALO=45 # s mínimos entre duas reações da IA
+INTERVALO_SOZINHO=180 # s sem nada acontecendo até reagir a "sozinho"
def validar_intencao(data):
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
raise ValueError('Intenção fora da lista de expressões')
@@ -24,6 +35,7 @@ class Cerebro:
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
self.publicar=lambda tipo,dados:None
+ self.vida_ligada=lambda:False # com o modo vida ligado, ficar sozinho é com ele
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start()
@@ -47,13 +59,18 @@ class Cerebro:
if self.busy or self.pending or self.conversando:raise ValueError('IA processando; aguarde')
self.pending=event
return dict(ok=True)
+ def opcoes(self,event):
+ recentes=set(self.history[-2:])
+ return [g for g in POR_EVENTO.get(event,GESTOS) if g not in recentes] or list(POR_EVENTO.get(event,GESTOS))
def decide(self,event):
- payload=dict(model='local',messages=[dict(role='system',content='Você é Carinha, um pequeno robô curioso, gentil e expressivo, inspirado em WALL-E. Escolha uma expressão adequada ao evento. Varie os gestos recentes. Responda somente JSON.'),dict(role='user',content=json.dumps(dict(evento=event,recentes=self.history[-3:]),ensure_ascii=False)+' /no_think')],max_tokens=32,temperature=.7,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='intencao',strict=True,schema=dict(type='object',properties=dict(gesto=dict(type='string',enum=list(GESTOS))),required=['gesto'],additionalProperties=False))))
+ opcoes=self.opcoes(event)
+ payload=dict(model='local',messages=[dict(role='system',content='Você é Carinha, um pequeno robô curioso, gentil e expressivo, inspirado em WALL-E. Escolha uma expressão adequada ao evento. Responda somente JSON.'),dict(role='user',content=json.dumps(dict(evento=event),ensure_ascii=False)+' /no_think')],max_tokens=32,temperature=.9,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='intencao',strict=True,schema=dict(type='object',properties=dict(gesto=dict(type='string',enum=opcoes)),required=['gesto'],additionalProperties=False))))
request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cabecalhos())
start=time.monotonic()
with urlopen(request,timeout=90) as response:data=json.load(response)
content=data['choices'][0]['message']['content']
gesto=validar_intencao(json.loads(content))
+ if gesto not in opcoes:raise ValueError('Gesto repetido ou fora do evento')
return dict(evento=event,gesto=gesto,segundos=round(time.monotonic()-start,2),horario=time.time())
def loop(self):
while True:
@@ -65,10 +82,12 @@ class Cerebro:
with self.controle.lock:
online=time.monotonic()-self.controle.status_at<5
state=dict(self.controle.estado)
- if not event and online and not state.get('dormindo') and time.monotonic()-self.last>20:
+ # ocupado com uma atividade (lendo, jogando...): não interrompe com gesto
+ livre=online and not state.get('dormindo') and not state.get('atividade')
+ if not event and livre and time.monotonic()-self.last>INTERVALO:
if state.get('microfone_ativo') and state.get('microfone',0)>.35:event='som'
elif state.get('camera_ativa') and state.get('movimento',0)>.05:event='movimento'
- elif time.monotonic()-self.last>60:event='sozinho'
+ elif time.monotonic()-self.last>INTERVALO_SOZINHO and not self.vida_ligada():event='sozinho'
if not event:continue
with self.lock:
if self.conversando:continue
@@ -79,7 +98,7 @@ class Cerebro:
if self.ativo and generation==self.generation:
self.result=result;self.error='';self.history.append(result['gesto']);self.history=self.history[-6:]
with self.controle.lock:
- if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo'):
+ if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo') and not self.controle.estado.get('atividade'):
self.enviar(result['gesto'])
except Exception as e:
with self.lock:self.error=str(e)[:200]
diff --git a/kiosk-server.py b/kiosk-server.py
index 3bfa3f6..5e9028e 100644
--- a/kiosk-server.py
+++ b/kiosk-server.py
@@ -521,6 +521,7 @@ def main():
vida.celular_atividade = celular_atividade
vida.alguem_perto = alguem_perto
vida.madrugada = lambda agora: madrugada(servidor.controle.config, agora)
+ servidor.cerebro.vida_ligada = lambda: vida.config['ligado']
# E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada
vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
diff --git a/rosto-motor.js b/rosto-motor.js
index d17a361..d80c85b 100644
--- a/rosto-motor.js
+++ b/rosto-motor.js
@@ -107,7 +107,20 @@
}
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
+ // Tonto: uma espiral girando no lugar de cada olho (cada um gira para um lado, como no emoji 😵💫).
+ function espiral(c, o, cx, cy, R, lado) {
+ const voltas = 2.75, giro = (o.t || 0) * 7 * lado;
+ c.save(); c.strokeStyle = css(o.cor); c.lineWidth = Math.max(2, R * 0.17); c.lineCap = 'round';
+ c.beginPath();
+ for (let th = 0; th <= voltas * Math.PI * 2; th += 0.22) {
+ const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
+ if (th) c.lineTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr); else c.moveTo(cx, cy);
+ }
+ c.stroke(); c.restore();
+ }
+
function olhoPlano(c, o, p, est, e) {
+ if (o.espiral) { espiral(c, o, p.cx, p.cy, Math.min(est.w, est.h) * p.ry * 0.5, p.lado || 1); return; }
const respira = 1 + 0.012 * Math.sin(o.t * 1.4);
const temConteudo = !!(o.icone || o.desenho);
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 };
@@ -162,6 +175,7 @@
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) {
+ if (o.espiral) { espiral(c, o, p.cx, p.cy, est.w * p.ry * 0.5, p.lado || 1); return; }
const R = est.w * p.ry * 0.5 * Math.max(1, p.ab) * (1 + 0.01 * Math.sin(o.t * 1.4));
const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save();
@@ -289,7 +303,7 @@
// ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
// de rosto de mesmo nome no carinha.html.
- const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
+ const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8, bocejar: 2.1 };
const maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
@@ -387,6 +401,16 @@
const y = fora + (y0 - fora) * v;
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
+ } else if (maos.modo === 'bocejar') {
+ // mão na frente da boca, com tapinhas, e os Zzz subindo do olho
+ const ida = voltinha(k / 0.25), volta = faixaDe(k, 0.78, 1);
+ const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
+ const bx = (e.cx + d.cx) / 2 + e.ry * 0.12, by = e.cy + e.ry * 1.2;
+ const tapa = k > 0.3 && k < 0.75 ? Math.abs(Math.sin((k - 0.3) * Math.PI * 6)) * s * 0.12 : 0;
+ mao(c, o, bx + e.ry * 0.5 * (1 - Math.min(1, v)), fora + (by + s * 0.55 - fora) * v + tapa, s * 0.9, -0.22, -1, 0.15, 0, false, true, true);
+ c.save(); c.globalAlpha *= Math.min(1, v);
+ desenharZzz(c, Object.assign({}, o, { dormindo: true }), olhos, 0.8);
+ c.restore();
} else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
diff --git a/tests/test_cerebro.py b/tests/test_cerebro.py
index a05ebfe..741d367 100644
--- a/tests/test_cerebro.py
+++ b/tests/test_cerebro.py
@@ -1,8 +1,25 @@
import unittest
-from cerebro import validar_intencao
+from cerebro import POR_EVENTO, Cerebro, validar_intencao
+
+
class Tests(unittest.TestCase):
def test_only_allowlisted_expression(self):
self.assertEqual(validar_intencao({'gesto':'feliz'}),'feliz')
for data in [{'gesto':'motor 2500'},{'gesto':'feliz','pulso':2500},[],{'gesto':'dormir'}]:
with self.assertRaises(ValueError):validar_intencao(data)
+
+ def test_opcoes_por_evento_sem_repetir(self):
+ c = Cerebro.__new__(Cerebro) # sem thread nem arquivo: só a escolha de opções
+ c.history = ['surpreso', 'olhar_esq']
+ opcoes = c.opcoes('som')
+ self.assertNotIn('surpreso', opcoes) # os dois últimos ficam de fora
+ self.assertNotIn('olhar_esq', opcoes)
+ self.assertNotIn('esconder', opcoes) # barulho não vira "esconder o rosto"
+ self.assertTrue(set(opcoes) <= set(POR_EVENTO['som']))
+ for evento, lista in POR_EVENTO.items():
+ for g in lista:
+ self.assertEqual(validar_intencao({'gesto': g}), g)
+ self.assertNotIn('acenar', POR_EVENTO['sozinho']) # sozinho: nada de gesto "para alguém"
+
+
if __name__=='__main__':unittest.main()
diff --git a/tests/test_vida.py b/tests/test_vida.py
index b86ed1f..5ce7976 100644
--- a/tests/test_vida.py
+++ b/tests/test_vida.py
@@ -159,6 +159,19 @@ class VidaTest(unittest.TestCase):
self.v.dormindo_proprio = True # dormindo, sem cara feia
self.assertEqual(self.v.zanga(), 0.0)
+ def test_sozinho_vive_a_vida_dele(self):
+ self.v.celular_online = lambda: True
+ self.v.alguem_perto = lambda: False
+ self.v.visto_em = DIA - 10 * 60 # ninguém há 10 min
+ self.v.necessidades.update(energia=80, alegria=40, social=10, diversao=80) # carente
+ self.v.rng.random = lambda: 0.99 # sorteios de atividade: não
+ for i in range(20):
+ gesto = self.v.agir(DIA + i * 100)
+ self.assertNotIn(gesto, ('acenar', 'coracao', 'piscadinha', 'timido', 'esconder', 'espiar'))
+ self.v.rng.random = lambda: 0.0 # sorteio: sim
+ self.v.atividade_em = 0
+ self.assertTrue(str(self.v.agir(DIA + 3000)).startswith('atividade ')) # sem plateia: vai fazer algo
+
def test_horario_da_madrugada(self):
from controle_pc import madrugada
h = lambda hora: time.mktime((2026, 10, 6, hora, 10, 0, 0, 0, -1))
diff --git a/vida.py b/vida.py
index 463822f..ef5f8bc 100644
--- a/vida.py
+++ b/vida.py
@@ -64,9 +64,10 @@ ATOS = {
('musica', 1, 'Cantou para passar o tempo'), ('bocejar', 2, 'Bocejou de tédio'),
('confuso', 1, 'Ficou confuso com alguma coisa')],
'carente': [('triste', 2, 'Sentiu falta de alguém'), ('olhar_baixo', 2, 'Ficou cabisbaixo'),
- ('esconder', 1, 'Escondeu o rosto')],
+ ('pensar', 1, 'Ficou pensando em alguém')],
'carente_perto': [('acenar', 3, 'Acenou chamando atenção'), ('espiar', 2, 'Espiou quem está aí'),
- ('coracao', 2, 'Pediu carinho'), ('surpreso', 1, 'Fez cara de "oi!"'), ('timido', 1, 'Ficou tímido')],
+ ('coracao', 2, 'Pediu carinho'), ('surpreso', 1, 'Fez cara de "oi!"'), ('timido', 1, 'Ficou tímido'),
+ ('esconder', 1, 'Escondeu o rosto, envergonhado')],
'triste': [('triste', 3, 'Ficou tristinho'), ('olhar_baixo', 2, 'Olhou para baixo'), ('pensar', 1, 'Ficou pensativo')],
'bravo': [('bravo', 3, 'Ficou emburrado'), ('olhar_baixo', 2, 'Bufou de mau humor'), ('confuso', 1, 'Resmungou sozinho')],
'cansado': [('bocejar', 3, 'Bocejou'), ('piscar2', 1, 'Piscou devagar'), ('olhar_baixo', 2, 'Cochilou de olho aberto'),
@@ -78,6 +79,8 @@ ATOS = {
# respeitado (som, movimento e PIR não acordam) até DESCANSADO; alegria caindo: o rosto vai ficando
# bravo (zanga); diversão < TEDIO: vai fazer uma atividade por alguns minutos.
COCHILO, SONO, DESCANSADO, TEDIO = 40, 20, 95, 45
+SOZINHO = 3 * 60 # s sem ninguém por perto: ele vive a vida dele (mais atividades, nada de gesto "para alguém")
+PARA_ALGUEM = {'acenar', 'coracao', 'piscadinha', 'timido', 'esconder', 'espiar', 'surpreso'}
ATIVAS = ['bola', 'malhar', 'corda', 'skate', 'dancar', 'pipa'] # gastam energia
CALMAS = ['ler', 'cafe', 'meditar', 'telescopio', 'pescar', 'computador', 'regar'] # quase não gastam
CRIATIVAS = ['pintar', 'violao', 'cantar', 'cozinhar', 'bolhas', 'videogame']
@@ -133,6 +136,7 @@ class Vida:
self.atividade_em = 0.0 # quando começou a última atividade
self.ultima_atividade = None
self.zanga_enviada = None
+ self.visto_em = time.time() # última vez que alguém esteve por perto
self.bloqueado = lambda: False # E-Stop ou modo presença com ninguém em casa
self.pode_gemini = lambda: bool(os.environ.get('GEMINI_API_KEY'))
self.madrugada = self.noite_padrao # o main troca pelo horário dos ajustes
@@ -460,6 +464,12 @@ class Vida:
# entediado: vai fazer alguma coisa por alguns minutos (pode ser interrompido)
if dia and folga and n['diversao'] < TEDIO and self.rng.random() < 0.75:
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(120, 300))
+ # ninguém olhando há um tempo: vai cuidar da vida dele (ler, pintar, música...)
+ if perto:
+ self.visto_em = agora
+ sozinho = agora - self.visto_em > SOZINHO
+ if dia and folga and sozinho and self.rng.random() < 0.4:
+ return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(60, 180))
# bem: de vez em quando, uma atividade curtinha por vontade própria
if dia and folga and humor in ('feliz', 'tranquilo') and self.rng.random() < 0.1:
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(40, 90))
@@ -474,6 +484,8 @@ class Vida:
n['diversao'] = _limitar(n['diversao'] + 8)
return 'reacao'
lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo'])
+ if sozinho: # sem plateia: nada de acenar ou ficar tímido
+ lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
gesto, texto = self.escolher(lista)
self.enfileirar(gesto)
if humor == 'entediado':
diff --git a/web/src/lib/guia.ts b/web/src/lib/guia.ts
index 974759f..f5ceedd 100644
--- a/web/src/lib/guia.ts
+++ b/web/src/lib/guia.ts
@@ -142,8 +142,8 @@ export const TOPICOS: Record = {
{ emoji: '🥳', nome: 'Comemorar', texto: 'Pula trocando de cor.' },
{ emoji: '😳', nome: 'Tímido', texto: 'Olha para baixo e para o lado, cor-de-rosa.' },
{ emoji: '😕', nome: 'Confuso', texto: 'Um olho meio fechado e a cabeça indo de um lado para o outro.' },
- { emoji: '🥱', nome: 'Bocejar', texto: 'Olhos pesados e boca aberta. Aparece sozinho quando ele está cansado ou entediado.' },
- { emoji: '😵💫', nome: 'Tonto', texto: 'Os olhos dão voltas.' },
+ { emoji: '🥱', nome: 'Bocejar', texto: 'Olhos pesados, a mão na frente da boca e Zzz subindo. Aparece sozinho quando ele está cansado ou entediado.' },
+ { emoji: '😵💫', nome: 'Tonto', texto: 'Espirais girando no lugar dos olhos e boca de "ó".' },
{ emoji: '⌨️', nome: 'Teclas 1 a 9', texto: 'Disparam os nove primeiros favoritos.' },
{ emoji: '🔍', nome: 'Ctrl+K', texto: 'Busca qualquer gesto, rosto, cor ou tela pelo nome.' },
],
@@ -183,7 +183,7 @@ export const TOPICOS: Record = {
titulo: 'IA local (Qwen)',
emoji: '🧠',
onde: 'Ao vivo › Personalidade',
- resumo: 'Com "Reagir sozinho com IA local" ligado, o Qwen escolhe uma expressão a partir dos sons e movimentos que o celular percebe. Roda sem internet e sem custo.',
+ resumo: 'Com "Reagir sozinho com IA local" ligado, o Qwen escolhe uma expressão a partir dos sons e movimentos que o celular percebe, no máximo a cada 45 s. Cada evento só tem gestos que combinam (barulho: susto ou olhar para o lado; movimento: olhar e acenar) e ele nunca repete os dois últimos. Não interrompe atividades e, com o modo vida ligado, ficar sozinho é com o modo vida. Roda sem internet e sem custo.',
},
acontecimentos: {
titulo: 'Acontecimentos',