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>
This commit is contained in:
+134
-9
@@ -205,6 +205,7 @@
|
||||
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);
|
||||
@@ -239,14 +240,135 @@
|
||||
ry: p.ry * d,
|
||||
}));
|
||||
ctx.clearRect(0, 0, tela.width, tela.height);
|
||||
if (estilo.pontos) return desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
|
||||
if (estilo.lente) { olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); return; }
|
||||
cctx.clearRect(0, 0, camada.width, camada.height);
|
||||
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
||||
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();
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,5 +407,8 @@
|
||||
|
||||
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
|
||||
|
||||
window.MotorRosto = { desenhar, retrato, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
|
||||
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) };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user