feat(rosto): microexpressões na boca e nas sobrancelhas acordado

De vez em quando, fora dos gestos, um canto da boca sobe, uma sobrancelha
arqueia, a testa franze pensando, a boca entreabre num suspiro, e tudo
volta suave ao neutro. O humor do modo vida escolhe quais (carente fica
preocupado, feliz dá sorrisinhos, cansado suspira). As sobrancelhas
descem um pouco ao piscar. Vale para o SVG e para a Matriz de LEDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 04:31:54 -03:00
co-authored by Claude Opus 5.5
parent b456f80716
commit 5fcd038861
2 changed files with 130 additions and 30 deletions
+65 -15
View File
@@ -203,8 +203,24 @@ function caminhoBoca(tipo) {
return "M " + (cx - meia) + "," + (cy - l * 0.10) + return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10); " Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
} }
return "M " + (cx - l * 0.30) + "," + cy + // repouso: leve // repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0"; const f = FEICAO.atual, w = l * 0.30, mx = cx + f.dx * l * 0.10;
const yE = cy - Math.max(0, f.lado) * l * 0.12, yD = cy - Math.max(0, -f.lado) * l * 0.12;
const curva = l * (0.18 + f.curva * 0.35);
let d = "M " + (mx - w) + "," + yE + " Q " + mx + "," + (cy + curva - f.abre * l * 0.12) + " " + (mx + w) + "," + yD;
if (f.abre > 0.04) d += " Q " + mx + "," + (cy + curva + f.abre * l * 0.30) + " " + (mx - w) + "," + yE;
return d;
}
/* Sobrancelha i (0 = esquerda): humor do gesto + microexpressão + desce um pouco ao piscar. */
function caminhoSobrancelha(i, esc) {
const c = i === 0 ? E1 : E2, lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, f = FEICAO.atual;
const ab = i === 0 ? r.ab : (r.ab2 === null ? r.ab : r.ab2);
const piscando = 1 - Math.min(1, Math.max(0, ab));
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC - (f.sob[i] - piscando * 3) * ESC;
const inc = ((r.forma === 'bravo' ? 18 : r.forma === 'triste' ? -14 : 0) + f.inc[i]) * lado * ESC;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
return 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele);
} }
// ------------------------------------------------------------------ desenho // ------------------------------------------------------------------ desenho
@@ -369,6 +385,50 @@ const LIVRES_HUMOR = {
cansado: ["olhar_baixo", "piscar2"], cansado: ["olhar_baixo", "piscar2"],
}; };
let HUMOR = "normal"; let HUMOR = "normal";
/* Microexpressões acordado: de vez em quando a boca e as sobrancelhas mexem sozinhas (um canto
da boca sobe, uma sobrancelha arqueia, um suspiro) e voltam devagar ao neutro. O humor do modo
vida escolhe quais. Valores em "pixels de projeto" (multiplicados por ESC no desenho). */
function feicaoNeutra() { return { curva: 0, lado: 0, abre: 0, dx: 0, sob: [0, 0], inc: [0, 0] }; }
const FEICAO = { atual: feicaoNeutra(), alvo: feicaoNeutra(), prox: 0, volta: 0, t: 0 };
const ao = () => (Math.random() < 0.5 ? 1 : -1);
const MICRO = {
arquear: () => { const f = feicaoNeutra(), i = Math.random() < 0.5 ? 0 : 1; f.sob[i] = 12; f.inc[i] = -4; return f; },
interesse: () => Object.assign(feicaoNeutra(), { sob: [8, 8], abre: 0.35 }),
meioSorriso: () => Object.assign(feicaoNeutra(), { lado: ao(), curva: 0.1 }),
sorrisinho: () => Object.assign(feicaoNeutra(), { curva: 0.35, sob: [4, 4] }),
ladinho: () => { const d = ao(); return Object.assign(feicaoNeutra(), { dx: d, lado: d * 0.4, curva: -0.08 }); },
pensativo: () => Object.assign(feicaoNeutra(), { inc: [8, 8], sob: [-3, -3], dx: ao() * 0.7, curva: -0.15 }),
preocupado: () => Object.assign(feicaoNeutra(), { inc: [-9, -9], sob: [4, 4], curva: -0.3 }),
suspiro: () => Object.assign(feicaoNeutra(), { sob: [5, 5], abre: 0.6, curva: -0.05 }),
};
const MICRO_HUMOR = {
feliz: ["sorrisinho", "sorrisinho", "meioSorriso", "arquear", "interesse"],
tranquilo: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse"],
entediado: ["ladinho", "ladinho", "pensativo", "suspiro", "arquear"],
carente: ["preocupado", "preocupado", "suspiro", "meioSorriso"],
triste: ["preocupado", "suspiro", "pensativo"],
cansado: ["suspiro", "suspiro", "ladinho"],
normal: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse", "pensativo", "suspiro"],
};
function atualizarFeicao(s) {
const dt = Math.min(0.1, Math.max(0, s - (FEICAO.t || s)));
FEICAO.t = s;
// só no rosto "de repouso": gesto com boca própria (sorriso, triste...) ou dormindo = neutro
const livre = !r.dormindo && !congelado && r.boca === "neutra";
if (!livre) {
FEICAO.alvo = feicaoNeutra();
FEICAO.prox = Math.max(FEICAO.prox, s + 1.5);
} else if (s >= FEICAO.prox) {
const lista = MICRO_HUMOR[HUMOR] || MICRO_HUMOR.normal;
FEICAO.alvo = MICRO[lista[Math.floor(Math.random() * lista.length)]]();
FEICAO.volta = s + 0.9 + Math.random() * 1.6;
FEICAO.prox = s + (2.5 + Math.random() * 5) * ({ cansado: 1.6, triste: 1.4, feliz: 0.8 }[HUMOR] || 1);
} else if (s >= FEICAO.volta) FEICAO.alvo = feicaoNeutra();
const k = 1 - Math.exp(-dt * 6), a = FEICAO.atual, b = FEICAO.alvo;
for (const c of ["curva", "lado", "abre", "dx"]) a[c] += (b[c] - a[c]) * k;
for (let i = 0; i < 2; i++) { a.sob[i] += (b.sob[i] - a.sob[i]) * k; a.inc[i] += (b.inc[i] - a.inc[i]) * k; }
}
function gestoSolto() { function gestoSolto() {
const lista = LIVRES_HUMOR[HUMOR] || LIVRES; const lista = LIVRES_HUMOR[HUMOR] || LIVRES;
return lista[Math.floor(Math.random() * lista.length)]; return lista[Math.floor(Math.random() * lista.length)];
@@ -472,13 +532,7 @@ function desenhar() {
const brow = document.getElementById('sobrancelha' + (i + 1)); const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none'; brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue; if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2; brow.setAttribute('d', caminhoSobrancelha(i, esc));
const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85;
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
brow.setAttribute('stroke', rgb(r.cor)); brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC); brow.setAttribute('stroke-width', 7 * ESC);
} }
@@ -495,12 +549,7 @@ function tracosDoRosto(esc) {
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" }); } else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
} }
if (EXTRAS.sobrancelhas && !r.dormindo) { if (EXTRAS.sobrancelhas && !r.dormindo) {
[E1, E2].forEach((c, i) => { for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
});
} }
return tracos; return tracos;
} }
@@ -1120,6 +1169,7 @@ function laco(t) {
if (window.innerWidth !== W || window.innerHeight !== H) ajustar(); if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000); checarSilencio(t / 1000);
atualizarFeicao(t / 1000);
const ocupado = congelado ? true : passo(dt); const ocupado = congelado ? true : passo(dt);
if (!ocupado) { if (!ocupado) {
+65 -15
View File
@@ -203,8 +203,24 @@ function caminhoBoca(tipo) {
return "M " + (cx - meia) + "," + (cy - l * 0.10) + return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10); " Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
} }
return "M " + (cx - l * 0.30) + "," + cy + // repouso: leve // repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0"; const f = FEICAO.atual, w = l * 0.30, mx = cx + f.dx * l * 0.10;
const yE = cy - Math.max(0, f.lado) * l * 0.12, yD = cy - Math.max(0, -f.lado) * l * 0.12;
const curva = l * (0.18 + f.curva * 0.35);
let d = "M " + (mx - w) + "," + yE + " Q " + mx + "," + (cy + curva - f.abre * l * 0.12) + " " + (mx + w) + "," + yD;
if (f.abre > 0.04) d += " Q " + mx + "," + (cy + curva + f.abre * l * 0.30) + " " + (mx - w) + "," + yE;
return d;
}
/* Sobrancelha i (0 = esquerda): humor do gesto + microexpressão + desce um pouco ao piscar. */
function caminhoSobrancelha(i, esc) {
const c = i === 0 ? E1 : E2, lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, f = FEICAO.atual;
const ab = i === 0 ? r.ab : (r.ab2 === null ? r.ab : r.ab2);
const piscando = 1 - Math.min(1, Math.max(0, ab));
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC - (f.sob[i] - piscando * 3) * ESC;
const inc = ((r.forma === 'bravo' ? 18 : r.forma === 'triste' ? -14 : 0) + f.inc[i]) * lado * ESC;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
return 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele);
} }
// ------------------------------------------------------------------ desenho // ------------------------------------------------------------------ desenho
@@ -369,6 +385,50 @@ const LIVRES_HUMOR = {
cansado: ["olhar_baixo", "piscar2"], cansado: ["olhar_baixo", "piscar2"],
}; };
let HUMOR = "normal"; let HUMOR = "normal";
/* Microexpressões acordado: de vez em quando a boca e as sobrancelhas mexem sozinhas (um canto
da boca sobe, uma sobrancelha arqueia, um suspiro) e voltam devagar ao neutro. O humor do modo
vida escolhe quais. Valores em "pixels de projeto" (multiplicados por ESC no desenho). */
function feicaoNeutra() { return { curva: 0, lado: 0, abre: 0, dx: 0, sob: [0, 0], inc: [0, 0] }; }
const FEICAO = { atual: feicaoNeutra(), alvo: feicaoNeutra(), prox: 0, volta: 0, t: 0 };
const ao = () => (Math.random() < 0.5 ? 1 : -1);
const MICRO = {
arquear: () => { const f = feicaoNeutra(), i = Math.random() < 0.5 ? 0 : 1; f.sob[i] = 12; f.inc[i] = -4; return f; },
interesse: () => Object.assign(feicaoNeutra(), { sob: [8, 8], abre: 0.35 }),
meioSorriso: () => Object.assign(feicaoNeutra(), { lado: ao(), curva: 0.1 }),
sorrisinho: () => Object.assign(feicaoNeutra(), { curva: 0.35, sob: [4, 4] }),
ladinho: () => { const d = ao(); return Object.assign(feicaoNeutra(), { dx: d, lado: d * 0.4, curva: -0.08 }); },
pensativo: () => Object.assign(feicaoNeutra(), { inc: [8, 8], sob: [-3, -3], dx: ao() * 0.7, curva: -0.15 }),
preocupado: () => Object.assign(feicaoNeutra(), { inc: [-9, -9], sob: [4, 4], curva: -0.3 }),
suspiro: () => Object.assign(feicaoNeutra(), { sob: [5, 5], abre: 0.6, curva: -0.05 }),
};
const MICRO_HUMOR = {
feliz: ["sorrisinho", "sorrisinho", "meioSorriso", "arquear", "interesse"],
tranquilo: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse"],
entediado: ["ladinho", "ladinho", "pensativo", "suspiro", "arquear"],
carente: ["preocupado", "preocupado", "suspiro", "meioSorriso"],
triste: ["preocupado", "suspiro", "pensativo"],
cansado: ["suspiro", "suspiro", "ladinho"],
normal: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse", "pensativo", "suspiro"],
};
function atualizarFeicao(s) {
const dt = Math.min(0.1, Math.max(0, s - (FEICAO.t || s)));
FEICAO.t = s;
// só no rosto "de repouso": gesto com boca própria (sorriso, triste...) ou dormindo = neutro
const livre = !r.dormindo && !congelado && r.boca === "neutra";
if (!livre) {
FEICAO.alvo = feicaoNeutra();
FEICAO.prox = Math.max(FEICAO.prox, s + 1.5);
} else if (s >= FEICAO.prox) {
const lista = MICRO_HUMOR[HUMOR] || MICRO_HUMOR.normal;
FEICAO.alvo = MICRO[lista[Math.floor(Math.random() * lista.length)]]();
FEICAO.volta = s + 0.9 + Math.random() * 1.6;
FEICAO.prox = s + (2.5 + Math.random() * 5) * ({ cansado: 1.6, triste: 1.4, feliz: 0.8 }[HUMOR] || 1);
} else if (s >= FEICAO.volta) FEICAO.alvo = feicaoNeutra();
const k = 1 - Math.exp(-dt * 6), a = FEICAO.atual, b = FEICAO.alvo;
for (const c of ["curva", "lado", "abre", "dx"]) a[c] += (b[c] - a[c]) * k;
for (let i = 0; i < 2; i++) { a.sob[i] += (b.sob[i] - a.sob[i]) * k; a.inc[i] += (b.inc[i] - a.inc[i]) * k; }
}
function gestoSolto() { function gestoSolto() {
const lista = LIVRES_HUMOR[HUMOR] || LIVRES; const lista = LIVRES_HUMOR[HUMOR] || LIVRES;
return lista[Math.floor(Math.random() * lista.length)]; return lista[Math.floor(Math.random() * lista.length)];
@@ -472,13 +532,7 @@ function desenhar() {
const brow = document.getElementById('sobrancelha' + (i + 1)); const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none'; brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue; if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2; brow.setAttribute('d', caminhoSobrancelha(i, esc));
const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85;
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
brow.setAttribute('stroke', rgb(r.cor)); brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC); brow.setAttribute('stroke-width', 7 * ESC);
} }
@@ -495,12 +549,7 @@ function tracosDoRosto(esc) {
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" }); } else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
} }
if (EXTRAS.sobrancelhas && !r.dormindo) { if (EXTRAS.sobrancelhas && !r.dormindo) {
[E1, E2].forEach((c, i) => { for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
});
} }
return tracos; return tracos;
} }
@@ -1120,6 +1169,7 @@ function laco(t) {
if (window.innerWidth !== W || window.innerHeight !== H) ajustar(); if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000); checarSilencio(t / 1000);
atualizarFeicao(t / 1000);
const ocupado = congelado ? true : passo(dt); const ocupado = congelado ? true : passo(dt);
if (!ocupado) { if (!ocupado) {