Files
carinha-control-panel/android/app/assets/carinha.html
T
julioandClaude Opus 5.5 4777ceb023 feat(vida): ideias do dia com inspiração da internet e "olha o que eu aprendi"
Todo dia, a partir das 9h, o Carinha cria 3 atividades novas (inspiracao.py):
- sinais de fontes abertas: tempo da cidade (Open-Meteo), datas
  comemorativas da semana (lista fixa + feriados da BrasilAPI), "neste dia"
  da Wikipédia e assuntos em alta no Google Trends Brasil;
- o Gemini escolhe ideias leves (descarta tragédia, política e afins) e
  cada uma vira atividade pelo estúdio (Criacao.gerar), com motivo e uma
  curiosidade;
- até 3 tentativas por dia, 30 min entre elas; botão "Criar mais agora";
- as atividades criadas entram na rotina do modo vida (as de hoje com 40%
  de chance) e, com alguém perto, ele conta a curiosidade: legenda e voz
  de robô (ordem "aprendi", até 2x por dia cada);
- painel: cartão "Ideias do dia" no estúdio (Mostrar, Contar, o que ele
  viu na internet, ajustes: quantas, horário, cidade, fontes) e tópico no
  guia.

Correções achadas no caminho:
- WebView de fábrica do S10 (Chrome 102) não tem AbortSignal.timeout: o
  celular nunca mandava o status e a placa o via offline;
- arquivos estáticos com Cache-Control no-cache e o app com LOAD_NO_CACHE:
  o WebView seguia com uma carinha.html velha depois de deploy;
- status do celular recusado vai para o journal (1 linha por minuto);
- legenda mais estreita para não cobrir a bateria.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:13:03 -03:00

1658 lines
83 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="pt-BR">
<meta charset="utf-8">
<title>Eilik</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
svg{display:block;width:100%;height:100%;position:relative;z-index:2}
/* atividade (momentos dele): a tela se divide. Deitado, o rosto vai para a esquerda e o bonequinho
aparece à direita; em pé, o rosto sobe e ele aparece embaixo. */
#tela,#rostoMotor{transition:transform .8s cubic-bezier(.2,.8,.2,1);transform-origin:50% 50%}
body.atividade #tela,body.atividade #rostoMotor{transform:translateX(-23%) scale(.74)}
@media (orientation:portrait){body.atividade #tela,body.atividade #rostoMotor{transform:translateY(-20%) scale(.8)}}
body.atividade #tela>rect{opacity:0} /* o fundo preto do SVG não pode tapar o bonequinho */
.corpo{fill-rule:evenodd}
.traco{fill:none;stroke-linecap:round}
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
#o1,#o2,#boca,#sobrancelha1,#sobrancelha2{color:#70cadd}
.iconeOlho,.figurinhaDinamica{animation:aparecerFigura .28s ease-out}
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
z-index:5;pointer-events:none;text-align:center;opacity:.82;font-family:system-ui,sans-serif;
animation:semServidorEntra 1.2s ease-out}
#semServidor svg{display:block;margin:0 auto;overflow:visible;filter:drop-shadow(0 0 3px currentColor)}
#semServidor svg.horizontal{display:none}
#semServidor svg.vertical{height:58vh;width:auto;max-width:100%}
#semServidor svg.vertical .olhinhos{animation-name:semServidorOlharCima}
@media (orientation:portrait){
body.semServidor #tela,body.semServidor #rostoMotor{transform:translateY(-20%) scale(.8)}
#semServidor{right:auto;top:auto;left:50%;bottom:3.5vh;transform:translateX(-50%);width:min(80vw,400px)}
#semServidor svg.horizontal{display:block;width:100%;height:auto}
#semServidor svg.vertical{display:none}
}
#semServidor .titulo{text-wrap:balance;font-weight:600;font-size:clamp(12px,3.2vh,20px);margin-top:.4em;letter-spacing:.02em}
#semServidor .sub{font-size:clamp(10px,2.4vh,15px);opacity:.6;margin-top:.25em;font-variant-numeric:tabular-nums}
#semServidor .placa rect,#semServidor .celular>rect{fill:none;stroke:currentColor;stroke-width:3}
#semServidor .chip{fill:currentColor;fill-opacity:.25}
#semServidor .pino{fill:currentColor;opacity:.7}
#semServidor .led{fill:#ff5a5a;animation:semServidorLed 1.6s steps(1) infinite}
#semServidor .cabo{stroke:currentColor;stroke-width:3;stroke-dasharray:5 6;opacity:.5;fill:none}
#semServidor .arco{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;opacity:0;
animation:semServidorArco 2.4s ease-out infinite}
#semServidor .a2{animation-delay:.3s}
#semServidor .a3{animation-delay:.6s}
#semServidor .xis path{stroke:#ff5a5a;stroke-width:5;stroke-linecap:round;fill:none}
#semServidor .xis{transform-box:fill-box;transform-origin:center;animation:semServidorXis 2.4s ease-in-out infinite}
#semServidor .olhinhos{fill:currentColor;transform-box:fill-box;transform-origin:center;
animation:semServidorOlhar 5s ease-in-out infinite}
#semServidor .boquinha{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round}
#semServidor .celular{transform-box:fill-box;transform-origin:50% 100%;animation:semServidorProcura 4.8s ease-in-out infinite}
#semServidor.cutucado .celular{animation:semServidorChacoalha .5s ease-in-out}
@keyframes semServidorEntra{from{opacity:0}to{opacity:.82}}
/* em pé no canto direito: o celularzinho olha para cima, para a placa */
@keyframes semServidorOlharCima{0%,30%{transform:translateY(-3px) scaleY(1)}34%{transform:translateY(-3px) scaleY(.1)}38%,60%{transform:translateY(-3px) scaleY(1)}
70%,90%{transform:translateY(1px) scaleY(1)}100%{transform:translateY(-3px)}}
@keyframes semServidorArco{0%{opacity:0}25%{opacity:.9}70%,100%{opacity:0}}
@keyframes semServidorXis{0%,55%,100%{transform:scale(1);opacity:.75}65%{transform:scale(1.25);opacity:1}80%{transform:scale(1);opacity:.85}}
@keyframes semServidorLed{0%{opacity:1}50%{opacity:.15}}
/* o celularzinho olha para a placa, olha de volta e pisca */
@keyframes semServidorOlhar{0%,30%{transform:translateX(-3px) scaleY(1)}34%{transform:translateX(-3px) scaleY(.1)}38%,60%{transform:translateX(-3px) scaleY(1)}
70%,90%{transform:translateX(1px) scaleY(1)}100%{transform:translateX(-3px)}}
@keyframes semServidorProcura{0%,100%{transform:rotate(0)}20%{transform:rotate(-5deg)}40%{transform:rotate(0)}}
@keyframes semServidorChacoalha{0%,100%{transform:rotate(0)}25%{transform:rotate(-9deg)}50%{transform:rotate(7deg)}75%{transform:rotate(-4deg)}}
@media(prefers-reduced-motion:reduce){#semServidor *,#semServidor{animation:none!important}#semServidor .arco{opacity:.6}}
</style>
<body>
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
<rect x="0" y="0" width="480" height="320" fill="rgb(0,0,0)"/>
<g id="o1"><path class="corpo"/><path class="traco"/><ellipse class="brilho" fill="rgb(245,250,255)"/></g>
<g id="o2"><path class="corpo"/><path class="traco"/><ellipse class="brilho" fill="rgb(245,250,255)"/></g>
<path id="boca" class="traco"/>
<path id="sobrancelha1" class="traco" style="display:none"/>
<path id="sobrancelha2" class="traco" style="display:none"/>
</svg>
<div id="fps"></div>
<script src="rosto-motor.js"></script>
<script src="voz-robo.js"></script>
<script>
// WebView de fábrica do S10 (Chrome 102) não tem AbortSignal.timeout (só no 103): sem isto o celular
// nunca mandava o status e a placa achava que ele estava offline.
if (window.AbortSignal && !AbortSignal.timeout) {
AbortSignal.timeout = (ms) => { const c = new AbortController(); setTimeout(() => c.abort(), ms); return c.signal; };
}
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
(function () {
const token = new URLSearchParams(location.search).get('t');
window.carinhaToken = token || '';
if (!token || !window.fetch) return;
const original = window.fetch.bind(window);
window.fetch = (recurso, opcoes = {}) => {
try {
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
if (url.origin === location.origin) {
const cabecalhos = new Headers(opcoes.headers || {});
cabecalhos.set('Authorization', 'Bearer ' + token);
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
}
} catch (e) { /* URL estranha: segue sem token */ }
return original(recurso, opcoes);
};
})();
"use strict";
/* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
Mesmas medidas, mesmas cores, mesmos gestos e o mesmo comportamento ocioso.
Roda em qualquer navegador, inclusive num kiosk (cage + Epiphany) na telinha.
------------------------------------------------------------------------- */
// Medidas de projeto (as mesmas do eilik.py) — a tela de verdade e 480x320.
const LW = 480, LH = 320;
const E1P = [150, 166], E2P = [330, 166]; // centros dos olhos
const RXP = 64, RYP = 76; // raio base
const BOCAP = [240, 284], BOCALP = 96; // centro e largura da boca
// A area util nem sempre e 480x320 (o navegador pode reservar uma barra de
// titulo), entao tudo e escalado junto: preenche a ALTURA mantendo a
// proporcao dos olhos, e as posicoes acompanham a largura e a altura.
let W = LW, H = LH, ESC = 1;
let E1 = E1P.slice(), E2 = E2P.slice(), RX = RXP, RY = RYP;
let BOCA = BOCAP.slice(), BOCA_L = BOCALP;
function ajustar() {
W = Math.max(80, window.innerWidth);
H = Math.max(60, window.innerHeight);
ESC = H / LH;
// O rosto fica CENTRADO e o afastamento dos olhos vem da altura (ESC), nao
// da largura: a cara mantem a mesma proporcao em qualquer tela — inclusive
// nas compridas, como a do Galaxy S10 (19:9). A sobra vira margem.
const meio = W / 2;
E1 = [meio - (LW / 2 - E1P[0]) * ESC, H * (E1P[1] / LH)];
E2 = [meio + (E2P[0] - LW / 2) * ESC, H * (E2P[1] / LH)];
RX = RXP * ESC;
RY = RYP * ESC;
BOCA = [W * (BOCAP[0] / LW), H * (BOCAP[1] / LH)];
BOCA_L = BOCALP * ESC;
const svg = document.getElementById("tela");
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
const fundo = svg.querySelector("rect");
fundo.setAttribute("width", W);
fundo.setAttribute("height", H);
}
ajustar();
addEventListener("resize", ajustar);
const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
function cuidarDaTela() {
const agora = performance.now();
if (!r.dormindo) SONO.desde = 0;
else if (!SONO.desde) SONO.desde = agora;
const apagar = SONO.apagar && r.dormindo && !window.carinhaSemServidor && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
document.body.style.visibility = apagar ? "hidden" : "";
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
}
}
setInterval(cuidarDaTela, 1000);
const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78];
const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255];
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
const VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255];
// -------------------------------------------------------------------- rostos
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = {
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
};
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
let FORMATO = "padrao";
let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME];
// ------------------------------------------------------------------ utilidades
const suave = (x) => x * x * x * (x * (x * 6 - 15) + 10); // smoothstep
const lerp = (a, b, e) => a + (b - a) * e;
const lerpCor = (a, b, e) => [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * e);
const rgb = (c) => "rgb(" + c.map((v) => Math.round(v)).join(",") + ")";
// ------------------------------------------------------------------ geometria
function caminhoElipse(cx, cy, rx, ry) {
return "M " + (cx - rx) + "," + cy +
" a " + rx + "," + ry + " 0 1 0 " + (2 * rx) + ",0" +
" a " + rx + "," + ry + " 0 1 0 " + (-2 * rx) + ",0 Z";
}
function caminhoArco(cx, cy, rx, ry) { // 196 -> 344 graus ( "^" )
const a0 = 196 * Math.PI / 180, a1 = 344 * Math.PI / 180;
const x0 = cx + rx * Math.cos(a0), y0 = cy + ry * Math.sin(a0);
const x1 = cx + rx * Math.cos(a1), y1 = cy + ry * Math.sin(a1);
return "M " + x0 + "," + y0 + " A " + rx + "," + ry + " 0 0 1 " + x1 + "," + y1;
}
function caminhoCoracao(cx, cy, s) {
return "M " + cx + "," + (cy + s * 0.72) +
" C " + (cx - s * 1.30) + "," + (cy - s * 0.30) + " " +
(cx - s * 0.80) + "," + (cy - s * 1.18) + " " +
cx + "," + (cy - s * 0.30) +
" C " + (cx + s * 0.80) + "," + (cy - s * 1.18) + " " +
(cx + s * 1.30) + "," + (cy - s * 0.30) + " " +
cx + "," + (cy + s * 0.72) + " Z";
}
function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
}
/* formas da boca (o humor escolhe qual) */
function deslocamentoBoca() {
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
}
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
function metadeOlho(esc) {
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
return m * RY * esc;
}
function caminhoBoca(tipo) {
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry);
}
if (tipo === "brava") { // canto para baixo
return "M " + (cx - meia) + "," + (cy + l * 0.16) +
" Q " + cx + "," + (cy - l * 0.26) + " " + (cx + meia) + "," + (cy + l * 0.16);
}
if (tipo === "triste") {
return "M " + (cx - l * 0.40) + "," + (cy + l * 0.16) +
" Q " + cx + "," + (cy - l * 0.20) + " " + (cx + l * 0.40) + "," + (cy + l * 0.16);
}
if (tipo === "sono") { // dormindo: um tracinho torto
return "M " + (cx - l * 0.24) + "," + cy +
" q " + (l * 0.24) + "," + (l * 0.16) + " " + (l * 0.48) + ",0";
}
if (tipo === "sorriso") { // feliz: sorriso grande
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
}
// repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
const f = FEICAO.atual, w = l * 0.30, mx = cx + f.dx * l * 0.10;
const yE = cy - Math.max(0, f.lado) * l * 0.12, yD = cy - Math.max(0, -f.lado) * l * 0.12;
const curva = l * (0.18 + f.curva * 0.35 - ZANGA * 0.3);
let d = "M " + (mx - w) + "," + yE + " Q " + mx + "," + (cy + curva - f.abre * l * 0.12) + " " + (mx + w) + "," + yD;
if (f.abre > 0.04) d += " Q " + mx + "," + (cy + curva + f.abre * l * 0.30) + " " + (mx - w) + "," + yE;
return d;
}
/* Sobrancelha i (0 = esquerda): humor do gesto + microexpressão + desce um pouco ao piscar. */
function caminhoSobrancelha(i, esc) {
const c = i === 0 ? E1 : E2, lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, f = FEICAO.atual;
const ab = i === 0 ? r.ab : (r.ab2 === null ? r.ab : r.ab2);
const piscando = 1 - Math.min(1, Math.max(0, ab));
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC - (f.sob[i] - piscando * 3) * ESC;
const base = r.forma === 'bravo' ? 18 : r.forma === 'triste' ? -14 : ZANGA * 14; // zanga franze a testa
const inc = (base + f.inc[i]) * lado * ESC;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
return 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele);
}
// ------------------------------------------------------------------ desenho
const OLHOS = [document.getElementById("o1"), document.getElementById("o2")];
const PE = OLHOS.map((g) => ({
corpo: g.querySelector(".corpo"),
traco: g.querySelector(".traco"),
brilho: g.querySelector(".brilho"),
}));
function esconderTudo(p) {
p.corpo.style.display = "none";
p.traco.style.display = "none";
p.brilho.style.display = "none";
}
const elBoca = document.getElementById("boca");
/* A boca so aparece nos rostos que tem boca; a forma segue o humor. */
function desenharBoca(tipo) {
if (!EXTRAS.boca) {
elBoca.style.display = "none";
return;
}
const forma = tipo || r.boca;
elBoca.setAttribute("d", caminhoBoca(forma));
elBoca.setAttribute("stroke-width", BOCA_L * 0.15);
elBoca.setAttribute("stroke", rgb(r.cor));
elBoca.style.color=rgb(r.cor);
elBoca.setAttribute("fill", forma === "aberta" ? rgb(r.cor) : "none");
elBoca.style.display = "";
}
/* Desenha um olho igual ao _olho() do eilik.py. */
function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
const p = PE[indice];
document.getElementById('o'+(indice+1)).style.color=rgb(cor);
esconderTudo(p);
if (!p.icone) {
p.icone = document.createElementNS('http://www.w3.org/2000/svg','text');
p.icone.setAttribute('class','iconeOlho');p.icone.setAttribute('text-anchor','middle');
p.icone.setAttribute('dominant-baseline','central');document.getElementById('o'+(indice+1)).append(p.icone);
}
const desenho = !r.dormindo && window.carinhaOlhosDesenho;
const icone = !r.dormindo && (window.carinhaOlhosIcone || desenho);
p.icone.style.display = icone && !desenho ? '' : 'none';
if (p.figurinha) p.figurinha.style.display=desenho?'':'none';
if(desenho && window.carinhaConstruirSVG){
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);
p.icone.setAttribute('font-size',Math.min(rx*1.1,ry*1.1));
}
if (forma === "aberto" || forma === "surpreso") {
const rr = Math.max(2, ry * ab);
p.corpo.setAttribute("d", caminhoElipse(cx, cy, rx, rr));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
if (ab > 0.62 && !icone) {
// o brilho ("pupila") anda dentro do olho: e o que da a sensacao de olhar
let hx = cx - rx * 0.30 + lx * 1.2 * ESC;
let hy = cy - rr * 0.36 + ly * 1.2 * ESC;
hx = Math.max(cx - rx * 0.46, Math.min(cx + rx * 0.46, hx));
hy = Math.max(cy - rr * 0.46, Math.min(cy + rr * 0.46, hy));
p.brilho.setAttribute("cx", hx);
p.brilho.setAttribute("cy", hy);
p.brilho.setAttribute("rx", rx * 0.20);
p.brilho.setAttribute("ry", rr * 0.18);
p.brilho.style.display = "";
}
} else if (forma === "feliz") {
p.traco.setAttribute("d", caminhoArco(cx, cy + ry * 0.22, rx * 1.0, ry * 0.80));
p.traco.setAttribute("stroke-width", rx * 0.34);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "dormir") {
p.traco.setAttribute("d", caminhoLinha(cx - rx * 0.86, cy + ry * 0.14,
cx + rx * 0.86, cy + ry * 0.14));
p.traco.setAttribute("stroke-width", rx * 0.22);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "bravo") {
p.traco.setAttribute("d", caminhoLinha(cx - lado * rx * 0.9, cy - ry * 0.42,
cx + lado * rx * 0.9, cy + ry * 0.26));
p.traco.setAttribute("stroke-width", rx * 0.26);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "triste") {
p.traco.setAttribute("d", caminhoLinha(cx - lado * rx * 0.9, cy + ry * 0.22,
cx + lado * rx * 0.9, cy - ry * 0.46));
p.traco.setAttribute("stroke-width", rx * 0.26);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "coracao") {
p.corpo.setAttribute("d", caminhoCoracao(cx, cy, Math.min(rx, ry) * 0.98));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
} else {
p.corpo.setAttribute("d", caminhoElipse(cx, cy, rx, ry));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
}
}
// ------------------------------------------------------------------ gestos
function sequencias() {
const piscar = [[{ ab: 0.0 }, 0.06], [{ ab: 0.0 }, 0.04], [{ ab: 1.0 }, 0.11]];
const piscar2 = piscar.concat([[{}, 0.09]], piscar);
const olhar_esq = [[{ dx: -24, dy: 3 }, 0.16], [{}, 0.55], [{ dx: 0, dy: 0 }, 0.2]];
const olhar_dir = [[{ dx: 24, dy: 3 }, 0.16], [{}, 0.55], [{ dx: 0, dy: 0 }, 0.2]];
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
[{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
[{ dx: -7 }, 0.06], [{ dx: 7 }, 0.06], [{ dx: -7 }, 0.06], [{ dx: 0 }, 0.06],
[{ forma: "aberto", cor: CIANO }, 0.32]];
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]];
const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
// Mais expressões: risada, bocejo, confusão, tontura, timidez e comemoração.
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.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 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],
[{ dy: -10, cor: VERDE }, 0.15], [{ dy: 2, cor: CIANO }, 0.15], [{ dy: -10, cor: AMARELO }, 0.15],
[{ dy: 0, cor: CIANO }, 0.2], [{ forma: "aberto", esc: 1.0 }, 0.3]];
return { rir, bocejar, confuso, tonto, timido, comemorar,
esconder, espiar, acenar, pensar, musica,
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
const r = {
ab: 1.0, ab2: null, esc: 1.0, dx: 0.0, dy: 0.0,
forma: "aberto", forma2: null, cor: CIANO.slice(), dormindo: false,
boca: "neutra",
fila: [], kf: null, kf_ini: null, kf_t: 0, cor_ini: CIANO.slice(),
};
const SEQS = sequencias();
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */
const LIVRES_HUMOR = {
feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"],
tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo", "timido"],
bravo: ["bravo", "olhar_baixo", "olhar_esq", "olhar_dir", "confuso"],
entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar", "bocejar", "confuso"],
carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir", "timido"],
triste: ["triste", "olhar_baixo", "olhar_baixo"],
cansado: ["olhar_baixo", "piscar2", "bocejar"],
};
let HUMOR = "normal";
/* Modo vida: zanga (0 a 1) deixa o rosto emburrado aos poucos quando a alegria cai; sono profundo =
dormiu de cansaço, e aí som, movimento e câmera não acordam (como na madrugada). */
let ZANGA = 0;
let SONO_PROFUNDO = false;
/* Microexpressões acordado: de vez em quando a boca e as sobrancelhas mexem sozinhas (um canto
da boca sobe, uma sobrancelha arqueia, um suspiro) e voltam devagar ao neutro. O humor do modo
vida escolhe quais. Valores em "pixels de projeto" (multiplicados por ESC no desenho). */
function feicaoNeutra() { return { curva: 0, lado: 0, abre: 0, dx: 0, sob: [0, 0], inc: [0, 0] }; }
const FEICAO = { atual: feicaoNeutra(), alvo: feicaoNeutra(), prox: 0, volta: 0, t: 0 };
const ao = () => (Math.random() < 0.5 ? 1 : -1);
const MICRO = {
arquear: () => { const f = feicaoNeutra(), i = Math.random() < 0.5 ? 0 : 1; f.sob[i] = 12; f.inc[i] = -4; return f; },
interesse: () => Object.assign(feicaoNeutra(), { sob: [8, 8], abre: 0.35 }),
meioSorriso: () => Object.assign(feicaoNeutra(), { lado: ao(), curva: 0.1 }),
sorrisinho: () => Object.assign(feicaoNeutra(), { curva: 0.35, sob: [4, 4] }),
ladinho: () => { const d = ao(); return Object.assign(feicaoNeutra(), { dx: d, lado: d * 0.4, curva: -0.08 }); },
pensativo: () => Object.assign(feicaoNeutra(), { inc: [8, 8], sob: [-3, -3], dx: ao() * 0.7, curva: -0.15 }),
preocupado: () => Object.assign(feicaoNeutra(), { inc: [-9, -9], sob: [4, 4], curva: -0.3 }),
suspiro: () => Object.assign(feicaoNeutra(), { sob: [5, 5], abre: 0.6, curva: -0.05 }),
};
const MICRO_HUMOR = {
feliz: ["sorrisinho", "sorrisinho", "meioSorriso", "arquear", "interesse"],
tranquilo: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse"],
entediado: ["ladinho", "ladinho", "pensativo", "suspiro", "arquear"],
carente: ["preocupado", "preocupado", "suspiro", "meioSorriso"],
triste: ["preocupado", "suspiro", "pensativo"],
cansado: ["suspiro", "suspiro", "ladinho"],
bravo: ["pensativo", "pensativo", "ladinho", "suspiro"],
normal: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse", "pensativo", "suspiro"],
};
function atualizarFeicao(s) {
const dt = Math.min(0.1, Math.max(0, s - (FEICAO.t || s)));
FEICAO.t = s;
// só no rosto "de repouso": gesto com boca própria (sorriso, triste...) ou dormindo = neutro
const livre = !r.dormindo && !congelado && r.boca === "neutra";
if (!livre) {
FEICAO.alvo = feicaoNeutra();
FEICAO.prox = Math.max(FEICAO.prox, s + 1.5);
} else if (s >= FEICAO.prox) {
const lista = MICRO_HUMOR[HUMOR] || MICRO_HUMOR.normal;
FEICAO.alvo = MICRO[lista[Math.floor(Math.random() * lista.length)]]();
FEICAO.volta = s + 0.9 + Math.random() * 1.6;
FEICAO.prox = s + (2.5 + Math.random() * 5) * ({ cansado: 1.6, triste: 1.4, feliz: 0.8 }[HUMOR] || 1);
} else if (s >= FEICAO.volta) FEICAO.alvo = feicaoNeutra();
const k = 1 - Math.exp(-dt * 6), a = FEICAO.atual, b = FEICAO.alvo;
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 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() {
if (!r.fila.length) { r.kf = null; return; }
const [alvo, dur] = r.fila[0];
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice();
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
if ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca;
r.kf = [alvo, dur];
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();
tocarSom(nome); // trilha sonora do gesto (se houver)
return true;
}
return false;
}
function passo(dt) { // avanca a fila; true se ha gesto em curso
if (!r.fila.length || !r.kf) return false;
const [alvo, dur] = r.kf;
r.kf_t += dt;
const e = suave(dur <= 0 ? 1 : Math.min(1, r.kf_t / dur));
for (const k of ["ab", "ab2", "esc", "dx", "dy"]) {
if (k in alvo && alvo[k] !== null && r.kf_ini[k] !== null) r[k] = lerp(r.kf_ini[k], alvo[k], e);
else if (k in alvo && alvo[k] === null) r[k] = null;
}
if ("cor" in alvo) r.cor = lerpCor(r.cor_ini, alvo.cor, e);
if (e >= 1) { r.fila.shift(); proximo(); }
return true;
}
/* 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, receita) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
}
/* Expressão criada no estúdio (criacao.py): receita já validada no servidor, só números e nomes de
peças. Vira uma sequência igual às de sequencias(), com mãos e espiral se a receita pedir. */
function corDaReceita(nome) {
return { claro: "claro", escuro: "escuro", rosa: ROSA, amarelo: AMARELO, verde: VERDE, vermelho: VERM }[nome] || CIANO;
}
function tocarExpressaoCriada(texto) {
let rc;
try { rc = JSON.parse(texto); } catch (e) { return; }
if (!rc || !Array.isArray(rc.passos) || !rc.passos.length) return;
if (r.dormindo) r.dormindo = false;
r.fila = rc.passos.slice(0, 12).map((p) => [{
forma: p.forma || "aberto", forma2: null, boca: p.boca || "neutra", cor: corDaReceita(p.cor),
dx: +p.dx || 0, dy: +p.dy || 0, esc: +p.esc || 1, ab: typeof p.ab === "number" ? p.ab : 1,
}, Math.max(0.05, Math.min(2, +p.duracao || 0.3))]);
proximo();
const total = r.fila.reduce((a, passo) => a + passo[1], 0);
if (rc.espiral) ESPIRAL.ate = performance.now() / 1000 + total;
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
}
function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
r.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0;
r.dy = 3.0 * Math.sin(t * 1.4);
return;
}
r.forma = "aberto"; r.forma2 = null;
r.boca = "neutra";
r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
// 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;
r.dy = r.dy * (1 - v) * 0.3 + ATIV.y * v;
if (ATIV.forma) r.forma = ATIV.forma;
if (ATIV.boca) r.boca = ATIV.boca;
if (ATIV.ab) r.ab = ATIV.ab;
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
r.forma = "bravo";
}
}
function desenhar() {
const esc = r.esc * ROSTO.escala, ab = r.ab;
const ab2 = r.ab2 === null ? r.ab : r.ab2;
const f2 = r.forma2 === null ? r.forma : r.forma2;
// durante o repouso o corpo do olho fica parado e so o brilho se move
// mirando: algo se mexeu ha pouco, entao o olho inteiro (nao so o brilho)
// desliza na direcao do movimento; parado, volta ao repouso discreto
const tAgora = performance.now() / 1000;
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
const comMotor = ROSTO.motor && window.MotorRosto;
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, espiral: espiralAtiva(),
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
})),
});
} else if (window.MotorRosto) window.MotorRosto.esconder();
for (let i = 0; i < 2 && !comMotor; i++) {
const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
brow.setAttribute('d', caminhoSobrancelha(i, esc));
brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC);
}
}
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
function tracosDoRosto(esc) {
const tracos = [];
if (EXTRAS.boca) {
const forma = r.boca;
if (r.dormindo) { // dormindo na Matriz: "– _ –"
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
}
return tracos;
}
// ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) {
const alvo = ROSTOS[(nome || "").trim().toLowerCase()];
if (!alvo) return false;
ROSTO_NOME = (nome || "").trim().toLowerCase();
ROSTO = alvo;
r.fila = []; r.kf = null;
r.boca = "neutra";
desenhar();
relatar(); // conta ao servidor qual rosto ficou
return true;
}
/* "Olha o que eu aprendi hoje!": a curiosidade de uma ideia do dia, em legenda e na voz de robô. */
function contarAprendi(texto) {
if (window.carinhaSemServidor || !texto) return;
if (r.dormindo) r.dormindo = false;
atividade(null);
animar("surpreso");
setTimeout(() => {
const frase = "Olha o que eu aprendi hoje! " + texto;
if (window.MotorRosto) window.MotorRosto.faixa("legenda", { texto: frase, segundos: Math.max(6, Math.min(16, frase.length / 10)) });
falar(frase);
animar("feliz");
}, 900);
}
function trata(ordem) {
const bruta = (ordem || "").trim();
if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase
ordem = bruta.toLowerCase();
if (!ordem) return;
if(ordem==='recarregar') {
if(new URLSearchParams(location.search).get('aparelho')==='1') {
fetch('/app/status',{cache:'no-store'}).then(r=>r.json()).then(s=>{const url=new URL(location.href);url.searchParams.set('atualizacao',s.recarga?.id||Date.now());location.replace(url.href);}).catch(()=>{});
}
return;
}
window.carinhaUltimoComando = Date.now();
if (ordem.indexOf("rosto ") === 0) {
trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("formato ") === 0) {
const f = ordem.slice(8).trim();
if (FORMATOS.includes(f)) FORMATO = f;
} else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) {
CIANO.splice(0, 3, ...v); r.cor = v;
}
} else if (ordem.indexOf("humor ") === 0) {
HUMOR = ordem.slice(6).trim();
} else if (ordem.indexOf("ritmo ") === 0) {
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
} else if (ordem.indexOf("expressao_criada ") === 0) {
tocarExpressaoCriada(ordem.slice(17));
} else if (ordem.indexOf("atividade_criada ") === 0) {
try { const rc = JSON.parse(ordem.slice(17)); atividade("receita", +rc.duracao || 25, rc); } catch (e) {}
} else if (ordem.indexOf("atividade ") === 0) {
const [nome, seg] = ordem.slice(10).trim().split(/\s+/); // "atividade bola 180"
atividade(nome, parseFloat(seg) || 0);
} else if (ordem.indexOf("zanga ") === 0) {
const z = parseFloat(ordem.slice(6));
if (!isNaN(z)) ZANGA = Math.max(0, Math.min(1, z));
} else if (ordem === "sono profundo" || ordem === "sono leve") {
SONO_PROFUNDO = ordem === "sono profundo";
} else if (ordem === "dormir" || ordem === "sono") {
r.dormindo = true;
atividade(null);
} else if (ordem === "acordar") {
if (window.carinhaSemServidor) { // sem a placa continua dormindo; o aviso chacoalha
const aviso = document.getElementById("semServidor");
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
return;
}
r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
}
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
atividade(null);
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
}
let congelado = false; // util para conferir poses (ver eilik.pose)
/* Mostra a pose caracteristica de um gesto e congela — serve para tirar
"foto" de cada gesto e conferir o desenho. eilik.soltar() volta ao normal. */
function pose(nome) {
const seq = SEQS[nome];
if (!seq) return false;
const alvo = seq[0][0];
r.fila = []; r.kf = null;
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
}
if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
congelado = true;
desenhar();
return true;
}
window.eilik = {
animar, trata, pose, trocarRosto, som, tocarSom, movimento, rastrear, face: r,
gestos: Object.keys(SEQS), rostos: Object.keys(ROSTOS),
rostoAtual: () => ROSTO_NOME,
soltar: () => { congelado = false; r.dormindo = false; },
presenca: () => MOV, // estado do detector de movimento (para conferir)
ouvido: () => MIC, // estado do microfone (para conferir)
ligarCamera, desligarCamera, decidirCameraOculta, // camera oculta (sensor)
cameraTick: detectarCamera, // um passo do detector (conferencia/sem laco)
};
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
conexaoOk();
const lista = await resp.json();
for (const nome of lista) trata(nome);
} catch (e) { /* sem servidor (file://) — ignora */ }
}
setInterval(buscarOrdens, 500);
// O app (window.CarinhaApp) guarda a ultima aparencia e os ajustes vindos do servidor:
// a copia do APK (file://) nao enxerga o armazenamento da pagina do servidor.
const LOCAL = location.protocol === "file:";
function lembrar(chave, valor) {
try { if (window.CarinhaApp && !LOCAL) window.CarinhaApp.guardar(chave, JSON.stringify(valor)); } catch (e) {}
}
function lembrado(chave) {
try { const s = window.CarinhaApp && window.CarinhaApp.lembrado(chave); return s ? JSON.parse(s) : null; } catch (e) { return null; }
}
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
// servidor vale a ultima que o app guardou (ou o padrao).
async function aplicarAparencia() {
let c = null;
if (!LOCAL) {
try {
const resp = await fetch("config", { cache: "no-store" });
if (resp.ok) c = await resp.json();
} catch (e) { /* sem servidor */ }
}
if (!c) c = lembrado("aparencia");
if (!c) return;
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
}
setTimeout(aplicarAparencia, 200);
// O painel troca rosto/cor por ordens: guarda a aparencia atual de tempos em tempos.
setInterval(() => { if (!window.carinhaSemServidor) lembrar("aparencia", { rosto: ROSTO_NOME, formato: FORMATO, cor: CIANO.slice() }); }, 15000);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
if (MODO_PAINEL) {
window.carinhaAtiva = false;
addEventListener('message', (e) => {
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
(function () {
let inicio = null;
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
addEventListener('pointerup', (e) => {
if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null;
if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
if (ATIV && !ATIV.saindo) { atividade(null); animar('surpreso'); cuidar('cutucar'); return; } // interrompeu
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
cuidar('cutucar');
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
});
})();
// teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
k: "olhar_baixo", w: "piscadinha" };
addEventListener("keydown", (ev) => {
const k = ev.key.toLowerCase();
if (TECLAS[k]) animar(TECLAS[k]);
else if (k === "d") { r.dormindo = !r.dormindo; if (!r.dormindo) animar("surpreso"); }
else if (k === " ") { animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); }
});
// ------------------------------------------------------------------ laço
const par = new URLSearchParams(location.search);
const FPS = parseFloat(par.get("fps") || "30"); // 0 = sem limite
let AUTO = par.get("auto") !== "0"; // gestos sozinhos
const COMPORTAMENTO = { ouvir: true, reagir: true };
const passoFixo = FPS > 0 ? 1 / FPS : 0;
// ----------------------------------------------------------- ouvido (microfone)
/* A ponte nativa do APK chama window.eilik.som(nivel) ~20x/s, com 0=silencio e
1=bem alto. Quem reage e a propria pagina, entao a telinha do Pi (sem
microfone) continua igual: sem chamadas, nada acontece. Ajustes por URL:
?somlim= ?somaltolim= ?sompausa= ?somcooldown= */
const MIC = {
ativo: false, // virou true na primeira leitura recebida
visto: 0, // t da ultima leitura recebida (s)
nivel: 0,
ultimoSom: 0, // t do ultimo som audivel (s)
estavaAlto: false,
proxSurpresa: 0,
ultimoLog: 0,
piso: null, // piso de ruido do ambiente (segue devagar)
ganho: parseFloat(par.get("somganho") || "0.40"), // quanto acima do piso = susto
limiarSom: parseFloat(par.get("somlim") || "0.35"), // acima: "tem som"
limiarAlto: parseFloat(par.get("somaltolim") || "0.55"), // minimo absoluto p/ susto
segSilencio: parseFloat(par.get("sompausa") || "20"), // s calado -> dormir
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
};
/* Acordar com som: precisa de N barulhos em 1 minuto (ajuste "Sons para acordar"). Na madrugada
silenciosa só barulho ALTO conta, e movimento/câmera não acordam (alguém se mexendo na cama). */
const DESPERTAR = { sons: 3, noite: true, ini: 23, fim: 7 };
function sonoRespeitado() { return madrugada() || SONO_PROFUNDO; }
function madrugada() {
if (!DESPERTAR.noite || DESPERTAR.ini === DESPERTAR.fim) return false;
const h = new Date().getHours();
return DESPERTAR.ini < DESPERTAR.fim ? h >= DESPERTAR.ini && h < DESPERTAR.fim : h >= DESPERTAR.ini || h < DESPERTAR.fim;
}
/* Perfil de sensibilidade = ritmo do modo vida (o servidor manda "ritmo calmo|normal|agitado").
Calmo precisa de vários estímulos para reagir e limiares mais altos; agitado reage a qualquer
som ou movimento. Multiplica os limiares dos Ajustes, sem mudar os Ajustes. */
const PERFIS = {
calmo: { lim: 1.5, reagir: 3, acordarSom: 2, acordarMov: 4, espera: 2, qualquer: false },
normal: { lim: 1.0, reagir: 1, acordarSom: 1, acordarMov: 2, espera: 1, qualquer: false },
agitado: { lim: 0.6, reagir: 1, acordarSom: 0.34, acordarMov: 1, espera: 0.5, qualquer: true },
};
let PERFIL = PERFIS.normal;
const limiar = (x) => Math.min(0.95, x * PERFIL.lim);
/* Conta o INÍCIO de cada estímulo (o mesmo som/movimento comprido vale um) numa janela de tempo
e diz quando chegou a `precisa`. */
function novoContador(janela) { return { janela, eventos: [], ultimo: -Infinity, estava: false, visto: -Infinity }; }
const CONTA = { somAcordar: novoContador(60), movAcordar: novoContador(30), somReagir: novoContador(20), movReagir: novoContador(20) };
function contar(c, ativo, agora, precisa, nome) {
// contador parado um tempo (ex.: o de "acordar" enquanto estava acordado): começa do zero
if (agora - c.visto > 2) c.estava = false;
c.visto = agora;
if (ativo && !c.estava && agora - c.ultimo >= 1.0) {
c.ultimo = agora;
c.eventos = c.eventos.filter((t) => agora - t < c.janela).concat(agora);
if (precisa > 1) console.log(nome + " " + c.eventos.length + "/" + precisa + (madrugada() ? " (madrugada)" : ""));
}
c.estava = ativo;
if (c.eventos.length >= precisa && agora - c.eventos[c.eventos.length - 1] < 1) { c.eventos = []; return true; }
return false;
}
/* Reação acordado: agitado varia; os outros se assustam. */
function reacaoEstimulo() {
if (!PERFIL.qualquer) return "surpreso";
const l = ["surpreso", "feliz", "alegre", "piscadinha", "surpreso"];
return l[Math.floor(Math.random() * l.length)];
}
function som(nivel) {
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
const agora = performance.now() / 1000;
MIC.ativo = true;
MIC.visto = agora;
MIC.nivel = n;
if (!COMPORTAMENTO.ouvir) return;
// piso de ruido: desce rapido e sobe devagar, acompanhando o ambiente. Assim
// o "susto" e sempre relativo ao barulho de fundo e nao dispara sem parar
// num lugar barulhento. Ajuste por URL: ?somganho=
if (MIC.piso === null) MIC.piso = n;
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
if (temSom) MIC.ultimoSom = agora;
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
if (agora - MIC.ultimoLog >= 2) {
MIC.ultimoLog = agora;
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
}
if (r.dormindo) {
const precisa = Math.max(1, Math.round(DESPERTAR.sons * PERFIL.acordarSom));
if (contar(CONTA.somAcordar, sonoRespeitado() ? alto : temSom, agora, precisa, "ouvido: barulho")) { // acordou
r.dormindo = false;
animar("surpreso");
MIC.proxSurpresa = agora + MIC.cooldown;
console.log("ouvido: acordou nivel=" + n.toFixed(2));
relatarSom("acordou", n);
}
} else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
agora >= MIC.proxSurpresa) {
MIC.proxSurpresa = agora + MIC.cooldown * PERFIL.espera; // susto: barulho alto e repentino
animar(reacaoEstimulo());
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
relatarSom("surpreso", n);
}
MIC.estavaAlto = alto;
}
/* Sem barulho por tempo demais, adormece (so quando o microfone esta ativo). */
function checarSilencio(agora) {
if (!COMPORTAMENTO.ouvir) return;
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
if (r.dormindo) return;
if (HUMOR !== "normal") return; // modo vida ligado: quem manda dormir é a energia
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
if (agora - atividade >= MIC.segSilencio) {
r.dormindo = true;
console.log("ouvido: adormeceu (" + MIC.segSilencio + "s sem som nem movimento)");
relatarSom("adormeceu", 0);
}
}
/* Conta ao servidor o que o ouvido percebeu — da para conferir de longe no
journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
if (evento !== "adormeceu") registrarEvento("som", evento, n);
}
/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */
function cuidar(acao) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
try {
fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ acao }) }).catch(() => {});
} catch (e) {}
}
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
function registrarEvento(tipo, evento, nivel) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
try {
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
} catch (e) {}
}
// ------------------------------------------------------ presenca (movimento)
/* A ponte nativa do APK abre a camera FRONTAL, mede o movimento (diferenca de
luminancia entre quadros) e chama window.eilik.movimento(vel, cx, cy) umas
12x/s: vel vai de 0 (parado) a 1 (muito movimento) e cx,cy e o centro do que
se mexeu, de 0 a 1 na imagem. A carinha acorda quando algo se mexe na frente
dela e, acordada, os olhos acompanham o movimento. Sem a ponte nada acontece
(a telinha do Pi continua igual, sem camera). Ajustes por URL:
?movlim= ?movforte= ?movcooldown= ?movolhar= */
const MOV = {
ativo: false, // virou true na primeira leitura recebida
vel: 0, // ultimo movimento (0 a 1)
cx: 0.5, cy: 0.5, // ultimo centro do movimento (0 a 1)
visto: 0, // t da ultima leitura (s)
ultimoMov: 0, // t do ultimo movimento percebido (s)
proxSurpresa: 0, // t a partir do qual um novo susto pode disparar
estavaForte: false, // ja vinha em movimento forte (so o INICIO assusta)
ultimoLog: 0,
olharX: 0, olharY: 0, // deslocamento dos olhos na direcao do movimento
limiar: parseFloat(par.get("movlim") || "0.05"), // acima: houve movimento
limiarForte: parseFloat(par.get("movforte") || "0.12"), // acima: susto (medido: mao ~0,1)
cooldown: parseFloat(par.get("movcooldown") || "6"), // s entre sustos
olharMax: parseFloat(par.get("movolhar") || "40"), // quanto os olhos seguem
};
const TRACK = {alvo:null,visto:-Infinity};
function rastrear(t) {
if (!t) { TRACK.alvo=null; return; }
if (!Number.isFinite(t.x) || !Number.isFinite(t.y) || t.x<0 || t.x>1 || t.y<0 || t.y>1 || !['mao','rosto'].includes(t.tipo)) return;
if (!COMPORTAMENTO.reagir || configRecebida?.seguirRastreamento===false || configRecebida?.sensorCamera===false ||
(t.tipo==='mao' && configRecebida?.rastrearMaos===false) || (t.tipo==='rosto' && configRecebida?.rastrearRosto===false) ||
(r.dormindo && !configRecebida?.cameraSono)) return;
const agora=performance.now()/1000;
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) {
MOV.ultimoMov=agora;
if(r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) { r.dormindo=false;animar('surpreso'); }
}
}
function movimento(vel, cx, cy) {
const v = Math.max(0, Math.min(1, Number(vel) || 0));
const agora = performance.now() / 1000;
MOV.ativo = true;
MOV.vel = v;
MOV.visto = agora;
if (typeof cx === "number" && !isNaN(cx)) MOV.cx = Math.max(0, Math.min(1, cx));
if (typeof cy === "number" && !isNaN(cy)) MOV.cy = Math.max(0, Math.min(1, cy));
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
if (TRACK.alvo && agora-TRACK.visto<.35) return;
const mexeu = v >= limiar(MOV.limiar);
if (mexeu) MOV.ultimoMov = agora;
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
const alvoX = mexeu ? (MOV.cx - 0.5) * 2 * MOV.olharMax : agora-MOV.ultimoMov<.2 ? MOV.olharX : 0;
const alvoY = mexeu ? (MOV.cy - 0.5) * 2 * MOV.olharMax * 0.6 : agora-MOV.ultimoMov<.2 ? MOV.olharY : 0;
MOV.olharX += (alvoX - MOV.olharX) * 0.65;
MOV.olharY += (alvoY - MOV.olharY) * 0.65;
if (agora - MOV.ultimoLog >= 2) {
MOV.ultimoLog = agora;
console.log("presenca nivel=" + v.toFixed(2) + " centro=" +
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
}
const forte = v >= limiar(MOV.limiarForte);
if (r.dormindo) {
// acordou: algo se mexeu na frente (de dia; calmo precisa de vários movimentos)
if (!sonoRespeitado() && contar(CONTA.movAcordar, mexeu, agora, PERFIL.acordarMov, "presenca: movimento")) {
r.dormindo = false;
animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown;
console.log("presenca: acordou nivel=" + v.toFixed(2));
relatarMov("acordou", v);
}
} else if (contar(CONTA.movReagir, PERFIL.qualquer ? mexeu : forte, agora, PERFIL.reagir, "presenca: estimulo") &&
agora >= MOV.proxSurpresa) {
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
animar(reacaoEstimulo());
console.log("presenca: surpreso nivel=" + v.toFixed(2));
relatarMov("surpreso", v);
}
MOV.estavaForte = forte;
}
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
}
// ------------------------------------------------- camera oculta (sensor)
/* A propria pagina abre a camera para SENTIR o movimento sem mostrar a imagem:
o video fica escondido e a tela continua so com os olhos do robo. Serve para
o kiosk do Pi com uma webcam (a pagina e servida por 127.0.0.1, que conta
como contexto seguro). No celular quem faz isso e a ponte nativa, que tambem
nao mostra imagem — entao, se ela ja estiver mandando movimento, nao abrimos
outra camera (dois acessos juntos costumam falhar).
Para ver a imagem em um cantinho (so para conferir): ?campreview=1
Para desligar de vez: ?camera=0 */
const CAM = {
querer: par.get("camera") !== "0"
&& typeof window.isSecureContext !== "undefined" && window.isSecureContext,
ativo: false,
erro: null,
fluxo: null,
video: null,
cv: null,
ctx: null,
lumaAnt: null,
quadros: 0, // aquecimento: a exposicao demora a assentar
CW: 160, CH: 120,
limite: 26, // salto de luminancia (igual a camera.html)
mostrar: par.get("campreview") === "1",
atraso: parseInt(par.get("camatraso") || "2000", 10), // espera antes de abrir
};
async function ligarCamera() {
if (CAM.ativo) return true;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
CAM.erro = "sem getUserMedia (contexto inseguro)";
return false;
}
try {
const fluxo = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "user" }, width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
CAM.fluxo = fluxo;
const v = document.createElement("video");
v.autoplay = true; v.muted = true; v.setAttribute("playsinline", "");
// escondido de verdade (fora da tela); so aparece com ?campreview=1
v.style.cssText = CAM.mostrar
? "position:fixed;right:8px;bottom:8px;width:160px;z-index:9;border:1px solid #24405a;border-radius:6px"
: "position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none";
v.srcObject = fluxo;
document.body.appendChild(v);
try { await v.play(); } catch (e) { /* segue */ }
CAM.video = v;
CAM.cv = document.createElement("canvas");
CAM.cv.width = CAM.CW; CAM.cv.height = CAM.CH;
CAM.ctx = CAM.cv.getContext("2d", { willReadFrequently: true });
CAM.quadros = 0;
CAM.ativo = true;
const f = fluxo.getVideoTracks()[0];
console.log("camera: sensor oculto ligado (" + ((f && f.label) || "camera") + ")");
requestAnimationFrame(lacoCamera);
return true;
} catch (e) {
CAM.erro = e.name;
console.log("camera: sensor oculto falhou — " + e.name);
return false;
}
}
function desligarCamera() {
CAM.ativo = false;
if (CAM.fluxo) { try { CAM.fluxo.getTracks().forEach((t) => t.stop()); } catch (e) {} }
if (CAM.video && CAM.video.parentNode) CAM.video.parentNode.removeChild(CAM.video);
CAM.fluxo = null; CAM.video = null; CAM.lumaAnt = null;
}
function detectarCamera() {
const v = CAM.video;
if (!v || !v.videoWidth) return;
CAM.ctx.drawImage(v, 0, 0, CAM.CW, CAM.CH);
const d = CAM.ctx.getImageData(0, 0, CAM.CW, CAM.CH).data;
if (!CAM.lumaAnt) CAM.lumaAnt = new Float32Array(CAM.CW * CAM.CH);
let mexidos = 0, sx = 0, sy = 0;
for (let i = 0; i < CAM.CW * CAM.CH; i++) {
const l = d[i * 4] * 0.299 + d[i * 4 + 1] * 0.587 + d[i * 4 + 2] * 0.114;
if (Math.abs(l - CAM.lumaAnt[i]) > CAM.limite) { mexidos++; sx += i % CAM.CW; sy += (i / CAM.CW) | 0; }
CAM.lumaAnt[i] = l;
}
CAM.quadros++;
if (CAM.quadros <= 6) return; // ignora o pico de entrada
const vel = Math.min(1, mexidos / (CAM.CW * CAM.CH * 0.25));
const cx = mexidos > 0 ? (sx / mexidos) / CAM.CW : 0.5;
const cy = mexidos > 0 ? (sy / mexidos) / CAM.CH : 0.5;
movimento(vel, cx, cy);
}
function lacoCamera() {
if (!CAM.ativo) return;
try { detectarCamera(); } catch (e) { CAM.erro = e.name; }
requestAnimationFrame(lacoCamera);
}
/* Decisao de abrir (ou nao) a camera escondida. Fica numa funcao a parte para
dar para conferir sem depender do relogio. */
function decidirCameraOculta() {
if (!CAM.querer) { if (!CAM.erro) CAM.erro = "desligada"; return; }
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
CAM.erro = "sem getUserMedia (contexto inseguro)";
console.log("camera: sem sensor na pagina — " + CAM.erro);
return;
}
// se a ponte nativa ja esta mandando movimento, deixa a camera com ela
// (dois acessos a camera ao mesmo tempo costumam falhar)
if (MOV.ativo) { CAM.erro = "ponte nativa no comando"; return; }
ligarCamera();
}
setTimeout(decidirCameraOculta, CAM.atraso);
// -------------------------------------------------------------- voz (saida)
/* Sons curtos sintetizados com Web Audio — sem arquivos, sem permissao e sem
rede. Cada gesto tem o seu (piscar e olhar ficam mudos de proposito). Para
silenciar: ?audio=0 ; volume: ?volume=0.5 */
const AUDIO = {
ligado: par.get("audio") !== "0",
volume: parseFloat(par.get("volume") || "0.35"),
ctx: null,
};
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null;
function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
SONO.apagar = c.apagarTela !== false;
if (typeof c.somAcordar === 'number') DESPERTAR.sons = Math.max(1, Math.round(c.somAcordar));
DESPERTAR.noite = c.noiteSilenciosa !== false;
if (typeof c.noiteInicio === 'number') DESPERTAR.ini = Math.round(c.noiteInicio);
if (typeof c.noiteFim === 'number') DESPERTAR.fim = Math.round(c.noiteFim);
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
MIC.limiarSom = c.somlim; MIC.ganho = c.somganho;
MIC.limiarAlto = c.somaltolim; MIC.segSilencio = c.sompausa; MIC.cooldown = c.somcooldown;
MOV.limiar = c.movlim; MOV.limiarForte = c.movforte;
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
configRecebida = c;
lembrar('config', c);
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
}
let sincronizando = false;
async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true;
try {
if (!window.carinhaSSE) {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
}
if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
signal: AbortSignal.timeout(2500), body: JSON.stringify({
tela_atualizada: par.get('atualizacao'), rosto: ROSTO_NOME, cor: r.cor, dormindo: r.dormindo,
microfone: MIC.nivel, microfone_ativo: window.carinhaSensores ? window.carinhaSensores.microfone : MIC.ativo && agora - MIC.visto < 2,
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
atividade: window.MotorRosto && window.MotorRosto.atividadeAtual ? window.MotorRosto.atividadeAtual() : null,
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
}) });
}
} catch (e) { /* servidor ausente: animação continua */ }
finally { sincronizando = false; }
}
setTimeout(sincronizarApp, 300);
setInterval(sincronizarApp, 1000);
function contextoAudio() {
if (AUDIO.ctx) return AUDIO.ctx;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
try { AUDIO.ctx = new AC(); } catch (e) { return null; }
return AUDIO.ctx;
}
/* Uma nota simples: freq -> freqFim (se houver), com subida e queda suaves. */
function nota(c, freq, t0, dur, tipo, vol, freqFim) {
const osc = c.createOscillator();
const ganho = c.createGain();
osc.type = tipo || "sine";
osc.frequency.setValueAtTime(freq, t0);
if (freqFim) osc.frequency.exponentialRampToValueAtTime(freqFim, t0 + dur);
ganho.gain.setValueAtTime(0.0001, t0);
ganho.gain.exponentialRampToValueAtTime(vol, t0 + 0.012);
ganho.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(ganho);
ganho.connect(c.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.03);
}
const SONS = {
feliz: (c, t, v) => { nota(c, 660, t, 0.12, "sine", v); nota(c, 990, t + 0.10, 0.16, "sine", v); },
alegre: (c, t, v) => [523, 659, 784, 1047].forEach((f, i) => nota(c, f, t + i * 0.07, 0.12, "triangle", v)),
coracao: (c, t, v) => { nota(c, 784, t, 0.5, "sine", v * 0.9); nota(c, 988, t + 0.04, 0.5, "sine", v * 0.7); },
piscadinha: (c, t, v) => { nota(c, 880, t, 0.09, "square", v * 0.6); nota(c, 1175, t + 0.09, 0.12, "square", v * 0.5); },
surpreso: (c, t, v) => nota(c, 300, t, 0.28, "sawtooth", v * 0.6, 1400), // "uou!"
bravo: (c, t, v) => nota(c, 220, t, 0.35, "sawtooth", v * 0.7, 90), // resmungo
triste: (c, t, v) => { nota(c, 500, t, 0.22, "sine", v * 0.8); nota(c, 330, t + 0.20, 0.34, "sine", v * 0.8); },
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
};
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
os sons dos gestos ficam quietos para não embolar. */
function falar(texto) {
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
const c = contextoAudio();
if (!c) return;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
AUDIO.mudoAte = performance.now() + dur * 1000;
} catch (e) { /* segue sem voz */ }
}
function tocarSom(gesto) {
if (!AUDIO.ligado) return false;
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
const f = SONS[gesto];
if (!voz && !f) return false;
const c = contextoAudio();
if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state);
return true;
} catch (e) { return false; }
}
let anterior = performance.now();
let descanso = 0;
let agora = performance.now();
let proxPiscar = agora + 1500 + Math.random() * 2500;
let proxGesto = agora + 6000 + Math.random() * 6000;
const elFps = document.getElementById("fps");
if (par.get("fpsmostrar") === "1") elFps.style.display = "block";
let contador = 0, marco = performance.now();
function laco(t) {
const dt = Math.min(0.25, (t - anterior) / 1000);
anterior = t;
// se a janela mudou de tamanho (ou so ficou pronta depois do primeiro
// quadro), refaz a geometria — assim a carinha nunca fica enquadrada errado
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000);
atualizarFeicao(t / 1000);
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null;
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
document.body.classList.toggle("atividade", !!ATIV && !ATIV.saindo);
if (window.carinhaSemServidor && !r.dormindo) { r.fila = []; r.kf = null; r.dormindo = true; } // sem a placa: só dorme
const ocupado = congelado ? true : passo(dt);
if (!ocupado) {
descanso += dt;
repouso(descanso);
if (r.dormindo) {
proxPiscar = proxGesto = t + 3600000;
} else if (t >= proxPiscar) {
animar("piscar");
proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto && !ATIV) {
animar(gestoSolto());
// cansado e triste mexem menos; feliz, mais
const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1;
proxGesto = t + (7000 + Math.random() * 8000) * ritmo;
}
} else if (t >= proxGesto) {
proxGesto = t + 7000 + Math.random() * 8000;
}
desenhar();
if (window.MotorRosto && window.MotorRosto.palco) window.MotorRosto.palco({ t: t / 1000, cor: CIANO });
contador++;
if (t - marco >= 1000) {
if (elFps.style.display !== "none") elFps.textContent = contador + " fps";
contador = 0; marco = t;
}
if (passoFixo > 0) {
const folga = passoFixo * 1000 - (performance.now() - t);
if (folga > 1) { setTimeout(() => requestAnimationFrame(laco), folga); return; }
}
requestAnimationFrame(laco);
}
requestAnimationFrame(laco);
// gesto inicial por URL: carinha.html?gesto=feliz
if (par.get("gesto")) setTimeout(() => trata(par.get("gesto")), 300);
// Relatorio de diagnostico: conta ao servidor o tamanho da janela e qual rosto
// esta em uso. Aparece no journal do servico carinha-web. Tambem e chamado
// quando alguem troca de rosto, para dar para conferir de longe.
function relatar() {
try {
const g = document.getElementById("tela").getBoundingClientRect();
const info = "rosto=" + ROSTO_NOME +
" janela=" + innerWidth + "x" + innerHeight +
" tela=" + screen.width + "x" + screen.height +
" svg=" + Math.round(g.width) + "x" + Math.round(g.height);
fetch("diag/" + encodeURIComponent(info)).catch(() => {});
} catch (e) {}
}
setTimeout(relatar, 1500);
/* Respostas escritas ficam na aba Conversar. No aparelho, só expressões. */
(function(){
if(location.protocol==='file:')return;
let seen=null,fetching=false;
async function poll(){
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
falar(m.texto);
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
}catch(e){}finally{fetching=false;}
}
setInterval(poll,1000);poll();
})();
/* ---------------------------------------------------------------- sem servidor
Sem a placa (copia do APK, ou o servidor caiu com a pagina aberta) a carinha dorme com a
ultima aparencia e ajustes guardados e mostra placa ✕ celular. Volta sozinha quando a placa
responde (o app recarrega a pagina do servidor; aberta pelo servidor, o SSE reconecta). */
let ultimoOk = performance.now();
const TOLERANCIA_SEM_SERVIDOR = 15000; // ms sem resposta antes de dormir
function conexaoOk() { ultimoOk = performance.now(); }
function mostrarSemServidor(ligar) {
let el = document.getElementById("semServidor");
document.body.classList.toggle("semServidor", ligar);
if (!ligar) { if (el) el.hidden = true; return; }
if (!el) {
el = document.createElement("div");
el.id = "semServidor";
// placa e celular pelo canto de cima à esquerda; o desenho vertical vai no canto direito com o celular deitado
const placa = (px, py) =>
'<g class="placa">' +
'<rect x="' + px + '" y="' + py + '" width="84" height="56" rx="6"/>' +
'<rect class="chip" x="' + (px + 29) + '" y="' + (py + 15) + '" width="26" height="26" rx="3"/>' +
[0,1,2,3,4,5,6,7].map((i) => '<circle class="pino" cx="' + (px + 11 + i * 9) + '" cy="' + (py + 7) + '" r="2"/>').join("") +
'<circle class="pino" cx="' + (px + 7) + '" cy="' + (py + 50) + '" r="2.4"/><circle class="pino" cx="' + (px + 77) + '" cy="' + (py + 50) + '" r="2.4"/>' +
'<circle class="led" cx="' + (px + 72) + '" cy="' + (py + 22) + '" r="2.6"/>' +
'</g>';
const celular = (cx, cy) =>
'<g class="celular">' +
'<rect x="' + cx + '" y="' + cy + '" width="40" height="76" rx="8"/>' +
'<g class="olhinhos"><ellipse cx="' + (cx + 13) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/><ellipse cx="' + (cx + 27) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/></g>' +
'<path class="boquinha" d="M' + (cx + 14) + ' ' + (cy + 48) + ' Q' + (cx + 20) + ' ' + (cy + 44) + ' ' + (cx + 26) + ' ' + (cy + 48) + '"/>' +
'</g>';
const xis = (x, y) => '<g class="xis"><path d="M' + (x - 10) + ' ' + (y - 10) + ' L' + (x + 10) + ' ' + (y + 10) + ' M' + (x + 10) + ' ' + (y - 10) + ' L' + (x - 10) + ' ' + (y + 10) + '"/></g>';
el.innerHTML =
'<svg class="horizontal" viewBox="0 0 300 104" aria-hidden="true">' + placa(18, 30) +
'<path class="cabo" d="M104 58 H146"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M212 50 A10 10 0 0 0 212 66"/>' +
'<path class="arco a2" d="M203 44 A18 18 0 0 0 203 72"/>' +
'<path class="arco a3" d="M194 38 A26 26 0 0 0 194 78"/>' +
'</g>' + xis(160, 58) + celular(220, 18) +
'</svg>' +
'<svg class="vertical" viewBox="0 0 120 262" aria-hidden="true">' + placa(18, 8) +
'<path class="cabo" d="M60 66 V108"/>' +
'<g class="arcos">' +
'<path class="arco a1" d="M52 172 A10 10 0 0 1 68 172"/>' +
'<path class="arco a2" d="M46 163 A18 18 0 0 1 74 163"/>' +
'<path class="arco a3" d="M40 154 A26 26 0 0 1 80 154"/>' +
'</g>' + xis(60, 122) + celular(40, 182) +
'</svg>' +
'<div class="titulo">sem conexão com a placa</div>' +
'<div class="sub"></div>';
document.body.appendChild(el);
}
let alvo = "";
try { alvo = (window.CarinhaApp && window.CarinhaApp.servidor()) || (LOCAL ? "" : location.host); } catch (e) {}
el.querySelector(".sub").textContent = "tentando de novo" + (alvo ? " · " + String(alvo).replace(/^https?:\/\//, "") : "") + "…";
el.style.color = "rgb(" + CIANO.map(Math.round).join(",") + ")";
el.hidden = false;
}
function checarServidor() {
if (MODO_PAINEL || par.get("espelho") === "1") return;
if (window.carinhaSSE) conexaoOk();
const fora = LOCAL || performance.now() - ultimoOk > TOLERANCIA_SEM_SERVIDOR;
if (window.carinhaSemServidor) mostrarSemServidor(true); // acompanha a cor guardada
if (fora === !!window.carinhaSemServidor) return;
window.carinhaSemServidor = fora;
console.log(fora ? "sem servidor: dormindo" : "servidor de volta");
mostrarSemServidor(fora);
if (fora) { atividade(null); r.fila = []; r.kf = null; r.dormindo = true; }
else trata("acordar");
}
// Copia do APK: os ultimos ajustes guardados (boca, sobrancelhas, sono...) valem também aqui.
setTimeout(() => { if (LOCAL) { const c = lembrado("config"); if (c) configurarApp(c); } checarServidor(); }, 300);
setInterval(checarServidor, 1000);
</script>
<script src="/reacoes-render.js"></script>
<script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
}
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
(window.carinhaSSE=false) até o EventSource reconectar. */
(function () {
const p = new URLSearchParams(location.search);
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
const aparelho = p.get('aparelho') === '1';
const t = window.carinhaToken;
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
let ultimaConversa = null, ultimaReacao = null, primeira = true;
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
fonte.addEventListener('pronto', () => {
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
if (primeira) { primeira = false; buscarOrdens(); }
window.carinhaSSE = true;
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
});
fonte.onerror = () => {
if (!window.carinhaSSE) return;
window.carinhaSSE = false;
// Volta ao polling sem repetir o que o SSE já entregou.
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
};
ouvir('expressao', (d) => trata(d.ordem));
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => {
if (window.MotorRosto) {
if (s.ocupado) window.MotorRosto.faixa('digitando');
else window.MotorRosto.faixa(null, { so: 'digitando' });
}
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
falar(m.texto);
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
ultimaReacao = d.id;
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
});
})();
</script>
</body>
</html>