- rosto-motor.js desenha luvinhas na cor dos olhos que sobem da borda da tela, sincronizadas com as sequencias de rosto de mesmo nome - faixa de baixo: legenda da resposta da conversa com efeito de digitacao, tres pontinhos enquanto a IA pensa, equalizador ao dancar e indicador de carga (raio e porcentagem) quando o celular esta no carregador - gestos novos no servidor, no painel e na tela Rosto; a IA (Qwen/Gemini) pode responder acenando, se escondendo ou pensando - a previa do painel tambem mostra a legenda da conversa Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1128 lines
51 KiB
HTML
1128 lines
51 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%}
|
|
.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}
|
|
</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>
|
|
/* 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 };
|
|
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];
|
|
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 caminhoBoca(tipo) {
|
|
const cx = BOCA[0], cy = BOCA[1], 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);
|
|
}
|
|
return "M " + (cx - l * 0.30) + "," + cy + // repouso: leve
|
|
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0";
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 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 (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: CIANO_E, 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: AZUL, 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]];
|
|
return { 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"];
|
|
|
|
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 ("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;
|
|
}
|
|
|
|
function animar(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;
|
|
}
|
|
|
|
function repouso(t) { // pose calma: olha devagar, "respira"
|
|
if (r.dormindo) {
|
|
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
|
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2));
|
|
r.ab = 0; r.ab2 = 0;
|
|
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
|
r.dx = 0;
|
|
r.dy = 3.0 * Math.sin(t * 1.4);
|
|
return;
|
|
}
|
|
r.forma = "aberto"; r.forma2 = null;
|
|
r.boca = "neutra";
|
|
r.cor = CIANO.slice();
|
|
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
|
|
// o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
|
|
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
|
|
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
|
|
}
|
|
|
|
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;
|
|
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
|
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
|
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
|
const comMotor = ROSTO.motor && window.MotorRosto;
|
|
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
|
|
if (comMotor) {
|
|
window.MotorRosto.desenhar({
|
|
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
|
|
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
|
|
carga: window.carinhaBateria || 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);
|
|
}
|
|
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
|
|
else desenharBoca();
|
|
for (let i = 0; i < 2; i++) {
|
|
const brow = document.getElementById('sobrancelha' + (i + 1));
|
|
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
|
|
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
|
const c = i === 0 ? E1 : E2;
|
|
const lado = i === 0 ? 1 : -1;
|
|
const largura = RX * esc * 0.85;
|
|
const y = c[1] - RY * esc - 20 * ESC;
|
|
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
|
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
|
|
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
|
|
brow.setAttribute('stroke', rgb(r.cor));
|
|
brow.setAttribute('stroke-width', 7 * ESC);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 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;
|
|
}
|
|
|
|
function trata(ordem) {
|
|
ordem = (ordem || "").trim().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 === "dormir" || ordem === "sono") {
|
|
r.dormindo = true;
|
|
} else if (ordem === "acordar") {
|
|
r.dormindo = false; animar("surpreso");
|
|
} else if (ordem === "aleatorio") {
|
|
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
|
} else if (SEQS[ordem]) {
|
|
if (window.MotorRosto) {
|
|
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
|
else window.MotorRosto.maos(ordem);
|
|
}
|
|
animar(ordem);
|
|
} else if (ordem === "calma") {
|
|
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
|
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
|
|
} 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 = 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;
|
|
const lista = await resp.json();
|
|
for (const nome of lista) trata(nome);
|
|
} catch (e) { /* sem servidor (file://) — ignora */ }
|
|
}
|
|
setInterval(buscarOrdens, 500);
|
|
|
|
// 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 (file://) isso falha calado e vale o padrao.
|
|
async function aplicarAparencia() {
|
|
try {
|
|
const resp = await fetch("config", { cache: "no-store" });
|
|
if (!resp.ok) return;
|
|
const c = await resp.json();
|
|
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); }
|
|
} catch (e) { /* sem servidor — segue com o padrao */ }
|
|
}
|
|
setTimeout(aplicarAparencia, 200);
|
|
// ?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 === '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'); return; }
|
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
|
|
if (dur < 350 && Math.hypot(dx, dy) < 15) {
|
|
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
|
|
};
|
|
|
|
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 >= MIC.limiarSom; // ruido audivel (absoluto)
|
|
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= 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 && temSom) { // acordou: tinha barulho
|
|
r.dormindo = false;
|
|
animar("surpreso");
|
|
MIC.proxSurpresa = agora + MIC.cooldown;
|
|
console.log("ouvido: acordou nivel=" + n.toFixed(2));
|
|
relatarSom("acordou", n);
|
|
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) {
|
|
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino
|
|
animar("surpreso");
|
|
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;
|
|
// 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) {}
|
|
}
|
|
|
|
// ------------------------------------------------------ presenca (movimento)
|
|
/* A ponte nativa do APK abre a camera FRONTAL, mede o movimento (diferenca de
|
|
luminancia entre quadros) e chama window.eilik.movimento(vel, cx, cy) umas
|
|
12x/s: vel vai de 0 (parado) a 1 (muito movimento) e cx,cy e o centro do que
|
|
se mexeu, de 0 a 1 na imagem. A carinha acorda quando algo se mexe na frente
|
|
dela e, acordada, os olhos acompanham o movimento. Sem a ponte nada acontece
|
|
(a telinha do Pi continua igual, sem camera). Ajustes por URL:
|
|
?movlim= ?movforte= ?movcooldown= ?movolhar= */
|
|
const MOV = {
|
|
ativo: false, // virou true na primeira leitura recebida
|
|
vel: 0, // ultimo movimento (0 a 1)
|
|
cx: 0.5, cy: 0.5, // ultimo centro do movimento (0 a 1)
|
|
visto: 0, // t da ultima leitura (s)
|
|
ultimoMov: 0, // t do ultimo movimento percebido (s)
|
|
proxSurpresa: 0, // t a partir do qual um novo susto pode disparar
|
|
estavaForte: false, // ja vinha em movimento forte (so o INICIO assusta)
|
|
ultimoLog: 0,
|
|
olharX: 0, olharY: 0, // deslocamento dos olhos na direcao do movimento
|
|
limiar: parseFloat(par.get("movlim") || "0.05"), // acima: houve movimento
|
|
limiarForte: parseFloat(par.get("movforte") || "0.12"), // acima: susto (medido: mao ~0,1)
|
|
cooldown: parseFloat(par.get("movcooldown") || "6"), // s entre sustos
|
|
olharMax: parseFloat(par.get("movolhar") || "40"), // quanto os olhos seguem
|
|
};
|
|
|
|
const TRACK = {alvo:null,visto:-Infinity};
|
|
function rastrear(t) {
|
|
if (!t) { TRACK.alvo=null; return; }
|
|
if (!Number.isFinite(t.x) || !Number.isFinite(t.y) || t.x<0 || t.x>1 || t.y<0 || t.y>1 || !['mao','rosto'].includes(t.tipo)) return;
|
|
if (!COMPORTAMENTO.reagir || configRecebida?.seguirRastreamento===false || configRecebida?.sensorCamera===false ||
|
|
(t.tipo==='mao' && configRecebida?.rastrearMaos===false) || (t.tipo==='rosto' && configRecebida?.rastrearRosto===false) ||
|
|
(r.dormindo && !configRecebida?.cameraSono)) return;
|
|
const agora=performance.now()/1000;
|
|
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
|
|
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
|
|
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
|
if(t.atividade===true) {
|
|
MOV.ultimoMov=agora;
|
|
if(r.dormindo) { 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 >= 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 >= MOV.limiarForte;
|
|
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente
|
|
r.dormindo = false;
|
|
animar("surpreso");
|
|
MOV.proxSurpresa = agora + MOV.cooldown;
|
|
console.log("presenca: acordou nivel=" + v.toFixed(2));
|
|
relatarMov("acordou", v);
|
|
} else if (forte && !MOV.estavaForte && agora >= MOV.proxSurpresa) {
|
|
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
|
|
MOV.proxSurpresa = agora + MOV.cooldown;
|
|
animar("surpreso");
|
|
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) {}
|
|
}
|
|
|
|
// ------------------------------------------------- camera oculta (sensor)
|
|
/* A propria pagina abre a camera para SENTIR o movimento sem mostrar a imagem:
|
|
o video fica escondido e a tela continua so com os olhos do robo. Serve para
|
|
o kiosk do Pi com uma webcam (a pagina e servida por 127.0.0.1, que conta
|
|
como contexto seguro). No celular quem faz isso e a ponte nativa, que tambem
|
|
nao mostra imagem — entao, se ela ja estiver mandando movimento, nao abrimos
|
|
outra camera (dois acessos juntos costumam falhar).
|
|
|
|
Para ver a imagem em um cantinho (so para conferir): ?campreview=1
|
|
Para desligar de vez: ?camera=0 */
|
|
const CAM = {
|
|
querer: par.get("camera") !== "0"
|
|
&& typeof window.isSecureContext !== "undefined" && window.isSecureContext,
|
|
ativo: false,
|
|
erro: null,
|
|
fluxo: null,
|
|
video: null,
|
|
cv: null,
|
|
ctx: null,
|
|
lumaAnt: null,
|
|
quadros: 0, // aquecimento: a exposicao demora a assentar
|
|
CW: 160, CH: 120,
|
|
limite: 26, // salto de luminancia (igual a camera.html)
|
|
mostrar: par.get("campreview") === "1",
|
|
atraso: parseInt(par.get("camatraso") || "2000", 10), // espera antes de abrir
|
|
};
|
|
|
|
async function ligarCamera() {
|
|
if (CAM.ativo) return true;
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
CAM.erro = "sem getUserMedia (contexto inseguro)";
|
|
return false;
|
|
}
|
|
try {
|
|
const fluxo = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: { ideal: "user" }, width: { ideal: 640 }, height: { ideal: 480 } },
|
|
audio: false,
|
|
});
|
|
CAM.fluxo = fluxo;
|
|
const v = document.createElement("video");
|
|
v.autoplay = true; v.muted = true; v.setAttribute("playsinline", "");
|
|
// escondido de verdade (fora da tela); so aparece com ?campreview=1
|
|
v.style.cssText = CAM.mostrar
|
|
? "position:fixed;right:8px;bottom:8px;width:160px;z-index:9;border:1px solid #24405a;border-radius:6px"
|
|
: "position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none";
|
|
v.srcObject = fluxo;
|
|
document.body.appendChild(v);
|
|
try { await v.play(); } catch (e) { /* segue */ }
|
|
CAM.video = v;
|
|
CAM.cv = document.createElement("canvas");
|
|
CAM.cv.width = CAM.CW; CAM.cv.height = CAM.CH;
|
|
CAM.ctx = CAM.cv.getContext("2d", { willReadFrequently: true });
|
|
CAM.quadros = 0;
|
|
CAM.ativo = true;
|
|
const f = fluxo.getVideoTracks()[0];
|
|
console.log("camera: sensor oculto ligado (" + ((f && f.label) || "camera") + ")");
|
|
requestAnimationFrame(lacoCamera);
|
|
return true;
|
|
} catch (e) {
|
|
CAM.erro = e.name;
|
|
console.log("camera: sensor oculto falhou — " + e.name);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function desligarCamera() {
|
|
CAM.ativo = false;
|
|
if (CAM.fluxo) { try { CAM.fluxo.getTracks().forEach((t) => t.stop()); } catch (e) {} }
|
|
if (CAM.video && CAM.video.parentNode) CAM.video.parentNode.removeChild(CAM.video);
|
|
CAM.fluxo = null; CAM.video = null; CAM.lumaAnt = null;
|
|
}
|
|
|
|
function detectarCamera() {
|
|
const v = CAM.video;
|
|
if (!v || !v.videoWidth) return;
|
|
CAM.ctx.drawImage(v, 0, 0, CAM.CW, CAM.CH);
|
|
const d = CAM.ctx.getImageData(0, 0, CAM.CW, CAM.CH).data;
|
|
if (!CAM.lumaAnt) CAM.lumaAnt = new Float32Array(CAM.CW * CAM.CH);
|
|
let mexidos = 0, sx = 0, sy = 0;
|
|
for (let i = 0; i < CAM.CW * CAM.CH; i++) {
|
|
const l = d[i * 4] * 0.299 + d[i * 4 + 1] * 0.587 + d[i * 4 + 2] * 0.114;
|
|
if (Math.abs(l - CAM.lumaAnt[i]) > CAM.limite) { mexidos++; sx += i % CAM.CW; sy += (i / CAM.CW) | 0; }
|
|
CAM.lumaAnt[i] = l;
|
|
}
|
|
CAM.quadros++;
|
|
if (CAM.quadros <= 6) return; // ignora o pico de entrada
|
|
const vel = Math.min(1, mexidos / (CAM.CW * CAM.CH * 0.25));
|
|
const cx = mexidos > 0 ? (sx / mexidos) / CAM.CW : 0.5;
|
|
const cy = mexidos > 0 ? (sy / mexidos) / CAM.CH : 0.5;
|
|
movimento(vel, cx, cy);
|
|
}
|
|
|
|
function lacoCamera() {
|
|
if (!CAM.ativo) return;
|
|
try { detectarCamera(); } catch (e) { CAM.erro = e.name; }
|
|
requestAnimationFrame(lacoCamera);
|
|
}
|
|
|
|
/* Decisao de abrir (ou nao) a camera escondida. Fica numa funcao a parte para
|
|
dar para conferir sem depender do relogio. */
|
|
function decidirCameraOculta() {
|
|
if (!CAM.querer) { if (!CAM.erro) CAM.erro = "desligada"; return; }
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
CAM.erro = "sem getUserMedia (contexto inseguro)";
|
|
console.log("camera: sem sensor na pagina — " + CAM.erro);
|
|
return;
|
|
}
|
|
// se a ponte nativa ja esta mandando movimento, deixa a camera com ela
|
|
// (dois acessos a camera ao mesmo tempo costumam falhar)
|
|
if (MOV.ativo) { CAM.erro = "ponte nativa no comando"; return; }
|
|
ligarCamera();
|
|
}
|
|
setTimeout(decidirCameraOculta, CAM.atraso);
|
|
|
|
// -------------------------------------------------------------- voz (saida)
|
|
/* Sons curtos sintetizados com Web Audio — sem arquivos, sem permissao e sem
|
|
rede. Cada gesto tem o seu (piscar e olhar ficam mudos de proposito). Para
|
|
silenciar: ?audio=0 ; volume: ?volume=0.5 */
|
|
const AUDIO = {
|
|
ligado: par.get("audio") !== "0",
|
|
volume: parseFloat(par.get("volume") || "0.35"),
|
|
ctx: null,
|
|
};
|
|
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
|
|
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
|
|
|
let configRecebida = null;
|
|
function configurarApp(c) {
|
|
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
|
|
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;
|
|
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,
|
|
bateria: window.carinhaBateria,
|
|
aparelho: window.carinhaAparelho,
|
|
video: window.carinhaVideo,
|
|
}) });
|
|
}
|
|
} catch (e) { /* servidor ausente: animação continua */ }
|
|
finally { sincronizando = false; }
|
|
}
|
|
setTimeout(sincronizarApp, 300);
|
|
setInterval(sincronizarApp, 1000);
|
|
|
|
function contextoAudio() {
|
|
if (AUDIO.ctx) return AUDIO.ctx;
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
if (!AC) return null;
|
|
try { AUDIO.ctx = new AC(); } catch (e) { return null; }
|
|
return AUDIO.ctx;
|
|
}
|
|
|
|
/* Uma nota simples: freq -> freqFim (se houver), com subida e queda suaves. */
|
|
function nota(c, freq, t0, dur, tipo, vol, freqFim) {
|
|
const osc = c.createOscillator();
|
|
const ganho = c.createGain();
|
|
osc.type = tipo || "sine";
|
|
osc.frequency.setValueAtTime(freq, t0);
|
|
if (freqFim) osc.frequency.exponentialRampToValueAtTime(freqFim, t0 + dur);
|
|
ganho.gain.setValueAtTime(0.0001, t0);
|
|
ganho.gain.exponentialRampToValueAtTime(vol, t0 + 0.012);
|
|
ganho.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
|
osc.connect(ganho);
|
|
ganho.connect(c.destination);
|
|
osc.start(t0);
|
|
osc.stop(t0 + dur + 0.03);
|
|
}
|
|
|
|
const SONS = {
|
|
feliz: (c, t, v) => { nota(c, 660, t, 0.12, "sine", v); nota(c, 990, t + 0.10, 0.16, "sine", v); },
|
|
alegre: (c, t, v) => [523, 659, 784, 1047].forEach((f, i) => nota(c, f, t + i * 0.07, 0.12, "triangle", v)),
|
|
coracao: (c, t, v) => { nota(c, 784, t, 0.5, "sine", v * 0.9); nota(c, 988, t + 0.04, 0.5, "sine", v * 0.7); },
|
|
piscadinha: (c, t, v) => { nota(c, 880, t, 0.09, "square", v * 0.6); nota(c, 1175, t + 0.09, 0.12, "square", v * 0.5); },
|
|
surpreso: (c, t, v) => nota(c, 300, t, 0.28, "sawtooth", v * 0.6, 1400), // "uou!"
|
|
bravo: (c, t, v) => nota(c, 220, t, 0.35, "sawtooth", v * 0.7, 90), // resmungo
|
|
triste: (c, t, v) => { nota(c, 500, t, 0.22, "sine", v * 0.8); nota(c, 330, t + 0.20, 0.34, "sine", v * 0.8); },
|
|
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
|
|
};
|
|
|
|
function tocarSom(gesto) {
|
|
if (!AUDIO.ligado) return false;
|
|
const f = SONS[gesto];
|
|
if (!f) return false;
|
|
const c = contextoAudio();
|
|
if (!c) return false;
|
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
|
try {
|
|
f(c, c.currentTime + 0.02, AUDIO.volume);
|
|
// 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);
|
|
|
|
const ocupado = congelado ? true : passo(dt);
|
|
if (!ocupado) {
|
|
descanso += dt;
|
|
repouso(descanso);
|
|
if (r.dormindo) {
|
|
proxPiscar = proxGesto = t + 3600000;
|
|
} else if (t >= proxPiscar) {
|
|
animar("piscar");
|
|
proxPiscar = t + 1600 + Math.random() * 3500;
|
|
} else if (AUTO && t >= proxGesto) {
|
|
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
|
proxGesto = t + 7000 + Math.random() * 8000;
|
|
}
|
|
} else if (t >= proxGesto) {
|
|
proxGesto = t + 7000 + Math.random() * 8000;
|
|
}
|
|
|
|
desenhar();
|
|
|
|
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;
|
|
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();
|
|
})();
|
|
|
|
</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;
|
|
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>
|