diff --git a/android/app/AndroidManifest.xml b/android/app/AndroidManifest.xml index 3a44d88..5eb2a71 100644 --- a/android/app/AndroidManifest.xml +++ b/android/app/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="14" + android:versionName="0.10.0"> diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index b54a948..b878025 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -595,6 +595,10 @@ function atividade(nome, segundos, receita) { if (!window.MotorRosto || !window.MotorRosto.atividade) return; if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; } if (r.dormindo) { r.dormindo = false; animar("surpreso"); } + lembrarDoDia(nome, receita); + const agora = performance.now() / 1000, dur = Number(segundos) || 0; + HUMANO.oops = dur > 20 && Math.random() < 0.35 ? { ini: agora + dur * (0.3 + 0.4 * Math.random()) } : null; + if (HUMANO.oops) HUMANO.oops.fim = HUMANO.oops.ini + 2.2; if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica"); } @@ -618,6 +622,91 @@ function tocarExpressaoCriada(texto) { if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos); } +/* ------------------------------------------------------------- jeito de gente + Acordar grogue, sonhar dormindo, hesitar antes de agir, errar na atividade e ficar sem graça, + olhar nos olhos e desviar, sorrir de volta para quem sorri e reagir à luz do ambiente. */ +const HUMANO = { + dormiaAntes: false, dormiuEm: 0, grogueIni: 0, grogueAte: 0, bocejou: true, + sonhoProx: 0, sonhoAte: 0, sonhos: ['estrela', 'bola', 'nota', 'coracao', 'livro', 'flor'], + oops: null, // {ini, fim}: um tropeço no meio da atividade + evita: { ate: 0, prox: 0, dx: 0, dy: 0 }, // desvio do olhar durante o contato visual + sorriso: { desde: 0, ultimo: 0 }, + luz: { lux: null, media: null, sonolentoAte: 0, ofuscadoAte: 0, mudou: 0 }, +}; +const OBJETO_DA_ATIVIDADE = { bola: 'bola', ler: 'livro', cafe: 'xicara', cantar: 'nota', violao: 'nota', pintar: 'pincel', + regar: 'flor', telescopio: 'estrela', malhar: 'halter', bolhas: 'bolha', videogame: 'controle', dancar: 'estrela', + meditar: 'coracao', cozinhar: 'chama', computador: 'controle', pescar: 'bolha', pipa: 'estrela', skate: 'estrela', + corda: 'estrela' }; +function lembrarDoDia(nome, receita) { // o que ele fez hoje vira assunto dos sonhos + const obj = receita && Array.isArray(receita.objetos) && receita.objetos[0] ? receita.objetos[0].tipo : OBJETO_DA_ATIVIDADE[nome]; + if (obj) HUMANO.sonhos = [obj].concat(HUMANO.sonhos.filter((o) => o !== obj)).slice(0, 8); +} +function falarFrase(texto, segundos) { // "dizer ..." do modo vida: legenda + voz de robô + if (window.carinhaSemServidor || !texto || r.dormindo) return; + if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) }); + falar(texto); +} +function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir + if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn(); + setTimeout(fn, 150 + Math.random() * 450); +} +function humanoTick(t) { + // acordou depois de dormir um tempo: fica grogue (olhos pesados, lento) e boceja + if (r.dormindo && !HUMANO.dormiaAntes) HUMANO.dormiuEm = t; + if (!r.dormindo && HUMANO.dormiaAntes && t - HUMANO.dormiuEm > 180) { + HUMANO.grogueIni = t; HUMANO.grogueAte = t + 30 + Math.random() * 20; HUMANO.bocejou = false; + } + HUMANO.dormiaAntes = r.dormindo; + if (!HUMANO.bocejou && t - HUMANO.grogueIni > 1.6 && !r.dormindo && !r.kf) { HUMANO.bocejou = true; animar('bocejar'); } + // dormindo (com a tela acesa): de vez em quando sonha com algo do dia + if (r.dormindo && !SONO.apagada && !window.carinhaSemServidor) { + if (!HUMANO.sonhoProx) HUMANO.sonhoProx = t + 40 + Math.random() * 60; + if (t >= HUMANO.sonhoProx) { + const dur = 6 + Math.random() * 4; + HUMANO.sonhoAte = t + dur; HUMANO.sonhoProx = t + dur + 45 + Math.random() * 90; + const obj = HUMANO.sonhos[Math.floor(Math.random() * Math.min(4, HUMANO.sonhos.length))]; + if (window.MotorRosto && window.MotorRosto.sonho) window.MotorRosto.sonho(obj, dur); + } + } else HUMANO.sonhoProx = 0; +} +function grogue(t) { // 1 = acabou de acordar, 0 = esperto + return t < HUMANO.grogueAte ? Math.min(1, (HUMANO.grogueAte - t) / (HUMANO.grogueAte - HUMANO.grogueIni)) : 0; +} +function piscarHumano(t) { animar(grogue(t) > 0.2 || t < HUMANO.luz.sonolentoAte ? 'piscar2' : 'piscar'); } +function desvioDoOlhar(t) { // olha nos olhos 2 a 5 s, desvia um instante e volta + const ev = HUMANO.evita; + if (t >= ev.prox) { + const dura = Math.random() < 0.7 ? 0.6 + Math.random() * 0.7 : 0; + ev.ate = t + dura; ev.prox = ev.ate + 2 + Math.random() * 3; + ev.dx = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 8); ev.dy = 3 + Math.random() * 5; + } + return t < ev.ate ? ev : null; +} +function contagio(t, agora) { // sorriu para ele (rastreamento do painel): sorri de volta + const S = HUMANO.sorriso; + if (t.tipo !== 'rosto' || typeof t.sorriso !== 'number' || r.dormindo || ATIV) { S.desde = 0; return; } + if (t.sorriso > 0.55) { if (!S.desde) S.desde = agora; } else S.desde = 0; + if (S.desde && agora - S.desde > 0.6 && agora - S.ultimo > 20 && !r.kf) { + S.ultimo = agora; S.desde = 0; + animar(t.sorriso > 0.8 ? 'rir' : 'feliz'); + } +} +function luzAmbiente(lux) { // sensor de luz do celular (lux) + lux = Number(lux); + if (!Number.isFinite(lux) || lux < 0) return; + const L = HUMANO.luz, t = performance.now() / 1000, antes = L.media; + L.lux = lux; + L.media = antes === null ? lux : antes + (lux - antes) * 0.15; + if (antes === null || t - L.mudou < 8) return; + if (lux < 3 && antes > 25) { // apagaram a luz: fica com sono + L.mudou = t; L.sonolentoAte = t + 60; L.media = lux; + if (!r.dormindo && !r.kf) animar('bocejar'); + } else if (lux > 80 && antes < 8) { // acenderam a luz de repente: aperta os olhos + L.mudou = t; L.ofuscadoAte = t + 1.8; L.media = lux; + if (r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) r.dormindo = false; + } +} + function repouso(t) { // pose calma: olha devagar, "respira" if (r.dormindo) { r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; @@ -626,6 +715,7 @@ function repouso(t) { // pose calma: olha devagar, "respira r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.dx = 0; r.dy = 3.0 * Math.sin(t * 1.4); + if (t < HUMANO.sonhoAte) { r.dx = 3.2 * Math.sin(t * 6.5); r.boca = 'sorriso'; } // sonhando return; } r.forma = "aberto"; r.forma2 = null; @@ -638,7 +728,7 @@ function repouso(t) { // pose calma: olha devagar, "respira const longe = Math.random() < 0.25; OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7); OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3); - OLHAR.prox = t + 1.2 + Math.random() * 3; + OLHAR.prox = t + (1.2 + Math.random() * 3) * (1 + 2 * grogue(t)); // grogue: olhar lento } OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3; OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3; @@ -651,9 +741,23 @@ function repouso(t) { // pose calma: olha devagar, "respira if (ATIV.forma) r.forma = ATIV.forma; if (ATIV.boca) r.boca = ATIV.boca; if (ATIV.ab) r.ab = ATIV.ab; + const o = HUMANO.oops; // errou: susto e depois fica sem graça + if (o && t >= o.ini && t < o.fim) { + if (t - o.ini < 0.5) { r.forma = 'surpreso'; r.boca = 'aberta'; r.ab = 1.1; } + else { r.forma = 'feliz'; r.boca = 'sorriso'; r.cor = lerpCor(r.cor, ROSA, 0.5); r.dy += 6; } + } } else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica) r.forma = "bravo"; } + const g = grogue(t); + if (g > 0) { // acabou de acordar: olhos pesados, cor apagada, olhar baixo + r.ab = Math.min(r.ab, 1 - 0.5 * g); + r.cor = lerpCor(r.cor, corEscura(0.7), 0.35 * g); + if (g > 0.5 && !ATIV) r.boca = 'sono'; + r.dx *= 1 - 0.5 * g; r.dy = r.dy * (1 - 0.5 * g) + 3 * g; + } + if (t < HUMANO.luz.sonolentoAte) r.ab = Math.min(r.ab, 0.78); + if (t < HUMANO.luz.ofuscadoAte) { r.ab = Math.min(r.ab, 0.3); r.forma = 'feliz'; } } function desenhar() { @@ -667,7 +771,9 @@ function desenhar() { const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo; const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV; 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 ev = seguindo && TRACK.alvo.tipo === 'rosto' ? desvioDoOlhar(tAgora) : null; + const dx = seguindo ? (ev ? MOV.olharX * 0.3 + ev.dx : MOV.olharX) : r.dx; + const dy = seguindo ? (ev ? MOV.olharY * 0.3 + ev.dy : MOV.olharY) : r.dy; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const comMotor = ROSTO.motor && window.MotorRosto; OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; }); @@ -751,6 +857,7 @@ function contarAprendi(texto) { function trata(ordem) { const bruta = (ordem || "").trim(); if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase + if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; } ordem = bruta.toLowerCase(); if (!ordem) return; if(ordem==='recarregar') { @@ -797,14 +904,16 @@ function trata(ordem) { if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); } return; } - r.dormindo = false; animar("surpreso"); + const longo = r.dormindo && performance.now() / 1000 - HUMANO.dormiuEm > 180; + r.dormindo = false; + if (!longo) animar("surpreso"); // sono longo: acorda devagar, grogue (humanoTick) } else if (ordem === "aleatorio") { animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); } else if (SEQS[ordem]) { if (window.MotorRosto) { if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 }); } - animar(ordem); + hesitar(() => animar(ordem), ordem); } else if (ordem === "calma") { // E-Stop do painel: interrompe a animação e volta ao rosto neutro. r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false; @@ -1121,6 +1230,7 @@ function rastrear(t) { (r.dormindo && !configRecebida?.cameraSono)) return; const agora=performance.now()/1000; TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y; + contagio(t, agora); MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65; MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65; if(t.atividade===true) { @@ -1306,6 +1416,7 @@ const AUDIO = { }; if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ +if (window.eilik) window.eilik.luz = luzAmbiente; // sensor de luz do celular (o app chama) let configRecebida = null; function configurarApp(c) { @@ -1353,6 +1464,7 @@ async function sincronizarApp() { bateria: window.carinhaBateria, aparelho: window.carinhaAparelho, video: window.carinhaVideo, + luz: HUMANO.luz.lux, }) }); } } catch (e) { /* servidor ausente: animação continua */ } @@ -1448,6 +1560,7 @@ function laco(t) { checarSilencio(t / 1000); atualizarFeicao(t / 1000); + humanoTick(t / 1000); ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null; if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null); @@ -1461,7 +1574,7 @@ function laco(t) { if (r.dormindo) { proxPiscar = proxGesto = t + 3600000; } else if (t >= proxPiscar) { - animar("piscar"); + piscarHumano(t / 1000); proxPiscar = t + 1600 + Math.random() * 3500; } else if (AUTO && t >= proxGesto && !ATIV) { animar(gestoSolto()); diff --git a/android/app/assets/rosto-motor.js b/android/app/assets/rosto-motor.js index 4b11a0c..299a4d7 100644 --- a/android/app/assets/rosto-motor.js +++ b/android/app/assets/rosto-motor.js @@ -287,11 +287,13 @@ olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); desenharMaos(ctx, o, olhos, tela.height); desenharZzz(ctx, o, olhos); + desenharSonho(ctx, o, olhos); } else { cctx.clearRect(0, 0, camada.width, camada.height); olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); desenharMaos(cctx, o, olhos, tela.height); desenharZzz(cctx, o, olhos); + desenharSonho(cctx, o, olhos); 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); @@ -421,6 +423,33 @@ } // ------------------------------------------------------------------ Zzz + // ----------------------------------------------------------- sonho + // Dormindo, às vezes aparece bem fraquinho um balão de pensamento com algo do dia (a bola, a estrela...). + const sonho = { obj: null, t0: 0, dur: 0 }; + function definirSonho(obj, segundos) { + sonho.obj = obj || null; sonho.t0 = performance.now() / 1000; sonho.dur = segundos || 7; + } + function desenharSonho(c, o, olhos) { + if (!o.dormindo || !sonho.obj) return; + const k = (o.t - sonho.t0) / sonho.dur; + if (k < 0) return; + if (k >= 1) { sonho.obj = null; return; } + const surge = Math.min(1, k * sonho.dur / 1.2, (1 - k) * sonho.dur / 1.2); // aparece e some devagar + const e = olhos.slice().sort((a, b) => a.cx - b.cx)[0]; // olho da esquerda na tela + const R = e.ry * 0.62, bx = e.cx - e.ry * 0.1; + const by = Math.max(R * 1.2, e.cy - e.ry * 1.75) + Math.sin(o.t * 1.3) * e.ry * 0.05; // nunca sai da tela + c.save(); + c.strokeStyle = css(o.cor); c.fillStyle = css(o.cor, 0.08); c.lineWidth = Math.max(1.5, R * 0.06); + c.globalAlpha = 0.45 * surge; + for (const [f, rr] of [[0.25, 0.09], [0.55, 0.15]]) { // bolinhas subindo até o balão + c.beginPath(); c.arc(e.cx + e.ry * 0.3 - f * e.ry * 0.3, e.cy - e.ry * 0.75 + (by + R - e.cy + e.ry * 0.75) * f, R * rr * 1.3, 0, Math.PI * 2); c.stroke(); + } + c.beginPath(); c.arc(bx, by, R, 0, Math.PI * 2); c.fill(); c.stroke(); + c.globalAlpha = 0.55 * surge; + desenharObjeto(c, sonho.obj, bx, by, R * 1.15, o.t, o.cor, Math.sin(o.t * 0.8) * 0.15, 0); + c.restore(); + } + function desenharZzz(c, o, olhos, fator) { fator = fator || 1; if (!o.dormindo) return; @@ -1385,7 +1414,7 @@ return ((!base.lente && FORMATOS[formato]) || base).h / 2; }, maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } }, - faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS), + faixa: definirFaixa, sonho: definirSonho, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS), /** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */ atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)), atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null), diff --git a/android/app/java/br/carinha/rosto/Luz.java b/android/app/java/br/carinha/rosto/Luz.java new file mode 100644 index 0000000..a1f5780 --- /dev/null +++ b/android/app/java/br/carinha/rosto/Luz.java @@ -0,0 +1,44 @@ +package br.carinha.rosto; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; + +/** + * Sensor de luz do celular (lux). A página decide o que fazer (apagaram a luz: sono; acenderam de + * repente: aperta os olhos). Manda no máximo a cada INTERVALO e só quando muda de verdade. + */ +final class Luz implements SensorEventListener { + interface Ouvinte { void luz(float lux); } + + private static final long INTERVALO = 700; // ms + private final SensorManager gerente; + private final Sensor sensor; + private final Ouvinte ouvinte; + private long enviadoEm; + private float enviado = -1; + + Luz(Context c, Ouvinte ouvinte) { + gerente = (SensorManager) c.getSystemService(Context.SENSOR_SERVICE); + sensor = gerente == null ? null : gerente.getDefaultSensor(Sensor.TYPE_LIGHT); + this.ouvinte = ouvinte; + } + + void ligar() { if (sensor != null) gerente.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL); } + + void desligar() { if (sensor != null) gerente.unregisterListener(this); } + + @Override public void onSensorChanged(SensorEvent e) { + float lux = e.values[0]; + long agora = System.currentTimeMillis(); + boolean mudou = enviado < 0 || Math.abs(lux - enviado) > Math.max(2f, enviado * 0.15f); + if (!mudou || agora - enviadoEm < INTERVALO) return; + enviado = lux; + enviadoEm = agora; + ouvinte.luz(lux); + } + + @Override public void onAccuracyChanged(Sensor s, int precisao) { } +} diff --git a/android/app/java/br/carinha/rosto/MainActivity.java b/android/app/java/br/carinha/rosto/MainActivity.java index 9118cab..28c460d 100644 --- a/android/app/java/br/carinha/rosto/MainActivity.java +++ b/android/app/java/br/carinha/rosto/MainActivity.java @@ -109,6 +109,7 @@ public class MainActivity extends Activity { private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto private final CerebroLocal cerebroLocal = new CerebroLocal(); private Descoberta descoberta; // acha a placa na rede quando o endereço salvo não responde + private Luz luz; // sensor de luz: a página reage a apagar/acender a luz private boolean jaCaiuParaLocal = false; private boolean jaRecarregou = false; private boolean usandoLocal = false; // servidor caiu: ficamos na copia do APK @@ -193,6 +194,11 @@ public class MainActivity extends Activity { cerebroLocal.chave = token; cerebroLocal.iniciar(this, servidor); descoberta = new Descoberta(this); + luz = new Luz(this, new Luz.Ouvinte() { + @Override public void luz(final float lux) { + if (emPrimeiroPlano) web.evaluateJavascript("window.eilik&&window.eilik.luz&&window.eilik.luz(" + fmt(lux) + ")", null); + } + }); remoto = new ControleRemoto(new Runnable() { @Override public void run() { runOnUiThread(new Runnable() { @@ -867,6 +873,7 @@ public class MainActivity extends Activity { protected void onResume() { super.onResume(); emPrimeiroPlano = true; + luz.ligar(); ultimoUsoTela = System.currentTimeMillis(); remoto.servidor(servidor); remoto.iniciar(); @@ -890,6 +897,7 @@ public class MainActivity extends Activity { protected void onPause() { super.onPause(); emPrimeiroPlano = false; + luz.desligar(); remoto.parar(); rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null); rtcAtivo = false; diff --git a/aprendizado.py b/aprendizado.py index 009848d..4832d31 100644 --- a/aprendizado.py +++ b/aprendizado.py @@ -135,6 +135,7 @@ class Aprendizado: self.pode_ia = lambda provedor: False self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro self.sono_avisado = 0.0 + self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None # horário da madrugada (Ajustes do celular): o main liga; None = madrugada silenciosa desligada self.noite = lambda: None self.mudar_noite = lambda ini, fim: None @@ -345,6 +346,7 @@ class Aprendizado: with self.lock: self.decisoes.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto, motivo=motivo)) del self.decisoes[MAX_DECISOES:] + self.console('aprendizado', texto, motivo, agora=agora) def porque_chegada(self, agora): t = time.localtime(agora + 3600) @@ -548,6 +550,7 @@ class Aprendizado: self.decisoes.insert(0, dict( horario=fim_do_dia, tipo='sono', texto='Mudou o horário de dormir para %dh às %dh' % (novo_ini, novo_fim), motivo='; '.join(motivos) + ' (o horário escolhido é %dh às %dh)' % tuple(self.sono_base))) + self.console('aprendizado', self.decisoes[0]['texto'], self.decisoes[0]['motivo']) return licoes # ------------------------------------------------------------------ semana: IA e plano diff --git a/carinha.html b/carinha.html index b54a948..b878025 100644 --- a/carinha.html +++ b/carinha.html @@ -595,6 +595,10 @@ function atividade(nome, segundos, receita) { if (!window.MotorRosto || !window.MotorRosto.atividade) return; if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; } if (r.dormindo) { r.dormindo = false; animar("surpreso"); } + lembrarDoDia(nome, receita); + const agora = performance.now() / 1000, dur = Number(segundos) || 0; + HUMANO.oops = dur > 20 && Math.random() < 0.35 ? { ini: agora + dur * (0.3 + 0.4 * Math.random()) } : null; + if (HUMANO.oops) HUMANO.oops.fim = HUMANO.oops.ini + 2.2; if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica"); } @@ -618,6 +622,91 @@ function tocarExpressaoCriada(texto) { if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos); } +/* ------------------------------------------------------------- jeito de gente + Acordar grogue, sonhar dormindo, hesitar antes de agir, errar na atividade e ficar sem graça, + olhar nos olhos e desviar, sorrir de volta para quem sorri e reagir à luz do ambiente. */ +const HUMANO = { + dormiaAntes: false, dormiuEm: 0, grogueIni: 0, grogueAte: 0, bocejou: true, + sonhoProx: 0, sonhoAte: 0, sonhos: ['estrela', 'bola', 'nota', 'coracao', 'livro', 'flor'], + oops: null, // {ini, fim}: um tropeço no meio da atividade + evita: { ate: 0, prox: 0, dx: 0, dy: 0 }, // desvio do olhar durante o contato visual + sorriso: { desde: 0, ultimo: 0 }, + luz: { lux: null, media: null, sonolentoAte: 0, ofuscadoAte: 0, mudou: 0 }, +}; +const OBJETO_DA_ATIVIDADE = { bola: 'bola', ler: 'livro', cafe: 'xicara', cantar: 'nota', violao: 'nota', pintar: 'pincel', + regar: 'flor', telescopio: 'estrela', malhar: 'halter', bolhas: 'bolha', videogame: 'controle', dancar: 'estrela', + meditar: 'coracao', cozinhar: 'chama', computador: 'controle', pescar: 'bolha', pipa: 'estrela', skate: 'estrela', + corda: 'estrela' }; +function lembrarDoDia(nome, receita) { // o que ele fez hoje vira assunto dos sonhos + const obj = receita && Array.isArray(receita.objetos) && receita.objetos[0] ? receita.objetos[0].tipo : OBJETO_DA_ATIVIDADE[nome]; + if (obj) HUMANO.sonhos = [obj].concat(HUMANO.sonhos.filter((o) => o !== obj)).slice(0, 8); +} +function falarFrase(texto, segundos) { // "dizer ..." do modo vida: legenda + voz de robô + if (window.carinhaSemServidor || !texto || r.dormindo) return; + if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) }); + falar(texto); +} +function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir + if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn(); + setTimeout(fn, 150 + Math.random() * 450); +} +function humanoTick(t) { + // acordou depois de dormir um tempo: fica grogue (olhos pesados, lento) e boceja + if (r.dormindo && !HUMANO.dormiaAntes) HUMANO.dormiuEm = t; + if (!r.dormindo && HUMANO.dormiaAntes && t - HUMANO.dormiuEm > 180) { + HUMANO.grogueIni = t; HUMANO.grogueAte = t + 30 + Math.random() * 20; HUMANO.bocejou = false; + } + HUMANO.dormiaAntes = r.dormindo; + if (!HUMANO.bocejou && t - HUMANO.grogueIni > 1.6 && !r.dormindo && !r.kf) { HUMANO.bocejou = true; animar('bocejar'); } + // dormindo (com a tela acesa): de vez em quando sonha com algo do dia + if (r.dormindo && !SONO.apagada && !window.carinhaSemServidor) { + if (!HUMANO.sonhoProx) HUMANO.sonhoProx = t + 40 + Math.random() * 60; + if (t >= HUMANO.sonhoProx) { + const dur = 6 + Math.random() * 4; + HUMANO.sonhoAte = t + dur; HUMANO.sonhoProx = t + dur + 45 + Math.random() * 90; + const obj = HUMANO.sonhos[Math.floor(Math.random() * Math.min(4, HUMANO.sonhos.length))]; + if (window.MotorRosto && window.MotorRosto.sonho) window.MotorRosto.sonho(obj, dur); + } + } else HUMANO.sonhoProx = 0; +} +function grogue(t) { // 1 = acabou de acordar, 0 = esperto + return t < HUMANO.grogueAte ? Math.min(1, (HUMANO.grogueAte - t) / (HUMANO.grogueAte - HUMANO.grogueIni)) : 0; +} +function piscarHumano(t) { animar(grogue(t) > 0.2 || t < HUMANO.luz.sonolentoAte ? 'piscar2' : 'piscar'); } +function desvioDoOlhar(t) { // olha nos olhos 2 a 5 s, desvia um instante e volta + const ev = HUMANO.evita; + if (t >= ev.prox) { + const dura = Math.random() < 0.7 ? 0.6 + Math.random() * 0.7 : 0; + ev.ate = t + dura; ev.prox = ev.ate + 2 + Math.random() * 3; + ev.dx = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 8); ev.dy = 3 + Math.random() * 5; + } + return t < ev.ate ? ev : null; +} +function contagio(t, agora) { // sorriu para ele (rastreamento do painel): sorri de volta + const S = HUMANO.sorriso; + if (t.tipo !== 'rosto' || typeof t.sorriso !== 'number' || r.dormindo || ATIV) { S.desde = 0; return; } + if (t.sorriso > 0.55) { if (!S.desde) S.desde = agora; } else S.desde = 0; + if (S.desde && agora - S.desde > 0.6 && agora - S.ultimo > 20 && !r.kf) { + S.ultimo = agora; S.desde = 0; + animar(t.sorriso > 0.8 ? 'rir' : 'feliz'); + } +} +function luzAmbiente(lux) { // sensor de luz do celular (lux) + lux = Number(lux); + if (!Number.isFinite(lux) || lux < 0) return; + const L = HUMANO.luz, t = performance.now() / 1000, antes = L.media; + L.lux = lux; + L.media = antes === null ? lux : antes + (lux - antes) * 0.15; + if (antes === null || t - L.mudou < 8) return; + if (lux < 3 && antes > 25) { // apagaram a luz: fica com sono + L.mudou = t; L.sonolentoAte = t + 60; L.media = lux; + if (!r.dormindo && !r.kf) animar('bocejar'); + } else if (lux > 80 && antes < 8) { // acenderam a luz de repente: aperta os olhos + L.mudou = t; L.ofuscadoAte = t + 1.8; L.media = lux; + if (r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) r.dormindo = false; + } +} + function repouso(t) { // pose calma: olha devagar, "respira" if (r.dormindo) { r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; @@ -626,6 +715,7 @@ function repouso(t) { // pose calma: olha devagar, "respira r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.dx = 0; r.dy = 3.0 * Math.sin(t * 1.4); + if (t < HUMANO.sonhoAte) { r.dx = 3.2 * Math.sin(t * 6.5); r.boca = 'sorriso'; } // sonhando return; } r.forma = "aberto"; r.forma2 = null; @@ -638,7 +728,7 @@ function repouso(t) { // pose calma: olha devagar, "respira const longe = Math.random() < 0.25; OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7); OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3); - OLHAR.prox = t + 1.2 + Math.random() * 3; + OLHAR.prox = t + (1.2 + Math.random() * 3) * (1 + 2 * grogue(t)); // grogue: olhar lento } OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3; OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3; @@ -651,9 +741,23 @@ function repouso(t) { // pose calma: olha devagar, "respira if (ATIV.forma) r.forma = ATIV.forma; if (ATIV.boca) r.boca = ATIV.boca; if (ATIV.ab) r.ab = ATIV.ab; + const o = HUMANO.oops; // errou: susto e depois fica sem graça + if (o && t >= o.ini && t < o.fim) { + if (t - o.ini < 0.5) { r.forma = 'surpreso'; r.boca = 'aberta'; r.ab = 1.1; } + else { r.forma = 'feliz'; r.boca = 'sorriso'; r.cor = lerpCor(r.cor, ROSA, 0.5); r.dy += 6; } + } } else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica) r.forma = "bravo"; } + const g = grogue(t); + if (g > 0) { // acabou de acordar: olhos pesados, cor apagada, olhar baixo + r.ab = Math.min(r.ab, 1 - 0.5 * g); + r.cor = lerpCor(r.cor, corEscura(0.7), 0.35 * g); + if (g > 0.5 && !ATIV) r.boca = 'sono'; + r.dx *= 1 - 0.5 * g; r.dy = r.dy * (1 - 0.5 * g) + 3 * g; + } + if (t < HUMANO.luz.sonolentoAte) r.ab = Math.min(r.ab, 0.78); + if (t < HUMANO.luz.ofuscadoAte) { r.ab = Math.min(r.ab, 0.3); r.forma = 'feliz'; } } function desenhar() { @@ -667,7 +771,9 @@ function desenhar() { const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo; const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV; 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 ev = seguindo && TRACK.alvo.tipo === 'rosto' ? desvioDoOlhar(tAgora) : null; + const dx = seguindo ? (ev ? MOV.olharX * 0.3 + ev.dx : MOV.olharX) : r.dx; + const dy = seguindo ? (ev ? MOV.olharY * 0.3 + ev.dy : MOV.olharY) : r.dy; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const comMotor = ROSTO.motor && window.MotorRosto; OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; }); @@ -751,6 +857,7 @@ function contarAprendi(texto) { function trata(ordem) { const bruta = (ordem || "").trim(); if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase + if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; } ordem = bruta.toLowerCase(); if (!ordem) return; if(ordem==='recarregar') { @@ -797,14 +904,16 @@ function trata(ordem) { if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); } return; } - r.dormindo = false; animar("surpreso"); + const longo = r.dormindo && performance.now() / 1000 - HUMANO.dormiuEm > 180; + r.dormindo = false; + if (!longo) animar("surpreso"); // sono longo: acorda devagar, grogue (humanoTick) } else if (ordem === "aleatorio") { animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); } else if (SEQS[ordem]) { if (window.MotorRosto) { if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 }); } - animar(ordem); + hesitar(() => animar(ordem), ordem); } else if (ordem === "calma") { // E-Stop do painel: interrompe a animação e volta ao rosto neutro. r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false; @@ -1121,6 +1230,7 @@ function rastrear(t) { (r.dormindo && !configRecebida?.cameraSono)) return; const agora=performance.now()/1000; TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y; + contagio(t, agora); MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65; MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65; if(t.atividade===true) { @@ -1306,6 +1416,7 @@ const AUDIO = { }; if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ +if (window.eilik) window.eilik.luz = luzAmbiente; // sensor de luz do celular (o app chama) let configRecebida = null; function configurarApp(c) { @@ -1353,6 +1464,7 @@ async function sincronizarApp() { bateria: window.carinhaBateria, aparelho: window.carinhaAparelho, video: window.carinhaVideo, + luz: HUMANO.luz.lux, }) }); } } catch (e) { /* servidor ausente: animação continua */ } @@ -1448,6 +1560,7 @@ function laco(t) { checarSilencio(t / 1000); atualizarFeicao(t / 1000); + humanoTick(t / 1000); ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null; if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null); @@ -1461,7 +1574,7 @@ function laco(t) { if (r.dormindo) { proxPiscar = proxGesto = t + 3600000; } else if (t >= proxPiscar) { - animar("piscar"); + piscarHumano(t / 1000); proxPiscar = t + 1600 + Math.random() * 3500; } else if (AUTO && t >= proxGesto && !ATIV) { animar(gestoSolto()); diff --git a/cerebro.py b/cerebro.py index e40a7d2..b48753f 100644 --- a/cerebro.py +++ b/cerebro.py @@ -39,6 +39,7 @@ class Cerebro: try:self.ativo=json.loads(self.path.read_text())['ativo'] is True except (OSError,ValueError,KeyError,TypeError):pass threading.Thread(target=self.loop,daemon=True).start() + console = staticmethod(lambda origem, texto, motivo='', nivel='info', agora=None: None) # o main troca pelo console do painel def cabecalhos(self): h={'Content-Type':'application/json'} if self.chave:h['Authorization']='Bearer '+self.chave @@ -100,8 +101,10 @@ class Cerebro: with self.controle.lock: if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo') and not self.controle.estado.get('atividade'): self.enviar(result['gesto']) + self.console('ia','IA local escolheu: '+result['gesto'],'evento "%s", respondeu em %.1f s'%(event,result['segundos'])) except Exception as e: with self.lock:self.error=str(e)[:200] + self.console('ia','IA local não respondeu',str(e)[:200],'detalhe') finally: self.last=time.monotonic() with self.lock:self.busy=False diff --git a/console.py b/console.py new file mode 100644 index 0000000..da2dede --- /dev/null +++ b/console.py @@ -0,0 +1,50 @@ +"""Console: tudo o que o Carinha está fazendo e por quê, ao vivo. + +Cada parte do servidor registra o que fez e o motivo (modo vida, sentidos, IA local, aprendizado, +ideias do dia, celular). As linhas ficam na memória (as últimas MAX_LINHAS) e vão para o painel pelo +SSE ('console'). Nível 'detalhe' é o que se repete muito (humor reenviado, disparo solto do sensor): +o painel esconde por padrão. +""" +import json +import threading +import time +from collections import deque + +MAX_LINHAS = 400 +ORIGENS = ('vida', 'sentidos', 'celular', 'ia', 'aprendizado', 'ideias', 'sistema') + + +class Console: + def __init__(self): + self.lock = threading.Lock() + self.linhas = deque(maxlen=MAX_LINHAS) + self.seq = 0 + self.publicar = lambda tipo, dados: None + + def registrar(self, origem, texto, motivo='', nivel='info', agora=None): + if origem not in ORIGENS: + origem = 'sistema' + with self.lock: + self.seq += 1 + linha = dict(id=self.seq, horario=agora or time.time(), origem=origem, texto=str(texto)[:240], + motivo=str(motivo or '')[:300], nivel='detalhe' if nivel == 'detalhe' else 'info') + self.linhas.append(linha) + try: + self.publicar('console', linha) + except Exception: + pass + return linha + + def status(self, desde=0): + with self.lock: + return dict(linhas=[l for l in self.linhas if l['id'] > desde], seq=self.seq, origens=list(ORIGENS)) + + def rotas(self, h, metodo): + rota = h.path.split('?', 1)[0] + if rota != '/console': + return False + if metodo != 'GET': + h.enviar(json.dumps(dict(erro='Método inválido')), codigo=400) + return True + h.enviar(json.dumps(self.status(), ensure_ascii=False)) + return True diff --git a/controle_pc.py b/controle_pc.py index 84f1004..4a24055 100644 --- a/controle_pc.py +++ b/controle_pc.py @@ -105,6 +105,8 @@ class ControlePC: self.rtc.encerrar() return novo.copy() + console = staticmethod(lambda origem, texto, motivo='', nivel='info', agora=None: None) # o main troca pelo console do painel + def resumo(self, host=''): """Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria').""" with self.lock: @@ -160,12 +162,14 @@ class ControlePC: if not isinstance(dados, dict): raise ValueError('Estado deve ser objeto JSON') with self.lock: + antes = self.estado self.estado = dados self.celular_ip = ip_informado(dados) or h.client_address[0] if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]: self.recarga["confirmada"] = True self.status_at = time.monotonic() self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0])) + self.mudancas(antes, dados) enviar({'ok': True}) else: enviar(self.resumo(h.headers.get("Host", "").split(":")[0])) @@ -176,6 +180,21 @@ class ControlePC: enviar({'erro': str(e)}, 503) return True + def mudancas(self, antes, agora): + """O que mudou no celular desde o último status: vai para o console do painel.""" + if not antes: + self.console('celular', 'Celular conectado', 'rosto %s' % agora.get('rosto', '?')) + return + if bool(antes.get('dormindo')) != bool(agora.get('dormindo')): + self.console('celular', 'O rosto dormiu' if agora.get('dormindo') else 'O rosto acordou') + if antes.get('atividade') != agora.get('atividade'): + if agora.get('atividade'): + self.console('celular', 'Começou a atividade: %s' % agora['atividade']) + else: + self.console('celular', 'Terminou a atividade: %s' % antes['atividade']) + if bool(antes.get('tela_apagada')) != bool(agora.get('tela_apagada')): + self.console('celular', 'Apagou a tela (dormindo há um tempo)' if agora.get('tela_apagada') else 'Acendeu a tela') + def registrar_recusa(self, rota, motivo): """Pedido do celular recusado: aparece no journal (no máximo 1 linha por minuto).""" agora = time.monotonic() diff --git a/deploy.ps1 b/deploy.ps1 index ac3930b..7392a56 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). $arquivos = @( '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', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'aprendizado.py', + 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'console.py', 'aprendizado.py', 'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json', 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js' ) diff --git a/historico.py b/historico.py index 84e6e05..651d17f 100644 --- a/historico.py +++ b/historico.py @@ -56,6 +56,7 @@ class Historico: ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma) self.processo_pir = None self.subidas_pir = [] # disparos ainda não confirmados (horários) + self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None self.subiu_em = None # quando o pino subiu da última vez (ligado direto desde então) with self.db() as db: db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)') @@ -163,6 +164,9 @@ class Historico: confirmou = len(self.subidas_pir) >= quantos if confirmou: self.confirmar_pir(agora) + else: + self.console('sentidos', 'Disparo solto do sensor de presença', 'sensibilidade %s: ainda não confirmou (%d de %d)' % ( + self.config['sensibilidade_pir'], len(self.subidas_pir), quantos), 'detalhe', agora) def checar_pir(self, agora=None): """Ligado direto por bastante tempo = alguém se mexendo na frente (o sensor foi redisparando).""" diff --git a/inspiracao.py b/inspiracao.py index b2a0836..0460c42 100644 --- a/inspiracao.py +++ b/inspiracao.py @@ -90,6 +90,7 @@ class Inspiracao: self.madrugada = lambda agora: False self.pode_gemini = lambda: False self.baixar = _baixar # trocado nos testes + self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None self.ocupado = False self.config = json.loads(json.dumps(PADRAO)) self.hoje = self._dia_vazio('') @@ -326,11 +327,13 @@ class Inspiracao: item = self.criacao.item(ident) if item: self.anotar('ideia', 'Teve uma ideia nova: %s %s (%s)' % (item['emoji'], item['nome'], item['motivo']), agora) + self.console('ideias', 'Criou a atividade %s %s' % (item['emoji'], item['nome']), item['motivo'], agora=agora) try: self.criacao.avisar() # a grade do estúdio mostra as novidades except Exception: pass except Exception as e: + self.console('ideias', 'Não conseguiu criar as ideias do dia', str(e)[:200], agora=agora) with self.lock: self.hoje['erro'] = str(e)[:200] if not manual: @@ -404,6 +407,7 @@ class Inspiracao: chave = str(item['id']) self.hoje['contadas'][chave] = self.hoje['contadas'].get(chave, 0) + 1 self.anotar('aprendi', 'Contou o que aprendeu: ' + item['aprendi'], agora) + self.console('ideias', 'Contou o que aprendeu', item['aprendi'], agora=agora) self.salvar() return dict(ok=True) diff --git a/kiosk-server.py b/kiosk-server.py index 8513331..4630ff6 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -45,6 +45,7 @@ from vida import Vida from criacao import Criacao from inspiracao import Inspiracao from aprendizado import Aprendizado +from console import Console TRAVA = threading.Lock() @@ -73,12 +74,13 @@ ARQUIVO_APARENCIA = "aparencia.json" # 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", "/rosto-motor.js", "/voz-robo.js", "/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task", + "/vendor/mediapipe/expressao.task", "/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"} # Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar. PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"} -PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/", +PREFIXOS_API = ("/eventos", "/console", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/", "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") @@ -122,10 +124,26 @@ EVENTOS = Eventos() ESTOP = {"ativo": False, "desde": None} +CONSOLE = Console() +# ordens que se repetem (o modo vida reenvia o humor a cada minuto): no console, só em "detalhes" +ORDENS_DETALHE = ("humor ", "ritmo ", "zanga ", "sono ") + + def enfileirar(ordem): """Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis.""" FILA.adicionar(ordem) EVENTOS.publicar("expressao", {"ordem": ordem}) + if ordem.startswith("dizer "): + CONSOLE.registrar("celular", "Falou: " + ordem[6:]) + elif ordem.startswith(("atividade_criada ", "expressao_criada ")): + try: + nome = json.loads(ordem.split(" ", 1)[1]).get("nome", "?") + except ValueError: + nome = "?" + CONSOLE.registrar("celular", "Mandou ao rosto a criação: " + nome) + elif not ordem.startswith("aprendi "): + CONSOLE.registrar("celular", "Mandou ao rosto: " + ordem, + nivel="detalhe" if ordem.startswith(ORDENS_DETALHE) else "info") def carregar_aparencia(): @@ -264,7 +282,7 @@ class Handler(SimpleHTTPRequestHandler): def modulos(self): s = self.server - return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro", + return [getattr(s, nome) for nome in ("console", "api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro", "braco", "servos", "controle") if hasattr(s, nome)] def estado(self): @@ -486,7 +504,8 @@ class Servidor(ThreadingHTTPServer): def ligar_eventos(servidor): - for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado"): + for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado", + "console"): if hasattr(servidor, nome): getattr(servidor, nome).publicar = EVENTOS.publicar @@ -560,6 +579,12 @@ def main(): def nome_criada(ident): return next((a['nome'] for a in servidor.criacao.atividades() if a['id'] == ident), None) servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada) + # Console: tudo o que ele faz e por quê (cada parte registra o motivo da decisão) + servidor.console = CONSOLE + for parte in (vida, aprendizado, inspiracao, servidor.cerebro, servidor.historico, servidor.controle): + parte.console = CONSOLE.registrar + vida.dia_anterior = lambda: aprendizado.historico[-1] if aprendizado.historico else None + CONSOLE.registrar("sistema", "Servidor iniciado", "versão " + versao()) aprendizado.anotar = lambda tipo, texto, agora=None: (vida.anotar(tipo, texto, agora), vida.avisar()) vida.aprendizado = aprendizado # a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10) diff --git a/obter-rastreamento.py b/obter-rastreamento.py index 045d059..1ffd6bd 100644 --- a/obter-rastreamento.py +++ b/obter-rastreamento.py @@ -36,6 +36,8 @@ def main(): 'LICENSE':'https://raw.githubusercontent.com/google-ai-edge/mediapipe/master/LICENSE', 'face.tflite':'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite', 'hands.task':'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task', + # expressão do rosto (sorriso) para o contágio emocional + 'expressao.task':'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task', }.items(): data = download(url); (dest/name).write_bytes(data) files[name] = {'url':url, 'sha256':hashlib.sha256(data).hexdigest()} diff --git a/rastreador-worker.js b/rastreador-worker.js index 7a3e11e..72a95fb 100644 --- a/rastreador-worker.js +++ b/rastreador-worker.js @@ -1,5 +1,7 @@ 'use strict'; let face = null, hands = null, vision = null; +// expressão (sorriso) para o contágio emocional: modelo opcional, roda 1 quadro a cada 4 +let expressao = null, semExpressao = false, quadro = 0, sorriso = null; let lastTime = 0; async function prepare(c) { if (!vision) { @@ -9,6 +11,13 @@ async function prepare(c) { } if (c.rosto && !face) face = await vision.module.FaceDetector.createFromOptions(vision.files, { baseOptions:{modelAssetPath:'/vendor/mediapipe/face.tflite',delegate:'CPU'},runningMode:'VIDEO',minDetectionConfidence:.6}); + if (c.rosto && !expressao && !semExpressao) { + try { + expressao = await vision.module.FaceLandmarker.createFromOptions(vision.files, { + baseOptions:{modelAssetPath:'/vendor/mediapipe/expressao.task',delegate:'CPU'},runningMode:'VIDEO',numFaces:1, + outputFaceBlendshapes:true}); + } catch (e) { semExpressao = true; } // sem o modelo: segue só com a posição do rosto + } if (c.maos && !hands) hands = await vision.module.HandLandmarker.createFromOptions(vision.files, { baseOptions:{modelAssetPath:'/vendor/mediapipe/hands.task',delegate:'CPU'},runningMode:'VIDEO',numHands:2, minHandDetectionConfidence:.6,minHandPresenceConfidence:.6,minTrackingConfidence:.5}); @@ -25,9 +34,17 @@ self.onmessage = async ({data}) => { w:d.boundingBox.width/bitmap.width,h:d.boundingBox.height/bitmap.height, score:d.categories[0]?.score || 0,pontos:d.keypoints.map(p=>({x:p.x,y:p.y})) })) : []; + if (!faces.length) sorriso = null; + else if (expressao && quadro++ % 4 === 0) { + const b = (expressao.detectForVideo(bitmap,stamp).faceBlendshapes || [])[0]; + if (b) { + const s = {}; for (const c of b.categories) s[c.categoryName] = c.score; + sorriso = ((s.mouthSmileLeft || 0) + (s.mouthSmileRight || 0)) / 2; + } + } const result = data.config.maos ? hands.detectForVideo(bitmap,stamp) : {landmarks:[]}; self.postMessage({tipo:'resultado',faces,hands:result.landmarks.map(h=>h.map(p=>({x:p.x,y:p.y}))), - ms:performance.now()-began,timestamp:data.timestamp}); + sorriso,ms:performance.now()-began,timestamp:data.timestamp}); } catch(e) { self.postMessage({tipo:'erro',erro:e.message || String(e)}); } finally { if (bitmap) bitmap.close(); } }; diff --git a/rosto-motor.js b/rosto-motor.js index 4b11a0c..299a4d7 100644 --- a/rosto-motor.js +++ b/rosto-motor.js @@ -287,11 +287,13 @@ olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); desenharMaos(ctx, o, olhos, tela.height); desenharZzz(ctx, o, olhos); + desenharSonho(ctx, o, olhos); } else { cctx.clearRect(0, 0, camada.width, camada.height); olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); desenharMaos(cctx, o, olhos, tela.height); desenharZzz(cctx, o, olhos); + desenharSonho(cctx, o, olhos); 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); @@ -421,6 +423,33 @@ } // ------------------------------------------------------------------ Zzz + // ----------------------------------------------------------- sonho + // Dormindo, às vezes aparece bem fraquinho um balão de pensamento com algo do dia (a bola, a estrela...). + const sonho = { obj: null, t0: 0, dur: 0 }; + function definirSonho(obj, segundos) { + sonho.obj = obj || null; sonho.t0 = performance.now() / 1000; sonho.dur = segundos || 7; + } + function desenharSonho(c, o, olhos) { + if (!o.dormindo || !sonho.obj) return; + const k = (o.t - sonho.t0) / sonho.dur; + if (k < 0) return; + if (k >= 1) { sonho.obj = null; return; } + const surge = Math.min(1, k * sonho.dur / 1.2, (1 - k) * sonho.dur / 1.2); // aparece e some devagar + const e = olhos.slice().sort((a, b) => a.cx - b.cx)[0]; // olho da esquerda na tela + const R = e.ry * 0.62, bx = e.cx - e.ry * 0.1; + const by = Math.max(R * 1.2, e.cy - e.ry * 1.75) + Math.sin(o.t * 1.3) * e.ry * 0.05; // nunca sai da tela + c.save(); + c.strokeStyle = css(o.cor); c.fillStyle = css(o.cor, 0.08); c.lineWidth = Math.max(1.5, R * 0.06); + c.globalAlpha = 0.45 * surge; + for (const [f, rr] of [[0.25, 0.09], [0.55, 0.15]]) { // bolinhas subindo até o balão + c.beginPath(); c.arc(e.cx + e.ry * 0.3 - f * e.ry * 0.3, e.cy - e.ry * 0.75 + (by + R - e.cy + e.ry * 0.75) * f, R * rr * 1.3, 0, Math.PI * 2); c.stroke(); + } + c.beginPath(); c.arc(bx, by, R, 0, Math.PI * 2); c.fill(); c.stroke(); + c.globalAlpha = 0.55 * surge; + desenharObjeto(c, sonho.obj, bx, by, R * 1.15, o.t, o.cor, Math.sin(o.t * 0.8) * 0.15, 0); + c.restore(); + } + function desenharZzz(c, o, olhos, fator) { fator = fator || 1; if (!o.dormindo) return; @@ -1385,7 +1414,7 @@ return ((!base.lente && FORMATOS[formato]) || base).h / 2; }, maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } }, - faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS), + faixa: definirFaixa, sonho: definirSonho, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS), /** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */ atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)), atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null), diff --git a/tests/test_humano.py b/tests/test_humano.py new file mode 100644 index 0000000..9ff00fe --- /dev/null +++ b/tests/test_humano.py @@ -0,0 +1,158 @@ +"""Jeito de gente do modo vida: reencontro e saudade, bom dia lembrando de ontem, pedidos com motivo, +largar atividade no meio, personalidade e o console com o porquê de cada decisão.""" +import random +import sys +import tempfile +import time +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from console import Console # noqa: E402 +from vida import Vida # noqa: E402 + +DIA = time.mktime((2026, 10, 12, 9, 0, 0, 0, 0, -1)) # uma segunda, 9h + + +class HumanoTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.v = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(1)) + self.ordens, self.linhas = [], [] + self.v.enfileirar = self.ordens.append + self.v.console = lambda origem, texto, motivo='', nivel='info', agora=None: self.linhas.append((origem, texto, motivo, nivel)) + self.v.celular_online = lambda: True + self.v.celular_dormindo = lambda: False + self.v.madrugada = lambda agora: False + self.perto = False + self.v.alguem_perto = lambda: self.perto + + def tearDown(self): + self.tmp.cleanup() + + def falas(self): + return [o[6:] for o in self.ordens if o.startswith('dizer ')] + + def chegar(self, agora, ausencia): + self.v.ultimo_perto = agora - ausencia + self.v.perto_antes = False + self.perto = True + self.v.tick(agora) + + def test_reencontro_cresce_com_o_tempo_fora(self): + self.v.saudou_dia = '2026-10-12' # bom dia já foi dado + self.chegar(DIA, 5 * 60) # 5 min fora: nada + self.assertEqual(self.falas(), []) + self.assertNotIn('acenar', self.ordens) + self.chegar(DIA + 100, 20 * 60) # 20 min: acena + self.assertIn('acenar', self.ordens) + self.chegar(DIA + 200, 2 * 3600) # 2 h: comemora + self.assertEqual(self.falas()[-1], 'Oi! Você voltou!') + social = self.v.necessidades['social'] + self.chegar(DIA + 300, 6 * 3600) # 6 h: saudade + self.assertEqual(self.falas()[-1], 'Senti sua falta!') + self.assertIn('coracao', self.ordens) + self.assertGreater(self.v.necessidades['social'], social) + self.assertTrue(any('ninguém por perto havia 6h00' in m for _, _, m, _ in self.linhas)) + + def test_bom_dia_lembra_de_ontem(self): + ontem = dict(presenca=[None] * 24, respostas=0, atos=8) + ontem['presenca'][1] = 0.6 # ficaram acordados até as 2h + self.v.dia_anterior = lambda: ontem + self.v.acaso = random.Random(0) + self.chegar(DIA, 8 * 3600) + fala = self.falas()[0] + self.assertTrue(fala.startswith('Bom dia! '), fala) + self.assertIn(fala[9:], ['Ontem a gente ficou acordado até as 2h, hein!', + 'Ontem ninguém brincou comigo... hoje vai ser diferente?']) + self.assertEqual(self.v.saudou_dia, '2026-10-12') + self.chegar(DIA + 3600, 30 * 60) # mesmo dia: não repete o bom dia + self.assertEqual(len([f for f in self.falas() if f.startswith('Bom dia')]), 1) + # de madrugada não faz festa + self.v.madrugada = lambda agora: True + n = len(self.falas()) + self.chegar(DIA + 7200, 6 * 3600) + self.assertEqual(len(self.falas()), n) + + def test_pede_o_que_falta_com_motivo_e_intervalo(self): + self.v.saudou_dia = '2026-10-12' + self.perto, self.v.perto_antes, self.v.visto_em = True, True, DIA + self.v.atividade_em = DIA # sem folga para atividades + self.v.necessidades.update(social=30, diversao=80, energia=80, alegria=70) + self.v.acaso = random.Random(3) + pedido = None + for i in range(40): + if self.v.agir(DIA + i) == 'pedido': + pedido = DIA + i + break + self.assertIsNotNone(pedido) + self.assertEqual(self.falas()[-1], 'Tô carente... me faz um carinho?') + self.assertIn('social 30', self.v.porque) + self.assertEqual(self.v.diario[0]['tipo'], 'pedido') + n = len(self.falas()) + for i in range(40): # menos de 20 min depois: não pede de novo + self.v.agir(pedido + 60 + i) + self.assertEqual(len(self.falas()), n) + + def test_larga_atividade_no_meio_e_conta(self): + self.v.acaso = random.Random(0) + largou = None + for i in range(60): + self.v.desistencia = None + self.v.comecar_atividade(DIA + i, 'ler', 200) + if self.v.desistencia: + largou = self.v.desistencia + break + self.assertIsNotNone(largou) + segundos = int(self.ordens[-1].split()[-1]) + self.assertLess(segundos, 110) # mandou ao celular a duração encurtada + self.v.tick(largou[0] + 1) + self.assertIn('bocejar', self.ordens) + self.assertIn('Largou no meio: cansou de ler um livro', [d['texto'] for d in self.v.diario[:3]]) + + def test_personalidade_muda_o_jeito_e_amadurece(self): + self.assertEqual(self.v.jeito('rir'), 1.0) # neutro nos testes + self.v.personalidade['brincadeira'] = 0.9 + self.assertGreater(self.v.jeito('rir'), 1.4) + self.v.personalidade['timidez'] = 0.2 + self.assertLess(self.v.jeito('timido'), 0.7) + antes = self.v.personalidade['timidez'] + for _ in range(10): + self.v.estimulo('carinho', DIA) + self.assertLess(self.v.personalidade['timidez'], antes) + self.v.salvar() + outra = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(1)) + self.assertEqual(outra.personalidade, self.v.personalidade) + self.assertIn('personalidade', self.v.status()) + + def test_cada_decisao_vai_para_o_console_com_o_porque(self): + self.v.necessidades['energia'] = 10 + self.v.proximo_ato = 0 + self.v.ultimo_tick = DIA + self.v.tick(DIA) + origem, texto, motivo, _ = self.linhas[-1] + self.assertEqual((origem, texto), ('vida', 'Ficou exausto e foi dormir')) + self.assertIn('energia 10: abaixo de 20', motivo) + self.assertIn('energia 10 · alegria', motivo) + self.v.estimulo('carinho', DIA) + self.assertIn(('sentidos', 'Recebeu carinho'), [(o, t) for o, t, _, _ in self.linhas]) + + +class ConsoleTest(unittest.TestCase): + def test_guarda_as_ultimas_e_publica(self): + c = Console() + publicados = [] + c.publicar = lambda tipo, dados: publicados.append((tipo, dados)) + for i in range(450): + c.registrar('vida', 'ato %d' % i, 'motivo') + c.registrar('inventada', 'vira sistema', nivel='qualquer') + st = c.status() + self.assertEqual(len(st['linhas']), 400) + self.assertEqual(st['linhas'][-1]['origem'], 'sistema') + self.assertEqual(st['linhas'][-1]['nivel'], 'info') + self.assertEqual(publicados[-1][0], 'console') + self.assertEqual(len(c.status(desde=449)['linhas']), 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/vida.py b/vida.py index a1d023f..8d32e6d 100644 --- a/vida.py +++ b/vida.py @@ -43,6 +43,8 @@ ESTIMULOS = { 'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'), 'alimentar': (dict(energia=30, alegria=4), 'feliz'), } +NOME_ESTIMULO = {'pir': 'O sensor de presença viu alguém', 'movimento': 'A câmera viu movimento', + 'som': 'Ouviu um barulho', 'conversa': 'Alguém conversou com ele'} CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou', 'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'} @@ -112,6 +114,16 @@ PERIODOS = [(5, 12, 'de manhã', ['um sol nascendo', 'um pãozinho', 'uma xícar (0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])] MAX_DIARIO = 60 + +# Personalidade: traços de 0 a 1 que mudam o jeito de tudo (0,5 = neutro) e amadurecem com o convívio. +TRACOS = ('curiosidade', 'brincadeira', 'timidez') +GESTOS_TRACO = {'brincadeira': {'rir', 'comemorar', 'alegre', 'musica', 'piscadinha'}, + 'curiosidade': {'olhar_esq', 'olhar_dir', 'pensar', 'espiar', 'confuso'}, + 'timidez': {'timido', 'esconder', 'espiar'}} +# Reencontro: quanto mais tempo a pessoa ficou fora, maior a festa. +AUSENCIA_ACENO, AUSENCIA_OI, AUSENCIA_SAUDADE = 10 * 60, 60 * 60, 4 * 3600 +INTERVALO_PEDIDO = 20 * 60 # no mínimo 20 min entre um pedido ("brinca comigo?") e outro +DESISTE = 0.12 # chance de largar uma atividade no meio porque ficou chato GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos} @@ -149,6 +161,17 @@ class Vida: self.aprendi_pendente = lambda: None # curiosidade de hoje que ainda dá para contar self.contar_aprendi = lambda ident, agora=None: None self.aprendizado = None # o que ele aprende com o dia a dia (aprendizado.py) + self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None # console do painel + self.dia_anterior = lambda: None # resumo de ontem (aprendizado): para lembrar no bom dia + # sorteios dos comportamentos "humanos" ficam à parte: não mexem na sequência do rng principal + self.acaso = random.Random() if rng is None else random.Random(7) + self.porque = '' # motivo da última decisão (vai para o console) + self.ultima = None # {horario, texto, porque}: a última decisão de fato + self.perto_antes = False + self.ultimo_perto = time.time() + self.pediu_em = 0.0 + self.desistencia = None # (quando, o que) se largou a atividade no meio + self.figurinha_mostrada = 0.0 agora = time.time() self.config = dict(PADRAO) @@ -157,6 +180,10 @@ class Vida: self.diario = [] self.emojis = dict(dia='', total=0, ultimo=0.0) self.dormindo_proprio = False + # cada Carinha nasce com um jeito um pouco diferente (nos testes, neutro) + self.personalidade = ({k: 0.5 for k in TRACOS} if rng is not None else + {k: round(self.acaso.uniform(0.35, 0.65), 2) for k in TRACOS}) + self.saudou_dia = '' try: salvo = json.loads(self.caminho.read_text(encoding='utf-8')) self.config.update(self.validar(salvo.get('config', {}))) @@ -165,6 +192,11 @@ class Vida: self.necessidades[k] = _limitar(float(salvo['necessidades'][k])) self.nascimento = float(salvo.get('nascimento', agora)) self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO] + if isinstance(salvo.get('personalidade'), dict): + for k in TRACOS: + if isinstance(salvo['personalidade'].get(k), (int, float)): + self.personalidade[k] = max(0.0, min(1.0, float(salvo['personalidade'][k]))) + self.saudou_dia = str(salvo.get('saudou_dia', '')) if isinstance(salvo.get('emojis'), dict): self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']}) except (OSError, ValueError, TypeError, AttributeError): @@ -222,7 +254,8 @@ class Vida: def salvar(self): with self.lock: dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento, - diario=self.diario, emojis=self.emojis) + diario=self.diario, emojis=self.emojis, personalidade=self.personalidade, + saudou_dia=self.saudou_dia) self.salvo_em = time.time() try: gravar_atomico(self.caminho, dados) @@ -274,7 +307,9 @@ class Vida: nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400), emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None, zanga=self.zanga(), sono_profundo=self.sono_profundo, - pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30]) + pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30], + personalidade=dict(self.personalidade), porque=self.porque, ultima=self.ultima, + proximo_ato_em=max(0, round(self.proximo_ato - agora))) def avisar(self): self.publicado_em = time.time() @@ -323,6 +358,10 @@ class Vida: self.interagiu_em = agora # alguém quis ele acordado (sensor não conta) if self.aprendizado: self.aprendizado.resposta(tipo, agora) + self.amadurecer(tipo) + self.console('sentidos', CUIDADOS.get(tipo) or NOME_ESTIMULO.get(tipo, tipo), + ', '.join('%s %+d' % (k, v) for k, v in efeito.items()), + 'detalhe' if tipo in ('som', 'movimento') else 'info', agora) if gesto and not self.bloqueado(): if self.dormindo(): self.acordar(agora, None) @@ -390,12 +429,108 @@ class Vida: self.anotar('atividade', 'Foi fazer %s %s%s' % (item['emoji'], item['nome'], motivo), agora) return 'atividade ' + nome nome = self.rng.choice(CALMAS) # apagaram a criação no meio do caminho + if nome != 'dormir' and segundos > 60 and self.acaso.random() < DESISTE * (1.3 - self.personalidade['curiosidade']): + segundos = segundos * self.acaso.uniform(0.3, 0.5) # vai largar no meio: ficou chato + self.desistencia = (agora + segundos, NOME_ATIVIDADE.get(nome, 'a atividade')) self.enfileirar('atividade %s %d' % (nome, segundos)) self.atividade_em = agora self.ultima_atividade = nome self.anotar('atividade', NOME_ATIVIDADE.get(nome, 'Foi fazer algo'), agora) return 'atividade ' + nome + def dizer(self, texto, gesto=None): + """Uma fala curta: o gesto e a frase em legenda, com a voz de robô.""" + if gesto: + self.enfileirar(gesto) + self.enfileirar('dizer ' + texto) + + def resumo(self): + n = self.necessidades + return 'energia %d · alegria %d · social %d · diversão %d · %s' % ( + n['energia'], n['alegria'], n['social'], n['diversao'], HUMORES[self.humor()][1].lower()) + + @staticmethod + def tempo(seg): + return '%d min' % round(seg / 60) if seg < 3600 else '%dh%02d' % (seg // 3600, seg % 3600 // 60) + + def saudacao(self, agora): + """Bom dia com uma lembrança de ontem (do que ele viveu, sem IA).""" + hora = time.localtime(agora).tm_hour + prefixo = 'Bom dia' if 5 <= hora < 12 else 'Boa tarde' if hora < 18 else 'Boa noite' + fatos = [] + ontem = None + try: + ontem = self.dia_anterior() + except Exception: + pass + if isinstance(ontem, dict): + pres = ontem.get('presenca') or [] + tarde = [hh for hh in range(0, 5) if hh < len(pres) and pres[hh] is not None and pres[hh] >= 0.3] + if tarde: + fatos.append('Ontem a gente ficou acordado até as %dh, hein!' % (max(tarde) + 1)) + elif len(pres) == 24 and pres[23] is not None and pres[23] >= 0.3: + fatos.append('Ontem a gente ficou até tarde, hein!') + if (ontem.get('respostas') or 0) >= 5: + fatos.append('Ontem foi um dia cheio de carinho. Obrigado!') + elif (ontem.get('atos') or 0) >= 5 and not ontem.get('respostas'): + fatos.append('Ontem ninguém brincou comigo... hoje vai ser diferente?') + if not fatos: + fatos.append('Acordei cheio de energia!' if self.necessidades['energia'] >= 80 else 'Que bom te ver!') + return '%s! %s' % (prefixo, self.acaso.choice(fatos)) + + def reencontro(self, agora, ausencia): + """Alguém apareceu: a reação depende de quanto tempo ficou fora (e de quem ele é).""" + if self.dormindo() or self.madrugada(agora) or self.bloqueado(): + return None + n, tim = self.necessidades, self.personalidade['timidez'] + dia = time.strftime('%Y-%m-%d', time.localtime(agora)) + motivo = 'ninguém por perto havia ' + self.tempo(ausencia) + if self.saudou_dia != dia and time.localtime(agora).tm_hour >= 5 and ausencia >= AUSENCIA_ACENO: + self.saudou_dia = dia + frase = self.saudacao(agora) + self.dizer(frase, 'timido' if tim > 0.65 else 'acenar') + ganho, texto, motivo = 10, 'Deu bom dia: "%s"' % frase, 'primeiro encontro do dia; ' + motivo + elif ausencia >= AUSENCIA_SAUDADE: + self.dizer('Senti sua falta!', 'comemorar') + self.enfileirar('coracao') + ganho, texto = 20, 'Fez festa: estava com saudade' + n['alegria'] = _limitar(n['alegria'] + 10) + elif ausencia >= AUSENCIA_OI: + self.dizer('Oi! Você voltou!', 'timido' if tim > 0.65 else 'comemorar') + ganho, texto = 10, 'Comemorou que alguém voltou' + elif ausencia >= AUSENCIA_ACENO: + self.enfileirar('acenar') + ganho, texto = 4, 'Acenou para quem voltou' + else: + return None + with self.lock: + n['social'] = _limitar(n['social'] + ganho) + self.anotar('social', texto, agora) + self.console('vida', texto, motivo, agora=agora) + self.avisar() + return texto + + def pedir_algo(self, agora): + """Iniciativa com motivo: pede o que está faltando (ou mostra o que fez).""" + n = self.necessidades + if n['social'] < 35: + frase, gesto, porque = 'Tô carente... me faz um carinho?', 'coracao', 'social %d' % n['social'] + elif n['diversao'] < 40: + frase, gesto, porque = 'Brinca comigo?', 'acenar', 'diversão %d' % n['diversao'] + elif (float(self.emojis.get('ultimo') or 0) > self.figurinha_mostrada and agora - float(self.emojis['ultimo']) < 3 * 3600 + and self.tocar_reacao(agora, anotar=False)): + self.figurinha_mostrada = float(self.emojis['ultimo']) + frase, gesto, porque = 'Olha o que eu desenhei!', None, 'criou uma figurinha e ainda não tinha mostrado' + elif n['energia'] < 35: + frase, gesto, porque = 'Tô com soninho...', 'bocejar', 'energia %d' % n['energia'] + else: + return None + self.pediu_em = agora + self.dizer(frase, gesto) + self.anotar('pedido', 'Pediu: "%s"' % frase, agora) + self.porque = 'alguém por perto e ' + porque + return 'pedido' + def pir_acorda(self, agora=None): """Disparo do PIR com o rosto dormindo: acorda? No ritmo calmo precisa de vários em 2 min.""" agora = agora or time.time() @@ -423,8 +558,27 @@ class Vida: """Quanto o aprendizado multiplica a chance de um ato (1 = sem opinião).""" return self.aprendizado.peso(nome) if self.aprendizado else 1.0 + def jeito(self, gesto): + """Quanto a personalidade puxa um gesto (1 = neutro): brincalhão ri mais, tímido se esconde mais.""" + f = 1.0 + for traco, gestos in GESTOS_TRACO.items(): + if gesto in gestos: + f *= 1 + (self.personalidade[traco] - 0.5) * 1.2 + return f + + def amadurecer(self, tipo): + """O convívio muda o jeito devagar: carinho deixa menos tímido, brincar deixa mais brincalhão.""" + p = self.personalidade + with self.lock: + if tipo in ('carinho', 'conversa', 'brincar'): + p['timidez'] = round(max(0.1, p['timidez'] - 0.003), 3) + if tipo == 'brincar': + p['brincadeira'] = round(min(0.95, p['brincadeira'] + 0.002), 3) + if tipo == 'conversa': + p['curiosidade'] = round(min(0.95, p['curiosidade'] + 0.001), 3) + def escolher(self, opcoes): - opcoes = [(g, p * self.gosto(g), t) for g, p, t in opcoes] + opcoes = [(g, p * self.gosto(g) * self.jeito(g), t) for g, p, t in opcoes] total = sum(p for _, p, _ in opcoes) x = self.rng.uniform(0, total) for gesto, peso, texto in opcoes: @@ -467,42 +621,48 @@ class Vida: self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora) return True - def tocar_reacao(self, agora): + def tocar_reacao(self, agora, anotar=True): try: reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0] except Exception: return False if not reacoes: return False - r = self.rng.choice(reacoes[:40]) + r = self.rng.choice(reacoes[:40]) if anotar else reacoes[0] # para mostrar: a mais nova try: self.treinamento.testar(r['id']) except ValueError: return False - self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora) + if anotar: + self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora) return True def agir(self, agora): """Um ato: o que um bichinho com essas necessidades faria agora.""" n = self.necessidades perto = self.alguem_perto() + self.porque = '' if self.dormindo(): # de madrugada nunca acorda sozinho (nem com gente se mexendo na cama); de dia, só descansado if n['energia'] >= DESCANSADO and not self.madrugada(agora): self.acordar(agora, 'Acordou descansado') + self.porque = 'energia %d: descansou' % n['energia'] return 'acordar' # aprendeu a rotina: acorda do cochilo um pouco antes da hora em que costuma ter gente if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50 and self.aprendizado.chega_logo(agora)): self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém') - self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.aprendizado.porque_chegada(agora), agora) + self.porque = self.aprendizado.porque_chegada(agora) + self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.porque, agora) return 'acordar' return None if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO: self.dormir(agora, 'Ficou tarde, foi dormir') + self.porque = 'madrugada e ninguém interagiu nos últimos 10 min' return 'dormir' if n['energia'] < SONO: self.dormir(agora, 'Ficou exausto e foi dormir', profundo=True) + self.porque = 'energia %d: abaixo de %d, sono pesado até descansar' % (n['energia'], SONO) return 'dormir' humor = self.humor() if self.celular_atividade(): @@ -511,9 +671,11 @@ class Vida: folga = agora - self.atividade_em > 90 # um respiro entre uma atividade e outra # cansado: um cochilo na caminha de vez em quando (recupera energia sem o sono pesado) if dia and folga and n['energia'] < COCHILO and self.rng.random() < 0.35: + self.porque = 'energia %d: abaixo de %d, um cochilo' % (n['energia'], COCHILO) return self.comecar_atividade(agora, 'dormir', self.rng.uniform(120, 240)) # entediado: vai fazer alguma coisa por alguns minutos (pode ser interrompido) if dia and folga and n['diversao'] < TEDIO and self.rng.random() < 0.75: + self.porque = 'diversão %d: entediado (abaixo de %d)' % (n['diversao'], TEDIO) return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(120, 300)) # ninguém olhando há um tempo: vai cuidar da vida dele (ler, pintar, música...) if perto: @@ -523,8 +685,9 @@ class Vida: if (dia and folga and sozinho and self.aprendizado and n['energia'] < 85 and self.aprendizado.casa_vazia(agora) and self.rng.random() < 0.3): segundos = self.rng.uniform(600, 1200) + self.porque = self.aprendizado.porque_vazia(agora) self.aprendizado.decidir('cochilo', 'Tirou um cochilo de %d min para guardar energia' % round(segundos / 60), - self.aprendizado.porque_vazia(agora), agora) + self.porque, agora) return self.comecar_atividade(agora, 'dormir', segundos) # alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!" if dia and perto and humor != 'bravo': @@ -533,26 +696,41 @@ class Vida: self.contar_aprendi(item['id'], agora) with self.lock: n['social'] = _limitar(n['social'] + 5) + self.porque = 'alguém por perto e aprendeu "%s" hoje' % item.get('nome', '?') return 'aprendi' + # iniciativa: com gente por perto, pede o que está faltando (tímido pede menos) + if (dia and perto and not sozinho and agora - self.pediu_em > INTERVALO_PEDIDO + and self.acaso.random() < 0.25 * (1.3 - self.personalidade['timidez'])): + pedido = self.pedir_algo(agora) + if pedido: + return pedido if dia and folga and sozinho and self.rng.random() < 0.4: + self.porque = 'ninguém por perto há %s: vai cuidar da vida dele' % self.tempo(agora - self.visto_em) return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(60, 180)) # bem: de vez em quando, uma atividade curtinha por vontade própria if dia and folga and humor in ('feliz', 'tranquilo') and self.rng.random() < 0.1: + self.porque = 'de bem (%s): vontade própria' % humor return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(40, 90)) # habilidade: inventar uma figurinha nova (raro, com limite por dia e intervalo) if not self.motivo_sem_emoji(agora) and self.celular_online() and self.rng.random() < 0.08: if self.criar_figurinha(agora, humor): + self.porque = 'teve uma ideia (até %d figurinhas por dia)' % self.config['max_emojis_dia'] return 'figurinha' # às vezes lembra de uma reação da biblioteca if self.treinamento is not None and humor in ('feliz', 'tranquilo', 'entediado') and self.rng.random() < 0.15: if self.tocar_reacao(agora): with self.lock: n['diversao'] = _limitar(n['diversao'] + 8) + self.porque = 'humor %s: lembrou de uma reação da biblioteca' % humor return 'reacao' lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo']) if sozinho: # sem plateia: nada de acenar ou ficar tímido lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3] gesto, texto = self.escolher(lista) + self.porque = 'humor %s%s%s' % ( + humor, ' com gente por perto' if perto else ', sozinho' if sozinho else '', + '; gosto %.1f×, jeito %.1f×' % (self.gosto(gesto), self.jeito(gesto)) + if (self.gosto(gesto), round(self.jeito(gesto), 1)) != (1.0, 1.0) else '') if self.aprendizado and perto: self.aprendizado.escolheu(gesto, agora) self.enfileirar(gesto) @@ -588,6 +766,21 @@ class Vida: self.sono_profundo = False self.enfileirar('sono leve') self.anotar('sono', 'Acordou antes de descansar', agora) + perto = self.alguem_perto() + if perto: + if not self.perto_antes: + self.reencontro(agora, agora - self.ultimo_perto) + self.ultimo_perto = agora + self.perto_antes = perto + if self.desistencia and agora >= self.desistencia[0]: + _, oque = self.desistencia + self.desistencia = None + if not self.dormindo(): + self.enfileirar('bocejar') + acao = oque[4:] if oque.startswith('Foi ') else 'a atividade' + texto = 'Largou no meio: cansou de ' + acao + self.anotar('acao', texto, agora) + self.console('vida', texto, 'perdeu a graça (curiosidade %.2f)' % self.personalidade['curiosidade'], agora=agora) if not self.bloqueado(): # o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado) zanga = self.zanga() @@ -600,8 +793,14 @@ class Vida: self.humor_enviado = (humor, agora) self.zanga_enviada = zanga if agora >= self.proximo_ato: + antes = self.diario[0]['horario'] if self.diario else None ato = self.agir(agora) self.aprender_ato(ato, agora) + if ato: + novo = self.diario[0] if self.diario and self.diario[0]['horario'] != antes else None + self.ultima = dict(horario=agora, texto=novo['texto'] if novo else ato, porque=self.porque) + self.console('vida', novo['texto'] if novo else ato, + (self.porque + ' · ' if self.porque else '') + self.resumo(), agora=agora) lo, hi = RITMOS[self.config['ritmo']] self.proximo_ato = agora + self.rng.uniform(lo, hi) if ato or agora - self.publicado_em > 30 or humor != self.humor(): diff --git a/web/src/App.svelte b/web/src/App.svelte index 47681de..d53f1d5 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,5 +1,5 @@ + +
+
+
+ +
+ {v?.nome_humor ?? 'Carregando'} + {fazendo} +
+
+
+ {#each NEC as n (n.id)} + {@const valor = v?.necessidades[n.id] ?? 0} +
+ {n.nome} + + {valor} +
+ {/each} +
+
+
Próxima decisão
{!v?.config.ligado ? 'modo vida desligado' : proximo === null ? '—' : `em ${proximo} s`}
+
Última decisão
{#if v?.ultima}{v.ultima.texto}{hora(v.ultima.horario)}{v.ultima.porque ? ' · ' + v.ultima.porque : ''}{:else}—{/if}
+
Presença
{estado.pir?.ativo ? 'sensor vendo alguém' : estado.pir?.presenca?.estado === 'ausente' ? 'ninguém por perto' : 'ninguém agora'}
+
Madrugada
{noite}
+ {#if v?.personalidade} +
Jeito
{Object.entries(v.personalidade).map(([k, x]) => `${TRACO[k] ?? k} ${Math.round(x * 100)}%`).join(' · ')}
+ {/if} +
+
+ +
+
+
+ {#each ORIGENS as o (o.id)} + + {/each} +
+
+ + + + +
+
+
    + {#each visiveis as l (l.id)} +
  1. + + {ORIGENS.find((o) => o.id === l.origem)?.nome ?? l.origem} + {l.texto}{#if l.motivo}porque {l.motivo}{/if} +
  2. + {:else} +
  3. Nada por aqui ainda. Cada decisão dele aparece aqui na hora, com o motivo.
  4. + {/each} +
+
+
+ +