- comando "telao" (validado: texto curto, tom, símbolos permitidos, até 2 lados) e letreiro por cima do rosto: título enorme que estoura e pisca, as duas jogadas (VOCÊ ✊ × ✋ CARINHA), cor por resultado; - pedra, papel e tesoura: 3, 2, 1 piscando, "JÁ!", o que ele leu da sua mão, "VENCI!!!" / "VOCÊ GANHOU!" / "EMPATE!" com placar e o campeão; quantos dedos e achou! também usam o telão; - o cartão do painel mostra o mesmo destaque. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1824 lines
94 KiB
HTML
1824 lines
94 KiB
HTML
<!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}
|
||
/* telão dos jogos: por cima do rosto, texto grande na cor dos olhos (o resultado tem cor própria) */
|
||
#telao{position:fixed;inset:0;z-index:8;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||
gap:2vh;background:rgba(0,0,0,.9);font-family:system-ui,sans-serif;color:var(--cor);text-align:center;pointer-events:none}
|
||
#telao .titulo{font-weight:900;font-size:clamp(40px,24vh,240px);line-height:1;letter-spacing:.02em;
|
||
text-shadow:0 0 .25em currentColor}
|
||
#telao .sub{font-size:clamp(14px,6vh,48px);font-weight:600;opacity:.9}
|
||
#telao .lados{display:flex;align-items:center;gap:5vw}
|
||
#telao .lado{display:flex;flex-direction:column;align-items:center;gap:1vh;min-width:22vw}
|
||
#telao .rot{font-size:clamp(12px,5vh,36px);font-weight:700;letter-spacing:.1em;text-transform:uppercase;opacity:.85}
|
||
#telao .sim{font-size:clamp(40px,26vh,220px);line-height:1}
|
||
#telao .nome{font-size:clamp(12px,5vh,36px);font-weight:600}
|
||
#telao .x{font-size:clamp(24px,12vh,110px);font-weight:300;opacity:.7}
|
||
#telao.tom-eu .titulo{color:#ffd166}
|
||
#telao.tom-voce .titulo{color:#7ae582}
|
||
#telao.tom-empate .titulo{color:#cfe8ff}
|
||
#telao.tom-conta .titulo{font-size:clamp(60px,48vh,420px)}
|
||
#telao.entra .titulo,#telao.entra .lados{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1)}
|
||
#telao.piscar.entra .titulo{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1),telaoPisca .5s steps(2) .45s infinite}
|
||
@keyframes telaoEstoura{from{transform:scale(.3);opacity:0}to{transform:scale(1);opacity:1}}
|
||
@keyframes telaoPisca{50%{opacity:.25}}
|
||
@media(prefers-reduced-motion:reduce){#telao *{animation:none!important}}
|
||
/* 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"); }
|
||
lembrarDoDia(nome, receita);
|
||
const agora = performance.now() / 1000, dur = Number(segundos) || 0;
|
||
HUMANO.oops = dur > 20 && Math.random() < 0.35 ? { ini: agora + dur * (0.3 + 0.4 * Math.random()) } : null;
|
||
if (HUMANO.oops) HUMANO.oops.fim = HUMANO.oops.ini + 2.2;
|
||
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);
|
||
}
|
||
|
||
/* ------------------------------------------------------------- jeito de gente
|
||
Acordar grogue, sonhar dormindo, hesitar antes de agir, errar na atividade e ficar sem graça,
|
||
olhar nos olhos e desviar, sorrir de volta para quem sorri e reagir à luz do ambiente. */
|
||
const HUMANO = {
|
||
dormiaAntes: false, dormiuEm: 0, grogueIni: 0, grogueAte: 0, bocejou: true,
|
||
sonhoProx: 0, sonhoAte: 0, sonhos: ['estrela', 'bola', 'nota', 'coracao', 'livro', 'flor'],
|
||
oops: null, // {ini, fim}: um tropeço no meio da atividade
|
||
evita: { ate: 0, prox: 0, dx: 0, dy: 0 }, // desvio do olhar durante o contato visual
|
||
sorriso: { desde: 0, ultimo: 0 },
|
||
luz: { lux: null, media: null, sonolentoAte: 0, ofuscadoAte: 0, mudou: 0 },
|
||
};
|
||
const OBJETO_DA_ATIVIDADE = { bola: 'bola', ler: 'livro', cafe: 'xicara', cantar: 'nota', violao: 'nota', pintar: 'pincel',
|
||
regar: 'flor', telescopio: 'estrela', malhar: 'halter', bolhas: 'bolha', videogame: 'controle', dancar: 'estrela',
|
||
meditar: 'coracao', cozinhar: 'chama', computador: 'controle', pescar: 'bolha', pipa: 'estrela', skate: 'estrela',
|
||
corda: 'estrela' };
|
||
function lembrarDoDia(nome, receita) { // o que ele fez hoje vira assunto dos sonhos
|
||
const obj = receita && Array.isArray(receita.objetos) && receita.objetos[0] ? receita.objetos[0].tipo : OBJETO_DA_ATIVIDADE[nome];
|
||
if (obj) HUMANO.sonhos = [obj].concat(HUMANO.sonhos.filter((o) => o !== obj)).slice(0, 8);
|
||
}
|
||
function falarFrase(texto, segundos) { // "dizer ..." do modo vida: legenda + voz de robô
|
||
if (window.carinhaSemServidor || !texto || r.dormindo) return;
|
||
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) });
|
||
falar(texto);
|
||
}
|
||
/* Telão dos jogos: contagem regressiva piscando, as duas jogadas lado a lado e o resultado grande. */
|
||
let telaoAte = null;
|
||
function mostrarTelao(json) {
|
||
let t;
|
||
try { t = JSON.parse(json); } catch (e) { return; }
|
||
let el = document.getElementById('telao');
|
||
if (!el) { el = document.createElement('div'); el.id = 'telao'; document.body.appendChild(el); }
|
||
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const lados = (t.lados || []).map((l) =>
|
||
'<div class="lado"><span class="rot">' + esc(l.rotulo) + '</span><span class="sim">' + esc(l.simbolo) + '</span>' +
|
||
'<span class="nome">' + esc(l.nome) + '</span></div>').join('<div class="x">×</div>');
|
||
el.className = 'tom-' + (t.tom || 'neutro') + (t.piscar ? ' piscar' : '');
|
||
el.style.setProperty('--cor', 'rgb(' + CIANO.map(Math.round).join(',') + ')');
|
||
el.innerHTML = (lados ? '<div class="lados">' + lados + '</div>' : '') +
|
||
(t.titulo ? '<div class="titulo">' + esc(t.titulo) + '</div>' : '') + (t.sub ? '<div class="sub">' + esc(t.sub) + '</div>' : '');
|
||
el.hidden = false;
|
||
void el.offsetWidth; el.classList.add('entra'); // reinicia a animação a cada letreiro
|
||
if (r.dormindo) r.dormindo = false;
|
||
clearTimeout(telaoAte);
|
||
telaoAte = setTimeout(() => { el.hidden = true; }, (t.segundos || 2.5) * 1000);
|
||
}
|
||
let olhosAte = null;
|
||
function mostrarNosOlhos(texto) { // "✌️ 2.5": símbolo nos olhos; "- 0" apaga
|
||
const i = texto.lastIndexOf(' ');
|
||
const simbolo = i > 0 ? texto.slice(0, i) : texto, seg = i > 0 ? parseFloat(texto.slice(i + 1)) : 2.5;
|
||
clearTimeout(olhosAte);
|
||
if (r.dormindo) { r.dormindo = false; }
|
||
window.carinhaOlhosIcone = simbolo && simbolo !== '-' ? simbolo : null;
|
||
if (window.carinhaOlhosIcone) olhosAte = setTimeout(() => { window.carinhaOlhosIcone = null; }, (seg || 2.5) * 1000);
|
||
}
|
||
function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir
|
||
if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn();
|
||
setTimeout(fn, 150 + Math.random() * 450);
|
||
}
|
||
function humanoTick(t) {
|
||
// acordou depois de dormir um tempo: fica grogue (olhos pesados, lento) e boceja
|
||
if (r.dormindo && !HUMANO.dormiaAntes) HUMANO.dormiuEm = t;
|
||
if (!r.dormindo && HUMANO.dormiaAntes && t - HUMANO.dormiuEm > 180) {
|
||
HUMANO.grogueIni = t; HUMANO.grogueAte = t + 30 + Math.random() * 20; HUMANO.bocejou = false;
|
||
}
|
||
HUMANO.dormiaAntes = r.dormindo;
|
||
if (!HUMANO.bocejou && t - HUMANO.grogueIni > 1.6 && !r.dormindo && !r.kf) { HUMANO.bocejou = true; animar('bocejar'); }
|
||
// dormindo (com a tela acesa): de vez em quando sonha com algo do dia
|
||
if (r.dormindo && !SONO.apagada && !window.carinhaSemServidor) {
|
||
if (!HUMANO.sonhoProx) HUMANO.sonhoProx = t + 40 + Math.random() * 60;
|
||
if (t >= HUMANO.sonhoProx) {
|
||
const dur = 6 + Math.random() * 4;
|
||
HUMANO.sonhoAte = t + dur; HUMANO.sonhoProx = t + dur + 45 + Math.random() * 90;
|
||
const obj = HUMANO.sonhos[Math.floor(Math.random() * Math.min(4, HUMANO.sonhos.length))];
|
||
if (window.MotorRosto && window.MotorRosto.sonho) window.MotorRosto.sonho(obj, dur);
|
||
}
|
||
} else HUMANO.sonhoProx = 0;
|
||
}
|
||
function grogue(t) { // 1 = acabou de acordar, 0 = esperto
|
||
return t < HUMANO.grogueAte ? Math.min(1, (HUMANO.grogueAte - t) / (HUMANO.grogueAte - HUMANO.grogueIni)) : 0;
|
||
}
|
||
function piscarHumano(t) { animar(grogue(t) > 0.2 || t < HUMANO.luz.sonolentoAte ? 'piscar2' : 'piscar'); }
|
||
function desvioDoOlhar(t) { // olha nos olhos 2 a 5 s, desvia um instante e volta
|
||
const ev = HUMANO.evita;
|
||
if (t >= ev.prox) {
|
||
const dura = Math.random() < 0.7 ? 0.6 + Math.random() * 0.7 : 0;
|
||
ev.ate = t + dura; ev.prox = ev.ate + 2 + Math.random() * 3;
|
||
ev.dx = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 8); ev.dy = 3 + Math.random() * 5;
|
||
}
|
||
return t < ev.ate ? ev : null;
|
||
}
|
||
function contagio(t, agora) { // sorriu para ele (rastreamento do painel): sorri de volta
|
||
const S = HUMANO.sorriso;
|
||
if (t.tipo !== 'rosto' || typeof t.sorriso !== 'number' || r.dormindo || ATIV) { S.desde = 0; return; }
|
||
if (t.sorriso > 0.55) { if (!S.desde) S.desde = agora; } else S.desde = 0;
|
||
if (S.desde && agora - S.desde > 0.6 && agora - S.ultimo > 20 && !r.kf) {
|
||
S.ultimo = agora; S.desde = 0;
|
||
animar(t.sorriso > 0.8 ? 'rir' : 'feliz');
|
||
}
|
||
}
|
||
function luzAmbiente(lux) { // sensor de luz do celular (lux)
|
||
lux = Number(lux);
|
||
if (!Number.isFinite(lux) || lux < 0) return;
|
||
const L = HUMANO.luz, t = performance.now() / 1000, antes = L.media;
|
||
L.lux = lux;
|
||
L.media = antes === null ? lux : antes + (lux - antes) * 0.15;
|
||
if (antes === null || t - L.mudou < 8) return;
|
||
if (lux < 3 && antes > 25) { // apagaram a luz: fica com sono
|
||
L.mudou = t; L.sonolentoAte = t + 60; L.media = lux;
|
||
if (!r.dormindo && !r.kf) animar('bocejar');
|
||
} else if (lux > 80 && antes < 8) { // acenderam a luz de repente: aperta os olhos
|
||
L.mudou = t; L.ofuscadoAte = t + 1.8; L.media = lux;
|
||
if (r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) r.dormindo = false;
|
||
}
|
||
}
|
||
|
||
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);
|
||
if (t < HUMANO.sonhoAte) { r.dx = 3.2 * Math.sin(t * 6.5); r.boca = 'sorriso'; } // sonhando
|
||
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) * (1 + 2 * grogue(t)); // grogue: olhar lento
|
||
}
|
||
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;
|
||
const o = HUMANO.oops; // errou: susto e depois fica sem graça
|
||
if (o && t >= o.ini && t < o.fim) {
|
||
if (t - o.ini < 0.5) { r.forma = 'surpreso'; r.boca = 'aberta'; r.ab = 1.1; }
|
||
else { r.forma = 'feliz'; r.boca = 'sorriso'; r.cor = lerpCor(r.cor, ROSA, 0.5); r.dy += 6; }
|
||
}
|
||
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
|
||
r.forma = "bravo";
|
||
}
|
||
const g = grogue(t);
|
||
if (g > 0) { // acabou de acordar: olhos pesados, cor apagada, olhar baixo
|
||
r.ab = Math.min(r.ab, 1 - 0.5 * g);
|
||
r.cor = lerpCor(r.cor, corEscura(0.7), 0.35 * g);
|
||
if (g > 0.5 && !ATIV) r.boca = 'sono';
|
||
r.dx *= 1 - 0.5 * g; r.dy = r.dy * (1 - 0.5 * g) + 3 * g;
|
||
}
|
||
if (t < HUMANO.luz.sonolentoAte) r.ab = Math.min(r.ab, 0.78);
|
||
if (t < HUMANO.luz.ofuscadoAte) { r.ab = Math.min(r.ab, 0.3); r.forma = 'feliz'; }
|
||
}
|
||
|
||
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 ev = seguindo && TRACK.alvo.tipo === 'rosto' ? desvioDoOlhar(tAgora) : null;
|
||
const dx = seguindo ? (ev ? MOV.olharX * 0.3 + ev.dx : MOV.olharX) : r.dx;
|
||
const dy = seguindo ? (ev ? MOV.olharY * 0.3 + ev.dy : 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
|
||
if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; }
|
||
if (/^olhos /i.test(bruta)) { mostrarNosOlhos(bruta.slice(6).trim()); return; } // jogos: ✊ ✋ ✌️ e números
|
||
if (/^telao /i.test(bruta)) { mostrarTelao(bruta.slice(6)); return; } // letreiro grande dos jogos
|
||
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;
|
||
}
|
||
const longo = r.dormindo && performance.now() / 1000 - HUMANO.dormiuEm > 180;
|
||
r.dormindo = false;
|
||
if (!longo) animar("surpreso"); // sono longo: acorda devagar, grogue (humanoTick)
|
||
} 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 });
|
||
}
|
||
hesitar(() => animar(ordem), 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;
|
||
contagio(t, agora);
|
||
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
|
||
if (window.eilik) window.eilik.luz = luzAmbiente; // sensor de luz do celular (o app chama)
|
||
|
||
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,
|
||
luz: HUMANO.luz.lux,
|
||
}) });
|
||
}
|
||
} 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);
|
||
humanoTick(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) {
|
||
piscarHumano(t / 1000);
|
||
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>
|