Files
julioandClaude Opus 5.5 319e63cc42 feat(rosto): esconder e espiar com maos inclinadas e movimento em arco
As maos sobem em arco a partir dos cantos de baixo, girando no caminho,
com um leve passa-e-volta e uma chegando um instante depois da outra.
Ficam inclinadas para dentro (~24 graus) mostrando o dorso (nos dos
dedos, polegar para fora) e sem tapar o olho inteiro. Espiar gira a mao
pelo punho como uma porta; acenar tambem balanca pelo punho.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:52:39 -03:00

477 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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
// Mão de desenho animado: palma, quatro dedos (o do meio maior), polegar para o lado do rosto
// e um punho de robô. dobra (0–1) fecha os dedos; mexe (0–1) agita os dedos ao acenar.
// O contorno é traçado antes do preenchimento: assim só a borda externa aparece.
function capsula(c, x0, y0, x1, y1, r) {
const a = Math.atan2(y1 - y0, x1 - x0) + Math.PI / 2;
c.moveTo(x0 + Math.cos(a) * r, y0 + Math.sin(a) * r);
c.arc(x0, y0, r, a, a + Math.PI);
c.arc(x1, y1, r, a + Math.PI, a + 2 * Math.PI);
c.closePath();
}
function formaMao(c, s, dobra, mexe, t, indicador) {
c.beginPath();
// punho (manga do robô)
caixa(c, -0.42 * s, 0.5 * s, 0.84 * s, 0.38 * s, 0.12 * s);
// palma
caixa(c, -0.5 * s, -0.36 * s, 1.0 * s, 0.95 * s, 0.34 * s);
// dedos: x da base, comprimento e abertura
// mindinho, anelar, médio e indicador (o indicador fica do lado do polegar)
const dedos = [[-0.355, 0.5, -0.16], [-0.12, 0.68, -0.05], [0.12, 0.74, 0.05], [0.355, 0.66, 0.14]];
dedos.forEach(([x, comp, abre], i) => {
const onda = mexe ? Math.sin(t * 14 + i * 0.9) * 0.06 * mexe : 0;
const fecha = indicador && i === 3 ? 0 : dobra; // "pensando": só o indicador esticado
const ang = abre + onda, l = comp * (1 - 0.7 * fecha) * s;
const bx = x * s, by = -0.26 * s;
capsula(c, bx, by, bx + Math.sin(ang) * l, by - Math.cos(ang) * l, 0.115 * s);
});
// polegar, saindo para o lado
capsula(c, 0.38 * s, 0.2 * s, (0.38 + 0.42 * (1 - 0.4 * dobra)) * s, (0.2 - 0.34) * s, 0.13 * s);
}
function mao(c, o, x, y, s, ang, espelho, dobra, mexe, indicador, pulso, costas) {
dobra = dobra || 0; mexe = mexe || 0;
s *= 0.82;
const escura = o.cor.map((v) => v * 0.34);
c.save();
c.translate(x, y); c.rotate(ang); c.scale(espelho, 1);
if (pulso) c.translate(0, -0.7 * s); // (x, y) é o punho: gira como uma mão de verdade
c.lineJoin = 'round'; c.lineCap = 'round';
formaMao(c, s, dobra, mexe, o.t || 0, indicador);
c.strokeStyle = css(o.cor); c.lineWidth = s * 0.17; c.stroke(); // contorno (metade some sob o preenchimento)
c.fillStyle = css(escura); c.fill();
// detalhes por dentro, finos: linha da palma, vincos dos dedos e a costura do punho
c.strokeStyle = css(o.cor, 0.55); c.lineWidth = s * 0.045;
if (costas) { // dorso da mão: nós dos dedos em vez da linha da palma
for (const nx of [-0.355, -0.12, 0.12, 0.355]) { c.beginPath(); c.arc(nx * s, -0.2 * s, 0.075 * s, Math.PI * 1.15, Math.PI * 1.85); c.stroke(); }
} else {
c.beginPath(); c.moveTo(-0.3 * s, 0.12 * s); c.quadraticCurveTo(0.02 * s, 0.26 * s, 0.3 * s, 0.06 * s); c.stroke();
}
c.beginPath(); c.moveTo(-0.36 * s, 0.6 * s); c.lineTo(0.36 * s, 0.6 * s); c.stroke();
c.restore();
}
// Sobe com um leve "passa e volta" no fim (como um braço que freia).
const voltinha = (x) => { x = Math.max(0, Math.min(1, x)); const k = 1.6; return 1 + (k + 1) * Math.pow(x - 1, 3) + k * Math.pow(x - 1, 2); };
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.8;
if (maos.modo === 'esconder' || maos.modo === 'espiar') {
// Cada mão vem do canto de baixo, por fora, num arco, girando até ficar inclinada para dentro.
// A da direita chega um pouco depois: duas mãos nunca se mexem igualzinho.
const cobrir = (olho, lado, atraso) => {
const ida = voltinha((k - atraso) / 0.24), volta = faixaDe(k, 0.8 + atraso * 0.5, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); // 0 = fora da tela, 1 = cobrindo
const alvoX = olho.cx - lado * olho.ry * 0.05, alvoY = olho.cy + olho.ry * 0.38;
const x = alvoX - lado * olho.ry * 1.6 * (1 - Math.min(1, v));
const y = fora + (alvoY - fora) * v;
const respira = Math.sin(o.t * 3 + lado) * s * 0.03 * Math.min(1, v);
const ang = lado * (0.42 + 0.55 * (1 - Math.min(1, v))); // gira enquanto sobe, termina a ~24°
return { x, y: y + respira, ang };
};
const pe = cobrir(e, 1, 0), pd = cobrir(d, -1, 0.05);
if (maos.modo === 'espiar') {
// a mão da esquerda gira pelo punho para fora, como uma porta, e o olho aparece
const abre = faixaDe(k, 0.3, 0.44) - faixaDe(k, 0.64, 0.76);
pe.ang -= abre * 1.05; pe.x -= abre * e.ry * 0.35; pe.y += abre * e.ry * 0.2;
}
const treme = Math.sin(o.t * 22) * s * 0.012; // tímido: tremidinha
// dorso virado para quem olha: polegar para fora, perto da bochecha
mao(c, o, pe.x + treme, pe.y, s * 1.1, pe.ang, -1, 0, 0, false, true, true);
mao(c, o, pd.x - treme, pd.y, s * 1.1, pd.ang, 1, 0, 0, false, true, true);
} else if (maos.modo === 'acenar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
const x = d.cx + d.ry * 2.2 + d.ry * 0.6 * (1 - Math.min(1, v)), y0 = d.cy + d.ry * 1.55;
const y = fora + (y0 - fora) * v;
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
} else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4;
const y = fora + (y0 - fora) * v + Math.sin(o.t * 9) * s * 0.03;
mao(c, o, x, y, s * 0.85, -0.5, -1, 0.95, 0, true);
}
}
// --------------------------------------------------------- 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) };
})();