diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index 26cfed2..72b4ca5 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -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 }; } @@ -394,6 +402,7 @@ function desenhar() { 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]) => ({ @@ -468,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. @@ -543,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); @@ -1051,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(); @@ -1091,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; diff --git a/android/app/assets/rosto-motor.js b/android/app/assets/rosto-motor.js index ea0cd4a..cbe18ea 100644 --- a/android/app/assets/rosto-motor.js +++ b/android/app/assets/rosto-motor.js @@ -205,6 +205,7 @@ bctx.clearRect(0, 0, colunas, linhas); bctx.save(); bctx.scale(f, f); olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i])); + desenharMaos(bctx, o, olhos, tela.height / d); bctx.restore(); const px = bctx.getImageData(0, 0, colunas, linhas).data; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height); @@ -239,14 +240,135 @@ ry: p.ry * d, })); ctx.clearRect(0, 0, tela.width, tela.height); - if (estilo.pontos) return desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d); - if (estilo.lente) { olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); return; } - cctx.clearRect(0, 0, camada.width, camada.height); - olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); - ctx.save(); - if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } - ctx.drawImage(camada, 0, 0); - ctx.restore(); + if (estilo.pontos) { + desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d); + } else if (estilo.lente) { + olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); + desenharMaos(ctx, o, olhos, tela.height); + } else { + cctx.clearRect(0, 0, camada.width, camada.height); + olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); + desenharMaos(cctx, o, olhos, tela.height); + ctx.save(); + if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } + ctx.drawImage(camada, 0, 0); + ctx.restore(); + } + desenharFaixa(ctx, o, tela.width, tela.height); + } + + // ------------------------------------------------------------------ mãos + // Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências + // de rosto de mesmo nome no carinha.html. + const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 }; + const maos = { modo: null, t0: null }; + const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); }; + const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b + + // Luva simples: polegar atrás da palma e dois vincos marcando os dedos. + function mao(c, o, x, y, s, ang, espelho) { + const escura = o.cor.map((v) => v * 0.36); + c.save(); + c.translate(x, y); c.rotate(ang); c.scale(espelho, 1); + c.fillStyle = css(escura); c.strokeStyle = css(o.cor); c.lineWidth = s * 0.1; c.lineJoin = 'round'; c.lineCap = 'round'; + c.beginPath(); c.ellipse(0.55 * s, 0.12 * s, 0.24 * s, 0.4 * s, -0.55, 0, Math.PI * 2); c.fill(); c.stroke(); // polegar + caixa(c, -0.6 * s, -0.75 * s, 1.2 * s, 1.55 * s, 0.55 * s); c.fill(); c.stroke(); // palma e dedos + c.lineWidth = s * 0.07; + for (const dx of [-0.2, 0.2]) { c.beginPath(); c.moveTo(dx * s, -0.72 * s); c.lineTo(dx * s, -0.34 * s); c.stroke(); } // vincos + c.restore(); + } + + function desenharMaos(c, o, olhos, H) { + if (!maos.modo) return; + if (maos.t0 === null) maos.t0 = o.t; + const k = (o.t - maos.t0) / DUR_MAOS[maos.modo]; + if (k >= 1) { maos.modo = null; return; } + const [e, d] = olhos.slice().sort((a, b) => a.cx - b.cx); // olho da esquerda e da direita na tela + const s = e.ry * 0.95, fora = H + s * 1.6; + const subir = (y, a, b) => fora + (y - fora) * faixaDe(k, a, b); + if (maos.modo === 'esconder' || maos.modo === 'espiar') { + const altura = (y) => (k < 0.8 ? subir(y, 0, 0.2) : y + (fora - y) * faixaDe(k, 0.82, 1)); + const treme = Math.sin(o.t * 24) * s * 0.025; // tímido: a mão treme um pouco + let afasta = 0; + if (maos.modo === 'espiar') afasta = s * 1.35 * (faixaDe(k, 0.3, 0.42) - faixaDe(k, 0.66, 0.76)); + mao(c, o, e.cx - afasta + treme, altura(e.cy + e.ry * 0.15), s, -0.12 - afasta / s * 0.3, 1); + mao(c, o, d.cx - treme, altura(d.cy + d.ry * 0.15), s, 0.12, -1); + } else if (maos.modo === 'acenar') { + const x = d.cx + d.ry * 2.1, y0 = d.cy + d.ry * 0.7; + const y = k < 0.8 ? subir(y0, 0, 0.2) : y0 + (fora - y0) * faixaDe(k, 0.82, 1); + const balanco = k > 0.2 && k < 0.8 ? Math.sin((k - 0.2) * Math.PI * 7) * 0.38 : 0; + mao(c, o, x, y, s * 0.9, balanco, -1); + } else if (maos.modo === 'pensar') { + const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4; + const y = (k < 0.8 ? subir(y0, 0, 0.2) : y0 + (fora - y0) * faixaDe(k, 0.82, 1)) + Math.sin(o.t * 9) * s * 0.03; + mao(c, o, x, y, s * 0.75, -0.35, -1); + } + } + + // --------------------------------------------------------- faixa de baixo + // A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga. + const faixa = { modo: null, texto: '', t0: null, dur: 0 }; + + function definirFaixa(modo, opcoes) { + opcoes = opcoes || {}; + if (!modo) { + if (!opcoes.so || faixa.modo === opcoes.so) faixa.modo = null; // so: só apaga esse modo + return; + } + faixa.modo = modo; faixa.t0 = performance.now() / 1000; faixa.texto = String(opcoes.texto || '').replace(/\s+/g, ' ').trim().slice(0, 400); + faixa.dur = opcoes.segundos || (modo === 'legenda' ? Math.max(3, Math.min(14, faixa.texto.length / 14 + 2.5)) : modo === 'digitando' ? 20 : 2.6); + } + + function desenharFaixa(c, o, W, H) { + const y = H * 0.955, fonte = Math.round(H * 0.042); + c.save(); + c.shadowColor = css(o.cor, 0.6); c.shadowBlur = H * 0.02; + if (o.carga && o.carga.carregando && typeof o.carga.nivel === 'number') { // carregando: canto direito + const n = Math.max(0, Math.min(100, o.carga.nivel)), bw = H * 0.075, bh = H * 0.036, bx = W - H * 0.13, by = y - bh / 2; + c.globalAlpha = 0.55 + 0.25 * Math.sin(o.t * 2.5); + c.strokeStyle = css(o.cor); c.lineWidth = Math.max(1, H * 0.005); + caixa(c, bx, by, bw, bh, bh * 0.25); c.stroke(); + c.fillStyle = css(o.cor); c.fillRect(bx + bw, y - bh * 0.2, bh * 0.15, bh * 0.4); + c.fillRect(bx + bh * 0.12, by + bh * 0.15, (bw - bh * 0.24) * n / 100, bh * 0.7); + c.font = '600 ' + Math.round(fonte * 0.8) + 'px system-ui,sans-serif'; c.textAlign = 'right'; c.textBaseline = 'middle'; + const tx = bx - H * 0.012; + c.fillText(Math.round(n) + '%', tx, y); + const rx = tx - c.measureText(Math.round(n) + '%').width - H * 0.022, r = H * 0.016; // raio desenhado na cor dos olhos + c.beginPath(); c.moveTo(rx + r * 0.3, y - r); c.lineTo(rx - r * 0.5, y + r * 0.15); c.lineTo(rx, y + r * 0.15); + c.lineTo(rx - r * 0.3, y + r); c.lineTo(rx + r * 0.5, y - r * 0.15); c.lineTo(rx, y - r * 0.15); c.closePath(); c.fill(); + c.globalAlpha = 1; + } + if (faixa.modo) { + if (faixa.t0 === null) faixa.t0 = o.t; + const k = (o.t - faixa.t0) / faixa.dur; + if (k >= 1) faixa.modo = null; + else { + const some = Math.min(1, (faixa.dur - (o.t - faixa.t0)) / 0.4); + c.globalAlpha = Math.max(0, some); + c.fillStyle = css(o.cor); + if (faixa.modo === 'legenda') { + // efeito de digitação; se não couber, mostra o final do que já foi "dito" + let txt = faixa.texto.slice(0, Math.floor((o.t - faixa.t0) * 30)); + c.font = '500 ' + fonte + 'px system-ui,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; + const max = W * 0.8; + while (txt.length > 1 && c.measureText(txt).width > max) txt = txt.slice(1); + c.fillText(txt, W / 2, y); + } else if (faixa.modo === 'digitando') { + for (let i = 0; i < 3; i++) { + const pulo = Math.max(0, Math.sin(o.t * 7 - i * 0.7)) * H * 0.012; + c.beginPath(); c.arc(W / 2 + (i - 1) * H * 0.035, y - pulo, H * 0.011, 0, Math.PI * 2); c.fill(); + } + } else if (faixa.modo === 'equalizador') { + const n = 15, larg = H * 0.016, passo = H * 0.026; + for (let i = 0; i < n; i++) { + const a = 0.25 + 0.75 * Math.abs(Math.sin(o.t * (5 + i % 4) + i * 1.3)) * Math.sin(Math.PI * (i + 0.5) / n); + const h = H * 0.04 * a; + caixa(c, W / 2 + (i - (n - 1) / 2) * passo - larg / 2, H * 0.988 - h, larg, h, larg / 2); c.fill(); + } + } + } + } + c.restore(); } /** @@ -285,5 +407,8 @@ function esconder() { if (tela) tela.style.display = 'none'; antes = 0; } - window.MotorRosto = { desenhar, retrato, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) }; + window.MotorRosto = { + desenhar, retrato, esconder, + maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } }, + faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) }; })(); diff --git a/carinha.html b/carinha.html index 26cfed2..72b4ca5 100644 --- a/carinha.html +++ b/carinha.html @@ -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 }; } @@ -394,6 +402,7 @@ function desenhar() { 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]) => ({ @@ -468,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. @@ -543,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); @@ -1051,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(); @@ -1091,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; diff --git a/cerebro.py b/cerebro.py index febae9b..e15fb3f 100644 --- a/cerebro.py +++ b/cerebro.py @@ -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: diff --git a/kiosk-server.py b/kiosk-server.py index 0f2b8e8..d9e3229 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -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"} diff --git a/rosto-motor.js b/rosto-motor.js index ea0cd4a..cbe18ea 100644 --- a/rosto-motor.js +++ b/rosto-motor.js @@ -205,6 +205,7 @@ bctx.clearRect(0, 0, colunas, linhas); bctx.save(); bctx.scale(f, f); olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i])); + desenharMaos(bctx, o, olhos, tela.height / d); bctx.restore(); const px = bctx.getImageData(0, 0, colunas, linhas).data; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height); @@ -239,14 +240,135 @@ ry: p.ry * d, })); ctx.clearRect(0, 0, tela.width, tela.height); - if (estilo.pontos) return desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d); - if (estilo.lente) { olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); return; } - cctx.clearRect(0, 0, camada.width, camada.height); - olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); - ctx.save(); - if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } - ctx.drawImage(camada, 0, 0); - ctx.restore(); + if (estilo.pontos) { + desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d); + } else if (estilo.lente) { + olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); + desenharMaos(ctx, o, olhos, tela.height); + } else { + cctx.clearRect(0, 0, camada.width, camada.height); + olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); + desenharMaos(cctx, o, olhos, tela.height); + ctx.save(); + if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } + ctx.drawImage(camada, 0, 0); + ctx.restore(); + } + desenharFaixa(ctx, o, tela.width, tela.height); + } + + // ------------------------------------------------------------------ mãos + // Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências + // de rosto de mesmo nome no carinha.html. + const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 }; + const maos = { modo: null, t0: null }; + const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); }; + const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b + + // Luva simples: polegar atrás da palma e dois vincos marcando os dedos. + function mao(c, o, x, y, s, ang, espelho) { + const escura = o.cor.map((v) => v * 0.36); + c.save(); + c.translate(x, y); c.rotate(ang); c.scale(espelho, 1); + c.fillStyle = css(escura); c.strokeStyle = css(o.cor); c.lineWidth = s * 0.1; c.lineJoin = 'round'; c.lineCap = 'round'; + c.beginPath(); c.ellipse(0.55 * s, 0.12 * s, 0.24 * s, 0.4 * s, -0.55, 0, Math.PI * 2); c.fill(); c.stroke(); // polegar + caixa(c, -0.6 * s, -0.75 * s, 1.2 * s, 1.55 * s, 0.55 * s); c.fill(); c.stroke(); // palma e dedos + c.lineWidth = s * 0.07; + for (const dx of [-0.2, 0.2]) { c.beginPath(); c.moveTo(dx * s, -0.72 * s); c.lineTo(dx * s, -0.34 * s); c.stroke(); } // vincos + c.restore(); + } + + function desenharMaos(c, o, olhos, H) { + if (!maos.modo) return; + if (maos.t0 === null) maos.t0 = o.t; + const k = (o.t - maos.t0) / DUR_MAOS[maos.modo]; + if (k >= 1) { maos.modo = null; return; } + const [e, d] = olhos.slice().sort((a, b) => a.cx - b.cx); // olho da esquerda e da direita na tela + const s = e.ry * 0.95, fora = H + s * 1.6; + const subir = (y, a, b) => fora + (y - fora) * faixaDe(k, a, b); + if (maos.modo === 'esconder' || maos.modo === 'espiar') { + const altura = (y) => (k < 0.8 ? subir(y, 0, 0.2) : y + (fora - y) * faixaDe(k, 0.82, 1)); + const treme = Math.sin(o.t * 24) * s * 0.025; // tímido: a mão treme um pouco + let afasta = 0; + if (maos.modo === 'espiar') afasta = s * 1.35 * (faixaDe(k, 0.3, 0.42) - faixaDe(k, 0.66, 0.76)); + mao(c, o, e.cx - afasta + treme, altura(e.cy + e.ry * 0.15), s, -0.12 - afasta / s * 0.3, 1); + mao(c, o, d.cx - treme, altura(d.cy + d.ry * 0.15), s, 0.12, -1); + } else if (maos.modo === 'acenar') { + const x = d.cx + d.ry * 2.1, y0 = d.cy + d.ry * 0.7; + const y = k < 0.8 ? subir(y0, 0, 0.2) : y0 + (fora - y0) * faixaDe(k, 0.82, 1); + const balanco = k > 0.2 && k < 0.8 ? Math.sin((k - 0.2) * Math.PI * 7) * 0.38 : 0; + mao(c, o, x, y, s * 0.9, balanco, -1); + } else if (maos.modo === 'pensar') { + const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4; + const y = (k < 0.8 ? subir(y0, 0, 0.2) : y0 + (fora - y0) * faixaDe(k, 0.82, 1)) + Math.sin(o.t * 9) * s * 0.03; + mao(c, o, x, y, s * 0.75, -0.35, -1); + } + } + + // --------------------------------------------------------- faixa de baixo + // A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga. + const faixa = { modo: null, texto: '', t0: null, dur: 0 }; + + function definirFaixa(modo, opcoes) { + opcoes = opcoes || {}; + if (!modo) { + if (!opcoes.so || faixa.modo === opcoes.so) faixa.modo = null; // so: só apaga esse modo + return; + } + faixa.modo = modo; faixa.t0 = performance.now() / 1000; faixa.texto = String(opcoes.texto || '').replace(/\s+/g, ' ').trim().slice(0, 400); + faixa.dur = opcoes.segundos || (modo === 'legenda' ? Math.max(3, Math.min(14, faixa.texto.length / 14 + 2.5)) : modo === 'digitando' ? 20 : 2.6); + } + + function desenharFaixa(c, o, W, H) { + const y = H * 0.955, fonte = Math.round(H * 0.042); + c.save(); + c.shadowColor = css(o.cor, 0.6); c.shadowBlur = H * 0.02; + if (o.carga && o.carga.carregando && typeof o.carga.nivel === 'number') { // carregando: canto direito + const n = Math.max(0, Math.min(100, o.carga.nivel)), bw = H * 0.075, bh = H * 0.036, bx = W - H * 0.13, by = y - bh / 2; + c.globalAlpha = 0.55 + 0.25 * Math.sin(o.t * 2.5); + c.strokeStyle = css(o.cor); c.lineWidth = Math.max(1, H * 0.005); + caixa(c, bx, by, bw, bh, bh * 0.25); c.stroke(); + c.fillStyle = css(o.cor); c.fillRect(bx + bw, y - bh * 0.2, bh * 0.15, bh * 0.4); + c.fillRect(bx + bh * 0.12, by + bh * 0.15, (bw - bh * 0.24) * n / 100, bh * 0.7); + c.font = '600 ' + Math.round(fonte * 0.8) + 'px system-ui,sans-serif'; c.textAlign = 'right'; c.textBaseline = 'middle'; + const tx = bx - H * 0.012; + c.fillText(Math.round(n) + '%', tx, y); + const rx = tx - c.measureText(Math.round(n) + '%').width - H * 0.022, r = H * 0.016; // raio desenhado na cor dos olhos + c.beginPath(); c.moveTo(rx + r * 0.3, y - r); c.lineTo(rx - r * 0.5, y + r * 0.15); c.lineTo(rx, y + r * 0.15); + c.lineTo(rx - r * 0.3, y + r); c.lineTo(rx + r * 0.5, y - r * 0.15); c.lineTo(rx, y - r * 0.15); c.closePath(); c.fill(); + c.globalAlpha = 1; + } + if (faixa.modo) { + if (faixa.t0 === null) faixa.t0 = o.t; + const k = (o.t - faixa.t0) / faixa.dur; + if (k >= 1) faixa.modo = null; + else { + const some = Math.min(1, (faixa.dur - (o.t - faixa.t0)) / 0.4); + c.globalAlpha = Math.max(0, some); + c.fillStyle = css(o.cor); + if (faixa.modo === 'legenda') { + // efeito de digitação; se não couber, mostra o final do que já foi "dito" + let txt = faixa.texto.slice(0, Math.floor((o.t - faixa.t0) * 30)); + c.font = '500 ' + fonte + 'px system-ui,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; + const max = W * 0.8; + while (txt.length > 1 && c.measureText(txt).width > max) txt = txt.slice(1); + c.fillText(txt, W / 2, y); + } else if (faixa.modo === 'digitando') { + for (let i = 0; i < 3; i++) { + const pulo = Math.max(0, Math.sin(o.t * 7 - i * 0.7)) * H * 0.012; + c.beginPath(); c.arc(W / 2 + (i - 1) * H * 0.035, y - pulo, H * 0.011, 0, Math.PI * 2); c.fill(); + } + } else if (faixa.modo === 'equalizador') { + const n = 15, larg = H * 0.016, passo = H * 0.026; + for (let i = 0; i < n; i++) { + const a = 0.25 + 0.75 * Math.abs(Math.sin(o.t * (5 + i % 4) + i * 1.3)) * Math.sin(Math.PI * (i + 0.5) / n); + const h = H * 0.04 * a; + caixa(c, W / 2 + (i - (n - 1) / 2) * passo - larg / 2, H * 0.988 - h, larg, h, larg / 2); c.fill(); + } + } + } + } + c.restore(); } /** @@ -285,5 +407,8 @@ function esconder() { if (tela) tela.style.display = 'none'; antes = 0; } - window.MotorRosto = { desenhar, retrato, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) }; + window.MotorRosto = { + desenhar, retrato, esconder, + maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } }, + faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) }; })(); diff --git a/tests/test_servidor.py b/tests/test_servidor.py index b9031b9..be4cdca 100644 --- a/tests/test_servidor.py +++ b/tests/test_servidor.py @@ -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], []) diff --git a/web/src/lib/comandos.svelte.ts b/web/src/lib/comandos.svelte.ts index 4efdf25..567fe94 100644 --- a/web/src/lib/comandos.svelte.ts +++ b/web/src/lib/comandos.svelte.ts @@ -20,6 +20,11 @@ export const GESTOS_INFO: Record = { 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); diff --git a/web/src/lib/componentes/RostoPrevia.svelte b/web/src/lib/componentes/RostoPrevia.svelte index fcc1205..1dde145 100644 --- a/web/src/lib/componentes/RostoPrevia.svelte +++ b/web/src/lib/componentes/RostoPrevia.svelte @@ -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 }); } }), ]; diff --git a/web/src/telas/Rosto.svelte b/web/src/telas/Rosto.svelte index 1faecdd..1eab648 100644 --- a/web/src/telas/Rosto.svelte +++ b/web/src/telas/Rosto.svelte @@ -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' },