Compare commits

..
Author SHA1 Message Date
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
julioandClaude Opus 5.5 f550930f56 feat(gestos): gestos com maos nos favoritos e ate 12 favoritos
Acenar, esconder, espiar, pensar e dancar entram nos favoritos padrao
(logo apos feliz e coracao). Quem ja tinha favoritos salvos recebe esses
gestos uma vez, sem perder os seus. Teclas 1 a 9 para os nove primeiros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:48:21 -03:00
julioandClaude Opus 5.5 230ca991a2 feat(rosto): mao com dedos, polegar e punho no lugar da luva
Quatro dedos separados (medio maior, levemente abertos), polegar para o
lado do rosto, punho de robo e contorno so na borda externa. Ao acenar os
dedos balancam; ao pensar o punho fecha com o indicador no rosto; para
esconder e espiar as maos cobrem o olho inteiro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:45:17 -03:00
julioandClaude Opus 5.5 628db8492f 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>
2026-10-06 02:39:00 -03:00
julioandClaude Opus 5.5 f45e05ab81 feat(ao-vivo): avisa bateria baixa e mostra o ultimo sinal do celular
- aviso no painel quando a bateria do celular chega a 15% e a 5% sem
  carregador (uma vez cada; volta a valer depois de carregar)
- selo do celular fica vermelho com bateria baixa
- celular offline mostra ha quanto tempo foi o ultimo sinal e com quanta
  bateria; abaixo de 10% sugere que a bateria acabou

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:24:33 -03:00
julioandClaude Opus 5.5 69018c8b36 feat(reacoes): emojis e figurinhas no mesmo padrao do rosto novo
- rosto-motor.js desenha emoji e figurinha dentro dos olhos no estilo e
  formato escolhidos (brilho, piscada; na Lente no lugar da iris; na
  Matriz os LEDs usam as cores do desenho)
- MotorRosto.retrato desenha um retrato parado em qualquer canvas
- miniaturas da biblioteca no painel usam o mesmo motor, no rosto, formato
  e cor atuais do robo (3:2, com o simbolo "ao redor" no canto)
- emoji e figurinha "ao redor" ganham o brilho na cor da reacao
- cartoes da biblioteca com as acoes numa linha so

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:19:19 -03:00
21 changed files with 758 additions and 88 deletions
+24 -2
View File
@@ -304,9 +304,17 @@ function sequencias() {
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
return { esconder, espiar, acenar, pensar, musica,
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
@@ -388,11 +396,14 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
const comMotor = ROSTO.motor && window.MotorRosto && !window.carinhaOlhosIcone && !window.carinhaOlhosDesenho;
const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) {
window.MotorRosto.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,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
@@ -466,6 +477,10 @@ function trata(ordem) {
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
@@ -541,6 +556,7 @@ if (MODO_PAINEL) {
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
@@ -1049,6 +1065,7 @@ setTimeout(relatar, 1500);
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
}catch(e){}finally{fetching=false;}
}
setInterval(poll,1000);poll();
@@ -1089,10 +1106,15 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => {
if (window.MotorRosto) {
if (s.ocupado) window.MotorRosto.faixa('digitando');
else window.MotorRosto.faixa(null, { so: 'digitando' });
}
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
+259 -6
View File
@@ -82,9 +82,35 @@
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
@@ -105,7 +131,8 @@
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 (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
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)';
@@ -117,11 +144,15 @@
// 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(e.top, 1 - Math.min(1, p.ab)));
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);
@@ -130,6 +161,7 @@
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;
@@ -173,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);
@@ -180,7 +213,7 @@
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(o.cor, 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)';
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();
}
}
@@ -207,17 +240,237 @@
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; }
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, 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) };
})();
+24 -2
View File
@@ -304,9 +304,17 @@ function sequencias() {
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
return { esconder, espiar, acenar, pensar, musica,
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
@@ -388,11 +396,14 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
const comMotor = ROSTO.motor && window.MotorRosto && !window.carinhaOlhosIcone && !window.carinhaOlhosDesenho;
const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) {
window.MotorRosto.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,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
@@ -466,6 +477,10 @@ function trata(ordem) {
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
@@ -541,6 +556,7 @@ if (MODO_PAINEL) {
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
@@ -1049,6 +1065,7 @@ setTimeout(relatar, 1500);
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
}catch(e){}finally{fetching=false;}
}
setInterval(poll,1000);poll();
@@ -1089,10 +1106,15 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => {
if (window.MotorRosto) {
if (s.ocupado) window.MotorRosto.faixa('digitando');
else window.MotorRosto.faixa(null, { so: 'digitando' });
}
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
+1 -1
View File
@@ -7,7 +7,7 @@ import time
from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar')
EVENTOS=('som','movimento','saudacao','sozinho')
def validar_intencao(data):
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
+2 -1
View File
@@ -51,7 +51,8 @@ FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
# Espelho dos gestos do carinha.html (para o painel montar os botoes)
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
"dormir", "acordar", "aleatorio", "parar"]
"dormir", "acordar", "aleatorio", "parar",
"esconder", "espiar", "acenar", "pensar", "musica"]
# Ordens internas que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"}
+2 -2
View File
@@ -15,13 +15,13 @@
function visual(recipe){
if(recipe.emoji){
const e=recipe.emoji;if(e.local==='olhos'){window.carinhaOlhosIcone=e.caractere;return;}
badge.style.cssText='position:fixed;right:5%;top:8%;z-index:15;font:clamp(42px,10vw,90px) sans-serif;pointer-events:none';badge.textContent=e.caractere;
badge.style.cssText='position:fixed;right:5%;top:8%;z-index:15;font:clamp(42px,10vw,90px) sans-serif;pointer-events:none;filter:drop-shadow(0 0 14px '+(recipe.passos[0].cor||'#5cd6ff')+')';badge.textContent=e.caractere;
animation=badge.animate([{transform:'scale(.92)'},{transform:'scale(1.06)'},{transform:'scale(.92)'}],{duration:1400,iterations:Infinity});return;
}
if(recipe.figurinha){
const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return;
if(recipe.figurinha.local==='olhos'){window.carinhaOlhosDesenho=recipe.figurinha;return;}
badge.style.cssText='position:fixed;right:5%;top:8%;width:24%;height:30%;z-index:15;pointer-events:none';svg.style.cssText='width:100%;height:100%';badge.append(svg);
badge.style.cssText='position:fixed;right:5%;top:8%;width:24%;height:30%;z-index:15;pointer-events:none;filter:drop-shadow(0 0 14px '+(recipe.passos[0].cor||'#5cd6ff')+')';svg.style.cssText='width:100%;height:100%';badge.append(svg);
animation=svg.animate([{transform:'scale(.92) rotate(-3deg)'},{transform:'scale(1.05) rotate(3deg)'},{transform:'scale(.92) rotate(-3deg)'}],{duration:1500,iterations:Infinity});return;
}
if(['ideia','cafe'].includes(recipe.simbolo)){window.carinhaOlhosIcone=icons[recipe.simbolo];return;}
+259 -6
View File
@@ -82,9 +82,35 @@
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
@@ -105,7 +131,8 @@
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 (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
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)';
@@ -117,11 +144,15 @@
// 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(e.top, 1 - Math.min(1, p.ab)));
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);
@@ -130,6 +161,7 @@
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;
@@ -173,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);
@@ -180,7 +213,7 @@
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(o.cor, 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)';
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();
}
}
@@ -207,17 +240,237 @@
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; }
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, 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) };
})();
+7
View File
@@ -82,6 +82,13 @@ def testar(base, server, capturas):
expect(page.get_by_text('samsung SM-G973F')).to_be_visible()
expect(page.get_by_text('192.168.1.16')).to_be_visible()
# 2b. bateria baixa sem carregador vira aviso na hora
page.request.post(base + '/app/status', headers=auth, data={'bateria': {'nivel': 12, 'carregando': False}})
expect(page.get_by_text('Bateria do celular em 12%. Ligue o carregador')).to_be_visible(timeout=5000)
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
# 3. gesto pelo dock
kiosk.FILA.limpar()
page.get_by_role('button', name='Feliz').first.click()
+4
View File
@@ -106,6 +106,10 @@ class ServidorTest(unittest.TestCase):
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
for novo in ('acenar', 'esconder', 'espiar', 'pensar', 'musica'): # gestos com mãos e faixa
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': novo})[0], 200)
kiosk.FILA.limpar()
kiosk.FILA.adicionar('feliz'); kiosk.FILA.adicionar('rosto miudo')
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
+2
View File
@@ -9,6 +9,8 @@
</head>
<body>
<div id="app"></div>
<!-- mesmo motor de rosto do celular, usado nas miniaturas das reações -->
<script src="/rosto-motor.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2 -6
View File
@@ -104,11 +104,7 @@
const conexaoTexto = $derived(
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
);
const bateria = $derived.by(() => {
const b = estado.app?.estado?.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
const bateria = $derived(estado.bateria.nivel);
</script>
<svelte:window onkeydown={teclado} />
@@ -147,7 +143,7 @@
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto}
</Selo>
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>
<Selo tom={!estado.celularOnline ? 'neutro' : estado.bateriaBaixa ? 'perigo' : 'ok'}>
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
</Selo>
{#if estado.conversa}
+21 -3
View File
@@ -20,17 +20,34 @@ export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
dormir: { nome: 'Dormir', emoji: '😴' },
acordar: { nome: 'Acordar', emoji: '🌅' },
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
acenar: { nome: 'Acenar', emoji: '👋' },
esconder: { nome: 'Esconder o rosto', emoji: '🙈' },
espiar: { nome: 'Espiar', emoji: '🫣' },
pensar: { nome: 'Pensar', emoji: '🤔' },
musica: { nome: 'Dançar', emoji: '🎵' },
};
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
const CHAVE_FAVORITOS = 'carinha_favoritos';
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
const CHAVE_MAOS = 'carinha_favoritos_maos'; // marca que os gestos com mãos já entraram
/** Até 12 favoritos; as teclas 1 a 9 valem para os nove primeiros. */
export const MAX_FAVORITOS = 12;
const GESTOS_MAOS = ['acenar', 'esconder', 'espiar', 'pensar', 'musica'];
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'piscadinha', 'surpreso', 'triste', 'dormir', 'acordar'];
function lerFavoritos(): string[] {
try {
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.slice(0, 9);
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
if (localStorage.getItem(CHAVE_MAOS)) return v.slice(0, MAX_FAVORITOS);
// Quem já tinha favoritos salvos ganha os gestos com mãos logo depois dos dois primeiros.
const novos = GESTOS_MAOS.filter((g) => !v.includes(g));
const lista = [...v.slice(0, 2), ...novos, ...v.slice(2)].slice(0, MAX_FAVORITOS);
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(lista));
localStorage.setItem(CHAVE_MAOS, '1');
return lista;
}
} catch {
/* sem armazenamento */
}
@@ -40,9 +57,10 @@ function lerFavoritos(): string[] {
class Favoritos {
lista = $state<string[]>(lerFavoritos());
alternar(g: string) {
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9);
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, MAX_FAVORITOS);
try {
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
localStorage.setItem(CHAVE_MAOS, '1');
} catch {
/* ok */
}
+28 -7
View File
@@ -5,15 +5,19 @@
// Cartão do aparelho com o que o próprio app informa (IP, modelo, Android, versão, Wi-Fi, bateria).
const s = $derived(estado.app?.estado ?? {});
const ap = $derived(s.aparelho ?? null);
const bateria = $derived.by(() => {
const b = s.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
const bateria = $derived(estado.bateria.nivel);
const carregando = $derived(estado.bateria.carregando);
// Tempo desde o último sinal, em texto curto ("há 3 min").
const semSinal = $derived.by(() => {
if (!estado.ultimoSinalEm) return '';
const seg = Math.max(0, Math.round((estado.agora - estado.ultimoSinalEm) / 1000));
if (seg < 60) return `há ${seg} s`;
if (seg < 3600) return `há ${Math.round(seg / 60)} min`;
if (seg < 86400) return `há ${Math.round(seg / 3600)} h`;
return `há ${Math.round(seg / 86400)} dia(s)`;
});
const carregando = $derived(typeof s.bateria === 'object' && !!s.bateria?.carregando);
const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull);
const ip = $derived(ap?.ip || estado.app?.celular_ip || '—');
const visto = $derived(estado.app?.idade ?? null);
</script>
<div class="celular" class:offline={!estado.celularOnline}>
@@ -25,7 +29,7 @@
{#if estado.celularOnline}
{[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'}
{:else}
offline{visto !== null ? ` · visto há ${Math.round(visto)} s` : ''}
offline{semSinal ? ' · último sinal ' + semSinal : ''}
{/if}
</span>
</div>
@@ -70,6 +74,12 @@
</div>
</dl>
{:else}
{#if bateria !== null && estado.ultimoSinalEm}
<p class="ultimo" class:baixa={bateria <= 15 && !carregando}>
<IconeBateria size={18} /> Último sinal com {bateria}%{carregando ? ', carregando' : ''}.
{#if bateria <= 10 && !carregando}Provavelmente a bateria acabou: ligue o carregador e abra o app.{/if}
</p>
{/if}
<p class="ajuda">Abra o app Carinha no celular e confira se ele aponta para este servidor (toque longo › Mudar o servidor).</p>
{/if}
</div>
@@ -170,6 +180,17 @@
rect.cheia {
fill: var(--texto);
}
.ultimo {
display: flex;
align-items: center;
gap: var(--e-2);
flex-wrap: wrap;
font-size: var(--t-sm);
font-weight: 500;
}
.ultimo.baixa {
color: var(--perigo);
}
.ajuda {
font-size: var(--t-sm);
color: var(--texto-3);
+2 -2
View File
@@ -11,10 +11,10 @@
{#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]}
<div class="item">
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<button class="gesto" onclick={() => gesto(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<span class="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span>
{#if !completo}<kbd>{i + 1}</kbd>{/if}
{#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
</button>
{#if completo}
<button
+61 -33
View File
@@ -1,56 +1,84 @@
<script lang="ts">
import { rgb, ROSTOS_INFO } from '../comandos.svelte';
import { estado } from '../estado.svelte';
import { SIMBOLO_EMOJI } from '../treinamento.svelte';
// Prévia estática de uma reação (sem iframe): figurinha SVG, emoji ou olhos do 1º passo.
// Retrato parado da reação, desenhado pelo mesmo motor do celular (rosto-motor.js),
// no rosto, formato e cor que o robô está usando agora.
let { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
let tela: HTMLCanvasElement;
const passo = $derived(receita?.passos?.[0]);
const simbolo = $derived(receita?.emoji?.caractere || SIMBOLO_EMOJI[receita?.simbolo] || '');
const nosOlhos = $derived({
icone:
receita?.emoji?.local === 'olhos'
? receita.emoji.caractere
: ['ideia', 'cafe'].includes(receita?.simbolo) && !receita?.emoji && !receita?.figurinha
? SIMBOLO_EMOJI[receita.simbolo]
: null,
desenho: receita?.figurinha?.local === 'olhos' ? receita.figurinha : null,
});
// O que aparece fora dos olhos (emoji/figurinha "ao redor", notas, estrelas, corações).
const aoRedor = $derived.by(() => {
if (receita?.emoji && receita.emoji.local !== 'olhos') return { emoji: receita.emoji.caractere };
if (receita?.figurinha && receita.figurinha.local !== 'olhos') return { figurinha: receita.figurinha };
if (['musica', 'estrela', 'coracao'].includes(receita?.simbolo)) return { emoji: SIMBOLO_EMOJI[receita.simbolo] };
return null;
});
const estilo = $derived(ROSTOS_INFO[estado.aparencia.rosto]?.novo ? estado.aparencia.rosto : 'vivo');
$effect(() => {
const motor = (window as any).MotorRosto;
if (!motor || !tela) return;
const d = Math.min(2, devicePixelRatio || 1);
tela.width = Math.round(tamanho * 1.5 * d); // 3:2, cabe os dois olhos com folga
tela.height = Math.round(tamanho * d);
motor.retrato(tela, {
estilo,
formato: estado.aparencia.formato ?? 'padrao',
cor: /^#[0-9a-f]{6}$/i.test(passo?.cor ?? '') ? rgb(passo.cor) : estado.aparencia.cor,
forma: passo?.olhos ?? 'aberto',
icone: nosOlhos.icone,
desenho: nosOlhos.desenho ? $state.snapshot(nosOlhos.desenho) : null,
});
});
</script>
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
{#if receita?.figurinha?.formas}
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}>
{#each receita.figurinha.formas as forma, i (i)}
<path d={forma.caminho} fill={forma.cor} />
{/each}
<canvas bind:this={tela}></canvas>
{#if aoRedor?.emoji}
<span class="extra">{aoRedor.emoji}</span>
{:else if aoRedor?.figurinha}
<svg class="extra" viewBox="0 0 100 100">
{#each aoRedor.figurinha.formas as forma, i (i)}<path d={forma.caminho} fill={forma.cor} />{/each}
</svg>
{:else if receita?.emoji}
<span class="emoji">{receita.emoji.caractere}</span>
{:else}
<svg viewBox="0 0 100 60" width={tamanho} height={tamanho * 0.6}>
{#if passo?.olhos === 'feliz'}
<path d="M18 38 Q30 18 42 38 M58 38 Q70 18 82 38" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
{:else if passo?.olhos === 'triste'}
<path d="M18 26 Q30 44 42 26 M58 26 Q70 44 82 26" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
{:else}
{@const ry = passo?.olhos === 'surpreso' ? 20 : 16}
<ellipse cx="30" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
<ellipse cx="70" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
{/if}
</svg>
{#if simbolo}<span class="simbolo">{simbolo}</span>{/if}
{/if}
</div>
<style>
.miniatura {
position: relative;
width: var(--t);
width: calc(var(--t) * 1.5);
height: var(--t);
display: grid;
place-items: center;
border-radius: var(--raio);
background: #05080c;
background: #000;
flex: none;
overflow: hidden;
}
.emoji {
font-size: calc(var(--t) * 0.55);
line-height: 1;
canvas {
width: 100%;
height: 100%;
display: block;
}
.simbolo {
.extra {
position: absolute;
right: 2px;
bottom: 0;
font-size: calc(var(--t) * 0.3);
right: 4px;
top: 3px;
width: calc(var(--t) * 0.34);
height: calc(var(--t) * 0.34);
font-size: calc(var(--t) * 0.26);
line-height: calc(var(--t) * 0.34);
text-align: center;
filter: drop-shadow(0 0 4px rgb(92 214 255 / 0.6));
}
</style>
@@ -31,6 +31,7 @@
ultimaConversa = c.ultima.id;
enviar({ tipo: 'ordem', ordem: 'acordar' });
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
enviar({ tipo: 'legenda', texto: c.ultima.texto });
}
}),
];
+33 -1
View File
@@ -3,6 +3,7 @@
* Nenhuma tela faz polling; todas leem daqui.
*/
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
import { avisos } from './avisos.svelte';
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
@@ -37,6 +38,18 @@ class Estado {
agora = $state(Date.now());
celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000);
/** Quando o celular mandou o último sinal (0 = nunca, nesta sessão nem no servidor). */
ultimoSinalEm = $state(0);
/** Bateria do último sinal recebido, mesmo com o celular offline. */
bateria = $derived.by(() => {
const b = this.app?.estado?.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return {
nivel: typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null,
carregando: typeof b === 'object' && !!b?.carregando,
};
});
bateriaBaixa = $derived(this.bateria.nivel !== null && this.bateria.nivel <= 15 && !this.bateria.carregando);
}
export const estado = new Estado();
@@ -51,6 +64,23 @@ export function ouvir(tipo: string, fn: Ouvinte): () => void {
return () => ouvintes.get(tipo)!.delete(fn);
}
// Avisa uma vez em 15% e outra em 5% (sem carregador); zera quando volta a carregar.
let avisouEm: number | null = null;
function avisarBateria() {
const { nivel, carregando } = estado.bateria;
if (nivel === null) return;
if (carregando || nivel > 20) {
avisouEm = null;
return;
}
const limite = nivel <= 5 ? 5 : nivel <= 15 ? 15 : null;
if (limite !== null && avisouEm !== limite) {
avisouEm = limite;
avisos.mostrar(`Bateria do celular em ${nivel}%. Ligue o carregador ou o Carinha vai desligar.`, 'erro');
noFeed('estop', `Bateria do celular em ${nivel}%`);
}
}
let seqFeed = 0;
function noFeed(tipo: string, texto: string) {
estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
@@ -67,6 +97,7 @@ function aplicarFoto(f: EstadoServidor) {
if (f.app) {
estado.app = f.app;
estado.appRecebidoEm = Date.now();
estado.ultimoSinalEm = typeof f.app.idade === 'number' ? Date.now() - f.app.idade * 1000 : 0;
}
if (f.app_config) estado.appConfig = f.app_config;
if (f.motores) estado.motores = f.motores;
@@ -83,7 +114,8 @@ const tratadores: Record<string, (d: any) => void> = {
},
telemetria: (d: Telemetria) => {
estado.app = d;
estado.appRecebidoEm = Date.now();
estado.appRecebidoEm = estado.ultimoSinalEm = Date.now();
avisarBateria();
},
app_config: (d: AppConfig) => (estado.appConfig = d),
aparencia: (d) => (estado.aparencia = d),
+1 -1
View File
@@ -146,7 +146,7 @@
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
</Grupo>
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 no teclado. Ctrl+K busca qualquer gesto.">
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos />
</Linha>
+9 -1
View File
@@ -204,7 +204,7 @@
</Card>
</div>
{:else if aba === 'gestos'}
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (atalhos 1 a 9).">
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (até 12; as teclas 1 a 9 valem para os nove primeiros).">
<Gestos completo />
</Card>
{:else}
@@ -364,6 +364,14 @@
.espaco {
flex: 1;
}
/* seis ações pequenas + Mostrar cabem numa linha num cartão de 300 px */
.acoes :global(.btn.pequeno.icone) {
width: 28px;
}
.acoes {
flex-wrap: nowrap;
gap: 2px;
}
.marcado {
color: var(--ok);
border-color: var(--ok);
+3 -1
View File
@@ -18,7 +18,7 @@
const classicos = $derived(estado.rostos.filter((r) => !ROSTOS_INFO[r]?.novo));
const formatos = $derived(estado.formatos.length ? estado.formatos : Object.keys(FORMATOS_INFO));
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
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);
@@ -28,6 +28,8 @@
{ titulo: 'Pálpebras no lugar de formas prontas', texto: 'Cada emoção é posição de pálpebra; as trocas ficam suaves em vez de saltar.', feito: true, onde: 'rosto' },
{ titulo: 'Nunca parado', texto: 'Respiração, sacadas e piscada que achata o olho. Também protege a tela AMOLED.', feito: true, onde: 'rosto' },
{ titulo: 'Toque na tela', texto: 'Cutucar dá susto e risada; deslizar o dedo é carinho; tocar acorda.', feito: true, onde: 'rosto' },
{ titulo: 'Mãos', texto: 'Mãozinhas na cor dos olhos: esconde o rosto, espia, acena e pensa com a mão no queixo.', feito: true, onde: 'rosto' },
{ titulo: 'Faixa de baixo', texto: 'Legenda da conversa sendo digitada, pontinhos enquanto pensa, equalizador ao dançar e carga quando está no carregador.', feito: true, onde: 'rosto' },
{ titulo: 'Fala pelo próprio celular', texto: 'A resposta da conversa sai no alto-falante do S10, com os olhos pulsando na voz.', feito: false, onde: 'app' },
{ titulo: 'Ritmo do dia', texto: 'Boceja à noite, fica sonolento com bateria baixa, mostra um raio quando carrega.', feito: false, onde: 'rosto e servidor' },
{ titulo: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' },
+1 -1
View File
@@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
'/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
'/carinha.html', '/reacoes-render.js', '/rosto-motor.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
export default defineConfig({
plugins: [svelte()],