Compare commits

..
Author SHA1 Message Date
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
julioandClaude Opus 5.5 972dd8dd66 feat(rosto): motor de palpebras, 4 rostos novos, formato do olho e tela Rosto
- rosto-motor.js: emocoes como posicao de palpebra (cima, inclinacao, baixo)
  com transicao suave; respiracao, sacadas e piscada que achata o olho
- rostos novos Carinha Viva, Bloco, Matriz (LEDs) e Lente (iris/pupila);
  os classicos continuam; emoji/figurinha nos olhos seguem no SVG
- formato do olho (padrao, redondo, pilula, quadrado, largo) para Viva,
  Bloco e Matriz; salvo na aparencia e enviado como ordem "formato X"
- toque na tela do S10: cutucar = susto e risada, deslizar = carinho,
  tocar dormindo acorda
- painel: tela Rosto (celular em destaque, cartoes de rosto, formatos,
  cores, testar emocoes so na previa ou no celular, lista do que ja esta
  pronto); Ao vivo leva a ela; paleta ganha rostos e formatos
- corrige /comando rosto que nao salvava a aparencia

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:33:56 -03:00
24 changed files with 1334 additions and 145 deletions
+47 -2
View File
@@ -25,6 +25,7 @@
<path id="sobrancelha2" class="traco" style="display:none"/> <path id="sobrancelha2" class="traco" style="display:none"/>
</svg> </svg>
<div id="fps"></div> <div id="fps"></div>
<script src="rosto-motor.js"></script>
<script> <script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao /* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */ próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
@@ -103,10 +104,17 @@ const BRANCO = [245, 250, 255];
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos // A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo" // crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = { const ROSTOS = {
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 }, eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 }, carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 }, miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
}; };
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
let FORMATO = "padrao";
let ROSTO_NOME = "carinha"; let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME]; let ROSTO = ROSTOS[ROSTO_NOME];
@@ -380,12 +388,27 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0); 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 dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
for (let i = 0; i < 2; i++) { 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,
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,
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
})),
});
} else if (window.MotorRosto) window.MotorRosto.esconder();
for (let i = 0; i < 2 && !comMotor; i++) {
const [c, abx, fx, lado] = olhos[i]; const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc, olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6); abx, fx, r.cor, lado, dx, dy * 0.6);
} }
desenharBoca(); if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
else desenharBoca();
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1)); const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none'; brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
@@ -430,6 +453,9 @@ function trata(ordem) {
trocarRosto(ordem.slice(6).trim()); trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) { } else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo" trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("formato ") === 0) {
const f = ordem.slice(8).trim();
if (FORMATOS.includes(f)) FORMATO = f;
} else if (ordem.indexOf("cor ") === 0) { } else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number); const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) { if (v.length === 3 && v.every((n) => !isNaN(n))) {
@@ -501,6 +527,7 @@ async function aplicarAparencia() {
if (!resp.ok) return; if (!resp.ok) return;
const c = await resp.json(); const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto); if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n))) if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); } { CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ } } catch (e) { /* sem servidor — segue com o padrao */ }
@@ -519,6 +546,24 @@ if (MODO_PAINEL) {
}); });
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000); } else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
(function () {
let inicio = null;
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
addEventListener('pointerup', (e) => {
if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null;
if (r.dormindo) { trata('acordar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
});
})();
// teclado (util para testar no PC) // teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo", const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir", t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
+289
View File
@@ -0,0 +1,289 @@
/* rosto-motor.js — motor de pálpebras dos rostos novos (Carinha Viva, Bloco, Matriz, Lente).
*
* O carinha.html continua decidindo O QUE o rosto faz (gestos, reações, sensores, rastreamento);
* aqui só se desenha. Cada "forma" vira posição de pálpebra (cima, inclinação, baixo) e o motor
* suaviza a passagem entre elas, então feliz → triste não salta de um desenho para outro.
* Também acrescenta vida: respiração, sacadas e achatamento na piscada.
*/
(function () {
'use strict';
// forma → pálpebras. top: pálpebra de cima (0 aberto, 1 fechado); slant: + = canto interno sobe;
// bot: pálpebra de baixo sobe ("olho sorrindo").
const PALPEBRAS = {
aberto: { top: 0, slant: 0, bot: 0 },
surpreso: { top: 0, slant: 0, bot: 0 },
feliz: { top: 0, slant: 0, bot: 0.46 },
coracao: { top: 0, slant: 0, bot: 0.42 },
triste: { top: 0.3, slant: 0.32, bot: 0 },
bravo: { top: 0.34, slant: -0.42, bot: 0.06 },
dormir: { top: 0.9, slant: 0.04, bot: 0.08 },
};
// Medidas em frações do raio vertical do olho clássico (RY), para casar com o layout atual.
const ESTILOS = {
vivo: { w: 1.0, h: 1.45, r: 0.5, brilho: true },
bloco: { w: 1.35, h: 1.25, r: 0.22, brilho: true },
matriz: { w: 1.0, h: 1.45, r: 0.5, brilho: false, pontos: true },
lente: { w: 1.15, h: 1.15, r: 0.58, lente: true },
};
// Formato do olho, escolhido no painel e combinável com Viva, Bloco e Matriz.
const FORMATOS = {
padrao: null,
redondo: { w: 1.15, h: 1.15, r: 0.58 },
pilula: { w: 0.8, h: 1.55, r: 0.4 },
quadrado: { w: 1.3, h: 1.3, r: 0.12 },
largo: { w: 1.65, h: 0.9, r: 0.3 },
};
let tela = null, ctx = null, camada = null, cctx = null, baixa = null, bctx = null;
const estado = [novo(), novo()];
const sacada = { x: 0, y: 0, alvoX: 0, alvoY: 0, proxima: 0 };
let antes = 0;
function novo() { return { top: 0, slant: 0, bot: 0, coracao: 0 }; }
const css = (c, a) => 'rgba(' + c.map((v) => Math.round(v)).join(',') + ',' + (a === undefined ? 1 : a) + ')';
function preparar() {
if (tela) return;
tela = document.createElement('canvas');
tela.id = 'rostoMotor';
tela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;display:none';
document.body.append(tela);
ctx = tela.getContext('2d');
camada = document.createElement('canvas'); cctx = camada.getContext('2d');
baixa = document.createElement('canvas'); bctx = baixa.getContext('2d', { willReadFrequently: true });
}
function medir() {
// Até 1,5x a densidade: a tela do S10 é enorme e o brilho custa caro.
const d = Math.min(1.5, window.devicePixelRatio || 1);
const w = Math.round(innerWidth * d), h = Math.round(innerHeight * d);
if (tela.width !== w || tela.height !== h) { tela.width = camada.width = w; tela.height = camada.height = h; }
return d;
}
function caixa(c, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w / 2, h / 2));
c.beginPath();
if (c.roundRect) c.roundRect(x, y, w, h, r);
else { // WebViews antigos
c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath();
}
}
function coracao(c, cx, cy, s) {
c.beginPath();
c.moveTo(cx, cy + s * 0.72);
c.bezierCurveTo(cx - s * 1.3, cy - s * 0.3, cx - s * 0.8, cy - s * 1.18, cx, cy - s * 0.3);
c.bezierCurveTo(cx + s * 0.8, cy - s * 1.18, cx + s * 1.3, cy - s * 0.3, cx, cy + s * 0.72);
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
const w = est.w * p.ry * respira * (1 + amassa);
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor);
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill();
c.save();
caixa(c, x - 2, y - 2, w + 4, h + 4, est.r * p.ry);
c.clip();
c.globalCompositeOperation = 'destination-out';
c.fillStyle = '#000';
const sl = p.lado > 0 ? e.slant : -e.slant; // lado +1 = olho esquerdo; canto interno = lado do nariz
const yEsq = y + h * (fecha + sl * 0.5), yDir = y + h * (fecha - sl * 0.5);
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 (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)';
c.beginPath(); c.ellipse(hx, hy, w * 0.1, h * 0.08, 0, 0, Math.PI * 2); c.fill();
}
c.restore();
}
// 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(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);
g.addColorStop(0, css(o.cor.map((v) => v * 0.5))); g.addColorStop(1, css(o.cor));
c.beginPath(); c.arc(ix, iy, R * 0.55, 0, Math.PI * 2); c.fillStyle = g; c.fill();
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;
const sl = p.lado > 0 ? e.slant : -e.slant;
const yEsq = p.cy - R + 2 * R * (fecha + sl * 0.5), yDir = p.cy - R + 2 * R * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(p.cx - R, p.cy - R); c.lineTo(p.cx + R, p.cy - R); c.lineTo(p.cx + R, yDir); c.lineTo(p.cx - R, yEsq); c.closePath(); c.fill();
c.strokeStyle = '#8994a3'; c.lineWidth = Math.max(1, R * 0.03);
c.beginPath(); c.moveTo(p.cx - R, yEsq); c.lineTo(p.cx + R, yDir); c.stroke();
c.beginPath(); c.ellipse(p.cx, p.cy + R * (2.3 - 2 * e.bot), R * 1.3, R * 1.3, 0, 0, Math.PI * 2); c.fill();
c.restore();
}
function atualizarPalpebras(dt, olhos, lento) {
const k = 1 - Math.exp(-dt * (lento ? 4 : 11));
olhos.forEach((p, i) => {
const alvo = PALPEBRAS[p.forma] || PALPEBRAS.aberto, e = estado[i];
e.top += (alvo.top - e.top) * k;
e.slant += (alvo.slant - e.slant) * k;
e.bot += (alvo.bot - e.bot) * k;
e.coracao += ((p.forma === 'coracao' ? 1 : 0) - e.coracao) * Math.min(1, k * 2);
});
}
function atualizarSacada(t, dt, parado) {
// Pequenos saltos do olhar quando nada está acontecendo; olhos de verdade nunca ficam parados.
if (parado && t > sacada.proxima) {
sacada.alvoX = (Math.random() - 0.5) * 14;
sacada.alvoY = (Math.random() - 0.5) * 6;
sacada.proxima = t + 0.8 + Math.random() * 2.4;
} else if (!parado) { sacada.alvoX = sacada.alvoY = 0; }
const k = 1 - Math.exp(-dt * 22);
sacada.x += (sacada.alvoX - sacada.x) * k;
sacada.y += (sacada.alvoY - sacada.y) * k;
}
function desenharPontos(estilo, o, olhos, d) {
// Matriz: desenha os olhos numa tela pequena e acende um LED por pixel.
const linhas = 38, colunas = Math.round(linhas * tela.width / tela.height);
if (baixa.width !== colunas || baixa.height !== linhas) { baixa.width = colunas; baixa.height = linhas; }
const f = linhas / tela.height * d;
bctx.clearRect(0, 0, colunas, linhas);
bctx.save(); bctx.scale(f, f);
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
const passo = tela.width / colunas, raio = passo * 0.36;
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([px[k], px[k + 1], px[k + 2]], 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)';
ctx.fill();
}
}
/**
* Desenha um quadro. o = { estilo, formato, t, cor:[r,g,b], dormindo, parado,
* olhos: [{ cx, cy, ry, ab, forma, lado, lx, ly }, ...] } em pixels CSS.
*/
function desenhar(o) {
preparar();
const base = ESTILOS[o.estilo];
if (!base) return esconder();
const formato = !base.lente && FORMATOS[o.formato];
const estilo = formato ? Object.assign({}, base, formato) : base;
tela.style.display = '';
const d = medir();
const dt = antes ? Math.min(0.1, o.t - antes) : 0.033;
antes = o.t;
atualizarPalpebras(dt, o.olhos, o.dormindo);
atualizarSacada(o.t, dt, o.parado);
const olhos = o.olhos.map((p) => Object.assign({}, p, {
cx: (p.cx + (o.parado ? sacada.x * 0.35 : 0)) * d,
cy: (p.cy + (o.parado ? sacada.y * 0.35 : 0)) * d,
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();
}
/**
* 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, retrato, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
})();
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
def run(*command): def run(*command):
subprocess.run([str(x) for x in command], check=True) subprocess.run([str(x) for x in command], check=True)
for name in ('carinha.html', 'camera.html'): for name in ('carinha.html', 'camera.html', 'rosto-motor.js'):
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name) shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder: with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
tmp = Path(folder) tmp = Path(folder)
+47 -2
View File
@@ -25,6 +25,7 @@
<path id="sobrancelha2" class="traco" style="display:none"/> <path id="sobrancelha2" class="traco" style="display:none"/>
</svg> </svg>
<div id="fps"></div> <div id="fps"></div>
<script src="rosto-motor.js"></script>
<script> <script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao /* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */ próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
@@ -103,10 +104,17 @@ const BRANCO = [245, 250, 255];
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos // A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo" // crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = { const ROSTOS = {
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 }, eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 }, carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 }, miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
}; };
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
let FORMATO = "padrao";
let ROSTO_NOME = "carinha"; let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME]; let ROSTO = ROSTOS[ROSTO_NOME];
@@ -380,12 +388,27 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0); 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 dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
for (let i = 0; i < 2; i++) { 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,
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,
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
})),
});
} else if (window.MotorRosto) window.MotorRosto.esconder();
for (let i = 0; i < 2 && !comMotor; i++) {
const [c, abx, fx, lado] = olhos[i]; const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc, olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6); abx, fx, r.cor, lado, dx, dy * 0.6);
} }
desenharBoca(); if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
else desenharBoca();
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1)); const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none'; brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
@@ -430,6 +453,9 @@ function trata(ordem) {
trocarRosto(ordem.slice(6).trim()); trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) { } else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo" trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("formato ") === 0) {
const f = ordem.slice(8).trim();
if (FORMATOS.includes(f)) FORMATO = f;
} else if (ordem.indexOf("cor ") === 0) { } else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number); const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) { if (v.length === 3 && v.every((n) => !isNaN(n))) {
@@ -501,6 +527,7 @@ async function aplicarAparencia() {
if (!resp.ok) return; if (!resp.ok) return;
const c = await resp.json(); const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto); if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n))) if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); } { CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ } } catch (e) { /* sem servidor — segue com o padrao */ }
@@ -519,6 +546,24 @@ if (MODO_PAINEL) {
}); });
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000); } else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
(function () {
let inicio = null;
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
addEventListener('pointerup', (e) => {
if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null;
if (r.dormindo) { trata('acordar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
});
})();
// teclado (util para testar no PC) // teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo", const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir", t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
+1 -1
View File
@@ -28,7 +28,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
$arquivos = @( $arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', 'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'version.json', 'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'version.json',
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js' 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
) )
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' } if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
+20 -7
View File
@@ -44,7 +44,9 @@ from api_config import ConfigAPI
TRAVA = threading.Lock() TRAVA = threading.Lock()
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar) # Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
ROSTOS = ["eilik", "carinha", "miudo"] ROSTOS = ["vivo", "bloco", "matriz", "lente", "eilik", "carinha", "miudo"]
# Formato do olho nos rostos novos (rosto-motor.js)
FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
# Espelho dos gestos do carinha.html (para o painel montar os botoes) # Espelho dos gestos do carinha.html (para o painel montar os botoes)
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz", GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
@@ -55,11 +57,11 @@ ORDENS_INTERNAS = {"calma"}
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num # Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
# arquivo ao lado do servidor, para sobreviver a reinicios. # arquivo ao lado do servidor, para sobreviver a reinicios.
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]} APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
ARQUIVO_APARENCIA = "aparencia.json" ARQUIVO_APARENCIA = "aparencia.json"
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel. # Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js",
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task", "/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm", "/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"} "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
@@ -123,6 +125,8 @@ def carregar_aparencia():
if isinstance(dados, dict): if isinstance(dados, dict):
if dados.get("rosto") in ROSTOS: if dados.get("rosto") in ROSTOS:
APARENCIA["rosto"] = dados["rosto"] APARENCIA["rosto"] = dados["rosto"]
if dados.get("formato") in FORMATOS:
APARENCIA["formato"] = dados["formato"]
cor = dados.get("cor") cor = dados.get("cor")
if isinstance(cor, list) and len(cor) == 3: if isinstance(cor, list) and len(cor) == 3:
APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor] APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor]
@@ -153,6 +157,11 @@ def aplicar_aparencia(dados):
if nome in ROSTOS: if nome in ROSTOS:
APARENCIA["rosto"] = nome APARENCIA["rosto"] = nome
ordens.append("rosto " + nome) ordens.append("rosto " + nome)
if "formato" in dados:
nome = str(dados["formato"]).strip().lower()
if nome in FORMATOS:
APARENCIA["formato"] = nome
ordens.append("formato " + nome)
if "cor" in dados: if "cor" in dados:
try: try:
APARENCIA["cor"] = validar_cor(dados["cor"]) APARENCIA["cor"] = validar_cor(dados["cor"])
@@ -235,7 +244,7 @@ class Handler(SimpleHTTPRequestHandler):
with TRAVA: with TRAVA:
aparencia = dict(APARENCIA) aparencia = dict(APARENCIA)
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)), dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)),
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS) estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS, formatos=FORMATOS)
if hasattr(s, "controle"): if hasattr(s, "controle"):
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy()) dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
if hasattr(s, "servos"): if hasattr(s, "servos"):
@@ -360,9 +369,13 @@ class Handler(SimpleHTTPRequestHandler):
def comando(self, dados): def comando(self, dados):
tipo, valor = dados.get("tipo"), dados.get("valor") tipo, valor = dados.get("tipo"), dados.get("valor")
s = self.server s = self.server
if tipo == "cor": if tipo == "rosto":
if not aplicar_aparencia({"cor": valor}): ordem_de_comando("rosto", valor) # valida e dá erro legível
raise ValueError("Cor inválida") aplicar_aparencia({"rosto": valor}) # salva, avisa os painéis e manda ao celular
return self.json({"ok": True})
if tipo in ("cor", "formato"):
if not aplicar_aparencia({tipo: valor}):
raise ValueError("Cor inválida" if tipo == "cor" else "Formato inválido")
return self.json({"ok": True}) return self.json({"ok": True})
if tipo == "recarregar": if tipo == "recarregar":
return self.recarregar() return self.recarregar()
+2 -2
View File
@@ -15,13 +15,13 @@
function visual(recipe){ function visual(recipe){
if(recipe.emoji){ if(recipe.emoji){
const e=recipe.emoji;if(e.local==='olhos'){window.carinhaOlhosIcone=e.caractere;return;} 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; animation=badge.animate([{transform:'scale(.92)'},{transform:'scale(1.06)'},{transform:'scale(.92)'}],{duration:1400,iterations:Infinity});return;
} }
if(recipe.figurinha){ if(recipe.figurinha){
const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return; const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return;
if(recipe.figurinha.local==='olhos'){window.carinhaOlhosDesenho=recipe.figurinha;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; 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;} if(['ideia','cafe'].includes(recipe.simbolo)){window.carinhaOlhosIcone=icons[recipe.simbolo];return;}
+289
View File
@@ -0,0 +1,289 @@
/* rosto-motor.js — motor de pálpebras dos rostos novos (Carinha Viva, Bloco, Matriz, Lente).
*
* O carinha.html continua decidindo O QUE o rosto faz (gestos, reações, sensores, rastreamento);
* aqui só se desenha. Cada "forma" vira posição de pálpebra (cima, inclinação, baixo) e o motor
* suaviza a passagem entre elas, então feliz → triste não salta de um desenho para outro.
* Também acrescenta vida: respiração, sacadas e achatamento na piscada.
*/
(function () {
'use strict';
// forma → pálpebras. top: pálpebra de cima (0 aberto, 1 fechado); slant: + = canto interno sobe;
// bot: pálpebra de baixo sobe ("olho sorrindo").
const PALPEBRAS = {
aberto: { top: 0, slant: 0, bot: 0 },
surpreso: { top: 0, slant: 0, bot: 0 },
feliz: { top: 0, slant: 0, bot: 0.46 },
coracao: { top: 0, slant: 0, bot: 0.42 },
triste: { top: 0.3, slant: 0.32, bot: 0 },
bravo: { top: 0.34, slant: -0.42, bot: 0.06 },
dormir: { top: 0.9, slant: 0.04, bot: 0.08 },
};
// Medidas em frações do raio vertical do olho clássico (RY), para casar com o layout atual.
const ESTILOS = {
vivo: { w: 1.0, h: 1.45, r: 0.5, brilho: true },
bloco: { w: 1.35, h: 1.25, r: 0.22, brilho: true },
matriz: { w: 1.0, h: 1.45, r: 0.5, brilho: false, pontos: true },
lente: { w: 1.15, h: 1.15, r: 0.58, lente: true },
};
// Formato do olho, escolhido no painel e combinável com Viva, Bloco e Matriz.
const FORMATOS = {
padrao: null,
redondo: { w: 1.15, h: 1.15, r: 0.58 },
pilula: { w: 0.8, h: 1.55, r: 0.4 },
quadrado: { w: 1.3, h: 1.3, r: 0.12 },
largo: { w: 1.65, h: 0.9, r: 0.3 },
};
let tela = null, ctx = null, camada = null, cctx = null, baixa = null, bctx = null;
const estado = [novo(), novo()];
const sacada = { x: 0, y: 0, alvoX: 0, alvoY: 0, proxima: 0 };
let antes = 0;
function novo() { return { top: 0, slant: 0, bot: 0, coracao: 0 }; }
const css = (c, a) => 'rgba(' + c.map((v) => Math.round(v)).join(',') + ',' + (a === undefined ? 1 : a) + ')';
function preparar() {
if (tela) return;
tela = document.createElement('canvas');
tela.id = 'rostoMotor';
tela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;display:none';
document.body.append(tela);
ctx = tela.getContext('2d');
camada = document.createElement('canvas'); cctx = camada.getContext('2d');
baixa = document.createElement('canvas'); bctx = baixa.getContext('2d', { willReadFrequently: true });
}
function medir() {
// Até 1,5x a densidade: a tela do S10 é enorme e o brilho custa caro.
const d = Math.min(1.5, window.devicePixelRatio || 1);
const w = Math.round(innerWidth * d), h = Math.round(innerHeight * d);
if (tela.width !== w || tela.height !== h) { tela.width = camada.width = w; tela.height = camada.height = h; }
return d;
}
function caixa(c, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w / 2, h / 2));
c.beginPath();
if (c.roundRect) c.roundRect(x, y, w, h, r);
else { // WebViews antigos
c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath();
}
}
function coracao(c, cx, cy, s) {
c.beginPath();
c.moveTo(cx, cy + s * 0.72);
c.bezierCurveTo(cx - s * 1.3, cy - s * 0.3, cx - s * 0.8, cy - s * 1.18, cx, cy - s * 0.3);
c.bezierCurveTo(cx + s * 0.8, cy - s * 1.18, cx + s * 1.3, cy - s * 0.3, cx, cy + s * 0.72);
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
const w = est.w * p.ry * respira * (1 + amassa);
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor);
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill();
c.save();
caixa(c, x - 2, y - 2, w + 4, h + 4, est.r * p.ry);
c.clip();
c.globalCompositeOperation = 'destination-out';
c.fillStyle = '#000';
const sl = p.lado > 0 ? e.slant : -e.slant; // lado +1 = olho esquerdo; canto interno = lado do nariz
const yEsq = y + h * (fecha + sl * 0.5), yDir = y + h * (fecha - sl * 0.5);
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 (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)';
c.beginPath(); c.ellipse(hx, hy, w * 0.1, h * 0.08, 0, 0, Math.PI * 2); c.fill();
}
c.restore();
}
// 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(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);
g.addColorStop(0, css(o.cor.map((v) => v * 0.5))); g.addColorStop(1, css(o.cor));
c.beginPath(); c.arc(ix, iy, R * 0.55, 0, Math.PI * 2); c.fillStyle = g; c.fill();
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;
const sl = p.lado > 0 ? e.slant : -e.slant;
const yEsq = p.cy - R + 2 * R * (fecha + sl * 0.5), yDir = p.cy - R + 2 * R * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(p.cx - R, p.cy - R); c.lineTo(p.cx + R, p.cy - R); c.lineTo(p.cx + R, yDir); c.lineTo(p.cx - R, yEsq); c.closePath(); c.fill();
c.strokeStyle = '#8994a3'; c.lineWidth = Math.max(1, R * 0.03);
c.beginPath(); c.moveTo(p.cx - R, yEsq); c.lineTo(p.cx + R, yDir); c.stroke();
c.beginPath(); c.ellipse(p.cx, p.cy + R * (2.3 - 2 * e.bot), R * 1.3, R * 1.3, 0, 0, Math.PI * 2); c.fill();
c.restore();
}
function atualizarPalpebras(dt, olhos, lento) {
const k = 1 - Math.exp(-dt * (lento ? 4 : 11));
olhos.forEach((p, i) => {
const alvo = PALPEBRAS[p.forma] || PALPEBRAS.aberto, e = estado[i];
e.top += (alvo.top - e.top) * k;
e.slant += (alvo.slant - e.slant) * k;
e.bot += (alvo.bot - e.bot) * k;
e.coracao += ((p.forma === 'coracao' ? 1 : 0) - e.coracao) * Math.min(1, k * 2);
});
}
function atualizarSacada(t, dt, parado) {
// Pequenos saltos do olhar quando nada está acontecendo; olhos de verdade nunca ficam parados.
if (parado && t > sacada.proxima) {
sacada.alvoX = (Math.random() - 0.5) * 14;
sacada.alvoY = (Math.random() - 0.5) * 6;
sacada.proxima = t + 0.8 + Math.random() * 2.4;
} else if (!parado) { sacada.alvoX = sacada.alvoY = 0; }
const k = 1 - Math.exp(-dt * 22);
sacada.x += (sacada.alvoX - sacada.x) * k;
sacada.y += (sacada.alvoY - sacada.y) * k;
}
function desenharPontos(estilo, o, olhos, d) {
// Matriz: desenha os olhos numa tela pequena e acende um LED por pixel.
const linhas = 38, colunas = Math.round(linhas * tela.width / tela.height);
if (baixa.width !== colunas || baixa.height !== linhas) { baixa.width = colunas; baixa.height = linhas; }
const f = linhas / tela.height * d;
bctx.clearRect(0, 0, colunas, linhas);
bctx.save(); bctx.scale(f, f);
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
const passo = tela.width / colunas, raio = passo * 0.36;
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([px[k], px[k + 1], px[k + 2]], 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)';
ctx.fill();
}
}
/**
* Desenha um quadro. o = { estilo, formato, t, cor:[r,g,b], dormindo, parado,
* olhos: [{ cx, cy, ry, ab, forma, lado, lx, ly }, ...] } em pixels CSS.
*/
function desenhar(o) {
preparar();
const base = ESTILOS[o.estilo];
if (!base) return esconder();
const formato = !base.lente && FORMATOS[o.formato];
const estilo = formato ? Object.assign({}, base, formato) : base;
tela.style.display = '';
const d = medir();
const dt = antes ? Math.min(0.1, o.t - antes) : 0.033;
antes = o.t;
atualizarPalpebras(dt, o.olhos, o.dormindo);
atualizarSacada(o.t, dt, o.parado);
const olhos = o.olhos.map((p) => Object.assign({}, p, {
cx: (p.cx + (o.parado ? sacada.x * 0.35 : 0)) * d,
cy: (p.cy + (o.parado ? sacada.y * 0.35 : 0)) * d,
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();
}
/**
* 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, retrato, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
})();
+17
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('samsung SM-G973F')).to_be_visible()
expect(page.get_by_text('192.168.1.16')).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 # 3. gesto pelo dock
kiosk.FILA.limpar() kiosk.FILA.limpar()
page.get_by_role('button', name='Feliz').first.click() page.get_by_role('button', name='Feliz').first.click()
@@ -121,6 +128,16 @@ def testar(base, server, capturas):
page.get_by_label('Buscar ajuste').fill('') page.get_by_label('Buscar ajuste').fill('')
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True) page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
# 6a. rosto: escolher estilo e formato do olho
page.goto(base + '/#/rosto')
page.get_by_role('radio', name='Bloco').click()
page.get_by_role('radio', name='Quadrado', exact=True).click()
page.wait_for_timeout(500)
assert kiosk.APARENCIA['rosto'] == 'bloco' and kiosk.APARENCIA['formato'] == 'quadrado', kiosk.APARENCIA
expect(page.get_by_text('BLOCO · QUADRADO')).to_be_visible()
if capturas:
page.screenshot(path=str(Path(capturas) / 'rosto.png'), full_page=True)
# 6b. conversa: pergunta de hora é respondida pelo relógio (sem IA) e chega por SSE # 6b. conversa: pergunta de hora é respondida pelo relógio (sem IA) e chega por SSE
page.get_by_role('link', name='Conversa').first.click() page.get_by_role('link', name='Conversa').first.click()
page.get_by_label('Mensagem').fill('Que horas são?') page.get_by_label('Mensagem').fill('Que horas são?')
+11
View File
@@ -109,6 +109,17 @@ class ServidorTest(unittest.TestCase):
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo']) self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], []) self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
def test_rostos_novos_e_formato_do_olho(self):
kiosk.ARQUIVO_APARENCIA = str(Path(self.tmp.name) / 'aparencia.json')
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'bloco'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'quadrado'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'triangulo'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['rosto bloco', 'formato quadrado'])
estado = self.pedir('GET', '/estado')[1]
self.assertEqual(estado['aparencia']['formato'], 'quadrado')
self.assertEqual(estado['aparencia']['rosto'], 'bloco') # a escolha fica salva
self.assertIn('pilula', estado['formatos'])
def test_post_face_usa_prefixo_rosto(self): def test_post_face_usa_prefixo_rosto(self):
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5) conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN}) conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN})
+2
View File
@@ -9,6 +9,8 @@
</head> </head>
<body> <body>
<div id="app"></div> <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> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+11 -11
View File
@@ -10,12 +10,13 @@
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte'; import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
import { conectar, estado } from './lib/estado.svelte'; import { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.svelte'; import { paleta } from './lib/paleta.svelte';
import { nav, ROTAS, type Rota } from './lib/rotas.svelte'; import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
import AoVivo from './telas/AoVivo.svelte'; import AoVivo from './telas/AoVivo.svelte';
import Ajustes from './telas/Ajustes.svelte'; import Ajustes from './telas/Ajustes.svelte';
import Conversa from './telas/Conversa.svelte'; import Conversa from './telas/Conversa.svelte';
import Movimento from './telas/Movimento.svelte'; import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte'; import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte';
const TELAS: Record<Rota, Component> = { const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo, 'ao-vivo': AoVivo,
@@ -23,8 +24,9 @@
reacoes: Reacoes, reacoes: Reacoes,
movimento: Movimento, movimento: Movimento,
ajustes: Ajustes, ajustes: Ajustes,
rosto: Rosto,
}; };
const ICONES: Record<Rota, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
conversa: MessageCircle, conversa: MessageCircle,
reacoes: Smile, reacoes: Smile,
@@ -32,6 +34,8 @@
ajustes: Settings, ajustes: Settings,
}; };
const Tela = $derived(TELAS[nav.atual]); const Tela = $derived(TELAS[nav.atual]);
// Páginas secundárias acendem a seção de onde vieram.
const secao = $derived(SECUNDARIAS.find((r) => r.id === nav.atual)?.volta ?? nav.atual);
// ---------- tema // ---------- tema
let tema = $state<'dark' | 'light'>('dark'); let tema = $state<'dark' | 'light'>('dark');
@@ -100,11 +104,7 @@
const conexaoTexto = $derived( const conexaoTexto = $derived(
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao], { conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
); );
const bateria = $derived.by(() => { const bateria = $derived(estado.bateria.nivel);
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;
});
</script> </script>
<svelte:window onkeydown={teclado} /> <svelte:window onkeydown={teclado} />
@@ -126,7 +126,7 @@
{#each ROTAS as r (r.id)} {#each ROTAS as r (r.id)}
{@const Icone = ICONES[r.id]} {@const Icone = ICONES[r.id]}
<li> <li>
<a href={'#/' + r.id} aria-current={nav.atual === r.id ? 'page' : undefined}> <a href={'#/' + r.id} aria-current={secao === r.id ? 'page' : undefined}>
<Icone size={20} /> <span>{r.titulo}</span> <Icone size={20} /> <span>{r.titulo}</span>
</a> </a>
</li> </li>
@@ -138,12 +138,12 @@
</nav> </nav>
<header class="topo"> <header class="topo">
<h1 class="titulo">{ROTAS.find((r) => r.id === nav.atual)?.titulo}</h1> <h1 class="titulo">{tituloDa(nav.atual)}</h1>
<div class="status linha"> <div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}> <Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto} {conexaoTexto}
</Selo> </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}%` : ''} Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
</Selo> </Selo>
{#if estado.conversa} {#if estado.conversa}
@@ -169,7 +169,7 @@
<Tela /> <Tela />
</main> </main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'} {#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto'}
<MiniRosto /> <MiniRosto />
{/if} {/if}
</div> </div>
+16
View File
@@ -53,6 +53,22 @@ export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome })); export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome })); export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb })); export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
export const formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome }));
/** Rostos: os novos usam o motor de pálpebras (rosto-motor.js) e aceitam formato de olho. */
export const ROSTOS_INFO: Record<string, { nome: string; descricao: string; novo: boolean; formato: boolean }> = {
vivo: { nome: 'Carinha Viva', descricao: 'O rosto de sempre com pálpebras, brilho e micro-movimentos.', novo: true, formato: true },
bloco: { nome: 'Bloco', descricao: 'Olhos quadrados que se recortam, estilo robô de mesa.', novo: true, formato: true },
matriz: { nome: 'Matriz', descricao: 'Painel de LEDs retrô; cada olho é feito de pontos.', novo: true, formato: true },
lente: { nome: 'Lente', descricao: 'Íris e pupila que dilata, pálpebras de metal.', novo: true, formato: false },
carinha: { nome: 'Clássico', descricao: 'O desenho original, com boca.', novo: false, formato: false },
miudo: { nome: 'Miúdo', descricao: 'Clássico com olhos menores.', novo: false, formato: false },
eilik: { nome: 'Eilik', descricao: 'Clássico sem boca.', novo: false, formato: false },
};
export const FORMATOS_INFO: Record<string, string> = {
padrao: 'Padrão', redondo: 'Redondo', pilula: 'Pílula', quadrado: 'Quadrado', largo: 'Largo',
};
export const nomeRosto = (id: string) => ROSTOS_INFO[id]?.nome ?? id;
export const recarregarCelular = () => export const recarregarCelular = () =>
tentar(async () => { tentar(async () => {
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' }); const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
+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). // 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 s = $derived(estado.app?.estado ?? {});
const ap = $derived(s.aparelho ?? null); const ap = $derived(s.aparelho ?? null);
const bateria = $derived.by(() => { const bateria = $derived(estado.bateria.nivel);
const b = s.bateria; const carregando = $derived(estado.bateria.carregando);
const n = typeof b === 'number' ? b : b?.nivel; // Tempo desde o último sinal, em texto curto ("há 3 min").
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null; 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 IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull);
const ip = $derived(ap?.ip || estado.app?.celular_ip || '—'); const ip = $derived(ap?.ip || estado.app?.celular_ip || '—');
const visto = $derived(estado.app?.idade ?? null);
</script> </script>
<div class="celular" class:offline={!estado.celularOnline}> <div class="celular" class:offline={!estado.celularOnline}>
@@ -25,7 +29,7 @@
{#if estado.celularOnline} {#if estado.celularOnline}
{[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'} {[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'}
{:else} {:else}
offline{visto !== null ? ` · visto há ${Math.round(visto)} s` : ''} offline{semSinal ? ' · último sinal ' + semSinal : ''}
{/if} {/if}
</span> </span>
</div> </div>
@@ -70,6 +74,12 @@
</div> </div>
</dl> </dl>
{:else} {: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> <p class="ajuda">Abra o app Carinha no celular e confira se ele aponta para este servidor (toque longo › Mudar o servidor).</p>
{/if} {/if}
</div> </div>
@@ -170,6 +180,17 @@
rect.cheia { rect.cheia {
fill: var(--texto); 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 { .ajuda {
font-size: var(--t-sm); font-size: var(--t-sm);
color: var(--texto-3); color: var(--texto-3);
+61 -33
View File
@@ -1,56 +1,84 @@
<script lang="ts"> <script lang="ts">
import { rgb, ROSTOS_INFO } from '../comandos.svelte';
import { estado } from '../estado.svelte';
import { SIMBOLO_EMOJI } from '../treinamento.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 { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
let tela: HTMLCanvasElement;
const passo = $derived(receita?.passos?.[0]); 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> </script>
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true"> <div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
{#if receita?.figurinha?.formas} <canvas bind:this={tela}></canvas>
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}> {#if aoRedor?.emoji}
{#each receita.figurinha.formas as forma, i (i)} <span class="extra">{aoRedor.emoji}</span>
<path d={forma.caminho} fill={forma.cor} /> {:else if aoRedor?.figurinha}
{/each} <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> </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} {/if}
</div> </div>
<style> <style>
.miniatura { .miniatura {
position: relative; position: relative;
width: var(--t); width: calc(var(--t) * 1.5);
height: var(--t); height: var(--t);
display: grid;
place-items: center;
border-radius: var(--raio); border-radius: var(--raio);
background: #05080c; background: #000;
flex: none; flex: none;
overflow: hidden;
} }
.emoji { canvas {
font-size: calc(var(--t) * 0.55); width: 100%;
line-height: 1; height: 100%;
display: block;
} }
.simbolo { .extra {
position: absolute; position: absolute;
right: 2px; right: 4px;
bottom: 0; top: 3px;
font-size: calc(var(--t) * 0.3); 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> </style>
@@ -36,6 +36,11 @@
]; ];
onDestroy(() => cancelar.forEach((c) => c())); onDestroy(() => cancelar.forEach((c) => c()));
/** Mostra uma ordem (gesto, rosto, formato) só na prévia, sem mandar ao celular. */
export function ordem(texto: string) {
enviar({ tipo: 'ordem', ordem: texto });
}
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */ /** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) { export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita }); enviar({ tipo: 'previa-reacao', receita });
+36 -2
View File
@@ -3,6 +3,7 @@
* Nenhuma tela faz polling; todas leem daqui. * Nenhuma tela faz polling; todas leem daqui.
*/ */
import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
import { avisos } from './avisos.svelte';
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos'; import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token'; export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
@@ -19,7 +20,8 @@ class Estado {
versao = $state(''); versao = $state('');
autenticacao = $state(false); autenticacao = $state(false);
estop = $state({ ativo: false, desde: null as number | null }); estop = $state({ ativo: false, desde: null as number | null });
aparencia = $state({ rosto: 'carinha', cor: [92, 214, 255] as [number, number, number] }); aparencia = $state<{ rosto: string; cor: [number, number, number]; formato?: string }>({ rosto: 'carinha', cor: [92, 214, 255], formato: 'padrao' });
formatos = $state<string[]>([]);
gestos = $state<string[]>([]); gestos = $state<string[]>([]);
rostos = $state<string[]>([]); rostos = $state<string[]>([]);
app = $state<Telemetria | null>(null); app = $state<Telemetria | null>(null);
@@ -36,6 +38,18 @@ class Estado {
agora = $state(Date.now()); agora = $state(Date.now());
celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000); 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(); export const estado = new Estado();
@@ -50,6 +64,23 @@ export function ouvir(tipo: string, fn: Ouvinte): () => void {
return () => ouvintes.get(tipo)!.delete(fn); 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; let seqFeed = 0;
function noFeed(tipo: string, texto: string) { function noFeed(tipo: string, texto: string) {
estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40); estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
@@ -62,9 +93,11 @@ function aplicarFoto(f: EstadoServidor) {
estado.aparencia = f.aparencia; estado.aparencia = f.aparencia;
estado.gestos = f.gestos; estado.gestos = f.gestos;
estado.rostos = f.rostos; estado.rostos = f.rostos;
estado.formatos = f.formatos ?? [];
if (f.app) { if (f.app) {
estado.app = f.app; estado.app = f.app;
estado.appRecebidoEm = Date.now(); 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.app_config) estado.appConfig = f.app_config;
if (f.motores) estado.motores = f.motores; if (f.motores) estado.motores = f.motores;
@@ -81,7 +114,8 @@ const tratadores: Record<string, (d: any) => void> = {
}, },
telemetria: (d: Telemetria) => { telemetria: (d: Telemetria) => {
estado.app = d; estado.app = d;
estado.appRecebidoEm = Date.now(); estado.appRecebidoEm = estado.ultimoSinalEm = Date.now();
avisarBateria();
}, },
app_config: (d: AppConfig) => (estado.appConfig = d), app_config: (d: AppConfig) => (estado.appConfig = d),
aparencia: (d) => (estado.aparencia = d), aparencia: (d) => (estado.aparencia = d),
+6 -2
View File
@@ -1,5 +1,7 @@
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */ /** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte'; import {
cor, CORES, formato, FORMATOS_INFO, gesto, GESTOS_INFO, gestosDisponiveis, nomeRosto, pararTudo, rearmar, recarregarCelular, rosto,
} from './comandos.svelte';
import { estado } from './estado.svelte'; import { estado } from './estado.svelte';
import { nav, ROTAS } from './rotas.svelte'; import { nav, ROTAS } from './rotas.svelte';
@@ -33,6 +35,8 @@ export function itensDaPaleta(): ItemPaleta[] {
// Itens base: navegação, ações gerais, gestos, rostos e cores. // Itens base: navegação, ações gerais, gestos, rostos e cores.
registrarNaPaleta(() => [ registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })), ...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
estado.estop.ativo estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar } ? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
: { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo }, : { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo },
@@ -43,6 +47,6 @@ registrarNaPaleta(() => [
titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`, titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
fazer: () => gesto(g), fazer: () => gesto(g),
})), })),
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })), ...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + nomeRosto(r), fazer: () => rosto(r) })),
...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })), ...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
]); ]);
+7 -2
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ /** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes'; export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -10,9 +10,14 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' }, { id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
]; ];
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
function lerHash(): Rota { function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota; const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo'; return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
} }
class Navegacao { class Navegacao {
+2 -1
View File
@@ -69,7 +69,8 @@ export interface EstadoServidor {
seq: number; seq: number;
autenticacao: boolean; autenticacao: boolean;
estop: { ativo: boolean; desde: number | null }; estop: { ativo: boolean; desde: number | null };
aparencia: { rosto: string; cor: [number, number, number] }; aparencia: { rosto: string; cor: [number, number, number]; formato?: string };
formatos?: string[];
gestos: string[]; gestos: string[];
rostos: string[]; rostos: string[];
app?: Telemetria; app?: Telemetria;
+15 -71
View File
@@ -6,7 +6,7 @@
import { postar } from '../lib/api'; import { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte'; import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.svelte'; import { tentar } from '../lib/avisos.svelte';
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte'; import { FORMATOS_INFO, hex, nomeRosto, recarregarCelular, ROSTOS_INFO } from '../lib/comandos.svelte';
import Celular from '../lib/componentes/Celular.svelte'; import Celular from '../lib/componentes/Celular.svelte';
import Gestos from '../lib/componentes/Gestos.svelte'; import Gestos from '../lib/componentes/Gestos.svelte';
import Grupo from '../lib/componentes/Grupo.svelte'; import Grupo from '../lib/componentes/Grupo.svelte';
@@ -154,29 +154,16 @@
</Grupo> </Grupo>
<Grupo titulo="Aparência"> <Grupo titulo="Aparência">
<Linha icone={Smile} cor="#5ac8fa" rotulo="Rosto"> <Linha
<div class="segmentado" role="radiogroup" aria-label="Rosto"> icone={Palette}
{#each estado.rostos as r (r)} cor="#ff9500"
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button> rotulo="Rosto, formato e cor"
{/each} detalhe={nomeRosto(estado.aparencia.rosto) + (ROSTOS_INFO[estado.aparencia.rosto]?.formato ? ' · ' + (FORMATOS_INFO[estado.aparencia.formato ?? 'padrao'] ?? '') : '')}
</div> href="#/rosto"
</Linha> >
<Linha icone={Palette} cor="#ff9500" rotulo="Cor dos olhos"> {#snippet acessorio()}
<div class="cores"> <span class="amostra-cor" style="--c: {hex(estado.aparencia.cor)}" aria-hidden="true"></span>
{#each CORES as c (c.nome)} {/snippet}
<button
class="cor"
style="--c: {hex(c.rgb)}"
aria-label={c.nome}
aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)}
onclick={() => cor(c.rgb)}
></button>
{/each}
<label class="cor livre" title="Outra cor">
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
<span class="sr-only">Escolher outra cor</span>
</label>
</div>
</Linha> </Linha>
</Grupo> </Grupo>
@@ -314,55 +301,12 @@
gap: var(--e-5); gap: var(--e-5);
min-width: 0; min-width: 0;
} }
.segmentado { .amostra-cor {
display: flex; width: 22px;
background: var(--superficie-2); height: 22px;
border-radius: 9px;
padding: 2px;
gap: 2px;
}
.segmentado button {
flex: 1;
min-height: 32px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
text-transform: capitalize;
cursor: pointer;
}
.segmentado button[aria-checked='true'] {
background: var(--superficie);
color: var(--texto);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
}
.cores {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.cor {
width: 34px;
height: 34px;
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--borda-forte);
background: var(--c); background: var(--c);
cursor: pointer; border: 2px solid var(--borda-forte);
position: relative;
}
.cor[aria-pressed='true'] {
outline: 2px solid var(--texto);
outline-offset: 2px;
}
.livre {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
}
.livre input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
} }
.feed { .feed {
list-style: none; list-style: none;
+8
View File
@@ -364,6 +364,14 @@
.espaco { .espaco {
flex: 1; 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 { .marcado {
color: var(--ok); color: var(--ok);
border-color: var(--ok); border-color: var(--ok);
+412
View File
@@ -0,0 +1,412 @@
<script lang="ts">
import { ArrowLeft, Check } from '@lucide/svelte';
import {
cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO,
} from '../lib/comandos.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado } from '../lib/estado.svelte';
let previa = $state<{ ordem: (t: string) => void }>();
// Por padrão as emoções só aparecem aqui na prévia; ligado, vão também para o celular.
let noCelular = $state(false);
const atual = $derived(estado.aparencia.rosto);
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
const aceitaFormato = $derived(!!ROSTOS_INFO[atual]?.formato);
const novos = $derived(estado.rostos.filter((r) => ROSTOS_INFO[r]?.novo));
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'];
function emocao(g: string) {
if (noCelular) gesto(g);
else previa?.ordem(g);
}
const IDEIAS: { titulo: string; texto: string; feito: boolean; onde: string }[] = [
{ 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: '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' },
{ titulo: 'Humor que dura', texto: 'Energia e alegria sobem com conversa e carinho e caem devagar.', feito: false, onde: 'servidor e IA' },
{ titulo: 'Hora nos olhos', texto: 'Ao perguntar a hora, ela aparece dentro dos olhos por alguns segundos.', feito: false, onde: 'rosto' },
];
</script>
<div class="rosto-pagina">
<a class="voltar" href="#/ao-vivo"><ArrowLeft size={16} /> Ao vivo</a>
<header>
<span class="rotulo">Galaxy S10 · 19:9 · AMOLED</span>
<h1>Rosto do Carinha</h1>
<p>Escolha o rosto, o formato dos olhos e a cor. A prévia abaixo é o mesmo rosto que roda no celular; clique nela para cutucar.</p>
</header>
<section class="bancada" aria-label="Prévia">
<div class="celular">
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" /></div>
<span class="camera-frontal" aria-hidden="true"></span>
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
</div>
<div class="emocoes" role="group" aria-label="Testar emoção">
{#each EMOCOES.filter((g) => GESTOS_INFO[g]) as g (g)}
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
{/each}
</div>
<Toggle
rotulo="Mandar as emoções também para o celular"
ajuda="Desligado, elas aparecem só na prévia"
checked={noCelular}
onchange={(v) => (noCelular = v)}
/>
</section>
<section class="bloco-escolha" aria-labelledby="t-rostos">
<h2 id="t-rostos">Rosto</h2>
<div class="cartoes" role="radiogroup" aria-label="Rosto">
{#each novos as r (r)}
<button class="cartao" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>
<span class="nome">{ROSTOS_INFO[r].nome} {#if atual === r}<Check size={16} />{/if}</span>
<span class="desc">{ROSTOS_INFO[r].descricao}</span>
</button>
{/each}
</div>
{#if classicos.length}
<div class="classicos" role="radiogroup" aria-label="Rostos clássicos">
<span class="rotulo">Clássicos</span>
{#each classicos as r (r)}
<button class="tecla" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>{nomeRosto(r)}</button>
{/each}
</div>
{/if}
</section>
<section class="bloco-escolha" aria-labelledby="t-formato">
<h2 id="t-formato">Formato dos olhos</h2>
<div class="formatos" role="radiogroup" aria-label="Formato dos olhos">
{#each formatos as f (f)}
<button class="formato" role="radio" aria-checked={formatoAtual === f} disabled={!aceitaFormato} onclick={() => formato(f)}>
<span class="amostra {f}" aria-hidden="true"></span>
{FORMATOS_INFO[f] ?? f}
</button>
{/each}
</div>
{#if !aceitaFormato}
<p class="fraco pequeno-texto">O formato vale para Carinha Viva, Bloco e Matriz.</p>
{/if}
</section>
<section class="bloco-escolha" aria-labelledby="t-cor">
<h2 id="t-cor">Cor dos olhos</h2>
<div class="cores">
{#each CORES as c (c.nome)}
<button class="cor" style="--c: {hex(c.rgb)}" aria-label={c.nome} aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)} onclick={() => cor(c.rgb)}></button>
{/each}
<label class="cor livre" title="Outra cor">
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
<span class="sr-only">Escolher outra cor</span>
</label>
</div>
</section>
<section class="bloco-escolha" aria-labelledby="t-vivo">
<h2 id="t-vivo">O que deixa o Carinha vivo</h2>
<ul class="ideias">
{#each IDEIAS as i (i.titulo)}
<li>
<div class="linha entre">
<h3>{i.titulo}</h3>
<span class="status" class:feito={i.feito}>{i.feito ? 'Pronto' : 'Próximo'}</span>
</div>
<p>{i.texto}</p>
<span class="onde">{i.onde}</span>
</li>
{/each}
</ul>
</section>
</div>
<style>
.rosto-pagina {
display: grid;
gap: var(--e-6);
max-width: 1120px;
}
.voltar {
display: inline-flex;
align-items: center;
gap: var(--e-1);
width: fit-content;
color: var(--texto-2);
text-decoration: none;
font-size: var(--t-sm);
}
header {
display: grid;
gap: var(--e-2);
}
header h1 {
font-size: clamp(1.6rem, 3.5vw, 2.3rem);
letter-spacing: -0.01em;
}
header p {
color: var(--texto-2);
max-width: 65ch;
}
.rotulo {
font: 500 11px var(--fonte-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--texto-3);
}
.bancada {
display: grid;
gap: var(--e-4);
}
.celular {
position: relative;
aspect-ratio: 19 / 9;
max-width: 100%;
border-radius: clamp(18px, 3.4vw, 40px);
padding: clamp(6px, 0.9vw, 12px);
background: linear-gradient(145deg, #2a313b, #11161d 45%, #222932);
box-shadow: 0 30px 80px rgb(0 0 0 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.08);
}
.tela {
width: 100%;
height: 100%;
border-radius: clamp(13px, 2.6vw, 30px);
overflow: hidden;
background: #000;
}
.camera-frontal {
position: absolute;
left: clamp(14px, 2.2vw, 26px);
top: 50%;
translate: 0 -50%;
width: clamp(6px, 0.8vw, 10px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #2b3a55, #05070a 70%);
}
.etiqueta {
position: absolute;
right: clamp(20px, 3vw, 36px);
top: clamp(14px, 2vw, 24px);
font: 500 11px var(--fonte-mono);
color: rgb(255 255 255 / 0.4);
letter-spacing: 0.08em;
pointer-events: none;
}
.emocoes,
.classicos {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
align-items: center;
}
.tecla {
min-height: 40px;
padding: 0 var(--e-4);
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: var(--superficie);
color: var(--texto);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.tecla:hover {
border-color: var(--destaque);
}
.tecla[aria-checked='true'] {
background: var(--destaque);
border-color: var(--destaque);
color: var(--destaque-texto);
}
.bloco-escolha {
display: grid;
gap: var(--e-3);
}
.bloco-escolha h2 {
font-size: var(--t-lg);
}
.cartoes {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--e-3);
}
.cartao {
display: grid;
gap: var(--e-1);
align-content: start;
padding: var(--e-4);
text-align: left;
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie);
color: var(--texto);
font: inherit;
cursor: pointer;
}
.cartao:hover {
border-color: var(--borda-forte);
}
.cartao[aria-checked='true'] {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.nome {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.nome :global(svg) {
color: var(--destaque);
}
.desc {
font-size: var(--t-sm);
color: var(--texto-2);
line-height: 1.4;
}
.formatos {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--e-2);
}
.formato {
display: grid;
justify-items: center;
gap: var(--e-2);
padding: var(--e-3) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie);
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.formato[aria-checked='true'] {
border-color: var(--destaque);
color: var(--texto);
}
.formato:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* amostras com as mesmas proporções do rosto-motor.js */
.amostra {
display: block;
width: 20px;
height: 29px;
border-radius: 10px;
background: var(--destaque);
}
.amostra.redondo {
width: 24px;
height: 24px;
border-radius: 50%;
}
.amostra.pilula {
width: 16px;
height: 31px;
border-radius: 8px;
}
.amostra.quadrado {
width: 26px;
height: 26px;
border-radius: 4px;
}
.amostra.largo {
width: 33px;
height: 18px;
border-radius: 6px;
}
.cores {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.cor {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--borda-forte);
background: var(--c);
cursor: pointer;
position: relative;
}
.cor[aria-pressed='true'] {
outline: 2px solid var(--texto);
outline-offset: 2px;
}
.livre {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
}
.livre input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.ideias {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--borda);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
overflow: hidden;
}
.ideias li {
display: grid;
gap: var(--e-2);
align-content: start;
padding: var(--e-4) var(--e-5);
background: var(--superficie);
}
.ideias h3 {
font-size: var(--t-md);
}
.ideias p {
font-size: var(--t-sm);
color: var(--texto-2);
}
.status {
flex: none;
font: 500 11px var(--fonte-mono);
padding: 2px 8px;
border-radius: var(--raio-pill);
border: 1px solid var(--borda-forte);
color: var(--texto-3);
}
.status.feito {
color: var(--ok);
border-color: transparent;
background: var(--ok-suave);
}
.onde {
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
}
@media (max-width: 900px) {
.cartoes {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ideias {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.formatos {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>
+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 alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos', const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face', '/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({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],