fix(rosto): parado mais humano; tonto com espirais e bocejo com a mão

Parado ele vivia escondendo o rosto: a IA local (Qwen 0.6B) reagia a cada
som ou "sozinho" a cada 20-60 s e repetia 'esconder' apesar do "varie".

- IA local: cada evento só oferece gestos que combinam (barulho: susto ou
  olhar; movimento: olhar e acenar), os dois últimos ficam fora do enum,
  no máximo a cada 45 s, não interrompe atividade e, com o modo vida
  ligado, não reage a "sozinho".
- Sem ninguém olhando (sem movimento/PIR há 3 min) nada de gesto "para
  alguém" (acenar, tímido, esconder...): ele vive a vida dele e começa
  atividades com mais frequência.
- Gestos soltos no celular: quase sempre sutis (olhar de lado, piscar
  devagar), gesto grande raro e nunca repetido em seguida.
- Olhar em repouso com fixações e sacadas em vez do vaivém contínuo.
- Tonto: espirais girando no lugar dos olhos (todos os rostos, inclusive
  Matriz) e boca de "ó". Bocejo: mão na frente da boca com tapinhas e
  Zzz subindo. As mãos agora aparecem também quando o gesto vem sozinho.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 21:38:32 -03:00
co-authored by Claude Opus 5.5
parent e73897d9c1
commit ff06f02705
10 changed files with 235 additions and 33 deletions
+56 -10
View File
@@ -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.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(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) { if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||''; forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy); 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 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), 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]]; [{ 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]]; [{ 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], 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]]; [{ 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 balanca = Array.from({ length: 8 }, (_, i) => [{ dx: i % 2 ? -5 : 5, dy: i % 2 ? 2 : -2 }, 0.2]);
const tonto = [[{ esc: 0.94, boca: "aberta" }, 0.15], ...roda, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]]; 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], 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]]; [{ 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], 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 (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; } 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() { function gestoSolto() {
const lista = LIVRES_HUMOR[HUMOR] || LIVRES; const grande = Math.random() < ({ feliz: 0.3, entediado: 0.25, bravo: 0.3 }[HUMOR] || 0.18);
return lista[Math.floor(Math.random() * lista.length)]; 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() { function proximo() {
@@ -476,7 +498,22 @@ function proximo() {
r.kf_t = 0; 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) { 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]) { if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]); r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo(); 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. */ /* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null; 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) { function atividade(nome, segundos) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return; if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); 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.boca = "neutra";
r.cor = CIANO.slice(); r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0; 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) // olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX; // centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY; 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 if (ATIV) { // fazendo algo: olha para a atividade
const v = ATIV.v; const v = ATIV.v;
r.dx = r.dx * (1 - v) * 0.3 + ATIV.x * 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" : ""; }); OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) { if (comMotor) {
window.MotorRosto.desenhar({ 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, icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null, carga: window.carinhaBateria || null,
iris: EXTRAS.iris, iris: EXTRAS.iris,
@@ -660,7 +707,6 @@ function trata(ordem) {
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) { if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 }); if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
} }
animar(ordem); animar(ordem);
} else if (ordem === "calma") { } else if (ordem === "calma") {
+25 -1
View File
@@ -107,7 +107,20 @@
} }
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes). // 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) { 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 respira = 1 + 0.012 * Math.sin(o.t * 1.4);
const temConteudo = !!(o.icone || o.desenho); const temConteudo = !!(o.icone || o.desenho);
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 }; 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. // Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) { 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 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))); const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save(); c.save();
@@ -289,7 +303,7 @@
// ------------------------------------------------------------------ mãos // ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências // 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. // 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 maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); }; 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 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 y = fora + (y0 - fora) * v;
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0; 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); 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') { } else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1); const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
+56 -10
View File
@@ -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.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(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) { if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||''; forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy); 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 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), 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]]; [{ 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]]; [{ 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], 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]]; [{ 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 balanca = Array.from({ length: 8 }, (_, i) => [{ dx: i % 2 ? -5 : 5, dy: i % 2 ? 2 : -2 }, 0.2]);
const tonto = [[{ esc: 0.94, boca: "aberta" }, 0.15], ...roda, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]]; 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], 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]]; [{ 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], 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 (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; } 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() { function gestoSolto() {
const lista = LIVRES_HUMOR[HUMOR] || LIVRES; const grande = Math.random() < ({ feliz: 0.3, entediado: 0.25, bravo: 0.3 }[HUMOR] || 0.18);
return lista[Math.floor(Math.random() * lista.length)]; 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() { function proximo() {
@@ -476,7 +498,22 @@ function proximo() {
r.kf_t = 0; 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) { 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]) { if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]); r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo(); 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. */ /* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
let ATIV = null; 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) { function atividade(nome, segundos) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return; if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); 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.boca = "neutra";
r.cor = CIANO.slice(); r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0; 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) // olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX; // centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY; 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 if (ATIV) { // fazendo algo: olha para a atividade
const v = ATIV.v; const v = ATIV.v;
r.dx = r.dx * (1 - v) * 0.3 + ATIV.x * 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" : ""; }); OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) { if (comMotor) {
window.MotorRosto.desenhar({ 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, icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null, carga: window.carinhaBateria || null,
iris: EXTRAS.iris, iris: EXTRAS.iris,
@@ -660,7 +707,6 @@ function trata(ordem) {
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) { if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 }); if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
} }
animar(ordem); animar(ordem);
} else if (ordem === "calma") { } else if (ordem === "calma") {
+24 -5
View File
@@ -7,8 +7,19 @@ import time
from urllib.request import Request,urlopen from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json 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') 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): def validar_intencao(data):
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS: 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') 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.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.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
self.publicar=lambda tipo,dados:None 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 try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start() 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') if self.busy or self.pending or self.conversando:raise ValueError('IA processando; aguarde')
self.pending=event self.pending=event
return dict(ok=True) 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): 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()) request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cabecalhos())
start=time.monotonic() start=time.monotonic()
with urlopen(request,timeout=90) as response:data=json.load(response) with urlopen(request,timeout=90) as response:data=json.load(response)
content=data['choices'][0]['message']['content'] content=data['choices'][0]['message']['content']
gesto=validar_intencao(json.loads(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()) return dict(evento=event,gesto=gesto,segundos=round(time.monotonic()-start,2),horario=time.time())
def loop(self): def loop(self):
while True: while True:
@@ -65,10 +82,12 @@ class Cerebro:
with self.controle.lock: with self.controle.lock:
online=time.monotonic()-self.controle.status_at<5 online=time.monotonic()-self.controle.status_at<5
state=dict(self.controle.estado) 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' 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 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 if not event:continue
with self.lock: with self.lock:
if self.conversando:continue if self.conversando:continue
@@ -79,7 +98,7 @@ class Cerebro:
if self.ativo and generation==self.generation: if self.ativo and generation==self.generation:
self.result=result;self.error='';self.history.append(result['gesto']);self.history=self.history[-6:] self.result=result;self.error='';self.history.append(result['gesto']);self.history=self.history[-6:]
with self.controle.lock: 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']) self.enviar(result['gesto'])
except Exception as e: except Exception as e:
with self.lock:self.error=str(e)[:200] with self.lock:self.error=str(e)[:200]
+1
View File
@@ -521,6 +521,7 @@ def main():
vida.celular_atividade = celular_atividade vida.celular_atividade = celular_atividade
vida.alguem_perto = alguem_perto vida.alguem_perto = alguem_perto
vida.madrugada = lambda agora: madrugada(servidor.controle.config, agora) 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 # 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" vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
+25 -1
View File
@@ -107,7 +107,20 @@
} }
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes). // 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) { 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 respira = 1 + 0.012 * Math.sin(o.t * 1.4);
const temConteudo = !!(o.icone || o.desenho); const temConteudo = !!(o.icone || o.desenho);
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 }; 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. // Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) { 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 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))); const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save(); c.save();
@@ -289,7 +303,7 @@
// ------------------------------------------------------------------ mãos // ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências // 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. // 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 maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); }; 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 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 y = fora + (y0 - fora) * v;
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0; 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); 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') { } else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1); const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
+18 -1
View File
@@ -1,8 +1,25 @@
import unittest import unittest
from cerebro import validar_intencao from cerebro import POR_EVENTO, Cerebro, validar_intencao
class Tests(unittest.TestCase): class Tests(unittest.TestCase):
def test_only_allowlisted_expression(self): def test_only_allowlisted_expression(self):
self.assertEqual(validar_intencao({'gesto':'feliz'}),'feliz') self.assertEqual(validar_intencao({'gesto':'feliz'}),'feliz')
for data in [{'gesto':'motor 2500'},{'gesto':'feliz','pulso':2500},[],{'gesto':'dormir'}]: for data in [{'gesto':'motor 2500'},{'gesto':'feliz','pulso':2500},[],{'gesto':'dormir'}]:
with self.assertRaises(ValueError):validar_intencao(data) 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() if __name__=='__main__':unittest.main()
+13
View File
@@ -159,6 +159,19 @@ class VidaTest(unittest.TestCase):
self.v.dormindo_proprio = True # dormindo, sem cara feia self.v.dormindo_proprio = True # dormindo, sem cara feia
self.assertEqual(self.v.zanga(), 0.0) 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): def test_horario_da_madrugada(self):
from controle_pc import madrugada from controle_pc import madrugada
h = lambda hora: time.mktime((2026, 10, 6, hora, 10, 0, 0, 0, -1)) h = lambda hora: time.mktime((2026, 10, 6, hora, 10, 0, 0, 0, -1))
+14 -2
View File
@@ -64,9 +64,10 @@ ATOS = {
('musica', 1, 'Cantou para passar o tempo'), ('bocejar', 2, 'Bocejou de tédio'), ('musica', 1, 'Cantou para passar o tempo'), ('bocejar', 2, 'Bocejou de tédio'),
('confuso', 1, 'Ficou confuso com alguma coisa')], ('confuso', 1, 'Ficou confuso com alguma coisa')],
'carente': [('triste', 2, 'Sentiu falta de alguém'), ('olhar_baixo', 2, 'Ficou cabisbaixo'), '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í'), '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')], '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')], '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'), '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 # 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. # bravo (zanga); diversão < TEDIO: vai fazer uma atividade por alguns minutos.
COCHILO, SONO, DESCANSADO, TEDIO = 40, 20, 95, 45 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 ATIVAS = ['bola', 'malhar', 'corda', 'skate', 'dancar', 'pipa'] # gastam energia
CALMAS = ['ler', 'cafe', 'meditar', 'telescopio', 'pescar', 'computador', 'regar'] # quase não gastam CALMAS = ['ler', 'cafe', 'meditar', 'telescopio', 'pescar', 'computador', 'regar'] # quase não gastam
CRIATIVAS = ['pintar', 'violao', 'cantar', 'cozinhar', 'bolhas', 'videogame'] 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.atividade_em = 0.0 # quando começou a última atividade
self.ultima_atividade = None self.ultima_atividade = None
self.zanga_enviada = 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.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.pode_gemini = lambda: bool(os.environ.get('GEMINI_API_KEY'))
self.madrugada = self.noite_padrao # o main troca pelo horário dos ajustes 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) # 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: 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)) 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 # 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: 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)) 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) n['diversao'] = _limitar(n['diversao'] + 8)
return 'reacao' return 'reacao'
lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo']) 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) gesto, texto = self.escolher(lista)
self.enfileirar(gesto) self.enfileirar(gesto)
if humor == 'entediado': if humor == 'entediado':
+3 -3
View File
@@ -142,8 +142,8 @@ export const TOPICOS: Record<string, TopicoGuia> = {
{ emoji: '🥳', nome: 'Comemorar', texto: 'Pula trocando de cor.' }, { 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: '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: '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: '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: 'Os olhos dão voltas.' }, { 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: 'Teclas 1 a 9', texto: 'Disparam os nove primeiros favoritos.' },
{ emoji: '🔍', nome: 'Ctrl+K', texto: 'Busca qualquer gesto, rosto, cor ou tela pelo nome.' }, { emoji: '🔍', nome: 'Ctrl+K', texto: 'Busca qualquer gesto, rosto, cor ou tela pelo nome.' },
], ],
@@ -183,7 +183,7 @@ export const TOPICOS: Record<string, TopicoGuia> = {
titulo: 'IA local (Qwen)', titulo: 'IA local (Qwen)',
emoji: '🧠', emoji: '🧠',
onde: 'Ao vivo › Personalidade', 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: { acontecimentos: {
titulo: 'Acontecimentos', titulo: 'Acontecimentos',