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>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
/* 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();
|
||||
}
|
||||
|
||||
// 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 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 (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(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);
|
||||
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(o.cor, 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();
|
||||
}
|
||||
|
||||
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
|
||||
|
||||
window.MotorRosto = { desenhar, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
|
||||
})();
|
||||
Reference in New Issue
Block a user