Files
carinha-control-panel/rosto-motor.js
T
julioandClaude Opus 5.5 628db8492f feat(rosto): maos (esconder, espiar, acenar, pensar) e faixa de baixo
- 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>
2026-10-06 02:39:00 -03:00

415 lines
21 KiB
JavaScript

/* rosto-motor.js — motor de pálpebras dos rostos novos (Carinha Viva, Bloco, Matriz, Lente).
*
* O carinha.html continua decidindo O QUE o rosto faz (gestos, reações, sensores, rastreamento);
* aqui só se desenha. Cada "forma" vira posição de pálpebra (cima, inclinação, baixo) e o motor
* suaviza a passagem entre elas, então feliz → triste não salta de um desenho para outro.
* Também acrescenta vida: respiração, sacadas e achatamento na piscada.
*/
(function () {
'use strict';
// forma → pálpebras. top: pálpebra de cima (0 aberto, 1 fechado); slant: + = canto interno sobe;
// bot: pálpebra de baixo sobe ("olho sorrindo").
const PALPEBRAS = {
aberto: { top: 0, slant: 0, bot: 0 },
surpreso: { top: 0, slant: 0, bot: 0 },
feliz: { top: 0, slant: 0, bot: 0.46 },
coracao: { top: 0, slant: 0, bot: 0.42 },
triste: { top: 0.3, slant: 0.32, bot: 0 },
bravo: { top: 0.34, slant: -0.42, bot: 0.06 },
dormir: { top: 0.9, slant: 0.04, bot: 0.08 },
};
// Medidas em frações do raio vertical do olho clássico (RY), para casar com o layout atual.
const ESTILOS = {
vivo: { w: 1.0, h: 1.45, r: 0.5, brilho: true },
bloco: { w: 1.35, h: 1.25, r: 0.22, brilho: true },
matriz: { w: 1.0, h: 1.45, r: 0.5, brilho: false, pontos: true },
lente: { w: 1.15, h: 1.15, r: 0.58, lente: true },
};
// Formato do olho, escolhido no painel e combinável com Viva, Bloco e Matriz.
const FORMATOS = {
padrao: null,
redondo: { w: 1.15, h: 1.15, r: 0.58 },
pilula: { w: 0.8, h: 1.55, r: 0.4 },
quadrado: { w: 1.3, h: 1.3, r: 0.12 },
largo: { w: 1.65, h: 0.9, r: 0.3 },
};
let tela = null, ctx = null, camada = null, cctx = null, baixa = null, bctx = null;
const estado = [novo(), novo()];
const sacada = { x: 0, y: 0, alvoX: 0, alvoY: 0, proxima: 0 };
let antes = 0;
function novo() { return { top: 0, slant: 0, bot: 0, coracao: 0 }; }
const css = (c, a) => 'rgba(' + c.map((v) => Math.round(v)).join(',') + ',' + (a === undefined ? 1 : a) + ')';
function preparar() {
if (tela) return;
tela = document.createElement('canvas');
tela.id = 'rostoMotor';
tela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;display:none';
document.body.append(tela);
ctx = tela.getContext('2d');
camada = document.createElement('canvas'); cctx = camada.getContext('2d');
baixa = document.createElement('canvas'); bctx = baixa.getContext('2d', { willReadFrequently: true });
}
function medir() {
// Até 1,5x a densidade: a tela do S10 é enorme e o brilho custa caro.
const d = Math.min(1.5, window.devicePixelRatio || 1);
const w = Math.round(innerWidth * d), h = Math.round(innerHeight * d);
if (tela.width !== w || tela.height !== h) { tela.width = camada.width = w; tela.height = camada.height = h; }
return d;
}
function caixa(c, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w / 2, h / 2));
c.beginPath();
if (c.roundRect) c.roundRect(x, y, w, h, r);
else { // WebViews antigos
c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath();
}
}
function coracao(c, cx, cy, s) {
c.beginPath();
c.moveTo(cx, cy + s * 0.72);
c.bezierCurveTo(cx - s * 1.3, cy - s * 0.3, cx - s * 0.8, cy - s * 1.18, cx, cy - s * 0.3);
c.bezierCurveTo(cx + s * 0.8, cy - s * 1.18, cx + s * 1.3, cy - s * 0.3, cx, cy + s * 0.72);
c.fill();
}
const FONTE_EMOJI = '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif';
/** Figurinha (viewBox 100x100, só M L C Q Z) ou emoji, centrado no olho. */
function conteudo(c, o, cx, cy, tam) {
if (o.desenho && Array.isArray(o.desenho.formas)) {
c.save();
c.translate(cx - tam / 2, cy - tam / 2);
c.scale(tam / 100, tam / 100);
for (const f of o.desenho.formas) {
if (typeof f.caminho !== 'string' || !/^[MLCQZmlcqz0-9.,\s+-]+$/.test(f.caminho) || !/^#[0-9a-f]{6}$/i.test(f.cor)) continue;
c.fillStyle = f.cor;
c.fill(new Path2D(f.caminho));
}
c.restore();
} else if (o.icone) {
c.save();
c.font = Math.round(tam * 0.8) + 'px ' + FONTE_EMOJI;
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(o.icone, cx, cy + tam * 0.04);
c.restore();
}
}
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
function olhoPlano(c, o, p, est, e) {
const respira = 1 + 0.012 * Math.sin(o.t * 1.4);
const temConteudo = !!(o.icone || o.desenho);
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 };
const fecha = Math.min(1, Math.max(e.top, 1 - Math.min(1, p.ab)));
const abre = Math.max(1, p.ab); // surpreso abre além de 1
const amassa = fecha > 0.5 && e.top < 0.5 ? (fecha - 0.5) * 0.2 : 0; // piscada achata e alarga
const w = est.w * p.ry * respira * (1 + amassa);
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor);
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill();
c.save();
caixa(c, x - 2, y - 2, w + 4, h + 4, est.r * p.ry);
c.clip();
c.globalCompositeOperation = 'destination-out';
c.fillStyle = '#000';
const sl = p.lado > 0 ? e.slant : -e.slant; // lado +1 = olho esquerdo; canto interno = lado do nariz
const yEsq = y + h * (fecha + sl * 0.5), yDir = y + h * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(x - 4, y - 4); c.lineTo(x + w + 4, y - 4); c.lineTo(x + w + 4, yDir); c.lineTo(x - 4, yEsq); c.closePath(); c.fill();
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
c.fillStyle = 'rgba(245,250,255,0.9)';
c.beginPath(); c.ellipse(hx, hy, w * 0.1, h * 0.08, 0, 0, Math.PI * 2); c.fill();
}
c.restore();
}
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) {
const R = est.w * p.ry * 0.5 * Math.max(1, p.ab) * (1 + 0.01 * Math.sin(o.t * 1.4));
const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save();
c.beginPath(); c.arc(p.cx, p.cy, R * 1.12, 0, Math.PI * 2); c.fillStyle = '#1b2029'; c.fill();
c.beginPath(); c.arc(p.cx, p.cy, R, 0, Math.PI * 2); c.clip();
c.fillStyle = '#e9eef3'; c.fillRect(p.cx - R, p.cy - R, 2 * R, 2 * R);
if (o.icone || o.desenho) {
conteudo(c, o, p.cx, p.cy, R * 1.3);
e = { top: 0, slant: 0, bot: 0 };
} else {
const ix = p.cx + Math.max(-R * 0.4, Math.min(R * 0.4, (p.lx + sacada.x) * R * 0.02));
const iy = p.cy + Math.max(-R * 0.35, Math.min(R * 0.35, (p.ly + sacada.y) * R * 0.02));
const g = c.createRadialGradient(ix, iy, R * 0.05, ix, iy, R * 0.55);
g.addColorStop(0, css(o.cor.map((v) => v * 0.5))); g.addColorStop(1, css(o.cor));
c.beginPath(); c.arc(ix, iy, R * 0.55, 0, Math.PI * 2); c.fillStyle = g; c.fill();
const pupila = p.ab > 1.05 ? 0.12 : e.bot > 0.3 ? 0.3 : 0.2; // susto contrai, alegria dilata
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fillStyle = '#05070a'; c.fill();
c.beginPath(); c.arc(ix - R * 0.2, iy - R * 0.22, R * 0.09, 0, Math.PI * 2); c.fillStyle = 'rgba(255,255,255,0.9)'; c.fill();
}
const metal = c.createLinearGradient(0, p.cy - R, 0, p.cy + R);
metal.addColorStop(0, '#5b6675'); metal.addColorStop(1, '#2b323d');
c.fillStyle = metal;
const sl = p.lado > 0 ? e.slant : -e.slant;
const yEsq = p.cy - R + 2 * R * (fecha + sl * 0.5), yDir = p.cy - R + 2 * R * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(p.cx - R, p.cy - R); c.lineTo(p.cx + R, p.cy - R); c.lineTo(p.cx + R, yDir); c.lineTo(p.cx - R, yEsq); c.closePath(); c.fill();
c.strokeStyle = '#8994a3'; c.lineWidth = Math.max(1, R * 0.03);
c.beginPath(); c.moveTo(p.cx - R, yEsq); c.lineTo(p.cx + R, yDir); c.stroke();
c.beginPath(); c.ellipse(p.cx, p.cy + R * (2.3 - 2 * e.bot), R * 1.3, R * 1.3, 0, 0, Math.PI * 2); c.fill();
c.restore();
}
function atualizarPalpebras(dt, olhos, lento) {
const k = 1 - Math.exp(-dt * (lento ? 4 : 11));
olhos.forEach((p, i) => {
const alvo = PALPEBRAS[p.forma] || PALPEBRAS.aberto, e = estado[i];
e.top += (alvo.top - e.top) * k;
e.slant += (alvo.slant - e.slant) * k;
e.bot += (alvo.bot - e.bot) * k;
e.coracao += ((p.forma === 'coracao' ? 1 : 0) - e.coracao) * Math.min(1, k * 2);
});
}
function atualizarSacada(t, dt, parado) {
// Pequenos saltos do olhar quando nada está acontecendo; olhos de verdade nunca ficam parados.
if (parado && t > sacada.proxima) {
sacada.alvoX = (Math.random() - 0.5) * 14;
sacada.alvoY = (Math.random() - 0.5) * 6;
sacada.proxima = t + 0.8 + Math.random() * 2.4;
} else if (!parado) { sacada.alvoX = sacada.alvoY = 0; }
const k = 1 - Math.exp(-dt * 22);
sacada.x += (sacada.alvoX - sacada.x) * k;
sacada.y += (sacada.alvoY - sacada.y) * k;
}
function desenharPontos(estilo, o, olhos, d) {
// Matriz: desenha os olhos numa tela pequena e acende um LED por pixel.
const linhas = 38, colunas = Math.round(linhas * tela.width / tela.height);
if (baixa.width !== colunas || baixa.height !== linhas) { baixa.width = colunas; baixa.height = linhas; }
const f = linhas / tela.height * d;
bctx.clearRect(0, 0, colunas, linhas);
bctx.save(); bctx.scale(f, f);
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
desenharMaos(bctx, o, olhos, tela.height / d);
bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
const passo = tela.width / colunas, raio = passo * 0.36;
for (let j = 0; j < linhas; j++) for (let i = 0; i < colunas; i++) {
const k = (j * colunas + i) * 4, a = px[k + 3] / 255;
ctx.beginPath(); ctx.arc((i + 0.5) * passo, (j + 0.5) * passo, raio, 0, Math.PI * 2);
ctx.fillStyle = a > 0.25 ? css([px[k], px[k + 1], px[k + 2]], 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)';
ctx.fill();
}
}
/**
* Desenha um quadro. o = { estilo, formato, t, cor:[r,g,b], dormindo, parado,
* olhos: [{ cx, cy, ry, ab, forma, lado, lx, ly }, ...] } em pixels CSS.
*/
function desenhar(o) {
preparar();
const base = ESTILOS[o.estilo];
if (!base) return esconder();
const formato = !base.lente && FORMATOS[o.formato];
const estilo = formato ? Object.assign({}, base, formato) : base;
tela.style.display = '';
const d = medir();
const dt = antes ? Math.min(0.1, o.t - antes) : 0.033;
antes = o.t;
atualizarPalpebras(dt, o.olhos, o.dormindo);
atualizarSacada(o.t, dt, o.parado);
const olhos = o.olhos.map((p) => Object.assign({}, p, {
cx: (p.cx + (o.parado ? sacada.x * 0.35 : 0)) * d,
cy: (p.cy + (o.parado ? sacada.y * 0.35 : 0)) * d,
ry: p.ry * d,
}));
ctx.clearRect(0, 0, tela.width, tela.height);
if (estilo.pontos) {
desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
} else if (estilo.lente) {
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
desenharMaos(ctx, o, olhos, tela.height);
} else {
cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
desenharMaos(cctx, o, olhos, tela.height);
ctx.save();
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
ctx.drawImage(camada, 0, 0);
ctx.restore();
}
desenharFaixa(ctx, o, tela.width, tela.height);
}
// ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
// de rosto de mesmo nome no carinha.html.
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
const maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
// Luva simples: polegar atrás da palma e dois vincos marcando os dedos.
function mao(c, o, x, y, s, ang, espelho) {
const escura = o.cor.map((v) => v * 0.36);
c.save();
c.translate(x, y); c.rotate(ang); c.scale(espelho, 1);
c.fillStyle = css(escura); c.strokeStyle = css(o.cor); c.lineWidth = s * 0.1; c.lineJoin = 'round'; c.lineCap = 'round';
c.beginPath(); c.ellipse(0.55 * s, 0.12 * s, 0.24 * s, 0.4 * s, -0.55, 0, Math.PI * 2); c.fill(); c.stroke(); // polegar
caixa(c, -0.6 * s, -0.75 * s, 1.2 * s, 1.55 * s, 0.55 * s); c.fill(); c.stroke(); // palma e dedos
c.lineWidth = s * 0.07;
for (const dx of [-0.2, 0.2]) { c.beginPath(); c.moveTo(dx * s, -0.72 * s); c.lineTo(dx * s, -0.34 * s); c.stroke(); } // vincos
c.restore();
}
function desenharMaos(c, o, olhos, H) {
if (!maos.modo) return;
if (maos.t0 === null) maos.t0 = o.t;
const k = (o.t - maos.t0) / DUR_MAOS[maos.modo];
if (k >= 1) { maos.modo = null; return; }
const [e, d] = olhos.slice().sort((a, b) => a.cx - b.cx); // olho da esquerda e da direita na tela
const s = e.ry * 0.95, fora = H + s * 1.6;
const subir = (y, a, b) => fora + (y - fora) * faixaDe(k, a, b);
if (maos.modo === 'esconder' || maos.modo === 'espiar') {
const altura = (y) => (k < 0.8 ? subir(y, 0, 0.2) : y + (fora - y) * faixaDe(k, 0.82, 1));
const treme = Math.sin(o.t * 24) * s * 0.025; // tímido: a mão treme um pouco
let afasta = 0;
if (maos.modo === 'espiar') afasta = s * 1.35 * (faixaDe(k, 0.3, 0.42) - faixaDe(k, 0.66, 0.76));
mao(c, o, e.cx - afasta + treme, altura(e.cy + e.ry * 0.15), s, -0.12 - afasta / s * 0.3, 1);
mao(c, o, d.cx - treme, altura(d.cy + d.ry * 0.15), s, 0.12, -1);
} else if (maos.modo === 'acenar') {
const x = d.cx + d.ry * 2.1, y0 = d.cy + d.ry * 0.7;
const y = k < 0.8 ? subir(y0, 0, 0.2) : y0 + (fora - y0) * faixaDe(k, 0.82, 1);
const balanco = k > 0.2 && k < 0.8 ? Math.sin((k - 0.2) * Math.PI * 7) * 0.38 : 0;
mao(c, o, x, y, s * 0.9, balanco, -1);
} else if (maos.modo === 'pensar') {
const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4;
const y = (k < 0.8 ? subir(y0, 0, 0.2) : y0 + (fora - y0) * faixaDe(k, 0.82, 1)) + Math.sin(o.t * 9) * s * 0.03;
mao(c, o, x, y, s * 0.75, -0.35, -1);
}
}
// --------------------------------------------------------- faixa de baixo
// A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga.
const faixa = { modo: null, texto: '', t0: null, dur: 0 };
function definirFaixa(modo, opcoes) {
opcoes = opcoes || {};
if (!modo) {
if (!opcoes.so || faixa.modo === opcoes.so) faixa.modo = null; // so: só apaga esse modo
return;
}
faixa.modo = modo; faixa.t0 = performance.now() / 1000; faixa.texto = String(opcoes.texto || '').replace(/\s+/g, ' ').trim().slice(0, 400);
faixa.dur = opcoes.segundos || (modo === 'legenda' ? Math.max(3, Math.min(14, faixa.texto.length / 14 + 2.5)) : modo === 'digitando' ? 20 : 2.6);
}
function desenharFaixa(c, o, W, H) {
const y = H * 0.955, fonte = Math.round(H * 0.042);
c.save();
c.shadowColor = css(o.cor, 0.6); c.shadowBlur = H * 0.02;
if (o.carga && o.carga.carregando && typeof o.carga.nivel === 'number') { // carregando: canto direito
const n = Math.max(0, Math.min(100, o.carga.nivel)), bw = H * 0.075, bh = H * 0.036, bx = W - H * 0.13, by = y - bh / 2;
c.globalAlpha = 0.55 + 0.25 * Math.sin(o.t * 2.5);
c.strokeStyle = css(o.cor); c.lineWidth = Math.max(1, H * 0.005);
caixa(c, bx, by, bw, bh, bh * 0.25); c.stroke();
c.fillStyle = css(o.cor); c.fillRect(bx + bw, y - bh * 0.2, bh * 0.15, bh * 0.4);
c.fillRect(bx + bh * 0.12, by + bh * 0.15, (bw - bh * 0.24) * n / 100, bh * 0.7);
c.font = '600 ' + Math.round(fonte * 0.8) + 'px system-ui,sans-serif'; c.textAlign = 'right'; c.textBaseline = 'middle';
const tx = bx - H * 0.012;
c.fillText(Math.round(n) + '%', tx, y);
const rx = tx - c.measureText(Math.round(n) + '%').width - H * 0.022, r = H * 0.016; // raio desenhado na cor dos olhos
c.beginPath(); c.moveTo(rx + r * 0.3, y - r); c.lineTo(rx - r * 0.5, y + r * 0.15); c.lineTo(rx, y + r * 0.15);
c.lineTo(rx - r * 0.3, y + r); c.lineTo(rx + r * 0.5, y - r * 0.15); c.lineTo(rx, y - r * 0.15); c.closePath(); c.fill();
c.globalAlpha = 1;
}
if (faixa.modo) {
if (faixa.t0 === null) faixa.t0 = o.t;
const k = (o.t - faixa.t0) / faixa.dur;
if (k >= 1) faixa.modo = null;
else {
const some = Math.min(1, (faixa.dur - (o.t - faixa.t0)) / 0.4);
c.globalAlpha = Math.max(0, some);
c.fillStyle = css(o.cor);
if (faixa.modo === 'legenda') {
// efeito de digitação; se não couber, mostra o final do que já foi "dito"
let txt = faixa.texto.slice(0, Math.floor((o.t - faixa.t0) * 30));
c.font = '500 ' + fonte + 'px system-ui,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
const max = W * 0.8;
while (txt.length > 1 && c.measureText(txt).width > max) txt = txt.slice(1);
c.fillText(txt, W / 2, y);
} else if (faixa.modo === 'digitando') {
for (let i = 0; i < 3; i++) {
const pulo = Math.max(0, Math.sin(o.t * 7 - i * 0.7)) * H * 0.012;
c.beginPath(); c.arc(W / 2 + (i - 1) * H * 0.035, y - pulo, H * 0.011, 0, Math.PI * 2); c.fill();
}
} else if (faixa.modo === 'equalizador') {
const n = 15, larg = H * 0.016, passo = H * 0.026;
for (let i = 0; i < n; i++) {
const a = 0.25 + 0.75 * Math.abs(Math.sin(o.t * (5 + i % 4) + i * 1.3)) * Math.sin(Math.PI * (i + 0.5) / n);
const h = H * 0.04 * a;
caixa(c, W / 2 + (i - (n - 1) / 2) * passo - larg / 2, H * 0.988 - h, larg, h, larg / 2); c.fill();
}
}
}
}
c.restore();
}
/**
* Retrato parado num canvas qualquer (miniaturas da biblioteca no painel).
* o = { estilo, formato, cor, forma, icone, desenho }.
*/
function retrato(alvo, o) {
const base = ESTILOS[o.estilo] || ESTILOS.vivo;
const formato = !base.lente && FORMATOS[o.formato];
const est = formato ? Object.assign({}, base, formato) : base;
const W = alvo.width, H = alvo.height, c = alvo.getContext('2d');
const pal = Object.assign({ coracao: o.forma === 'coracao' ? 1 : 0 }, PALPEBRAS[o.forma] || PALPEBRAS.aberto);
const op = { t: 0, cor: o.cor || [92, 214, 255], icone: o.icone || null, desenho: o.desenho || null };
const ry = H * 0.36, sep = Math.min(W * 0.23, H * 0.62);
const olhos = [-1, 1].map((s) => ({ cx: W / 2 + s * sep, cy: H / 2, ry, ab: o.forma === 'surpreso' ? 1.12 : 1, forma: o.forma, lado: -s, lx: 0, ly: 0 }));
c.clearRect(0, 0, W, H);
if (est.lente) { olhos.forEach((p) => olhoLente(c, op, p, est, pal)); return; }
const tmp = document.createElement('canvas'); tmp.width = W; tmp.height = H;
const t = tmp.getContext('2d');
olhos.forEach((p) => olhoPlano(t, op, p, est, Object.assign({}, pal)));
if (est.pontos) {
const passo = Math.max(3, Math.round(H / 16)), d = t.getImageData(0, 0, W, H).data;
for (let y = passo / 2; y < H; y += passo) for (let x = passo / 2; x < W; x += passo) {
const k = (Math.floor(y) * W + Math.floor(x)) * 4, a = d[k + 3] / 255;
c.beginPath(); c.arc(x, y, passo * 0.38, 0, Math.PI * 2);
c.fillStyle = a > 0.25 ? css([d[k], d[k + 1], d[k + 2]], 0.4 + a * 0.6) : 'rgba(255,255,255,0.05)';
c.fill();
}
return;
}
c.save();
if (est.brilho) { c.shadowColor = css(op.cor, 0.7); c.shadowBlur = H * 0.08; }
c.drawImage(tmp, 0, 0);
c.restore();
}
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = {
desenhar, retrato, esconder,
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
})();