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',