feat(rosto): iris, boca e sobrancelhas na Matriz; emocoes da tela Rosto vao ao celular

- opcao "iris" (app config, Ajustes e tela Rosto): anel mais escuro com
  pupila que acompanha o olhar, dilata na alegria e contrai no susto; na
  Matriz vira LEDs apagados
- Matriz desenha boca e sobrancelhas em LEDs (antes a tela de pontos
  cobria o SVG); Bloco e Lente passam a obedecer o ajuste de boca
- tela Rosto: boca, sobrancelhas e iris ali mesmo; as teclas de emocao
  agora vao ao celular por padrao ("testar so na previa" e opcional)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 03:15:39 -03:00
co-authored by Claude Opus 5.5
parent a67c5ea9af
commit 1029b5fa99
7 changed files with 126 additions and 19 deletions
+25 -4
View File
@@ -92,7 +92,7 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false };
const EXTRAS = { boca: true, sobrancelhas: false, iris: false };
const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78];
const ROSA = [255, 112, 176];
@@ -404,6 +404,8 @@ function desenhar() {
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
@@ -417,11 +419,12 @@ function desenhar() {
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1;
@@ -435,6 +438,24 @@ function desenhar() {
}
}
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
function tracosDoRosto(esc) {
const tracos = [];
if (EXTRAS.boca) {
const forma = r.boca;
tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
[E1, E2].forEach((c, i) => {
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - RY * esc * 1.25 - 20 * 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;
}
// ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) {
@@ -894,7 +915,7 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null;
function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
+22 -1
View File
@@ -132,7 +132,21 @@
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
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)';
@@ -205,6 +219,13 @@
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);
bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data;
+25 -4
View File
@@ -92,7 +92,7 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false };
const EXTRAS = { boca: true, sobrancelhas: false, iris: false };
const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78];
const ROSA = [255, 112, 176];
@@ -404,6 +404,8 @@ function desenhar() {
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
iris: EXTRAS.iris,
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
@@ -417,11 +419,12 @@ function desenhar() {
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1;
@@ -435,6 +438,24 @@ function desenhar() {
}
}
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
function tracosDoRosto(esc) {
const tracos = [];
if (EXTRAS.boca) {
const forma = r.boca;
tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
[E1, E2].forEach((c, i) => {
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - RY * esc * 1.25 - 20 * 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;
}
// ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) {
@@ -894,7 +915,7 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null;
function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
+1 -1
View File
@@ -20,7 +20,7 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
somaltolim=0.55, sompausa=20, somcooldown=5,
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
boca=True, sobrancelhas=False, videoRTC=True,
boca=True, sobrancelhas=False, iris=False, videoRTC=True,
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
cameraLado='user', videoResolucao='auto', videoFPS=0,
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
+22 -1
View File
@@ -132,7 +132,21 @@
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
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)';
@@ -205,6 +219,13 @@
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);
bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data;
+1
View File
@@ -46,6 +46,7 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
{ chave: 'iris', rotulo: 'Mostrar íris', ajuda: 'Pupila que acompanha o olhar (Viva, Bloco e Matriz)', tipo: 'toggle' },
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
+30 -8
View File
@@ -5,6 +5,7 @@
} from '../lib/comandos.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { salvarApp } from '../lib/ajustes.svelte';
import { estado } from '../lib/estado.svelte';
let previa = $state<{ ordem: (t: string) => void; legenda: (t: string) => void }>();
@@ -24,8 +25,8 @@
const c = voz();
if (c) (window as any).VozRobo.balbuciar(c, FRASE, 0.5);
}
// Por padrão as emoções só aparecem aqui na prévia; ligado, vão também para o celular.
let noCelular = $state(false);
// As emoções vão para o celular (e a prévia mostra junto); "só na prévia" serve para testar quieto.
let soPrevia = $state(false);
const atual = $derived(estado.aparencia.rosto);
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
@@ -36,8 +37,8 @@
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'dormir', 'acordar'];
function emocao(g: string) {
if (noCelular) gesto(g);
else previa?.ordem(g);
if (soPrevia) previa?.ordem(g);
else gesto(g);
const c = voz();
if (c) (window as any).VozRobo.tocar(c, g, 0.5);
}
@@ -88,10 +89,10 @@
onchange={(v) => (ouvirAqui = v)}
/>
<Toggle
rotulo="Mandar as emoções também para o celular"
ajuda="Desligado, elas aparecem só na prévia"
checked={noCelular}
onchange={(v) => (noCelular = v)}
rotulo="Testar só na prévia, sem mandar ao celular"
ajuda="Desligado, cada emoção aparece no celular na hora"
checked={soPrevia}
onchange={(v) => (soPrevia = v)}
/>
</section>
@@ -130,6 +131,21 @@
{/if}
</section>
<section class="bloco-escolha" aria-labelledby="t-detalhes">
<h2 id="t-detalhes">Detalhes do rosto</h2>
<div class="detalhes">
<Toggle rotulo="Boca" checked={estado.appConfig?.boca !== false} disabled={!estado.appConfig} onchange={(v) => salvarApp({ boca: v })} />
<Toggle rotulo="Sobrancelhas" checked={!!estado.appConfig?.sobrancelhas} disabled={!estado.appConfig} onchange={(v) => salvarApp({ sobrancelhas: v })} />
<Toggle
rotulo="Íris"
ajuda={aceitaFormato ? 'Pupila que acompanha o olhar' : 'Vale para Viva, Bloco e Matriz'}
checked={!!estado.appConfig?.iris}
disabled={!estado.appConfig}
onchange={(v) => salvarApp({ iris: v })}
/>
</div>
</section>
<section class="bloco-escolha" aria-labelledby="t-cor">
<h2 id="t-cor">Cor dos olhos</h2>
<div class="cores">
@@ -356,6 +372,12 @@
height: 18px;
border-radius: 6px;
}
.detalhes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
gap: var(--e-2) var(--e-6);
max-width: 780px;
}
.cores {
display: flex;
flex-wrap: wrap;