Files
carinha-control-panel/android/app/assets/rosto-motor.js
T
julioandClaude Opus 5.5 d7a17b5683 feat(rosto): atividades (momentos dele) e seis gestos novos
Atividades: a tela se divide, o rosto vai para a esquerda e encolhe, e
à direita um bonequinho do Carinha (silhueta na cor dos olhos) faz algo:
ler (vira a página), cantar (microfone e notas), jogar bola
(embaixadinha), videogame (TV com joguinho), pintar (cavalete que ganha
pinceladas) e malhar (halteres e suor). Os olhos grandes acompanham:
leem as linhas, seguem a bola, olham a TV. Camada própria atrás dos
olhos, vale para todos os rostos; em pé (retrato) a cena vai para baixo.

- Comando {tipo: "atividade", valor: nome|parar}; toque na tela, dormir
  e E-Stop encerram; o celular informa a atividade na telemetria.
- Modo vida escolhe pelo humor (feliz canta e joga bola, tranquilo lê
  e pinta, entediado joga videogame), nunca de madrugada, e não
  interrompe uma atividade em andamento.
- Gestos novos: rir, comemorar, tímido, confuso, bocejar e tonto; o
  humor e o modo vida também os usam.
- Painel: favoritos vão até 20 e o bravo volta junto com os novos;
  botões de atividade no Ao vivo, no Ctrl+K e tópico no guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:12:14 -03:00

840 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 (o.dormindo && !temConteudo) { // dormindo: "–" (olho fechado)
const esp = Math.max(2, p.ry * 0.2);
caixa(c, p.cx - w * 0.5, p.cy + h * 0.08 - esp / 2, w, esp, esp / 2); c.fill();
return;
}
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 (o.iris && fecha < 0.6 && e.bot < 0.35) {
const R = Math.min(w, h) * 0.3;
const ix = Math.max(x + R * 1.1, Math.min(x + w - R * 1.1, p.cx + (p.lx + sacada.x) * w * 0.016));
const iy = Math.max(y + R * 1.1, Math.min(y + h - R * 1.1, p.cy + h * 0.04 + (p.ly + sacada.y) * h * 0.016));
c.fillStyle = css(o.cor.map((v) => v * 0.42));
c.beginPath(); c.arc(ix, iy, R, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'destination-out';
const pupila = p.ab > 1.05 ? 0.38 : e.bot > 0.2 ? 0.62 : 0.5; // susto contrai, alegria dilata
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (est.brilho) {
c.fillStyle = 'rgba(245,250,255,0.9)';
c.beginPath(); c.arc(ix - R * 0.42, iy - R * 0.42, R * 0.2, 0, Math.PI * 2); c.fill();
}
} 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]));
for (const traco of o.tracos || []) {
if (typeof traco.d !== 'string') continue;
const caminho = new Path2D(traco.d);
bctx.strokeStyle = bctx.fillStyle = css(o.cor);
bctx.lineWidth = traco.largura; bctx.lineCap = 'round'; bctx.lineJoin = 'round';
if (traco.cheio) bctx.fill(caminho); else bctx.stroke(caminho);
}
desenharMaos(bctx, o, olhos, tela.height / d);
desenharZzz(bctx, o, olhos, 1.6); // em LEDs o Z precisa ser maior para ler
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);
desenharZzz(ctx, o, olhos);
} 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);
desenharZzz(cctx, o, olhos);
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);
}
}
// ------------------------------------------------------------------ Zzz
function desenharZzz(c, o, olhos, fator) {
fator = fator || 1;
if (!o.dormindo) return;
const d = olhos.slice().sort((a, b) => a.cx - b.cx)[1]; // olho da direita na tela
const x0 = d.cx + d.ry * 0.7, y0 = d.cy - d.ry * 0.55;
c.save();
c.strokeStyle = css(o.cor); c.lineCap = 'round'; c.lineJoin = 'round';
for (let i = 0; i < 3; i++) {
const k = ((o.t || 0) * 0.32 + i / 3) % 1; // cada Z num ponto do ciclo
const tam = d.ry * (0.28 + 0.3 * k) * fator;
const x = x0 + d.ry * 0.7 * k + Math.sin(k * Math.PI * 2) * d.ry * 0.08, y = y0 - d.ry * 1.7 * k;
c.globalAlpha = Math.min(1, k / 0.15) * (1 - k);
c.lineWidth = Math.max(1.5, tam * 0.2);
c.beginPath();
c.moveTo(x - tam / 2, y - tam / 2); c.lineTo(x + tam / 2, y - tam / 2);
c.lineTo(x - tam / 2, y + tam / 2); c.lineTo(x + tam / 2, y + tam / 2);
c.stroke();
}
c.restore();
}
// --------------------------------------------------------- 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();
}
// ------------------------------------------------------------ atividades
// "Momentos dele": um bonequinho do Carinha (silhueta na cor dos olhos) faz algo na parte de
// baixo da tela enquanto os olhos sobem, encolhem e acompanham. Camada própria, atrás dos olhos,
// vale para qualquer rosto (SVG ou motor). O carinha.html pergunta para onde olhar
// (olharAtividade) e chama palco() a cada quadro.
const ATIVIDADES = {
ler: { nome: 'Lendo', emoji: '📖', dur: [25, 45] },
cantar: { nome: 'Cantando', emoji: '🎤', dur: [18, 30] },
bola: { nome: 'Jogando bola', emoji: '⚽', dur: [20, 35] },
videogame: { nome: 'Videogame', emoji: '🎮', dur: [25, 45] },
pintar: { nome: 'Pintando', emoji: '🎨', dur: [25, 40] },
malhar: { nome: 'Malhando', emoji: '🏋️', dur: [15, 25] },
};
const ativ = { nome: null, t0: 0, dur: 0, fim: 0 };
let palcoTela = null, pctx = null;
const agoraS = () => performance.now() / 1000;
function iniciarAtividade(nome, segundos) {
if (!ATIVIDADES[nome]) { pararAtividade(); return false; }
const [a, b] = ATIVIDADES[nome].dur;
ativ.nome = nome; ativ.t0 = agoraS(); ativ.fim = 0;
ativ.dur = segundos || a + Math.random() * (b - a);
return true;
}
function pararAtividade() { if (ativ.nome && !ativ.fim) ativ.fim = agoraS(); }
// 0..1: entra e sai em 0,6 s; termina sozinha depois da duração
function visibilidade(t) {
if (!ativ.nome) return 0;
if (!ativ.fim && t - ativ.t0 >= ativ.dur) ativ.fim = t;
const entra = Math.min(1, Math.max(0, (t - ativ.t0) / 0.6));
if (!ativ.fim) return entra;
const sai = 1 - (t - ativ.fim) / 0.6;
if (sai <= 0) { ativ.nome = null; return 0; }
return Math.min(entra, sai);
}
// movimentos de cada cena, em função do tempo k (s) desde o início
const bola = (k) => { const p = (k % 1.1) / 1.1; return { p, h: 4 * p * (1 - p), x: 0.35 * Math.sin(k * 0.7) }; };
const pincel = (k) => ({ x: Math.sin(k * 3.1) * 0.6 + Math.sin(k * 1.3) * 0.4, y: Math.cos(k * 2.3) * 0.7 });
const forca = (k) => (1 - Math.cos(k * 2.2)) / 2; // 0 = halteres no ombro, 1 = braços em cima
/** Para onde os olhos grandes olham (unidades do rosto: dx ±24, dy ±12) e que cara fazem. */
function olharAtividade(t) {
const v = visibilidade(t);
if (!v) return null;
const k = t - ativ.t0, info = { nome: ativ.nome, v, x: 0, y: 0, saindo: !!ativ.fim };
const lado = innerWidth >= innerHeight * 1.2 ? 1 : 0; // deitado: a cena fica à direita
if (ativ.nome === 'ler') { // lê linha a linha: varre da esquerda para a direita
const linha = (k % 2.4) / 2.4;
info.x = (linha < 0.9 ? -8 + 16 * (linha / 0.9) : 8 - 16 * ((linha - 0.9) / 0.1)) + lado * 13;
info.y = 7 + (Math.floor(k / 2.4) % 3) * 1.5;
} else if (ativ.nome === 'cantar') {
info.x = 6 * Math.sin(k * 1.3) + lado * 12; info.y = -4 + 4 * Math.sin(k * 4);
info.forma = 'feliz'; info.boca = Math.sin(k * 7) > -0.2 ? 'aberta' : 'sorriso';
} else if (ativ.nome === 'bola') { // segue a bola
const b = bola(k); info.x = b.x * 16 + lado * 14; info.y = 9 - b.h * 18;
} else if (ativ.nome === 'videogame') { // vidrado na TV; de vez em quando comemora
info.x = 18 + lado * 5 + Math.sin(k * 9) * 1.5; info.y = 1;
if (k % 9 > 7.6) { info.forma = 'feliz'; info.boca = 'sorriso'; }
} else if (ativ.nome === 'pintar') {
const p = pincel(k); info.x = 16 + lado * 5 + p.x * 4; info.y = -1 + p.y * 5;
} else if (ativ.nome === 'malhar') {
const f = forca(k); info.x = lado * 14; info.y = 6 - f * 12; info.ab = f > 0.85 ? 0.55 : 1;
}
return info;
}
// --- o bonequinho: cabeça-tela com olhinhos, antena, tronco, braços e pernas articulados.
// Ângulos em radianos: 0 = apontando para baixo; positivo gira para a direita da tela.
function membro(c, x0, y0, a1, a2, l1, l2, w) {
const x1 = x0 + Math.sin(a1) * l1, y1 = y0 + Math.cos(a1) * l1;
const x2 = x1 + Math.sin(a1 + a2) * l2, y2 = y1 + Math.cos(a1 + a2) * l2;
c.beginPath(); capsula(c, x0, y0, x1, y1, w / 2); c.fill(); c.stroke();
c.beginPath(); capsula(c, x1, y1, x2, y2, w / 2); c.fill(); c.stroke();
return { x: x2, y: y2 };
}
function boneco(c, x, y, s, p, cor, k) {
c.fillStyle = css(cor.map((v) => v * 0.22), 0.96); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.lineJoin = 'round'; c.lineCap = 'round';
const w = s * 0.27, braco = [s * 0.62, s * 0.58];
const perna = p.sentado ? [s * 0.38, s * 0.62] : [s * 0.72, s * 0.7];
const hx = x + (p.dx || 0) * s, hy = p.sentado ? y - perna[1] - s * 0.12 : y - (perna[0] + perna[1]) * 0.97;
const pE = p.pE || [-0.08, 0], pD = p.pD || [0.08, 0];
membro(c, hx - s * 0.25, hy, pE[0], pE[1], perna[0], perna[1], w * 1.1);
membro(c, hx + s * 0.25, hy, pD[0], pD[1], perna[0], perna[1], w * 1.1);
c.save();
c.translate(hx, hy); c.rotate(-(p.inclina || 0));
// tronco
caixa(c, -s * 0.5, -s * 1.12, s, s * 1.12, s * 0.25); c.fill(); c.stroke();
c.save(); c.fillStyle = css(cor, 0.85); // luzinha no peito
c.beginPath(); c.arc(0, -s * 0.62, s * 0.09 * (0.8 + 0.2 * Math.sin(k * 3)), 0, Math.PI * 2); c.fill(); c.restore();
// cabeça (uma telinha, como o celular) com olhinhos e antena
c.save(); c.translate(0, -s * 1.25); c.rotate(-(p.cabeca || 0));
c.beginPath(); c.moveTo(0, -s * 0.95); c.lineTo(0, -s * 1.25); c.stroke();
c.save(); c.fillStyle = css(cor); c.beginPath(); c.arc(0, -s * 1.3, s * 0.1, 0, Math.PI * 2); c.fill(); c.restore();
caixa(c, -s * 0.66, -s * 0.98, s * 1.32, s * 0.98, s * 0.28); c.fill(); c.stroke();
const ox = (p.olhoX || 0) * s * 0.12, oy = (p.olhoY || 0) * s * 0.1;
const pisca = (k % 3.7) < 0.12 ? 0.15 : 1;
c.save(); c.fillStyle = css(cor);
for (const lado of [-1, 1]) {
c.beginPath(); c.ellipse(lado * s * 0.27 + ox, -s * 0.5 + oy, s * 0.11, s * 0.16 * pisca, 0, 0, Math.PI * 2); c.fill();
}
c.restore(); c.restore();
// braços (no mesmo quadro do tronco inclinado); as mãos voltam para segurar os objetos
const bE = p.bE || [-0.25, 0.15], bD = p.bD || [0.25, -0.15];
const mE = membro(c, -s * 0.58, -s * 0.98, bE[0], bE[1], braco[0], braco[1], w);
const mD = membro(c, s * 0.58, -s * 0.98, bD[0], bD[1], braco[0], braco[1], w);
c.restore();
const rot = (pt) => { // do quadro do tronco para a tela
const a = -(p.inclina || 0), cs = Math.cos(a), sn = Math.sin(a);
return { x: hx + pt.x * cs - pt.y * sn, y: hy + pt.x * sn + pt.y * cs };
};
return { mE: rot(mE), mD: rot(mD), quadril: { x: hx, y: hy }, cabeca: rot({ x: 0, y: -s * 1.75 }) };
}
const claro = (cor, a) => css(cor.map((v) => v + (255 - v) * 0.55), a);
const escuro = (cor) => css(cor.map((v) => v * 0.25));
function nota(c, x, y, s) { // ♪
c.beginPath(); c.ellipse(x, y, s * 0.2, s * 0.15, -0.4, 0, Math.PI * 2); c.fill();
c.fillRect(x + s * 0.14, y - s * 0.7, s * 0.06, s * 0.7);
c.beginPath(); c.moveTo(x + s * 0.2, y - s * 0.7); c.quadraticCurveTo(x + s * 0.5, y - s * 0.55, x + s * 0.42, y - s * 0.3); c.stroke();
}
const CENAS = {
ler(c, x, y, s, k, cor) {
const b = boneco(c, x, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], cabeca: -0.08, olhoY: 0.8,
olhoX: -0.8 + 1.6 * ((k % 2.4) / 2.4), bE: [0.2, 2.4], bD: [-0.2, -2.4],
}, cor, k);
// livro aberto entre as mãos, com uma página virando de vez em quando
const cx = (b.mE.x + b.mD.x) / 2, cy = (b.mE.y + b.mD.y) / 2 + s * 0.14, lw = s * 0.72, lh = s * 0.55;
c.fillStyle = claro(cor, 0.95); c.strokeStyle = css(cor);
for (const lado of [-1, 1]) {
c.beginPath(); c.moveTo(cx, cy + lh * 0.1); c.lineTo(cx + lado * lw, cy); c.lineTo(cx + lado * lw, cy - lh);
c.lineTo(cx, cy - lh * 0.9); c.closePath(); c.fill(); c.stroke();
}
c.strokeStyle = css(cor, 0.5); c.lineWidth = Math.max(1, s * 0.03);
for (let i = 1; i <= 3; i++) for (const lado of [-1, 1]) {
const yy = cy - lh * (0.25 * i); c.beginPath(); c.moveTo(cx + lado * lw * 0.15, yy); c.lineTo(cx + lado * lw * 0.85, yy - lh * 0.03); c.stroke();
}
const virar = (k % 6) / 0.7; // vira em 0,7 s a cada 6 s
if (virar < 1) {
const px = cx + lw * Math.cos(virar * Math.PI), sobe = Math.sin(virar * Math.PI) * lh * 0.25;
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.moveTo(cx, cy + lh * 0.1); c.lineTo(px, cy - sobe); c.lineTo(px, cy - lh - sobe);
c.lineTo(cx, cy - lh * 0.9); c.closePath(); c.fill(); c.stroke();
}
},
cantar(c, x, y, s, k, cor) {
const balanco = Math.sin(k * 2.2);
const b = boneco(c, x, y, s, {
dx: balanco * 0.12, inclina: balanco * 0.08, cabeca: balanco * 0.1, olhoY: -0.3,
pE: [-0.1 - Math.max(0, balanco) * 0.15, 0], pD: [0.1 - Math.min(0, balanco) * 0.15, 0],
bD: [2.45, 0.9], bE: [-0.9 - 0.4 * Math.sin(k * 4.4), -0.4],
}, cor, k);
// microfone na mão direita, perto da boca
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); capsula(c, b.mD.x, b.mD.y, b.mD.x - s * 0.1, b.mD.y - s * 0.4, s * 0.07); c.fill(); c.stroke();
c.fillStyle = claro(cor, 1); c.beginPath(); c.arc(b.mD.x - s * 0.12, b.mD.y - s * 0.5, s * 0.14, 0, Math.PI * 2); c.fill(); c.stroke();
// notas subindo
c.fillStyle = css(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
const alfa = c.globalAlpha;
for (let i = 0; i < 3; i++) {
const f = (k * 0.45 + i / 3) % 1;
c.globalAlpha = alfa * Math.min(1, f / 0.15) * (1 - f);
nota(c, b.cabeca.x + s * (0.9 + 0.5 * i) + Math.sin(f * 6 + i) * s * 0.2, b.cabeca.y + s * 0.4 - f * s * 1.6, s * 0.75);
}
c.globalAlpha = alfa;
},
bola(c, x, y, s, k, cor) {
const bl = bola(k), chute = Math.max(0, 1 - bl.p / 0.18) + Math.max(0, (bl.p - 0.9) / 0.1);
const b = boneco(c, x, y, s, {
dx: bl.x * 0.5, olhoX: bl.x * 2, olhoY: 0.6 - bl.h * 1.4,
pD: [0.08 + chute * 0.9, -chute * 0.6], pE: [-0.08, 0], bE: [-0.6, 0.3], bD: [0.6, -0.3],
}, cor, k);
const bx = b.quadril.x + s * 0.55 + bl.x * s * 0.3, by = y - s * 0.55 - bl.h * s * 2.6, r = s * 0.3;
const alfa = c.globalAlpha;
c.save(); c.globalAlpha = alfa * 0.35 * (1 - bl.h); c.fillStyle = css(cor); // sombra no chão
c.beginPath(); c.ellipse(bx, y, r * (1.1 - bl.h * 0.5), r * 0.18, 0, 0, Math.PI * 2); c.fill(); c.restore();
c.save(); c.translate(bx, by); c.rotate(k * 5);
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3)); // gomo da bola
c.beginPath(); for (let i = 0; i < 5; i++) { const a = i * 1.2566; c.lineTo(Math.cos(a) * r * 0.38, Math.sin(a) * r * 0.38); } c.closePath(); c.fill();
c.restore();
},
videogame(c, x, y, s, k, cor) {
const aperta = Math.sin(k * 23) * 0.05;
const b = boneco(c, x - s * 1.3, y, s, {
sentado: true, pE: [-1.35, 1.35], pD: [1.35, -1.35], olhoX: 1, olhoY: 0.1, cabeca: -0.05,
bE: [0.15 + aperta, 0.95], bD: [-0.15 - aperta, -0.95],
}, cor, k);
// controle
const cx = (b.mE.x + b.mD.x) / 2, cy = (b.mE.y + b.mD.y) / 2;
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
caixa(c, cx - s * 0.42, cy - s * 0.16, s * 0.84, s * 0.34, s * 0.16); c.fill(); c.stroke();
c.fillStyle = css(cor.map((v) => v * 0.3));
c.fillRect(cx - s * 0.3, cy - s * 0.02, s * 0.16, s * 0.05); c.fillRect(cx - s * 0.245, cy - s * 0.08, s * 0.05, s * 0.17);
for (const [dx, dy] of [[0.22, -0.05], [0.3, 0.04]]) { c.beginPath(); c.arc(cx + dx * s, cy + dy * s, s * 0.04, 0, Math.PI * 2); c.fill(); }
// TV com um joguinho: bonequinho pulando obstáculos
const tx = x + s * 1.15, tw = s * 2.1, th = s * 1.45, ty = y - s * 0.55 - th;
c.fillStyle = escuro(cor); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(tx + tw / 2, ty + th); c.lineTo(tx + tw / 2, y); c.moveTo(tx + tw * 0.3, y); c.lineTo(tx + tw * 0.7, y); c.stroke();
caixa(c, tx, ty, tw, th, s * 0.12); c.fill(); c.stroke();
c.save(); caixa(c, tx + s * 0.1, ty + s * 0.1, tw - s * 0.2, th - s * 0.2, s * 0.06); c.clip();
const chao = ty + th - s * 0.32, px = s * 0.11;
c.fillStyle = css(cor, 0.6); c.fillRect(tx, chao, tw, s * 0.05);
const obst = tx + tw - ((k * 1.4) % 1) * (tw + s * 0.4);
c.fillStyle = css(cor); c.fillRect(obst, chao - px * 2, px * 1.4, px * 2);
const ox = tx + tw * 0.3, perto = (ox + px * 2 - obst) / (s * 0.9) + 0.5;
const pulo = perto > 0 && perto < 1 ? Math.sin(perto * Math.PI) : 0;
c.fillStyle = claro(cor, 1); c.fillRect(ox, chao - px * 2.4 - pulo * s * 0.45, px * 1.6, px * 2.4);
if (k % 9 > 7.6) { // vitória: estrelinhas na tela
c.fillStyle = claro(cor, 1); c.font = Math.round(s * 0.4) + 'px sans-serif'; c.textAlign = 'center';
c.fillText('★ ★ ★', tx + tw / 2, ty + th * 0.4);
}
c.restore();
},
pintar(c, x, y, s, k, cor) {
const pc = pincel(k);
const ex = x + s * 0.9, ew = s * 1.6, eh = s * 1.35, ey = y - s * 1.0 - eh;
// cavalete
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.07);
c.beginPath(); c.moveTo(ex + ew * 0.2, y); c.lineTo(ex + ew * 0.5, ey - s * 0.15); c.lineTo(ex + ew * 0.8, y); c.stroke();
c.fillStyle = claro(cor, 0.95); caixa(c, ex, ey, ew, eh, s * 0.05); c.fill(); c.stroke();
// pinceladas que vão aparecendo (sempre as mesmas para a mesma pintura)
const tintas = [cor, [255, 112, 176], [255, 218, 96], [120, 255, 160]];
const n = Math.min(40, Math.floor(k / 0.7));
c.save(); caixa(c, ex, ey, ew, eh, s * 0.05); c.clip();
for (let i = 0; i < n; i++) {
const a = Math.sin(i * 12.9898 + ativ.t0) * 43758.5453, rnd = a - Math.floor(a);
const b2 = Math.sin(i * 78.233 + ativ.t0) * 12345.678, rnd2 = b2 - Math.floor(b2);
c.fillStyle = css(tintas[i % 4], 0.9);
c.beginPath(); c.ellipse(ex + ew * (0.12 + rnd * 0.76), ey + eh * (0.12 + rnd2 * 0.76), s * 0.16, s * 0.07, rnd * 3, 0, Math.PI * 2); c.fill();
}
c.restore();
const b = boneco(c, x - s * 0.5, y, s, {
inclina: -0.06, olhoX: 1, olhoY: pc.y * 0.5, cabeca: -0.08,
bD: [1.75 + pc.y * 0.25, -0.35 + pc.x * 0.3], bE: [-0.7, 0.9], pE: [-0.12, 0], pD: [0.08, 0],
}, cor, k);
// pincel numa mão, paleta na outra
c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.06);
c.beginPath(); c.moveTo(b.mD.x - s * 0.1, b.mD.y + s * 0.08); c.lineTo(b.mD.x + s * 0.4, b.mD.y - s * 0.12); c.stroke();
c.fillStyle = css(tintas[Math.floor(k / 0.7) % 4]); c.beginPath(); c.arc(b.mD.x + s * 0.43, b.mD.y - s * 0.13, s * 0.07, 0, Math.PI * 2); c.fill();
c.fillStyle = claro(cor, 0.95); c.beginPath(); c.ellipse(b.mE.x, b.mE.y, s * 0.32, s * 0.2, -0.3, 0, Math.PI * 2); c.fill(); c.stroke();
for (let i = 0; i < 3; i++) { c.fillStyle = css(tintas[i + 1]); c.beginPath(); c.arc(b.mE.x - s * 0.15 + i * s * 0.13, b.mE.y - s * 0.03, s * 0.05, 0, Math.PI * 2); c.fill(); }
},
malhar(c, x, y, s, k, cor) {
const f = suave(forca(k));
const b = boneco(c, x, y, s, {
olhoY: -0.4 + (1 - f) * 0.5, pE: [-0.22, 0.08], pD: [0.22, -0.08],
bE: [-1.25 - f * 1.75, -1.9 + f * 1.75], bD: [1.25 + f * 1.75, 1.9 - f * 1.75],
}, cor, k);
// halteres
c.fillStyle = claro(cor, 1); c.strokeStyle = css(cor); c.lineWidth = Math.max(1.5, s * 0.05);
for (const m of [b.mE, b.mD]) {
c.fillRect(m.x - s * 0.25, m.y - s * 0.035, s * 0.5, s * 0.07);
for (const lado of [-1, 1]) { caixa(c, m.x + lado * s * 0.28 - s * 0.07, m.y - s * 0.17, s * 0.14, s * 0.34, s * 0.04); c.fill(); c.stroke(); }
}
if (f > 0.85) { // gotinha de suor
const g = (k * 2) % 1;
c.fillStyle = claro(cor, 1 - g);
c.beginPath(); c.arc(b.cabeca.x + s * 0.72, b.cabeca.y + s * 0.3 + g * s * 0.5, s * 0.07, 0, Math.PI * 2); c.fill();
}
},
};
/** Desenha a cena da atividade (camada atrás dos olhos). o = { t, cor }. */
function desenharPalco(o) {
const v = visibilidade(o.t);
if (!v) {
if (palcoTela && palcoTela.style.display !== 'none') palcoTela.style.display = 'none';
return false;
}
if (!palcoTela) {
palcoTela = document.createElement('canvas'); palcoTela.id = 'palcoAtividade';
palcoTela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:1';
document.body.append(palcoTela); pctx = palcoTela.getContext('2d');
}
palcoTela.style.display = '';
const d = Math.min(1.5, window.devicePixelRatio || 1), W = Math.round(innerWidth * d), H = Math.round(innerHeight * d);
if (palcoTela.width !== W || palcoTela.height !== H) { palcoTela.width = W; palcoTela.height = H; }
const deitado = W >= H * 1.2;
const s = deitado ? H * 0.145 : Math.min(W * 0.075, H * 0.075);
const x = deitado ? W * 0.7 : W / 2, y = deitado ? H * 0.9 : H * 0.95;
const c = pctx, k = o.t - ativ.t0;
c.clearRect(0, 0, W, H);
c.save();
c.globalAlpha = v;
c.translate((1 - v) * (deitado ? W * 0.06 : 0), (1 - v) * (deitado ? 0 : H * 0.08)); // desliza ao entrar
c.strokeStyle = css(o.cor, 0.35); c.lineWidth = Math.max(1, s * 0.04); // chão
c.beginPath(); c.moveTo(x - s * 2.6, y); c.lineTo(Math.min(W * 0.98, x + s * 3.6), y); c.stroke();
c.shadowColor = css(o.cor, 0.55); c.shadowBlur = s * 0.35;
CENAS[ativ.nome](c, x, y, s, k, o.cor);
c.restore();
return true;
}
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = {
desenhar, retrato, esconder,
/** Metade da altura do olho (em frações de RY) para o estilo/formato: posiciona as sobrancelhas. */
metadeAltura: (estilo, formato) => {
const base = ESTILOS[estilo]; if (!base) return 1;
if (base.lente) return base.w * 0.5 * 1.12;
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
},
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
atividade: (nome, segundos) => (nome ? iniciarAtividade(nome, segundos) : (pararAtividade(), false)),
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
olharAtividade, palco: desenharPalco,
atividades: Object.fromEntries(Object.entries(ATIVIDADES).map(([k, v]) => [k, { nome: v.nome, emoji: v.emoji }])) };
})();